Repository navigation
fix(metadata-protocol)!: an item's lock is the strictest lock among the stored rows in scope for its address (#21761) - #21801
Conversation
…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>
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>
…ck-address-selection
…ck-address-selection
📓 Docs Drift CheckThis PR changes 1 package(s): 31 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 12 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. 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 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
|
ACCEPT (seat review) — PR #21801 at head
|
… 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>
… 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>
…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>
…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>
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,resolveOverlayLockLayerinpackages/metadata-protocol/src/item-lock.ts. The write doors'_lockgate,getMetaItem,getMetaItemLayeredand the list all call it. No caller pre-selects a row for the lock any more.strictestLock, read off the lock algebra). Two rows that refuse different verbs (no-overlay,no-delete) join tofull.findServedOverlayRowis 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.lockReason/lockDocsUrl/lockSourceare 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 inprotocol.ts(overlayLockLayerAt; the gate'sreadLockGateOverlayLayer; the lock source of both reads; the list item and thegetMetaDiagnosticstile through the list body). One test file outside that surface:packages/objectql/src/protocol-meta.test.ts, whose outage double now failsfindas well asfindOne, because the gate's first read is afind. The rest of the diff is tests, the changeset, and the engine-double ledger rowcheck:engine-double-contract --writeadded.Rulings honoured, and one place they meet
findOnecould return package B's row. In arrangementA none + B fullwith 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 (DeleteMetaItemRequestdeclares none), so a package-scopeddeletablecan 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.findOnecould 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):readLockGateOverlayLayer→findServedOverlayRow, with nopackageId, canonical spelling only, and bound the rowfindOnereturned 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.getMetaItemandgetMetaItemLayeredhandedfindServedOverlayRowthe request'spackageId(present or absent) and took that row's lock.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 oforigin/maintouch no file ofmetadata-protocol): every caller handsresolveOverlayLockLayerits address. The gate's address carriespackageIdfor save and publish and none for delete and rollback; the reads carry the request's. Measured over an engine double, 30 requests, bothfindOneorders.b→h= merge base → this head; the door columns are the_lockgate's verdict; reads are store reads per call._lockSummary over the 30 requests and 60 door verdicts:
getMetaItem,getMetaItemLayeredand 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.packageIdin hand, butassertLockAllowsWritenever 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 at05b41c9449. With the lock family masked (lock,lockReason,lockSource,lockDocsUrl,editable,deletable, and_lock/_lockReason/_lockSource/_lockDocsUrlanywhere), the dumps are byte-identical: 0 diffs in 90.H4 (one address, one selection).
resolveOverlayLockLayer(address, rowsIn, { otherSpelling }). The address fields areITEM_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:overlayLockLayerAt, an enginefindper scope, used by the gate,getMetaItemandgetMetaItemLayered;The one declared difference is unchanged: the gate passes
otherSpelling: false, the readstrue(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 wasfindOne; now it isfind, because the strictest lock needs every row of the item, andfindOnecannot enumerate them. Measured: 1→1 on all 30 requests. The reads pay one extrafindper 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:requestPackage(packageIdabsent / present) andpackageRow(the package's row: none, or each lock level), beside the existing package-lessstoredRow.ITEM_ADDRESS_FIELDSagainst an axis (ADDRESS_AXES, keyed byItemAddressField, so a new field fails typecheck and the run).getMetaItem=getMetaItemLayered= the door, for save and delete, against an oracle written from the rule.none, packagefull), both orders, package named and not. The reads and the door agree onfull, the body saysfull, and content is the package's own row.none, package-lessfull), both orders.full, nevernone; the refusal carries the binding row's prose.sys_metadataread fails, and save (with and without a package) and delete answer 503 from the gate.(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 throughscripts/ablation-replace.mjs: one row, package-agnostic, canonical,findOneorg then env.388e091cb01e→cf3701af4ce0../protocol.jsfromsrc, so nodistwas involved.-t '#21761. pin (4|5)': 5 red / 4 green.expected 'admitted' to deeply equal { refused: … }, or a non-lock error whereITEM_LOCKEDwas expected.git checkout HEAD -- PATH(absolute path, under trap). Blob after = blob at HEAD =388e091cb01e;git diff HEADempty.Tests
At
7fe49587a0(after mergingorigin/mainate83c9f6154). The final head273ead3408adds only a second merge oforigin/main(e27a7c0c9e), which touchescloud-connection,service-storage, a pm reference doc and two ratchet baselines and no file ofspec,metadata-core,metadata-protocolorobjectql, 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 --listFilesincludes all four edited test files.metadata-protocoldist: the 8 lock-touching protocol files pass (160 tests). The full objectql suite passed at632a00751d: 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 --ranover the recordedcmd :: exit Nlist: "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)".check-partof-closing-keyword: re-run with this body asPR_BODY, exit 0;check-closing-target-claimandcheck-single-claim-paths: NOT MEASURED. Each needs a PR number and an API token; this session'sGH_TOKENis invalid, and their guard workflows run on the PR.check-closing-keyword-parity --body: only#21761binds.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 thisClause-②line (minorpasses;patchexits 1).check:adr-0087-registration: one declared-breaking changeset, carrying its disposition.eslint --no-inline-config --format jsonover the 7 touched.tsfiles, at273ead3408: 7 files, 0 errors, 0 warnings.calculateConfigForFile/isPathIgnored: every one of the 7 is linted (none ignored).eslint.config.mjsenables no type-aware linting (noparserOptions.project, which reads null for each file), so this diff cannot move any untouched file's verdict.pnpm lintis CI's.Acceptance notes
Changeset grade. The dispatch order asked for
patch.check-changeset-no-major.mjsrefusespatchon a moved package underClause-②: no (narrowing)during the launch window. It was measured on a throwaway local commit: exit 1, "it grades NO package whosepackages/**/src/**it moves" at the level a narrowing owes. So the changeset isminor, with the!title and an ADR-0087not-required (no-migration-prescription)disposition;check:adr-0087-registrationreads 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 mirrorsgetArtifactItem's prefer-local-then-first-composite order, with packages B (full, registered first) and A (no lock) shipping one view name:getMetaItemnaming A readslock: none, editable: true;ITEM_LOCKEDwithsource=artifactand 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.
mergePackageAwareOverlaypicks, 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, whilegetMetaItemnaming 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'seffectivebody now carries the binding overlay lock family asgetMetaItem's body does.codeandoverlaystay raw. On the artifact axiseffectivestill never carried the artifact's family, as before.Generated by Claude Code