Skip to content

fix(metadata-protocol): the read envelope's lock / editable / deletable report the write doors' locked-base verdict - #21693

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21670-layered-lock-flags
Oct 4, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-21670-layered-lock-flags

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21670

Clause-②: no

What was wrong

Both metadata reads publish the ADR-0010 protection envelope beside the item: getMetaItem, and getMetaItemLayered, which serves GET /api/v1/meta/:type/:name/layers and its deprecated ?layers=true spelling. That envelope was resolveLockState(document), built from the item's own _lock and nothing else.

The write doors also refuse a second kind of item: one a code package ships, on a type with no per-org overlay channel. They answer 403 NOT_OVERRIDABLE, or ITEM_LOCKED when the write names the read-only package. So packaged flows and actions read lock: "none", editable: true and deletable: true while every door refused them in place. So did the packaged items of 21 other types.

Trace

  • Producer. packages/metadata-protocol/src/protocol.ts has two call sites of resolveLockState, one in getMetaItem and one in getMetaItemLayered. The spec declares the fields once, in MetadataProtectionEnvelopeFields (packages/spec/src/api/protocol.zod.ts), and both responses share them.

  • The doors' predicate. packagedBaseRefusal is public on the same class. It holds refusePackagedBaseOverride and refusePackagedBaseRemoval, both lifted out of saveMetaItem / deleteMetaItem, and the /automation doors already ask it. It does not depend on topology:

    • on an environment kernel, the protocol throws the refusal;
    • on a host-config kernel (the showcase), SysMetadataRepository.assertAllowed / assertDeleteAllowed throws the same refusal at the write.

    The table below measures both kernels. They agree on every type.

The change

One private derivation, servedLockState, which both reads now call:

  • editable is the _lock verdict AND packagedBaseRefusal(save) === null.
  • deletable is the _lock verdict AND packagedBaseRefusal(delete) === null.
  • lock is the ADR-0010 state whose evaluateLockForWrite / evaluateLockForDelete verdicts are exactly those two booleans. It is read off the lock algebra, so there is no second table. The spec's declared algebra therefore still holds: editable is false iff lock is no-overlay or full, and deletable is false iff lock is no-delete or full.
  • An item's own _lock and the package verdict join; neither replaces the other.
  • lockReason, lockSource and lockDocsUrl are unchanged: they are still only what the document declares.
  • No write door changes.

Spec wording. The lock field's .describe() said it refuses "with 403 ITEM_LOCKED" and is "Resolved from the document's _lock". This change makes both statements false for package-door locks, so the description now names both refusals. This is wording only: no key, type, optionality or accept set moves (Clause-② no). check:generated --fix regenerated content/docs/references/api/protocol.mdx, one line.

Beyond the claimed file surface: the spec description edit and its regenerated reference line, and scripts/engine-double-contract.pinned.json, where the gate records the new test's engine double.

Measured: every registry type, both kernels

Packaged item with no _lock. Each read was none / true / true before this change. The environment kernel and the host-config kernel gave identical rows for all types (0 differ).

types save door delete door read now (lock / editable / deletable)
view, dashboard, report, translation, email_template admitted admitted none / true / true (unchanged)
page, app, dataset, book, permission, position, tool, skill 403 NOT_OVERRIDABLE admitted (the supportsOverlay overlay-removal carve-out) no-overlay / false / true
object, field, hook, seed, picklist, mapping, action, flow, job, datasource, external_catalog, api, doc, capability, agent 403 NOT_OVERRIDABLE 403 NOT_OVERRIDABLE full / false / false

An org-owned item is a stored row that no package ships. For the 22 types that can be created at runtime, both doors admit it on both kernels, and it reads none / true / true (unchanged).

Pins

The new file packages/metadata-protocol/src/protocol.read-lock-flags-write-door.test.ts holds 107 cases.

  • Packaged flow and action: lock is not none, editable: false, deletable: false, on both reads and both kernels.
  • Org-owned flow and action: read none / true / true.
  • The table: 100 rows, made of 28 types × 2 kernels for packaged items plus 22 × 2 for org-owned items. Each row asserts three things: read equals door, the spec's algebra holds, and the by-name read equals the layered read.
    • On the environment kernel the door is saveMetaItem / deleteMetaItem, end to end. An admission counts only if it reached the _lock gate and that gate answered null.
    • On the host-config kernel the door is the repository gate. An admission means the engine was touched, which only happens past the gate.
  • Lit control:
    • the roster equals DEFAULT_METADATA_TYPE_REGISTRY, with a floor of 28;
    • each verb was both refused and admitted on each kernel;
    • all three lock states appeared.
  • Controls:
    • with OS_METADATA_WRITABLE=flow,action, the read is none / true / true and the door admits;
    • a packaged view with _lock: 'no-delete' reads no-delete, and its delete door refuses with ITEM_LOCKED;
    • a packaged flow with _lock: 'no-delete' reads full, which shows the two verdicts join rather than replace.

Evidence

  • Red first. At 515955b905 (the pin alone, on main's code): 50 failed / 57 passed of 107. The failures read expected 'none' not to be 'none' and expected { editable: true, deletable: true } to deeply equal { editable: false, deletable: false }.

  • Green with the fix: 107 / 107.

  • Ablation. With the fix committed, scripts/ablation-replace.mjs replaced return { ...declared, lock, editable, deletable }; with return declared;.

    • The mutation landed: anchor count 1 → 0, marker 0 → 1, blob c462702ad973 → f61b76d75c58.
    • Result: 50 failed / 57 passed, read from the vitest summary line.
    • Restored by blob hash: c462702ad973 equals the HEAD blob, and git diff HEAD is empty.
    • A first attempt was a no-op: its replacement still contained the anchor, so the tool refused it and no test ran.
  • @objectstack/metadata-protocol. tsc --noEmit exits 0, and the new test is in the program (--listFiles count 1). vitest run: 210 files, 3570 passed, 19 skipped.

  • Consumer sweep (downstream readers of the envelope that drive a real protocol or pass it through):

    package files result
    objectql src/protocol-* 39 612 passed
    plugin-security permission-set and packaged tests 13 191 passed
    rest src/meta-* 49 1077 passed
    runtime src/domains/meta-* 19 1048 passed
  • Gates. dispatch-gates --commands at 017a2c7599 derives 120 commands. --ran reconciles 118 run, all at exit 0, and two not measured. Both are declared to CI:

    • check:dual-build-cjs-loads exits 3: it needs every package built. This diff adds no module to any entry's import closure: MetadataLockSchema comes from @objectstack/spec/kernel, which protocol.ts already imports.
    • check:type-check-debt hit the foreground cap. It re-measures the DEBT-ledger packages repo-wide. metadata-protocol has no DEBT entry and its own tsc is clean, and the spec edit is describe text only.
  • Re-run at the final head 017a2c7599:

    • check:engine-double-contract (the new double is recorded in the pinned ledger)
    • check:objectql-double-limit
    • check:nul-bytes
    • check:cross-package-test-inputs
    • check:test-source-alias
    • check:type-check-coverage
    • check:durability-log-level
    • the changeset gates
    • spec check:generated: "All 15 generated artifacts are up to date"

Acceptance notes

  • Host-config DELETE. On a host-config kernel, a DELETE of a packaged flow or action that has NO overlay row still answers 200 "No customization overlay found ... already at artifact default". That is the reset door's no-op, and nothing is removed. deletable: false is the base-removal verdict, which the predicate's own docblock states for every topology, so the two do not contradict. On an environment kernel the same DELETE answers 403 NOT_OVERRIDABLE.
  • Who sees the change. The Setup metadata admin's resource page (objectui ResourceEditPage) reads layered.editable, deletable and lock.
    • Packaged items in the two locked groups will now draw its lock banner. Their write affordances were already off, by type (canWriteByType).
    • The no-overlay group keeps its reset / delete button (deletable is true).
    • Studio's designers read GET /api/v1/packages writable, which is unchanged.
  • Observation, not filed: _lock on host-config. On a host-config kernel the item-level _lock gate (lockWriteRefusal, assertLockAllowsDelete) returns no refusal while environmentId is undefined. So an overlay-type item that declares _lock reads editable: false there, while the /meta save admits it. The read is stricter than the door. This PR leaves it alone, because the doors' policy is outside this card. No shipped producer was found: the protection.lock declarations in platform-objects are all on object, which the package door refuses anyway. Carrier: none.
  • Observation, not filed: diagnostics count. getMetaDiagnostics().stats[type].locked counts declared _lock only, not package-door locks. Carrier: none.
  • Boundary: code-only types. field, picklist, job, api, capability and agent have no org-owned arm in the table. No runtime door can author one (NOT_CREATABLE), and that refusal is not the locked-base predicate. Their packaged arm is in the table.
  • Not merged with main. origin/main has moved 2 commits since 7d0781482d, touching the organizations plugin and sdui-parser. Both are disjoint from this diff.
  • No live boot. The REST layered door passes the protocol's answer through unchanged (createMetaLayeredAnswer spreads it), so the protocol pin and the REST / runtime sweep stand in. No showcase boot was run.

Generated by Claude Code

claude added 4 commits October 4, 2026 05:26
…he write doors, every type (red on main)

The ADR-0010 envelope both metadata reads publish says lock none, editable
true, deletable true for a packaged flow or action that every write door
refuses in place. This pin measures the read against the doors themselves
(saveMetaItem / deleteMetaItem on an environment kernel, the repository gate
on a host-config kernel) for every type in the registry, with a lit control.
It fails on main: 50 of 107 cases.

Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn
Co-authored-by: Claude <noreply@anthropic.com>
…te doors' locked-base verdict

Both metadata reads resolved lock / editable / deletable from the document's
own _lock alone, so an item a code package ships on a type with no overlay
channel (flow, action, object, …) read lock none, editable true and deletable
true while every write door refused it in place. One derivation now joins the
_lock verdict with packagedBaseRefusal, the predicate the /meta and
/automation doors already share, and both reads call it. lock is read back
off the ADR-0010 lock algebra, so the envelope keeps its declared shape.

The lock field's spec description named only _lock and ITEM_LOCKED; it now
names both refusals it reports. Wording only: no key, type or accept set moves.

Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn
Co-authored-by: Claude <noreply@anthropic.com>
…n; add the changeset

Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn
Co-authored-by: Claude <noreply@anthropic.com>
…le-contract ledger

Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m 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 2 package(s): @objectstack/metadata-protocol, @objectstack/spec, touching 8 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))

⛔ 3 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-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
  • 1 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 — 139 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 251a7dd4b491d1f216e8a470d0efd0dc7e8ac5e8 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 251a7dd4b491d1f216e8a470d0efd0dc7e8ac5e8

⚠️ 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 251a7dd4b491d1f216e8a470d0efd0dc7e8ac5e8 → 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: 017a2c7599cb7946e118e2e8600acc5b5432c9bd
Local-runs: none

Inputs read: #21670's body, triage 5976009143, claim 5976826510, the dev report 5977264927 (read as claims), PR #21693's body, its 6 files and its diff, and the head's check-runs. Read-only: nothing was built, run or re-run.

Gates at review time: 18 completed success, 2 skipped (Console Pin Gate, Packed-tarball smoke, both opt-in), and 12 still in_progress: Lint & Repo Gates, Test Core 1/6 to 6/6, Type Check · workspace, Type Check · consumer gates, Dogfood Regression Gate 2/3 and 3/3, Temporal Conformance. Those 12 have no verdict yet. This review does not stand in for them, and the merge waits for them.

① Derived judgments

servedLockState: same predicate, no second table. Holds.

  • It takes declared = resolveLockState(document, artifactBacked), which is the _lock verdict, unchanged.
  • editable is declared.editable && packagedBaseRefusal({type, name, operation: 'save'}) === null, and deletable is the same with 'delete'.
  • packagedBaseRefusal turns the throws of refusePackagedBaseOverride / refusePackagedBaseRemoval into a value. saveMetaItem and deleteMetaItem call those same two functions at their package doors, and the /automation doors already ask packagedBaseRefusal.
  • So the predicate is asked, not copied, and no type list or policy table is added.
  • The read omits packageId. That does not change the boolean: a named read-only base re-codes the refusal to ITEM_LOCKED, and a named writable base still falls through to NOT_OVERRIDABLE. The verdict is refused either way, and only the code differs.

lock is read off the ADR-0010 algebra. Holds.

  • MetadataLockSchema is a plain z.enum of none, no-overlay, no-delete and full.
  • evaluateLockForWrite refuses no-overlay and full. evaluateLockForDelete refuses no-delete and full.
  • The four states map one-to-one onto the four (editable, deletable) pairs, so find always returns the one state whose verdicts equal the booleans.
  • The spec's invariants therefore hold by construction: editable is false iff lock is no-overlay or full, and deletable is false iff lock is no-delete or full.
  • lockReason, lockSource, lockDocsUrl and resettable pass through unchanged. That matches their declarations: "present only when declared", and "true iff artifact-backed".

The unreachable throw is sound. It can fire only if the algebra stops covering a pair, and then it fails loudly instead of publishing a lock that disagrees with the booleans.

  • Non-blocking: the comment says a state added without an answer must fail here. In fact an added state gets null from both evaluators, matches none's pair, and find returns none first. So the throw guards a pair left uncovered, not an addition. Either way the published invariant cannot break.

Both reads call it.

Published values are true to their doors. I checked against DEFAULT_METADATA_TYPE_REGISTRY at the head.

  • The save door refuses when the item is artifact-backed and isOverlayAllowed is false: 23 types. The delete door adds the mergesOverlayAtRead carve-out, so it refuses 15 of them.
  • 15 types read full / false / false. These are object, field, hook, seed, picklist, mapping, action, flow, job, datasource, external_catalog, api, doc, capability and agent. Each has allowOrgOverride false and supportsOverlay false, so both doors refuse.
  • 8 types read no-overlay / false / true. These are page, app, dataset, book, permission, position, tool and skill. Each has allowOrgOverride false and supportsOverlay true, so save refuses and the A legacy env overlay on an artifact-backed item of a rolled-back type can no longer be REMOVED through the ordinary delete path (403) — only via OS_METADATA_WRITABLE #6960 overlay-removal repair is admitted.
  • 5 overlay types are unchanged: view, dashboard, report, translation and email_template.
  • 15 + 8 + 5 = 28, the registry roster.
  • Org-owned items are unchanged. They are not artifact-backed, so packagedBaseRefusal answers null and the read is exactly resolveLockState as before.
  • The OS_METADATA_WRITABLE hatch folds into isOverlayAllowed, so the read opens with the door, and a control pins this.

packages/spec change: wording only. Confirmed.

  • In protocol.zod.ts, the expression lock: MetadataLockSchema.optional() is untouched. Only the .describe() string and the module-local TSDoc above MetadataProtectionEnvelopeFields change.
  • No key, type, enum member, optionality or accept set moves. editable, deletable and resettable are byte-identical.
  • The regenerated protocol.mdx line equals the concatenated new describe string. No other committed copy of the old sentence remains at the head.
  • Spec property liveness and Build Docs are green.
  • Non-blocking: the TSDoc says the _lock join produces all three verdicts, but resettable is not joined (it stays artifactBacked). It is imprecise, not false.

engine-double-contract.pinned.json: legitimate.

  • The file is GENERATED, and it is the coverage ledger, which may only grow.
  • The one added row is (protocol.read-lock-flags-write-door.test.ts, findOne, 1), in sorted position.
  • The new test's findOne doubles route through assertEngineFindOnePredicate.
  • It is additive, and no row is removed. The gate that owns this file (Lint & Repo Gates) is still in_progress.

The pin test: not weakened.

  • 107 cases: 4 flow and action cases (2 kernels × 2 arms), 100 table rows (28 × 2 packaged, 22 × 2 org-owned), 1 lit control and 2 controls.
  • It compares the read to the doors, never to the predicate.
    • On the environment kernel, the door is saveMetaItem / deleteMetaItem end to end. An admission counts only if the spied _lock gate was reached once and answered null.
    • On the host-config kernel, the door is SysMetadataRepository.put / delete. That is an independent implementation, and an admission must reach the engine.
    • Each row also asserts the spec algebra, and that the by-name read equals the layered read.
  • Red first. The claimed 50 of 107 is consistent: 2 flow and action cases, 46 table rows (23 types × 2 kernels), the lit control's three-state set, and the _lock join control.
  • Ablation. return declared; reproduces the same 50, and the fix is restored by its blob hash.
  • Lit control:
    • the roster equals the registry, with a floor of 28;
    • refusals and admissions both appeared, for both verbs on both kernels;
    • all three lock states appeared.
  • Non-blocking: the _lock controls run on the environment kernel only. That leaves the host-config _lock asymmetry out of the pin, consistently with its being filed separately rather than hidden.

② Semver level

Correct.

  • @objectstack/metadata-protocol patch. A published read value is corrected to the refusal the server already enforces. No door, key or accept set changes.
  • @objectstack/spec patch. Describe and TSDoc text only. No schema shape moves.
  • Clause-②: no is right. The claim's own reasoning ("no accept set and no published key moves") holds at the head, and the stop condition (a spec shape that must change) was not met.
  • The observable change is narrower. Clients gating affordances on editable / deletable now hide writes the server already answers with 403. That is a bug fix, and the changeset says so.
  • Check Changeset is green. The changeset-no-major gate falls under Lint & Repo Gates, which is still in_progress.

③ Boundary flags

Stop valve: the seat's ruling A is upheld.

  • The valve stops the dev only if the declared shape must change. It did not.
  • The old describe ("refuse ... with 403 ITEM_LOCKED", "Resolved from the document's _lock") became false the moment the fix landed.
  • Option B would publish a false field description for the time between landings. Option C would publish it indefinitely. Both contradict "declared is enforced".
  • Keeping the wording edit in the same changeset is the correct reading.

Out-of-scope findings, filed as #21694 (open, bug, domain:engine). Both are carried accurately.

Deviations.

  • The file surface is wider than the claim. Three files are added beyond it:

    • packages/spec/src/api/protocol.zod.ts (describe and TSDoc);
    • content/docs/references/api/protocol.mdx (one generated line);
    • scripts/engine-double-contract.pinned.json (a gate-written row).

    Each is the mechanical consequence of the in-scope change: a describe made false, its generated twin, and the ledger row for the new double. None widens behaviour. The single-writer, same-issue, card-claims-branch and Governed Surface Queue Guard checks are green.

  • The PR assignee was not set, because the auto-mode classifier refused it. That is the seat's housekeeping, not a contract matter.

  • Not run by the dev: a live showcase boot, check:dual-build-cjs-loads and check:type-check-debt, all declared to CI. Type Check · debt ledger has since gone green, and Build Core is green.

  • Main has moved since merge-base 7d0781482d. No commit on main touches the three code and ledger files in this diff.

Implemented-by: claude/issue-21670-layered-lock-flags
Reviewed-by: session_016tKoy8NJa35Yih1FdzrVmn

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 4, 2026 06:30
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 4, 2026 06:30
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 4, 2026
Merged via the queue into main with commit fe10172 Oct 4, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21670-layered-lock-flags branch October 4, 2026 07:35
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…dy`, including one with neither a `body` nor a `handler` (objectstack-ai#21689) (objectstack-ai#21706)

Fixes objectstack-ai#21689
Clause-②: no (narrowing)

This carries out triage's ruling on objectstack-ai#21689 (comment 5977077883, unlocked
in 5977495602): **one predicate. The metadata save door refuses any hook
with no `body`**, with a named error and the prescription "give it a
`body`". The handler-only refusal that objectstack-ai#21658 landed (PR objectstack-ai#21686,
`ced217ca30`) is now one case of it. `HookSchema` is untouched.

## What changes

`runtimeHookWithoutBodyRefusal` in
`packages/metadata-protocol/src/protocol.ts` keeps its name, its one
call site in `saveMetaItem` and its one envelope. Its predicate widens
from "a non-empty `handler` string and no `body` object" to "no `body`
object". It is the same judgement install-local's
`collectHooksWithoutBody` makes on its own door (`hookCarriesBody` in
`packages/runtime/src/app-artifact-handlers.ts`).

- **Envelope:** `VALIDATION_ERROR` / 400, unchanged. No code is added
and the ledger is not edited.
- **When and where:** unchanged from objectstack-ai#21658. It runs in draft and in
publish mode, after the type-schema parse (a malformed `body` keeps the
schema's located 422), and before the authoring gate and every write.
- **Message, two readings of one rule:**
- With a `handler` that names a function, the message is byte-identical
to the one objectstack-ai#21658 shipped. It names the hook and the function.
- With neither field, or an empty `handler`: "Invalid hook: 'NAME'
carries no `body`, so it has nothing to run. Give it a `body` (sandboxed
JS, `{ language: 'js', source }`, or an expression), which is stored
with the hook. A hook saved through the metadata API ships with no code
package, so its `body` is the only code it can run."
- Length: the handler form is 499 characters with 65-character names, as
before. The neither form is 282 characters plus the hook name, well
under the 500-character REST bound.
- **What still saves:** a hook with a `body`, with or without a
`handler` beside it.

## The PM's mechanism hypotheses, measured (at `1db5322ba2`, then rerun
on the merged head `15402d98f8`)

| # | Hypothesis | Reading |
|:--|:--|:--|
| H1 | Widen the existing predicate, one function, one call site, one
code. | **Holds.** One early return removed, and the message chosen by
whether `handler` names a function. The measured messages read right for
both shapes (quoted above; the sweep's failure output below shows the
neither text verbatim as the door produced it). |
| H2 | Five suites use a bare hook as their schema-valid probe; report
any others. | **Holds, with two more.** With the predicate widened and
every probe untouched, the full `metadata-protocol` suite went 7 failed
/ 3461 passed (4 files), and the full `objectql` suite went 6 failed /
7446 passed (3 files). The five named suites, plus `metadata-protocol`
`protocol.save-receipt-wording` and `objectql`
`metadata-validation-sweep`. A grep of `runtime`, `rest`, `plugins/*`,
`services/*`, `cli`, `verify`, `qa` and `examples/**` for hook saves
through this door (`type: 'hook'` items, `/meta/hook` paths) found only
`runtime`'s two pin tests, which already carry bodies. See the fixture
triage below. |
| H3 | `migrateStoredMetadata` and `duplicatePackage` surface a stored
bare row as their recorded failure; stored rows keep their bytes. |
**Holds.** Measured with a one-off harness that is not committed (the
stub engine of `protocol.stored-residue-resave.test.ts`).
`duplicatePackage` over a package holding one bare hook row and one body
hook row answered `{ success: false, copiedCount: 1, failedCount: 1 }`
with this refusal in `failed[0].error`, and the source rows' bytes were
unchanged. `migrateStoredMetadata({ apply: true })` over a bare row
answered `{ scanned: 1, canonical: 1, rewritten: 0, failed: 0 }` with
the bytes unchanged. **Population of stored bare hooks reachable from
this repository: zero.** `examples/**` and `packages/qa/**` seed no
`sys_metadata` hook rows (zero `type: 'hook'` items). |
| H4 | A built artifact's `handler` hook never passes `saveMetaItem`. |
**Holds at `1db5322ba2`.** Every production call site either saves a
fixed type other than `hook` (`automation.ts` and
`flow-credential-migration.ts` for flow, `packages.ts` for app,
`permission-set-projection.ts` for permission) or forwards an author's
or a stored row's type. The forwarding callers are the REST `PUT
/meta/:type/:name` (`rest-server.ts`), the dispatcher's metadata save
(`domains/meta.ts`), `migrateStoredMetadata` and `duplicatePackage`.
`AppPlugin`, `loadArtifactBundle`, the install-local door and the boot
path make zero `saveMetaItem` calls. The composed pin's X and Z controls
hold it end to end. |
| H5 | No first-party authoring path emits a hook with neither field
through this door. | **Holds for what this repository holds.** `os meta
register` forwards the author's own file and emits no hook of its own.
The `os` create and scaffold templates author `defineStack` sources,
which reach the artifact door and never this one. `packages/mcp` has no
hook tool. Studio is at the objectui pin `ab18797215`, unchanged since
objectstack-ai#21658's census, which read its hook skeleton carrying a `body`. **Not
measured:** the cloud AI build agent's metadata tools, which live
outside this repository. |

## Fixture triage: seven probe suites move to a body-carrying hook

Each probe item gains `body: { language: 'js', source: 'return;' }` and
nothing else. No probe is deleted, and no assertion is changed or
loosened.

| Suite | What the probe measures | Still measures it |
|:--|:--|:--|
| `metadata-protocol` `protocol.code-only-types` (2 probes) | The objectstack-ai#5086
code-only gate does not catch `hook` (`allowRuntimeCreate` only) on
either kernel; the objectstack-ai#5264 matrix shows a hook save answers a repository
receipt. | Yes: `success: true` and one row on both kernels, and the
receipt fields. |
| `metadata-protocol` `protocol.meta-types-mint-door-agreement` | `PUT
/meta/hook` behaves as `/meta/types` advertises (declared, creatable),
read off one fact. | Yes: both cases are green. |
| `metadata-protocol` `protocol.unrecognised-meta-type` | A declared
runtime-create-only type still saves past the unrecognised-type refusal.
| Yes. |
| `objectql` `overlay-precedence` (3 probes: `hook`, plural `hooks`,
single-kernel bypass) | The two-tier verdict: brand-new items of
`allowRuntimeCreate` types pass the overlay whitelist, and a single
kernel bypasses the overlay gate. | Yes. |
| `objectql` `protocol-meta` (3 probes) | The PR-10d.7 two-tier model:
an artifact-backed hook is refused `NOT_OVERRIDABLE` / 403; a brand-new
hook and an edit of a DB-only hook are accepted. | Yes. The
`NOT_OVERRIDABLE` probe was green before the move too, because the
provenance gate runs first. It moved anyway, so that the refusal it
measures can only be the provenance gate's. The registry-seeded items
there are scenery for provenance, not saves through the door, and keep
their bytes. |
| **Beyond the five:** `metadata-protocol`
`protocol.save-receipt-wording` (`OVERLAYLESS_PROBES.hook`) | The
receipt sentence for a brand-new overlay-less type. | Yes. |
| **Beyond the five:** `objectql` `metadata-validation-sweep`
(`FIXTURES.hook`) | Valid gets 200; invalid gets the schema's 422 naming
`events`. | Yes. The invalid fixture carries the body too, so it stays
the valid fixture minus the one field the schema must name. |

## The enumeration pin, on the real door (ADR-0112: each refusal asserts
`code` and `status`)

Composed kernel,
`packages/runtime/src/hook-handler-package-scope.pin.test.ts`, through
`PUT /api/v1/meta/hook/NAME` as the signed-in administrator:

| Pin | Case | Asserts |
|:--|:--|:--|
| 1. A `body` hook saves. | ②b (existing) | 2xx; it binds and runs, and
so does one with both fields. |
| 2. A handler-only hook is refused. | ② (existing) | 400
`VALIDATION_ERROR`, naming the hook and the function and prescribing a
`body`; GET by name answers 404. |
| 3. A hook with neither field is refused. | **②c (new)** | 400
`VALIDATION_ERROR`, naming the hook and prescribing a `body`; GET by
name answers 404. |
| (2 and 3) Nothing bound. | **"② and ②c nothing bound" (widened)** |
After ②b's re-sync, the binder recorded no refusal of either hook and no
skip of the bare one (`skipsOf`, a new reader of the engine logger's
`skipping hook` warns). |
| 4. A built artifact's `handler` hook through its own door is
unchanged. | X and Z controls (existing) | App X's hook naming its own
`functions` entry binds and runs. App Z's hook naming a function its
`--artifact` runtime module exports binds and runs. |

At unit level, section 8 of
`protocol.invalid-metadata-422-face-inventory.test.ts` is new. It covers
publish and draft with neither field, and an empty `handler`. Each case
asserts `code`, `status`, the named hook, the prescription and an empty
store. Section 7 (objectstack-ai#21658) is unchanged and still green.

## Reverse verification (the fix committed first, at `0c32c32bb1`)

**Mutation.** `node scripts/ablation-replace.mjs` restored the old
handler-only guard after the body test: `typeof hook.handler !==
'string' || hook.handler === ''` returns `undefined`, behind a marker
constant `ABLATED_21689_NEITHER`. The anchor count went 1 to 0 and the
blob went `3059168d5703` to `237d2559c48b`.
`@objectstack/metadata-protocol` was rebuilt, and `node
scripts/ablation-dist-preflight.mjs @objectstack/metadata-protocol
ABLATED_21689_NEITHER` found the marker in `dist/index.js` and
`dist/index.cjs`. A shell `trap` restore on EXIT, INT and TERM wrapped
the whole run.

The first attempt was a **no-op**, and it is disclosed here. Its
replacement re-contained the anchor line, so `ablation-replace` refused
it ("the anchor count moved 1 to 1"), ran nothing and proved the
restore. The second attempt re-spelled the body test (`typeof hook.body
=== 'object' && hook.body`, the same truth table), so the anchor left
the file.

**Prediction:** pin 3 red, and pins 1, 2 and 4 green. **Observed:**

- **Unit (src), sections 4 to 8:** 3 failed, all three section 8 cases
(publish neither, draft neither, empty `handler`). 15 passed, including
all of section 7 (handler-only refused in both modes, the body CONTROL,
body beside handler, malformed body 422).
- **Composed (dist):**
- ②c failed. It received `{ status: 200 }` with `Saved hook
'scope_authored_bare' (env-wide, state=active)`.
- "② and ②c nothing bound" failed. `skipsOf('scope_authored_bare')` held
3 binder skips, which also proves the new reader fires.
  - ① ✓, X control ✓, Z control ✓, ② ✓ and ②b ✓: 2 failed, 5 passed.

**Restore.**

- `ablation-replace` restored the file. The blob equals HEAD
(`3059168d5703`) and `git diff HEAD` is empty. The outer trap's hash
compare agreed.
- Whole-tree `git status --porcelain` is empty.
- After a rebuild, the `--absent` preflight found the marker in none of
the 24 built files, and the tree was clean.
- The reruns are green: 26/26 (face inventory) and 7/7 (composed).

## Tests (at `15402d98f8`, after merging `origin/main` `8843505d91`,
which carries PR objectstack-ai#21693)

- `pnpm --filter @objectstack/metadata-protocol exec vitest run
--maxWorkers=2`: 210 files passed and 3 skipped; 3578 tests passed and
19 skipped.
- `pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2
--project local`: 372 files and 7455 tests passed.
- Runtime `hook-handler-package-scope.pin.test.ts` and
`stored-metadata-body-boundary.pin.test.ts`: 14/14.
- Typecheck, exit 0 for all three packages:
- metadata-protocol `tsc --noEmit`. Its program includes all five edited
test files (`--listFiles`, one hit each).
- objectql and runtime: `tsc` plus `check:test-typecheck`, OK, with the
debt ledgers held. Their test layers compile under `tsconfig.test.json`
(`include: src/**/*`).
- Dependency closure: `pnpm turbo run build
--filter='@objectstack/runtime^...' --concurrency=2`, 29/29.
`packages/spec` moved on main's side, so `pnpm --filter
@objectstack/spec check:generated` also ran: all 15 generated artifacts
are up to date.
- The rest of `packages/runtime`'s suite is declared to CI.

## Gates (at `15402d98f8`)

Every family below ran first at `623b4a0b94` and ran again in full on
the merged head `15402d98f8`. The figures are the second run's.

**Derived.** `node scripts/pm/dispatch-gates.mjs --commands` (no paths)
derives 66 families, the same list on both heads, and all 66 ran.

- All 66 exited 0. That includes `check:dual-build-cjs-loads`: 106
published require entry points across 66 packages load. On the first
head it had exited 3, PREREQUISITE NOT MET, for want of a full build.
- `--ran` reconciliation: 66 accounted for, 66 run, 0 NOT-MEASURED (a
derived zero), 0 UNRUN.

**Artifact-roster block** (54 families, outside the derived total). All
54 ran.

- 51 exited 0. These include `check:error-status-conformance`,
`check:error-code-casing`, `check:authz-resolver`,
`check:route-ledger-census`, `check-changeset-fixed` and
`check:engine-double-contract`.
- 3 exited 2, NOT WIRED without PR context:
`check-closing-target-claim`, `check-partof-closing-keyword` and
`check-single-claim-paths`. They are rerun with this PR's context, and
the results go in the os-dev report.

**Symbol-anchor sweeps**, all exit 0:

- `check:adr-symbol-anchors`: 2167 anchors across 140 records.
- `check:scripts-symbol-anchors`: 3760 anchors across 282 scripts.
- `check:spec-docblock-symbol-anchors`: 4867 anchors across 1861 spec
sources.
- `check:adr-anchors`: OK.

**Lint.** CI owns `pnpm lint`. This PR records a proven narrowing
instead:

- **Population:** `eslint.config.mjs` lints `files:
['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']`.
- **Count:** `eslint --no-inline-config --format json` over the 10
changed TS files reports 10 files, 0 errors and 0 warnings.
- **Invariance:** the config enables no type-aware linting (no
`parserOptions.project`), so this diff cannot move the verdict of any
untouched file.

## Changeset

`.changeset/21689-hook-no-body-save-door.md`: `minor` for
`@objectstack/metadata-protocol`, `Clause-②: no (narrowing)`, the
BREAKING banner, and the ADR-0087 marker `not-required
(no-migration-prescription)` with the census above.
`check-adr-0087-registration --base origin/main` accepts it.

## Landing point

As the claim predicted: `packages/metadata-protocol/src/protocol.ts`,
`runtimeHookWithoutBodyRefusal` (`saveMetaItem`, type `hook`), plus the
seven probe suites. No producer elsewhere needs a change. No governed
surface is touched. PR objectstack-ai#21693, which edits the read region and the
import block of `protocol.ts`, landed on main before this PR opened. It
is merged in here; the merge was clean, and this diff touches neither
region.

## Acceptance notes

- **The draft-promotion and restore doors still do not re-ask this
rule.** objectstack-ai#21658's PR recorded this for the `handler` form, and it now
covers the neither form too. `publishMetaItem`, `rollbackMetaItem` and
`revertCommit` can make a draft or a history version stored before this
change into an active bare row, which the runtime skips at re-sync as
before. Carrier: none.
- **`os meta register hook --data FILE`** forwards the author's file
through this door, so a bare hook file now gets this 400 and the CLI
prints its message. It needs no change of its own.
- **The binder's skip line** reads `skipping hook with unresolved
handler` for a hook that has no `handler`. No stored row of that shape
can be minted through this door any more. Noted, not filed.

---
_Generated by [Claude
Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…ad serves for the request's organization (objectstack-ai#21716) (objectstack-ai#21737)

Fixes objectstack-ai#21716
Clause-②: no (narrowing)

## What was wrong

The two item reads (`getMetaItem`, and `getMetaItemLayered`, which
serves `GET /api/v1/meta/:type/:name/layers`) resolve a stored row by
precedence: the organization's own row, else the env-wide row
(ADR-0005). The ADR-0010 `_lock` gate's overlay limb
(`getEffectiveLock`) asked for one row only: `organization_id` equal to
the request's organization. So when an organization had no row of its
own and the env-wide row declared `_lock: 'full'`, the reads served the
env-wide row and published `lock: full`, `editable: false`, `deletable:
false`, while that organization's save, publish, rollback and delete
were admitted. ADR-0010 §3.3 says `full` means "Overlay writes
rejected". This is the third member of the family, on the organization
axis. The first was the package door (PR objectstack-ai#21693) and the second the
topology axis (PR objectstack-ai#21715).

## H1: the two resolutions, measured at `c43a8ae612`

Measured on the real `ObjectStackProtocolImplementation` over an engine
double, for a `view`. The table was identical on an environment kernel
and a host-config kernel. "Door" is the overlay limb's own
`sys_metadata` query.

| stored rows | request | both reads serve | door read at base | door
verdict |
|---|---|---|---|---|
| env-wide `full` | no organization | env-wide row, `full` |
`organization_id` null: env-wide row | `full`: agrees |
| env-wide `full` | `org_a` | env-wide row, `full` | `organization_id =
org_a`: nothing | `none`: **split** |
| org `full` | no organization | nothing, `none` | null: nothing |
`none`: agrees |
| org `full` | `org_a` | org row, `full` | org_a: org row | `full`:
agrees |
| env `full` + org `none` | no organization | env-wide row, `full` |
env-wide row | agrees |
| env `full` + org `none` | `org_a` | org row, `none` | org row | agrees
|
| env `none` + org `full` | no organization | env-wide row, `none` |
env-wide row | agrees |
| env `none` + org `full` | `org_a` | org row, `full` | org row | agrees
|
| neither | either | nothing, `none` | nothing | agrees |

There is one split cell per kernel, and it is the card's.

## What changed (H2)

Only `packages/metadata-protocol/src/protocol.ts` changes at runtime.

- **One resolution.** A new private method, `findServedOverlayRow`,
holds the served-row resolution. It applies the org-scoped row first and
the env-wide row as the fallback, with ADR-0048 prefer-local inside each
scope, and returns the row and its scope. Three callers now use it, and
the three hand-written copies are gone:
  - `getMetaItem`'s row read and its draft-preview arm;
  - `getMetaItemLayered`'s overlay layer;
  - `getEffectiveLock`'s overlay limb.
- **The same organization gate.** The gate passes the organization
through `organizationIdForMetaRead`, the predicate both reads apply. So
on a type with no per-org channel, the door ignores an org-scoped row
exactly as the reads do. Pin 4 holds this.
- **One declared difference, `otherSpelling`.** The reads keep their
at-rest tolerance: as a last resort in each scope, they also read a row
stored under the type's other spelling. The gate passes `false` and
stays on the canonical spelling. This was measured, not assumed:
- Full reuse turned
`packages/objectql/src/protocol-meta-type-canonicalization.test.ts` red
("a plural-spelled write addresses the canonical namespace and no
other"). On a create, the gate queried `type: 'actions'` after the
canonical row missed.
- That test pins objectstack-ai#4432's rule that a write addresses only the canonical
namespace. Changing it would loosen it, so the gate keeps the rule and
the test is untouched.
- The difference is stated on `findServedOverlayRow` and in the gate's
header.
- **A stale docblock fixed.** The `getEffectiveLock` header still told
callers to gate on `environmentId`, which has not been true since
objectstack-ai#21694. It now says the method answers alike on every topology.
- **The ledger.** `scripts/engine-double-contract.pinned.json` gains one
row for the new pin's `findOne` double. It was written by the gate's own
`--write`: 1 added, 0 lost.

No `packages/spec/src/**` file is touched, so no contract review is owed
on that ground. No governed surface is touched.

## H3: precedence when both rows exist

The reads serve the organization's own row to that organization. That
precedence is the ruling recorded in `getMetaItem` (ADR-0005:
precedence, never a merge). The gate now binds the lock of that same
row, whatever the env-wide row declares, and pin 3 covers it.

ADR-0010 §3.3 states its lock table per item and records no cross-scope
cascade. I found no text under which the reads' precedence is wrong, so
the precedence is unchanged here.

## H4: every caller moves together

`getEffectiveLock` has two callers. Both read the new limb, because the
change is inside it:

- `lockWriteRefusal` is reached through:
  - `assertLockAllowsWrite`, from `saveMetaItem` and `rollbackMetaItem`;
- `promoteDraftForPublish`, from `publishMetaItem` and
`publishPackageDrafts`.
- `assertLockAllowsDelete` is reached from `deleteMetaItem`, which
`deletePackage` and `discardPackageDrafts` also call.

Pin 2 drives save, delete, publish and rollback.

## What moves (H5)

- **Tests: none re-aimed.** No existing test changed in the final shape.
- The one red seen during the work was the canonicalization case above.
It was caused by a first shape of this fix, not by the split, and it is
green again under the final shape.
- `metadata-protocol`: 212 files and 3675 tests pass (3589 before, plus
86 new).
  - `objectql`: 372 files and 7458 tests pass.
- **Examples.** `git grep` finds no `_lock` and no `protection:` under
`examples/**`.
- **Shipped locks.** `platform-objects` declares `protection.lock` only
on `app` items (`setup`, `studio`, `account`) and `object` items
(`sys_*`). Both types are `allowOrgOverride: false`, so the gate never
carries an organization for them, and their artifact limb is unchanged.
- **Writers that reach the gate with an organization:**
- over the wire, REST `PUT`, `DELETE`, publish and rollback, and the
runtime dispatcher's save. They carry `organizationIdForMetaWrite`,
which is non-empty only for `view`, `dashboard`, `report`, `translation`
and `email_template`;
- in process, `migrateStoredMetadata` (the row's own organization, so
its own row is served and nothing changes), `deletePackage` and
`discardPackageDrafts` (the row's organization), and `duplicatePackage`
(a new name).
- **Newly refused.** An org-scoped write of one of those five types is
now refused `ITEM_LOCKED` / 403 when that organization has no row of its
own and the env-wide row's lock refuses the operation. On a type with no
per-org channel, an in-process removal of a pre-objectstack-ai#6190 org-scoped row is
now judged by the env-wide row's lock, the row both reads serve.
- **The dev server's boot and seed replay.** Seed rows are type `seed`,
which has no per-org channel, and they name no organization. The boot
path reads; it does not write through these doors. This was measured by
`git grep` of every non-test caller of the four write verbs. **NOT
MEASURED: a live server boot** (no dev server was started for this
card).
- **Cost.** With an organization, the gate makes 2 `findOne` reads on a
miss (the org row, then the env-wide row) where it made 1. Without an
organization nothing changes.

## Pins

`packages/metadata-protocol/src/protocol.lock-org-axis-agree.test.ts`
has 86 cases. Each one drives the real doors and the real reads on one
protocol instance, and every refusal asserts `code` and `status`
(ADR-0112).

1. **The family's enumeration pin (triage's acceptance).** One table
covers kernel topology (environment, host-config), row scope (env-wide,
org-scoped), request scope (no organization, `org_a`), every
`MetadataLockSchema` level (read off the schema itself) and operation
(save, delete). That is 64 rows, each its own named case.
- In every row the door admits exactly when the envelope says `editable`
(save) or `deletable` (delete).
   - Both reads must agree with each other first.
- A span check holds the table size to the product of the axes, and a
lit control proves the org axis reaches the env-wide row.
2. **The measured defect.** An env-wide `_lock: 'full'` row is tested on
both kernels.
   - The org-scoped read says `editable: false`.
- Save and delete are refused `ITEM_LOCKED` / 403 with `lock: 'full'`.
The denial rows are written to `sys_metadata_audit` under `org_a`.
   - Publish and rollback are refused the same way.
3. **Precedence (H3).** Three lock pairs ((full, none), (none, full),
(no-delete, no-overlay)) are tested against both request scopes on both
kernels. The served row is named in every case, and both doors follow
it.
4. **The organization gate.** A `page` (no per-org channel) is tested
with an env-wide row and an org-scoped residue row whose locks differ.
The read serves the env-wide row, and the door binds that row's lock.

## Reverse verification

Both ablations ran from the committed fix (HEAD `7b37480d8c`), through
`scripts/ablation-replace.mjs` in wrap mode, inside a script whose `EXIT
INT TERM` trap restores from `HEAD` by absolute path. The pin imports
`./protocol.js`, which resolves to source, so no rebuild is in the path.
The expected direction was declared before each run.

1. **The exact-`organization_id` limb restored** (one `findOne` on
`organization_id: organizationId ?? null`).
- The anchor went from 1 to 0, and 1 marker was on disk. The blob went
from `e6a207612cc4` to `c598f7de3b47`.
   - Predicted: 16 red, 70 green. Measured: **16 failed, 70 passed**.
- Red: the 8 org-axis cells of pin 1 (an env-wide row, an `org_a`
request, with `no-overlay`/`full` on save and `no-delete`/`full` on
delete, on both kernels), all 4 cases of pin 2, and all 4 of pin 4.
- Green: the other 56 rows of pin 1, the span check and the lit control,
and all of pin 3.
2. **The organization gate removed from the door only.**
   - The anchor went from 1 to 0, and 1 marker was on disk.
- Predicted: 4 red. Measured: **4 failed, 82 passed**, all of them pin
4.

**Restore.** After each leg, the blob equals the `HEAD` blob
`e6a207612cc4`, `git diff HEAD` is empty, and `git status --porcelain`
is empty. Both the tool and the trap proved it.

## Tests

On head `87e350bbaf`, after merging `origin/main` at `316be321ef` (which
touched `runtime` and `scripts/` only):

- `@objectstack/metadata-protocol`:
- `vitest run`: 212 files passed and 3 skipped; 3675 tests passed and 19
skipped.
- `typecheck` (`tsc --noEmit`) is green. `--listFiles` compiles 215 test
files, including the new pin.
- `@objectstack/objectql`, against the rebuilt `metadata-protocol` dist:
  - `vitest run --project local`: 372 files and 7458 tests passed.
  - `--project repo`: 1 file and 5 tests passed.
- **Lint, narrowed and proved.** `eslint --no-inline-config --format
json` over the two changed TypeScript files gives 2 file results, with 0
errors and 0 warnings.
- The population comes from ESLint's own config: `isPathIgnored` is
false for both files, and each computes a 5-rule config.
- Invariance: neither computed config has `parserOptions.project` or
`projectService`, and `eslint.config.mjs` states it never enables
type-aware linting. So this diff cannot move any untouched file's
verdict.

NOT MEASURED, owned by CI or out of reach here:

- HTTP: the reach is on the real protocol over doubles.
- a live server boot.
- the `rest` and `runtime` suites. They are consumers, and no export,
spec contract or wire shape changes.
- the Dogfood Regression Gate.
- Temporal Conformance.
- the whole-workspace type-check lanes.
- the full `pnpm lint`.

## Gates

All of these ran on head `87e350bbaf`, after the final commit. Each exit
code was captured before any pipe.

- **Derived set.** `node scripts/pm/dispatch-gates.mjs --commands` (no
paths) derives 72 families. All 72 exit 0. The `--ran` reconciliation
reads "72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN".
- Among them: `check:adr-0087-registration` (1 breaking changeset,
carrying its disposition), `check:changeset-no-major`,
`check:empty-changeset`, `check:engine-double-contract` (855 rows held),
`check:nul-bytes`, `check:doc-authoring`,
`check:cross-package-test-inputs`, `check:test-source-alias`,
`check:published-files`, `check:dts-closure`,
`check:dual-build-cjs-loads` and `check:lean-entry-closure`.
- The last two first answered PREREQUISITE NOT MET (exit 3). They were
re-run after a full `turbo run build` and exit 0.
- **Artifact-roster block.** All 54 roster commands were run; 51 exit 0.
- `check-closing-target-claim`, `check-partof-closing-keyword` and
`check-single-claim-paths` exit 2 NOT WIRED, because they read a pull
request.
- **The four symbol-anchor sweeps.** All four exit 0:
  - `check:adr-symbol-anchors`: 2167 anchors across 140 records resolve.
- `check:scripts-symbol-anchors`: 3760 anchors across 282 scripts
resolve.
- `check:spec-docblock-symbol-anchors`: 4950 anchors across 1868 spec
sources resolve.
  - `check:adr-anchors`: OK.

## Acceptance notes

Three members of the same family sit outside triage's five axes. Each
was measured on the real reads and the gate over doubles, at
`c43a8ae612`, and none is fixed here. They are reported to the seat for
its call.

- **The artifact axis: an explicit `none`.** A packaged view declares
`_lock: 'none'`, and its stored row declares `full`.
- Both reads say `editable: true`, because `mergeArtifactProtection`
lets the artifact's explicit value win.
- The gate refuses, because its artifact limb skips `none` and its
overlay limb finds `full`.
  - The door is stricter than the read.
- **The layered read: a packaged item's stored lock.** A packaged view
declares no lock, and its stored row declares `full`.
- `getMetaItem` says `full` / `editable: false`, which agrees with the
door.
- `getMetaItemLayered` says `none` / `editable: true`, because its lock
source is `code ?? overlay`.
  - The two reads disagree with each other.
- **The other spelling.** This is the declared difference above. A
pre-objectstack-ai#4432 row stored under the plural spelling is served by the reads
when no canonical row exists in that scope. The gate does not read it.
No live write mints such a row.

Two smaller notes:

- **The package axis.** The gate asks without a `packageId`, while a
read that names one prefers that package's row (ADR-0048). The two can
split only when one (type, name, scope) holds rows from two packages.
Not measured.
- **One pathological layered case.** `getMetaItemLayered` used to fall
back to the env-wide row when an org row's stored body was JSON `null`.
It now reports the org row, as `getMetaItem` always did.

---
_Generated by [Claude
Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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/m tests tooling

Projects

None yet

2 participants