Repository navigation
fix(metadata-protocol): a seed replay no longer rewrites every seeded row that carries a password or secret field on every boot - #24696
Conversation
…ead path masks A secret field, and a password field on an object not managed by better-auth, reads back as SECRET_MASK through engine.find, so the replay compared the seed's plaintext with the mask and rewrote every seeded row carrying one on every boot. Such a field no longer blocks the skip while a value is set; setting or clearing one is still a change, and a load that skipped a row without comparing one says so in one info line naming the fields and the remedy. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD
…eplay skip Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AHeUBbe6S3JRawcpbLejXD
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 89b2f743547f98c1ccca78cc4bf22f644d351ab6 && git checkout 89b2f743547f98c1ccca78cc4bf22f644d351ab6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 045dae8f23d21e7631e425bb851cad5ab2bc26fd a947341b91360e265db6cf549f2225e1c8615c3f && git checkout -B drift-repro 045dae8f23d21e7631e425bb851cad5ab2bc26fd && git merge --no-ff a947341b91360e265db6cf549f2225e1c8615c3f
node scripts/docs-audit/affected-docs.mjs --json 045dae8f23d21e7631e425bb851cad5ab2bc26fd
|
席位核对 · ACCEPT ·
|
Fixes #24624
Clause-②: no (a replay comparison: no accept set, served body or masking verdict moves; the loader takes no new read and never sees a stored credential)
What this changes
Ruling 6278934212 on #24624 records letter B of analysis 6275762852 (the maintainer, verbatim: 「同意」): a masked field never blocks the replay's skip.
packages/metadata-protocol/src/seed-loader.ts: the replay read (engine.find,isSystem: true) serves a setsecretfield, and a setpasswordfield on an object not managed by better-auth, asSECRET_MASK(ADR-0100).isNoOpReplaycompared the seed's plaintext with that mask, read the row as changed, and rewrote it on every boot. It now skips a field when all three hold: the object declares the field masked on read, the existing row read backSECRET_MASK, and the seed sets a value.isNoOpReplaystays a pure predicate, becausedecideWriteActionasks it up to three times per record (the refusal check, the early signal and the decision). The skip arms carry the uncompared fields, and the load records them only where a skip is counted.infoline per load, when a replay skipped at least one row without comparing a credential. It names every such field, the rule, and both remedies (change another field of the row, or rebuild the database). Text from the real-engine reading below:ObjectQL.maskSecretFieldsorcollectMaskedReadFields, add an export, edit an ADR, or add a dependency.How the loader knows a field is masked (dispatch mechanism assumption 2)
collectMaskedReadFieldsfrom@objectstack/objectql: impossible.packages/objectql/package.jsonalready lists@objectstack/metadata-protocolunderdependencies(workspace:*), so the import would create a dependency cycle.existing[key] === SECRET_MASK) fixes the showcase. But it would also skip any non-credential field that happens to store the eight-bullet string, so a seed change to that field would be dropped silently. Ablation leg 2 below measures this: the pina field the declaration does not mask keeps comparing, even when it holds the mask's textgoes red.isMaskedOnReadFieldType(type, managedBy)from@objectstack/spec/data. That is the declarationcollectMaskedReadFieldsitself asks, and@objectstack/specis already a dependency of this package. The value check readsSECRET_MASKfrom the same module. The loader contains no copy of the "secret, or password on a non-better-auth object" rule.One reading of the ruling for the seat to confirm: presence still counts
The ruling's execution text says
isNoOpReplay"skips the fieldscollectMaskedReadFieldscollects". This PR skips such a field only while its value is set. The mask preserves presence: an unset credential reads backnull, never the mask (SECRET_MASK's docblock, ADR-0100). So anullread is a stored fact the replay can compare:nullover a set one is written, which clears it.Neither case churns, because the next replay reads the same presence and skips. Ablation leg 3 swaps in the literal reading (
return comparands.masked.has(key);), and it turns only the presence pin red. If the seat reads the ruling literally, that one-line change is the entire difference.Notice level:
info, and what that costsinfo: this skip is the declared, ruled outcome, and it fires on every healthy replay of a seed that carries a credential (the stock showcase, every boot). The precedent isfilterByEnv's skip line in the same file, which logs atinfo"rather than crying wolf on every production boot". The official examples also boot with zero warnings (examples/app-showcase/test/no-startup-warnings.test.ts). Nothing the system claims to hold is lost, because the row keeps its credential, so this is not AGENTS.md's durability class.warn(packages/cli/src/utils/log-level.ts). The boot-quiet window also drops in-windowinfowhatever--log-levelsays (BOOT_DIAGNOSTIC_FLOOR = 'warn',packages/cli/src/utils/boot-log-capture.ts). So on anos devoros serveboot the line is not on screen. It reaches a host logging atinfooutside that window: hot reload, package apply, draft publish, marketplace install and per-organization replay. If the seat wants the line visible at boot, it is a one-word change towarn, at the price of a warning on every showcase boot.Tests (all at
a947341b91, after mergingorigin/main92ce3af404)packages/metadata-protocol/src/seed-loader-replay.test.ts, blockseed replay — a credential the read path masks never blocks the skip. The engine double wraps the existing faithful engine. It stores apasswordverbatim and asecretas an opaque ref, stampsupdated_aton every write, and masks on read throughisMaskedOnReadFieldType(returning copies). Both write paths are covered: batcheddecideWriteAction, and sequentialwriteRecordthrough a self-referencing object.updated_atincluded, is deep-equal (both paths ×upsert/update);passwordandsecret) is not rewritten, and the load emits exactly one notice, atinfo, naming every uncompared field;passwordbecomes the seed's new value, and thesecretupdate carries the new plaintext;bfc1539fe0source), 10 of the new pins failed: every row carrying a credential was rewritten (3 of 4 rows updated on an unchanged replay).pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2:Test Files 249 passed | 3 skipped (252),Tests 20793 passed | 19 skipped (20812), verify-lockVERDICT command-exit 0.pnpm --filter @objectstack/metadata-protocol typecheck: exit 0.tsc --listFilesincludesseed-loader-replay.test.ts.scripts/ablation-replace.mjs(anchor hits 1, mutation proven on disk by count and blob, restored to the HEAD blob3963a4b4withgit diff HEADempty after each leg). The test imports./seed-loader.jsfromsrc, so nodist/sits on the path. Expected directions were declared first, and all three held:isNoOpReplay: 10 failed | 15 passed (the replay, credential-only, other-field and presence pins);return stored === SECRET_MASK && seedValue != null;, i.e. option (a) alone): 1 failed | 24 passed (the literal-bullets pin only);return comparands.masked.has(key);, the literal reading): 2 failed | 23 passed (the presence pin, both paths).Real-engine reading (scratch harness, not committed)
Setup: real
ObjectQLwithSqlDriver(better-sqlite3) on one SQLite file. Everyexamples/app-showcaseobject definition, plussys_business_unit, and every showcase seed dataset (19 datasets, 132 rows). One process per "boot"; the loader runs from this branch's source.Specimen — Fullstoredf_passwordupdated_athunter2(plaintext at rest;engine.findreads the mask)…23:17:00.189Zhunter2hunter3)hunter2hunter3+f_phonechangedhunter3Specimen — Full)This confirms dispatch mechanism assumption 1 on the real engine:
f_passwordis stored as plaintext, and the seed's own system-context read returnsSECRET_MASK.Gates
a947341b91:node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 64 families, with the change set taken from git by the tool. All 64 exited 0, each exit captured before any pipe.--ranreports64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN, a derived zero because every line carries its exit code. On the pre-merge head20abbaf71d,check:dual-build-cjs-loadsprintedPREREQUISITE NOT MET(not every package had adist/). Ata947341b91it measured green: 111 require entry points across 69 packages.node scripts/check-platform-checklist.mjs --changed-from origin/main: no export was renamed or removed.test:repo: the diff touches norepo-project test. CI'sTest Coreruns it.Acceptance notes
.changeset/24624-replay-masked-field.md,@objectstack/metadata-protocolpatch, carrying theClause-②: noline.historyrecords on a churned replay. That belonged to the first claim's measurement list, and this PR removes the churn.f_secret: its seed comment says the seed path has no CryptoProvider, so asecretvalue is refused fail-closed. Thesecrethalf is therefore pinned through the double only, not read on the real engine.seedValueEqualscompares time and ISO-datetime strings literally, so09:15vs09:15:00and…Zvs….000Zbumpupdated_at(5 showcase rows per restart) #24562's acceptance measure was all 132 showcase rows unchanged on a second boot. The harness above reads boot 2 as 0 updated and 132 skipped.Generated by Claude Code