🤖 The Fable review of the storage posture is back. It does not agree with my framing, and having checked the code it is right on the parts that matter.
Verdict
Keep per-item rows for v1. But the item write path has a correctness bug that becomes a data-loss bug the day a second device or a second member writes, and there are cheap habits worth lifting from the Eleven home server now.
Where my framing was wrong
Eleven is not identifier-free. The package comment in home/main.go ("no email, no username") is stale. home/db.go declares email_anchors, siwa_anchors and oidc_anchors, each an HMAC under its own env-held pepper, and docs/accounts-and-recovery.md (2026-07-16) explicitly reversed "no email ever" after real lockouts. Eleven has the pepper problem four times over, and its "always send a code, unknown addresses too" door is the same oracle suppression as our decoy salt. There is no stronger identity posture to adopt — we are already at Eleven's actual posture, not its stale comment.
Eleven binds no AAD. Its encryptBytes is bare AES-GCM(iv, ct); the envelope carries no user, object or version binding, and its ETag is server-chosen. Our length-prefixed domain-tagged AAD is strictly stronger. Not a trade to make.
Eleven leaks write timing too. blobs(user_id, version, data, updated_at) keeps the last five versions with timestamps. The single blob hides item count and which-item — not when-written.
Its blob works because of two facts we do not have: the payload is a server list under 16 KiB, and the only concurrent writers are one person's devices, under an LWW-per-URL merge the doc admits "can drop a concurrent reorder".
The blob question, decided on its merits
What per-item rows leak today, and which is written down nowhere: exact item count per vault, per-item created_at/updated_at in the replicated file, item size class, and in transport which item id a write touched. Reads leak nothing per item — /api/items returns the whole vault and there is no per-item GET. That property is worth keeping deliberately.
One blob would hide count, per-item timing and which-item. It would not hide vault write timing, nor vault size, because fixed padding is impossible for a password vault: 1PUX carries attachments, and 16 KiB is about thirty items.
It is incompatible with v1 for a reason independent of concurrency. Drop-box writes (seal to vP_j, re-encrypted later by the next unlocked writer) and agent create-only staging are writes by a party that cannot open the vault. They cannot produce the next blob. So "one blob" is really "one blob plus an inbox table" — and the inbox has per-row residue for the most sensitive class of item there is: captured, unreviewed secrets. Plus every edit uploads the whole vault, every poll downloads it, and an iOS AutoFill extension under a memory budget would have to open the whole vault to fill one password.
Notably it thinks my conflict-storm worry was overstated. Records are independent, ids are random, versions are per-item, writes happen a few times a day: union-by-id, max-version-wins, tombstones for deletes converges in a CAS retry loop with no human involvement. The cost of one blob is bandwidth and the inbox, not merging.
Middle grounds it walked: per-member blob (reject — N copies of shared data, revocation multiplies, membership still visible); per-vault shards on hash(item_id) mod K (A1-clean, but resharding and the inbox remain); append-only sealed segments plus periodic snapshot — the honest v2 shape, where segments sealed under K_vault and segments sealed to vP_j are the same table so the inbox stops being a separate residue class. Not before the protocol is proven once.
Ranked changes. The first two are required by shared vaults
1. Compare-and-swap on item writes. store.UpdateItem is WHERE id = ? AND vault_id = ? and writes whatever version the client sends. No precondition — I checked. Two devices editing one item lose an edit silently; two members likewise; and a client can reuse a version number, which undercuts what the AAD binding is for. Fix: add AND version = ? with expected = new minus one, and return 409 with the current version on zero rows affected — Eleven's putBlob contract applied per item. Same on DeleteItem, which has no precondition either. Scopes every conflict to one item.
2. Vault epoch in the AAD, eager rotation in one transaction. AAD becomes (vault_id, epoch, "item", item_id, version). Member removal is then one endpoint: re-seal all items under K_vault', reissue member wraps, delete the row, in a single SQLite transaction with CAS on the epoch. Re-encrypting N rows versus one blob is the same bytes; atomicity is what matters and per-item rows can have it. This also settles a wording conflict I had not noticed: AGENTS.md decision 1 says the key "rotates forward for new and changed items" (lazy) while DESIGN.md says items are re-encrypted on member removal (eager). Lazy needs a per-row key-generation column that leaks "changed since the removal". Eager is better on residue — the text should say eager.
3. Drop CreatedAt/UpdatedAt from Item, order by id. The server never needs them; the client sorts by sealed title. Same for VaultMember.CreatedAt (when sharing began) and LoginChallenge.CreatedAt (redundant with ExpiresAt). Then write item count, write timing and which-item into the compromise matrix as accepted residue. Zero cost.
4. Padding floor. 256-byte buckets over a 150–500 byte body give two or three classes, which separates "login" from "login with notes" from "secure note". Eleven's real property is that the common case is uniform. Get it with max(1024, next 256 multiple): nearly every login becomes exactly 1 KiB + tag and only notes and attachments stand out. Four times the bytes on small items is irrelevant — 5000 items is 5 MiB. Touches pad.go, items.js, IsBucketAligned and the golden vectors.
5. Batch write endpoint. PUT /api/items taking a list. Import needs it (a 1PUX of 2000 items is 2000 requests today), it hides which-of-these in transport, and it is the shape a segment format would keep.
6. Fresh-auth gate on access-reducing operations. Eleven's asymmetry: a bearer session reads and writes, but anything that reduces who can get in needs a live ceremony within five minutes. We gate delete-item, remove-member, delete-vault and remove-passkey-wrap on the session alone. sessions.Session already carries AuthTime.
7. Tombstones and one retained previous version, purge ceremony-gated. A stolen browser session can delete every item today with no product-level history. Eleven keeps five blob versions for exactly this. Costs edit and delete counts becoming visible — the class Eleven accepts. Say so in the matrix.
8. Rate limiting. There is none anywhere in internal/contra — I checked. /auth/begin sends mail on every request; /auth/salt and /auth/challenge do a lookup for anyone. Eleven has a per-IP limiter and a 30-second per-address gap. The existence-oracle claims only hold under timing analysis if volume is capped.
9. Sign in by account id, not email — owner's call. The blind index does four jobs: signup dedupe, binding Complete to the proved address, finding the account at sign-in, and the recovery-notification email. The second is bindable by the token alone. The third can use the account id, since a fresh device needs the Account Key card anyway and the card's QR can carry the id (today kit.js prints an 8-hex-char handle — 32 bits, not an identifier). Then the decoy-salt machinery goes away and the pepper leaves the sign-in hot path entirely. And a live catch: the recovery notification is unimplementable as written — the server holds only an HMAC and cannot mail it. It works only if recovery starts by typing the address. Its recommendation: keep the index for dedupe and that notification, pepper off-instance as already ruled.
Related: "passkey for quick sign-in" (decision 3) is not built. No WebAuthn credential is stored, no assertion verified, no passkey sign-in route. passkey.js only adds a PRF wrap inside an existing passphrase session. A discoverable-credential login is the identifier-free door, and when it lands the credential public key is a new column that needs its canary.
10. Caps. AddPasskey accepts unbounded wraps per account. Per-vault item quota for the free rung (decision 9).
11. Optional: stateless challenges, retiring login_challenges from the replicated file the way Eleven retired ceremony_proofs. Rows are already deleted on spend, so the gain is WAL and free-page residue only. Low priority.
Two things to write down
We do not have rollback protection and the comments say we do. DESIGN.md, store.go and items.js all say the version in the AAD stops rollback. It stops relabelling. A server that serves the old (version, nonce, ciphertext) triple consistently produces a blob that opens. Detecting rollback needs client-side memory of the highest version per item, and for multi-device a sealed manifest. Eleven has the same gap, worse. Do not claim rollback protection in copy until it exists.
rastrillo's sessions table is in the replicated file and holds sign-in timing per account. TestMercilessGreps scopes its DDL check to CONTRA's own tables by design, so this is not a test gap — but it is transport shape at rest and belongs in the compromise matrix.
What it says is already right
Per-item rows with a whole-vault read and no per-item GET. AAD binding with client-minted ids bound before the server sees ciphertext, and server-enforced quantisation (Eleven only caps size). The sharing graph published as accepted residue rather than hidden. W_pw and W_rc never removed, which is Eleven's last-method guard by construction. 404-not-403 and the owner filter in SQL.
Proposed next steps
Changes 1 and 2 are correctness under sharing and should land before #14. 3, 4 and 8 are cheap and want doing while there are no real vaults. The rest is a backlog to file, not to build tonight.
With #22's docs edit now unblocked, the single dated DESIGN.md pass should carry: the KCV reworded so key confirmation is the primary job; "committing AEAD and XAES considered and rejected"; eager rotation replacing the lazy wording; the rollback claim corrected; and the new residue lines (item count, per-item timing, which-item, the sessions table).
Not starting any of it without a go-ahead — this is a design change, not a web/* item.