Repository navigation
fix(metadata-protocol): the read envelope's lock / editable / deletable report the write doors' locked-base verdict - #21693
Conversation
…he write doors, every type (red on main) The ADR-0010 envelope both metadata reads publish says lock none, editable true, deletable true for a packaged flow or action that every write door refuses in place. This pin measures the read against the doors themselves (saveMetaItem / deleteMetaItem on an environment kernel, the repository gate on a host-config kernel) for every type in the registry, with a lit control. It fails on main: 50 of 107 cases. Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude <noreply@anthropic.com>
…te doors' locked-base verdict Both metadata reads resolved lock / editable / deletable from the document's own _lock alone, so an item a code package ships on a type with no overlay channel (flow, action, object, …) read lock none, editable true and deletable true while every write door refused it in place. One derivation now joins the _lock verdict with packagedBaseRefusal, the predicate the /meta and /automation doors already share, and both reads call it. lock is read back off the ADR-0010 lock algebra, so the envelope keeps its declared shape. The lock field's spec description named only _lock and ITEM_LOCKED; it now names both refusals it reports. Wording only: no key, type or accept set moves. Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude <noreply@anthropic.com>
…n; add the changeset Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude <noreply@anthropic.com>
…le-contract ledger Claude-Session: https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 139 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 4f238ed6aa4e29e260f76e8ddd2d93448fa915fd && git checkout 4f238ed6aa4e29e260f76e8ddd2d93448fa915fd
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 251a7dd4b491d1f216e8a470d0efd0dc7e8ac5e8 017a2c7599cb7946e118e2e8600acc5b5432c9bd && git checkout -B drift-repro 251a7dd4b491d1f216e8a470d0efd0dc7e8ac5e8 && git merge --no-ff 017a2c7599cb7946e118e2e8600acc5b5432c9bd
node scripts/docs-audit/affected-docs.mjs --json 251a7dd4b491d1f216e8a470d0efd0dc7e8ac5e8
|
Contract reviewServed-tier: Inputs read: #21670's body, triage Gates at review time: 18 completed success, 2 skipped (Console Pin Gate, Packed-tarball smoke, both opt-in), and 12 still ① Derived judgments
The unreachable throw is sound. It can fire only if the algebra stops covering a pair, and then it fails loudly instead of publishing a lock that disagrees with the booleans.
Both reads call it.
Published values are true to their doors. I checked against
The pin test: not weakened.
② Semver levelCorrect.
③ Boundary flagsStop valve: the seat's ruling A is upheld.
Out-of-scope findings, filed as #21694 (open,
Deviations.
Implemented-by: VERDICT: PASS |
…dy`, including one with neither a `body` nor a `handler` (objectstack-ai#21689) (objectstack-ai#21706) Fixes objectstack-ai#21689 Clause-②: no (narrowing) This carries out triage's ruling on objectstack-ai#21689 (comment 5977077883, unlocked in 5977495602): **one predicate. The metadata save door refuses any hook with no `body`**, with a named error and the prescription "give it a `body`". The handler-only refusal that objectstack-ai#21658 landed (PR objectstack-ai#21686, `ced217ca30`) is now one case of it. `HookSchema` is untouched. ## What changes `runtimeHookWithoutBodyRefusal` in `packages/metadata-protocol/src/protocol.ts` keeps its name, its one call site in `saveMetaItem` and its one envelope. Its predicate widens from "a non-empty `handler` string and no `body` object" to "no `body` object". It is the same judgement install-local's `collectHooksWithoutBody` makes on its own door (`hookCarriesBody` in `packages/runtime/src/app-artifact-handlers.ts`). - **Envelope:** `VALIDATION_ERROR` / 400, unchanged. No code is added and the ledger is not edited. - **When and where:** unchanged from objectstack-ai#21658. It runs in draft and in publish mode, after the type-schema parse (a malformed `body` keeps the schema's located 422), and before the authoring gate and every write. - **Message, two readings of one rule:** - With a `handler` that names a function, the message is byte-identical to the one objectstack-ai#21658 shipped. It names the hook and the function. - With neither field, or an empty `handler`: "Invalid hook: 'NAME' carries no `body`, so it has nothing to run. Give it a `body` (sandboxed JS, `{ language: 'js', source }`, or an expression), which is stored with the hook. A hook saved through the metadata API ships with no code package, so its `body` is the only code it can run." - Length: the handler form is 499 characters with 65-character names, as before. The neither form is 282 characters plus the hook name, well under the 500-character REST bound. - **What still saves:** a hook with a `body`, with or without a `handler` beside it. ## The PM's mechanism hypotheses, measured (at `1db5322ba2`, then rerun on the merged head `15402d98f8`) | # | Hypothesis | Reading | |:--|:--|:--| | H1 | Widen the existing predicate, one function, one call site, one code. | **Holds.** One early return removed, and the message chosen by whether `handler` names a function. The measured messages read right for both shapes (quoted above; the sweep's failure output below shows the neither text verbatim as the door produced it). | | H2 | Five suites use a bare hook as their schema-valid probe; report any others. | **Holds, with two more.** With the predicate widened and every probe untouched, the full `metadata-protocol` suite went 7 failed / 3461 passed (4 files), and the full `objectql` suite went 6 failed / 7446 passed (3 files). The five named suites, plus `metadata-protocol` `protocol.save-receipt-wording` and `objectql` `metadata-validation-sweep`. A grep of `runtime`, `rest`, `plugins/*`, `services/*`, `cli`, `verify`, `qa` and `examples/**` for hook saves through this door (`type: 'hook'` items, `/meta/hook` paths) found only `runtime`'s two pin tests, which already carry bodies. See the fixture triage below. | | H3 | `migrateStoredMetadata` and `duplicatePackage` surface a stored bare row as their recorded failure; stored rows keep their bytes. | **Holds.** Measured with a one-off harness that is not committed (the stub engine of `protocol.stored-residue-resave.test.ts`). `duplicatePackage` over a package holding one bare hook row and one body hook row answered `{ success: false, copiedCount: 1, failedCount: 1 }` with this refusal in `failed[0].error`, and the source rows' bytes were unchanged. `migrateStoredMetadata({ apply: true })` over a bare row answered `{ scanned: 1, canonical: 1, rewritten: 0, failed: 0 }` with the bytes unchanged. **Population of stored bare hooks reachable from this repository: zero.** `examples/**` and `packages/qa/**` seed no `sys_metadata` hook rows (zero `type: 'hook'` items). | | H4 | A built artifact's `handler` hook never passes `saveMetaItem`. | **Holds at `1db5322ba2`.** Every production call site either saves a fixed type other than `hook` (`automation.ts` and `flow-credential-migration.ts` for flow, `packages.ts` for app, `permission-set-projection.ts` for permission) or forwards an author's or a stored row's type. The forwarding callers are the REST `PUT /meta/:type/:name` (`rest-server.ts`), the dispatcher's metadata save (`domains/meta.ts`), `migrateStoredMetadata` and `duplicatePackage`. `AppPlugin`, `loadArtifactBundle`, the install-local door and the boot path make zero `saveMetaItem` calls. The composed pin's X and Z controls hold it end to end. | | H5 | No first-party authoring path emits a hook with neither field through this door. | **Holds for what this repository holds.** `os meta register` forwards the author's own file and emits no hook of its own. The `os` create and scaffold templates author `defineStack` sources, which reach the artifact door and never this one. `packages/mcp` has no hook tool. Studio is at the objectui pin `ab18797215`, unchanged since objectstack-ai#21658's census, which read its hook skeleton carrying a `body`. **Not measured:** the cloud AI build agent's metadata tools, which live outside this repository. | ## Fixture triage: seven probe suites move to a body-carrying hook Each probe item gains `body: { language: 'js', source: 'return;' }` and nothing else. No probe is deleted, and no assertion is changed or loosened. | Suite | What the probe measures | Still measures it | |:--|:--|:--| | `metadata-protocol` `protocol.code-only-types` (2 probes) | The objectstack-ai#5086 code-only gate does not catch `hook` (`allowRuntimeCreate` only) on either kernel; the objectstack-ai#5264 matrix shows a hook save answers a repository receipt. | Yes: `success: true` and one row on both kernels, and the receipt fields. | | `metadata-protocol` `protocol.meta-types-mint-door-agreement` | `PUT /meta/hook` behaves as `/meta/types` advertises (declared, creatable), read off one fact. | Yes: both cases are green. | | `metadata-protocol` `protocol.unrecognised-meta-type` | A declared runtime-create-only type still saves past the unrecognised-type refusal. | Yes. | | `objectql` `overlay-precedence` (3 probes: `hook`, plural `hooks`, single-kernel bypass) | The two-tier verdict: brand-new items of `allowRuntimeCreate` types pass the overlay whitelist, and a single kernel bypasses the overlay gate. | Yes. | | `objectql` `protocol-meta` (3 probes) | The PR-10d.7 two-tier model: an artifact-backed hook is refused `NOT_OVERRIDABLE` / 403; a brand-new hook and an edit of a DB-only hook are accepted. | Yes. The `NOT_OVERRIDABLE` probe was green before the move too, because the provenance gate runs first. It moved anyway, so that the refusal it measures can only be the provenance gate's. The registry-seeded items there are scenery for provenance, not saves through the door, and keep their bytes. | | **Beyond the five:** `metadata-protocol` `protocol.save-receipt-wording` (`OVERLAYLESS_PROBES.hook`) | The receipt sentence for a brand-new overlay-less type. | Yes. | | **Beyond the five:** `objectql` `metadata-validation-sweep` (`FIXTURES.hook`) | Valid gets 200; invalid gets the schema's 422 naming `events`. | Yes. The invalid fixture carries the body too, so it stays the valid fixture minus the one field the schema must name. | ## The enumeration pin, on the real door (ADR-0112: each refusal asserts `code` and `status`) Composed kernel, `packages/runtime/src/hook-handler-package-scope.pin.test.ts`, through `PUT /api/v1/meta/hook/NAME` as the signed-in administrator: | Pin | Case | Asserts | |:--|:--|:--| | 1. A `body` hook saves. | ②b (existing) | 2xx; it binds and runs, and so does one with both fields. | | 2. A handler-only hook is refused. | ② (existing) | 400 `VALIDATION_ERROR`, naming the hook and the function and prescribing a `body`; GET by name answers 404. | | 3. A hook with neither field is refused. | **②c (new)** | 400 `VALIDATION_ERROR`, naming the hook and prescribing a `body`; GET by name answers 404. | | (2 and 3) Nothing bound. | **"② and ②c nothing bound" (widened)** | After ②b's re-sync, the binder recorded no refusal of either hook and no skip of the bare one (`skipsOf`, a new reader of the engine logger's `skipping hook` warns). | | 4. A built artifact's `handler` hook through its own door is unchanged. | X and Z controls (existing) | App X's hook naming its own `functions` entry binds and runs. App Z's hook naming a function its `--artifact` runtime module exports binds and runs. | At unit level, section 8 of `protocol.invalid-metadata-422-face-inventory.test.ts` is new. It covers publish and draft with neither field, and an empty `handler`. Each case asserts `code`, `status`, the named hook, the prescription and an empty store. Section 7 (objectstack-ai#21658) is unchanged and still green. ## Reverse verification (the fix committed first, at `0c32c32bb1`) **Mutation.** `node scripts/ablation-replace.mjs` restored the old handler-only guard after the body test: `typeof hook.handler !== 'string' || hook.handler === ''` returns `undefined`, behind a marker constant `ABLATED_21689_NEITHER`. The anchor count went 1 to 0 and the blob went `3059168d5703` to `237d2559c48b`. `@objectstack/metadata-protocol` was rebuilt, and `node scripts/ablation-dist-preflight.mjs @objectstack/metadata-protocol ABLATED_21689_NEITHER` found the marker in `dist/index.js` and `dist/index.cjs`. A shell `trap` restore on EXIT, INT and TERM wrapped the whole run. The first attempt was a **no-op**, and it is disclosed here. Its replacement re-contained the anchor line, so `ablation-replace` refused it ("the anchor count moved 1 to 1"), ran nothing and proved the restore. The second attempt re-spelled the body test (`typeof hook.body === 'object' && hook.body`, the same truth table), so the anchor left the file. **Prediction:** pin 3 red, and pins 1, 2 and 4 green. **Observed:** - **Unit (src), sections 4 to 8:** 3 failed, all three section 8 cases (publish neither, draft neither, empty `handler`). 15 passed, including all of section 7 (handler-only refused in both modes, the body CONTROL, body beside handler, malformed body 422). - **Composed (dist):** - ②c failed. It received `{ status: 200 }` with `Saved hook 'scope_authored_bare' (env-wide, state=active)`. - "② and ②c nothing bound" failed. `skipsOf('scope_authored_bare')` held 3 binder skips, which also proves the new reader fires. - ① ✓, X control ✓, Z control ✓, ② ✓ and ②b ✓: 2 failed, 5 passed. **Restore.** - `ablation-replace` restored the file. The blob equals HEAD (`3059168d5703`) and `git diff HEAD` is empty. The outer trap's hash compare agreed. - Whole-tree `git status --porcelain` is empty. - After a rebuild, the `--absent` preflight found the marker in none of the 24 built files, and the tree was clean. - The reruns are green: 26/26 (face inventory) and 7/7 (composed). ## Tests (at `15402d98f8`, after merging `origin/main` `8843505d91`, which carries PR objectstack-ai#21693) - `pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2`: 210 files passed and 3 skipped; 3578 tests passed and 19 skipped. - `pnpm --filter @objectstack/objectql exec vitest run --maxWorkers=2 --project local`: 372 files and 7455 tests passed. - Runtime `hook-handler-package-scope.pin.test.ts` and `stored-metadata-body-boundary.pin.test.ts`: 14/14. - Typecheck, exit 0 for all three packages: - metadata-protocol `tsc --noEmit`. Its program includes all five edited test files (`--listFiles`, one hit each). - objectql and runtime: `tsc` plus `check:test-typecheck`, OK, with the debt ledgers held. Their test layers compile under `tsconfig.test.json` (`include: src/**/*`). - Dependency closure: `pnpm turbo run build --filter='@objectstack/runtime^...' --concurrency=2`, 29/29. `packages/spec` moved on main's side, so `pnpm --filter @objectstack/spec check:generated` also ran: all 15 generated artifacts are up to date. - The rest of `packages/runtime`'s suite is declared to CI. ## Gates (at `15402d98f8`) Every family below ran first at `623b4a0b94` and ran again in full on the merged head `15402d98f8`. The figures are the second run's. **Derived.** `node scripts/pm/dispatch-gates.mjs --commands` (no paths) derives 66 families, the same list on both heads, and all 66 ran. - All 66 exited 0. That includes `check:dual-build-cjs-loads`: 106 published require entry points across 66 packages load. On the first head it had exited 3, PREREQUISITE NOT MET, for want of a full build. - `--ran` reconciliation: 66 accounted for, 66 run, 0 NOT-MEASURED (a derived zero), 0 UNRUN. **Artifact-roster block** (54 families, outside the derived total). All 54 ran. - 51 exited 0. These include `check:error-status-conformance`, `check:error-code-casing`, `check:authz-resolver`, `check:route-ledger-census`, `check-changeset-fixed` and `check:engine-double-contract`. - 3 exited 2, NOT WIRED without PR context: `check-closing-target-claim`, `check-partof-closing-keyword` and `check-single-claim-paths`. They are rerun with this PR's context, and the results go in the os-dev report. **Symbol-anchor sweeps**, all exit 0: - `check:adr-symbol-anchors`: 2167 anchors across 140 records. - `check:scripts-symbol-anchors`: 3760 anchors across 282 scripts. - `check:spec-docblock-symbol-anchors`: 4867 anchors across 1861 spec sources. - `check:adr-anchors`: OK. **Lint.** CI owns `pnpm lint`. This PR records a proven narrowing instead: - **Population:** `eslint.config.mjs` lints `files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']`. - **Count:** `eslint --no-inline-config --format json` over the 10 changed TS files reports 10 files, 0 errors and 0 warnings. - **Invariance:** the config enables no type-aware linting (no `parserOptions.project`), so this diff cannot move the verdict of any untouched file. ## Changeset `.changeset/21689-hook-no-body-save-door.md`: `minor` for `@objectstack/metadata-protocol`, `Clause-②: no (narrowing)`, the BREAKING banner, and the ADR-0087 marker `not-required (no-migration-prescription)` with the census above. `check-adr-0087-registration --base origin/main` accepts it. ## Landing point As the claim predicted: `packages/metadata-protocol/src/protocol.ts`, `runtimeHookWithoutBodyRefusal` (`saveMetaItem`, type `hook`), plus the seven probe suites. No producer elsewhere needs a change. No governed surface is touched. PR objectstack-ai#21693, which edits the read region and the import block of `protocol.ts`, landed on main before this PR opened. It is merged in here; the merge was clean, and this diff touches neither region. ## Acceptance notes - **The draft-promotion and restore doors still do not re-ask this rule.** objectstack-ai#21658's PR recorded this for the `handler` form, and it now covers the neither form too. `publishMetaItem`, `rollbackMetaItem` and `revertCommit` can make a draft or a history version stored before this change into an active bare row, which the runtime skips at re-sync as before. Carrier: none. - **`os meta register hook --data FILE`** forwards the author's file through this door, so a bare hook file now gets this 400 and the CLI prints its message. It needs no change of its own. - **The binder's skip line** reads `skipping hook with unresolved handler` for a hook that has no `handler`. No stored row of that shape can be minted through this door any more. Noted, not filed. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ad serves for the request's organization (objectstack-ai#21716) (objectstack-ai#21737) Fixes objectstack-ai#21716 Clause-②: no (narrowing) ## What was wrong The two item reads (`getMetaItem`, and `getMetaItemLayered`, which serves `GET /api/v1/meta/:type/:name/layers`) resolve a stored row by precedence: the organization's own row, else the env-wide row (ADR-0005). The ADR-0010 `_lock` gate's overlay limb (`getEffectiveLock`) asked for one row only: `organization_id` equal to the request's organization. So when an organization had no row of its own and the env-wide row declared `_lock: 'full'`, the reads served the env-wide row and published `lock: full`, `editable: false`, `deletable: false`, while that organization's save, publish, rollback and delete were admitted. ADR-0010 §3.3 says `full` means "Overlay writes rejected". This is the third member of the family, on the organization axis. The first was the package door (PR objectstack-ai#21693) and the second the topology axis (PR objectstack-ai#21715). ## H1: the two resolutions, measured at `c43a8ae612` Measured on the real `ObjectStackProtocolImplementation` over an engine double, for a `view`. The table was identical on an environment kernel and a host-config kernel. "Door" is the overlay limb's own `sys_metadata` query. | stored rows | request | both reads serve | door read at base | door verdict | |---|---|---|---|---| | env-wide `full` | no organization | env-wide row, `full` | `organization_id` null: env-wide row | `full`: agrees | | env-wide `full` | `org_a` | env-wide row, `full` | `organization_id = org_a`: nothing | `none`: **split** | | org `full` | no organization | nothing, `none` | null: nothing | `none`: agrees | | org `full` | `org_a` | org row, `full` | org_a: org row | `full`: agrees | | env `full` + org `none` | no organization | env-wide row, `full` | env-wide row | agrees | | env `full` + org `none` | `org_a` | org row, `none` | org row | agrees | | env `none` + org `full` | no organization | env-wide row, `none` | env-wide row | agrees | | env `none` + org `full` | `org_a` | org row, `full` | org row | agrees | | neither | either | nothing, `none` | nothing | agrees | There is one split cell per kernel, and it is the card's. ## What changed (H2) Only `packages/metadata-protocol/src/protocol.ts` changes at runtime. - **One resolution.** A new private method, `findServedOverlayRow`, holds the served-row resolution. It applies the org-scoped row first and the env-wide row as the fallback, with ADR-0048 prefer-local inside each scope, and returns the row and its scope. Three callers now use it, and the three hand-written copies are gone: - `getMetaItem`'s row read and its draft-preview arm; - `getMetaItemLayered`'s overlay layer; - `getEffectiveLock`'s overlay limb. - **The same organization gate.** The gate passes the organization through `organizationIdForMetaRead`, the predicate both reads apply. So on a type with no per-org channel, the door ignores an org-scoped row exactly as the reads do. Pin 4 holds this. - **One declared difference, `otherSpelling`.** The reads keep their at-rest tolerance: as a last resort in each scope, they also read a row stored under the type's other spelling. The gate passes `false` and stays on the canonical spelling. This was measured, not assumed: - Full reuse turned `packages/objectql/src/protocol-meta-type-canonicalization.test.ts` red ("a plural-spelled write addresses the canonical namespace and no other"). On a create, the gate queried `type: 'actions'` after the canonical row missed. - That test pins objectstack-ai#4432's rule that a write addresses only the canonical namespace. Changing it would loosen it, so the gate keeps the rule and the test is untouched. - The difference is stated on `findServedOverlayRow` and in the gate's header. - **A stale docblock fixed.** The `getEffectiveLock` header still told callers to gate on `environmentId`, which has not been true since objectstack-ai#21694. It now says the method answers alike on every topology. - **The ledger.** `scripts/engine-double-contract.pinned.json` gains one row for the new pin's `findOne` double. It was written by the gate's own `--write`: 1 added, 0 lost. No `packages/spec/src/**` file is touched, so no contract review is owed on that ground. No governed surface is touched. ## H3: precedence when both rows exist The reads serve the organization's own row to that organization. That precedence is the ruling recorded in `getMetaItem` (ADR-0005: precedence, never a merge). The gate now binds the lock of that same row, whatever the env-wide row declares, and pin 3 covers it. ADR-0010 §3.3 states its lock table per item and records no cross-scope cascade. I found no text under which the reads' precedence is wrong, so the precedence is unchanged here. ## H4: every caller moves together `getEffectiveLock` has two callers. Both read the new limb, because the change is inside it: - `lockWriteRefusal` is reached through: - `assertLockAllowsWrite`, from `saveMetaItem` and `rollbackMetaItem`; - `promoteDraftForPublish`, from `publishMetaItem` and `publishPackageDrafts`. - `assertLockAllowsDelete` is reached from `deleteMetaItem`, which `deletePackage` and `discardPackageDrafts` also call. Pin 2 drives save, delete, publish and rollback. ## What moves (H5) - **Tests: none re-aimed.** No existing test changed in the final shape. - The one red seen during the work was the canonicalization case above. It was caused by a first shape of this fix, not by the split, and it is green again under the final shape. - `metadata-protocol`: 212 files and 3675 tests pass (3589 before, plus 86 new). - `objectql`: 372 files and 7458 tests pass. - **Examples.** `git grep` finds no `_lock` and no `protection:` under `examples/**`. - **Shipped locks.** `platform-objects` declares `protection.lock` only on `app` items (`setup`, `studio`, `account`) and `object` items (`sys_*`). Both types are `allowOrgOverride: false`, so the gate never carries an organization for them, and their artifact limb is unchanged. - **Writers that reach the gate with an organization:** - over the wire, REST `PUT`, `DELETE`, publish and rollback, and the runtime dispatcher's save. They carry `organizationIdForMetaWrite`, which is non-empty only for `view`, `dashboard`, `report`, `translation` and `email_template`; - in process, `migrateStoredMetadata` (the row's own organization, so its own row is served and nothing changes), `deletePackage` and `discardPackageDrafts` (the row's organization), and `duplicatePackage` (a new name). - **Newly refused.** An org-scoped write of one of those five types is now refused `ITEM_LOCKED` / 403 when that organization has no row of its own and the env-wide row's lock refuses the operation. On a type with no per-org channel, an in-process removal of a pre-objectstack-ai#6190 org-scoped row is now judged by the env-wide row's lock, the row both reads serve. - **The dev server's boot and seed replay.** Seed rows are type `seed`, which has no per-org channel, and they name no organization. The boot path reads; it does not write through these doors. This was measured by `git grep` of every non-test caller of the four write verbs. **NOT MEASURED: a live server boot** (no dev server was started for this card). - **Cost.** With an organization, the gate makes 2 `findOne` reads on a miss (the org row, then the env-wide row) where it made 1. Without an organization nothing changes. ## Pins `packages/metadata-protocol/src/protocol.lock-org-axis-agree.test.ts` has 86 cases. Each one drives the real doors and the real reads on one protocol instance, and every refusal asserts `code` and `status` (ADR-0112). 1. **The family's enumeration pin (triage's acceptance).** One table covers kernel topology (environment, host-config), row scope (env-wide, org-scoped), request scope (no organization, `org_a`), every `MetadataLockSchema` level (read off the schema itself) and operation (save, delete). That is 64 rows, each its own named case. - In every row the door admits exactly when the envelope says `editable` (save) or `deletable` (delete). - Both reads must agree with each other first. - A span check holds the table size to the product of the axes, and a lit control proves the org axis reaches the env-wide row. 2. **The measured defect.** An env-wide `_lock: 'full'` row is tested on both kernels. - The org-scoped read says `editable: false`. - Save and delete are refused `ITEM_LOCKED` / 403 with `lock: 'full'`. The denial rows are written to `sys_metadata_audit` under `org_a`. - Publish and rollback are refused the same way. 3. **Precedence (H3).** Three lock pairs ((full, none), (none, full), (no-delete, no-overlay)) are tested against both request scopes on both kernels. The served row is named in every case, and both doors follow it. 4. **The organization gate.** A `page` (no per-org channel) is tested with an env-wide row and an org-scoped residue row whose locks differ. The read serves the env-wide row, and the door binds that row's lock. ## Reverse verification Both ablations ran from the committed fix (HEAD `7b37480d8c`), through `scripts/ablation-replace.mjs` in wrap mode, inside a script whose `EXIT INT TERM` trap restores from `HEAD` by absolute path. The pin imports `./protocol.js`, which resolves to source, so no rebuild is in the path. The expected direction was declared before each run. 1. **The exact-`organization_id` limb restored** (one `findOne` on `organization_id: organizationId ?? null`). - The anchor went from 1 to 0, and 1 marker was on disk. The blob went from `e6a207612cc4` to `c598f7de3b47`. - Predicted: 16 red, 70 green. Measured: **16 failed, 70 passed**. - Red: the 8 org-axis cells of pin 1 (an env-wide row, an `org_a` request, with `no-overlay`/`full` on save and `no-delete`/`full` on delete, on both kernels), all 4 cases of pin 2, and all 4 of pin 4. - Green: the other 56 rows of pin 1, the span check and the lit control, and all of pin 3. 2. **The organization gate removed from the door only.** - The anchor went from 1 to 0, and 1 marker was on disk. - Predicted: 4 red. Measured: **4 failed, 82 passed**, all of them pin 4. **Restore.** After each leg, the blob equals the `HEAD` blob `e6a207612cc4`, `git diff HEAD` is empty, and `git status --porcelain` is empty. Both the tool and the trap proved it. ## Tests On head `87e350bbaf`, after merging `origin/main` at `316be321ef` (which touched `runtime` and `scripts/` only): - `@objectstack/metadata-protocol`: - `vitest run`: 212 files passed and 3 skipped; 3675 tests passed and 19 skipped. - `typecheck` (`tsc --noEmit`) is green. `--listFiles` compiles 215 test files, including the new pin. - `@objectstack/objectql`, against the rebuilt `metadata-protocol` dist: - `vitest run --project local`: 372 files and 7458 tests passed. - `--project repo`: 1 file and 5 tests passed. - **Lint, narrowed and proved.** `eslint --no-inline-config --format json` over the two changed TypeScript files gives 2 file results, with 0 errors and 0 warnings. - The population comes from ESLint's own config: `isPathIgnored` is false for both files, and each computes a 5-rule config. - Invariance: neither computed config has `parserOptions.project` or `projectService`, and `eslint.config.mjs` states it never enables type-aware linting. So this diff cannot move any untouched file's verdict. NOT MEASURED, owned by CI or out of reach here: - HTTP: the reach is on the real protocol over doubles. - a live server boot. - the `rest` and `runtime` suites. They are consumers, and no export, spec contract or wire shape changes. - the Dogfood Regression Gate. - Temporal Conformance. - the whole-workspace type-check lanes. - the full `pnpm lint`. ## Gates All of these ran on head `87e350bbaf`, after the final commit. Each exit code was captured before any pipe. - **Derived set.** `node scripts/pm/dispatch-gates.mjs --commands` (no paths) derives 72 families. All 72 exit 0. The `--ran` reconciliation reads "72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN". - Among them: `check:adr-0087-registration` (1 breaking changeset, carrying its disposition), `check:changeset-no-major`, `check:empty-changeset`, `check:engine-double-contract` (855 rows held), `check:nul-bytes`, `check:doc-authoring`, `check:cross-package-test-inputs`, `check:test-source-alias`, `check:published-files`, `check:dts-closure`, `check:dual-build-cjs-loads` and `check:lean-entry-closure`. - The last two first answered PREREQUISITE NOT MET (exit 3). They were re-run after a full `turbo run build` and exit 0. - **Artifact-roster block.** All 54 roster commands were run; 51 exit 0. - `check-closing-target-claim`, `check-partof-closing-keyword` and `check-single-claim-paths` exit 2 NOT WIRED, because they read a pull request. - **The four symbol-anchor sweeps.** All four exit 0: - `check:adr-symbol-anchors`: 2167 anchors across 140 records resolve. - `check:scripts-symbol-anchors`: 3760 anchors across 282 scripts resolve. - `check:spec-docblock-symbol-anchors`: 4950 anchors across 1868 spec sources resolve. - `check:adr-anchors`: OK. ## Acceptance notes Three members of the same family sit outside triage's five axes. Each was measured on the real reads and the gate over doubles, at `c43a8ae612`, and none is fixed here. They are reported to the seat for its call. - **The artifact axis: an explicit `none`.** A packaged view declares `_lock: 'none'`, and its stored row declares `full`. - Both reads say `editable: true`, because `mergeArtifactProtection` lets the artifact's explicit value win. - The gate refuses, because its artifact limb skips `none` and its overlay limb finds `full`. - The door is stricter than the read. - **The layered read: a packaged item's stored lock.** A packaged view declares no lock, and its stored row declares `full`. - `getMetaItem` says `full` / `editable: false`, which agrees with the door. - `getMetaItemLayered` says `none` / `editable: true`, because its lock source is `code ?? overlay`. - The two reads disagree with each other. - **The other spelling.** This is the declared difference above. A pre-objectstack-ai#4432 row stored under the plural spelling is served by the reads when no canonical row exists in that scope. The gate does not read it. No live write mints such a row. Two smaller notes: - **The package axis.** The gate asks without a `packageId`, while a read that names one prefers that package's row (ADR-0048). The two can split only when one (type, name, scope) holds rows from two packages. Not measured. - **One pathological layered case.** `getMetaItemLayered` used to fall back to the env-wide row when an org row's stored body was JSON `null`. It now reports the org row, as `getMetaItem` always did. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21670
Clause-②: no
What was wrong
Both metadata reads publish the ADR-0010 protection envelope beside the item:
getMetaItem, andgetMetaItemLayered, which servesGET /api/v1/meta/:type/:name/layersand its deprecated?layers=truespelling. That envelope wasresolveLockState(document), built from the item's own_lockand nothing else.The write doors also refuse a second kind of item: one a code package ships, on a type with no per-org overlay channel. They answer
403 NOT_OVERRIDABLE, orITEM_LOCKEDwhen the write names the read-only package. So packaged flows and actions readlock: "none",editable: trueanddeletable: truewhile every door refused them in place. So did the packaged items of 21 other types.Trace
Producer.
packages/metadata-protocol/src/protocol.tshas two call sites ofresolveLockState, one ingetMetaItemand one ingetMetaItemLayered. The spec declares the fields once, inMetadataProtectionEnvelopeFields(packages/spec/src/api/protocol.zod.ts), and both responses share them.The doors' predicate.
packagedBaseRefusalis public on the same class. It holdsrefusePackagedBaseOverrideandrefusePackagedBaseRemoval, both lifted out ofsaveMetaItem/deleteMetaItem, and the/automationdoors already ask it. It does not depend on topology:SysMetadataRepository.assertAllowed/assertDeleteAllowedthrows the same refusal at the write.The table below measures both kernels. They agree on every type.
The change
One private derivation,
servedLockState, which both reads now call:editableis the_lockverdict ANDpackagedBaseRefusal(save) === null.deletableis the_lockverdict ANDpackagedBaseRefusal(delete) === null.lockis the ADR-0010 state whoseevaluateLockForWrite/evaluateLockForDeleteverdicts are exactly those two booleans. It is read off the lock algebra, so there is no second table. The spec's declared algebra therefore still holds:editableis false ifflockisno-overlayorfull, anddeletableis false ifflockisno-deleteorfull._lockand the package verdict join; neither replaces the other.lockReason,lockSourceandlockDocsUrlare unchanged: they are still only what the document declares.Spec wording. The
lockfield's.describe()said it refuses "with 403ITEM_LOCKED" and is "Resolved from the document's_lock". This change makes both statements false for package-door locks, so the description now names both refusals. This is wording only: no key, type, optionality or accept set moves (Clause-② no).check:generated --fixregeneratedcontent/docs/references/api/protocol.mdx, one line.Beyond the claimed file surface: the spec description edit and its regenerated reference line, and
scripts/engine-double-contract.pinned.json, where the gate records the new test's engine double.Measured: every registry type, both kernels
Packaged item with no
_lock. Each read wasnone/true/truebefore this change. The environment kernel and the host-config kernel gave identical rows for all types (0 differ).lock/editable/deletable)view,dashboard,report,translation,email_templatenone/ true / true (unchanged)page,app,dataset,book,permission,position,tool,skillNOT_OVERRIDABLEsupportsOverlayoverlay-removal carve-out)no-overlay/ false / trueobject,field,hook,seed,picklist,mapping,action,flow,job,datasource,external_catalog,api,doc,capability,agentNOT_OVERRIDABLENOT_OVERRIDABLEfull/ false / falseAn org-owned item is a stored row that no package ships. For the 22 types that can be created at runtime, both doors admit it on both kernels, and it reads
none/ true / true (unchanged).Pins
The new file
packages/metadata-protocol/src/protocol.read-lock-flags-write-door.test.tsholds 107 cases.lockis notnone,editable: false,deletable: false, on both reads and both kernels.none/ true / true.saveMetaItem/deleteMetaItem, end to end. An admission counts only if it reached the_lockgate and that gate answerednull.DEFAULT_METADATA_TYPE_REGISTRY, with a floor of 28;OS_METADATA_WRITABLE=flow,action, the read isnone/ true / true and the door admits;_lock: 'no-delete'readsno-delete, and its delete door refuses withITEM_LOCKED;_lock: 'no-delete'readsfull, which shows the two verdicts join rather than replace.Evidence
Red first. At
515955b905(the pin alone, onmain's code): 50 failed / 57 passed of 107. The failures readexpected 'none' not to be 'none'andexpected { editable: true, deletable: true } to deeply equal { editable: false, deletable: false }.Green with the fix: 107 / 107.
Ablation. With the fix committed,
scripts/ablation-replace.mjsreplacedreturn { ...declared, lock, editable, deletable };withreturn declared;.c462702ad973→f61b76d75c58.c462702ad973equals the HEAD blob, andgit diff HEADis empty.@objectstack/metadata-protocol.tsc --noEmitexits 0, and the new test is in the program (--listFilescount 1).vitest run: 210 files, 3570 passed, 19 skipped.Consumer sweep (downstream readers of the envelope that drive a real protocol or pass it through):
src/protocol-*src/meta-*src/domains/meta-*Gates.
dispatch-gates --commandsat017a2c7599derives 120 commands.--ranreconciles 118 run, all at exit 0, and two not measured. Both are declared to CI:check:dual-build-cjs-loadsexits 3: it needs every package built. This diff adds no module to any entry's import closure:MetadataLockSchemacomes from@objectstack/spec/kernel, whichprotocol.tsalready imports.check:type-check-debthit the foreground cap. It re-measures the DEBT-ledger packages repo-wide.metadata-protocolhas no DEBT entry and its owntscis clean, and the spec edit is describe text only.Re-run at the final head
017a2c7599:check:engine-double-contract(the new double is recorded in the pinned ledger)check:objectql-double-limitcheck:nul-bytescheck:cross-package-test-inputscheck:test-source-aliascheck:type-check-coveragecheck:durability-log-levelcheck:generated: "All 15 generated artifacts are up to date"Acceptance notes
200"No customization overlay found ... already at artifact default". That is the reset door's no-op, and nothing is removed.deletable: falseis the base-removal verdict, which the predicate's own docblock states for every topology, so the two do not contradict. On an environment kernel the same DELETE answers 403NOT_OVERRIDABLE.ResourceEditPage) readslayered.editable,deletableandlock.canWriteByType).no-overlaygroup keeps its reset / delete button (deletableis true).GET /api/v1/packageswritable, which is unchanged._lockon host-config. On a host-config kernel the item-level_lockgate (lockWriteRefusal,assertLockAllowsDelete) returns no refusal whileenvironmentIdis undefined. So an overlay-type item that declares_lockreadseditable: falsethere, while the/metasave admits it. The read is stricter than the door. This PR leaves it alone, because the doors' policy is outside this card. No shipped producer was found: theprotection.lockdeclarations inplatform-objectsare all onobject, which the package door refuses anyway. Carrier: none.getMetaDiagnostics().stats[type].lockedcounts declared_lockonly, not package-door locks. Carrier: none.field,picklist,job,api,capabilityandagenthave no org-owned arm in the table. No runtime door can author one (NOT_CREATABLE), and that refusal is not the locked-base predicate. Their packaged arm is in the table.origin/mainhas moved 2 commits since7d0781482d, touching the organizations plugin and sdui-parser. Both are disjoint from this diff.createMetaLayeredAnswerspreads it), so the protocol pin and the REST / runtime sweep stand in. No showcase boot was run.Generated by Claude Code