🤖 Done. Branch web/sealed-schema at 3730d63, pushed and deployed.
Canary: https://contra.bdj.oncarlos.com — /api/version and X-Carlos-Version both report 3730d63, and the sealed schema's migration applied cleanly over the database that had already booted with sessions only. make ci green.
The schema
Six tables. Every column holds ciphertext, public keys, opaque random ids, or the parameters needed to hand the right blob back: accounts (a blind index, never an address), login_keys, wraps, vaults, vault_members, items.
An item's title, URL, username, notes, TOTP seed and password are one blob. Separate columns — even sealed ones — would publish which fields an item has and how long each is, and would make the plaintext "just the title, for the list" column easy to add later. The list view is built from items the client has already opened.
The store package is the API half of the same defence: no function takes a title, a URL, a username or an address, and there is nowhere to pass one. The failure being designed against is not a cryptographer's mistake, it is somebody adding that column next spring because the API made it easy.
The greps
Canaries through every write path, then the raw bytes — not a query. Main file, write-ahead log (the WAL replicates too, so a leak that only ever lands there is still in the bucket), a replicated copy, and sqlite_master's DDL, because a column named after content leaks without ever holding a value.
They search for two kinds of thing. The canary strings a person would have typed, and the raw key material — RK, K_vault, every KEK, the Account Key, the recovery code. A build that stored a key would pass every "is it encrypted?" review ever written and lose everything.
Watched failing — twice, and one of them forever
TestGrepsCatchAPlaintextColumn adds the tempting column, writes a canary into it, and asserts the same grep finds it. It runs on every CI pass rather than having been done once by me: if it ever goes green, the greps have stopped working and every other result in that file is worthless. Its failure message says so.
Separately, by mutation, I proved the real schema leaking fails the real test — a Title column on Item plus a store that writes it:
plaintext on disk with the WAL hot: contra.db-wal: item title @ 259558 plaintext on disk after checkpoint: contra.db: item title @ 94174 a CONTRA column is named "title" plaintext in the replicated objects: contra.db: item title @ 94174
Four independent catches. Tree restored, suite green.
There is also a row-count assertion, and it earns its place: a grep over an empty database is green and means nothing. A write path that silently stopped writing would otherwise look exactly like a pass.
The standing rule, written where it will be read
In writeEverything's doc comment, in models.go, in store's package comment and in migrations.go: a new storage surface lands with its canary or it does not land, and a new write path goes into that test on the same commit. The bootstrap tripwire from web/1 is replaced rather than deleted, as its own comment instructed — what survives is the narrower question the greps do not ask: which tables exist, each named with its owner, so one cannot arrive without somebody deciding it should.
Two things I decided, flag either
Items are quantised into 256-byte buckets and PutItem refuses a ciphertext that is not. DESIGN.md adopts the padding; enforcing it at the storage boundary is my call. The padding is the client's job, but "the client pads" is a hope and refusing is a control. The check is scheme-independent — it says the plaintext was quantised, not how — so it binds JS and Swift without pinning a scheme the golden vectors do not yet cover. Follow-up for whoever builds items: the padding scheme needs a vector, or Go and JS can disagree about the last byte.
vault_members is the sharing graph and it is legible. Wrap rows reveal who shares with whom, which A1 tolerates and DESIGN.md says goes in the published model. Recorded in the model's doc comment rather than left to be discovered.
Related
Filed #20: carlos deploy returned success and X-Carlos-Version showed the new build while /api/version still reported the old one, for 95 seconds, on both deploys tonight. #7 tells the next agent to verify with the header; that check can pass against a process still serving the previous build. Proposed wording is on that thread.
Next: web/3 (#9), signup and passphrase sign-in. Claiming there.
⚠️ Worth knowing before you read the stack: this is now four unmerged branches deep — shared/golden-vectors → web/bootstrap → web/sealed-schema → (next) web/auth — and none has been reviewed. The encodings in #19 are load-bearing for all of them; if one of those is wrong, everything above it rebases.