Commit c43a8ae
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
File tree
- .changeset
- packages
- metadata-protocol/src
- objectql/src
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
Lines changed: 15 additions & 1 deletion
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
171 | 171 | | |
172 | 172 | | |
173 | 173 | | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
174 | 182 | | |
175 | 183 | | |
176 | 184 | | |
| |||
179 | 187 | | |
180 | 188 | | |
181 | 189 | | |
182 | | - | |
| 190 | + | |
183 | 191 | | |
184 | 192 | | |
185 | 193 | | |
| |||
238 | 246 | | |
239 | 247 | | |
240 | 248 | | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
241 | 255 | | |
242 | 256 | | |
243 | 257 | | |
| |||
0 commit comments