Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/21694-lock-door-every-topology.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/metadata-protocol': minor
---

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)

Clause-②: no (narrowing)

<!-- 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). -->

**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.

**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.

**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.

**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.
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,14 @@ function makeSession(opts: {
for (const r of opts.seed ?? []) rows.set(r.id, r);
const historyRows: Array<Record<string, unknown>> = [];
const artifactKeys = new Set((opts.artifacts ?? []).map((a) => `${a.type}|${a.name}`));
// [#21694] The ADR-0010 `_lock` gate reads `sys_metadata` on EVERY topology
// now, ahead of the probe read this file injects its fault into — and a
// failure of the gate's own read is answered fail-closed, `503
// SERVICE_UNAVAILABLE` (#5706): that gate's contract, not this re-wrap's.
// So `failFindOne` arms only once the lock verdict is in, and the fault
// lands on the probe read, the seam this file pins (a gate that is never
// reached leaves the fault unarmed, and the case fails loudly).
let lockVerdictIn = false;

const engine: any = {
async findOne(table: string, o: { where: Record<string, unknown> }) {
Expand All @@ -179,7 +187,7 @@ function makeSession(opts: {
return historyRows.find((h) => matchesWhere(h, o.where)) ?? null;
}
if (table !== 'sys_metadata') return null;
if (opts.failFindOne) opts.failFindOne();
if (opts.failFindOne && lockVerdictIn) opts.failFindOne();
for (const row of rows.values()) if (matchesWhere(row as any, o.where)) return row;
return null;
},
Expand Down Expand Up @@ -238,6 +246,12 @@ function makeSession(opts: {
() => new Map(),
opts.environmentId,
) as any;
const lockGate = protocol.assertLockAllowsDelete.bind(protocol);
protocol.assertLockAllowsDelete = async (args: unknown) => {
const verdict = await lockGate(args);
lockVerdictIn = true;
return verdict;
};
return { protocol, rows, historyRows };
}

Expand Down
Loading
Loading