oficina / docs Public

Cover legacy read and snapshot activation boundaries

Paul Campbell pushed by paul@keymail.dev df7db788ab2dfc70e1c7ec8a12e04452f9f5dce8 parent fccd178
4 files changed, +144 −8
  • PRODUCT.md +1 −4
  • docs/superpowers/specs/2026-09-07-content-sealing.md +93 −0
  • internal/docs/content_runtime_test.go +49 −0
  • internal/docs/store.go +1 −4

CI passed — run details

diff --git a/PRODUCT.md b/PRODUCT.md
index f3ac319..f95de50 100644
--- a/PRODUCT.md
+++ b/PRODUCT.md
@@ -71,10 +71,7 @@ its foundations:
held by the running instance, enforced by a test that greps raw SQLite bytes
and bucket objects for fixture plaintext, and it enumerates the six things
that *do* leak (§2.1) rather than eliding them.
- **None of that is implemented today.** `Store.Text`
- (`internal/docs/store.go`) says so in its own doc comment — sealing is out
- of scope for the plan that built it — and no plaintext-grep test exists in
- the repo. Do not describe this instance as encrypting content at rest, and
+ Do not describe this instance as encrypting content at rest, and
do not let this entry be read as a shipped property; it is the position the
product is being built toward, and the thing that makes the position
defensible is the leak list, which is honest about what the trade costs.
diff --git a/docs/superpowers/specs/2026-09-07-content-sealing.md b/docs/superpowers/specs/2026-09-07-content-sealing.md
new file mode 100644
index 0000000..2828f5c
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-07-content-sealing.md
@@ -0,0 +1,93 @@
+# Document body sealing implementation spec
+
+Status: approved for implementation by Claude Fable 5.1
+(`claude-fable-5-1`), session
+`8aa40554-38b1-4597-9cb8-439ab594e268`. The final rollout amendment and
+approval are recorded in Amadan Docs discussion 39. Whole-branch review,
+exact-head CI, release integration, canary and deployment evidence are
+separate gates.
+
+## Scope and encoding
+
+Seal `doc_updates.update_bin` and `doc_snapshots.state`. Titles and other
+metadata are outside this slice. Historical plaintext backups remain
+preserved and are not claimed encrypted by this change.
+
+Use a separate `DOCS_CONTENT_KEY`: strict standard-base64 encoding of 32
+random bytes. Require it in production before opening the database, even
+before activation. Only explicit development mode may use the fixed public
+development key; maintenance always validates as production.
+
+Encoding 1 uses AES-256-GCM with a random 12-byte nonce prepended to the
+ciphertext and tag. Associated data binds the fixed content domain, row
+kind, immutable document UUID, sequence and snapshot ABI version. Allocate
+the sequence inside the writer transaction before sealing the update.
+Never send ciphertext to the CRDT core or sync clients. Decrypt and
+validate all response rows before writing HTTP success headers.
+
+The additive migration creates encoding columns defaulting to 0 and a
+singleton activation marker containing encoding, key fingerprint,
+authenticated keycheck and scrubbed bookkeeping. Keycheck binds a fixed
+sentinel and remains meaningful for an empty database. Wrong-key errors
+are distinct from authentication failures and never include content bytes.
+
+## Runtime states
+
+A database without the marker is in legacy compatibility mode. Refuse
+startup if it already contains nonzero encoding rows. Log an Error on
+every legacy boot with structured encoding and key-fingerprint fields.
+Legacy reads accept only encoding 0. Both update and snapshot inserts
+re-read the marker through the same writer transaction as the insert and
+refuse if activation has occurred. A process never switches modes live.
+
+A database with the marker verifies its keycheck and refuses startup if
+an EXISTS query finds any update or snapshot with encoding other than 1.
+Activated reads reject other encodings and authenticate every body used.
+An unusable snapshot may be abandoned for the retained update log; it is
+an accelerator and is never the only copy of document history.
+
+## Explicit maintenance
+
+Only the `docs-seal` subcommand invokes conversion or scrubbing. It requires
+an existing regular database path and the expected key fingerprint. Read
+the key only from `DOCS_CONTENT_KEY`. Reuse the exact server `BootSchema`.
+Never resolve serving options, bind a listener or start replication.
+There is no deactivate or marker-removal operation.
+
+Conversion is one transaction. Process bounded batches, bind bodies to
+their stored document UUID, verify already-sealed rows and reject unknown
+encodings or orphaned rows. On first conversion write the keycheck marker
+atomically with the converted rows. On explicit repair of an activated
+mixed database, convert remaining encoding-0 rows while leaving the marker,
+fingerprint and keycheck unchanged. Clear only scrubbed bookkeeping when
+new legacy bytes have been converted. Any failure rolls back all changes.
+
+Scrubbing is an explicit operation after conversion. The operator stops
+the server and replication first. Set scrubbed false, use a pinned writer
+connection, check the complete TRUNCATE-checkpoint result, VACUUM, check a
+second TRUNCATE checkpoint, and set scrubbed true only on success. A busy
+checkpoint is failure. Never put conversion or VACUUM on normal startup.
+Every restored copy needs another explicit scrub; its stored scrubbed bit
+is not evidence about the restored file's pages or WAL.
+
+## Rollout constraints
+
+Deploy and smoke-test the compatibility-capable release with the real
+production key before activation. Retain that exact release as the rollback
+target. Verify key fingerprint and backup/key recovery before conversion.
+Stop serving and replication for maintenance, then restart the same
+compatibility-capable release after activation. Never roll back to an older
+plaintext-only binary once sealed rows exist. Preserve historical backups.
+A restored pre-conversion backup is visibly legacy and needs activation;
+it must never be silently presented as encrypted.
+
+## Required evidence
+
+Use actual Yjs fixtures and real SQLite. Cover authenticated row identity,
+wrong keys and tampering; encrypted HTTP read/write and snapshots; stale
+legacy reads and writes; both tables' mixed-state startup checks; explicit
+maintenance inputs; atomic repair and marker preservation; raw database
+and WAL canaries; incomplete checkpoints; process-exit recovery; and an
+independent restored-copy rehearsal. Observe meaningful tests fail when
+their safeguards are removed, then restore the code. Full repository CI
+and an independent complete-diff review remain required before merge.
diff --git a/internal/docs/content_runtime_test.go b/internal/docs/content_runtime_test.go
index cf4209b..17b081a 100644
--- a/internal/docs/content_runtime_test.go
+++ b/internal/docs/content_runtime_test.go
@@ -278,3 +278,52 @@ func TestProductionBootRequiresContentKeyBeforeOpeningLegacyDatabase(t *testing.
t.Fatalf("missing production content key: %v", err)
}
}
+
+func TestStaleLegacyStoreRefusesSealedUpdatesAndSnapshots(t *testing.T) {
+ app, _ := activatedContentApp(t)
+ ctx := context.Background()
+ owner := app.TestPerson(t, "owner@example.com")
+ doc, err := app.store.Create(ctx, owner, "Stale")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := app.store.ApplyUpdate(ctx, doc, Actor{PersonID: owner, Subject: "owner@example.com"}, validUpdate(t)); err != nil {
+ t.Fatal(err)
+ }
+ h, seq, _, err := app.store.replay(ctx, doc)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := app.store.writeSnapshot(ctx, doc, h, seq); err != nil {
+ h.Close(ctx)
+ t.Fatal(err)
+ }
+ h.Close(ctx)
+ app.store.content = nil
+ if _, err := app.store.newestSnapshot(ctx, doc.ID); !errors.Is(err, errContentEncoding) {
+ t.Fatalf("legacy snapshot read: %v", err)
+ }
+ rec := serve(app, app.TestRequest(t, "owner@example.com", http.MethodGet, "/d/"+doc.Sqid+"/updates?raw=1", nil))
+ if rec.Code != http.StatusInternalServerError || rec.Header().Get("Docs-Seq") != "" {
+ t.Fatalf("legacy update GET: %d, seq %q", rec.Code, rec.Header().Get("Docs-Seq"))
+ }
+}
+
+func TestContentStartupRejectsPlaintextSnapshotWithoutLegacyUpdates(t *testing.T) {
+ app, _ := activatedContentApp(t)
+ doc, err := app.store.Create(context.Background(), app.TestPerson(t, "owner@example.com"), "Snapshot")
+ if err != nil {
+ t.Fatal(err)
+ }
+ snapshot := DocSnapshot{DocID: doc.ID, Seq: 1, State: validUpdate(t), ABIVersion: coreABIVersion, CreatedAt: time.Now().UTC()}
+ if err := app.store.db.Create(&snapshot).Error; err != nil {
+ t.Fatal(err)
+ }
+ candidate, err := New(app.cfg)
+ if candidate != nil {
+ candidate.Close()
+ }
+ if !errors.Is(err, errContentMixed) {
+ t.Fatalf("plaintext snapshot boot: %v", err)
+ }
+}
diff --git a/internal/docs/store.go b/internal/docs/store.go
index d268d4a..5abefeb 100644
--- a/internal/docs/store.go
+++ b/internal/docs/store.go
@@ -576,10 +576,7 @@ func (s *Store) reseedForReplay(ctx context.Context, doc *Document, full *core.H
}
// Text returns doc's current text — read from the real CRDT core by
-// replaying its update log, never from a database column. Sealing
-// (spec §2 — per-row encryption of update_bin, same as document
-// titles) is out of scope for this plan; this plaintext replay is not
-// a decision that content stays unsealed.
+// replaying its update log, never from a database column.
func (s *Store) Text(ctx context.Context, doc *Document) (string, error) {
h, _, _, err := s.replay(ctx, doc)
if err != nil {