Skip to content

Proposal G, PR1: zero-knowledge SavedDeck schema + deck cap setting - #85

Merged
WilfordGrimley merged 5 commits into
masterfrom
claude/proposal-g-schema-backend
Jul 18, 2026
Merged

Proposal G, PR1: zero-knowledge SavedDeck schema + deck cap setting#85
WilfordGrimley merged 5 commits into
masterfrom
claude/proposal-g-schema-backend

Conversation

@WilfordGrimley

@WilfordGrimley WilfordGrimley commented Jul 18, 2026

Copy link
Copy Markdown

Build per docs/proposals/proposal-g-user-accounts-saved-decks.md. First of 4 sequenced PRs (schema+backend → Discord OAuth UX [#86] → saved-decks API → frontend).

Revised mid-review: the owner directed a zero-knowledge encryption design (§8, added to the spec in this same PR) partway through review — the server must be cryptographically unable to read saved deck contents. This PR's schema was revised in place (still unmerged, so no follow-up migration needed) to match.

Description

  1. §8 zero-knowledge amendment added to the spec doc: client-side WebCrypto only (PBKDF2-SHA256 ≥600k iterations + per-user salt → AES-256-GCM master key; per-deck DEKs wrapped by it; entire payload including the title is ciphertext server-side), a user-held recovery key as the ZK-preserving recovery path, a Discord-gated data-destroying account reset as the last resort, and an explicit rejection of any admin/Discord-derived decryption path. Full design, consequences, and required tests are in §8.
  2. SavedDeck (revised): no more name/state/is_public — now ciphertext + ciphertext_nonce + wrapped_dek + wrapped_dek_nonce, opaque to the backend by design. The old UniqueConstraint on (owner, name) is gone — it can no longer be enforced server-side once titles are encrypted (this becomes a client-side-only check, a real documented behavior change from the original decision 4/7).
  3. UserCryptoProfile (new): one per user, created at first save — PBKDF2 salt + iteration count (public-safe, not secret) plus two independently-wrapped copies of the same master key (passphrase slot, recovery slot). Both models CASCADE-delete with the owner.
  4. SAVED_DECK_MAX_PER_USER setting (default 100): unaffected by the ZK revision — still a plain row-count abuse guard on kind=deck rows, same idiom as MODERATORS_GROUP_NAME/CARD_REPORT_RATE.
  5. Admin registration for both models (no content columns shown for SavedDeck — ciphertext isn't inspectable).

Incidental fix (unchanged from the original PR): found and fixed a pre-existing bug in admin.pyAdminTagAliasSuggestion's actions referenced two methods that were indented under an unrelated ModelAdmin class further down the file. Moved them back to the class that actually references them.

Checklist

  • I have installed pre-commit and installed the hooks with pre-commit install before creating any commits.
  • I have updated any related tests for code I modified or added new tests where appropriate.
  • I have manually tested my changes as follows:
    • Installed Django + enough of requirements.txt locally against a real local Postgres instance (not present in this sandbox by default).
    • python manage.py makemigrations --check --dry-run cardpicker confirms migration 0065 exactly matches the model state — no drift, both before and after the ZK revision.
    • python manage.py migrate applies cleanly end to end.
    • Manual shell smoke tests confirm: SavedDeck genuinely has no name/state/is_public attributes; two decks for the same owner never collide regardless of content; UserCryptoProfile's two wrapped-key slots are independently stored; one profile per owner is enforced; owner.delete() cascades to both SavedDeck and UserCryptoProfile.
    • ruff check, isort --check-only, black==22.8.0 --check, mypy --config-file mypy.ini MPCAutofill/ all clean.
    • test_saved_deck_model.py rewritten for the new schema, but still could not execute it via pytest in this sandbox — conftest.py's ES/Postgres fixtures are testcontainers-based (real Docker required) and one is session-autouse. CI has real Docker and ran the original version of this file successfully on the first push; the rewritten version is written to run the same way there.
  • I have updated any relevant documentation or created new documentation where appropriate. (the §8 amendment IS the documentation for this revision, added in the same commit)

Merge-time checklist


Generated by Claude Code

Adds SavedDeckKind/SavedDeck (docs/proposals/proposal-g-user-accounts-saved-decks.md
§3) as a fresh model rather than resurrecting the dead Project/ProjectMember
pair - see that section's "note on prior art" for why. One additive
migration (0065), admin registration, and SAVED_DECK_MAX_PER_USER (env-driven,
default 100, same idiom as MODERATORS_GROUP_NAME/CARD_REPORT_RATE) as the
abuse-guard cap decision 4 calls for. The 5-per-user snapshot FIFO ring
(decision 7) is deliberately NOT a setting - it's a fixed implementation
safety valve per the spec, enforced in the view layer landing in the next PR.

Scope note: this PR is schema-only. §7's authed-vote-tier machinery
(AUTHED_VOTE_GATE_MODE, authed_vote_weight(), AbstractWeightedVote.account_tier)
is explicitly scoped by the spec itself as "a separate, later build ... own
migration and consensus-math change, own PR, own review - not bundled with
this HOLD's core scope" and "does not block Proposal G building at its
queued slot" - so it isn't included here, read as the spec's own deferral
rather than an omission.

Incidentally fixed a pre-existing bug found while editing admin.py:
AdminTagAliasSuggestion declares actions=["accept_suggestions",
"reject_suggestions"] but those two methods were indented under a
different, unrelated ModelAdmin class further down the file (AdminCardScanLog),
not under AdminTagAliasSuggestion itself - moved them back to the class that
actually references them.

Verified against a real local Postgres instance (this sandbox has no
Django/DB by default - installed it plus enough of requirements.txt to
import cardpicker.models cleanly): `makemigrations --check --dry-run`
confirms 0065 exactly matches the model state (no drift), a full `migrate`
applies cleanly end to end, and a manual smoke test confirms the
conditional unique constraint (blocks duplicate deck names per owner,
allows duplicate snapshot names, allows a snapshot to share a deck's name)
and CASCADE delete-on-owner-removal all behave exactly as specified.
`ruff`/`isort`/`black==22.8.0`/`mypy --config-file mypy.ini` all clean.

Added test_saved_deck_model.py + a SavedDeckFactory, but could not execute
them via pytest in this sandbox: cardpicker/tests/conftest.py's ES/Postgres
fixtures are testcontainers-based (real Docker required) and one is
session-autouse, so no test in this suite can run without Docker - the
same nested-Docker limitation already documented in docs/troubleshooting.md.
The tests are written to run in CI/local dev where Docker is available;
not a substitute for that real pytest run, flagging the gap explicitly.
claude added 2 commits July 18, 2026 20:26
Adds §8 - the server must be cryptographically unable to read saved deck
contents. Client-side WebCrypto only: PBKDF2-SHA256 >=600k iterations +
per-user salt derives a master key; per-deck AES-256-GCM DEKs wrapped by
it; the entire deck payload including the title is ciphertext
server-side. A user-held recovery key (generated at passphrase creation,
wraps the same master key) is the ZK-preserving recovery path; a
Discord-gated, data-destroying account reset is the last resort if both
are lost. No admin-side or Discord-derived decryption/escrow path exists
or will be added - recorded as explicitly rejected.

Supersedes §2/§3's originally-specified plaintext JSONField storage and
its server-side name-uniqueness constraint (no longer enforceable once
titles are encrypted - becomes a client-side-only check, a real behavior
change documented in the new Consequences section). §2/§3's original
text is left as historical record per this doc's existing convention of
appending amendments rather than rewriting approved spec text (see
proposal-b.md for the same pattern) - decision 10 and forward-pointer
notes mark what's superseded.

Adds decision 10, updates the header/status line, and the legal
data-inventory paragraph for the owner's PIPEDA review.

Next: revise PR1 (#85, still open/unmerged) to match this schema before
it merges, then PR3 (opaque-blob API) and PR4 (crypto module + UX).
…intext name)

Per §8's ZK amendment: SavedDeck no longer has name/state/is_public.
It now stores ciphertext + ciphertext_nonce + wrapped_dek + wrapped_dek_nonce
- the entire frontend Project shape, including the deck's own title, lives
inside client-encrypted ciphertext the backend never inspects. The
saveddeck_owner_name_unique_for_decks constraint is gone - it can't be
enforced server-side once titles are encrypted (name-uniqueness becomes a
client-side-only check, per §8's Consequences section).

New UserCryptoProfile model (one per user, created at first save): PBKDF2
salt + iteration count (public-safe, not secret) plus two independently-
wrapped copies of the same master key - one wrapped by the passphrase-
derived key, one by the user-held recovery key. Both CASCADE-delete with
the owner, same as SavedDeck.

Regenerated migration 0065 (renamed 0065_usercryptoprofile_saveddeck.py)
rather than adding a second one, since this branch/PR is still unmerged -
verified via makemigrations --check --dry-run (no drift) and a full
migrate against real Postgres.

Updated SavedDeckFactory (now produces random opaque bytes instead of a
plaintext name/state, exactly as valid here as a real client-produced
blob since nothing server-side inspects it) and added
UserCryptoProfileFactory. Rewrote test_saved_deck_model.py: confirms the
name/state/is_public fields are genuinely gone, confirms two decks for
the same owner never collide regardless of content, confirms the two
wrapped-key slots are independently stored, confirms one-profile-per-
owner and cascade delete for both models.

Verified: ruff/isort/black==22.8.0/mypy all clean. Manual Django-shell
smoke test against real Postgres covers every assertion in the rewritten
test file (same testcontainers-Docker limitation as before prevents
running pytest directly in this sandbox - CI has real Docker and will
run it for real, as it already did for PR1's original test file).
@WilfordGrimley WilfordGrimley changed the title Proposal G, PR1: SavedDeck model + migration + deck cap setting Proposal G, PR1: zero-knowledge SavedDeck schema + deck cap setting Jul 18, 2026
Caught while designing PR4a's crypto module: the "Key design" bullet said
"a passphrase change re-wraps every deck's DEK" (echoing the owner's
original terse phrasing), but the "Recovery key" section - written from
the addendum's own explicit clarification ("a recovery key generated
before a later passphrase change still works... it wraps the master key,
which never changed") - only makes sense if the master key is a separate,
randomly-generated value that's wrapped (not derived) by the passphrase
key, and never regenerated. Under that model, DEKs (wrapped by the never-
changing master key) never need re-wrapping on a passphrase change either
- only the ONE master key (stored on UserCryptoProfile) does. The two
sections contradicted each other; fixed "Key design" and "Tests required"
to state the single, self-consistent model, and fixed a same-root error
in the legal data-inventory paragraph ("two wrapped copies of that deck's
encryption key" -> two wrapped copies of the user's one master key).

No schema/code impact - UserCryptoProfile (already merged into this PR)
only ever stored one master key's two wrapped copies per user, never
anything per-deck, so this was purely a documentation self-consistency
bug, not a shipped design flaw. Fixing it now, before PR4a's crypto
module gets built against the wrong mental model.
Expands the ZK amendment's "deck sharing" future-work bullet into a full
design per the owner's addendum: key-in-URL-fragment share creation,
unauthenticated recipient decrypt flow, revocation with an optional
DEK-rotation-on-revoke option, and the tests required when PR-5 is
eventually built. Nothing built in this commit — confirms the new
SavedDeckShare table is additive to PR-1's schema and doesn't require
changing SavedDeck/UserCryptoProfile.
@WilfordGrimley
WilfordGrimley merged commit ad0ca75 into master Jul 18, 2026
2 of 3 checks passed
@WilfordGrimley
WilfordGrimley deleted the claude/proposal-g-schema-backend branch July 18, 2026 22:24
WilfordGrimley added a commit that referenced this pull request Jul 18, 2026
* Proposal G, PR1: SavedDeck model, migration, per-user deck cap setting

Adds SavedDeckKind/SavedDeck (docs/proposals/proposal-g-user-accounts-saved-decks.md
§3) as a fresh model rather than resurrecting the dead Project/ProjectMember
pair - see that section's "note on prior art" for why. One additive
migration (0065), admin registration, and SAVED_DECK_MAX_PER_USER (env-driven,
default 100, same idiom as MODERATORS_GROUP_NAME/CARD_REPORT_RATE) as the
abuse-guard cap decision 4 calls for. The 5-per-user snapshot FIFO ring
(decision 7) is deliberately NOT a setting - it's a fixed implementation
safety valve per the spec, enforced in the view layer landing in the next PR.

Scope note: this PR is schema-only. §7's authed-vote-tier machinery
(AUTHED_VOTE_GATE_MODE, authed_vote_weight(), AbstractWeightedVote.account_tier)
is explicitly scoped by the spec itself as "a separate, later build ... own
migration and consensus-math change, own PR, own review - not bundled with
this HOLD's core scope" and "does not block Proposal G building at its
queued slot" - so it isn't included here, read as the spec's own deferral
rather than an omission.

Incidentally fixed a pre-existing bug found while editing admin.py:
AdminTagAliasSuggestion declares actions=["accept_suggestions",
"reject_suggestions"] but those two methods were indented under a
different, unrelated ModelAdmin class further down the file (AdminCardScanLog),
not under AdminTagAliasSuggestion itself - moved them back to the class that
actually references them.

Verified against a real local Postgres instance (this sandbox has no
Django/DB by default - installed it plus enough of requirements.txt to
import cardpicker.models cleanly): `makemigrations --check --dry-run`
confirms 0065 exactly matches the model state (no drift), a full `migrate`
applies cleanly end to end, and a manual smoke test confirms the
conditional unique constraint (blocks duplicate deck names per owner,
allows duplicate snapshot names, allows a snapshot to share a deck's name)
and CASCADE delete-on-owner-removal all behave exactly as specified.
`ruff`/`isort`/`black==22.8.0`/`mypy --config-file mypy.ini` all clean.

Added test_saved_deck_model.py + a SavedDeckFactory, but could not execute
them via pytest in this sandbox: cardpicker/tests/conftest.py's ES/Postgres
fixtures are testcontainers-based (real Docker required) and one is
session-autouse, so no test in this suite can run without Docker - the
same nested-Docker limitation already documented in docs/troubleshooting.md.
The tests are written to run in CI/local dev where Docker is available;
not a substitute for that real pytest run, flagging the gap explicitly.

* Proposal G: zero-knowledge encryption amendment (owner-directed)

Adds §8 - the server must be cryptographically unable to read saved deck
contents. Client-side WebCrypto only: PBKDF2-SHA256 >=600k iterations +
per-user salt derives a master key; per-deck AES-256-GCM DEKs wrapped by
it; the entire deck payload including the title is ciphertext
server-side. A user-held recovery key (generated at passphrase creation,
wraps the same master key) is the ZK-preserving recovery path; a
Discord-gated, data-destroying account reset is the last resort if both
are lost. No admin-side or Discord-derived decryption/escrow path exists
or will be added - recorded as explicitly rejected.

Supersedes §2/§3's originally-specified plaintext JSONField storage and
its server-side name-uniqueness constraint (no longer enforceable once
titles are encrypted - becomes a client-side-only check, a real behavior
change documented in the new Consequences section). §2/§3's original
text is left as historical record per this doc's existing convention of
appending amendments rather than rewriting approved spec text (see
proposal-b.md for the same pattern) - decision 10 and forward-pointer
notes mark what's superseded.

Adds decision 10, updates the header/status line, and the legal
data-inventory paragraph for the owner's PIPEDA review.

Next: revise PR1 (#85, still open/unmerged) to match this schema before
it merges, then PR3 (opaque-blob API) and PR4 (crypto module + UX).

* Proposal G, PR1 revision: zero-knowledge schema (opaque blobs, no plaintext name)

Per §8's ZK amendment: SavedDeck no longer has name/state/is_public.
It now stores ciphertext + ciphertext_nonce + wrapped_dek + wrapped_dek_nonce
- the entire frontend Project shape, including the deck's own title, lives
inside client-encrypted ciphertext the backend never inspects. The
saveddeck_owner_name_unique_for_decks constraint is gone - it can't be
enforced server-side once titles are encrypted (name-uniqueness becomes a
client-side-only check, per §8's Consequences section).

New UserCryptoProfile model (one per user, created at first save): PBKDF2
salt + iteration count (public-safe, not secret) plus two independently-
wrapped copies of the same master key - one wrapped by the passphrase-
derived key, one by the user-held recovery key. Both CASCADE-delete with
the owner, same as SavedDeck.

Regenerated migration 0065 (renamed 0065_usercryptoprofile_saveddeck.py)
rather than adding a second one, since this branch/PR is still unmerged -
verified via makemigrations --check --dry-run (no drift) and a full
migrate against real Postgres.

Updated SavedDeckFactory (now produces random opaque bytes instead of a
plaintext name/state, exactly as valid here as a real client-produced
blob since nothing server-side inspects it) and added
UserCryptoProfileFactory. Rewrote test_saved_deck_model.py: confirms the
name/state/is_public fields are genuinely gone, confirms two decks for
the same owner never collide regardless of content, confirms the two
wrapped-key slots are independently stored, confirms one-profile-per-
owner and cascade delete for both models.

Verified: ruff/isort/black==22.8.0/mypy all clean. Manual Django-shell
smoke test against real Postgres covers every assertion in the rewritten
test file (same testcontainers-Docker limitation as before prevents
running pytest directly in this sandbox - CI has real Docker and will
run it for real, as it already did for PR1's original test file).

* Proposal G, PR3: opaque-blob saved-decks API

Per §3/§8: require_authenticated decorator (cardpicker/security.py) plus
7 endpoints - 2/savedDecks/ (list), 2/saveDeck/ (upsert), 2/loadDeck/,
2/deleteDeck/, 2/cryptoProfile/ (GET), 2/saveCryptoProfile/ (upsert),
2/resetSavedDecks/ (the destructive last-resort reset). Every
ciphertext/nonce/wrapped-key field is base64 in transit, opaque to the
backend - stored and returned faithfully, never decrypted or inspected.

- saveDeck upserts by key (null creates, existing key updates in place if
  owned else 403); kind defaults to "deck"; a "snapshot" create skips the
  SAVED_DECK_MAX_PER_USER cap entirely and prunes the owner's snapshot
  rows to the newest SAVED_DECK_SNAPSHOT_RING_SIZE (5, a fixed constant
  per decision 7, not a setting) afterwards.
- No server-side name-uniqueness check - can't exist once titles are
  encrypted (§8's Consequences). The old spec's renameDeck endpoint is
  gone too: renaming is now just a normal saveDeck update-by-key call,
  since there's no server-visible name to rename.
- get_saved_decks returns full per-deck ciphertext, not just metadata -
  the deck's title lives inside it, so the client must decrypt each row
  to render "My Decks"; there's no lighter-weight name to return instead.
  Documented as a real, closed-eyes tradeoff, not solved differently,
  since §8 enumerates the stored fields as an exhaustive list ("nothing
  else").
- saveCryptoProfile is an upsert covering both first-save creation and a
  passphrase change (which only ever replaces this one row via
  update_or_create - deck ciphertext/wrapped-DEKs are never touched).
  kdfIterations is checked against SAVED_DECK_MIN_KDF_ITERATIONS (default
  600,000, matching §8's own floor) as a defensive floor against a
  buggy/malicious client persisting a weak key derivation.
- resetSavedDecks requires an explicit confirm:true and deletes every
  SavedDeck + the crypto profile for the requesting user - the "lost
  both keys" last resort from §8's Account reset design. No admin-side
  or Discord-derived decryption path exists anywhere in this stack, by
  design (§8's "Explicitly rejected").

New JSON schemas under schemas/schemas/endpoints/ for all 7
request/response shapes, regenerated schema_types.py/schema_types.ts via
quicktype. Caught and fixed a real quicktype naming collision: adding
schemas with a "kind" enum property caused quicktype's disambiguation to
rename the pre-existing generic `Kind` type (used only by VoteQueueRequest
before now) to `VoteQueueRequestKind` - fixed the two import sites
(views.py, store/api.ts) that referenced the old name. Also had to
isort+black the raw quicktype Python output and prettier the raw
TypeScript output - the committed files are always post-processed, not
raw generator output, and diffing against the unprocessed version
produces a huge spurious diff.

Verified against real Postgres via Django's test client (not pytest -
same testcontainers/Docker limitation as before): a 17-assertion smoke
script covering every endpoint - anonymous rejection, crypto-profile
creation with the iteration floor enforced, deck create/update-in-place,
list scoping, ownership 403s on load/save/delete across two different
owners, cap enforcement with the friendly message, snapshot-ring pruning
to exactly 5 after creating 8, delete, and the destructive reset flow's
confirm requirement and full cleanup - all passed. Added
test_saved_deck_views.py (pytest, mirrors test_moderation_views.py's
ownership/403 pattern) covering the same ground for real CI to run,
which has actual Docker (as it already did for PR1's model tests).
ruff/isort/black==22.8.0/mypy clean on the backend; tsc/eslint/prettier
clean and the full 304-test jest suite passes on the frontend.

* CI: fix isort drift (local isort 8.0.1 vs pinned 5.12.0 collapsed an import differently)

Ran the correctly-pinned isort==5.12.0 (matching .pre-commit-config.yaml)
locally after CI caught a real drift my newer local isort didn't - it
collapses a 3-line wrapped import into one line where newer isort leaves
it wrapped. No semantic change, purely the import statement's formatting.

---------

Co-authored-by: Claude <noreply@anthropic.com>
WilfordGrimley pushed a commit that referenced this pull request Jul 18, 2026
Task-end wiki/docs check (CLAUDE.md): this changed what a USER sees (My
Decks page, editor Save/breadcrumb, navbar sign-in) - all 5 sequenced
PRs (#85, #86, #94, #89, #93) are now merged, so there's real behavior
to document. Covers the zero-knowledge crypto mental model, backend
endpoints/constants, frontend file map, the still-design-only PR-5/PR-6
addenda, and the owner-only Discord-credentials/legal-review pointers.
Added to docs/README.md's flat index.

Wiki note (cloud session, per CLAUDE.md convention): the project's
GitHub wiki itself (a separate, generated-view target from docs/) likely
wants a new "Saved Decks" user-facing page once this feature is visible
in production - flagging here rather than editing it directly, since
that's the documented cloud-session convention.
WilfordGrimley added a commit that referenced this pull request Jul 19, 2026
…gns, docs (design/docs only) (#99)

* Proposal G spec: PR-6 design for deck portability (design only)

Formalizes what the zero-knowledge, server-unbound crypto design already
implies: export/import of the complete encrypted bundle (no unlock
required for export - it's the same ciphertext the server already
holds), a versioned public format as the actual portability contract, a
standalone decrypt tool as the trust anchor ("if this site vanishes
tomorrow, your decks are still yours"), honest offline-attackability
limits, and an explicit rejection of any server-bound key material.
Nothing built in this commit - the owner's addendum was explicit that
this lands with a later PR-6.

Also updates the doc's stale header status line (still said "BUILDING...
PR1/PR2 opened" from before any of the 5 sequenced PRs had merged) now
that schema+backend (#85), sign-in relocation (#86), the saved-decks API
(#94, recreated after #88's base-deletion auto-close), the crypto module
(#89), and the frontend UI wiring (#93) have all landed on master - and
adds the portability sentence to the legal data-inventory paragraph, per
the addendum's explicit instruction.

* docs: add docs/features/saved-decks.md now that Proposal G has merged

Task-end wiki/docs check (CLAUDE.md): this changed what a USER sees (My
Decks page, editor Save/breadcrumb, navbar sign-in) - all 5 sequenced
PRs (#85, #86, #94, #89, #93) are now merged, so there's real behavior
to document. Covers the zero-knowledge crypto mental model, backend
endpoints/constants, frontend file map, the still-design-only PR-5/PR-6
addenda, and the owner-only Discord-credentials/legal-review pointers.
Added to docs/README.md's flat index.

Wiki note (cloud session, per CLAUDE.md convention): the project's
GitHub wiki itself (a separate, generated-view target from docs/) likely
wants a new "Saved Decks" user-facing page once this feature is visible
in production - flagging here rather than editing it directly, since
that's the documented cloud-session convention.

* Proposal G spec: PR-7 design for art provenance (design only)

Per-slot provenance (driveId, sourceName, sourceType, optional
contentPhash, indexedBy) in a future deckPayload version (bumps
formatVersion per PR-6's own versioning rule), so an un-indexed slot
renders a direct-from-drive thumbnail with a "not in this catalog"
badge + origin link instead of breaking. States the moderation-bypass
rationale explicitly (user's own private data, client-side fetch, never
served/cached by this server) rather than leaving it implicit. XML 2.0
gains three optional, backwards-compatible attributes for third-party
phash->federation-verdict joins. Hard line: provenance never enters the
federation verdict export, which stays conclusions-only.

Addendum clarifications folded in: importing any XML version with
un-indexed drive IDs still leaves those slots viewable via the same
direct-drive rendering (the badge/link only appear with 2.0+
provenance present); web PDF export of un-indexed slots is explicitly
out of scope for PR-7 (v1 answer is view + print-via-desktop-tool
guidance, not a foregone-conclusion export fallback).

Nothing built - per the owner's explicit instruction, this is spec-only.
Also updates docs/features/saved-decks.md's "not yet built" list and
the proposal doc's Future-work/header pointers to include PR-7 alongside
PR-5/PR-6.

* docs/README.md: fix stale Proposal G status + PR-5/6/7 addenda references

The Plans & proposals status table still said HOLD for Proposal G even
though the core build has fully shipped (only the PR-5/6/7 addenda
remain HOLD) - matches proposal-c's existing PARTIAL precedent for the
same shape (some shipped, some still HOLD). Also fixed the
features/saved-decks.md summary bullet, which still said "PR-5/PR-6"
before PR-7 was added.

* Proposal G spec: PR-6 revision/modifiedAt fields + deck roaming note

Two small fields added to PR-6's encrypted-payload envelope: revision
(int, incremented per save) and modifiedAt (timestamp) - private inside
the payload like everything else, bumping formatVersion per PR-6/PR-7's
shared versioning rule. Purpose: makes an export/import round-trip
self-describing (a bundle can be compared against the server's current
copy without any server-side plaintext comparison) and seeds any future
cross-instance sync with conflict-detection for free.

Also adds a "Deck roaming" future-work paragraph after "Deck sharing":
cross-instance blob sync is ZK-compatible in principle (only ciphertext
would travel, never keys) but is a full protocol in its own right
(discovery, consent, conflict surfacing, deletion propagation) -
explicitly out of scope until federation has real peers to sync
between. Manual export/import (PR-6) is the supported path today; the
new revision/modifiedAt fields exist in part to make that manual path
safe without committing to automatic sync's unsolved questions.

Nothing built - design-only, per the owner's instruction.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants