Skip to content

fix(metadata-protocol): one item-lock resolution for the _lock gate, both reads and the served body (#21738) - #21759

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21738-lock-one-resolver
Oct 4, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-21738-lock-one-resolver

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21738
Clause-②: no

What was wrong

The ADR-0010 _lock gate of the write doors (getEffectiveLock, behind save, publish, rollback and delete) resolves an item's lock from two layers: the packaged artifact's _lock unless it is 'none', then the stored sys_metadata row's. The two item reads did not use that resolution. Each derived the lock its own way, and neither matched the door on the artifact layer:

  • Position 1. mergeArtifactProtection copied any declared artifact _lock over the stored row's, 'none' included. A packaged view whose artifact declares 'none', under a stored row declaring 'full', read editable: true on both reads while the door refused the save with ITEM_LOCKED.
  • Position 2. getMetaItemLayered (GET /api/v1/meta/:type/:name/layers) read the lock off code ?? overlay. A packaged view with no _lock under a stored 'full' read lock: 'none' there, while getMetaItem and the door said locked.

Triage's direction 5980131521 (verbatim in the dispatch): one resolution, three callers. The door's semantics stand, and the reads follow the door.

H1: every place an item's lock was derived, at ff29410ed2

site caller artifact layer stored layer rule
getEffectiveLock the _lock gate (save, publish, rollback, delete, package publish's promotion) lookupArtifactItem(canonical type, name), no package findServedOverlayRow (canonical spelling, no package, org-gated) artifact unless 'none', else row, else 'none'
mergeArtifactProtection served body of getMetaItem, getMetaItems list items, registry hydration, runtime view-container expansion the caller's artifact the item's own _lock artifact's _lock whenever defined ('none' too), else the item's
getMetaItem envelope servedLockState(document) via the merge above (package-scoped lookup) the served row, or a registry / MetadataService item the merged document's _lock
getMetaItemLayered envelope servedLockState(code ?? overlay ?? {}) the code layer (MetadataService copy, else artifact, else runtime-only registry item) only when there is no code layer the code layer's _lock
getMetaDiagnostics locked count servedLockState(list item) via the list's merge the list item the merged list body's _lock
servedLockState the three above none of its own none of its own joins the given document's lock with packagedBaseRefusal

packages/rest/src and packages/runtime/src derive no lock: both pass the protocol's envelope through (git grep of resolveLockState, extractProtection, _lock and the envelope flags). The one out-of-package consumer of a read's lock-adjacent fields, plugin-security's packaged permission-set gate, reads getMetaItemLayered's code._packageId, not its lock.

What changed

Landing spot: packages/metadata-protocol/src/protocol.ts, as the claim predicted, plus a new module beside it. No packages/spec/src/** path is touched.

  • item-lock.ts (new, not exported from the package entry). ITEM_LOCK_LAYERS = ['artifact', 'overlay'] is the precedence and the parameter set. resolveItemLock(layers) returns the first layer whose declared _lock is not 'none', with that layer's prose, else 'none' with no prose. resolveItemLockLazily(readers) asks the same function after each layer is read and stops at the first binding layer.
  • The door (getEffectiveLock) reads its two layers through resolveItemLockLazily, so the store read still happens only when the artifact does not bind. Its overlay read moved, unchanged, into readLockGateOverlayLayer. Refusal code, status, text and source= are byte-identical.
  • getMetaItem hands the resolver the artifact it already looked up and the stored body of the row findServedOverlayRow already served. It keeps that body even when the row is not adopted (a shipped flow name, metadata: the by-name flow read serves a stored row's body under the shipping package's provenance for a shipped flow name, so it disagrees with the flow list, which serves the loader's body #20946), because the gate binds it. No extra store read.
  • getMetaItemLayered hands it lookupArtifactItem (a registry read) and the served row's stored body. code ?? overlay ?? {} is no longer a lock source. It still feeds provenance / packageId / packageVersion, unchanged. A row-less name that a stored container expands contributes no overlay layer, because the gate does not read the container's row.
  • servedLockState takes the resolver's answer and derives editable / deletable from it, joined with packagedBaseRefusal as before. It reads only provenance / packageId / packageVersion off the document. No third derivation.
  • mergeArtifactProtection copies the artifact's _lock* family only when resolveItemLock says the artifact's lock binds. Its _packageId / _packageVersion / _provenance copies are unchanged (H3).
  • The diagnostics count resolves the same way, over the list's artifact lookup and the listed document. That is still a registry read, never a store read.

Measured: the door does not move, and the reads now follow it

The census used the real ObjectStackProtocolImplementation over an engine double, on a view. It covered 216 cells: 2 topologies × 6 artifact states (absent, no _lock, each of the 4 levels) × 9 stored states (none, or an env-wide / org-scoped row at each level) × 2 request scopes. Each cell read getMetaItem, getMetaItemLayered, the save door and the delete door.

  • Before (ff29410ed2 code): 36 split cells, 18 per topology. 9 are position 1 (artifact 'none' under a binding stored lock), and 9 are position 2 (artifact with no _lock under one).
  • After: 0 split cells.
  • Door columns: identical in all 216 cells, before against after. So no door verdict changed.
  • getMetaItem changed in 18 cells (position 1 only). getMetaItemLayered changed in 36 (positions 1 and 2).

H3, every other field. A second probe ran at the merge base and at this branch on the same 216 cells. It dumped getMetaItem's envelope and served body, getMetaItemLayered's envelope and its code / overlay / effective / overlayScope / _diagnostics, and the getMetaItems list item. With the lock family masked (lock, lockReason, lockSource, lockDocsUrl, editable, deletable and the body's _lock*), all 216 dumps are byte-identical. The lock family differs in 54 cells:

  • 18 position-1 cells (both reads, the body and the list item now carry the stored lock);
  • 18 position-2 cells (getMetaItemLayered only);
  • 18 explicit-'none'-artifact cells where nothing binds. There the explicit 'none' artifact's prose is no longer reported on the envelope, and its _lock* fields are no longer copied onto a stored row's body.

H4: residue census (position 3), and why the fallback stays

H5: the acceptance pin, generated from the resolver's inputs

protocol.lock-one-resolution.test.ts:

  1. 1632 generated rows. The axes are: artifact (6) × stored row (none, or env-wide / org-scoped × 4 levels × canonical / other spelling: 17) × request scope (2) × topology (2) × request spelling (view / views) × operation (save / delete). The axes are grouped under LAYER_AXES, keyed by ItemLockLayer, so a layer added to ITEM_LOCK_LAYERS fails the typecheck. The completeness check also fails the run, naming the layer, and it asserts resolveItemLock.length === 1 and that every axis is used exactly once. Each row runs on one protocol instance. Both reads must agree, both must equal an oracle written from the rule (iterating ITEM_LOCK_LAYERS), and the door must admit exactly when the envelope says editable / deletable. Rows stored under the other spelling assert the declared difference instead (the reads see the residue row and the door does not), so they flip by name when the fallback retires.
  2. PR fix(metadata-protocol)!: the ADR-0010 _lock gate reads the row the read serves for the request's organization (#21716) #21737's 64 rows are folded in, with none lost. A test rebuilds their 64 titles and asserts each is a row of this table. They and their lit control moved here out of protocol.lock-org-axis-agree.test.ts; that file keeps its pins 2 to 4.
  3. Position 1, named. On both topologies: both reads say full, with editable and deletable false and lockReason from the stored row. Provenance is still the artifact's. The served body, the list item and the diagnostics tile say full. Save and delete are refused ITEM_LOCKED / 403 with lock: 'full'.
  4. Position 2, named. On both topologies: getMetaItemLayered says full like getMetaItem, its code layer is still the package's (no _lock), and the door refuses.

The #5840 outage pin (protocol.metadata-store-outage.test.ts) had a "healthy" leg. That leg's locked artifact existed only in the MetadataService double, never in the registry the gate reads. It now declares the artifact in the registry, package-stamped, as a loader does. Its outage leg is unchanged and still answers 503.

Reverse verification

Both legs ran from committed HEAD db49dc4d2fab (the protocol.ts blob), with scripts/ablation-replace.mjs in wrap mode. They ran inside a script carrying an absolute-path trap restore from HEAD. The subject resolves from source (./protocol.js), so no rebuild was in the path. Each direction was declared first.

  • Leg A: mergeArtifactProtection restored as a lock source. The copy became unconditional, and getMetaItem took its lock from the merged document again. Anchors 1 to 0 and markers 0 to 1, twice; blob db49dc4d2fab became cb753a719d8b, then decca0d87278. Predicted 146 red / 1494 green. Measured 146 failed / 1494 passed: pin 2 on both topologies, plus all 144 pin-1 rows with an explicit 'none' artifact under a served stored lock. Pin 3 stayed green.
  • Leg B: the layered read's code ?? overlay ?? {} restored. Anchor 1 to 0 and marker 0 to 1; blob db49dc4d2fab became bdd63140c660. Predicted 292 red / 1348 green. Measured 292 failed / 1348 passed: pin 3 on both topologies, plus the 288 pin-1 rows whose artifact declares no _lock or 'none' under a served stored lock. Pin 2 went red too, because its layered half was the same derivation.
  • Restore, both legs: blob equal to the HEAD blob db49dc4d2fab, git diff HEAD empty and git status --porcelain empty, checked by the tool and by the trap.

Tests (all at 60232c3d17, after merging origin/main ea7ff394b6)

  • @objectstack/metadata-protocol: vitest run. 213 files passed, 3 skipped. 5249 tests passed, 19 skipped. That is base 3675, plus 1640 new, minus 66 moved. tsc --noEmit is green, and --listFiles compiles item-lock.ts and the new pin.
  • @objectstack/objectql against the rebuilt metadata-protocol dist: --project local gives 372 files, 7458 tests passed, and --project repo gives 1 file, 5 tests passed. The dist was built from ae121eac22; protocol.ts and item-lock.ts are unchanged since.
  • Narrowed lint: eslint --no-inline-config --format json over the 5 changed TS files gives 5 file results, 0 errors and 0 warnings. The population was read from eslint's own config: isPathIgnored is false for all 5, and each computed config has 5 or 6 rules. Invariance: no computed config sets parserOptions.project or projectService, and eslint.config.mjs never enables type-aware linting, so this diff cannot move a verdict on an untouched file.
  • NOT MEASURED: HTTP (reach is on doubles); rest / runtime / plugin-security / plugin-email / client suites, which are consumers with no export or wire-shape change (their lock-field assertions were grepped and use fakes); CI-owned Dogfood, Temporal Conformance, type-check lanes and the full pnpm lint.

Gates (at 60232c3d17; exit codes captured before any pipe)

  • node scripts/pm/dispatch-gates.mjs --commands (no paths) derived 72 families: 72 run, all exit 0. --ran reports "72 derived famil(ies) accounted for — 72 run, 0 NOT-MEASURED". check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET) and exited 0 after a full turbo build.
  • Artifact-roster block (54): 51 exit 0. The 3 PR-context guards (check-closing-target-claim, check-partof-closing-keyword, check-single-claim-paths) exited 2, NOT WIRED, before this PR existed; they are re-run with this PR's context in the report.
  • The four symbol-anchor sweeps exit 0: adr-symbol-anchors 2167 anchors across 140 records; scripts-symbol-anchors 3760 across 282 scripts; spec-docblock-symbol-anchors 4950 across 1868 sources; adr-anchors OK.
  • check:engine-double-contract asked for the new pin's findOne double, and its own --write recorded it: the ledger gained one findOne entry for the new file, and lost none.
  • check:durability-log-level: 70 read seams before and after. The --depth-cost census is identical apart from line numbers and one enclosing function's name (getEffectiveLock became readLockGateOverlayLayer).

Acceptance notes (not filed here)

  • The package axis, now measured on the double. A read naming a package (?package=, ADR-0048 prefer-local) serves that package's row, while the gate asks package-agnostic (findOne with no package_id). The gate therefore binds whichever row the driver returns first. With a package-less row and a package A row of one (type, name, scope), 2 of the 3 arrangements probed (which row declares the lock, and which row the driver returns first) split read from door. This is row selection, not the resolution, and this PR leaves it unchanged; it is reported to the seat.
  • A lock only a copy the doors never read declares. A MetadataService copy the dev watcher reloaded after boot, or a package-less runtime registration, used to read locked while the doors admitted the write. It now reads as the doors answer, and the changeset says so.

Generated by Claude Code

claude added 6 commits October 4, 2026 14:22
…both reads and the served body (#21738)

getMetaItem, getMetaItemLayered, the getMetaDiagnostics locked count and
mergeArtifactProtection now take the item's ADR-0010 lock from
resolveItemLock (new item-lock.ts), the rule getEffectiveLock already
enforced: the first layer in ITEM_LOCK_LAYERS order (artifact, overlay)
whose declared _lock is not 'none' binds. The door reads its layers
through resolveItemLockLazily, so it still reads the store only when the
artifact does not bind.

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…from the item-lock resolution's layers (#21738)

protocol.lock-one-resolution.test.ts: 1632 generated rows (artifact x
stored row x request scope x topology x request spelling x operation),
a completeness check keyed by ITEM_LOCK_LAYERS, PR #21737's 64 rows
checked as a subset (moved out of protocol.lock-org-axis-agree.test.ts),
and positions 1 and 2 as named cases. The #5840 outage pin's healthy leg
now declares its locked artifact in the registry, the layer the gate reads.

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…ine-double ledger (check:engine-double-contract --write) (#21738)

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

github-actions Bot commented Oct 4, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 23 documentable anchor(s).

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), getMetaItemLayered (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/data-modeling/drivers.mdx (via getMetaItem (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/kernel/contracts/metadata-service.mdx (via getPublished (sdk, the bare tail of client method meta.getPublished, bound to GET /api/v1/meta/:type/:name/published; the bare tail of client method meta.getPublished, bound to GET /meta/:type/:name/published))
  • content/docs/kernel/services-checklist.mdx (via getMetaItem (symbol, a method of class ObjectStackProtocolImplementation))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-1.mdx (via getMetaDiagnostics (symbol, a method of class ObjectStackProtocolImplementation))
  • content/docs/releases/v17/17-3.mdx (via /:type/:name/published (route, bridged from symbol getMetaItemLayered — its route source's handler names it))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • 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 c7a60e1c3fba0f429fa9fcb0d440da1751bdd011 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from d69cfde9b9560e3f49783f102469425467e3d196 — the merge of head 60232c3d179a1892b82daae80bb6011e0e78c8d9 into base c7a60e1c3fba0f429fa9fcb0d440da1751bdd011, 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 d69cfde9b9560e3f49783f102469425467e3d196 && git checkout d69cfde9b9560e3f49783f102469425467e3d196
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c7a60e1c3fba0f429fa9fcb0d440da1751bdd011 60232c3d179a1892b82daae80bb6011e0e78c8d9 && git checkout -B drift-repro c7a60e1c3fba0f429fa9fcb0d440da1751bdd011 && git merge --no-ff 60232c3d179a1892b82daae80bb6011e0e78c8d9

node scripts/docs-audit/affected-docs.mjs --json c7a60e1c3fba0f429fa9fcb0d440da1751bdd011

⚠️ 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 c7a60e1c3fba0f429fa9fcb0d440da1751bdd011 → 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

ACCEPT — PR #21759 at head 60232c3d17

domain:engine#1 · session_017ErfyP2Rx7XWHJA27QjyUi · read at 2026-10-04T16:07Z. The os-dev report is on #21738. Judged against GitHub and the branch, not against the report.

  • Shape: draft, base main, assignee os-project-manager.

    • The first lines are Fixes #21738 and Clause-②: no.
    • The closing-keyword scan finds #21738 only.
  • Scope: 7 files, +828/-122:

    • a new item-lock.ts, not exported from the package entry;
    • protocol.ts;
    • a new generated pin;
    • the 64-row pin moved out of the org-axis file, which keeps its pins 2 to 4;
    • the outage fixture;
    • the engine-double ledger, through the gate's own --write;
    • the changeset.

    NOT governed. No packages/spec/src/** file is touched. A local git merge-tree against origin/main is clean.

  • The diff, read: one resolution, as ruled.

    • resolveItemLock takes the first layer in ITEM_LOCK_LAYERS (artifact, overlay) whose declared _lock is not 'none'.
    • getEffectiveLock reads its layers lazily, so a binding artifact still costs no store read.
    • getMetaItem and getMetaItemLayered pass in the artifact and the served row's body they already hold.
    • mergeArtifactProtection copies the lock family only when the artifact binds.
    • code ?? overlay is no longer a lock source.
    • servedLockState and the diagnostics count read the same answer.
    • ⛔ No third derivation.
  • The census closes positions 1 and 2, and moves no door.

    • The 216 cells are topology × 6 artifact states × 9 stored states × request scope. They held 36 read-versus-door splits before and 0 after.
    • The door columns are byte-identical in all 216, so Clause-②: no holds.
    • Every non-lock field of both reads and the list is byte-identical. The lock family differs in exactly the 54 cells the changeset names.
  • Position 3 census:

  • The acceptance pin:

  • Reverse verification: both legs ran from committed HEAD with a trap restore.

    • Leg A (mergeArtifactProtection as a lock source): 146 red, as predicted, all of them explicit-'none' rows.
    • Leg B (layered code ?? overlay): 292 red, as predicted.
    • The restores were proved by blob equality and empty diff and status.
  • The fixtures: the outage test's healthy leg now declares its locked artifact in the registry, because the old fixture's lock was never visible to the door. That completes a declaration and loosens nothing; no expect line was removed.

  • Changeset, checked sentence by sentence:

    • patch, Clause-②: no.
    • Position 1 (an explicit 'none' over a stored lock now reads locked, on the served body, the list and the count too) matches the 18 cells.
    • Position 2 (the layered read takes the stored lock) matches the 36 layered cells.
    • "lockReason / lockSource / lockDocsUrl are the binding layer's, absent when none binds" matches the explicit-'none' cells.
    • "A _lock only a copy the doors never read declares is no longer reported" matches the outage fixture's change and the dev-watcher note.
    • "Unchanged" (every door verdict, code and text; provenance and package fields; row selection; the declared spelling difference) matches the census.
  • Evidence: metadata-protocol passes 5249 tests (3675 + 1640 new − 66 moved). objectql passes 7458 against the rebuilt dist. Typecheck is green.

  • Gates:

    • dispatch-gates --ran: 72 of 72 exit 0.
    • The roster's 54 families exit 0 together with the 3 PR-context guards with this PR's context, and so do the four symbol-anchor sweeps.
  • CI: read by the seat at landing.

Out-of-scope findings:

  • Filed by the seat (class b, a named producer, measured on the real reads and gate over an engine double): the package axis. With one package-less row and one package A row of the same item, a read naming package A serves A's row, while the gate's overlay read selects without a package. 2 of 3 arrangements split. This is row selection, which the resolver does not see, so its completeness check cannot. It is filed as the family's package-axis card.
  • Acceptance note, not filed: the dev watcher's reloaded MetadataService copy. No production producer is measured.

Generated by Claude Code

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