Repository navigation
Commit fe10172
fix(metadata-protocol): the read envelope's lock / editable / deletable report the write doors' locked-base verdict (#21693)
Fixes #21670
Clause-②: no
## What was wrong
Both metadata reads publish the ADR-0010 protection envelope beside the
item: `getMetaItem`, and `getMetaItemLayered`, which serves `GET
/api/v1/meta/:type/:name/layers` and its deprecated `?layers=true`
spelling. That envelope was `resolveLockState(document)`, built from the
item's own `_lock` and 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`, or `ITEM_LOCKED` when the write names the read-only
package. So packaged flows and actions read `lock: "none"`, `editable:
true` and `deletable: true` while every door refused them in place. So
did the packaged items of 21 other types.
## Trace
- **Producer.** `packages/metadata-protocol/src/protocol.ts` has two
call sites of `resolveLockState`, one in `getMetaItem` and one in
`getMetaItemLayered`. The spec declares the fields once, in
`MetadataProtectionEnvelopeFields`
(`packages/spec/src/api/protocol.zod.ts`), and both responses share
them.
- **The doors' predicate.** `packagedBaseRefusal` is public on the same
class. It holds `refusePackagedBaseOverride` and
`refusePackagedBaseRemoval`, both lifted out of `saveMetaItem` /
`deleteMetaItem`, and the `/automation` doors already ask it. It does
not depend on topology:
- on an environment kernel, the protocol throws the refusal;
- on a host-config kernel (the showcase),
`SysMetadataRepository.assertAllowed` / `assertDeleteAllowed` throws 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:
- `editable` is the `_lock` verdict AND `packagedBaseRefusal(save) ===
null`.
- `deletable` is the `_lock` verdict AND `packagedBaseRefusal(delete)
=== null`.
- `lock` is the ADR-0010 state whose `evaluateLockForWrite` /
`evaluateLockForDelete` verdicts 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: `editable` is false iff `lock`
is `no-overlay` or `full`, and `deletable` is false iff `lock` is
`no-delete` or `full`.
- An item's own `_lock` and the package verdict join; neither replaces
the other.
- `lockReason`, `lockSource` and `lockDocsUrl` are unchanged: they are
still only what the document declares.
- No write door changes.
**Spec wording.** The `lock` field's `.describe()` said it refuses "with
403 `ITEM_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 --fix`
regenerated `content/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 was `none` / `true` / `true`
before this change. The environment kernel and the host-config kernel
gave identical rows for all types (0 differ).
| types | save door | delete door | read now (`lock` / `editable` /
`deletable`) |
|---|---|---|---|
| `view`, `dashboard`, `report`, `translation`, `email_template` |
admitted | admitted | `none` / true / true (unchanged) |
| `page`, `app`, `dataset`, `book`, `permission`, `position`, `tool`,
`skill` | 403 `NOT_OVERRIDABLE` | admitted (the `supportsOverlay`
overlay-removal carve-out) | `no-overlay` / false / true |
| `object`, `field`, `hook`, `seed`, `picklist`, `mapping`, `action`,
`flow`, `job`, `datasource`, `external_catalog`, `api`, `doc`,
`capability`, `agent` | 403 `NOT_OVERRIDABLE` | 403 `NOT_OVERRIDABLE` |
`full` / false / false |
An 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.ts`
holds 107 cases.
- **Packaged flow and action:** `lock` is not `none`, `editable: false`,
`deletable: false`, on both reads and both kernels.
- **Org-owned flow and action:** read `none` / true / true.
- **The table:** 100 rows, made of 28 types × 2 kernels for packaged
items plus 22 × 2 for org-owned items. Each row asserts three things:
read equals door, the spec's algebra holds, and the by-name read equals
the layered read.
- On the environment kernel the door is `saveMetaItem` /
`deleteMetaItem`, end to end. An admission counts only if it reached the
`_lock` gate and that gate answered `null`.
- On the host-config kernel the door is the repository gate. An
admission means the engine was touched, which only happens past the
gate.
- **Lit control:**
- the roster equals `DEFAULT_METADATA_TYPE_REGISTRY`, with a floor of
28;
- each verb was both refused and admitted on each kernel;
- all three lock states appeared.
- **Controls:**
- with `OS_METADATA_WRITABLE=flow,action`, the read is `none` / true /
true and the door admits;
- a packaged view with `_lock: 'no-delete'` reads `no-delete`, and its
delete door refuses with `ITEM_LOCKED`;
- a packaged flow with `_lock: 'no-delete'` reads `full`, which shows
the two verdicts join rather than replace.
## Evidence
- **Red first.** At `515955b905` (the pin alone, on `main`'s code): 50
failed / 57 passed of 107. The failures read `expected 'none' not to be
'none'` and `expected { 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.mjs`
replaced `return { ...declared, lock, editable, deletable };` with
`return declared;`.
- The mutation landed: anchor count 1 → 0, marker 0 → 1, blob
`c462702ad973` → `f61b76d75c58`.
- Result: 50 failed / 57 passed, read from the vitest summary line.
- Restored by blob hash: `c462702ad973` equals the HEAD blob, and `git
diff HEAD` is empty.
- A first attempt was a no-op: its replacement still contained the
anchor, so the tool refused it and no test ran.
- **`@objectstack/metadata-protocol`.** `tsc --noEmit` exits 0, and the
new test is in the program (`--listFiles` count 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):
| package | files | result |
|---|---|---|
| objectql `src/protocol-*` | 39 | 612 passed |
| plugin-security permission-set and packaged tests | 13 | 191 passed |
| rest `src/meta-*` | 49 | 1077 passed |
| runtime `src/domains/meta-*` | 19 | 1048 passed |
- **Gates.** `dispatch-gates --commands` at `017a2c7599` derives 120
commands. `--ran` reconciles 118 run, all at exit 0, and two not
measured. Both are declared to CI:
- `check:dual-build-cjs-loads` exits 3: it needs every package built.
This diff adds no module to any entry's import closure:
`MetadataLockSchema` comes from `@objectstack/spec/kernel`, which
`protocol.ts` already imports.
- `check:type-check-debt` hit the foreground cap. It re-measures the
DEBT-ledger packages repo-wide. `metadata-protocol` has no DEBT entry
and its own `tsc` is 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-limit`
- `check:nul-bytes`
- `check:cross-package-test-inputs`
- `check:test-source-alias`
- `check:type-check-coverage`
- `check:durability-log-level`
- the changeset gates
- spec `check:generated`: "All 15 generated artifacts are up to date"
## Acceptance notes
- **Host-config DELETE.** On a host-config kernel, a DELETE of a
packaged flow or action that has NO overlay row still answers `200` "No
customization overlay found ... already at artifact default". That is
the reset door's no-op, and nothing is removed. `deletable: false` is
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 403 `NOT_OVERRIDABLE`.
- **Who sees the change.** The Setup metadata admin's resource page
(objectui `ResourceEditPage`) reads `layered.editable`, `deletable` and
`lock`.
- Packaged items in the two locked groups will now draw its lock banner.
Their write affordances were already off, by type (`canWriteByType`).
- The `no-overlay` group keeps its reset / delete button (`deletable` is
true).
- Studio's designers read `GET /api/v1/packages` `writable`, which is
unchanged.
- **Observation, not filed: `_lock` on host-config.** On a host-config
kernel the item-level `_lock` gate (`lockWriteRefusal`,
`assertLockAllowsDelete`) returns no refusal while `environmentId` is
undefined. So an overlay-type item that declares `_lock` reads
`editable: false` there, while the `/meta` save 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: the
`protection.lock` declarations in `platform-objects` are all on
`object`, which the package door refuses anyway. Carrier: none.
- **Observation, not filed: diagnostics count.**
`getMetaDiagnostics().stats[type].locked` counts declared `_lock` only,
not package-door locks. Carrier: none.
- **Boundary: code-only types.** `field`, `picklist`, `job`, `api`,
`capability` and `agent` have 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.
- **Not merged with main.** `origin/main` has moved 2 commits since
`7d0781482d`, touching the organizations plugin and sdui-parser. Both
are disjoint from this diff.
- **No live boot.** The REST layered door passes the protocol's answer
through unchanged (`createMetaLayeredAnswer` spreads it), so the
protocol pin and the REST / runtime sweep stand in. No showcase boot was
run.
---
_Generated by [Claude
Code](https://claude.ai/code/session_016tKoy8NJa35Yih1FdzrVmn)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 234d1d8 commit fe10172
6 files changed
Lines changed: 475 additions & 14 deletions
File tree
- .changeset
- content/docs/references/api
- packages
- metadata-protocol/src
- spec/src/api
- 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 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1344 | 1344 | | |
1345 | 1345 | | |
1346 | 1346 | | |
1347 | | - | |
| 1347 | + | |
1348 | 1348 | | |
1349 | 1349 | | |
1350 | 1350 | | |
| |||
0 commit comments