Repository navigation
Commit b7a13c7
Fixes #21738
Clause-②: no
## What was wrong
The ADR-0010 `_lock` gate of the write doors (`getEffectiveLock`, behind
save, publish, rollback and delete) resolves an item's lock from two
layers: the packaged artifact's `_lock` unless it is `'none'`, then the
stored `sys_metadata` row's. The two item reads did not use that
resolution. Each derived the lock its own way, and neither matched the
door on the artifact layer:
- **Position 1.** `mergeArtifactProtection` copied any declared artifact
`_lock` over the stored row's, `'none'` included. A packaged view whose
artifact declares `'none'`, under a stored row declaring `'full'`, read
`editable: true` on both reads while the door refused the save with
`ITEM_LOCKED`.
- **Position 2.** `getMetaItemLayered` (`GET
/api/v1/meta/:type/:name/layers`) read the lock off `code ?? overlay`. A
packaged view with no `_lock` under a stored `'full'` read `lock:
'none'` there, while `getMetaItem` and the door said locked.
Triage's direction 5980131521 (verbatim in the dispatch): one
resolution, three callers. The door's semantics stand, and the reads
follow the door.
## H1: every place an item's lock was derived, at `ff29410ed2`
| site | caller | artifact layer | stored layer | rule |
|---|---|---|---|---|
| `getEffectiveLock` | the `_lock` gate (save, publish, rollback,
delete, package publish's promotion) | `lookupArtifactItem(canonical
type, name)`, no package | `findServedOverlayRow` (canonical spelling,
no package, org-gated) | artifact unless `'none'`, else row, else
`'none'` |
| `mergeArtifactProtection` | served body of `getMetaItem`,
`getMetaItems` list items, registry hydration, runtime view-container
expansion | the caller's artifact | the item's own `_lock` | artifact's
`_lock` whenever defined (`'none'` too), else the item's |
| `getMetaItem` envelope | `servedLockState(document)` | via the merge
above (package-scoped lookup) | the served row, or a registry /
MetadataService item | the merged document's `_lock` |
| `getMetaItemLayered` envelope | `servedLockState(code ?? overlay ??
{})` | the code layer (MetadataService copy, else artifact, else
runtime-only registry item) | only when there is no code layer | the
code layer's `_lock` |
| `getMetaDiagnostics` `locked` count | `servedLockState(list item)` |
via the list's merge | the list item | the merged list body's `_lock` |
| `servedLockState` | the three above | none of its own | none of its
own | joins the given document's lock with `packagedBaseRefusal` |
`packages/rest/src` and `packages/runtime/src` derive no lock: both pass
the protocol's envelope through (`git grep` of `resolveLockState`,
`extractProtection`, `_lock` and the envelope flags). The one
out-of-package consumer of a read's lock-adjacent fields,
`plugin-security`'s packaged permission-set gate, reads
`getMetaItemLayered`'s `code._packageId`, not its lock.
## What changed
Landing spot: `packages/metadata-protocol/src/protocol.ts`, as the claim
predicted, plus a new module beside it. No `packages/spec/src/**` path
is touched.
- **`item-lock.ts` (new, not exported from the package entry).**
`ITEM_LOCK_LAYERS = ['artifact', 'overlay']` is the precedence and the
parameter set. `resolveItemLock(layers)` returns the first layer whose
declared `_lock` is not `'none'`, with that layer's prose, else `'none'`
with no prose. `resolveItemLockLazily(readers)` asks the same function
after each layer is read and stops at the first binding layer.
- **The door** (`getEffectiveLock`) reads its two layers through
`resolveItemLockLazily`, so the store read still happens only when the
artifact does not bind. Its overlay read moved, unchanged, into
`readLockGateOverlayLayer`. Refusal code, status, text and `source=` are
byte-identical.
- **`getMetaItem`** hands the resolver the artifact it already looked up
and the stored body of the row `findServedOverlayRow` already served. It
keeps that body even when the row is not adopted (a shipped flow name,
#20946), because the gate binds it. No extra store read.
- **`getMetaItemLayered`** hands it `lookupArtifactItem` (a registry
read) and the served row's stored body. `code ?? overlay ?? {}` is no
longer a lock source. It still feeds `provenance` / `packageId` /
`packageVersion`, unchanged. A row-less name that a stored container
expands contributes no overlay layer, because the gate does not read the
container's row.
- **`servedLockState`** takes the resolver's answer and derives
`editable` / `deletable` from it, joined with `packagedBaseRefusal` as
before. It reads only `provenance` / `packageId` / `packageVersion` off
the document. No third derivation.
- **`mergeArtifactProtection`** copies the artifact's `_lock*` family
only when `resolveItemLock` says the artifact's lock binds. Its
`_packageId` / `_packageVersion` / `_provenance` copies are unchanged
(H3).
- **The diagnostics count** resolves the same way, over the list's
artifact lookup and the listed document. That is still a registry read,
never a store read.
## Measured: the door does not move, and the reads now follow it
The census used the real `ObjectStackProtocolImplementation` over an
engine double, on a `view`. It covered 216 cells: 2 topologies × 6
artifact states (absent, no `_lock`, each of the 4 levels) × 9 stored
states (none, or an env-wide / org-scoped row at each level) × 2 request
scopes. Each cell read `getMetaItem`, `getMetaItemLayered`, the save
door and the delete door.
- Before (`ff29410ed2` code): 36 split cells, 18 per topology. 9 are
position 1 (artifact `'none'` under a binding stored lock), and 9 are
position 2 (artifact with no `_lock` under one).
- After: 0 split cells.
- **Door columns: identical in all 216 cells**, before against after. So
no door verdict changed.
- `getMetaItem` changed in 18 cells (position 1 only).
`getMetaItemLayered` changed in 36 (positions 1 and 2).
**H3, every other field.** A second probe ran at the merge base and at
this branch on the same 216 cells. It dumped `getMetaItem`'s envelope
and served body, `getMetaItemLayered`'s envelope and its `code` /
`overlay` / `effective` / `overlayScope` / `_diagnostics`, and the
`getMetaItems` list item. With the lock family masked (`lock`,
`lockReason`, `lockSource`, `lockDocsUrl`, `editable`, `deletable` and
the body's `_lock*`), **all 216 dumps are byte-identical**. The lock
family differs in 54 cells:
- 18 position-1 cells (both reads, the body and the list item now carry
the stored lock);
- 18 position-2 cells (`getMetaItemLayered` only);
- 18 explicit-`'none'`-artifact cells where nothing binds. There the
explicit `'none'` artifact's prose is no longer reported on the
envelope, and its `_lock*` fields are no longer copied onto a stored
row's body.
## H4: residue census (position 3), and why the fallback stays
- Plural-spelled rows were writable through
`@objectstack/metadata-protocol@16.1.0`: its
`SysMetadataRepository.whereFor` keyed `type: ref.type` raw. The fold
(#4432, commit `63b33e6a18`) first shipped in `17.0.0-rc.2`. Ancestry
was read in both directions on a full (non-shallow) clone: `63b33e6a18`
is not an ancestor of the `16.1.0` tag (exit 1); the control `16.0.0` is
(exit 0).
- `MIGRATION_SUPPORT_FLOOR` is 16, so 16.x deployments are on a
supported upgrade path.
- `os migrate meta --stored` (`migrateStoredMetadata`) rewrites bodies,
never a stored type spelling. It reports the six URL-only plural
spellings `skipped` (#8957), and it leaves a manifest-plural row at
rest, re-saving a canonical row only when the body needs a conversion.
- **So residue can exist on a supported path, and nothing rewrites it.**
The reads' other-spelling fallback stays, and the difference stays
declared (`findServedOverlayRow`'s `otherSpelling`; answer A on PR
#21737). By the triage's own condition, no separate retirement card
follows from this census.
## H5: the acceptance pin, generated from the resolver's inputs
`protocol.lock-one-resolution.test.ts`:
1. **1632 generated rows.** The axes are: artifact (6) × stored row
(none, or env-wide / org-scoped × 4 levels × canonical / other spelling:
17) × request scope (2) × topology (2) × request spelling (`view` /
`views`) × operation (save / delete). The axes are grouped under
`LAYER_AXES`, keyed by `ItemLockLayer`, so a layer added to
`ITEM_LOCK_LAYERS` fails the typecheck. The completeness check also
fails the run, naming the layer, and it asserts `resolveItemLock.length
=== 1` and that every axis is used exactly once. Each row runs on one
protocol instance. Both reads must agree, both must equal an oracle
written from the rule (iterating `ITEM_LOCK_LAYERS`), and the door must
admit exactly when the envelope says `editable` / `deletable`. Rows
stored under the other spelling assert the declared difference instead
(the reads see the residue row and the door does not), so they flip by
name when the fallback retires.
2. **PR #21737's 64 rows are folded in, with none lost.** A test
rebuilds their 64 titles and asserts each is a row of this table. They
and their lit control moved here out of
`protocol.lock-org-axis-agree.test.ts`; that file keeps its pins 2 to 4.
3. **Position 1, named.** On both topologies: both reads say `full`,
with `editable` and `deletable` false and `lockReason` from the stored
row. Provenance is still the artifact's. The served body, the list item
and the diagnostics tile say `full`. Save and delete are refused
`ITEM_LOCKED` / 403 with `lock: 'full'`.
4. **Position 2, named.** On both topologies: `getMetaItemLayered` says
`full` like `getMetaItem`, its `code` layer is still the package's (no
`_lock`), and the door refuses.
The #5840 outage pin (`protocol.metadata-store-outage.test.ts`) had a
"healthy" leg. That leg's locked artifact existed only in the
MetadataService double, never in the registry the gate reads. It now
declares the artifact in the registry, package-stamped, as a loader
does. Its outage leg is unchanged and still answers 503.
## Reverse verification
Both legs ran from committed HEAD `db49dc4d2fab` (the `protocol.ts`
blob), with `scripts/ablation-replace.mjs` in wrap mode. They ran inside
a script carrying an absolute-path `trap` restore from `HEAD`. The
subject resolves from source (`./protocol.js`), so no rebuild was in the
path. Each direction was declared first.
- **Leg A: `mergeArtifactProtection` restored as a lock source.** The
copy became unconditional, and `getMetaItem` took its lock from the
merged document again. Anchors 1 to 0 and markers 0 to 1, twice; blob
`db49dc4d2fab` became `cb753a719d8b`, then `decca0d87278`. Predicted 146
red / 1494 green. **Measured 146 failed / 1494 passed**: pin 2 on both
topologies, plus all 144 pin-1 rows with an explicit `'none'` artifact
under a served stored lock. Pin 3 stayed green.
- **Leg B: the layered read's `code ?? overlay ?? {}` restored.** Anchor
1 to 0 and marker 0 to 1; blob `db49dc4d2fab` became `bdd63140c660`.
Predicted 292 red / 1348 green. **Measured 292 failed / 1348 passed**:
pin 3 on both topologies, plus the 288 pin-1 rows whose artifact
declares no `_lock` or `'none'` under a served stored lock. Pin 2 went
red too, because its layered half was the same derivation.
- **Restore, both legs:** blob equal to the HEAD blob `db49dc4d2fab`,
`git diff HEAD` empty and `git status --porcelain` empty, checked by the
tool and by the trap.
## Tests (all at `60232c3d17`, after merging `origin/main` `ea7ff394b6`)
- `@objectstack/metadata-protocol`: `vitest run`. **213 files passed, 3
skipped. 5249 tests passed, 19 skipped.** That is base 3675, plus 1640
new, minus 66 moved. `tsc --noEmit` is green, and `--listFiles` compiles
`item-lock.ts` and the new pin.
- `@objectstack/objectql` against the rebuilt `metadata-protocol` dist:
`--project local` gives **372 files, 7458 tests passed**, and `--project
repo` gives 1 file, 5 tests passed. The dist was built from
`ae121eac22`; `protocol.ts` and `item-lock.ts` are unchanged since.
- Narrowed lint: `eslint --no-inline-config --format json` over the 5
changed TS files gives 5 file results, 0 errors and 0 warnings. The
population was read from eslint's own config: `isPathIgnored` is false
for all 5, and each computed config has 5 or 6 rules. Invariance: no
computed config sets `parserOptions.project` or `projectService`, and
`eslint.config.mjs` never enables type-aware linting, so this diff
cannot move a verdict on an untouched file.
- **NOT MEASURED:** HTTP (reach is on doubles); `rest` / `runtime` /
`plugin-security` / `plugin-email` / `client` suites, which are
consumers with no export or wire-shape change (their lock-field
assertions were grepped and use fakes); CI-owned Dogfood, Temporal
Conformance, type-check lanes and the full `pnpm lint`.
## Gates (at `60232c3d17`; exit codes captured before any pipe)
- `node scripts/pm/dispatch-gates.mjs --commands` (no paths) derived 72
families: **72 run, all exit 0.** `--ran` reports "72 derived famil(ies)
accounted for — 72 run, 0 NOT-MEASURED". `check:dual-build-cjs-loads`
first exited 3 (PREREQUISITE NOT MET) and exited 0 after a full turbo
build.
- Artifact-roster block (54): 51 exit 0. The 3 PR-context guards
(`check-closing-target-claim`, `check-partof-closing-keyword`,
`check-single-claim-paths`) exited 2, NOT WIRED, before this PR existed;
they are re-run with this PR's context in the report.
- The four symbol-anchor sweeps exit 0: `adr-symbol-anchors` 2167
anchors across 140 records; `scripts-symbol-anchors` 3760 across 282
scripts; `spec-docblock-symbol-anchors` 4950 across 1868 sources;
`adr-anchors` OK.
- `check:engine-double-contract` asked for the new pin's `findOne`
double, and its own `--write` recorded it: the ledger gained one
`findOne` entry for the new file, and lost none.
- `check:durability-log-level`: 70 read seams before and after. The
`--depth-cost` census is identical apart from line numbers and one
enclosing function's name (`getEffectiveLock` became
`readLockGateOverlayLayer`).
## Acceptance notes (not filed here)
- **The package axis, now measured on the double.** A read naming a
package (`?package=`, ADR-0048 prefer-local) serves that package's row,
while the gate asks package-agnostic (`findOne` with no `package_id`).
The gate therefore binds whichever row the driver returns first. With a
package-less row and a package A row of one `(type, name, scope)`, 2 of
the 3 arrangements probed (which row declares the lock, and which row
the driver returns first) split read from door. This is row selection,
not the resolution, and this PR leaves it unchanged; it is reported to
the seat.
- **A lock only a copy the doors never read declares.** A
MetadataService copy the dev watcher reloaded after boot, or a
package-less runtime registration, used to read locked while the doors
admitted the write. It now reads as the doors answer, and the changeset
says so.
---
_Generated by [Claude
Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent a6a7547 commit b7a13c7
7 files changed
Lines changed: 828 additions & 122 deletions
File tree
- .changeset
- packages/metadata-protocol/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 | + | |
| 18 | + | |
| 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 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
0 commit comments