Repository navigation
Commit 18fe681
Fixes #21803
Clause-②: no (narrowing)
## What changes
Two installed code packages may ship one `(type, name)` (ADR-0048 §3.4).
The ADR-0010 `_lock` gate looked the packaged artifact up with no
package, so it bound the first package registered. `getMetaItem`,
`getMetaItemLayered`, the list and the `getMetaDiagnostics` tile looked
it up with the request's package. So one item had two lock answers, and
the door's answer depended on registration order. Measured on the real
`SchemaRegistry` (H1 below), the defect runs in both directions:
- B ships `_lock: 'full'`, A ships no lock, **B registered first**: a
read naming A says `none` while the door refuses. This is the card's
case: it fails closed.
- Same arrangement, **A registered first**: a read naming B says `full`
while the door **admits**. This one fails open, because B's packaged
lock is not enforced at all.
Now:
- **One selection of the artifact layer.**
`resolveArtifactLockLayer(address, artifactsOf)` in
`packages/metadata-protocol/src/item-lock.ts` returns the artifact of
every installed package that ships the name, with the address's own
package first and then the others by package id. The protocol's reader
is `shippedArtifactsOf`, wrapped per address as `artifactLockLayerAt`.
The gate's artifact limb, both reads, the list items and the diagnostics
tile all take the layer there. No caller picks an artifact for the lock.
- **The lock is the strictest per-package answer.** `resolveItemLock`
reads the layers once per shipping package: that package's artifact over
the stored rows in scope, first binding layer wins, exactly the rule as
before. The item's lock is the strictest of those answers. With one
package shipping the name, or none, the answer is unchanged.
- **Content stays prefer-local.** A read naming A is still served A's
artifact under A's provenance; only its lock family follows the binding
package.
- **The served body states the resolution's answer**
(`withItemLockFamily`, which replaces `withOverlayLockFamily`). That
means the binding layer's lock family, and no `_lock*` key when nothing
binds. This also settles the position folded in from 5988387087 (H6
below).
- The overlay rows are read lazily as before: only when some shipping
package's artifact declares no lock, or no package ships the name.
## Rulings honoured, and the one mechanism hypothesis falsified
- Triage's direction (5987395126, restated in 5987813232): strictest
among the installed packages that ship the name, never a widening, one
resolution for the gate and both reads, no prefer-local artifact lock at
the door. All honoured.
- **H3, taken literally, widens, so the route changed.** The hypothesis
was to make the artifact layer the strictest artifact, then keep "first
binding layer wins". The BASE census shows a cell where that admits a
save the door refused. A ships no lock, B ships `no-delete`, and the
env-wide row declares `no-overlay`. With A registered first, the door at
BASE refuses the save, because A's artifact does not bind and the row's
`no-overlay` does. A strictest artifact layer would bind B's `no-delete`
and admit it. So the resolution takes the strictest **per-package**
answer instead. That is the ruling's own wording read per package, and
its "never a widening" holds by construction: the artifact the gate
bound before is always one of the shipping packages. That cell now
refuses both verbs under both orders (pin 9).
## H1: the artifact lookups at BASE `c4d57131b5`, on the real
`SchemaRegistry`
B ships `_lock: 'full'`, A ships no lock. Each call is listed with which
package's artifact it bound.
| order | request | gate (`getEffectiveLock`) | save / delete |
`getMetaItem` = `getMetaItemLayered` | list slots | tile locked |
|---|---|---|---|---|---|---|
| B first | no package | B: full | refused / refused | B: full | B full,
A none | 1 of 2 |
| B first | naming A | B: full | refused / refused | **A: none** | A
none | 0 of 1 |
| B first | naming B | B: full | refused / refused | B: full | B full |
1 of 1 |
| A first | no package | A: none | admitted / admitted | A: none | A
none, B full | 1 of 2 |
| A first | naming A | A: none | admitted / admitted | A: none | A none
| 0 of 1 |
| A first | naming B | **A: none** | **admitted** / admitted | B: full |
B full | 1 of 1 |
The call sites:
- The gate's artifact limb (`lookupArtifactItem(canonicalType, name)`)
binds the first package registered, whatever the request names.
- `getMetaItem` and `getMetaItemLayered` (`lookupArtifactItem(type,
name, packageId)`) bind the named package's artifact, or the first
registered when no package is named.
- The list items and the diagnostics tile (`packageId ??
item._packageId`) bind each slot's own package.
- `mergeArtifactProtection` put whatever its caller had looked up.
With B **disabled**, the gate bound B only when B was registered first,
while a read naming A said `none` under both orders. After the change,
every row above reads `full` with B's prose, is refused on both verbs,
and keeps prefer-local content.
## H2: what the registry enumerates
`SchemaRegistry.getArtifactItem(type, name, packageId?)` answers one
entry: the asked package's own, else the first composite registered. The
registry has no method that returns them all. The reader composes
existing public methods only, so **no new public surface**:
- `listItems(type)`, indexed by name, gives the packages whose entries
ship each name. It hides a disabled package's entries.
- `getAllPackages()` plus `isPackageDisabled(id)` add the disabled
packages, probed for every name.
- `getArtifactItem(type, name, P)` per candidate package, kept only when
it is P's own (`_packageId === P`). Line 17607's view-expansion code
already uses this idiom.
- The package-less `getArtifactItem(type, name)` is always in the set.
It is the old gate's answer, and the only way to reach a plain-key
artifact.
- An `object` has one owner (ADR-0029 D3), so its single owner-layer
lookup is the whole set.
- A registry whose listing throws contributes no package ids, so the set
falls back to the package-less lookup. That is never looser than the old
gate (pinned by `protocol.runtime-authoring-gate.test.ts`).
## H4: census, before and after, on the real `SchemaRegistry`
There are 2000 door verdicts, from A's lock (5 values) × B's lock (5) ×
the env-wide row (5) × registration order (2) × request shape (save
naming none, A or B; delete naming none) × B enabled or disabled. Every
verdict comes from the real gate (`getEffectiveLock`).
| transition | cells |
|---|---|
| refused → admitted (widenings) | **0** |
| admitted → refused (narrowings) | 416 |
| unchanged | 1584 |
Verdicts that depend on registration order: **416 of 1000 cell pairs
before, 0 after.** The narrowings split evenly, 26 per (enabled or
disabled × order × shape × verb) group.
## H5: the family's enumeration pin
`protocol.lock-one-resolution.test.ts` pin 1 is now the family's
enumeration:
- **Positions.** Each position is a layer of `ITEM_LOCK_LAYERS` × a
family axis (topology, organization, package), with the table axes that
open it and the card that measured it.
- **Completeness check, failing by name**, when:
- a layer × family axis has no position;
- a position is opened by no slice;
- an axis of the table belongs to no position;
- an `ITEM_ADDRESS_FIELDS` field has no axis.
- **Rows.** The union of three slices, each a full product: 24 000 rows
in all, each run under both stored-row orders and both registration
orders.
- The family product: PR #21801's 16 320 rows. It is checked against a
frozen copy of their own titles, so no row is lost.
- **The artifact layer × package** (7 200 rows): another installed
package ships the name at each lock level, crossed with the package's
own artifact, the env-wide row's lock, every request shape (no package,
the package, the other package), both organization scopes, both
topologies and both spellings.
- **The stored rows × organization × package** (480 rows): the
organization holds only another package's row.
- **Every row asserts** that both reads report the oracle's lock, that
the door's verdict matches, that the door agrees with the envelope (bar
the one declared spelling difference), and that a served body states the
envelope's lock.
- **Named pins:**
- **pin 8**, the card's case under both orders, for reads naming A, B or
no package, plus the list and the tile;
- **pin 9**, the never-widening join;
- **pin 10**, a disabled package;
- **pin 11**, the folded position.
- The same case runs on the **real `SchemaRegistry`** in
`packages/objectql/src/protocol-lock-artifact-package-axis.test.ts`,
because `@objectstack/metadata-protocol` cannot import objectql.
## H6: the folded position (5988387087)
This is fixed without a ruling. Only the served body disagreed: the
envelope and the door already agreed on `none`. `withItemLockFamily`
writes the resolution's answer onto the body and removes any `_lock*`
key the answer does not carry. Pin 11 covers it; the layered read still
reports the stored layer as stored. Slice 3 of the pin carries the same
position.
## Reverse verification (one-shot, committed state)
- **Gate limb.** At `323ab0b07a`, through `scripts/ablation-replace.mjs`
in wrap mode, the gate's artifact limb was put back to the
package-agnostic first artifact
(`[this.lookupArtifactItem(canonicalType, name)]`). The anchor went 1 →
0 and the blob `182c6677` → `c3840b80`.
- The pin file went **2506 red** / 21538 green.
- **Pin 8 is red under "package A registered first"** for all three
request shapes, and green under "B registered first".
- Also red: pin 9 (6), pin 10 (1), and 2496 rows of the artifact ×
package slice. Nothing red outside rows where another package ships the
name.
- Restored with `git checkout HEAD -- PATH`: the blob equals HEAD
(`182c6677`) and `git diff HEAD` is empty.
- **H6 body clearing.** Ablated the same way: **14 red** (the 12 slice-3
cells of the folded arrangement, plus pin 11 × 2). Restored, with `git
diff HEAD` empty.
## Tests
- `pnpm --filter @objectstack/metadata-protocol test` at `323ab0b07a`:
214 files passed (3 skipped), 27 745 tests passed (19 skipped).
- objectql, the 30 test files that touch locks or read envelopes (the
new one included), at `323ab0b07a`: 940 passed. The real-registry file
again at `d1551fde71`: 14 of 14 (the last commit only made its `find`
double hold `limit`).
- `typecheck` for both packages at `323ab0b07a`: clean. objectql's test
layer (`check:test-typecheck`) compiles the new file, with no new debt.
## Gates, at `d1551fde71`
- `node scripts/pm/dispatch-gates.mjs --commands` derived 74 commands,
and the artifact-roster block printed 54 more outside that total. With
the four symbol-anchor sweeps (`check:adr-symbol-anchors`,
`check:scripts-symbol-anchors`, `check:spec-docblock-symbol-anchors`,
`check:adr-anchors`) that makes **131 commands: 128 exit 0.**
- 3 are **NOT MEASURED** locally (NOT WIRED: they need a pull request
context): `check-closing-target-claim`, `check-partof-closing-keyword`
and `check-single-claim-paths`. Their guard workflows run them on this
PR.
- `--ran` reconciliation: 74 derived, 74 run, 0 NOT-MEASURED, 0 UNRUN,
with every exit code recorded.
- `check:engine-double-contract` asked for one pin row for the new
objectql double. It was recorded through its own `--write`.
## Lint (a proven narrowing; the repo-wide `pnpm lint` is CI's run)
Run: `eslint --no-inline-config --format json` over the four touched TS
files: 4 files, 0 errors, 0 warnings.
- **Population.** Read from eslint's own config: `isPathIgnored` is
false for all four.
- **Count.** Read from the JSON output.
- **Invariance.** `eslint.config.mjs` never enables type-aware linting
(no `parserOptions.project`, no typed rules), and its plugins are local
per-file AST rules with no import resolution. The one removed export
(`withOverlayLockFamily`) has zero remaining references. So this diff
cannot move any untouched file's verdict.
## Landing points
- `packages/metadata-protocol/src/item-lock.ts` and `protocol.ts`, as
the claim's file surface said.
- The pin (`protocol.lock-one-resolution.test.ts`) and the changeset.
- Outside that surface:
- `packages/objectql/src/protocol-lock-artifact-package-axis.test.ts`:
the real-registry pin, since the protocol package cannot import
objectql.
- `scripts/engine-double-contract.pinned.json`: the gate's own
`--write`.
- No `packages/spec/src/**`, no governed surface, no new public export:
`item-lock.ts` is not re-exported from the package entry.
## Acceptance notes
- `packagedArtifactOwner` (the ADR-0126 flow-owner classification) and
`isArtifactBacked` still take the package-less first artifact. They
classify owner and existence, not a lock: existence does not depend on
order, but the reported owner of a flow name two packages ship is the
first registered. This is not a lock-family position; noted, not filed.
Carrier: none.
- A read can report a lock with no body served: content never serves
another package's row, while the lock's scope includes it. The envelope
and the door agree there, so the pin asserts the body only when one is
served.
- A served body's lock family is now normalized through
`extractProtection`. An explicit `_lock: 'none'` is no longer kept on a
body, and a `_lockSource` outside the spec's enum is not echoed. The
full metadata-protocol suite and the objectql envelope tests stay green.
- Cost: the list and the tile read the registry's listing once per type
and probe only the packages that ship each name (commit `f38762577c`).
The gate reads the listing once per write.
---
_Generated by [Claude
Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5b2d189 commit 18fe681
6 files changed
Lines changed: 1151 additions & 208 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 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
11 | 11 | | |
12 | 12 | | |
13 | 13 | | |
14 | | - | |
15 | | - | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
16 | 18 | | |
17 | 19 | | |
18 | 20 | | |
| |||
76 | 78 | | |
77 | 79 | | |
78 | 80 | | |
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 | + | |
81 | 129 | | |
82 | 130 | | |
83 | 131 | | |
| |||
91 | 139 | | |
92 | 140 | | |
93 | 141 | | |
94 | | - | |
95 | | - | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
96 | 146 | | |
97 | 147 | | |
98 | 148 | | |
| |||
101 | 151 | | |
102 | 152 | | |
103 | 153 | | |
104 | | - | |
105 | | - | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
106 | 166 | | |
107 | 167 | | |
108 | 168 | | |
| |||
127 | 187 | | |
128 | 188 | | |
129 | 189 | | |
130 | | - | |
131 | | - | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
132 | 194 | | |
133 | 195 | | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
134 | 207 | | |
135 | | - | |
| 208 | + | |
136 | 209 | | |
137 | 210 | | |
138 | 211 | | |
| |||
147 | 220 | | |
148 | 221 | | |
149 | 222 | | |
150 | | - | |
151 | | - | |
152 | | - | |
153 | | - | |
154 | | - | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
155 | 230 | | |
156 | 231 | | |
157 | 232 | | |
158 | 233 | | |
159 | 234 | | |
160 | | - | |
| 235 | + | |
| 236 | + | |
161 | 237 | | |
162 | | - | |
163 | | - | |
164 | | - | |
165 | | - | |
166 | | - | |
167 | | - | |
168 | | - | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
169 | 243 | | |
170 | 244 | | |
171 | 245 | | |
| |||
313 | 387 | | |
314 | 388 | | |
315 | 389 | | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
316 | 423 | | |
317 | | - | |
318 | | - | |
319 | | - | |
320 | | - | |
321 | | - | |
322 | | - | |
323 | | - | |
324 | | - | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
325 | 433 | | |
326 | | - | |
| 434 | + | |
327 | 435 | | |
328 | | - | |
329 | | - | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
330 | 444 | | |
331 | 445 | | |
332 | 446 | | |
| |||
0 commit comments