Skip to content

Commit c43a8ae

Browse files
fix(metadata-protocol)!: the ADR-0010 _lock gate refuses on a host-config kernel too, and the diagnostics locked count reads the item envelope derivation (#21694) (#21715)
Fixes #21694 Clause-②: no (narrowing) ## Arm 1a: no recorded reason exempts a host-config kernel I measured H1 before writing any code. - **Where the short-circuit came from.** `if (this.environmentId === undefined) return null;` opened both `lockWriteRefusal` and `assertLockAllowsDelete`. It arrived in 8eca8f3 (2026-05-29, "Update files", then in `packages/objectql/src/protocol.ts`), and neither docblock gave a reason. The only rationale ever written is the title of the test that pinned it: "environmentId=undefined bypasses L3 (control-plane bootstrap)" (`packages/objectql/src/protocol-lock-enforcement.test.ts`). 2796a1f (#1775) later called the bypass "intentional" in the registry-shadow suite's docblock, again without a reason. - **ADR-0010 records no carve-out.** The §3.3 lock table has no topology column. §6 Phase 1 places `_lock` enforcement in save, publish, delete and rollback. The "single-process dev / bootstrap" sentence in §3.8 is about the `OS_METADATA_WRITABLE` type hatch, which this gate never read. - **#6710's precedent refutes the inference.** The `MetadataAuthoringChannel` docblock and the comment in `assertRuntimeAuthoringRules` record three facts. `environmentId` is a row-scoping key. The CLI's host-config assembler (`serve.ts`, `new ObjectQLPlugin()` with no options) leaves it undefined while it serves end-user `PUT /api/v1/meta/*`. That assembler is the showcase's own boot shape. #6710 re-keyed the ADR-0005 carve-out to a declared `authoringChannel`, and #5086, #7674 and #9380 retired the same proxy elsewhere. - **`getEffectiveLock` has no topology term.** Its artifact limb reads the registry, and its overlay limb reads `sys_metadata` through the engine. It does resolve a lock on a host-config kernel: pin 1 is refused there with both `source=overlay` and `source=artifact`. So the gate refuses on every topology, as `packagedBaseRefusal` does. I kept no exemption for the declared `package-author` channel either. No ADR or comment records one for L3, and no assembly in this repository declares that channel (`git grep` finds 0 non-test declarations). H3 (arm 1b) is not taken. ## What changed Only `packages/metadata-protocol/src/protocol.ts` changes at runtime. 1. **The gate.** `lockWriteRefusal` and `assertLockAllowsDelete` lose the short-circuit. `publishMetaItem` (through `promoteDraftForPublish`) and `rollbackMetaItem` already call them unconditionally, so those two verbs now refuse on a host-config kernel too. 2. **The two call sites inside `if (this.environmentId !== undefined)`.** `saveMetaItem` and `deleteMetaItem` asked the gate inside that block, behind their package doors, so removing the short-circuit alone would not reach them. The gate moves out of the block, and it keeps its rank on both kernels: it runs only when `packagedBaseRefusal` admits the write. - On an environment kernel the package door has already thrown by that point, so nothing changes there. - On a host-config kernel the package door is the repository's (`SysMetadataRepository.assertAllowed`, at the write), so a packaged base it refuses keeps that answer. - Without the rank, a packaged app or object that declares `_lock` would answer `ITEM_LOCKED` on a host-config kernel and `NOT_OVERRIDABLE` on an environment kernel. That would be a new topology split in the refusal code. Ablation 2 below pins it. 3. **The count.** `getMetaDiagnostics().stats[type].locked` now counts items whose `servedLockState(...)` reports a lock other than `'none'`. That is the same derivation `getMetaItem` and `getMetaItemLayered` publish. The field's docblock says so. No `packages/spec/src/**` file is touched, so no contract review is owed on that ground. No governed surface is touched. ## Reach: correcting the card's "no shipped producer" Triage graded p3 on the premise that "every `protection.lock` in `platform-objects` is on `object`". Three apps carry one too: `setup`, `studio` and `account` (`packages/platform-objects/src/apps/*.app.ts`, `protection.lock: 'full'`). `app` has `supportsOverlay: true`, so the #6960 carve-out lets a removal past the package door, and only the `_lock` gate stood behind it. - **At the base (16d241a).** I ran the real `ObjectStackProtocolImplementation` over an in-memory double, without an `environmentId`. Deleting a packaged app that declares `_lock: 'full'` answered success. An environment kernel answered `403 ITEM_LOCKED`. - **On this branch, on a live showcase.** I started a fresh dev server (`pnpm dev -- --fresh -p 38694`) and signed in as the seeded admin. `DELETE /api/v1/meta/object/sys_organization` answered `200` ("No customization overlay found"), where an environment kernel answers `403 NOT_OVERRIDABLE`, so the server is host-config. - `GET /api/v1/meta/app/setup` answers `lock: full`, `editable: false`, `deletable: false`. - `DELETE /api/v1/meta/app/setup` answers `403 ITEM_LOCKED` ("app/setup is locked (_lock=full, source=artifact)"). The grade is the seat's call. This corrects the premise behind it. ## What moves (H2) - **The dev server's own boot.** The fresh showcase boot above logged no `ITEM_LOCKED`, no "is locked (_lock=" and no "metadata store could not be read" (0 hits in 72 lines). Nothing the boot writes is refused. - **`examples/**`.** No example declares `_lock` or `protection:` (`git grep`: 0 hits). - **Writers that now meet the gate on a host-config kernel**, as they already did on an environment kernel: - `os migrate meta --stored --apply` (`migrateStoredMetadata`): a row whose effective lock refuses the write is reported failed instead of rewritten. - `deletePackage` and `discardPackageDrafts`: a locked item becomes a `failed[]` row. - `duplicatePackage`. - plugin-security's permission-set projection. - service-automation's flow-credential migration. - the `/automation` doors, which write through `saveMetaItem` and `deleteMetaItem`. - **A host-config deployment that relied on writing a `_lock`ed item.** Its saves, publishes, rollbacks and deletes are refused now, as on an environment kernel. A stored row that declares `full` can no longer be written or removed through `/meta` on any kernel. The ADR-0010 §3.8 override path is not implemented, and that is unchanged here. - **Fail-closed read.** On a host-config kernel the gate's own `sys_metadata` read now runs first. A failed read is answered `503 SERVICE_UNAVAILABLE`, with the driver error on `cause` (#5706), before anything is written. A delete used to reach the store and answer with the driver's code or a `500`. - **NOT MEASURED: the cloud control-plane assembly.** It is outside this repository, and if it declares `package-author` and writes `_lock`ed items through `saveMetaItem`, it now meets the gate. - **Tests that moved: 14 cases in 6 files, all fixtures, none a product regression.** Each one leaned on the bypass: - `metadata-protocol/protocol.delete-rewrap-envelope.test.ts` (4 cases). The fault was injected into the first `sys_metadata` read, which is now the gate's own read and fails closed with a 503. The fault now arms once the lock verdict is in, so it lands on the probe read this file pins. - `objectql/protocol-lock-enforcement.test.ts` (1 case). It pinned the bypass itself. It now pins `ITEM_LOCKED` on save and delete, and asserts no `update` or `delete` reached the engine. - `objectql/plugin.authoring-channel.test.ts` (1 case). The bare `new ObjectQLPlugin()` kernel had no driver, so the gate's read failed closed with a 503 before the authoring gate could answer. It now registers a memory driver, as `serve.ts` does, and also asserts that nothing is stored. - `objectql/protocol-meta.test.ts` (1 case). "Fail fast when findOne is unavailable" now asserts the gate's `503 SERVICE_UNAVAILABLE`, with `cause` being the driver error. The test title loses its "500". - `objectql/protocol-publish-package-drafts.test.ts` (3 cases). The double stubbed `assertLockAllowsWrite`, but since #8594 the publish path asks `lockWriteRefusal`. The stub moves to `lockWriteRefusal`. - `objectql/protocol-registry-shadow.test.ts` (4 cases). The suite wrote its overlay row through a `PUT` that the bypass admitted. The row is now written before the package's lock arrives, which is the pre-dating overlay that the envelope graft exists for. The two removal cases use `no-overlay`, because `full` now refuses removal on every kernel, and the first case also asserts that a further `PUT` is refused. ## The count's cost (H4) `servedLockState` makes no store read. It is `resolveLockState` (pure), plus two `packagedBaseRefusal` calls (registry lookups, which build an `Error` only for a refused item), plus `isArtifactBacked` (registry). The list items already carry the merged artifact protection (`mergeArtifactProtection` in `getMetaItems`), which is the same document shape the item read derives from. So the sweep stays bounded at one registry walk per item, and the store reads are unchanged. ## One authority (H5) I grepped `packages/metadata-protocol/src` and `packages/rest/src` (non-test) for `_lock` readers. - **What remains.** `getEffectiveLock` remains for the doors, `servedLockState` for the read and now for the count, and `mergeArtifactProtection` copies the envelope. - **What went.** The declared-`_lock` count was the only second predicate, and it is gone. - **`rest`.** It has none. - **`runtime`.** The `/automation` doors ask `packagedBaseRefusal`, then write through `saveMetaItem` and `deleteMetaItem`, so the `_lock` gate covers them. - One same-family disagreement is left, and it is not topological. It is in the acceptance notes and the report. ## Pins `packages/metadata-protocol/src/protocol.lock-door-read-agree.test.ts` runs the real doors and the real reads. Each refusal asserts `code` and `status` (ADR-0112). 1. **Pin 1, host-config.** - An overlay `view` row is tried with each of the four `_lock` states. Save and delete do what `editable` and `deletable` say: `ITEM_LOCKED`/403 where the read says no, and admitted past the gate where it says yes. Admission is proved by spying on the gate (reached, answered `null`). - A publish of a `full` row is refused. - A packaged `app` declaring `full` is refused on delete with `ITEM_LOCKED`/403. Its save keeps `NOT_OVERRIDABLE`/403, and the gate is not reached. 2. **Pin 2, both kernels.** `stats[type].locked` equals the number of items whose `getMetaItem` envelope reads locked, for `flow`, `action`, `app` and `view`. - Lit control: the tiles are `{flow: 1, action: 1, app: 2, view: 2}`. - A count of declared `_lock` would give `{0, 0, 1, 2}`. The flow, action and second app are locked by the package door alone. 3. **Pin 3, environment kernel, unchanged.** An overlay `full` row is refused `ITEM_LOCKED` on save and delete, and a `no-delete` row passes the gate on save and is refused on delete. A packaged app keeps `NOT_OVERRIDABLE` on save and `ITEM_LOCKED` on delete, the same codes the host-config kernel now gives. ## Reverse verification Both ablations ran from the committed fix (HEAD 2ec0a2c), through `scripts/ablation-replace.mjs`, inside a script with an `EXIT INT TERM` trap that restores from `HEAD`. The pin file imports `./protocol.js` (source), so no rebuild is in the path. The predicted direction was declared before running. 1. **Ablation 1 restored the short-circuit in both helpers and the declared-`_lock` count.** - The anchors landed on disk: the gate anchor went from 2 to 0 with 2 markers on disk, and the count anchor from 1 to 0 with 1 marker. The blob went from 185d138 to 5b338f26488f. - The result was as predicted. In pin 1, 5 cases went red, and the `_lock=none` control stayed green. Both pin 2 cases went red. All 3 pin 3 cases stayed green. Total: 7 failed, 4 passed (11). 2. **Ablation 2 removed the rank at both call sites (`true ||` before `packagedBaseRefusal`).** - The anchor went from 2 to 0, with 2 markers on disk. - Only the packaged-app case went red, because the save answered `ITEM_LOCKED` instead of `NOT_OVERRIDABLE`. Pin 3's environment twin stayed green. Total: 1 failed, 10 passed. **Restore.** After each ablation the blob equals the `HEAD` blob 185d138, `git diff HEAD` on the file is empty and `git status --porcelain` is empty. The tool proved this on each leg, and so did the trap. ## Tests On the merged head 4265c6b (after #21706): - `@objectstack/metadata-protocol`: `vitest run`, 211 files passed and 3 skipped, 3589 tests passed and 19 skipped. `typecheck` (`tsc --noEmit`) is green, and `--listFiles` shows it compiles 214 test files, including both test files touched here. - `@objectstack/objectql`: `vitest run --project local`, 372 files and 7458 tests passed. `typecheck` (tsc, tsconfig.scripts, `check:test-typecheck`) is green. On 2ec0a2c (before the second merge of main, which touched neither package's lock path): - `@objectstack/rest` (`--project local`): 260 files, 4897 tests passed, 326 skipped. - `@objectstack/runtime` (`--project local`): 321 files, 4563 tests passed, 19 skipped. - `@objectstack/plugin-security` (the 7 files that call the write verbs): 150 tests passed. - `@objectstack/service-automation` (2 files): 24 tests passed. These four were run because the order asked to measure which tests move. They are downstream consumers, and this is not a public-surface change. ## Gates Run on head 4265c6b, after the final commit. Each command's exit code was captured before any pipe. - **Derived set.** `node scripts/pm/dispatch-gates.mjs --commands` (no paths) derives 74 families. All 74 exit 0, and the `--ran` reconciliation reads "74 derived, 74 run, 0 NOT-MEASURED, 0 UNRUN". These include `check:adr-0087-registration` (the `no-migration-prescription` disposition is accepted), `check:empty-changeset`, `check:changeset-no-major` (its level axis is PR-scoped and reads this body in CI), `check:engine-double-contract`, `check:nul-bytes`, `check:doc-authoring`, `check:cross-package-test-inputs`, `check:test-source-alias`, `check:published-files` and `check:dts-closure`. - **`check:engine-double-contract`.** At first it asked for the new pin file's `findOne` double to be recorded. `--write` added one row to `scripts/engine-double-contract.pinned.json`, with "0 added or grown, 0 lost", and that row is committed here. - **Artifact-roster block.** The derivation prints 54 roster commands outside its total, and all 54 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 (`PR_NUMBER` / `PR_BODY`). I ran the body gate locally against this body before opening the PR, and the other two run in CI on this PR. - **The four symbol-anchor sweeps**, which no path derives for a source change. 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`: 4877 anchors across 1865 spec sources resolve. - `check:adr-anchors`: green. NOT MEASURED locally, owned by CI: - the Dogfood Regression Gate. The live showcase checks above cover its door for the setup app. - the Temporal Conformance live-database job. - the whole-workspace type-check lanes. - the full `pnpm lint`. ## Acceptance notes - **Same family, not topological (reported, not filed).** An env-wide `view` row declares `_lock: 'full'`. An org-scoped read (`getMetaItem` with `organizationId: 'org_a'`) serves that row and reports `lock: full`, `editable: false`. The `_lock` gate for an `org_a` save admits, because `getEffectiveLock`'s overlay limb looks up `organization_id = org_a` only. Measured on both kernels with the protocol over a double: the save went on to validation. This is the door and the read disagreeing on the org axis, and ADR-0010 §3.3 rejects overlay writes under `full`. The report names it for the seat. - **Pre-existing on a host-config kernel, untouched here.** - Deleting a packaged object with no overlay row is a no-op success, while the read says `deletable: false`. That is the package door #21670 accepted, not the `_lock` gate, which ranks below it. - A stored row of a code-only type that declares `_lock` now answers `ITEM_LOCKED`, where an environment kernel answers `NOT_CREATABLE`. The host-config protocol has no `NOT_CREATABLE` door. No producer writes such a row. - **Two `lockSource` vocabularies.** The door's sentence names the limb (`source=artifact` or `source=overlay`), while the envelope carries the declared `MetadataLockSource` (`package` for the setup app). This is pre-existing. - **The #21670 table.** Its host-config door is the repository gate alone, and its items declare no `_lock`, so it stays valid. The `_lock` limb on that kernel is covered by pin 1 here. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3fa850c commit c43a8ae

10 files changed

Lines changed: 481 additions & 45 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
fix(metadata-protocol)!: the ADR-0010 `_lock` gate refuses on a host-config kernel too, and the diagnostics `locked` count reads the item envelope's derivation (#21694)
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) a write-door verdict that now answers on one more kernel topology: the per-item _lock gate (save, publish, rollback, delete) used to return no refusal on a kernel with no environmentId, and now refuses there what it already refused on an environment-bound kernel. No authorable key, spelling, export or stored shape is retired or renamed: MetadataLock keeps its four states, every stored row keeps parsing and is neither read nor rewritten by an upgrade, and which items an operator meant to keep editable is not something a ledger entry can rewrite. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers a lock verdict (not already-registered); and the change is a door verdict plus a count, not a TypeScript declaration (not runtime-interface-only or type-surface-only). -->
10+
11+
**BREAKING**: a `/meta` write that a host-config kernel used to accept can now be refused. A host-config kernel is one with no `environmentId`: the CLI's lightweight assembler boots one for a stack whose `plugins` are instantiated, which is how the showcase app runs. On such a kernel the item-level `_lock` gate never ran, so `saveMetaItem`, `publishMetaItem`, `rollbackMetaItem` and `deleteMetaItem` performed writes on items whose read envelope said `editable: false` or `deletable: false`. The gate now runs on every kernel, and answers the same `403 ITEM_LOCKED` an environment-bound kernel always gave. It ships as `minor` under the launch-window convention for accept-set narrowings. No export is added or removed.
12+
13+
**What is refused now, on a host-config kernel.** A save, publish or rollback of an item whose effective `_lock` is `no-overlay` or `full`, and a delete of an item whose effective `_lock` is `no-delete` or `full`. The effective `_lock` is the packaged artifact's when one declares it, otherwise the stored row's. Each refusal writes its `denied` row to `sys_metadata_audit`, as on an environment kernel. The gate's own `sys_metadata` read fails closed there too: when it fails for any reason other than the table not being provisioned yet, the write is answered `503 SERVICE_UNAVAILABLE` before anything is written, where a delete used to reach the store and answer with the driver's code or a `500`. One shipped case reaches it: the platform's `setup`, `studio` and `account` apps declare `protection.lock: 'full'`, and a `DELETE /api/v1/meta/app/setup` used to pass the package door (removing a legacy app overlay is allowed) and then remove the app's overlay row, or answer success with nothing to remove. It now answers `403 ITEM_LOCKED`. The refusal names the lock and where it came from (`source=artifact` or `source=overlay`). If a host-config deployment relied on writing such an item: a lock a code package declares is changed in that package's source (`protection.lock`) and redeployed; a lock a stored row declares is held exactly as an environment-bound kernel holds it, so a row declaring `no-overlay` can still be deleted and saved again, and a row declaring `full` is no longer writable or removable through `/meta` on any kernel.
14+
15+
**Unchanged.** Which code a packaged base answers: the `_lock` gate still ranks below the package door on both kernels, so a packaged item on a type with no overlay channel keeps `NOT_OVERRIDABLE` (or `ITEM_LOCKED` when the write names the read-only package) on a host-config kernel too. Every refusal, receipt and envelope on an environment-bound kernel. Both reads (`getMetaItem`, `getMetaItemLayered`), which already reported the declared `_lock` on every kernel.
16+
17+
**The diagnostics count.** `getMetaDiagnostics().stats[type].locked` (the Studio directory's per-type tile) counted items with a declared `_lock`. It now counts items whose read envelope reports a lock other than `'none'`, from the same derivation the item read publishes. So an item that the package door refuses in place, such as a packaged flow or action with no `_lock` of its own, is counted, as its envelope has read locked since the previous release. The derivation reads the registry only, so the sweep makes no extra store read per item.

‎packages/metadata-protocol/src/protocol.delete-rewrap-envelope.test.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,14 @@ function makeSession(opts: {
171171
for (const r of opts.seed ?? []) rows.set(r.id, r);
172172
const historyRows: Array<Record<string, unknown>> = [];
173173
const artifactKeys = new Set((opts.artifacts ?? []).map((a) => `${a.type}|${a.name}`));
174+
// [#21694] The ADR-0010 `_lock` gate reads `sys_metadata` on EVERY topology
175+
// now, ahead of the probe read this file injects its fault into — and a
176+
// failure of the gate's own read is answered fail-closed, `503
177+
// SERVICE_UNAVAILABLE` (#5706): that gate's contract, not this re-wrap's.
178+
// So `failFindOne` arms only once the lock verdict is in, and the fault
179+
// lands on the probe read, the seam this file pins (a gate that is never
180+
// reached leaves the fault unarmed, and the case fails loudly).
181+
let lockVerdictIn = false;
174182

175183
const engine: any = {
176184
async findOne(table: string, o: { where: Record<string, unknown> }) {
@@ -179,7 +187,7 @@ function makeSession(opts: {
179187
return historyRows.find((h) => matchesWhere(h, o.where)) ?? null;
180188
}
181189
if (table !== 'sys_metadata') return null;
182-
if (opts.failFindOne) opts.failFindOne();
190+
if (opts.failFindOne && lockVerdictIn) opts.failFindOne();
183191
for (const row of rows.values()) if (matchesWhere(row as any, o.where)) return row;
184192
return null;
185193
},
@@ -238,6 +246,12 @@ function makeSession(opts: {
238246
() => new Map(),
239247
opts.environmentId,
240248
) as any;
249+
const lockGate = protocol.assertLockAllowsDelete.bind(protocol);
250+
protocol.assertLockAllowsDelete = async (args: unknown) => {
251+
const verdict = await lockGate(args);
252+
lockVerdictIn = true;
253+
return verdict;
254+
};
241255
return { protocol, rows, historyRows };
242256
}
243257

0 commit comments

Comments
 (0)