Skip to content

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

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-23119-save-door-lock-envelope
Oct 13, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-23119-save-door-lock-envelope

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #23119
Clause-②: no (narrowing)

What changes

The metadata save door (PUT /api/v1/meta/{type}/{name}, and every write that reaches saveMetaItem'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 answered 403 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 protectionBlockRefusal and in its refusal family:

body sends outcome
no _lock* key saved; the row keeps its stored family (not a removal)
a family equal to one a read of this item serves accepted and ignored (the row keeps its stored family)
anything else: an add, a change (state or prose), a removal (_lock: 'none', a partial echo), a family copied from another item 400 VALIDATION_ERROR, nothing written
  • The refusal names every _lock* key sent and ADR-0010's door ("declare protection on 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.
  • Server-stated faces (source, writeFace: 'package-duplicate') keep stamping as before. The unmanaged import's strip (stripUnmanagedImportLock) is untouched.
  • A _lock* value the type's own declaration refuses (_lock: 'locked', a non-string reason) is handed to the parse untouched and still answers 422 INVALID_METADATA, so the unmanaged import's documented refusal of such an item (pinned in package-unmanaged-install.test.ts) does not move.
  • No stored lock is unbound.

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, the overlay layer and the layer order are untouched. shared/protection.zod.ts is 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":

  1. The item's resolved lock at the save's address: the one item-lock resolution's answer, resolveItemLock over 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 by withItemLockFamily. This is what getMetaItem's item, the layered read's effective and the list serve, i.e. what servedLockState reports and what Studio and AI Studio echo.
  2. The family stored on the row this save writes (the repository's write-address resolution, 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, the previewDrafts read and the layered read's overlay layer 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 into getMetaItemLayered().overlay and 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 code layer 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 on f931e765b7 (base) with a protocol probe saving { _lock: 'no-delete' } on each face (all stored it verbatim before; after, every author face answers 400, both server faces still stamp):

face who body carries _lock*? treatment
meta-envelope REST PUT author's judged
meta-dispatch runtime dispatcher author's judged
no face in-process doors: flow save and clone, permission-set clone, permission-set data door and backfill, position write-through and backfill, the package publish's app flip the clones drop _lock*; the data door merges into the stored overlay body; the app flip re-saves the list's served body judged (echoes pass)
external-import service-datasource "Import as Object" never: the body is server-built from introspection plus name and label (code read of importObject) carry-forward only
package-unmanaged-import importUnmanagedPackage never a well-formed one: stripUnmanagedImportLock drops it, and the import writes only names no row held carry-forward only (no row, so no family)

No 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 _lock gate reads it from any stored body.

Readings, before and after

Protocol door (the saveMetaItem / getMetaItem / deleteMetaItem the REST PUT / GET / DELETE forward to), stub engine with real rows. A showcase boot was not re-run (see Narrowings).

reading base f931e765b7 this branch
PUT app/probe_rawlock_app with _lock: 'full', _lockReason: 'raw' 200, row stores the family 400 VALIDATION_ERROR, no row
GET after it lock: 'full', editable: false, deletable: false item absent; the same body without the family saves and reads lock: 'none', editable, deletable
second PUT / DELETE 403 ITEM_LOCKED / 403 ITEM_LOCKED 200 / 200
a server-stamped no-delete, then an author save with no family lock dropped: read lock: 'none', DELETE 200 lock kept: read lock: 'no-delete', DELETE 403 ITEM_LOCKED
echo of a packaged no-delete on its first overlay the overlay row stores the package's family 200, the overlay row stores none, the item still reads no-delete

Tests

At 6bbc5475cc unless stated.

  • New 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 + status via resolveThrownHttpError) and the issues[] 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; full still answers 403 ITEM_LOCKED first; CONTROL body without a family; CONTROL malformed values stay 422 INVALID_METADATA; an in-process door; external_catalog.
  • pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2 (whole package, run at 2ed18c717c, 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') in 063ca29be4; that file plus the new one re-run: 2 files, 43 tests pass.
  • Resolver and neighbour pins re-run, files untouched: 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 mention saveMetaItem or _lock (against a rebuilt @objectstack/metadata-protocol dist, 5 hits of the new method in dist/index.js): 53 files, 740 tests pass. protocol-derived-provenance-doors.test.ts pinned 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 typecheck exit 0 (--listFiles includes both edited test files); pnpm --filter @objectstack/objectql run typecheck exit 0 (tsc plus check:test-typecheck, debt held).

Ablation (one-time, not kept)

node scripts/ablation-replace.mjs replaced the arm's request.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, blob 5ce2896165 to 8faf50ed0d. The subject resolves through ./protocol.js (source, no dist/ involved).

  • Ablated: 18 failed, 7 passed of 25. Red: every add, change and removal pin, the draft add, the clone-refused half, the in-process door, 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), full answering ITEM_LOCKED, and both CONTROLs.
  • Restored: blob equals the HEAD blob 5ce2896165, git diff HEAD empty.

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, including check-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-contract was red (exit 1) until the new test's pinned doubles were recorded (--write: 3 rows added, 0 lost) in 6bbc5475cc; re-run exit 0.
  • NOT MEASURED: check:dual-build-cjs-loads and check: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 --ran reconciliation: 75 derived families accounted for, 75 run.
  • Lint: pnpm exec eslint --no-inline-config --format json over the 5 touched TypeScript files at 6bbc5475cc: 5 files, 0 errors, 0 warnings. Population: eslint.config.mjs lints **/*.ts minus its NEVER_LINTED list, and none of the 5 is ignored (no "file ignored" warning). Invariance: the config enables no type-aware linting (no parserOptions.project, stated at its own :327), so this diff cannot move a verdict on an untouched file. The repo-wide pnpm lint is CI's.

Narrowings (declared)

  • After about an hour of os-verify-lock queue 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.
  • No showcase boot: the readings are taken at the protocol door the REST routes forward to. packages/qa/dogfood (install-local-unmanaged-mode.dogfood.test.ts saves 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-0087 not-required (no-migration-prescription). Level: the WHICH LEVEL rule in pr-automation.yml grades a widening minor and a fix that changes no public surface patch; this narrows a published accept set, which is a breaking change, and check-changeset-no-major.mjs states that during the launch window a breaking change ships minor (the grade #23096's entry took for the same door). The arm: clause2-line.mjs defines no (narrowing) as "NOT a widening, but breaking", which is this diff. No @objectstack/spec entry: its prose is not changed. The changeset supersedes one sentence of the pending 23096-save-door-protection entry, its "A body without protection saves 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 pending 15213-* entry becomes false.

Acceptance notes

  • Deviations from the file surface. The rule needs helpers: LOCK_FAMILY_KEYS (derived from MetadataProtectionFields, so this file adds no hand-kept list beside the one item-lock.ts already keeps), lockFamilyOf, sameLockFamily, withLockFamily and authoredLockFamilyRefusal sit beside protectionBlockRefusal, and the private authoredLockFamily sits just above saveMetaItem; the arm itself gains one call. Outside protocol.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), and scripts/engine-double-contract.pinned.json (the new test's doubles).
  • Cost. Every author-arm save makes one more indexed sys_metadata head read (the target row, plus the active row for a first draft), with no try/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).
  • Boundary: the layered code layer is not an echo source. For a package that declares protection.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).
  • A malformed _lock* value gets the parse's 422 first and this door's 400 only once it is well-formed. Accepted to keep the unmanaged import's refusal unchanged.
  • Existing state is untouched. A row (or a pending draft) that already stores an author-written lock keeps it, and publishing such a draft still promotes it: unbinding is the maintainer's (triage 6135754247), and a reported stuck item is the ADR-0010 §3.8 card.

Generated by Claude Code

…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>
…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>
…'s pinned doubles

Claude-Session: https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 13, 2026
@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 10 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/metadata-protocol/src/item-lock.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json e540e7ffb9b9a9cfc9897722c2e04941e9924a8e.

⛔ 9 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/metadata-protocol/src/item-lock.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json e540e7ffb9b9a9cfc9897722c2e04941e9924a8e → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 950976fc84ad7b1254c5a9b737ea919d2cb826a1 — the merge of head 010639549d199247daa802e778966a86f29f6c62 into base e540e7ffb9b9a9cfc9897722c2e04941e9924a8e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs e540e7ffb9b9a9cfc9897722c2e04941e9924a8e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6bbc5475cc5ce38a6b367c52eaf0f35a6358ed6c
Local-runs: none

Inputs: card #23119 (body; comments 6135754247 triage, 6136862148 unlock, 6136889453 claim, 6138569909 dev report); PR #23167 (body, 7-file list, net diff against origin/main, merge-base f931e765b7, 714 added / 13 removed, six commits); the check-runs on the head; ADR-0010 (docs/adr/0010-metadata-protection-model.md) and packages/spec/src/shared/protection.zod.ts on origin/main; #23096's record 6135514431 on PR #23115; and the rule texts the brief names (pr-automation.yml WHICH LEVEL, clause2-line.mjs, check-adr-0087-registration.mjs, check-changeset-no-major.mjs). Read-only: git reads of the fetched ref refs/review/pr-23167 and REST reads; nothing built, run or re-run.

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 judgments

The direction: right. authoredLockFamily is called once, inside the author arm (request.source === undefined && request.writeFace !== 'package-duplicate'), on the line after protectionBlockRefusal; the else arm (withoutProtectionBlock) is byte-identical, so source and package-duplicate writes keep stamping (pinned for both faces, and the stamp survives author saves on both author faces). Three outcomes, as triage binds: no _lock* key, and withLockFamily(body, stored) writes the row's stored family back onto the body (a carry-forward, not a removal; the fail-open the dev measured on base, a stamped no-delete lost to the next author save, is closed and pinned); an echo, accepted, and the body still gets the stored family, so the row keeps its own; anything else, authoredLockFamilyRefusal, nothing written. No silent strip: the only key removal is of a family equal to the served one on a row that stores none (the packaged first overlay), which is "ignored", not "stripped". No stored lock is unbound: every path through the method writes stored onto the body or throws. stripUnmanagedImportLock and importUnmanagedPackage are not in the diff. Position against the gates: the L3 gate assertLockAllowsWrite runs above the arm, so full and no-overlay still answer 403 ITEM_LOCKED before any family judgement (pinned); the per-type parse and the destructive diff run below it.

The echo test: the served set, no wider and no narrower. Traced on the head. The active item read (getMetaItem) serves its body through mergeArtifactProtection(..., itemLock), which is withItemLockFamily(item, resolveItemLock({ artifact: artifactLockLayerAt(address), overlay: overlayLockLayerAt(address, { otherSpelling: true }) })); the layered read's effective and the list (readFlattenedMetaItems) serve the same family. That is echo source 1, computed in the arm with the same two layer readers and otherSpelling: true. The draft read (state: 'draft') returns the draft row's body verbatim (it returns before the overlay-lock read), the previewDrafts arm returns the draft row verbatim (convertStoredItem of the row), and the layered overlay layer is the row as stored: echo source 2, the row the save writes, read through repo.headAt(ref, { state, packageId: args.packageId ?? null, member }), the same write-address resolution storedHeadAt uses one call later for the version lock, with the first-draft fallback to the active row at target.packageId (the inherited binding); rowToItem hands the raw stored body, so the compare is byte-faithful. The layered code layer is the one served document outside the set (the dev's declared boundary, flag 6). A family copied from another item passes only when it is equal in value to one of this item's two families, in which case it is indistinguishable from an echo and is ignored (the row keeps its own); the clone pin covers the other case (a stamped family on an unlocked clone target: refused, no row). The packaged first overlay: the pin has the row storing none and getMetaItem still answering lock: 'no-delete', deletable: false through the artifact layer, so the package's lock keeps binding. One asymmetry, not a defect: previewDrafts serves the draft row while a publish-mode save compares against the active row, so a verbatim echo of a legacy draft whose family differs from the active row's is refused on a publish-mode save; that save would change the active row's lock, refusal is the rule, the message carries the remedy, and the population is rows written before this change.

The early return: right, and it never stores. For each sent key whose declared schema (overlaySchemaDeclaredKey, the walker protectionBlockRefusal uses) refuses the value, the body is returned unchanged. The per-type parse at the bottom of saveMetaItem (resolveOverlaySchema(request.type, request.item).safeParse) runs for every face in both modes with no force or draft exemption, and _lock* is declared wherever MetadataProtectionFields is spread, so the same value fails there and the save throws 422 INVALID_METADATA before the put (four values pinned, no metadata write). A malformed-but-schema-passing value cannot exist: the arm's check and the parse apply the same declared schema to the same value. A type with no schema (external_catalog) skips the early return and is judged (pinned). LOCK_FAMILY_KEYS, derived from MetadataProtectionFields, is exactly the four keys item-lock.ts hard-codes (_lock, _lockReason, _lockSource, _lockDocsUrl); the PR body's "no third hand-kept list" is off by one (item-lock keeps the second), prose only.

The faces: each judged right; none is server-stated in fact. meta-envelope (the REST PUT, built field by field) and meta-dispatch: the author's body, judged, both pinned across every case. No face: the flow save (automation.ts stores "the definition as the caller's door built it"), judged, right; the flow and permission-set clones delete every key of MetadataProtectionFields before saving, so they send no family; the permission-set data door saves getMetaItemLayered().overlay (the stored row verbatim) into that row's own package (storedRowPackageArg), echo source 2; the permission-set and position backfills and the position write-through build their bodies from data rows (permissionSetBodyFromRow, positionBodyFromRow), no family; the package publish's app flip re-saves the list's served body with packageId: id, echo source 1. external-import: the body is server-built, and no file under packages/services/service-datasource/src mentions _lock; carry-forward only. package-unmanaged-import: stripUnmanagedImportLock removes all four keys when every present one parses, and hands a body with a non-parsing one on untouched, where the arm's early return leaves it to the parse's 422, as before; the import writes with mode: 'publish' into names no row holds, so a stripped body carries nothing forward. No door the dispatch names carries a server-stamped lock through the author arm, so nothing legitimate is refused or dropped.

The envelope: right. 400 VALIDATION_ERROR, issues[] one entry per sent key with code: 'custom' (the MetadataIssueEntry shape), the family and position of protectionBlockRefusal; resolveThrownHttpError carries issues for any status (established in 6135514431). The message prescribes first ("Remove ... and send it again. Nothing was written."), names the effect (set, change, remove), the ADR-0010 clause and the one door (a code package's protection, Phase 4.3), and stays well under the 500-character REST bound for ordinary names. Nit, prose only: a lone prose key on an unlocked item (_lockReason alone) is described as "would set a lock on it"; it sets no lock, and the refusal is still right.

The edited tests: faithful re-pins. protocol.package-publish-audit-rows.test.ts: the five fixtures lock the ACTIVE row after staging drafts; they now do it with source: 'migrate-stored', force: true, the call shape migrateStoredMetadata itself makes on the head, and every assertion (batch abort, rollback, denied rows) is untouched; auditedBefore is snapshotted after the fixture, so the fixture's own audit rows never enter a count. protocol-derived-provenance-doors.test.ts: the old pin stored an author-written family verbatim; the new pair keeps the intent it was written for (the three derived keys go and the family is not stripped: a stamped family survives an author echo of the derived keys) and adds the refusal with nothing written. No weakening.

item-lock.ts: prose only. The diff replaces two docblock lines with six; ITEM_LOCK_LAYERS, resolveItemLock, firstBindingLayer, resolveOverlayLockLayer, resolveArtifactLockLayer and withItemLockFamily are byte-identical, and the amended sentence states 6135754247's reading.

② Semver level

@objectstack/metadata-protocol: minor: right. The act narrows a published accept set (a body the door stored now answers 400), so it is breaking and not the "changes no public surface stays patch" case; check-changeset-no-major.mjs states that in the launch window a breaking change ships minor, the **BREAKING** banner and the ADR-0087 disposition carry the breaking-ness, and the level axis stands down on a no declaration. Clause-②: no (narrowing) is the arm clause2-line.mjs defines as "NOT a widening, but breaking", which is this diff. No @objectstack/spec entry: that package's text is not in the diff.

not-required (no-migration-prescription): right. The gate refuses the category only where the changeset's body prescribes a rewrite of a consumer's code or metadata; this remedy is a request value at one runtime door, the FROM / TO / Remedy shape 6135514431 accepted for 23096-save-door-protection and five stock entries. A ledger entry would be wrong, not merely unneeded: no stored row is rewritten, and an ADR-0087 conversion replays over package sources, where _lock* is the loader's legitimate stamp. Check Changeset and the lint family that runs the gate are green on the head.

③ Boundary flags

  1. BLOCKING: a same-release contract contradiction this PR's changeset must supersede, the class 6135514431 flag 1 named on the sibling. .changeset/23096-save-door-protection.md sits in origin/main's unreleased stock and states, under "Unchanged": "A body without protection saves as before." At this head a body without protection that carries an authored _lock* family answers 400 VALIDATION_ERROR (this PR's own FROM and TO). Both bodies ship verbatim into one CHANGELOG.md release of @objectstack/metadata-protocol, so the release contradicts itself on one input. The PR body's claim that "No sentence of a pending 15213-* or 23096-* entry becomes false" is wrong on this sentence. Fix, one sentence in .changeset/23119-save-door-lock-envelope.md, in the house form the sibling itself uses: "This entry supersedes one sentence of the entry 23096-save-door-protection, in this same release", stating that its "A body without protection saves as before" now holds for a body that also sends no _lock* key or echoes the served family, while a body whose family would set, change or remove the lock is refused as this entry states. Nothing else moves. The 15213-* entries stay true: the unmanaged install still strips a parseable family, and a non-parsing one still meets the parse's 422.
  2. Base f931e765b7 instead of 0d43004100: the merge-base against origin/main is f931e765b7, one spec/ui commit past the claim's tree; the touched surface is identical and the diff applies cleanly against today's main. Not blocking.
  3. Hunks beyond the arm: five module-level helpers beside protectionBlockRefusal, one private method above saveMetaItem, one import; the arm gains one call. All in protocol.ts, all needed by the rule. Not blocking.
  4. Existing test files edited: both relied on the author face storing a lock, the behaviour this card reverses; neither is one of test: the stage-8 test half — suites stop depending on the four catalog row objects (#15204 stage 8, S8-T) #22962's files; the ledger json is gate-prescribed (three rows, none lost). Not blocking.
  5. Measurement at the protocol door rather than a showcase boot: the REST PUT, GET and DELETE forward to the three methods measured, the arm sits in the protocol, and Build Core and Dogfood Regression Gate are green. Not blocking.
  6. The two noted findings: rows and pending drafts that already store an author-written lock keep it, and publishing such a draft still promotes it: that is the triage's own line (this card unbinds nothing; a stuck item is the §3.8 card). A body copied from the layered code layer with _lock: 'none': refused loudly with a one-step remedy; the layer is raw by another lane's ruling and is not a Studio round-trip source. Neither blocking. Same class, noted for the owning seat's list: restoreVersion and rollbackMetaItem reach put without the arm, so a historical body written before this change can bring its author-written family back; existing state, no new door.
  7. Dev deviations, each answered: Clause-②: no (narrowing) on the PR's line 2 against the claim's bare no: right, the arm is what clause2-line.mjs asks of a breaking non-widening (②); the verify-lock narrowing and the one re-run: process, no effect on the diff.
  8. One Test Core shard (4/6) was still running at the poll above; the landing waits on it whatever this verdict says.

Adopted by domain:engine seat 2 at 2026-10-13T05:23Z, after a tool-use audit of the isolated reviewer's transcript: reads, git fetch into a review ref, and one record write. It made no GitHub write and ran no build, test or gate. The seat re-read the blocking item before adopting it: .changeset/23096-save-door-protection.md on origin/main says under Unchanged "A body without protection saves as before." The verdict holds. The dev adds the supersession sentence in a patch round, and a delta record runs on the new head.

Implemented-by: claude/issue-23119-save-door-lock-envelope
Reviewed-by: session_01ADzJtzYTLUfgrRZHxkagkX

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 010639549d199247daa802e778966a86f29f6c62
Local-runs: none

Delta record after the FAIL 6138876344 on 6bbc5475cc. Inputs: card #23119 (body; comments 6135754247 triage, 6136862148 unlock, 6136889453 claim, 6138569909 and 6139133868 the dev reports for round 0 and patch round 1); PR #23167 (its seat-edited body, the 7-file list, the net diff against the merge-base f931e765b7 with origin/main at e540e7ffb9, the delta 6bbc5475cc..010639549d, its comments: the prior record and the docs-drift comment); the check-runs on the head; .changeset/23096-save-door-protection.md and the four .changeset/15213-*.md entries on origin/main. Read-only: git reads of the fetched ref refs/review/pr-23167-r1 and REST reads; nothing built, run or re-run. The delta is re-judged in full; the rest of 6138876344 is confirmed on this head, not re-derived.

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 judgments

The delta is what the dev reports, and nothing else. git diff 6bbc5475cc..010639549d: three files, +5/-2. .changeset/23119-save-door-lock-envelope.md: one paragraph and its blank line, inserted between the envelope paragraph and FROM. protocol.ts: one line in authoredLockFamilyRefusal, where the servedLock === 'none' branch of the effect ternary now reads (sentLock === 'none' ? 'add lock keys to it' : 'set a lock on it'). protocol.save-door-lock-family.test.ts: the add pin's expected effect becomes family._lock && family._lock !== 'none' ? 'set a lock on it' : 'add lock keys to it', plus one comment line. No other hunk: item-lock.ts, the two re-pinned test files, the ledger json and every other line of protocol.ts are byte-identical to 6bbc5475cc, so every ① judgment of 6138876344 (the arm's position after protectionBlockRefusal inside the author arm with the else arm untouched, the echo set of exactly two families, the early return to the parse's 422, the faces, the envelope, the faithful re-pins, item-lock.ts prose only) holds on this head unchanged.

The supersession paragraph: true at this head, in the house form, naming the sentence exactly. Form: it is the skeleton 23096-save-door-protection itself uses to supersede 15213-unmanaged-lock-strip, clause for clause (the bold lead "This entry supersedes one sentence of the entry …, in this same release.", then "That entry says …", "What holds now: …", "Where the two entries disagree, this one is current."), at the same place in the entry (after the explanatory paragraph, before FROM). Quotation: "A body without protection saves as before" is the first sentence of the sibling's Unchanged bullet, character for character, backticks included, and "under its unchanged behaviour" names that bullet. Truth against the code: a body with no _lock* key passes the arm through withLockFamily(body, stored), so it is accepted as before; a family equal to the stored row's (repo.headAt at the save's state, package and member, the active row for a first draft) or to the resolved served family (resolveItemLock over artifactLockLayerAt and overlayLockLayerAt with otherSpelling: true) passes; any other family throws authoredLockFamilyRefusal, 400 VALIDATION_ERROR, before put. "Sends back exactly the _lock* family a read of that item serves" covers both echo sources, since the draft read and the layered overlay layer serve the stored row and the item read, the layered effective and the list serve the resolved family. Two readings the paragraph leaves to the entry, neither a falsity: "saves as before" is the sibling's sense, accepted rather than refused at this door (the row now keeps a stamped family that the old door dropped, and this entry's FROM and TO state both halves); and the second clause names the set, change and remove class, while a non-binding family on an unlocked item (a lone prose key, _lock: 'none') is refused too, under the first clause's "only when" and the entry's "Anything else is refused". A family with a value the declaration refuses meets the parse's 422 before and after, which the entry's Unchanged bullet states and the paragraph's "as this entry states" points at.

No other sibling sentence turns false, unsuperseded. Read every sentence of 23096-save-door-protection and of the four pending 15213-* entries on origin/main (unmanaged-lock-strip, install-modes, unmanaged-import-verb, readonly-package-remedy). Three of the 15213-* entries moved between the merge-base and e540e7ffb9 (#23126 landing: their stale supersession paragraphs and the "no door calls it yet" sentence were rewritten in place); the texts judged here are the current ones, and 23096-save-door-protection is unchanged. 23096: "A packaged item's lock still binds … 403 ITEM_LOCKED … before this refusal is asked" holds, since assertLockAllowsWrite runs above the arm (pinned: full, stamped, answers ITEM_LOCKED to a body with and without _lock: 'none'); "A row stored before this change keeps its bytes" holds, since no row is rewritten; its own supersession of 15213 holds, since protectionBlockRefusal runs before authoredLockFamily (a block beside a refused _lock* still meets the 400) and a refused _lock* value with no block still meets the 422 through the arm's early return (pinned, four values, no write). 15213-unmanaged-lock-strip: "writes every item without _lock, _lockReason, _lockSource and _lockDocsUrl" holds (stripUnmanagedImportLock and importUnmanagedPackage are not in the diff); "Refusals are unchanged … 422 INVALID_METADATA" holds for its _lock* half (a value the declarations refuse is handed on, and the arm returns the body untouched to the parse), and the import writes a stripped body into names no row holds, so it never meets the new 400; "A managed install is unchanged" holds (the loader's stamp is not a save through the arm). 15213-unmanaged-import-verb's "the next save edits it" holds: the copy stores no family, and an author save with no family, or with an echo, passes. install-modes and readonly-package-remedy state nothing this door touches.

The new wording: true for every input that reaches it; the envelope is unchanged. authoredLockFamilyRefusal is reached only with a non-empty sent unequal to both the stored and the served family. servedLock is served._lock ?? 'none', and withItemLockFamily writes no _lock key for a non-binding answer, so servedLock === 'none' is exactly "the item serves no lock". There: sent._lock absent (a lone prose key) or 'none' reads "add lock keys to it", which is what accepting the body would do and no more; 'full', 'no-overlay' or 'no-delete' reads "set a lock on it". On a type with no schema (external_catalog) sent._lock may be any value: one that is neither absent nor 'none' reads "set a lock on it" (the right refusal, the verb approximate), and null reads "add lock keys to it". The binding branches are untouched: a served lock met by 'none' or by a body with no _lock reads "remove its lock" (accepting it would remove it), anything else "change its lock" (the partial echo that drops the prose included, pinned). Only the effect string moves: code, status, issues[] (one custom entry per sent key, the key as path), the prescription-first message, the ADR-0010 clause and the one door are the same bytes. The pin asserts the same split over the five families on both author faces and keeps the "row stores none" check; the probe, the draft add, the in-process door, external_catalog and the clone pins still expect "set a lock on it" for a binding _lock, which the code gives, and the stamped-item pins still expect "change" and "remove", which are untouched.

② Semver level

Unchanged from 6138876344, and the delta gives no reason to move it: @objectstack/metadata-protocol: minor (an accept-set narrowing is breaking; check-changeset-no-major.mjs ships a breaking change minor in the launch window, the grade 23096-save-door-protection took for the same door), the **BREAKING** banner, Clause-②: no (narrowing) (the arm clause2-line.mjs defines as not a widening but breaking, which this diff is), and the ADR-0087 marker not-required (no-migration-prescription) (the remedy is a request value at one runtime door; no stored row and no package source is rewritten, and a conversion would be wrong, not merely unneeded). The supersession paragraph prescribes no rewrite and names no ADR-0087 id, so the disposition's category is unchanged. No @objectstack/spec entry: that package's text is not in the diff. Check Changeset is green on the head, twice.

③ Boundary flags

  1. The prior FAIL's blocking item: resolved. 6138876344 flag 1 asked for one supersession sentence in .changeset/23119-save-door-lock-envelope.md, in the sibling's house form, naming "A body without protection saves as before". The head carries it (bc55905aae), judged true, in form and exact in ①; .changeset/23096-* and .changeset/15213-* are untouched, as the claim required. The same-release contradiction is closed.
  2. The PR body's two corrected passages: true. Changeset section: "The changeset supersedes one sentence of the pending 23096-save-door-protection entry, its "A body without protection saves 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 pending 15213-* entry becomes false." Matches ①. Acceptance notes: "LOCK_FAMILY_KEYS (derived from MetadataProtectionFields, so this file adds no hand-kept list beside the one item-lock.ts already keeps)". protocol.ts at this head keeps no literal _lock* list, LOCK_FAMILY_KEYS filters Object.keys(MetadataProtectionFields) on the _lock prefix, which yields exactly the four keys, and item-lock.ts:325 keeps the one four-key list the sentence names. The optional third passage ("a family that binds nothing … is described as adding lock keys rather than setting a lock") is present and true.
  3. restoreVersion / rollbackMetaItem: still non-blocking. On the head rollbackMetaItem runs assertLockAllowsWrite and then repo.restoreVersion, which reaches put without saveMetaItem's author arm, so a history body stored before this change with an author-written family comes back with it. The L3 gate still runs first, no new door opens, the card's surface is the author arm only, and triage 6135754247 keeps unbinding and existing state with the maintainer; the dev's round-1 report carries it as a noted finding with this seat as carrier. Not blocking.
  4. Base movement. The merge-base is still f931e765b7; origin/main is at e540e7ffb9, 15 commits on, none touching protocol.ts, item-lock.ts, package-unmanaged-install.ts, the spec's protection files or 23096-save-door-protection.md; the three 15213-* edits are prose that ① judged in its current form. The dev reports a clean merge-tree, and the API reports the PR mergeable. Not blocking.
  5. Flags 2 to 7 of 6138876344 (the base, hunks beyond the arm, the edited test files and ledger, measurement at the protocol door, the two noted findings, the dev deviations) are unchanged by a delta that touches none of them.
  6. Two Test Core shards (3/6 and 4/6) were still running at the poll above; the landing waits on them whatever this verdict says. The round-1 dev report ran 19 of the 75 derived families locally and named the 56 left unrun; the check-runs on the head cover those families and are the verdicts here.

Adopted by domain:engine seat 2 at 2026-10-13T05:56Z, after a tool-use audit of the isolated reviewer's transcript: reads, git fetch into a review ref, and one record write. It made no GitHub write and ran no build, test or gate. This record supersedes the FAIL 6138876344 for landing purposes; that record stays as history.

Implemented-by: claude/issue-23119-save-door-lock-envelope
Reviewed-by: session_01ADzJtzYTLUfgrRZHxkagkX

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 13, 2026 05:58
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 13, 2026 05:58
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 13, 2026
Merged via the queue into main with commit aa5bfdf Oct 13, 2026
48 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23119-save-door-lock-envelope branch October 13, 2026 06:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants