Skip to content

fix(metadata-protocol)!: an item's lock is the strictest lock among the stored rows in scope for its address (#21761) - #21801

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-21761-lock-address-selection
Oct 5, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/issue-21761-lock-address-selection

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21761
Clause-②: no (narrowing)

What changes

An item's ADR-0010 lock is now selected from the item's address (type, name, organization scope, packageId) by one function, resolveOverlayLockLayer in packages/metadata-protocol/src/item-lock.ts. The write doors' _lock gate, getMetaItem, getMetaItemLayered and the list all call it. No caller pre-selects a row for the lock any more.

  • The rows in scope. ADR-0005 precedence, as before: the organization's rows when it holds any row of the item, else the env-wide rows. Within that scope, every row of the item counts, whichever package it is bound to.
  • The lock is the strictest among them. A write is refused when any row in scope refuses it, and a delete likewise (strictestLock, read off the lock algebra). Two rows that refuse different verbs (no-overlay, no-delete) join to full.
  • Content stays prefer-local (ADR-0048). findServedOverlayRow is unchanged in what it serves; it now decides the content only. A read naming package A is still served A's row. When the binding lock comes from another row, the served body carries the binding row's lock family (withOverlayLockFamily), so the body never states a lock the envelope does not report.
  • Prose. Among rows that declare the strictest lock, the reported lockReason / lockDocsUrl / lockSource are the address's own package row's, then the package-less row's, then other packages' by id. So the prose does not depend on row order either.

Landing points, as the claim's file surface said: item-lock.ts, and the row selection in protocol.ts (overlayLockLayerAt; the gate's readLockGateOverlayLayer; the lock source of both reads; the list item and the getMetaDiagnostics tile through the list body). One test file outside that surface: packages/objectql/src/protocol-meta.test.ts, whose outage double now fails find as well as findOne, because the gate's first read is a find. The rest of the diff is tests, the changeset, and the engine-double ledger row check:engine-double-contract --write added.

Rulings honoured, and one place they meet

  • Strictest-wins, content prefer-local, no prefer-local lock: as ruled in 5982313964 and restated in 5982824254.
  • Rows in scope for a request naming package A. The ruling names A's row and the package-less row. This PR keeps both and also keeps other packages' rows of the same name in scope (pin 7). Measured reason: before this change the gate asked with no package, so findOne could return package B's row. In arrangement A none + B full with B returned first, a save naming A was refused at the merge base. With A and the package-less row only, it would be admitted. That is a widening, which the ruling itself excludes ("never more permissive than either row the door could pick today"). The delete door also carries no package at all (DeleteMetaItemRequest declares none), so a package-scoped deletable can report that door only from a package-agnostic row set. Narrowing the set to A's row and the package-less row is a widening and stays the maintainer's to rule; it is raised in the report, not taken here.
  • Rows in scope for a request naming no package. Every row of the item in scope: exactly the set the gate's findOne could have returned. So the lock is never more permissive than any row the door could pick before.

H1: the package axis, before and after

Before (merge base ebfe658c72):

  • The gate went through readLockGateOverlayLayer → findServedOverlayRow, with no packageId, canonical spelling only, and bound the row findOne returned first. A save with ?package= reached the gate without its package (measured: the gate's address was package-less for every request), and a delete carries none.
  • getMetaItem and getMetaItemLayered handed findServedOverlayRow the request's packageId (present or absent) and took that row's lock.
  • The list item and the diagnostics tile never called findServedOverlayRow. They read the lock off the body the list's per-slot merge picked (the latest of the package's row and the package-less row).

After (this branch's sources at 05b41c9449; the later merges of origin/main touch no file of metadata-protocol): every caller hands resolveOverlayLockLayer its address. The gate's address carries packageId for save and publish and none for delete and rollback; the reads carry the request's. Measured over an engine double, 30 requests, both findOne orders. b→h = merge base → this head; the door columns are the _lock gate's verdict; reads are store reads per call.

arrangement · row order · request getMetaItem lock layered lock list item _lock tile locked door save door delete gate reads getMetaItem reads
arr1 pkgless none + A full [as] · package A full→full full→full full→full 1→1 admitted→refused admitted→refused 1→1 1→2
arr1 pkgless none + A full [as] · no package none→full none→full full→full 1→1 admitted→refused admitted→refused 1→1 1→2
arr1 pkgless none + A full [rev] · package A full→full full→full full→full 1→1 refused→refused refused→refused 1→1 1→2
arr1 pkgless none + A full [rev] · no package full→full full→full none→full 0→1 refused→refused refused→refused 1→1 1→2
arr2 A none + pkgless full [as] · package A none→full none→full none→full 0→1 admitted→refused admitted→refused 1→1 1→2
arr2 A none + pkgless full [as] · no package none→full none→full full→full 1→1 admitted→refused admitted→refused 1→1 1→2
arr2 A none + pkgless full [rev] · package A none→full none→full none→full 0→1 refused→refused refused→refused 1→1 1→2
arr2 A none + pkgless full [rev] · no package full→full full→full none→full 0→1 refused→refused refused→refused 1→1 1→2
arr3a pkgless no-overlay + A no-delete [as] · package A no-delete→full no-delete→full no-delete→full 1→1 refused→refused admitted→refused 1→1 1→2
arr3a pkgless no-overlay + A no-delete [as] · no package no-overlay→full no-overlay→full no-delete→full 1→1 refused→refused admitted→refused 1→1 1→2
arr3a pkgless no-overlay + A no-delete [rev] · package A no-delete→full no-delete→full no-delete→full 1→1 admitted→refused refused→refused 1→1 1→2
arr3a pkgless no-overlay + A no-delete [rev] · no package no-delete→full no-delete→full no-overlay→full 1→1 admitted→refused refused→refused 1→1 1→2
arr3b pkgless full + A no-overlay [as] · package A no-overlay→full no-overlay→full no-overlay→full 1→1 refused→refused refused→refused 1→1 1→2
arr3b pkgless full + A no-overlay [as] · no package full→full full→full no-overlay→full 1→1 refused→refused refused→refused 1→1 1→2
arr3b pkgless full + A no-overlay [rev] · package A no-overlay→full no-overlay→full no-overlay→full 1→1 refused→refused admitted→refused 1→1 1→2
arr3b pkgless full + A no-overlay [rev] · no package no-overlay→full no-overlay→full full→full 1→1 refused→refused admitted→refused 1→1 1→2
arr3c pkgless no-delete + A full [as] · package A full→full full→full full→full 1→1 admitted→refused refused→refused 1→1 1→2
arr3c pkgless no-delete + A full [as] · no package no-delete→full no-delete→full full→full 1→1 admitted→refused refused→refused 1→1 1→2
arr3c pkgless no-delete + A full [rev] · package A full→full full→full full→full 1→1 refused→refused refused→refused 1→1 1→2
arr3c pkgless no-delete + A full [rev] · no package full→full full→full no-delete→full 1→1 refused→refused refused→refused 1→1 1→2
xB1 A none + B full [as] · package A none→full none→full none→full 0→1 admitted→refused admitted→refused 1→1 1→2
xB1 A none + B full [as] · no package none→full none→full full→full 1→1 admitted→refused admitted→refused 1→1 1→2
xB1 A none + B full [rev] · package A none→full none→full none→full 0→1 refused→refused refused→refused 1→1 1→2
xB1 A none + B full [rev] · no package full→full full→full none→full 0→1 refused→refused refused→refused 1→1 1→2
xB2 pkgless none + A none + B full [as] · package A none→full none→full none→full 0→1 admitted→refused admitted→refused 1→1 1→2
xB2 pkgless none + A none + B full [as] · no package none→full none→full full→full 1→1 admitted→refused admitted→refused 1→1 1→2
xB2 pkgless none + A none + B full [rev] · package A none→full none→full none→full 0→1 refused→refused refused→refused 1→1 1→2
xB2 pkgless none + A none + B full [rev] · no package full→full full→full none→full 0→1 refused→refused refused→refused 1→1 1→2
xOrg org B full + env A none · org + package A none→full none→full none→full 0→1 refused→refused refused→refused 1→1 5→6
xOrg org B full + env A none · org, no package full→full full→full full→full 1→1 refused→refused refused→refused 1→1 1→2

Summary over the 30 requests and 60 door verdicts:

  • 0 widenings (no write refused at the base is admitted at head).
  • 24 narrowings, all where the base verdict depended on row order.
  • 36 unchanged.
  • Order-dependent groups: 14 of 14 at the base, 0 of 14 at head.
  • Requests where getMetaItem, getMetaItemLayered and the door disagree: 8 of 30 at the base, 0 of 30 at head.

H2, H3, H4, H5

  • H2 (what the door carries). Measured: the save door had request.packageId in hand, but assertLockAllowsWrite never received it, so the gate saw no package; the delete request declares none. It now carries it for save and publish. Rows in scope are package-agnostic for every address (above), so carrying the package picks only the prose, never the rows. Widening check: 0 of 60 door verdicts.

  • H3 (content stays prefer-local). Both reads' full envelopes and bodies (layered: code, overlay, effective) and the list items were dumped for all 30 requests at the base sources and at 05b41c9449. With the lock family masked (lock, lockReason, lockSource, lockDocsUrl, editable, deletable, and _lock / _lockReason / _lockSource / _lockDocsUrl anywhere), the dumps are byte-identical: 0 diffs in 90.

  • H4 (one address, one selection). resolveOverlayLockLayer(address, rowsIn, { otherSpelling }). The address fields are ITEM_ADDRESS_FIELDS = ['type', 'name', 'organizationId', 'packageId']. rowsIn(organizationId, spelling) returns every stored row of the item in one scope under one spelling; the function applies the precedence and the strictest join itself. Callers:

    • the protocol's overlayLockLayerAt, an engine find per scope, used by the gate, getMetaItem and getMetaItemLayered;
    • the list, a filter over the package-agnostic rows it read.

    The one declared difference is unchanged: the gate passes otherSpelling: false, the reads true (meta overlays: unnormalized type segment creates phantom rows that shadow the code-authored listing and cannot be deleted #4432).

  • H5 (fail-closed, store reads). A failing read in scope still refuses with 503 (pin 6, with and without a package; the getEffectiveLock 的 overlay 读用裸 catch,sys_metadata 读失败时保护闸门 fail-open(_lock 落成 'none',写/删被放行) #5706 pin's double now fails the gate's find). The gate's store reads per check are unchanged in count: one read per scope, stopping at the first scope that holds a row. Before that was findOne; now it is find, because the strictest lock needs every row of the item, and findOne cannot enumerate them. Measured: 1→1 on all 30 requests. The reads pay one extra find per active read (1→2; 5→6 in the org-plus-package case), because the content row (prefer-local) and the lock rows (all in scope) are different sets. A package-scoped list pays one extra package-agnostic row read, which the overlay cache answers on a hit.

Pins

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

  1. The generated pin gains the address as input columns: requestPackage (packageId absent / present) and packageRow (the package's row: none, or each lock level), beside the existing package-less storedRow.
  2. Pin 4: arrangement 1 (package-less none, package full), both orders, package named and not. The reads and the door agree on full, the body says full, and content is the package's own row.
  3. Pin 5: arrangement 2 (package none, package-less full), both orders. full, never none; the refusal carries the binding row's prose.
  4. Pin 6: the getEffectiveLock 的 overlay 读用裸 catch,sys_metadata 读失败时保护闸门 fail-open(_lock 落成 'none',写/删被放行) #5706 outage. Every sys_metadata read fails, and save (with and without a package) and delete answer 503 from the gate.
  5. Pin 7: a third package's row binds a save naming A, under both orders. An organization holding only another package's row is the scope for reads and door alike. The list item and the tile report the item's lock.

(The dispatch order's pins 2 and 3 are this file's pins 4 and 5: the file already had #21738's pins 2 and 3.)

Reverse verification

Committed first (68567308d1). The gate's pre-change selection was restored through scripts/ablation-replace.mjs: one row, package-agnostic, canonical, findOne org then env.

  • The anchor hit 1 → 0 and the marker 0 → 1; blob 388e091cb01e → cf3701af4ce0.
  • The tests resolve ./protocol.js from src, so no dist was involved.
  • Predicted, then measured, on -t '#21761. pin (4|5)': 5 red / 4 green.
    • Red: pin 4 "package-less row returned first" (package named and not); pin 5 "package row returned first" (package named and not); pin 5's refusal-prose case. Each failed with expected 'admitted' to deeply equal { refused: … }, or a non-lock error where ITEM_LOCKED was expected.
    • Green: the four cases where the old gate's first row happened to be the binding one.
  • Restored with git checkout HEAD -- PATH (absolute path, under trap). Blob after = blob at HEAD = 388e091cb01e; git diff HEAD empty.

Tests

At 7fe49587a0 (after merging origin/main at e83c9f6154). The final head 273ead3408 adds only a second merge of origin/main (e27a7c0c9e), which touches cloud-connection, service-storage, a pm reference doc and two ratchet baselines and no file of spec, metadata-core, metadata-protocol or objectql, so these readings carry:

  • pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2: 213 files passed, 3 skipped; 19 952 tests passed, 19 skipped.
  • pnpm --filter @objectstack/metadata-protocol typecheck: exit 0. tsc --listFiles includes all four edited test files.
  • objectql, against the rebuilt metadata-protocol dist: the 8 lock-touching protocol files pass (160 tests). The full objectql suite passed at 632a00751d: 373 files, 7463 tests.

Gates

All at head 273ead3408, after the final commit.

  • node scripts/pm/dispatch-gates.mjs --commands (no paths) derived 74 commands. All 74 ran, plus the 54-row artifact-roster block it prints outside its total and the four symbol-anchor sweeps (check:adr-symbol-anchors, check:scripts-symbol-anchors, check:spec-docblock-symbol-anchors, check:adr-anchors): 132 commands, 129 exit 0.
  • dispatch-gates --ran over the recorded cmd :: exit N list: "74 derived famil(ies) accounted for — 74 run, 0 NOT-MEASURED (a DERIVED zero — all 74 recorded an exit code and none of them is 3)".
  • The three non-zero results are roster gates that read a pull request, and locally have none (exit 2, NOT WIRED):
    • check-partof-closing-keyword: re-run with this body as PR_BODY, exit 0;
    • check-closing-target-claim and check-single-claim-paths: NOT MEASURED. Each needs a PR number and an API token; this session's GH_TOKEN is invalid, and their guard workflows run on the PR.
  • check-closing-keyword-parity --body: only #21761 binds.
  • Named readings:
    • check:engine-double-contract: OK, after recording the one new pinned row through its own --write.
    • check:durability-log-level: green, read-seam invention included.
    • check:lean-entry-closure: green, after building objectql.
    • check:dual-build-cjs-loads: green.
    • check:nul-bytes: OK.
    • check:changeset-no-major: the level axis was measured with a simulated PR body carrying this Clause-② line (minor passes; patch exits 1).
    • check:adr-0087-registration: one declared-breaking changeset, carrying its disposition.
  • Lint, a proven narrowing.
    • Command: eslint --no-inline-config --format json over the 7 touched .ts files, at 273ead3408: 7 files, 0 errors, 0 warnings.
    • Population, read from eslint's own calculateConfigForFile / isPathIgnored: every one of the 7 is linted (none ignored).
    • Invariance: eslint.config.mjs enables no type-aware linting (no parserOptions.project, which reads null for each file), so this diff cannot move any untouched file's verdict.
    • The repo-wide pnpm lint is CI's.
  • NOT MEASURED here, CI's:
    • Test Core shards beyond the two packages above;
    • Temporal Conformance, Dogfood, Build Core;
    • the workspace type-check lane.

Acceptance notes

  • Changeset grade. The dispatch order asked for patch. check-changeset-no-major.mjs refuses patch on a moved package under Clause-②: no (narrowing) during the launch window. It was measured on a throwaway local commit: exit 1, "it grades NO package whose packages/**/src/** it moves" at the level a narrowing owes. So the changeset is minor, with the ! title and an ADR-0087 not-required (no-migration-prescription) disposition; check:adr-0087-registration reads it.

  • Artifact layer, same family, not changed here. The reads look the artifact up package-scoped (lookupArtifactItem(type, name, packageId)); the gate looks it up package-agnostically. Measured on a registry double that mirrors getArtifactItem's prefer-local-then-first-composite order, with packages B (full, registered first) and A (no lock) shipping one view name:

    • getMetaItem naming A reads lock: none, editable: true;
    • a save naming A is refused ITEM_LOCKED with source=artifact and B's reason.

    Reported in the PR's report as a finding for the seat.

  • The list serves the latest of a slot's rows. mergePackageAwareOverlay picks, per package slot, the latest of that package's row and the package-less row. With both present, the list's body for package A is the package-less row when it comes back later, while getMetaItem naming A serves A's row (H1 table, list column at the base). The lock family on the list body now follows the binding rows; the content selection is unchanged and is reported as a finding.

  • getMetaItemLayered's effective body now carries the binding overlay lock family as getMetaItem's body does. code and overlay stay raw. On the artifact axis effective still never carried the artifact's family, as before.


Generated by Claude Code

claude added 7 commits October 5, 2026 00:30
…ctest row in scope

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…in; arrangements 1 and 2 under both row orders

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
…d model the outage on both read verbs

Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
Co-authored-by: Claude <noreply@anthropic.com>
… binding overlay lock family, as getMetaItem's does

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

github-actions Bot commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

31 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 045f764c2672c499d85393c0a37ef90ddd48263d.

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

What this run could not see
  • 1 cross-cutting symbol(s) contributed no route anchor: organizationId (5 routes)
  • 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 045f764c2672c499d85393c0a37ef90ddd48263d → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 045f764c2672c499d85393c0a37ef90ddd48263d

⚠️ 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 045f764c2672c499d85393c0a37ef90ddd48263d → 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 (seat review) — PR #21801 at head 273ead3408

domain:engine#1 · session_017ErfyP2Rx7XWHJA27QjyUi · read at 2026-10-05T02:10Z. The os-dev report is on #21761 (5986879399). Judged against GitHub and the branch, not against the report.

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

    • The first lines are Fixes #21761 and Clause-②: no (narrowing).
    • The closing-keyword scan finds #21761 only.
  • Scope: 9 files, +923/-194:

    • metadata-protocol's item-lock.ts and protocol.ts;
    • the generated pin and three test doubles whose gate read is now find;
    • objectql's protocol-meta.test.ts outage double;
    • one engine-double-contract row, written by the gate's own --write;
    • the changeset.

    NOT governed, and no packages/spec/src/** path. Clause-② is no, so no contract review is owed.

  • The diff, read: triage's direction (5982313964, restated by the unlock 5982824254).

    • resolveOverlayLockLayer(address, rowsIn, { otherSpelling }) in item-lock.ts is the one selection. Every caller hands it the item's address (ITEM_ADDRESS_FIELDS: type, name, organization, package): the _lock gate through readLockGateOverlayLayer, getMetaItem, getMetaItemLayered (its effective body included) and the list items the diagnostics tile counts. No caller pre-selects a row for the lock any more.
    • The rows in scope follow ADR-0005: the organization's rows when it holds any row of the item, else the env-wide rows. The lock is the strictest among them, read off the lock algebra (strictestLock), so no-overlay with no-delete gives full.
    • Content stays prefer-local: findServedOverlayRow now decides content only. A served body carries the binding row's lock family through withOverlayLockFamily.
    • The gate keeps getEffectiveLock 的 overlay 读用裸 catch,sys_metadata 读失败时保护闸门 fail-open(_lock 落成 'none',写/删被放行) #5706: overlayLockLayerAt runs inside the same fail-closed catch, and pin 6 holds it. Its store reads per check stay 1 to 1.
  • Measured:

    • Of 60 door verdicts over 30 requests and both row orders: 0 widenings and 24 narrowings.
    • Row-order-dependent groups fell from 14 of 14 to 0 of 14.
    • Read-versus-door disagreements fell from 8 of 30 to 0 of 30.
    • Masked content was byte-identical in all 90 dumps (H3).
  • Pins:

  • Reverse verification: from committed 68567308d1, the gate's pre-change selection was planted (findOne, org then env, no package).

    • Pin 4 failed on the package-less-row-first order, ×2.
    • Pin 5 failed on the package-row-first order, ×2, plus its refusal-prose case.
    • The other four selected cases stayed green.
    • The restore was proved by blob equality and an empty git diff HEAD.

The dev's open questions, answered

  1. Another package's row in scope: A, keep it.
    • Triage's selection rule rests on one premise (5982313964): "Strictest-wins is never more permissive than either row the door could pick today, so it does not widen."
    • The door today asks with no package, so it can bind any row of the item, a third package's row included.
    • The two-row set in the ruling's sentence therefore breaks that premise. Measured: with A's none and B's full, B returned first, a save naming A goes from refused to admitted. That is a widening, which the ruling reserves to the maintainer (⛔).
    • A is the only set that keeps the ruling's ⛔. This applies the ruling; it does not reopen it.
  2. Changeset grade: A, minor.
    • Under Clause-②: no (narrowing), check-changeset-no-major refuses patch on a moved package (measured: patch exits 1, minor exits 0).
    • AGENTS.md's post-task checklist grades a narrowing the same way.
    • The order's patch was the seat's error. The ! title and the ADR-0087 not-required (no-migration-prescription) disposition match.

Changeset, checked sentence by sentence

  • The four "what moves for consumers" bullets match pins 4, 5 and 7 and the census rows.
  • The no-overlay + no-delete → full bullet matches the arr3 rows.
  • "No write the door refused before is admitted now" matches the 0-widening census.
  • "Content stays prefer-local" matches H3's 0 of 90.

Evidence

  • metadata-protocol: 19952 tests pass in 213 files, at 7fe49587a0 under os-verify-lock. The final merge of origin/main touches none of its files.
  • objectql: 7463 tests pass.
  • The typecheck is green, and --listFiles compiles the edited tests.

Gates

  • dispatch-gates --ran: 74 of 74 exit 0.
  • The artifact roster, the four symbol-anchor sweeps and check:engine-double-contract (after its own --write) are green.
  • The 3 PR-context guards were NOT MEASURED locally, because the dev session's token was invalid. CI runs them on this head: "The card this PR closes must claim this branch", "No other open PR may claim the same single-writer path" and "No other open PR may claim the same issue" are all success.
  • CI: read at landing.

Deviations, read

  • Out of the claim's file surface: the test doubles and the pinned row. Each follows from the gate's read becoming find.
  • A local commit with --no-verify: used once, only to measure the patch grade. It was reset and never pushed.
  • One command refused by the dev session's safety check (an rm -f over a shell variable). The dev re-issued it in the check's own suggested safe form. The seat redid nothing.

Out-of-scope findings, filed by the seat


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 5, 2026 02:40
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 5, 2026 02:40
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 5, 2026
Merged via the queue into main with commit 18c2ddc Oct 5, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21761-lock-address-selection branch October 5, 2026 03:17
os-project-manager pushed a commit that referenced this pull request Oct 5, 2026
… artifact layer's package axis

The generated pin becomes the family's enumeration: named positions (layer x
topology / organization / package), slices that open them, and a completeness
check that fails by name. PR #21801's 16 320 rows are kept, checked under
their own titles. Every row also asserts the served body states the envelope's
lock. Named pins: the card's case, the never-widening join, a disabled
package, and the folded content-scope position.

Claude-Session: 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
… decision in words instead of a tracker number (stage 15) (objectstack-ai#21810)

Part of objectstack-ai#20749
Clause-②: no

Stage 15 of this card: the next area of class (e), the test strings
shipped under `packages/spec/src`, as ruled in `5902360492` on objectstack-ai#20513.
This stage takes the first name-ordered file group directly under
`packages/spec/src/data/`: the 20 test files from
`aggregate-field-type-compatibility.test.ts` to
`date-range-presets.test.ts`. They carried 94 messages and 102 tracker
ids, citing 60 records. Every one of those ids now either states what
its record decided, in words (form D), or is dropped where the title
already says it. Text only: no assertion, identifier, test count or code
comment changes.

## Census at the base (`0a3480311a`)

Instruments: `census10.cjs` (md5 `9d08602ab972b4b8643c90d64d40fa41`),
`census.cjs` (md5 `6e42a45a926d375013c32d62f16a296e`), `census-wide.cjs`
(md5 `c98410a19529c439adb0afbfb00026a2`) and `dirtable.cjs` (md5
`dda605c54745b4a60cc14c9a686e4eff`). They are byte-identical to the
copies stages 10 to 14 used. A literal counts as a test title when its
folded message is argument 0 of a `describe` / `it` / `test` call,
`.each` / `.skip` / `.only` chains included. Everything else is an
"other" string.

Both instruments read **1325 messages / 1406 ids in 282 files**, the
seat's reading at `0a3480311a` (stage 14's head).

| directory | files | messages / ids | titles | other |
|:--|--:|--:|--:|--:|
| `data/` (this PR: the first 20 files) | 95 | 468 / 501 | 445 / 475 |
23 / 26 |
| `ui/` | 81 | 393 / 416 | 375 / 398 | 18 / 18 |
| `api/` | 40 | 189 / 201 | 181 / 193 | 8 / 8 |
| `system/` | 34 | 154 / 165 | 128 / 138 | 26 / 27 |
| (files directly in `src/`) | 30 | 118 / 120 | 117 / 119 | 1 / 1 |
| `ai/` | 1 | 2 / 2 | 0 | 2 / 2 |
| `contracts/` | 1 | 1 / 1 | 0 | 1 / 1 |
| **total** | **282** | **1325 / 1406** | **1246 / 1323** | **79 / 83**
|

The group reads **94 messages / 102 ids in 20 files**, the seat's
figures, file for file:

| file (under `data/`) | messages / ids | titles | other |
|:--|--:|--:|--:|
| `aggregate-field-type-compatibility.test.ts` | 4 / 4 | 4 / 4 | 0 |
| `analytics-date-range-closed-vocabulary.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `analytics-date-range-two-bound-window.test.ts` | 3 / 3 | 3 / 3 | 0 |
| `analytics-query-window-integer.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `analytics-strictness-batchd.test.ts` | 9 / 9 | 9 / 9 | 0 |
| `analytics.test.ts` | 6 / 6 | 6 / 6 | 0 |
| `api-derivation.test.ts` | 6 / 6 | 6 / 6 | 0 |
| `api-methods-batch-conformance.test.ts` | 3 / 4 | 1 / 1 | 2 / 3 |
| `authoring-key-lint.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `autonumber-format.test.ts` | 3 / 3 | 3 / 3 | 0 |
| `autonumber-unanchored-boundary.test.ts` | 3 / 4 | 3 / 4 | 0 |
| `bulk-write-hook-conformance.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `calendar-day.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `context-tokens.test.ts` | 1 / 1 | 1 / 1 | 0 |
| `currency-mode-family-closure.pin.test.ts` | 2 / 2 | 2 / 2 | 0 |
| `currency-precision-iso4217.test.ts` | 7 / 7 | 7 / 7 | 0 |
| `data-engine.test.ts` | 20 / 25 | 20 / 25 | 0 |
| `datasource-credential-redaction.test.ts` | 6 / 6 | 6 / 6 | 0 |
| `datasource.test.ts` | 10 / 10 | 10 / 10 | 0 |
| `date-range-presets.test.ts` | 2 / 3 | 2 / 3 | 0 |
| **20 files** | **94 / 102** | **92 / 99** | **2 / 3** |

- **Controls.** Lit, a title: `data/document.test.ts` reads 2 / 2 at the
head. Lit, "other" strings: the two in
`data/external-lookup-retirement.test.ts` (`:89`, `:130`) still read at
the head. Dark: the file comment at
`data/analytics-strictness-batchd.test.ts:4` (it names the strictness
batch by its number) reads 0. Planted in a scratch copy of the head
`data/calendar-day.test.ts`: an id put into a title reads 1 / 1, and an
id put into a comment reads 0.
- **A wider pattern** (any `#` plus digits) reads the same totals in 19
of the 20 files. In `aggregate-field-type-compatibility.test.ts` it
reads one more, a decision-batch number at `:150` that sits beside a
cited record in the same literal. The gate's pattern needs three to five
digits, so it is not counted there.
- **At the head:** 1231 messages / 1304 ids in 262 files. The 20 files
read 0 / 0 on both patterns, and no other file moved.

## How the area was chosen

`data/` has no subdirectory to split by (449 ids directly under it,
`data/driver/` 52), so its stages take name-ordered file groups near the
~100-id bound, as stage 14's report proposed. This census reads the
first group at exactly 102, the claim's figure, so the rule needed no
re-cut.

**Named for the next stages** (re-cut from the head census, 1231 / 1304;
`data/` 374 / 399 left):
- `data/` in four more stages, name-ordered:
1. `default-value-shape.test.ts` to `filter-comparand-shape.test.ts`: 20
files, 94 messages / 100 ids;
2. `filter-comparand-type.test.ts` to
`filter-view-operator-parity.test.ts`: 20 files, 95 / 99;
3. `filter.test.ts` to `object.test.ts`: 17 files, 103 / 114.
`object.test.ts` alone carries 42, so no cut lands nearer the bound;
4. `query-transport.test.ts` to `validation.test.ts` (11 files, 34 / 34)
with `data/driver/` (7 files, 48 / 52): 86 ids.
- `ui/` 416, about four stages. `api/` 201, two. `system/` 165, two. The
files directly in `src/`, 120, one.
- The three docblock needles (`ai/build-progress.test.ts:236`, `:237`,
`contracts/approval-service.test.ts:274`), one stage with their
docblocks.

## What each id became

24 literals (28 ids) now state a decision in words. 2 literals (2 ids)
get their subject back in words where the number stood in for it. 69
literals (72 ids) drop a number the title already explains. (95 literals
in 94 messages: the `sys_organization` reason string is one message over
two lines.)

Every cited record was read with its comments through REST: 55 answer
200. objectstack-ai#6345, objectstack-ai#8876, objectstack-ai#9040, objectstack-ai#10194 and objectstack-ai#17014 answer 404, and their
decisions were read from what landed: `e2798fa` (one driver vocabulary
for start and migrate), `d634e66` (the username half of the URL userinfo
grammar), `2420641` (a credential in the mongo `options` passthrough is
refused), `2306a76` (`theme` / `analytics_cube` validated at the `/meta`
write door) and `80aef80` (a one-day window for the one-day presets),
each with its CHANGELOG entry. No cross-repo record is cited in this
group.

| record(s) | literal (under `data/`) | now reads |
|:--|:--|:--|
| objectstack-ai#11152 | `aggregate-field-type-compatibility.test.ts:150` | "accepts
`sum` / `avg` / `min` / `max` over booleans — numbers on every backend,
a ruling that outranks the refused-by-default rule". The maintainer
ruled that booleans aggregate as numbers on every backend; decision
batch 80 held that ruling over batch 59's blanket refusal of unnamed
pairs. That batch number went with the id. |
| objectstack-ai#4001 (3) | `analytics-strictness-batchd.test.ts:83`, `:248`, `:306` |
"batch D, unknown keys refused — …" before "the doors the cube family is
reachable through", "alias claims are true of the surfaces they point
at" and "deliberate non-closures (re-verdicts, not omissions)". The
campaign's decision: an unknown key is refused, not stripped. |
| objectstack-ai#3878 (2) | `analytics-strictness-batchd.test.ts:270`, `:297` |
"matching the dispatcher's bespoke hint at the /analytics entry" and
"the retired-envelope tombstones still fire". The body is the bare
`AnalyticsQuery`; the `{ cube, query }` envelope was retired with
tombstones, and the entry answers 400 with a hint at `where`. |
| objectstack-ai#18612 | `analytics.test.ts:314` | "a persisted cube heals at the door
— the retired join `sql` / `relationship` are stripped (ADR-0087 D2)". |
| objectstack-ai#3391 | `api-derivation.test.ts:16` | "api-derivation — one table
resolves the effective operations from six primitives". The server is
the only adjudicator, through one derivation table. |
| objectstack-ai#3543 | `api-derivation.test.ts:286` | "vocabulary split — authors
write six primitives, the wire speaks operations". The authored enum
shrank; the wire vocabulary stayed byte-stable. |
| objectstack-ai#15873 | `api-methods-batch-conformance.test.ts:221` | A declared
reason string: "(a ruling grants `update`; both are column-clamped per
row by ADR-0092 D2)". Option (a), decision batch 64. |
| objectstack-ai#3786 | `authoring-key-lint.test.ts:37` | "lintAuthoredRecordKeys — an
unknown authoring key is reported, not swallowed", the decision its
source docblock records. |
| objectstack-ai#6555 | `autonumber-format.test.ts:23` | "DEFAULT_AUTONUMBER_FORMAT /
resolveAutonumberFormat — one declared default both sides read". Route
3: `{0000}` became the contract default, and both fallbacks went away. |
| objectstack-ai#5038 | `bulk-write-hook-conformance.test.ts:114` | "records the after
half as DELIVERED — the engine fires it once per row". |
| objectstack-ai#5574 | `bulk-write-hook-conformance.test.ts:119` | "records the
before half as DELIVERED — the engine dispatches it per row too". |
| objectstack-ai#20126 | `currency-mode-family-closure.pin.test.ts:348` |
"currency-mode family — the enumerating closure pin: `defaultCurrency`
holds only under `fixed`". |
| objectstack-ai#19992 | `currency-precision-iso4217.test.ts:163` | "the removed
`currencyConfig.precision` at rest: a stored row carrying the baked
`precision: 2` is served canonical". |
| objectstack-ai#7918 | `currency-precision-iso4217.test.ts:224` | "… where the ISO
4217 width check used to refuse it". That check was the record's option
A, later reversed. |
| objectstack-ai#3407, objectstack-ai#6437 | `data-engine.test.ts:1185` |
"DroppedFieldsEventSchema.reason — why a write dropped submitted fields,
widened past the readonly pair". |
| objectstack-ai#6262, objectstack-ai#6433, objectstack-ai#6435 | `data-engine.test.ts:1198` | "primary_key is the
value the engine reports when it strips a payload id it ruled is not an
identifier", the schema's own wording of that strip on the bulk and the
by-id paths. |
| objectstack-ai#8300 | `datasource-credential-redaction.test.ts:70` | "(the drift
guard on the one credential-key definition)". |
| objectstack-ai#8876 | `datasource-credential-redaction.test.ts:232` | "— the
username half of the same alignment". |
| objectstack-ai#8337 | `datasource-credential-redaction.test.ts:243` |
"redactUrlCredentialQueryParams — the read half: a credential query
parameter is never served back". |
| objectstack-ai#8153 | `datasource.test.ts:673` | "— unchanged by the managed-row
credentialsRef allowance". The ruling allowed `external.credentialsRef`,
and only it, on managed rows. |
| objectstack-ai#4614, objectstack-ai#8793 | `date-range-presets.test.ts:14` | "date-range preset
vocabulary — one source of truth, read by both the UI and the data
side". |

**Subject restored (2 ids):** objectstack-ai#20126 at
`currency-mode-family-closure.pin.test.ts:403` ("currency-mode closure
controls — each rule can fail, and passes what it must") and objectstack-ai#7918 at
`currency-precision-iso4217.test.ts:311` ("carries the measured anchors
— 0 digits for JPY, 2 for USD, 3 for KWD"). That literal moved from
double to single quotes, since it no longer holds an apostrophe.

**Dropped only (72 ids):** objectstack-ai#1603, objectstack-ai#2377, objectstack-ai#3026, objectstack-ai#3391, objectstack-ai#3543, objectstack-ai#3545,
objectstack-ai#3795 (9), objectstack-ai#4001 (2), objectstack-ai#4286, objectstack-ai#4346 (2), objectstack-ai#4538, objectstack-ai#4583, objectstack-ai#5586, objectstack-ai#6345,
objectstack-ai#6555, objectstack-ai#6560, objectstack-ai#7178 (5), objectstack-ai#7265, objectstack-ai#7287 (2), objectstack-ai#7802 (2), objectstack-ai#8032, objectstack-ai#8057 (2),
objectstack-ai#8153 (7), objectstack-ai#8336, objectstack-ai#8337, objectstack-ai#9040, objectstack-ai#10194, objectstack-ai#10414, objectstack-ai#13802, objectstack-ai#16041, objectstack-ai#16632,
objectstack-ai#17014, objectstack-ai#17296, objectstack-ai#17598 (2), objectstack-ai#18278, objectstack-ai#19992 (3), objectstack-ai#20011, objectstack-ai#20300 (2),
objectstack-ai#20550, objectstack-ai#20600, objectstack-ai#20808 (3), objectstack-ai#21365 (2).

- Each of these titles already states the decision it pins: for example
"empty array → deny-all (flipped semantics)" for objectstack-ai#3391, "accepts the
BARE query string — the canonical ADR-0061 D1 spelling" for objectstack-ai#7178, or
"`currencyConfig.precision` is removed: refused with the prescription,
whatever its value" for objectstack-ai#19992.
- **Small rewordings that carry no new claim:**
`analytics-strictness-batchd.test.ts:307` reads "are CLOSED now" where
it named the record; `autonumber-unanchored-boundary.test.ts:51` reads
"(ruled: mixed content is out of contract)"; `datasource.test.ts:553`
reads "(the happy path)". The circled part numbers after objectstack-ai#17598 went
with the id.
- **The two `api-methods-batch-conformance.test.ts` reason strings**
(`sys_api_key`, `sys_organization`) end "rather than hitting /batch."
now. The table is read only through `!== undefined`, so no assertion
reads their text.

## Readers

- **Test-name filters:** none. A tracked-tree search for `-t` and
`--testNamePattern` finds only `packages/qa/dogfood/README.md:142` (`-t
"owner-scoped"`), which is unrelated.
- **Snapshots:** none. No `__snapshots__` directory exists under
`data/`, and no `.snap` file is tracked under `packages/spec`.
- **Projects:** two touched files are listed in
`packages/spec/vitest.repo-tests.json`:
`api-methods-batch-conformance.test.ts` and
`currency-mode-family-closure.pin.test.ts`. Both were run in the `repo`
project at the base and at the head, and the other 18 in `local`.
- **By substring:** every old literal, plus a window around each id (289
needles), was searched across the tracked tree outside its own file. No
gate, doc, filter, snapshot or `scripts/check-*.mjs` self-test reads
one. The 14 hits are:
- **sibling titles in other lanes:** `service-analytics`
`aggregate-nontemporal-measure-refusal.test.ts:344` and `objectql`
`engine-autonumber-default-format.test.ts:248`;
- **this card's later `data/` stage:**
`data/driver/postgres.test.ts:169`, the same "placeholders are not
resolved here" title, already in the census;
- **comments, CHANGELOG, an audit ledger and liveness evidence:** `lint`
`validate-dataset-measure-aggregates.test.ts:179`, `service-analytics`
`dataset-compiler.ts:227`, `objectql` `engine.ts:6206` and `:6272`,
`analytics.zod.ts:1002`,
`docs/audits/2026-07-unknown-key-strictness-ledger.md:728`, two
`packages/spec/CHANGELOG.md` entries and the `liveness/field.json:218`
evidence string, which quotes the `engine.ts` comment. None reads a test
title.
- **Same-text titles named in stage 14's ACCEPT** (`(objectstack-ai#15680)`,
`(objectstack-ai#5955)`, `objectstack-ai#3896 close-out`): none falls in this group.

## Text-only proof

Stage 10's scratch tool (`textonly10.cjs`, md5
`d5e4801dbb4329ab1984da91e92fc47c`) compares base and head file by file
on three legs:
1. **Skeleton:** the full AST, with string pieces masked. It must be
identical.
2. **Comments:** every comment, byte-equal.
3. **Strings:** each changed string leaf must sit in a test-call title
position or on a declared line, must carry a tracker id before, and must
carry no `#` plus digits after. The declared lines are the three
reason-string leaves in `api-methods-batch-conformance.test.ts`.

- **Result:** 20 of 20 files SAME on all three legs, as predicted in
writing before the run.
- **Totals:** 95 changed literals, 92 titles and 3 declared. The diff's
`+` and `-` lines are exactly the 95 planned lines, and every file keeps
its line count.
- **Controls (10 of 10 as predicted, on scratch copies, each anchor hit
once):** identifier rename DIFF; numeric literal DIFF; comment edit
COMMENT DIFF; a non-title string given an id VIOLATION; a rewritten
title given a new id VIOLATION; a title that was id-free at base edited
VIOLATION; one title reverted to base SAME; a declared string given a
new id VIOLATION; an undeclared `expect` message changed VIOLATION; a
title re-split into a `+` chain DIFF.

**Test counts:** the 20 files were run at the base, in a separate base
worktree, and at the head, with `--project local --project repo`. Both
sides read 553 / 553 passed, with the same count and status sequence per
file in 20 of 20. 291 full test names change, and each equals the base
name with the planned replacements applied (0 mismatches). No full name
repeats on either side.

## `main` merged in, once

objectstack-ai#21800 (the console pin bump) landed while this branch was being
verified, and it rewrites the comment block at `:61-77` of
`api-methods-batch-conformance.test.ts`. This PR edits only string
literals in that file, more than 100 lines below the block, so
`origin/main` (`18c2ddc1ec`, which also carries objectstack-ai#21801) was merged in
with a plain merge, no rebase, and no conflict. The PR's delta against
`main` is still exactly the 20 files, +95 / -95. Every reading in this
body was re-taken on the merged head `bf16ad1190`, against `18c2ddc1ec`
as the base: the census (1325 / 1406 there, 1231 / 1304 here, unchanged
by the two commits), the text-only proof and its controls (the three
declared lines now sit at `:202`, `:230` and `:235`), the 20-file runs,
the full build, the suite, the typecheck and the gates. Re-fetched just
before this PR opened, `origin/main` was one commit further
(`75ddcd1b41`, objectstack-ai#21805, in `cloud-connection`, `metadata-core` and
`runtime`). It touches no `packages/spec` path and no file here, so it
was not merged.

## Changeset: `skip-changeset`

Measured, not assumed:
- `npm pack --dry-run` of `@objectstack/spec` lists 2068 files. 0 of the
20 touched files are in it, and no `*.test.ts` at all. Of `src/`, only
the `*.zod.ts` sources ship: the controls `src/data/analytics.zod.ts`,
`src/data/data-engine.zod.ts` and `dist/data/index.js` are in it.
- In the built `dist/`, five new phrases and four old literals each read
in 0 files. The control `Unrecognized key(s) on` reads in 42.

So this PR publishes nothing, and no changeset is added.

## Verification (at `bf16ad1190`)

- `pnpm turbo run build` over all packages: 71 / 71 (also 71 / 71 at the
pre-merge head `89c4b300c2`).
- `@objectstack/spec`:
  - `vitest run --project local`: 615 files, 18360 passed, 1 todo.
- `typecheck` exit 0, including `check:test-typecheck` (52 files / 246
errors / 135 pinned signatures held). Its program holds all 20 touched
files, counted with `tsc --listFilesOnly -p tsconfig.test.json`.
- `check:generated`: all 15 generated artifacts up to date after the
merge.
- **Gates:** `dispatch-gates --commands` derived 79 families, the same
set as stages 13 and 14, and all 79 exit 0. `--ran` reconciles: 79
derived, 79 run, 0 NOT-MEASURED, 0 UNRUN.
- The five roster families whose rosters sit under a touched directory
were also run, and each exits 0: `check:meta-url-spelling`,
`check:spec-changes`, `check:authz-resolver`, `check:error-code-casing`
and `check:filter-alias-parity`.
- **ESLint, a proven narrowing:** `--no-inline-config` over the 20 files
reads 0 errors and 0 warnings. The population comes from ESLint's own
config: 20 configured, 0 ignored. No file sets `parserOptions.project`
or `projectService`, so no untouched file's verdict can move.
- `check-governed-merges --test`: NOT governed, 190 changed lines.

## Acceptance notes

- **No needle in this group.** Every id was a title or a declared reason
string; no expected value of an assertion over a source docblock was
found. The three known needles are untouched.
- **Same-id test titles in other packages** are their lanes' test-string
shares. A search of `describe` / `it` / `test` lines outside
`packages/spec` finds 156 lines citing ids this PR handled, in 79 files
of 23 packages: `objectql` 73 (29 files), `rest` 18 (7), `runtime` 7
(4), `plugin-security` 6 (5), `cli` 6 (3), `lint` 6 (4),
`service-datasource` 6 (3), `driver-sql` 4 (4), `platform-objects` 4
(2), `plugin-approvals` 3 (2), `service-automation` 3 (2),
`driver-mongodb` 3 (1), `plugin-auth` 3 (1), `service-analytics` 3 (3),
`metadata-core` 2 (1), `plugin-hono-server` 2 (1), and one each in
`client`, `triggers`, `core`, `metadata-protocol`, `qa/dogfood`, `types`
and `driver-memory`.
- **Two spec test files outside `src/`** carry same-id titles:
`packages/spec/scripts/file-description.test.ts:66` and
`packages/spec/scripts/format-type.test.ts:85`. They are outside class
(e) as ruled ("the test strings shipped under `src/`").
- **Numeric delivery fields:**
`bulk-write-hook-conformance.test.ts:115-116` and `:129-130` assert
`engineDeliveryIssue: 5038` / `5574`, numbers in the source contract
table. They are not strings, the gate's pattern cannot see them, and
they are not this card's share.
- **Code comments still carry ids** in these files and their sources,
for example the header of `analytics-strictness-batchd.test.ts` and the
`SINGLE_RECORD_WRITE_ONLY` comments in
`api-methods-batch-conformance.test.ts`. Comments are not this card's
share, and none is touched here.

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

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Oct 7, 2026
…stalled packages that ship its name (objectstack-ai#21803) (objectstack-ai#21844)

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

## What changes

Two installed code packages may ship one `(type, name)` (ADR-0048 §3.4).
The ADR-0010 `_lock` gate looked the packaged artifact up with no
package, so it bound the first package registered. `getMetaItem`,
`getMetaItemLayered`, the list and the `getMetaDiagnostics` tile looked
it up with the request's package. So one item had two lock answers, and
the door's answer depended on registration order. Measured on the real
`SchemaRegistry` (H1 below), the defect runs in both directions:

- B ships `_lock: 'full'`, A ships no lock, **B registered first**: a
read naming A says `none` while the door refuses. This is the card's
case: it fails closed.
- Same arrangement, **A registered first**: a read naming B says `full`
while the door **admits**. This one fails open, because B's packaged
lock is not enforced at all.

Now:

- **One selection of the artifact layer.**
`resolveArtifactLockLayer(address, artifactsOf)` in
`packages/metadata-protocol/src/item-lock.ts` returns the artifact of
every installed package that ships the name, with the address's own
package first and then the others by package id. The protocol's reader
is `shippedArtifactsOf`, wrapped per address as `artifactLockLayerAt`.
The gate's artifact limb, both reads, the list items and the diagnostics
tile all take the layer there. No caller picks an artifact for the lock.
- **The lock is the strictest per-package answer.** `resolveItemLock`
reads the layers once per shipping package: that package's artifact over
the stored rows in scope, first binding layer wins, exactly the rule as
before. The item's lock is the strictest of those answers. With one
package shipping the name, or none, the answer is unchanged.
- **Content stays prefer-local.** A read naming A is still served A's
artifact under A's provenance; only its lock family follows the binding
package.
- **The served body states the resolution's answer**
(`withItemLockFamily`, which replaces `withOverlayLockFamily`). That
means the binding layer's lock family, and no `_lock*` key when nothing
binds. This also settles the position folded in from 5988387087 (H6
below).
- The overlay rows are read lazily as before: only when some shipping
package's artifact declares no lock, or no package ships the name.

## Rulings honoured, and the one mechanism hypothesis falsified

- Triage's direction (5987395126, restated in 5987813232): strictest
among the installed packages that ship the name, never a widening, one
resolution for the gate and both reads, no prefer-local artifact lock at
the door. All honoured.
- **H3, taken literally, widens, so the route changed.** The hypothesis
was to make the artifact layer the strictest artifact, then keep "first
binding layer wins". The BASE census shows a cell where that admits a
save the door refused. A ships no lock, B ships `no-delete`, and the
env-wide row declares `no-overlay`. With A registered first, the door at
BASE refuses the save, because A's artifact does not bind and the row's
`no-overlay` does. A strictest artifact layer would bind B's `no-delete`
and admit it. So the resolution takes the strictest **per-package**
answer instead. That is the ruling's own wording read per package, and
its "never a widening" holds by construction: the artifact the gate
bound before is always one of the shipping packages. That cell now
refuses both verbs under both orders (pin 9).

## H1: the artifact lookups at BASE `c4d57131b5`, on the real
`SchemaRegistry`

B ships `_lock: 'full'`, A ships no lock. Each call is listed with which
package's artifact it bound.

| order | request | gate (`getEffectiveLock`) | save / delete |
`getMetaItem` = `getMetaItemLayered` | list slots | tile locked |
|---|---|---|---|---|---|---|
| B first | no package | B: full | refused / refused | B: full | B full,
A none | 1 of 2 |
| B first | naming A | B: full | refused / refused | **A: none** | A
none | 0 of 1 |
| B first | naming B | B: full | refused / refused | B: full | B full |
1 of 1 |
| A first | no package | A: none | admitted / admitted | A: none | A
none, B full | 1 of 2 |
| A first | naming A | A: none | admitted / admitted | A: none | A none
| 0 of 1 |
| A first | naming B | **A: none** | **admitted** / admitted | B: full |
B full | 1 of 1 |

The call sites:

- The gate's artifact limb (`lookupArtifactItem(canonicalType, name)`)
binds the first package registered, whatever the request names.
- `getMetaItem` and `getMetaItemLayered` (`lookupArtifactItem(type,
name, packageId)`) bind the named package's artifact, or the first
registered when no package is named.
- The list items and the diagnostics tile (`packageId ??
item._packageId`) bind each slot's own package.
- `mergeArtifactProtection` put whatever its caller had looked up.

With B **disabled**, the gate bound B only when B was registered first,
while a read naming A said `none` under both orders. After the change,
every row above reads `full` with B's prose, is refused on both verbs,
and keeps prefer-local content.

## H2: what the registry enumerates

`SchemaRegistry.getArtifactItem(type, name, packageId?)` answers one
entry: the asked package's own, else the first composite registered. The
registry has no method that returns them all. The reader composes
existing public methods only, so **no new public surface**:

- `listItems(type)`, indexed by name, gives the packages whose entries
ship each name. It hides a disabled package's entries.
- `getAllPackages()` plus `isPackageDisabled(id)` add the disabled
packages, probed for every name.
- `getArtifactItem(type, name, P)` per candidate package, kept only when
it is P's own (`_packageId === P`). Line 17607's view-expansion code
already uses this idiom.
- The package-less `getArtifactItem(type, name)` is always in the set.
It is the old gate's answer, and the only way to reach a plain-key
artifact.
- An `object` has one owner (ADR-0029 D3), so its single owner-layer
lookup is the whole set.
- A registry whose listing throws contributes no package ids, so the set
falls back to the package-less lookup. That is never looser than the old
gate (pinned by `protocol.runtime-authoring-gate.test.ts`).

## H4: census, before and after, on the real `SchemaRegistry`

There are 2000 door verdicts, from A's lock (5 values) × B's lock (5) ×
the env-wide row (5) × registration order (2) × request shape (save
naming none, A or B; delete naming none) × B enabled or disabled. Every
verdict comes from the real gate (`getEffectiveLock`).

| transition | cells |
|---|---|
| refused → admitted (widenings) | **0** |
| admitted → refused (narrowings) | 416 |
| unchanged | 1584 |

Verdicts that depend on registration order: **416 of 1000 cell pairs
before, 0 after.** The narrowings split evenly, 26 per (enabled or
disabled × order × shape × verb) group.

## H5: the family's enumeration pin

`protocol.lock-one-resolution.test.ts` pin 1 is now the family's
enumeration:

- **Positions.** Each position is a layer of `ITEM_LOCK_LAYERS` × a
family axis (topology, organization, package), with the table axes that
open it and the card that measured it.
- **Completeness check, failing by name**, when:
  - a layer × family axis has no position;
  - a position is opened by no slice;
  - an axis of the table belongs to no position;
  - an `ITEM_ADDRESS_FIELDS` field has no axis.
- **Rows.** The union of three slices, each a full product: 24 000 rows
in all, each run under both stored-row orders and both registration
orders.
- The family product: PR objectstack-ai#21801's 16 320 rows. It is checked against a
frozen copy of their own titles, so no row is lost.
- **The artifact layer × package** (7 200 rows): another installed
package ships the name at each lock level, crossed with the package's
own artifact, the env-wide row's lock, every request shape (no package,
the package, the other package), both organization scopes, both
topologies and both spellings.
- **The stored rows × organization × package** (480 rows): the
organization holds only another package's row.
- **Every row asserts** that both reads report the oracle's lock, that
the door's verdict matches, that the door agrees with the envelope (bar
the one declared spelling difference), and that a served body states the
envelope's lock.
- **Named pins:**
- **pin 8**, the card's case under both orders, for reads naming A, B or
no package, plus the list and the tile;
  - **pin 9**, the never-widening join;
  - **pin 10**, a disabled package;
  - **pin 11**, the folded position.
- The same case runs on the **real `SchemaRegistry`** in
`packages/objectql/src/protocol-lock-artifact-package-axis.test.ts`,
because `@objectstack/metadata-protocol` cannot import objectql.

## H6: the folded position (5988387087)

This is fixed without a ruling. Only the served body disagreed: the
envelope and the door already agreed on `none`. `withItemLockFamily`
writes the resolution's answer onto the body and removes any `_lock*`
key the answer does not carry. Pin 11 covers it; the layered read still
reports the stored layer as stored. Slice 3 of the pin carries the same
position.

## Reverse verification (one-shot, committed state)

- **Gate limb.** At `323ab0b07a`, through `scripts/ablation-replace.mjs`
in wrap mode, the gate's artifact limb was put back to the
package-agnostic first artifact
(`[this.lookupArtifactItem(canonicalType, name)]`). The anchor went 1 →
0 and the blob `182c6677` → `c3840b80`.
  - The pin file went **2506 red** / 21538 green.
- **Pin 8 is red under "package A registered first"** for all three
request shapes, and green under "B registered first".
- Also red: pin 9 (6), pin 10 (1), and 2496 rows of the artifact ×
package slice. Nothing red outside rows where another package ships the
name.
- Restored with `git checkout HEAD -- PATH`: the blob equals HEAD
(`182c6677`) and `git diff HEAD` is empty.
- **H6 body clearing.** Ablated the same way: **14 red** (the 12 slice-3
cells of the folded arrangement, plus pin 11 × 2). Restored, with `git
diff HEAD` empty.

## Tests

- `pnpm --filter @objectstack/metadata-protocol test` at `323ab0b07a`:
214 files passed (3 skipped), 27 745 tests passed (19 skipped).
- objectql, the 30 test files that touch locks or read envelopes (the
new one included), at `323ab0b07a`: 940 passed. The real-registry file
again at `d1551fde71`: 14 of 14 (the last commit only made its `find`
double hold `limit`).
- `typecheck` for both packages at `323ab0b07a`: clean. objectql's test
layer (`check:test-typecheck`) compiles the new file, with no new debt.

## Gates, at `d1551fde71`

- `node scripts/pm/dispatch-gates.mjs --commands` derived 74 commands,
and the artifact-roster block printed 54 more outside that total. With
the four symbol-anchor sweeps (`check:adr-symbol-anchors`,
`check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors`,
`check:adr-anchors`) that makes **131 commands: 128 exit 0.**
- 3 are **NOT MEASURED** locally (NOT WIRED: they need a pull request
context): `check-closing-target-claim`, `check-partof-closing-keyword`
and `check-single-claim-paths`. Their guard workflows run them on this
PR.
- `--ran` reconciliation: 74 derived, 74 run, 0 NOT-MEASURED, 0 UNRUN,
with every exit code recorded.
- `check:engine-double-contract` asked for one pin row for the new
objectql double. It was recorded through its own `--write`.

## Lint (a proven narrowing; the repo-wide `pnpm lint` is CI's run)

Run: `eslint --no-inline-config --format json` over the four touched TS
files: 4 files, 0 errors, 0 warnings.

- **Population.** Read from eslint's own config: `isPathIgnored` is
false for all four.
- **Count.** Read from the JSON output.
- **Invariance.** `eslint.config.mjs` never enables type-aware linting
(no `parserOptions.project`, no typed rules), and its plugins are local
per-file AST rules with no import resolution. The one removed export
(`withOverlayLockFamily`) has zero remaining references. So this diff
cannot move any untouched file's verdict.

## Landing points

- `packages/metadata-protocol/src/item-lock.ts` and `protocol.ts`, as
the claim's file surface said.
- The pin (`protocol.lock-one-resolution.test.ts`) and the changeset.
- Outside that surface:
- `packages/objectql/src/protocol-lock-artifact-package-axis.test.ts`:
the real-registry pin, since the protocol package cannot import
objectql.
- `scripts/engine-double-contract.pinned.json`: the gate's own
`--write`.
- No `packages/spec/src/**`, no governed surface, no new public export:
`item-lock.ts` is not re-exported from the package entry.

## Acceptance notes

- `packagedArtifactOwner` (the ADR-0126 flow-owner classification) and
`isArtifactBacked` still take the package-less first artifact. They
classify owner and existence, not a lock: existence does not depend on
order, but the reported owner of a flow name two packages ship is the
first registered. This is not a lock-family position; noted, not filed.
Carrier: none.
- A read can report a lock with no body served: content never serves
another package's row, while the lock's scope includes it. The envelope
and the door agree there, so the pin asserts the body only when one is
served.
- A served body's lock family is now normalized through
`extractProtection`. An explicit `_lock: 'none'` is no longer kept on a
body, and a `_lockSource` outside the spec's enum is not echoed. The
full metadata-protocol suite and the objectql envelope tests stay green.
- Cost: the list and the tile read the registry's listing once per type
and probe only the packages that ship each name (commit `f38762577c`).
The gate reads the listing once per write.

---
_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
…nce, so org-owned sets, clones and runtime-package sets edit again (objectstack-ai#21857)

Fixes objectstack-ai#21789
Clause-②: no

## What this changes

The packaged-permission-set lock in `plugin-security` answers one
question for both write doors: is this set shipped by a code (artifact)
package? It answered it as "does any engine-registry item of this name
carry a package id?". The registry holds stored rows as well as
artifacts, and the metadata list read (`GET /api/v1/meta/permission`,
which every Studio page load issues) stamps a stored row's `package_id`
column onto its body as `_packageId`. So a set saved into a writable
runtime package looked code-shipped after the first list read.

The read the console renders from had the matching defect. The security
plugin keeps a marked copy of every overlay-backed definition in the
metadata manager for the evaluator (the "projection echo"), and the
protocol's layered read serves that copy as the item's `code` layer. The
echo carried no provenance, so an org's own set, a clone and a
runtime-package set all reported a `code` layer with no `provenance`.
objectui's permission-matrix editor reads exactly that as "a code
package ships this" and rendered them locked, while every write door
accepted the save.

Two edits, both in `packages/plugins/plugin-security/src`:

1. `packaged-permission-set-lock.ts`, `declaredPackageIdOf`: a
tenant-authored item (ADR-0010 `_provenance: 'org'`, the stamp the
hydrator writes on every stored row) is never a shipped artifact. It is
read through `isTenantAuthored` from `@objectstack/metadata-core`, the
exclusion `isCodeArtifactBody` and `SchemaRegistry.getArtifactItem`
already apply. No second provenance evaluator. A stored row of a name a
code package ships is hydrated wearing the artifact's envelope
(`_provenance: 'package'`), and the artifact itself is in the same list,
so a code-shipped set stays locked.
2. `permission-set-projection.ts`: the projection echo carries
`_provenance: 'org'` exactly when `classifyPackagedPermissionSet` (the
classifier both write doors ask, fed the same layered probe) answers
`org` for the name. A `packaged` or `unknown` verdict leaves the echo
unstamped, as before. The reported state and the enforced state are one
judgment.

Not touched: `metadata-protocol` (H4 was not needed), `packages/spec`,
any error code, any export, any parameter of an exported function (the
new parameter is on the module-private `syncEvaluatorRegistry`). The
lock-resolution semantics from objectstack-ai#21801 are unchanged.

## Measurements, before and after

Driven through the real showcase over HTTP, base `088428fb` (before) and
this branch (after):

| shape | before: door (`PUT /meta` after the list read, `PATCH /data`)
| before: layered read | after: door | after: layered read |
|---|---|---|---|---|
| set in a writable runtime package | 403 `NOT_OVERRIDABLE` / 403
`NOT_OVERRIDABLE` | `code` = echo, no `provenance`, `editable: true` |
200 / 200 | `provenance: 'org'`, `editable: true` |
| org-owned set (data door) | 200 / 200 | `code` = echo, no
`provenance`, `editable: true` | 200 / 200 | `provenance: 'org'`,
`editable: true` |
| clone ("Clone to customize") | 200 / 200 | `code` = echo, no
`provenance`, `editable: true` | 200 / 200 | `provenance: 'org'`,
`editable: true` |
| control: `showcase_contributor` (shipped by `com.example.showcase`) |
403 `NOT_OVERRIDABLE` / 403 `NOT_OVERRIDABLE` | `code._packageId` = the
package, `provenance: 'package'`, `editable: false` | unchanged |
unchanged |

The runtime-package set's registry row after the list read was `{
_packageId: 'com.dogfood.lock21789', _provenance: 'org' }`: the
provenance that tells it apart was on the body all along.

## Mechanism hypotheses, measured

- **H1, holds.** The lock read any non-sentinel `_packageId` as
code-shipped. The three shapes carry, in the registry: runtime-package
set `{ _packageId: PKG, _provenance: 'org' }` (the package id appears
only after a list read; neither the write-through nor the boot hydration
stamps it), org-owned set and clone `{ _provenance: 'org' }`, no package
id. The clone's record has `created_by` and `organization_id` null, but
so do the org-owned set's and the runtime-package set's records: it is
not specific to the clone.
- **H2, holds.** The platform's one answer is `isCodeArtifactBody` /
`isTenantAuthored` in `@objectstack/metadata-core` (already a dependency
of `plugin-security`). The lock reuses `isTenantAuthored`; it keeps its
two documented extensions (the echo-marker skip and the spec `packageId`
fallback).
- **H3, holds, with a refinement.** The org-owned set and the clone were
never refused by the server (both doors 200 before the fix); their
"lock" was report-only. The runtime-package set was refused by both
doors while the server's own `editable` said `true`. So the reported
state and the enforced state were split in both directions, and the fix
pins both.
- **H4, not needed.** No `metadata-protocol` edit: the layered read
already reads `provenance` off the `code` layer, and the echo now states
it.
- **H5, the lock's judgment (the smaller one).** Stamping the clone's
`created_by` / organization would not change anything the lock or the
console reads: the server lock already answered `org` for the clone, and
the console's lock came from the echo's missing provenance.
- **H6, holds.** The code-shipped set is still refused at both doors
with `403 NOT_OVERRIDABLE` (the data door's refusal is the lock's own
sentence naming the clone path), and its layered read still reports
`provenance: 'package'`, its package id and `editable: false`, before
and after a cold boot.

## Pins

- `packaged-permission-set-lock.test.ts`, block `[objectstack-ai#21789]`: the
classifier over the bodies the hydrator registers (runtime-package row,
org-owned row, clone; a shipped artifact, alone and beside a legacy
overlay wearing its envelope, in both orders), the layered probe with no
registry, the data door (hatch-open double, so only the lock can
refuse), and the metadata-door gate, with the refusal asserted on `code`
and `status`.
- `permission-set-projection.test.ts`: the echo of a set no code package
ships carries `_provenance: 'org'`; the control shows the echo of a
legacy overlay of a shipped set does not.
-
`packages/qa/dogfood/test/permission-set-lock-row-provenance.dogfood.test.ts`
(new): the showcase, the three shapes made through their real doors
(`POST /packages` then `PUT /meta/permission/NAME?package=PKG`; `POST
/data/sys_permission_set`; the shipped `clone_permission_set` action's
own payload), the list read, a precondition that the list read stamped
the package id, then both doors and the layered read for each shape, the
code-shipped control at both doors and on the read, and a cold boot on
the same file that reads the three shapes again (the echo minted by the
boot's reconciliation) and re-checks the control.

## Ablations (each committed first, mutated through
`scripts/ablation-replace.mjs`, rebuilt, dist proven, restored to
`HEAD`)

Both ablations were run at `e9dff47f` (the fix and its pins committed,
pre-merge), each through `node scripts/ablation-replace.mjs` (anchor
hits went from 1 to 0, blob changed), then `pnpm turbo run build
--filter=@objectstack/plugin-security`, then `node
scripts/ablation-dist-preflight.mjs @objectstack/plugin-security MARKER
--absent` (exit 0: marker absent from every built file), because the
dogfood suite resolves `plugin-security` from `dist/`. Each restore was
proven by blob equality with `HEAD` and an empty `git diff HEAD`, then a
rebuild and the preflight without `--absent` (exit 0, marker back in
`dist/index.js`, tree clean).

| ablation | what was put back | unit result | dogfood result |
|---|---|---|---|
| 1 | the lock reads "has a package id" again: `if
(isTenantAuthored(item)) return null;` replaced by a no-op | 5 failed /
87 passed: the four classifier/door pins for the runtime-package shape,
and the echo pin | 2 failed / 12 passed: runtime-package set, metadata
door and data door |
| 2 | the echo states no provenance again: the `_provenance: 'org'`
spread replaced by an empty one | 1 failed / 91 passed: the echo pin | 4
failed / 10 passed: the layered-read pin for each of the three shapes,
and the cold-boot read |

The controls (a code-shipped set refused, and its read reporting
`provenance: 'package'`) stayed green in both directions, as they must.
A first attempt at ablation 1 used a replacement that left the
`isTenantAuthored` import unused, so the DTS step of the build failed
(the JS bundle still carried the ablation and the same pins went red);
it was redone with the import kept in use, and the numbers above are
from the clean run.

## Tests and gates

All on `9e3e32ed` (this branch after merging `origin/main` `8832655a`,
which carries objectstack-ai#21812 and touches `plugin-security`), after rebuilding
the dogfood dependency closure:

- `pnpm --filter @objectstack/plugin-security exec vitest run
--maxWorkers=2`: 167 files passed, 3600 tests passed, 45 skipped.
- `pnpm --filter @objectstack/plugin-security typecheck`: exit 0
(including `check:test-typecheck`: 0 errors).
- `pnpm --filter @objectstack/dogfood exec vitest run --maxWorkers=2
test/permission-set-lock-row-provenance.dogfood.test.ts`: 14 passed.
`pnpm --filter @objectstack/dogfood typecheck`: exit 0.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 71 gate commands; all 71 run, every
exit 0, `--ran` verdict: 71 derived, 71 run, 0 NOT-MEASURED, 0 UNRUN.
`check:dual-build-cjs-loads` first answered exit 3 (PREREQUISITE NOT
MET: eight packages outside the dogfood closure had no `dist/`); those
eight were built and the gate re-run, exit 0.
- Lint, narrowed and proven: `pnpm exec eslint --no-inline-config
--format json` over the five touched TypeScript files (the changeset is
not linted): 5 files in the JSON output, 0 errors, 0 warnings. The
population is the files this diff touches; `eslint.config.mjs` never
enables type-aware linting (no `parserOptions.project`, no typed rules,
stated in its own header), so this diff cannot move the verdict on any
untouched file. The repo-wide `pnpm lint` is CI's.

## Acceptance notes

- **Not in this PR: `metadata-protocol`.** Measured, the layered read
did not need to change for the read and the lock to agree: it reads
`provenance` off its `code` layer, which is the plugin's projection
echo, and all three shapes read `provenance: 'org'` with `protocol.ts`
byte-identical to `main`. No shape is left unfixed without a protocol
edit. This PR's file list is disjoint from objectstack-ai#21844's (`item-lock.ts`,
`protocol.ts`, two protocol/objectql tests, the engine-double ledger,
its changeset).
- **Observation, not filed (carrier: the `domain:engine` seat, objectstack-ai#21844
holds the region).** For a set no artifact ships, the layered read's
`code` layer is still a non-null body (the echo, read through
`readItemFromMetadataService` in `getMetaItemLayered`), while
`GetMetaItemLayeredResponseSchema.code` says `null` when no artifact
ships the item. The registry fallback right below it already drops a
tenant-authored item (`runtimeOnly`, `isTenantAuthored`); the
MetadataService read does not. After this PR the echo is tenant-stamped,
so a provenance-only filter there would answer `code: null` for these
sets; no client reads a wrong answer today, which is why it is noted
here rather than built.
- **Finding, reported for the seat to file (same family: a package id
read as "shipped by code").** The Discard Overlay action's eligibility
(`permission-set-overlay-discard.ts`, `discardPermissionSetOverlay`)
reads `_packageId ?? packageId` on the registry item, as the lock did.
Measured on `088428fb` and again on this branch: after a list read,
`POST /api/v1/security/permission-sets/ID/discard-overlay` on a set
saved into a writable runtime package answered 200 and deleted the set's
only `sys_metadata` row. The action declares, and
`content/docs/permissions/permission-sets.mdx` repeats, that it refuses
any set that is not currently package-declared.
`permission-set-drift.ts`'s declared filter carries the same reading.
Not fixed here: outside the claimed file surface.
- **Finding, reported for the seat to file.** A data-door edit (`PATCH
/api/v1/data/sys_permission_set/ID`) of a set saved into a writable
runtime package writes a second, package-less active `sys_metadata` row
carrying the edit and leaves the package-bound row unchanged (the
write-through's update leg calls `saveMetaItem` without the row's
package). Measured on this branch: two active rows after one PATCH; the
projected record reads `managed_by: 'admin'`, `package_id: null`. It is
reachable on `main` before any list read, and through a package-less
`PUT /meta`; this PR lets the data door accept the edit after a list
read too.
- **H5.** The clone's record has `created_by` and `organization_id`
null, and so do the other two shapes' records: the projection writes
them in system context. It is not what locked the clone, and is not
changed here.
- **Docs.** No `content/docs` sentence is made false by this change;
`content/docs/concepts/metadata-lifecycle.mdx` (runtime-created sets,
package-bound rows included, keep working) becomes true.
`content/docs/permissions/permission-sets.mdx` still says an edit of a
packaged set through Setup becomes an environment overlay, which the
lock has refused since the clone-to-customize ruling; that is older
drift, not touched here.
- **Report state.** `Clause-②: no` is copied from the claim: the fix
restores the lock's declared population (code-shipped sets) and widens
no accepted input; a code-shipped set is refused exactly as before.

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

---------

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/xl tests tooling

Projects

None yet

2 participants