Repository navigation
fix(metadata-protocol)!: the metadata save door never sets, changes or removes an item's lock: a body's _lock* family is kept from the stored row, echoed, or refused - #23167
Conversation
…lock family and refuse a body that would set, change or remove it WIP: implementation; tests and changeset follow. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
… author faces Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…and refuses an authored one Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…d lock through a server face An author's save no longer sets a lock, so the five seeds that wrote one through it state the migration face instead; the publish refusals they set up are unchanged. Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…'s pinned doubles Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. 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 950976fc84ad7b1254c5a9b737ea919d2cb826a1 && git checkout 950976fc84ad7b1254c5a9b737ea919d2cb826a1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e540e7ffb9b9a9cfc9897722c2e04941e9924a8e 010639549d199247daa802e778966a86f29f6c62 && git checkout -B drift-repro e540e7ffb9b9a9cfc9897722c2e04941e9924a8e && git merge --no-ff 010639549d199247daa802e778966a86f29f6c62
node scripts/docs-audit/affected-docs.mjs --json e540e7ffb9b9a9cfc9897722c2e04941e9924a8e
|
Contract reviewServed-tier: Inputs: card #23119 (body; comments 6135754247 triage, 6136862148 unlock, 6136889453 claim, 6138569909 dev report); PR #23167 (body, 7-file list, net diff against Check-runs on the head, polled 2026-10-13T05:19Z: 32 success, 6 skipped (Console Pin Gate, Build Docs, Temporal Conformance, the Dogfood Regression shard matrix, Dogfood Verify CLI, Packed-tarball smoke: opt-in or path-gated), 1 still running: Test Core 4/6. Green includes Check Changeset, Governed Surface Queue Guard, Build Core, Dogfood Regression Gate, Test Core 1/6, 2/6, 3/6, 5/6 and 6/6, every Lint family (ESLint, PM dispatch-gates, the CI/release/engine gates, the repo and tooling gates, the docs and code guards, the three scoped lints), Type Check source gates, debt ledger, consumer gates and workspace, and the claim, single-writer and part-of guards. Their conclusions are the gate verdicts; nothing below re-derives one. ① Derived judgmentsThe direction: right. The echo test: the served set, no wider and no narrower. Traced on the head. The active item read ( The early return: right, and it never stores. For each sent key whose declared schema ( The faces: each judged right; none is server-stated in fact. The envelope: right. The edited tests: faithful re-pins.
② Semver level
③ Boundary flags
Adopted by Implemented-by: VERDICT: FAIL |
…ry's 'saves as before' sentence Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
…m is refused as adding lock keys, not setting a lock Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Delta record after the FAIL 6138876344 on Check-runs on the head, polled 2026-10-13T05:51Z: 46 runs, 36 success, 8 skipped (opt-in or path-gated: Console Pin Gate, Build Docs, Dogfood Verify CLI, the Dogfood Regression shard matrix, Temporal Conformance, Packed-tarball smoke, and the second Auto Label and Check PR Size runs that the body edit at 2026-10-13T05:47Z re-triggered), 2 still running: Test Core 3/6 and 4/6. Green includes Check Changeset twice (the run the push started and the run the body edit started), Governed Surface Queue Guard, Build Core, Dogfood Regression Gate, Test Core 1/6, 2/6, 5/6 and 6/6, every Lint family (ESLint, PM dispatch-gates, the CI/release/engine gates, the repo and tooling gates, the docs and code guards, the three scoped lints), every Type Check family (source gates, debt ledger, consumer gates, workspace, and TypeScript Type Check), Check Documentation Links, and the claim, single-writer and part-of guards. Their conclusions are the gate verdicts; nothing below re-derives one. ① Derived judgmentsThe delta is what the dev reports, and nothing else. The supersession paragraph: true at this head, in the house form, naming the sentence exactly. Form: it is the skeleton No other sibling sentence turns false, unsuperseded. Read every sentence of The new wording: true for every input that reaches it; the envelope is unchanged. ② Semver levelUnchanged from 6138876344, and the delta gives no reason to move it: ③ Boundary flags
Adopted by Implemented-by: VERDICT: PASS |
Fixes #23119
Clause-②: no (narrowing)
What changes
The metadata save door (
PUT /api/v1/meta/{type}/{name}, and every write that reachessaveMetaItem's author arm) used to store a body's_lock*family (_lock,_lockReason,_lockDocsUrl,_lockSource) verbatim. A hand-written or echoed_lock: 'full'then bound an environment item for good: the next save and the delete each answered403 ITEM_LOCKED, and no door unlocks it. The reverse also held: an author save that sent no_lock*key dropped a lock a server face had stamped on the row.Per the binding triage direction (6135754247), the author arm now keeps the lock family the row it writes already stores, beside
protectionBlockRefusaland in its refusal family:_lock*key_lock: 'none', a partial echo), a family copied from another item400 VALIDATION_ERROR, nothing written_lock*key sent and ADR-0010's door ("declareprotectionon it in a code package's source, where the package loader translates it into the item's lock (ADR-0010 Phase 4.3)");issues[]carries each key as a path. Not a silent strip. On an item that serves no lock, a family that binds nothing (a lone prose key, or_lock: none) is described as adding lock keys rather than setting a lock.source,writeFace: 'package-duplicate') keep stamping as before. The unmanaged import's strip (stripUnmanagedImportLock) is untouched._lock*value the type's own declaration refuses (_lock: 'locked', a non-string reason) is handed to the parse untouched and still answers422 INVALID_METADATA, so the unmanaged import's documented refusal of such an item (pinned inpackage-unmanaged-install.test.ts) does not move.item-lock.ts: the docblock sentence that called a stored row lock "an administrator's stored lock" now reads it as amended in 6135754247 (one the runtime stamped, never one an author wrote). Prose only: the resolver, theoverlaylayer and the layer order are untouched.shared/protection.zod.tsis not edited: its guidance ("never authored") is true without naming the save door, and becomes more true with this change.What the echo is compared against, and why
Two families count as "what the read served for that item":
resolveItemLockover every shipping package's artifact (artifactLockLayerAt) and the strictest stored row in scope (overlayLockLayerAt,otherSpelling: true, exactly as the reads pass it), written as a family bywithItemLockFamily. This is whatgetMetaItem'sitem, the layered read'seffectiveand the list serve, i.e. whatservedLockStatereports and what Studio and AI Studio echo.headAt, at the save's state, package and bundle member; a first draft reads the active row it overlays). This is what the draft read, thepreviewDraftsread and the layered read'soverlaylayer serve verbatim. Accepting it changes nothing, since it is the family carried forward anyway. It is load-bearing for a real producer: the permission-set data door merges its patch intogetMetaItemLayered().overlayand saves into that row, and a row storing_lock: 'none'serves an empty resolved family.A new item has no row and serves no lock, so any family on it is an add. A first overlay of a packaged item echoes the artifact's family: accepted, the new row stores none, and the package's lock still binds through the artifact layer. The layered read's raw
codelayer is deliberately not an echo source (see Acceptance notes).Measured faces that reach the author arm
The arm is
request.source === undefined && request.writeFace !== 'package-duplicate'. Measured onf931e765b7(base) with a protocol probe saving{ _lock: 'no-delete' }on each face (all stored it verbatim before; after, every author face answers400, both server faces still stamp):_lock*?meta-envelopePUTmeta-dispatch_lock*; the data door merges into the stored overlay body; the app flip re-saves the list's served bodyexternal-importimportObject)package-unmanaged-importimportUnmanagedPackagestripUnmanagedImportLockdrops it, and the import writes only names no row heldNo face that reaches the arm stamps a lock in fact, so the direction's shape holds; no
needs_decision. A type with no schema (external_catalog: the only registry type whose schema does not declare_lock) is judged too, because no parse refuses the key there and the_lockgate reads it from any stored body.Readings, before and after
Protocol door (the
saveMetaItem/getMetaItem/deleteMetaItemthe RESTPUT/GET/DELETEforward to), stub engine with real rows. A showcase boot was not re-run (see Narrowings).f931e765b7PUT app/probe_rawlock_appwith_lock: 'full', _lockReason: 'raw'200, row stores the family400 VALIDATION_ERROR, no rowGETafter itlock: 'full',editable: false,deletable: falselock: 'none', editable, deletablePUT/DELETE403 ITEM_LOCKED/403 ITEM_LOCKED200/200no-delete, then an author save with no familylock: 'none',DELETE200lock: 'no-delete',DELETE403 ITEM_LOCKEDno-deleteon its first overlay200, the overlay row stores none, the item still readsno-deleteTests
At
6bbc5475ccunless stated.packages/metadata-protocol/src/protocol.save-door-lock-family.test.ts: 25 tests, pass. On both author faces: the card's probe; the add (every lock state and a lone prose key), the change (state, reason, docs URL) and the removal (none, a partial echo) each refused with the envelope (code+statusviaresolveThrownHttpError) and theissues[]paths; draft add refused; echo accepted for a server-stamped row, for a packaged item's first overlay, in draft mode (first draft and the draft read's own echo, then publish keeps the lock), and on a clone of an unlocked item, while a clone carrying another item's lock is refused. Plus: both server faces stamp and the stamp survives author saves;fullstill answers403 ITEM_LOCKEDfirst; CONTROL body without a family; CONTROL malformed values stay422 INVALID_METADATA; an in-process door;external_catalog.pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2(whole package, run at2ed18c717c, implementation unchanged since): 230 files passed, 1 failed (protocol.package-publish-audit-rows.test.ts, 5 tests: its fixtures seeded a stored lock through the author face, the door this card closes). Fixture moved onto the server face (source: 'migrate-stored') in063ca29be4; that file plus the new one re-run: 2 files, 43 tests pass.protocol.lock-one-resolution.test.ts([finding] lock family, artifact-layer axis: an explicit artifact_lock: 'none'reads editable while the door refuses, and the layered read takes the code layer's lock over a stored row's #21738's resolver pin),protocol.save-door-protection.test.ts,package-unmanaged-install.test.ts,protocol.lock-door-read-agree.test.ts: 4 files, 24129 tests pass.packages/objectql: the 53 test files that mentionsaveMetaItemor_lock(against a rebuilt@objectstack/metadata-protocoldist, 5 hits of the new method indist/index.js): 53 files, 740 tests pass.protocol-derived-provenance-doors.test.tspinned the verbatim store ("does NOT touch the _lock* family"); it now pins the same intent under the new rule: a stored family is kept while the three derived keys go, and an authored family is refused with nothing written.pnpm --filter @objectstack/metadata-protocol run typecheckexit 0 (--listFilesincludes both edited test files);pnpm --filter @objectstack/objectql run typecheckexit 0 (tsc pluscheck:test-typecheck, debt held).Ablation (one-time, not kept)
node scripts/ablation-replace.mjsreplaced the arm'srequest.item = await this.authoredLockFamily({with a no-op that returns the body (the verbatim store), on the committed tree, with a trap restore. On disk: anchor 1 to 0, marker 0 to 1, blob5ce2896165to8faf50ed0d. The subject resolves through./protocol.js(source, nodist/involved).external_catalog, both server-stamp carry-forward pins (the verbatim store drops the stamped lock), and the packaged first-overlay echo pin (the verbatim store copies the package's family into the row, so its "stores none" assertion goes red: the "ignored" half). Green: the stored-row echo (both faces), the draft echo (both faces),fullansweringITEM_LOCKED, and both CONTROLs.5ce2896165,git diff HEADempty.Gates
The dispatch's 80-command lead list plus the families derived on this diff (
node scripts/pm/dispatch-gates.mjs --commands, 75 families; union 98 commands), each run sequentially with its exit captured before any pipe: 95 exit 0, includingcheck-adr-0087-registration --base origin/main("1 declared-breaking changeset(s), each carrying an ADR-0087 disposition"),check-changeset-no-major,check-empty-changeset,check:doc-authoring,check:error-status-conformance,check:dispatcher-error-vocabulary,check:durability-log-level,check:nul-bytes,check:type-check-coverage,check:type-check-debt,check:test-source-alias,check:cross-package-test-inputs.check:engine-double-contractwas red (exit 1) until the new test's pinned doubles were recorded (--write: 3 rows added, 0 lost) in6bbc5475cc; re-run exit 0.check:dual-build-cjs-loadsandcheck:lean-entry-closure, reason: both exited 3 (PREREQUISITE NOT MET), they load built entry points for packages this worktree did not build. CI runs both.dispatch-gates --ranreconciliation: 75 derived families accounted for, 75 run.pnpm exec eslint --no-inline-config --format jsonover the 5 touched TypeScript files at6bbc5475cc: 5 files, 0 errors, 0 warnings. Population:eslint.config.mjslints**/*.tsminus itsNEVER_LINTEDlist, and none of the 5 is ignored (no "file ignored" warning). Invariance: the config enables no type-aware linting (noparserOptions.project, stated at its own:327), so this diff cannot move a verdict on an untouched file. The repo-widepnpm lintis CI's.Narrowings (declared)
os-verify-lockqueue timeouts behind another seat's full builds and dogfood batteries, the light targeted runs above (the two-file, four-file and 53-file vitest runs, the ablation and the two typechecks) ran outside the lock with--maxWorkers=2. The closure build and the first full package run went through the lock.packages/qa/dogfood(install-local-unmanaged-mode.dogfood.test.tssaves each copied item's served body back) was not run locally: it needs the full build, and it rides main's full battery. Its copied bodies carry no_lock*key, so they pass as no-family saves.Changeset
.changeset/23119-save-door-lock-envelope.md,@objectstack/metadata-protocol: minor,**BREAKING**,Clause-②: no (narrowing), ADR-0087not-required (no-migration-prescription). Level: the WHICH LEVEL rule inpr-automation.ymlgrades a wideningminorand a fix that changes no public surfacepatch; this narrows a published accept set, which is a breaking change, andcheck-changeset-no-major.mjsstates that during the launch window a breaking change shipsminor(the grade #23096's entry took for the same door). The arm:clause2-line.mjsdefinesno (narrowing)as "NOT a widening, but breaking", which is this diff. No@objectstack/specentry: its prose is not changed. The changeset supersedes one sentence of the pending23096-save-door-protectionentry, its "A body withoutprotectionsaves as before": that now holds only for a body that also sends no_lock*key or sends back exactly the_lock*family a read of that item serves. No sentence of a pending15213-*entry becomes false.Acceptance notes
LOCK_FAMILY_KEYS(derived fromMetadataProtectionFields, so this file adds no hand-kept list beside the oneitem-lock.tsalready keeps),lockFamilyOf,sameLockFamily,withLockFamilyandauthoredLockFamilyRefusalsit besideprotectionBlockRefusal, and the privateauthoredLockFamilysits just abovesaveMetaItem; the arm itself gains one call. Outsideprotocol.ts, three existing files had to move with the behaviour: the objectql provenance-door pin and the publish-audit fixtures (both relied on the author face storing a lock), andscripts/engine-double-contract.pinned.json(the new test's doubles).sys_metadatahead read (the target row, plus the active row for a first draft), with notry/catch: a stored lock that cannot be read fails the save rather than being written away. A body that carries a_lock*family not equal to the stored one adds the served-lock read (artifact lookup plus the rows in scope).codelayer is not an echo source. For a package that declaresprotection.lock: 'none', that raw layer carries_lock: 'none'while every resolved read serves no family; a body copied from it is refused, loudly, with a one-step remedy (remove the keys)._lock*value gets the parse's422first and this door's400only once it is well-formed. Accepted to keep the unmanaged import's refusal unchanged.Generated by Claude Code