Repository navigation
fix(metadata-protocol)!: the ADR-0010 _lock gate reads the row the read serves for the request's organization (#21716) - #21737
Conversation
…the read serves for the organization getEffectiveLock's overlay limb asked for organization_id equal to the request's organization only, while getMetaItem and getMetaItemLayered resolve the org-scoped row, else the env-wide row (ADR-0005). An env-wide row declaring _lock: full read locked for an organization with no row of its own, and that organization's save, publish, rollback and delete were admitted. One resolution, findServedOverlayRow, now serves both reads (the draft preview arm included) and the gate's overlay limb, behind the reads' own organizationIdForMetaRead gate. The family's enumeration pin drives the read and the door over topology x row scope x request scope x every lock level x operation on one protocol instance per row. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
…g inside the shared served-row resolution The reads' at-rest tolerance for a row stored under the type's other spelling is not extended into the write path: a write addresses the canonical namespace only. It is the one declared difference, a parameter of findServedOverlayRow rather than a second query. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
…res with the reads Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
…ck-org-axis-agree
…e organization Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
…e-double ledger Written by check-engine-double-contract --write: 1 added, 0 lost. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
📓 Docs Drift CheckThis PR changes 1 package(s): 15 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 6 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c173ff2de1b3e393b9ff896285742a3caee2b378 && git checkout c173ff2de1b3e393b9ff896285742a3caee2b378
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 316be321ef2405a12e8d016f07b25fad7039b4f9 f508c21ce30f499c5125858d8bc55023890e874c && git checkout -B drift-repro 316be321ef2405a12e8d016f07b25fad7039b4f9 && git merge --no-ff f508c21ce30f499c5125858d8bc55023890e874c
node scripts/docs-audit/affected-docs.mjs --json 316be321ef2405a12e8d016f07b25fad7039b4f9
|
REWORK (narrow, one changeset sentence) — PR #21737 at head
|
…row resolution makes The layered read now serves an organization's own stored row whose body is JSON null, as getMetaItem already did, instead of falling back to the env-wide row. No live writer stores a null body. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi
ACCEPT — PR #21737 at head
|
…from the item-lock resolution's layers (#21738) protocol.lock-one-resolution.test.ts: 1632 generated rows (artifact x stored row x request scope x topology x request spelling x operation), a completeness check keyed by ITEM_LOCK_LAYERS, PR #21737's 64 rows checked as a subset (moved out of protocol.lock-org-axis-agree.test.ts), and positions 1 and 2 as named cases. The #5840 outage pin's healthy leg now declares its locked artifact in the registry, the layer the gate reads. Claude-Session: https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi Co-authored-by: Claude <noreply@anthropic.com>
…tead of a tracker number (stage 12) (objectstack-ai#21750) Part of objectstack-ai#20749 Clause-②: no Stage 12 of this card, and the third area of class (e): the test strings shipped under `packages/spec/src`, as ruled in `5902360492` on objectstack-ai#20513. This stage takes the whole `shared/` directory. Its 85 test-title and test-string literals carried 95 tracker ids: 92 cite 49 records, and 3 are a `#0000` placeholder that cites none. Each id 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 (`72b6333cd8`, the claim's base) Instrument: stage 10's `census10.cjs` (md5 `9d08602ab972b4b8643c90d64d40fa41`) and stage 9's `census.cjs` (md5 `6e42a45a926d375013c32d62f16a296e`), both byte-identical to the copies stages 10 and 11 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 **1594 messages / 1701 ids in 369 files at the base**, which is stage 11's reading at its head exactly. `shared/` reads 85 / 95, also stage 11's figure. | directory | files | messages / ids | titles | other | |:--|--:|--:|--:|--:| | `data/` | 95 | 468 / 501 | 445 / 475 | 23 / 26 | | `ui/` | 81 | 392 / 415 | 374 / 397 | 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 | | **`shared/`** (this PR) | 21 | **85 / 95** | 73 / 81 | 12 / 14 | | `contracts/` | 25 | 63 / 74 | 59 / 70 | 4 / 4 | | `conversions/` | 9 | 34 / 34 | 34 / 34 | 0 | | `security/` | 8 | 28 / 28 | 28 / 28 | 0 | | `ai/` | 9 | 18 / 20 | 13 / 15 | 5 / 5 | | `identity/` | 6 | 15 / 15 | 14 / 14 | 1 / 1 | | `integration/` | 4 | 14 / 14 | 13 / 13 | 1 / 1 | | `migrations/` | 2 | 9 / 12 | 9 / 12 | 0 | | `marketplace/`, `meta-spelling/`, `studio/` | 5 | 7 / 7 | 7 / 7 | 0 | | **total** | **369** | **1594 / 1701** | **1495 / 1596** | **99 / 105** | - **Controls.** Lit, a title: `shared/duration.test.ts:45` reads one message with objectstack-ai#18122. Lit, an `expect` message: `shared/retry-policy.test.ts:113` reads one message. Dark: the comment at `shared/alias-integrity.test.ts:519` ("objectstack-ai#5593 migrated the last of them") reads 0; the file's messages sit at `:516`, `:770` and onwards. Planted in a scratch copy: an id added to a title reads 1 / 1, and an id in an added comment reads 0. - **A wider pattern** (any `#` plus digits) reads 87 / 97 under `shared/` at the base. The two extra hits are `(objectstack-ai#1)` placeholders in two fixtures of `retired-key-migrate-sentence.test.ts` (`:378`, `:380`, the "accepts the two legal shapes" case). They fall outside the gate's 3-to-5-digit pattern and the claim's surface, and are untouched. At the head the wider pattern reads only those two, and the gate pattern reads 0 / 0. - **At the head:** 1509 messages / 1606 ids in 348 files. `shared/` reads 0 / 0. Nothing else moved. ## How the area was chosen Stage 10's rule: rank whole first-level directories by ids, and take the busiest one within about 10% of the ~100-id bound. The four busiest each exceed the bound alone: `data/` (501), `ui/` (415), `api/` (201) and `system/` (165). The files directly in `src/` (120) are 20% over. `shared/` (95) is the busiest whole directory within the bound, and its census reads exactly stage 11's 95, so the rule needed no second pass. **Named for the next stages:** `data/` (about five stages, by subdirectory or file group; `data/driver/` alone is 52), `ui/` (about four), `api/` (two), `system/` (two), the files directly in `src/` (one, 120), `contracts/` with `conversions/` (one, 108), and `security/`, `ai/`, `identity/`, `integration/`, `migrations/`, `marketplace/`, `meta-spelling/` and `studio/` together (one, 96). ## What each id became 22 ids now state a decision in words, one per literal. 73 are dropped: 67 where the title already says what a live record decided, 3 dead numbers whose decisions were read from what landed, and the 3 `#0000` placeholders. Every cited record was read with its comments through REST. 46 answer 200. Three answer 404, and their decisions were read from their landing commits. | record | ids | result | |:--|--:|:--| | objectstack-ai#7889 | 3 of 5 | The three synthetic `history` strings in the reachability self-test read "n/a — planted for the reachability self-test". Nothing reads them: `unreachableAliasRows` reads only `aliases` and `guidanceSets`. Dropped from the 2 titles that state the check. | | objectstack-ai#6416 | 1 of 2 | "the three hand-written error maps are FOLDED and judged here". Dropped from the `expect` message that already explains the blind spot. | | objectstack-ai#6619 | 1 of 6 | "the two maps the first fold MISSED": objectstack-ai#6619 folded three maps, and objectstack-ai#6805 found two more. Dropped from 5 literals that state the fold. | | objectstack-ai#7887 | 1 of 4 | "editability lives on fields — the boundary prescription an author actually sees". That is the maintainer's boundary ruling: sections and page components gate visibility only. Dropped from 3 titles. | | objectstack-ai#5955 | 1 of 2 | "(the fix-first order survives the new set)": the history sentence is emitted last, so the author reads the fix first. Dropped once. | | objectstack-ai#15811 | 1 of 2 | "evaluated-slot population — no declaring position still mounts the persistence schema". Dropped once, where the title states the rule. | | objectstack-ai#3278 | 1 | "never re-advertises `js`, retired in favour of ScriptBody". objectstack-ai#3278 retired the `js` expression dialect as redundant with the L2 `ScriptBody`. | | objectstack-ai#7739 | 1 | "EXTERNAL_ERROR_HTTP_STATUS — each external error carries an HTTP status". The title was the symbol alone. Its landing gave the whole family one status table, so none can leak as a bare 500. | | objectstack-ai#19101 | 1 of 2 | "lazySchema × z.toJSONSchema — a lazy reference keeps its metadata". The ruling fixed the root cause in `lazySchema`, so descriptions no longer depend on which generator ran. Dropped once. | | objectstack-ai#8421 | 1 of 2 | "(flipped once unknown type names were refused)", where the title said "objectstack-ai#8421 flipped this". Dropped once. | | objectstack-ai#16845 | 1 of 2 | "ProtectionSchema — unknown-key refusal names the surface and the rename": the block adopted `strictObject`. Dropped from "accept set is unchanged". | | objectstack-ai#12840 | 1 | "acceptRetiredDefaultResidue — a retired default parses as inert residue". The title was the symbol alone. | | objectstack-ai#17109 | 1 | "enumWithRetiredValues — value-level retirement beside retiredKey()": the maintainer chose a generic helper, not a one-enum refinement. | | objectstack-ai#6414 | 1 | The `expect` message reads "the prescription must not point at the retired ETL layer": objectstack-ai#6414 retired L2 `ETLPipeline`. | | objectstack-ai#4001 | 2 | The synthetic widget `history` reads "Until unknown keys were refused, these were dropped silently". Both copies change together. The test asserts the message ends with that exact constant, so the fixture and its expectation move as one. | | objectstack-ai#7832 | 3 of 5 | "the reverse-direction alias curation added here", "already curated before the reverse-direction sweep", and "the two `visibleWhen` shapes the first sweep never enumerated". objectstack-ai#7832 was the reverse direction of the action shapes' alias table. Dropped twice. | | objectstack-ai#7816 | 1 | "the boolean `enabled: false` first". objectstack-ai#7816 noted that the boolean case must point at `enabled`, or the hint just moves the confusion. | | dropped only (live) | 49 | objectstack-ai#4661 (2), objectstack-ai#4962, objectstack-ai#4964, objectstack-ai#4971, objectstack-ai#4990, objectstack-ai#5389 (3), objectstack-ai#5481 (2), objectstack-ai#5552, objectstack-ai#5593 (2), objectstack-ai#6630, objectstack-ai#6805 (4), objectstack-ai#6856, objectstack-ai#7030, objectstack-ai#8201, objectstack-ai#8202 (2), objectstack-ai#8318 (4), objectstack-ai#8382 (2), objectstack-ai#8424, objectstack-ai#9529 (3), objectstack-ai#7894 (4), objectstack-ai#10096, objectstack-ai#10848, objectstack-ai#13859 (3), objectstack-ai#15028, objectstack-ai#15035, objectstack-ai#15423, objectstack-ai#15430, objectstack-ai#16320, objectstack-ai#18122. Each title already states the pinned decision. | | dropped only (404) | 3 | objectstack-ai#6085 (landing `026101660`: the dialect table drops `js`, adds `template` and states it IS the enum), objectstack-ai#10485 (`35ad101`: the `themes` carrier key and `ThemeSchema` retired, `/meta/theme` refused), objectstack-ai#11166 (`735f5c709`: `SchemaDiffEntry` gains a distinct `unreachable` kind). Each title already carries what landed. | | `#0000` | 3 | A placeholder in synthetic fixtures (`retired-key.test.ts:26`, `:28`; the planted source in `retired-key-migrate-sentence.test.ts:342`), cited no record. Dropped. The two `retired-key.test.ts` constants are both input and expectation, so the byte-for-byte assertions move with them. It is the same reasoning by which objectstack-ai#17109's patch round took the made-up `(ADR-0000)` out of these fixtures: a made-up number squats on a real one. | ## 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. `shared/` has no `__snapshots__`, and no `.snap` file is tracked under `packages/spec`. - **Gates that read a touched file:** - `scripts/check-future-spec-major.mjs` keeps a witnessed ledger entry for `retired-key.test.ts` (`witness: /was removed in @objectstack\/spec 99/`, `covers: 4`). The rewritten constants still carry that sentence. `check:future-spec-major` was derived for this diff and exits 0, "every witness still matching". - `packages/spec/vitest.repo-tests.json` lists `retired-key-migrate-sentence.test.ts`, so that file runs in the `repo` project. It was run there at the base and at the head. - **Titles by substring:** every old literal, plus a window around each id (221 needles), was searched across the tracked tree outside its own file. No gate, doc, filter or script reads one. The 21 needle hits land on 8 lines: - Sibling titles in other lanes' files: `packages/cli/test/format-zod-union.test.ts:301` (`[objectstack-ai#5389] formatZodErrors …`) and `packages/metadata-protocol/src/protocol.unrecognised-meta-type.test.ts:171` (`[objectstack-ai#10485] \`theme\` …`). - A later stage's row in this package: `packages/spec/src/ai/knowledge-source.test.ts:97`, an `it.each` row name with "retired at objectstack-ai#3278". - A self-test fixture in `scripts/check-future-spec-major.mjs:767-768` that copies the two `retired-key.test.ts` constants with `(#0000)`. It is its own text and does not read the file. - Code comments: the `strict-object.ts:71` docblock example and `suggestions.zod.ts:319`. - A self-test fixture with a different string in `scripts/check-doc-authoring.mjs:2507`. ## 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 string leaf that changed must sit in a test-call title position, or on one of the 12 declared lines. Each changed leaf must carry a tracker id before and no `#` plus digits after. The declared lines are: - the synthetic `history` strings at `alias-integrity.test.ts:917`, `:939`, `:952` and `strict-object.test.ts:16`, `:196`; - the `expect` messages at `alias-integrity.test.ts:1100` and `retry-policy.test.ts:113`; - the fixture constants at `retired-key.test.ts:26`, `:28` and the planted source at `retired-key-migrate-sentence.test.ts:342`; - the fixture names at `union-branch-policy.parity.test.ts:164`, `:169`, which reach `it` titles through a template. - **Result:** 21 of 21 files SAME, 85 changed (73 title, 12 declared), on all three legs. - **Diff hunks:** exactly the 85 planned lines, with every file keeping 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 with 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 keeping an id VIOLATION; an undeclared `expect` message changed VIOLATION; a title re-split into a `+` chain DIFF. **Test counts:** the 21 files were run at the base (in a separate base worktree) and at the head, 20 in the `local` project and one in `repo`. Both sides read 567 / 567 passed, with the same count and status sequence per file in 21 of 21. 450 full test names change, and each equals the base name with the planned replacements applied. ## Changeset: `skip-changeset` Measured, not assumed: - `npm pack --dry-run` of `@objectstack/spec` lists 2068 files under `files[]`. 0 of the 21 touched files are in it, and 0 `*.test.ts` at all. The control `src/shared/expression.zod.ts` is in it. - In `dist/`, four new phrases read in 0 files. Of three old ones, two read in 0, and the third reads only in `shared/index.d.ts` / `.d.mts`, from the `strict-object.ts` docblock comment, not from a test. The control `Unrecognized key(s) on` reads in 42. So this PR publishes nothing, and no changeset is added. ## Verification (at `fd8fd92d13`) - `pnpm turbo run build` over all packages: 71 / 71. - `@objectstack/spec`: - `vitest run --project local`: 613 files, 18215 passed, 1 todo. - `typecheck` exit 0, including `check:test-typecheck`. Its program holds all 21 touched files, counted with `tsc --listFilesOnly`. - **Gates:** `dispatch-gates --commands` derived 80 families, and all 80 exit 0. That is stage 11's 79 plus `check:future-spec-major`. `--ran` reconciles: 80 derived, 80 run, 0 NOT-MEASURED, 0 UNRUN. - 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 21 files, 0 errors and 0 warnings. The population comes from ESLint's own config: 21 configured, 0 ignored. No `parserOptions.project` or `projectService`, so no untouched file's verdict can move. - `check-governed-merges --test`: NOT governed, 170 changed lines. ## Acceptance notes - **Code comments still carry ids** in these 21 files and in the `shared/` sources. One of them ships: the `strict-object.ts:71` docblock example quotes the old widget `history` with `objectstack-ai#4001`, and it is emitted into `dist/shared/index.d.ts`. Comments are not this card's share and are untouched here. - **Sibling titles in other packages** repeat two rewritten titles' ids (`cli`'s `format-zod-union.test.ts:301`, `metadata-protocol`'s `protocol.unrecognised-meta-type.test.ts:171`). Each is its own lane's test-string stage. `spec`'s `ai/knowledge-source.test.ts:97` is this card's later small-directories stage. - **`scripts/check-future-spec-major.mjs:767-768`** keeps its own self-test copy of the two fixture constants with `(#0000)`. It is a non-test file outside this claim's surface, and it reads nothing from `retired-key.test.ts`. - **`origin/main` moved** seven commits past the base before this PR opened (objectstack-ai#21737, objectstack-ai#21741, objectstack-ai#21739, objectstack-ai#21744, objectstack-ai#21740, objectstack-ai#21746, objectstack-ai#21749). One touches `packages/spec` (`api/error-code-ledger.zod.ts`); none touches a file in this diff, so nothing was merged. --- _Generated by [Claude Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_ Co-authored-by: Claude <noreply@anthropic.com>
…both reads and the served body (objectstack-ai#21738) (objectstack-ai#21759) Fixes objectstack-ai#21738 Clause-②: no ## What was wrong The ADR-0010 `_lock` gate of the write doors (`getEffectiveLock`, behind save, publish, rollback and delete) resolves an item's lock from two layers: the packaged artifact's `_lock` unless it is `'none'`, then the stored `sys_metadata` row's. The two item reads did not use that resolution. Each derived the lock its own way, and neither matched the door on the artifact layer: - **Position 1.** `mergeArtifactProtection` copied any declared artifact `_lock` over the stored row's, `'none'` included. A packaged view whose artifact declares `'none'`, under a stored row declaring `'full'`, read `editable: true` on both reads while the door refused the save with `ITEM_LOCKED`. - **Position 2.** `getMetaItemLayered` (`GET /api/v1/meta/:type/:name/layers`) read the lock off `code ?? overlay`. A packaged view with no `_lock` under a stored `'full'` read `lock: 'none'` there, while `getMetaItem` and the door said locked. Triage's direction 5980131521 (verbatim in the dispatch): one resolution, three callers. The door's semantics stand, and the reads follow the door. ## H1: every place an item's lock was derived, at `ff29410ed2` | site | caller | artifact layer | stored layer | rule | |---|---|---|---|---| | `getEffectiveLock` | the `_lock` gate (save, publish, rollback, delete, package publish's promotion) | `lookupArtifactItem(canonical type, name)`, no package | `findServedOverlayRow` (canonical spelling, no package, org-gated) | artifact unless `'none'`, else row, else `'none'` | | `mergeArtifactProtection` | served body of `getMetaItem`, `getMetaItems` list items, registry hydration, runtime view-container expansion | the caller's artifact | the item's own `_lock` | artifact's `_lock` whenever defined (`'none'` too), else the item's | | `getMetaItem` envelope | `servedLockState(document)` | via the merge above (package-scoped lookup) | the served row, or a registry / MetadataService item | the merged document's `_lock` | | `getMetaItemLayered` envelope | `servedLockState(code ?? overlay ?? {})` | the code layer (MetadataService copy, else artifact, else runtime-only registry item) | only when there is no code layer | the code layer's `_lock` | | `getMetaDiagnostics` `locked` count | `servedLockState(list item)` | via the list's merge | the list item | the merged list body's `_lock` | | `servedLockState` | the three above | none of its own | none of its own | joins the given document's lock with `packagedBaseRefusal` | `packages/rest/src` and `packages/runtime/src` derive no lock: both pass the protocol's envelope through (`git grep` of `resolveLockState`, `extractProtection`, `_lock` and the envelope flags). The one out-of-package consumer of a read's lock-adjacent fields, `plugin-security`'s packaged permission-set gate, reads `getMetaItemLayered`'s `code._packageId`, not its lock. ## What changed Landing spot: `packages/metadata-protocol/src/protocol.ts`, as the claim predicted, plus a new module beside it. No `packages/spec/src/**` path is touched. - **`item-lock.ts` (new, not exported from the package entry).** `ITEM_LOCK_LAYERS = ['artifact', 'overlay']` is the precedence and the parameter set. `resolveItemLock(layers)` returns the first layer whose declared `_lock` is not `'none'`, with that layer's prose, else `'none'` with no prose. `resolveItemLockLazily(readers)` asks the same function after each layer is read and stops at the first binding layer. - **The door** (`getEffectiveLock`) reads its two layers through `resolveItemLockLazily`, so the store read still happens only when the artifact does not bind. Its overlay read moved, unchanged, into `readLockGateOverlayLayer`. Refusal code, status, text and `source=` are byte-identical. - **`getMetaItem`** hands the resolver the artifact it already looked up and the stored body of the row `findServedOverlayRow` already served. It keeps that body even when the row is not adopted (a shipped flow name, objectstack-ai#20946), because the gate binds it. No extra store read. - **`getMetaItemLayered`** hands it `lookupArtifactItem` (a registry read) and the served row's stored body. `code ?? overlay ?? {}` is no longer a lock source. It still feeds `provenance` / `packageId` / `packageVersion`, unchanged. A row-less name that a stored container expands contributes no overlay layer, because the gate does not read the container's row. - **`servedLockState`** takes the resolver's answer and derives `editable` / `deletable` from it, joined with `packagedBaseRefusal` as before. It reads only `provenance` / `packageId` / `packageVersion` off the document. No third derivation. - **`mergeArtifactProtection`** copies the artifact's `_lock*` family only when `resolveItemLock` says the artifact's lock binds. Its `_packageId` / `_packageVersion` / `_provenance` copies are unchanged (H3). - **The diagnostics count** resolves the same way, over the list's artifact lookup and the listed document. That is still a registry read, never a store read. ## Measured: the door does not move, and the reads now follow it The census used the real `ObjectStackProtocolImplementation` over an engine double, on a `view`. It covered 216 cells: 2 topologies × 6 artifact states (absent, no `_lock`, each of the 4 levels) × 9 stored states (none, or an env-wide / org-scoped row at each level) × 2 request scopes. Each cell read `getMetaItem`, `getMetaItemLayered`, the save door and the delete door. - Before (`ff29410ed2` code): 36 split cells, 18 per topology. 9 are position 1 (artifact `'none'` under a binding stored lock), and 9 are position 2 (artifact with no `_lock` under one). - After: 0 split cells. - **Door columns: identical in all 216 cells**, before against after. So no door verdict changed. - `getMetaItem` changed in 18 cells (position 1 only). `getMetaItemLayered` changed in 36 (positions 1 and 2). **H3, every other field.** A second probe ran at the merge base and at this branch on the same 216 cells. It dumped `getMetaItem`'s envelope and served body, `getMetaItemLayered`'s envelope and its `code` / `overlay` / `effective` / `overlayScope` / `_diagnostics`, and the `getMetaItems` list item. With the lock family masked (`lock`, `lockReason`, `lockSource`, `lockDocsUrl`, `editable`, `deletable` and the body's `_lock*`), **all 216 dumps are byte-identical**. The lock family differs in 54 cells: - 18 position-1 cells (both reads, the body and the list item now carry the stored lock); - 18 position-2 cells (`getMetaItemLayered` only); - 18 explicit-`'none'`-artifact cells where nothing binds. There the explicit `'none'` artifact's prose is no longer reported on the envelope, and its `_lock*` fields are no longer copied onto a stored row's body. ## H4: residue census (position 3), and why the fallback stays - Plural-spelled rows were writable through `@objectstack/metadata-protocol@16.1.0`: its `SysMetadataRepository.whereFor` keyed `type: ref.type` raw. The fold (objectstack-ai#4432, commit `63b33e6a18`) first shipped in `17.0.0-rc.2`. Ancestry was read in both directions on a full (non-shallow) clone: `63b33e6a18` is not an ancestor of the `16.1.0` tag (exit 1); the control `16.0.0` is (exit 0). - `MIGRATION_SUPPORT_FLOOR` is 16, so 16.x deployments are on a supported upgrade path. - `os migrate meta --stored` (`migrateStoredMetadata`) rewrites bodies, never a stored type spelling. It reports the six URL-only plural spellings `skipped` (objectstack-ai#8957), and it leaves a manifest-plural row at rest, re-saving a canonical row only when the body needs a conversion. - **So residue can exist on a supported path, and nothing rewrites it.** The reads' other-spelling fallback stays, and the difference stays declared (`findServedOverlayRow`'s `otherSpelling`; answer A on PR objectstack-ai#21737). By the triage's own condition, no separate retirement card follows from this census. ## H5: the acceptance pin, generated from the resolver's inputs `protocol.lock-one-resolution.test.ts`: 1. **1632 generated rows.** The axes are: artifact (6) × stored row (none, or env-wide / org-scoped × 4 levels × canonical / other spelling: 17) × request scope (2) × topology (2) × request spelling (`view` / `views`) × operation (save / delete). The axes are grouped under `LAYER_AXES`, keyed by `ItemLockLayer`, so a layer added to `ITEM_LOCK_LAYERS` fails the typecheck. The completeness check also fails the run, naming the layer, and it asserts `resolveItemLock.length === 1` and that every axis is used exactly once. Each row runs on one protocol instance. Both reads must agree, both must equal an oracle written from the rule (iterating `ITEM_LOCK_LAYERS`), and the door must admit exactly when the envelope says `editable` / `deletable`. Rows stored under the other spelling assert the declared difference instead (the reads see the residue row and the door does not), so they flip by name when the fallback retires. 2. **PR objectstack-ai#21737's 64 rows are folded in, with none lost.** A test rebuilds their 64 titles and asserts each is a row of this table. They and their lit control moved here out of `protocol.lock-org-axis-agree.test.ts`; that file keeps its pins 2 to 4. 3. **Position 1, named.** On both topologies: both reads say `full`, with `editable` and `deletable` false and `lockReason` from the stored row. Provenance is still the artifact's. The served body, the list item and the diagnostics tile say `full`. Save and delete are refused `ITEM_LOCKED` / 403 with `lock: 'full'`. 4. **Position 2, named.** On both topologies: `getMetaItemLayered` says `full` like `getMetaItem`, its `code` layer is still the package's (no `_lock`), and the door refuses. The objectstack-ai#5840 outage pin (`protocol.metadata-store-outage.test.ts`) had a "healthy" leg. That leg's locked artifact existed only in the MetadataService double, never in the registry the gate reads. It now declares the artifact in the registry, package-stamped, as a loader does. Its outage leg is unchanged and still answers 503. ## Reverse verification Both legs ran from committed HEAD `db49dc4d2fab` (the `protocol.ts` blob), with `scripts/ablation-replace.mjs` in wrap mode. They ran inside a script carrying an absolute-path `trap` restore from `HEAD`. The subject resolves from source (`./protocol.js`), so no rebuild was in the path. Each direction was declared first. - **Leg A: `mergeArtifactProtection` restored as a lock source.** The copy became unconditional, and `getMetaItem` took its lock from the merged document again. Anchors 1 to 0 and markers 0 to 1, twice; blob `db49dc4d2fab` became `cb753a719d8b`, then `decca0d87278`. Predicted 146 red / 1494 green. **Measured 146 failed / 1494 passed**: pin 2 on both topologies, plus all 144 pin-1 rows with an explicit `'none'` artifact under a served stored lock. Pin 3 stayed green. - **Leg B: the layered read's `code ?? overlay ?? {}` restored.** Anchor 1 to 0 and marker 0 to 1; blob `db49dc4d2fab` became `bdd63140c660`. Predicted 292 red / 1348 green. **Measured 292 failed / 1348 passed**: pin 3 on both topologies, plus the 288 pin-1 rows whose artifact declares no `_lock` or `'none'` under a served stored lock. Pin 2 went red too, because its layered half was the same derivation. - **Restore, both legs:** blob equal to the HEAD blob `db49dc4d2fab`, `git diff HEAD` empty and `git status --porcelain` empty, checked by the tool and by the trap. ## Tests (all at `60232c3d17`, after merging `origin/main` `ea7ff394b6`) - `@objectstack/metadata-protocol`: `vitest run`. **213 files passed, 3 skipped. 5249 tests passed, 19 skipped.** That is base 3675, plus 1640 new, minus 66 moved. `tsc --noEmit` is green, and `--listFiles` compiles `item-lock.ts` and the new pin. - `@objectstack/objectql` against the rebuilt `metadata-protocol` dist: `--project local` gives **372 files, 7458 tests passed**, and `--project repo` gives 1 file, 5 tests passed. The dist was built from `ae121eac22`; `protocol.ts` and `item-lock.ts` are unchanged since. - Narrowed lint: `eslint --no-inline-config --format json` over the 5 changed TS files gives 5 file results, 0 errors and 0 warnings. The population was read from eslint's own config: `isPathIgnored` is false for all 5, and each computed config has 5 or 6 rules. Invariance: no computed config sets `parserOptions.project` or `projectService`, and `eslint.config.mjs` never enables type-aware linting, so this diff cannot move a verdict on an untouched file. - **NOT MEASURED:** HTTP (reach is on doubles); `rest` / `runtime` / `plugin-security` / `plugin-email` / `client` suites, which are consumers with no export or wire-shape change (their lock-field assertions were grepped and use fakes); CI-owned Dogfood, Temporal Conformance, type-check lanes and the full `pnpm lint`. ## Gates (at `60232c3d17`; exit codes captured before any pipe) - `node scripts/pm/dispatch-gates.mjs --commands` (no paths) derived 72 families: **72 run, all exit 0.** `--ran` reports "72 derived famil(ies) accounted for — 72 run, 0 NOT-MEASURED". `check:dual-build-cjs-loads` first exited 3 (PREREQUISITE NOT MET) and exited 0 after a full turbo build. - Artifact-roster block (54): 51 exit 0. The 3 PR-context guards (`check-closing-target-claim`, `check-partof-closing-keyword`, `check-single-claim-paths`) exited 2, NOT WIRED, before this PR existed; they are re-run with this PR's context in the report. - The four symbol-anchor sweeps exit 0: `adr-symbol-anchors` 2167 anchors across 140 records; `scripts-symbol-anchors` 3760 across 282 scripts; `spec-docblock-symbol-anchors` 4950 across 1868 sources; `adr-anchors` OK. - `check:engine-double-contract` asked for the new pin's `findOne` double, and its own `--write` recorded it: the ledger gained one `findOne` entry for the new file, and lost none. - `check:durability-log-level`: 70 read seams before and after. The `--depth-cost` census is identical apart from line numbers and one enclosing function's name (`getEffectiveLock` became `readLockGateOverlayLayer`). ## Acceptance notes (not filed here) - **The package axis, now measured on the double.** A read naming a package (`?package=`, ADR-0048 prefer-local) serves that package's row, while the gate asks package-agnostic (`findOne` with no `package_id`). The gate therefore binds whichever row the driver returns first. With a package-less row and a package A row of one `(type, name, scope)`, 2 of the 3 arrangements probed (which row declares the lock, and which row the driver returns first) split read from door. This is row selection, not the resolution, and this PR leaves it unchanged; it is reported to the seat. - **A lock only a copy the doors never read declares.** A MetadataService copy the dev watcher reloaded after boot, or a package-less runtime registration, used to read locked while the doors admitted the write. It now reads as the doors answer, and the changeset says so. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…he stored rows in scope for its address (objectstack-ai#21761) (objectstack-ai#21801) Fixes objectstack-ai#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` (objectstack-ai#4432). - **H5 (fail-closed, store reads).** A failing read in scope still refuses with 503 (pin 6, with and without a package; the objectstack-ai#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`. - Its completeness check names every field of `ITEM_ADDRESS_FIELDS` against an axis (`ADDRESS_AXES`, keyed by `ItemAddressField`, so a new field fails typecheck and the run). - Every row runs under both orders the store can return the two rows in. - Every row asserts `getMetaItem` = `getMetaItemLayered` = the door, for save and delete, against an oracle written from the rule. - 16 320 rows, plus the subset check that PR objectstack-ai#21737's 64 rows are still in the table. 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 objectstack-ai#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 objectstack-ai#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 'objectstack-ai#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 `objectstack-ai#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](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21716
Clause-②: no (narrowing)
What was wrong
The two item reads (
getMetaItem, andgetMetaItemLayered, which servesGET /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_lockgate's overlay limb (getEffectiveLock) asked for one row only:organization_idequal 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 publishedlock: full,editable: false,deletable: false, while that organization's save, publish, rollback and delete were admitted. ADR-0010 §3.3 saysfullmeans "Overlay writes rejected". This is the third member of the family, on the organization axis. The first was the package door (PR #21693) and the second the topology axis (PR #21715).H1: the two resolutions, measured at
c43a8ae612Measured on the real
ObjectStackProtocolImplementationover an engine double, for aview. The table was identical on an environment kernel and a host-config kernel. "Door" is the overlay limb's ownsys_metadataquery.fullfullorganization_idnull: env-wide rowfull: agreesfullorg_afullorganization_id = org_a: nothingnone: splitfullnonenone: agreesfullorg_afullfull: agreesfull+ orgnonefullfull+ orgnoneorg_anonenone+ orgfullnonenone+ orgfullorg_afullnoneThere is one split cell per kernel, and it is the card's.
What changed (H2)
Only
packages/metadata-protocol/src/protocol.tschanges at runtime.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.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.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 passesfalseand stays on the canonical spelling. This was measured, not assumed:packages/objectql/src/protocol-meta-type-canonicalization.test.tsred ("a plural-spelled write addresses the canonical namespace and no other"). On a create, the gate queriedtype: 'actions'after the canonical row missed.findServedOverlayRowand in the gate's header.getEffectiveLockheader still told callers to gate onenvironmentId, which has not been true since finding(metadata-protocol): two lock reports the #21670 read fix left unaligned — a host-config kernel's_lockgate admits a save the read now calls non-editable, andgetMetaDiagnostics().stats[type].lockedcounts declared_lockonly #21694. It now says the method answers alike on every topology.scripts/engine-double-contract.pinned.jsongains one row for the new pin'sfindOnedouble. 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
getEffectiveLockhas two callers. Both read the new limb, because the change is inside it:lockWriteRefusalis reached through:assertLockAllowsWrite, fromsaveMetaItemandrollbackMetaItem;promoteDraftForPublish, frompublishMetaItemandpublishPackageDrafts.assertLockAllowsDeleteis reached fromdeleteMetaItem, whichdeletePackageanddiscardPackageDraftsalso call.Pin 2 drives save, delete, publish and rollback.
What moves (H5)
metadata-protocol: 212 files and 3675 tests pass (3589 before, plus 86 new).objectql: 372 files and 7458 tests pass.git grepfinds no_lockand noprotection:underexamples/**.platform-objectsdeclaresprotection.lockonly onappitems (setup,studio,account) andobjectitems (sys_*). Both types areallowOrgOverride: false, so the gate never carries an organization for them, and their artifact limb is unchanged.PUT,DELETE, publish and rollback, and the runtime dispatcher's save. They carryorganizationIdForMetaWrite, which is non-empty only forview,dashboard,report,translationandemail_template;migrateStoredMetadata(the row's own organization, so its own row is served and nothing changes),deletePackageanddiscardPackageDrafts(the row's organization), andduplicatePackage(a new name).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-org 作用域的 flow overlay 只在「本进程内发布后」绑定触发器,重启后静默失绑——冷启动两条读路径都把 organization_id 非空的行滤掉了 #6190 org-scoped row is now judged by the env-wide row's lock, the row both reads serve.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 bygit grepof every non-test caller of the four write verbs. NOT MEASURED: a live server boot (no dev server was started for this card).findOnereads 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.tshas 86 cases. Each one drives the real doors and the real reads on one protocol instance, and every refusal assertscodeandstatus(ADR-0112).org_a), everyMetadataLockSchemalevel (read off the schema itself) and operation (save, delete). That is 64 rows, each its own named case.editable(save) ordeletable(delete)._lock: 'full'row is tested on both kernels.editable: false.ITEM_LOCKED/ 403 withlock: 'full'. The denial rows are written tosys_metadata_auditunderorg_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), throughscripts/ablation-replace.mjsin wrap mode, inside a script whoseEXIT INT TERMtrap restores fromHEADby 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.organization_idlimb restored (onefindOneonorganization_id: organizationId ?? null).e6a207612cc4toc598f7de3b47.org_arequest, withno-overlay/fullon save andno-delete/fullon delete, on both kernels), all 4 cases of pin 2, and all 4 of pin 4.Restore. After each leg, the blob equals the
HEADblobe6a207612cc4,git diff HEADis empty, andgit status --porcelainis empty. Both the tool and the trap proved it.Tests
On head
87e350bbaf, after mergingorigin/mainat316be321ef(which touchedruntimeandscripts/only):@objectstack/metadata-protocol:vitest run: 212 files passed and 3 skipped; 3675 tests passed and 19 skipped.typecheck(tsc --noEmit) is green.--listFilescompiles 215 test files, including the new pin.@objectstack/objectql, against the rebuiltmetadata-protocoldist:vitest run --project local: 372 files and 7458 tests passed.--project repo: 1 file and 5 tests passed.eslint --no-inline-config --format jsonover the two changed TypeScript files gives 2 file results, with 0 errors and 0 warnings.isPathIgnoredis false for both files, and each computes a 5-rule config.parserOptions.projectorprojectService, andeslint.config.mjsstates 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:
restandruntimesuites. They are consumers, and no export, spec contract or wire shape changes.pnpm lint.Gates
All of these ran on head
87e350bbaf, after the final commit. Each exit code was captured before any pipe.node scripts/pm/dispatch-gates.mjs --commands(no paths) derives 72 families. All 72 exit 0. The--ranreconciliation reads "72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN".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-loadsandcheck:lean-entry-closure.turbo run buildand exit 0.check-closing-target-claim,check-partof-closing-keywordandcheck-single-claim-pathsexit 2 NOT WIRED, because they read a pull request.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.none. A packaged view declares_lock: 'none', and its stored row declaresfull.editable: true, becausemergeArtifactProtectionlets the artifact's explicit value win.noneand its overlay limb findsfull.full.getMetaItemsaysfull/editable: false, which agrees with the door.getMetaItemLayeredsaysnone/editable: true, because its lock source iscode ?? overlay.Two smaller notes:
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.getMetaItemLayeredused to fall back to the env-wide row when an org row's stored body was JSONnull. It now reports the org row, asgetMetaItemalways did.Generated by Claude Code