Skip to content

Commit fe10172

Browse files
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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
'@objectstack/spec': patch
4+
---
5+
6+
The metadata reads' `lock` / `editable` / `deletable` now say what the write doors do with a packaged item
7+
8+
Clause-②: no
9+
10+
Both metadata reads publish the ADR-0010 protection envelope beside the item: `GET /api/v1/meta/:type/:name/layers` (and its deprecated `?layers=true` spelling), and the by-name read `GET /api/v1/meta/:type/:name` where it resolves the envelope. The envelope was resolved from the item's own `_lock` alone, so it ignored the other refusal the write doors apply: an item a code package ships, on a type with no per-org overlay channel, is locked against in-place edits.
11+
12+
**Before.** A packaged flow, action, object, hook, seed, mapping, datasource, external catalog, doc, picklist, field, job, api, capability or agent with no `_lock` read `lock: 'none'`, `editable: true` and `deletable: true`. A packaged page, app, dataset, book, permission set, position, tool or skill read the same. Yet `PUT` refused each of them with `403 NOT_OVERRIDABLE` (or `403 ITEM_LOCKED` when the write names the read-only package), and the removal of the first group was refused too.
13+
14+
**After.** Each read reports what its doors answer:
15+
16+
- The first group reads `lock: 'full'`, `editable: false` and `deletable: false`.
17+
- The second group reads `lock: 'no-overlay'`, `editable: false` and `deletable: true`. Removing a leftover overlay row of these types is allowed: that is the repair path for overlays written before their per-org channel was withdrawn.
18+
- Items of the overlay types (`view`, `dashboard`, `report`, `translation`, `email_template`) are unchanged. So are items no package ships, such as an organization's own flows and actions, and every item while the `OS_METADATA_WRITABLE` operator hatch opens its type.
19+
20+
An item's own `_lock` still applies on top: the two refusals join, and neither replaces the other. `lockReason`, `lockSource` and `lockDocsUrl` are still present only when the item declares them. `provenance` and `packageId` already name the package.
21+
22+
The verdict is the one the write doors already share, read rather than re-derived, so the read moves whenever a door moves. The `lock` field's description in `@objectstack/spec` now names both refusals it reports. No key, type or accepted value changes.
23+
24+
**What to do.** Nothing, unless a client gated an edit or delete affordance on `editable` / `deletable`: it now hides that affordance for packaged items the server refuses, instead of offering a write that answers 403. The refusal itself names the sanctioned route for each type: for a packaged flow, clone it under a new name or switch it off; for a packaged action, switch it off.

‎content/docs/references/api/protocol.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1344,7 +1344,7 @@ Enable package response
13441344
| **name** | `string` | ✅ | Item name |
13451345
| **item** | `any` | ✅ | Metadata item definition |
13461346
| **sortability** | `{ fields: Record<string, object> }` | optional | Per-column sortability projection — present exactly when `type` is `object`, on every serving branch. Computed at serve time from the served document via the spec's own storage predicates; consumers render sort affordances from this signal and never re-derive it from field `type`. See `ObjectSortabilitySchema` for the closed category set. |
1347-
| **lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Resolved lock verdict for this item (ADR-0010 §3.3). `none` means unlocked; `no-overlay` / `no-delete` / `full` refuse the corresponding write with 403 `ITEM_LOCKED`. Resolved from the document's `_lock`, with the packaged artifact winning over any org overlay. |
1347+
| **lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Resolved lock verdict for this item (ADR-0010 §3.3). `none` means unlocked; `no-overlay` / `no-delete` / `full` mean the write doors refuse the corresponding write with 403. Joins two refusals: the document's own `_lock` (`ITEM_LOCKED`; the packaged artifact wins over any org overlay), and the locked packaged base — an item a code package ships, on a type with no per-org overlay channel (`NOT_OVERRIDABLE`, or `ITEM_LOCKED` when the write names the read-only package). |
13481348
| **lockReason** | `string` | optional | Human-readable explanation shown next to a refused write. Present only when the resolved item declares `_lockReason`. |
13491349
| **lockSource** | `Enum<'artifact' \| 'package' \| 'env-forced'>` | optional | Which layer asserted the lock. Present only when the resolved item declares `_lockSource`. |
13501350
| **lockDocsUrl** | `string` | optional | Documentation link surfaced beside `lockReason`. Present only when the resolved item declares `_lockDocsUrl`. |

0 commit comments

Comments
 (0)