diff --git a/.changeset/liveness-live-elsewhere-verdict.md b/.changeset/liveness-live-elsewhere-verdict.md new file mode 100644 index 0000000000..5321f1b092 --- /dev/null +++ b/.changeset/liveness-live-elsewhere-verdict.md @@ -0,0 +1,33 @@ +--- +"@objectstack/spec": patch +--- + +feat(spec): fifth liveness-ledger verdict `live-elsewhere` — dead here, enforced in a sibling repo, with gate-executable criteria (#13483) + +The liveness ledger had no verdict for a key that is dead locally but genuinely +enforced in a sibling repo. The measured case is `manifest.runtime`: zero +load-side dispatch in objectstack+objectui, yet the cloud marketplace publish +gate hard-rejects (HTTP 422) an unverified publisher requesting the `node` tier +(#12400, cloud @15f55df). `dead` read alone licenses deleting the marketplace's +trust-gate input — deletion the maintainer explicitly ruled out (#11330) — and +`live` is refused by the gate, whose repo-local evidence must resolve against +this checkout. The stopgap was a qualifying sentence in the row's `note`; prose +is the weakest protection the ledger knows. + +`live-elsewhere` says the split as data, and because `check-liveness.mts` +deliberately forces a decision on unknown statuses, it lands with criteria the +gate executes (`scripts/liveness/elsewhere.mts`, exit 1 on each): the +`evidence` must attribute at least one path to a foreign realm (the enforcer +pointer, foreign commit pinned in prose), `evidenceScope` must be +`"cross-repo"`, `verifiedAt` is REQUIRED (nothing local can watch a foreign +enforcer rot, so an undated elsewhere-claim would be unfalsifiable forever), +and the attestation expires: past 180 days the gate fails demanding a +re-reading of the foreign enforcer — never a re-stamp. If no seat with access +re-attests, the red build is the escalation back to the maintainer. + +The status joins the evidence-scan population (any local path such an entry +also cites is held to the full existence/line/anchor/key-mention standard), +`STATUS_COLUMNS`, and the generated `state-counts.md` (new `elsewhere` column). +`manifest.runtime` migrates as the first row, attested at the #12400 reading +(2026-08-29). The ledger README documents the verdict and its re-attestation +discipline. diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 0813e7994d..169dc2c7bf 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -60,7 +60,8 @@ registry to fold it back onto — the override *is* its governance. |---|---| | `live` | Has a runtime consumer. Cite it in `evidence`, preferably anchored to the consuming symbol — `file#symbol` — with `file:line` as an optional convenience; for another repo's path, prefix the realm — `objectui: packages/app-shell/…` (see below). | | `experimental` / `planned` | Declared, intentionally not enforced yet. Also read from a spec `.describe()` marker like `[EXPERIMENTAL — not enforced]`. | -| `dead` | Parsed, no consumer. Tracked for **enforce-or-remove** (ADR-0049). | +| `dead` | Parsed, no consumer **anywhere the census looked**. Tracked for **enforce-or-remove** (ADR-0049). A key that is dead here but enforced in a sibling repo is NOT `dead` — it is `live-elsewhere`, below. | +| `live-elsewhere` | Dead here by measurement, **genuinely enforced in a sibling repo** (#13483). Not deletable, and deliberately unable to satisfy `live`'s local-evidence rules; carries its own gate-executable criteria — see the section below. Ledger-entry-only (no `.describe()` marker). | Resolution per property: **ledger entry → spec `.describe()` marker → UNCLASSIFIED**. Framework provenance/lock fields (`_lock*`, `_provenance`, `_packageId/Version`, @@ -251,9 +252,61 @@ younger than nearly every entry. A value outside the vocabulary FAILS, the same asymmetry as `verifiedAt` — a value the parser cannot read would silently exempt that entry from every future sweep. -⚠️ Neither `cross-repo` value in the tree today covers **`cloud`**: the closed -runtime is not reachable from an open-source checkout, so a `cross-repo` claim -means "the realms named in the evidence", never "everywhere". +⚠️ A `cross-repo` claim means "the realms named in the evidence", never +"everywhere". The **`cloud`** realm is the hard case: the closed runtime is not +reachable from an open-source checkout, and reachability is seat-dependent even +for agents (`add_repo` on cloud is denied from some seats while the #10812 and +#12400 readings prove cloud-capable seats exist) — which is exactly the +constraint the `live-elsewhere` discipline below is built around. + +### `live-elsewhere` — dead here, enforced in a sibling repo (#13483) + +The verdict for a key with **zero local dispatch and a real foreign enforcer**. +The measured template is `manifest.runtime`: two CLI echo lines here, and the +cloud marketplace publish gate hard-rejecting (HTTP 422) an unverified +publisher requesting the `node` tier (#12400). `dead` lies about that key — read +alone it licenses deleting the marketplace's trust-gate input, which the +maintainer ruling of 2026-08-30 (#11330) explicitly ruled out — and `live` is +refused by this gate, whose repo-local evidence must resolve against this +checkout. Until #13483 the truth lived in a qualifying sentence inside the +row's `note`; prose is the weakest protection this ledger knows. + +The gate cannot resolve another repo's file (deliberate — see the realm-marker +boundary above), so a `live-elsewhere` row is held to criteria the gate CAN +execute, all four enforced by `scripts/liveness/elsewhere.mts` (`✗`, exit 1): + +1. **A foreign pointer.** `evidence` must attribute at least one path to a + foreign realm — `cloud: packages/…/plugin-permission-audit.ts#auditPluginPermissions` + — and should pin the foreign commit in the prose (`@15f55df`, house style + since `action.undoable`). The verdict IS this pointer; without it the status + is an unverified label. +2. **`"evidenceScope": "cross-repo"`.** The claim is cross-repo by definition; + an `in-repo` or absent scope asserts elsewhere-ness no look ever covered. +3. **A dated attestation.** `verifiedAt` is REQUIRED — the date someone with + access to the named repo actually read the enforcer. Absent is legal on + every other status because the file/line/symbol/key-mention checks keep + watching local citations between re-verifications; nothing can watch a + foreign enforcer from here, so an undated elsewhere-claim would be + unfalsifiable forever. +4. **Expiry.** Past 180 days (the ledger-wide freshness threshold — one policy, + one place; what differs is the consequence) the gate fails demanding + re-attestation. Expiry is the only mechanical event this repo can generate + about a claim it cannot re-measure, so here — and only here — age is a merge + gate rather than a worklist. + +**Re-attestation means re-reading, never re-stamping.** Someone with access to +the named repo reads the enforcer at its current head, repairs the pointer if +it moved, re-pins the commit, and stamps `verifiedAt` with that reading's date. +⛔ Re-stamping the date without the reading is the "trust the prose" downgrade +this status exists to end. If the window closes and no seat with access can be +found, the red build **is** the escalation: the row goes back to the maintainer +(needs-user-decision) to rule whether an unverifiable pointer still counts as +evidence. Trust is time-boxed, never institutional. + +A local read that appears alongside (e.g. the CLI echo lines) belongs in the +`note` — the `evidence` field carries the foreign enforcer. Any repo-local path +a `live-elsewhere` entry does cite is held to the full scanned-status standard +(existence, line bound, symbol anchor, key mention), same as everyone else's. ### ⚠️ An authoring/preview renderer is NOT a runtime consumer @@ -839,7 +892,7 @@ marker where the Notes cell goes, never a guess at what belongs there. | validation | seeded 2026-08-01 (#4488). The ADR-0020 carrier: the evaluator honors active/events/priority/severity/type/condition/message (the zod header's "only reads type/condition/…" prose is STALE — trust the ledger). Dead 3 = label/description/tags, declared governance metadata, kept unmarked. Union walk boundary recorded: only base + `script` keys walked; per-variant keys are governed by the evaluator's tests, not ledger rows. **No longer a registered metadata kind** — #4509 retired it under ADR-0088 (a standalone rule had no object-binding key and every variant is `.strict()`, so it bound to nothing and gated no write; a state machine authored that way saved cleanly and did nothing). The rule VOCABULARY is untouched and fully live via `object.validations[]`, so the ledger keeps governing it through the gate's spec-only override, alongside `webhook` and `query`. The contrast with the two bridges in the same batch is the point: enforce-or-remove picked ENFORCE where the feature existed and only the wiring was missing, and REMOVE where the shape itself could not carry the feature | | api | seeded 2026-08-04 (#5271, part of #5206; PR #5312) — **not a metadata type until that same change made it one**, which is the row's point: governance and registration landed together, the treatment `datasource` did not get (#4487) and paid for with six inert keys found by hand. What #5206 measured before the fix: `api` was in neither `DEFAULT_METADATA_TYPE_REGISTRY` nor `BUILTIN_METADATA_TYPE_SCHEMAS`, so `saveMetaItem`'s `resolveOverlaySchema('api', …)` → `getMetadataTypeSchema('api')` returned `undefined` and took its own documented branch — an unregistered type is stored **unvalidated** — while `getMetaTypes()` could not enumerate the type at all, so Studio rendered neither list nor form. That issue names the shape precisely and it is the inverse of this ledger's usual one: **enforced but undeclared** (the matcher was already indexing these entries, #5089), where `dead` is declared-but-unenforced. The seeding pass classified 27 keys — live 25 / planned 2 / dead 0 — each cited `file:line` at the consumer layer that reads it: the MATCHER (`packages/metadata/src/endpoint-matcher.ts`) indexes `name`/`path`/`method`; the EXECUTOR (`packages/runtime/src/endpoint-executor.ts`) dispatches on `type` and reads `target`/`objectParams`; the POLICY chain (`packages/runtime/src/endpoint-policy.ts` + `security/inbound-rate-limit.ts`) enforces `authRequired`/`rateLimit`/`cacheTtl`; the MAPPING layer (`packages/runtime/src/api-mapping.ts`) applies `inputMapping`/`outputMapping`; and OpenAPI enrichment (`packages/rest/src/openapi-endpoints.ts`) emits `summary`/`description`. Timing was the reason it was cheap: #5040's E-series had built every one of those consumers and all of it was on main, so each key had a real evidence path rather than a promise. **Planned 2 = `inputMapping.transform` + `outputMapping.transform`, and `planned` rather than `dead` is load-bearing**: `dead` here means parsed with no consumer — a silent no-op — and these are the opposite, parsed and then LOUDLY REFUSED at publish (`endpoint-publish-gate.ts` mappingGate) and again at runtime, because no transformation-function registry exists anywhere in the platform. An author who writes one is told so and told what to do instead, so there is nothing for enforce-or-remove to chase; they stay in the vocabulary because admitting them needs a function registry **and** a sandbox ruling (#5040 §3.4), which is a design decision, not a key to quietly delete. Zero dead | | capability | seeded 2026-08-08 (#5961; PR #6540) — `CapabilityDeclarationSchema`, the DECLARATION side of ADR-0066 D1's three-way separation: packages DEFINE a capability, permission sets GRANT it via `systemPermissions`, resources REQUIRE it via `requiredPermissions`. **The gate's 12 and the seeding PR's 5 are the same measurement at two granularities** — PR #6540 call-graph-closed **5 authorable properties**, every one to a real reader in `packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts` (the one consumer that turns a declaration into a `sys_capability` row), all `live`, with no `PENDING_GOVERNANCE` debt recorded; the other 7 are the ADR-0010 protection-envelope keys the gate auto-classifies `live` and which carry `null` verdicts in the file, exactly as on `permission`/`position`. The same worked example as `api` above and PR #6540 says so in those words — **enforced but undeclared**, the mirror of the hole #5271 closed. What #5961 measured: absent from `DEFAULT_METADATA_TYPE_REGISTRY`, `BUILTIN_METADATA_TYPE_SCHEMAS` and `HAND_CRAFTED_SCHEMAS`, so `isRuntimeCreateAllowed()` took its no-static-entry fallback (permanently true) and `saveMetaItem` its no-schema branch — `PUT /api/v1/meta/capability/:name` accepted **arbitrary JSON** onto an authorization surface whose names `systemPermissions`/`requiredPermissions` resolve by string, while `/meta/types` synthesised a false `allowRuntimeCreate: true` descriptor Studio drew a raw-JSON create form from. #5870 did not open that path (the write gate reads the registry, not the item store); it only made the type visible in `getMetaTypes()`, and both the issue and this row say so to stop the next reader filing it as a regression. Landed as ruling A on ADR-0066 D1's own authority: `allowRuntimeCreate: false` **and** `allowOrgOverride: false`, the second self-judged inside the ruling's rationale and flagged for veto — a tenant overlay of a package declaration would lift `scope` from `org` to `platform`, which is the one field on this type that is an escalation rather than display. Its reverse verification is worth copying: deleting the registry entry gave 7 red / 3 green and measured something **sharper than predicted** — a garbage payload turned 422 rather than resolving, i.e. the schema binding is a real second line of defence behind the registry row, not a restatement of it; deleting the schema binding alone gave exactly 3 red. `packageId` is the one key that reads oddly: deliberately a FALLBACK, not the primary, since #5870 added `capabilities` to the ObjectQL stamped-collection list so `_packageId` now reaches a declaration and wins — it stays `live` because the fallback branch still decides materialization for any declaration arriving unstamped. Zero dead | -| manifest | seeded 2026-08-23 (#10728) — **not a metadata type and not a stack collection either**, which is the row's whole point. `ManifestSchema` (`packages/spec/src/kernel/manifest.zod.ts:132`) is what an author writes as `objectstack.config.ts` or a packaged manifest; it is parsed at `packages/objectql/src/registry.ts:2950` and by `os plugin build`, and it sat outside the ratchet's universe entirely — `GOVERNED` listed no `plugin`/`manifest`/`package`, `SPEC_ONLY_SCHEMAS` covered only webhook/query/validation/qa, `PENDING_GOVERNANCE` was empty so the gate reported itself **complete**, and `liveness/` had no file for it. A ratchet extended only to unregistered KINDS would not have reached it either: the retired-key entry `17.kernel__Manifest__loading.ts` records that `PLURAL_TO_SINGULAR` has no `packages`/`plugins` entry, so a manifest is never walked as a stack collection member. That blind spot was paid for twice, by hand and after the fact — `loading`'s ten inert keys (#4914, one of them `sandboxing`, which isolated nothing while looking like isolation) and the `contributes` census (#10627), which found exactly ONE reader of the 11-member block monorepo-wide. Dead 21 = the ten dead `contributes` members (`kinds` is the sole live one — `engine.ts:4504` → `registerKind`), the five `capabilities.*` and two `configuration.*` keys (all three containers have ZERO reads of the container itself, so no key beneath one can be read), plus `extensions`, `integrity`, `runtime`, and the tombstoned `loading` whose row must stay because `retiredKey()` keeps the key in the walked shape. **Two of those are security-shaped and neither is retired here**: `integrity` declares per-file digests the runtime is documented to re-verify at unpack while nothing computes or checks them, and `runtime` — the ADR-0025 §3.6 trust tier — is read only by two CLI lines that ECHO the value, with no `runtime === 'sandbox'` branch anywhere, while `loading`'s own tombstone used to redirect upgrading authors to it as something "which [is] enforced". `runtime`'s disposition has since MOVED and is the family's one SPLIT verdict: #12400 measured the cloud leg and found a real consumer — the marketplace publish gate hard-rejects (HTTP 422) an unverified publisher requesting the `node` tier — so the maintainer ruling of 2026-08-30 (#11330) took "say it truthfully" over retirement, and retirement is ruled OUT because deleting the key would tear out that gate's input. The tombstone, both `runtime` describes and the D3 entry now state the split (publish-gate enforced; load-side NOT implemented); the `dead` row stays, scoped to LOAD-SIDE dispatch in objectstack+objectui, and load-side enforcement is a v18 direction rather than a removal. The `contributes` dispositions have since MOVED: the cloud leg was measured CLEAN 2026-08-24 (#10812; cloud `origin/main` @ 5b5925a, zero `manifest.contributes` reads, controls held), and #10724 then executed — the nine mechanically-dead members are `retiredKey()` tombstones (D3 `plugin-manifest-contributes-dead-members-retired`), their rows staying because a tombstone keeps the key in the walked shape. #10726 then executed too (ruled B 2026-08-22; D3 `plugin-manifest-contributes-routes-retired`), tombstoning `routes` and leaving `kinds` the block's sole live member. The remaining non-`contributes` `dead` rows keep their three-repo census verdicts as an enforce-or-remove worklist, not a licence to delete | +| manifest | seeded 2026-08-23 (#10728) — **not a metadata type and not a stack collection either**, which is the row's whole point. `ManifestSchema` (`packages/spec/src/kernel/manifest.zod.ts:132`) is what an author writes as `objectstack.config.ts` or a packaged manifest; it is parsed at `packages/objectql/src/registry.ts:2950` and by `os plugin build`, and it sat outside the ratchet's universe entirely — `GOVERNED` listed no `plugin`/`manifest`/`package`, `SPEC_ONLY_SCHEMAS` covered only webhook/query/validation/qa, `PENDING_GOVERNANCE` was empty so the gate reported itself **complete**, and `liveness/` had no file for it. A ratchet extended only to unregistered KINDS would not have reached it either: the retired-key entry `17.kernel__Manifest__loading.ts` records that `PLURAL_TO_SINGULAR` has no `packages`/`plugins` entry, so a manifest is never walked as a stack collection member. That blind spot was paid for twice, by hand and after the fact — `loading`'s ten inert keys (#4914, one of them `sandboxing`, which isolated nothing while looking like isolation) and the `contributes` census (#10627), which found exactly ONE reader of the 11-member block monorepo-wide. Dead 20 = the ten dead `contributes` members (`kinds` is the sole live one — `engine.ts:4504` → `registerKind`), the five `capabilities.*` and two `configuration.*` keys (all three containers have ZERO reads of the container itself, so no key beneath one can be read), plus `extensions`, `integrity`, and the tombstoned `loading` whose row must stay because `retiredKey()` keeps the key in the walked shape. **`integrity` is security-shaped and not retired here**: it declares per-file digests the runtime is documented to re-verify at unpack while nothing computes or checks them (#11331 — cloud persists the map, nothing verifies it). `runtime` — the ADR-0025 §3.6 trust tier, the other security-shaped key, read locally only by two CLI lines that ECHO the value with no `runtime === 'sandbox'` branch anywhere, while `loading`'s own tombstone used to redirect upgrading authors to it as something "which [is] enforced" — is the family's SPLIT verdict and the ledger's first `live-elsewhere` row (#13483): #12400 measured the cloud leg (cloud @15f55df, 2026-08-29) and found a real consumer — the marketplace publish gate hard-rejects (HTTP 422) an unverified publisher requesting the `node` tier — so the maintainer ruling of 2026-08-30 (#11330) took "say it truthfully" over retirement, and retirement is ruled OUT because deleting the key would tear out that gate's input. The tombstone, both `runtime` describes and the D3 entry state the split (publish-gate enforced; load-side NOT implemented); the row's verdict now says it as data rather than as a qualifying note, under the elsewhere criteria (foreign pointer + cross-repo scope + attestation with a 180d expiry), and load-side enforcement is a v18 direction rather than a removal. The `contributes` dispositions have since MOVED: the cloud leg was measured CLEAN 2026-08-24 (#10812; cloud `origin/main` @ 5b5925a, zero `manifest.contributes` reads, controls held), and #10724 then executed — the nine mechanically-dead members are `retiredKey()` tombstones (D3 `plugin-manifest-contributes-dead-members-retired`), their rows staying because a tombstone keeps the key in the walked shape. #10726 then executed too (ruled B 2026-08-22; D3 `plugin-manifest-contributes-routes-retired`), tombstoning `routes` and leaving `kinds` the block's sole live member. The remaining non-`contributes` `dead` rows keep their three-repo census verdicts as an enforce-or-remove worklist, not a licence to delete | The `dead` set across types is the enforce-or-remove worklist (ADR-0049); every misleading entry carries `authorWarn` so authors hear about it at compile time diff --git a/packages/spec/liveness/manifest.json b/packages/spec/liveness/manifest.json index db99efc6e1..ea1d0d669c 100644 --- a/packages/spec/liveness/manifest.json +++ b/packages/spec/liveness/manifest.json @@ -250,10 +250,11 @@ } }, "runtime": { - "status": "dead", - "verifiedAt": "2026-08-23", + "status": "live-elsewhere", + "verifiedAt": "2026-08-29", "evidenceScope": "cross-repo", - "note": "READ TWICE HERE, ENFORCED AT THE CLOUD PUBLISH GATE, NOT ENFORCED AT LOAD — three different facts, so read this row before assuming either zero references or zero enforcement. The only reads of `manifest.runtime` in objectstack are two CLI progress lines that echo the value: packages/cli/src/commands/plugin/build.ts:127 and packages/cli/src/commands/plugin/publish.ts:94, both `runtime: ${manifest.runtime ?? 'unset'}`. Nothing dispatches on the tier: there is no `runtime === 'sandbox'` branch anywhere, and the QuickJS runner (packages/runtime/src/sandbox/) is the hook/action SCRIPT-BODY sandbox, reached from body-runner factories, never from a plugin's declared tier. objectui: zero reads. So the declared capability — \"in-process full PluginContext vs QuickJS-WASM capability-gated vs out-of-process\" (ADR-0025 §3.6) — is not delivered here, and echoing a string into a build log is not isolation. WHY THIS WAS SECURITY-SHAPED RATHER THAN COSMETIC, AND WHAT WAS DONE: the tombstone this ledger records one key above used to tell every upgrading author, verbatim, \"If you were relying on [loading.sandboxing] for isolation, you had none — use the plugin trust tier (`manifest.runtime`) and the permission declarations, which are enforced\" — a redirection at a tier this repo does not enforce, i.e. ADR-0049 false compliance with a shipped migration message attached. ⚠️ SCOPE LIMIT NOW DISCHARGED: the cloud leg was measured by #12400 and it CHANGED the picture — the cloud control plane really does consume this key at the marketplace publish gate (`plugin-permission-audit.ts`: an unverified publisher requesting the `node` tier is hard-rejected with HTTP 422 and forced to manual review, reading of 2026-08-29). So the honest statement is split: ENFORCED at publish (cloud), NOT ENFORCED at load (here). Maintainer ruling 2026-08-30 (#11330) took option B — correct the text, do not retire the key — and explicitly ruled OUT retirement, because deleting the key would tear out the marketplace's trust-gate input. That correction has landed: the tombstone and both `runtime` describes now state the publish-gate/load-side split, and so does the ADR-0087 D3 entry `plugin-manifest-loading-retired`. ⚠️ READ `dead` NARROWLY HERE: it is the objectstack+objectui verdict about LOAD-SIDE dispatch (still true — the only local reads are the two CLI echoes above), and it is NOT a licence to delete, because the key has a real cross-repo consumer. Adding load-side enforcement is the open half, tracked as a v18 direction, not as a removal." + "evidence": "cloud: packages/service-cloud/src/plugin-permission-audit.ts#auditPluginPermissions @15f55df + packages/service-cloud/src/routes/package-publish.ts:530 — the marketplace publish gate: an unverified publisher requesting the node tier is hard-rejected with HTTP 422 and forced to manual review (#12400 reading of 2026-08-29). Load-side dispatch here is dead by measurement; the note carries that half.", + "note": "ENFORCED AT THE CLOUD PUBLISH GATE, NOT ENFORCED AT LOAD — and since #13483 the split IS the verdict: `live-elsewhere` says dead here by measurement, genuinely enforced in a sibling repo, and unlike the `dead` this row carried until then (with this note's qualifying sentence as the only guard — prose, which no check reads), it cannot be read as a licence to delete and the gate holds its criteria (foreign pointer, cross-repo scope, dated attestation with a 180d expiry — scripts/liveness/elsewhere.mts). LOCAL HALF, measured 2026-08-23 against b9e9227e3 with per-key controls: the only reads of `manifest.runtime` in objectstack are two CLI progress lines that echo the value — packages/cli/src/commands/plugin/build.ts:127 and packages/cli/src/commands/plugin/publish.ts:94, both `runtime: ${manifest.runtime ?? 'unset'}`. Nothing dispatches on the tier: there is no `runtime === 'sandbox'` branch anywhere, and the QuickJS runner (packages/runtime/src/sandbox/) is the hook/action SCRIPT-BODY sandbox, reached from body-runner factories, never from a plugin's declared tier. objectui: zero reads. So the declared capability — \"in-process full PluginContext vs QuickJS-WASM capability-gated vs out-of-process\" (ADR-0025 §3.6) — is not delivered at load, and echoing a string into a build log is not isolation. FOREIGN HALF (the evidence pointer): #12400 measured cloud origin/main @15f55df on 2026-08-29, controls held (15 manifest-property reads findable there) — `auditPluginPermissions` treats `runtime === 'node'` from an unverified publisher as a hard block, the caller turns it into HTTP 422 with forced manual review, and the tier is persisted to the version row (plugin-artifact.ts:179, package-publish.ts:531). `verifiedAt` on this row is THAT reading's date; re-attestation means someone with cloud access re-reads the enforcer and re-pins the commit — never a bare re-stamp (access is seat-dependent: `add_repo` on cloud is denied from some seats, including the #13483 seat on 2026-09-01, while the #10812 and #12400 readings prove cloud-capable seats exist). WHY THIS WAS SECURITY-SHAPED RATHER THAN COSMETIC: the tombstone this ledger records one key above used to tell every upgrading author, verbatim, \"If you were relying on [loading.sandboxing] for isolation, you had none — use the plugin trust tier (`manifest.runtime`) and the permission declarations, which are enforced\" — a redirection at a tier this repo does not enforce at load, i.e. ADR-0049 false compliance with a shipped migration message attached. Maintainer ruling 2026-08-30 (#11330) took option B — correct the text, do not retire the key — and explicitly ruled OUT retirement, because deleting the key would tear out the marketplace's trust-gate input; the tombstone, both `runtime` describes and the ADR-0087 D3 entry `plugin-manifest-loading-retired` state the publish-gate/load-side split. Adding load-side enforcement is the open half, tracked as a v18 direction, not as a removal — if it lands, the verdict here flips to `live` with local evidence." }, "packaging": { "status": "live", diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 5bd69cacda..af92e0804f 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -25,37 +25,37 @@ protection envelope, which the gate auto-classifies `live` on every type that spreads `MetadataProtectionFields`. See the README's counting-method section for both corollaries. -| Type | live | exp | dead | planned | classified | -|---|---|---|---|---|---| -| `object` | 51 | 0 | 0 | 1 | 52 | -| `field` | 89 | 0 | 1 | 2 | 92 | -| `flow` | 34 | 0 | 6 | 0 | 40 | -| `action` | 41 | 0 | 3 | 2 | 46 | -| `hook` | 18 | 0 | 2 | 0 | 20 | -| `permission` | 36 | 0 | 6 | 0 | 42 | -| `position` | 12 | 0 | 0 | 0 | 12 | -| `agent` | 21 | 4 | 1 | 0 | 26 | -| `tool` | 13 | 1 | 0 | 0 | 14 | -| `skill` | 16 | 0 | 1 | 0 | 17 | -| `dataset` | 27 | 0 | 0 | 0 | 27 | -| `page` | 23 | 0 | 0 | 1 | 24 | -| `view` | 79 | 0 | 9 | 0 | 88 | -| `report` | 21 | 0 | 0 | 0 | 21 | -| `dashboard` | 34 | 0 | 7 | 0 | 41 | -| `webhook` | 19 | 0 | 0 | 0 | 19 | -| `query` | 16 | 0 | 5 | 0 | 21 | -| `datasource` | 30 | 0 | 0 | 0 | 30 | -| `app` | 47 | 0 | 9 | 0 | 56 | -| `book` | 20 | 0 | 1 | 0 | 21 | -| `doc` | 15 | 0 | 0 | 0 | 15 | -| `email_template` | 21 | 0 | 0 | 0 | 21 | -| `job` | 15 | 0 | 0 | 0 | 15 | -| `mapping` | 14 | 0 | 0 | 0 | 14 | -| `seed` | 12 | 0 | 0 | 0 | 12 | -| `translation` | 19 | 0 | 0 | 2 | 21 | -| `validation` | 15 | 0 | 3 | 0 | 18 | -| `api` | 25 | 0 | 0 | 2 | 27 | -| `capability` | 12 | 0 | 0 | 0 | 12 | -| `qa` | 4 | 0 | 5 | 0 | 9 | -| `manifest` | 22 | 0 | 16 | 0 | 38 | -| **total** | **821** | **5** | **75** | **10** | **911** | +| Type | live | exp | elsewhere | dead | planned | classified | +|---|---|---|---|---|---|---| +| `object` | 51 | 0 | 0 | 0 | 1 | 52 | +| `field` | 89 | 0 | 0 | 1 | 2 | 92 | +| `flow` | 34 | 0 | 0 | 6 | 0 | 40 | +| `action` | 41 | 0 | 0 | 3 | 2 | 46 | +| `hook` | 18 | 0 | 0 | 2 | 0 | 20 | +| `permission` | 36 | 0 | 0 | 6 | 0 | 42 | +| `position` | 12 | 0 | 0 | 0 | 0 | 12 | +| `agent` | 21 | 4 | 0 | 1 | 0 | 26 | +| `tool` | 13 | 1 | 0 | 0 | 0 | 14 | +| `skill` | 16 | 0 | 0 | 1 | 0 | 17 | +| `dataset` | 27 | 0 | 0 | 0 | 0 | 27 | +| `page` | 23 | 0 | 0 | 0 | 1 | 24 | +| `view` | 79 | 0 | 0 | 9 | 0 | 88 | +| `report` | 21 | 0 | 0 | 0 | 0 | 21 | +| `dashboard` | 34 | 0 | 0 | 7 | 0 | 41 | +| `webhook` | 19 | 0 | 0 | 0 | 0 | 19 | +| `query` | 16 | 0 | 0 | 5 | 0 | 21 | +| `datasource` | 30 | 0 | 0 | 0 | 0 | 30 | +| `app` | 47 | 0 | 0 | 9 | 0 | 56 | +| `book` | 20 | 0 | 0 | 1 | 0 | 21 | +| `doc` | 15 | 0 | 0 | 0 | 0 | 15 | +| `email_template` | 21 | 0 | 0 | 0 | 0 | 21 | +| `job` | 15 | 0 | 0 | 0 | 0 | 15 | +| `mapping` | 14 | 0 | 0 | 0 | 0 | 14 | +| `seed` | 12 | 0 | 0 | 0 | 0 | 12 | +| `translation` | 19 | 0 | 0 | 0 | 2 | 21 | +| `validation` | 15 | 0 | 0 | 3 | 0 | 18 | +| `api` | 25 | 0 | 0 | 0 | 2 | 27 | +| `capability` | 12 | 0 | 0 | 0 | 0 | 12 | +| `qa` | 4 | 0 | 0 | 5 | 0 | 9 | +| `manifest` | 22 | 0 | 1 | 15 | 0 | 38 | +| **total** | **821** | **5** | **1** | **74** | **10** | **911** | diff --git a/packages/spec/scripts/liveness/build-state-counts.mts b/packages/spec/scripts/liveness/build-state-counts.mts index 99f7ac2bf4..3e29e3fab6 100644 --- a/packages/spec/scripts/liveness/build-state-counts.mts +++ b/packages/spec/scripts/liveness/build-state-counts.mts @@ -113,7 +113,7 @@ const rows = foldStateCounts(Object.keys(types), Object.fromEntries( // The header's rule is that a RED gate is not fatal here — the gate is red // precisely when this artifact needs rewriting. This failure is the exception, // and it is the same exception the unparseable report above already carves out: -// there is nothing to rewrite. The fold that produced `rows` reads four status +// there is nothing to rewrite. The fold that produced `rows` reads the published status // names and drops everything else, so writing now would publish an understated // `classified` — and the gate's freshness leg would then compare those bytes // against a re-render of the SAME understated fold and call it current. A stale @@ -134,7 +134,7 @@ if (totalErrors.length) { const rendered = renderStateCounts(rows); writeFileSync(join(ledgerRoot, STATE_COUNTS_FILE), rendered); -const total = rows.reduce((a, r) => a + r.live + r.experimental + r.dead + r.planned, 0); +const total = rows.reduce((a, r) => a + r.live + r.experimental + r['live-elsewhere'] + r.dead + r.planned, 0); console.log(`✓ wrote ${STATE_COUNTS_PATH}`); console.log(` ${rows.length} governed type(s), ${total} classified propert(ies).`); diff --git a/packages/spec/scripts/liveness/check-liveness.mts b/packages/spec/scripts/liveness/check-liveness.mts index aec5fa17ec..da6e5acad9 100644 --- a/packages/spec/scripts/liveness/check-liveness.mts +++ b/packages/spec/scripts/liveness/check-liveness.mts @@ -33,8 +33,11 @@ // went unchecked until #4080 mapped the asymmetry (a strict removal takes the key // out of the walked shape, so the forward pass just stops asking). See orphans.mts. // -// Statuses: live | experimental | planned | dead. Resolution per property: +// Statuses: live | experimental | planned | dead | live-elsewhere. Resolution per property: // ledger entry → spec `.describe()` marker ([EXPERIMENTAL — not enforced]) → UNCLASSIFIED +// (`live-elsewhere` — dead here by measurement, genuinely enforced in a sibling +// repo — is ledger-entry-only and carries its own executable criteria; see +// elsewhere.mts and the wiring in classify(), #13483.) // // PROVE-IT-RUNS (ADR-0054): a `live` entry may carry a `proof` (a dogfood test // reference `#`). For the HIGH-RISK classes bound this phase @@ -163,6 +166,13 @@ import { type VerificationReport, } from './verification.mts'; import { checkCitationLines, checkEvidence, checkEvidenceAnchors, countLines, type EvidenceScan } from './evidence.mts'; +import { + ELSEWHERE_EXPIRED_GUIDANCE, + ELSEWHERE_GUIDANCE, + ELSEWHERE_MAX_AGE_DAYS, + LIVE_ELSEWHERE_STATUS, + checkElsewhereEntry, +} from './elsewhere.mts'; import { KEY_MENTION_GUIDANCE, findUnanchoredCitations, @@ -358,8 +368,13 @@ function markerStatus(d: string): string | null { // // So the scan reads every status whose evidence is a POINTER AT CODE, whatever // verdict that pointer supports: `live` (a consumer reads the key), `planned` -// (a refuser rejects it), `experimental` (declared, not enforced). -const EVIDENCE_SCANNED_STATUSES = new Set(['live', 'planned', 'experimental']); +// (a refuser rejects it), `experimental` (declared, not enforced), and +// `live-elsewhere` (#13483 — a sibling repo's enforcer; its load-bearing +// pointer is FOREIGN, which the scan counts and never resolves, and any +// repo-local path such an entry also cites is held to the same standard as +// everyone else's. What makes the foreign pointer REQUIRED rather than merely +// counted is the elsewhere check in classify(), not this set). +const EVIDENCE_SCANNED_STATUSES = new Set(['live', 'planned', 'experimental', 'live-elsewhere']); // `dead` is OUT, and this is that boundary written into the code rather than // left in a PR residual — the half of #13041 that is worth doing whichever way @@ -593,6 +608,15 @@ const report: any = { anchorsChecked: 0, // resolvable local `path#symbol` anchors asked anchorsUnresolved: [] as string[], // ...of which this many name a symbol the file does not contain — FAILS anchorsMalformed: [] as string[], // an anchor that is not one identifier — also FAILS (silent-degrade guard) + // The `live-elsewhere` criteria (#13483) — dead here, enforced in a sibling + // repo. The gate cannot resolve the foreign file, so it holds the claim's + // SHAPE (foreign pointer, cross-repo scope, dated attestation) and its CLOCK + // (the attestation expires). Two lists because the repairs differ in kind: + // a malformed row needs writing, an expired one needs a RE-READING of the + // foreign enforcer — see elsewhere.mts. + elsewhereChecked: 0, // ledger rows carrying the live-elsewhere verdict + elsewhereMalformed: [] as string[], // ...missing a foreign pointer / cross-repo scope / verifiedAt — FAILS + elsewhereExpired: [] as string[], // ...whose attestation outlived the window — FAILS, demanding re-attestation }; // Every classified entry, for the `verifiedAt` fold below. Collected during the @@ -722,6 +746,27 @@ function classify(type: string, path: string, status: string, led: any, cat: any ...findUnanchoredCitations(`${type}/${path}`, leafKeyOf(path), resolved, contentOf), ); } + // ── the live-elsewhere criteria (#13483) ── + // Everything above judges what the entry cites; this judges whether a + // live-elsewhere entry carries what its verdict REQUIRES: a foreign-realm + // pointer at the enforcer, a declared cross-repo scope, and a dated, unexpired + // attestation. Run per row rather than per evidence string, because the + // failure this exists to catch is precisely the entry that carries NOTHING — + // a bare status would otherwise be the unverified label the card names. It + // also deliberately reaches a child inheriting `childrenDefault: + // "live-elsewhere"` with no row of its own: a blanket elsewhere-claim over + // undated children is the same unfalsifiable shape. + if (status === LIVE_ELSEWHERE_STATUS && led !== null) { + report.elsewhereChecked++; + const found = checkElsewhereEntry({ + key: `${type}/${path}`, + evidence: led?.evidence, + evidenceScope: led?.evidenceScope, + verifiedAt: led?.verifiedAt, + }); + report.elsewhereMalformed.push(...found.malformed); + report.elsewhereExpired.push(...found.expired); + } // ── ADR-0054 prove-it-runs ── const boundClass = BOUND_PROOF_PATHS.get(`${type}/${path}`); if (led?.proof !== undefined) { @@ -1008,6 +1053,15 @@ const failed = // for exactly that citation. report.anchorsUnresolved.length > 0 || report.anchorsMalformed.length > 0 || + // ...and the live-elsewhere criteria (#13483). Red from day one on the + // zero-census argument: the status and its checks land in one change, so the + // gate starts green over a population of exactly the rows migrated with it, + // and only a row that drops its criteria — or an attestation that expires — + // can red it. Expiry failing the build is the point, not a bug: it is the + // only mechanical event this repo can generate about a claim it cannot + // re-measure locally (see elsewhere.mts). + report.elsewhereMalformed.length > 0 || + report.elsewhereExpired.length > 0 || // ...and the within-file half (#11457). Red rather than ⚠ on the strength of // the census that designed it: the signal was measured over the whole ledger // BEFORE it was switched on, the seven real rots it found were repaired, the @@ -1164,6 +1218,26 @@ if (asJson) { ' overstated for free.', ); } + // The live-elsewhere population (#13483). Printed every run, the two-number + // discipline one more time: "no findings" over zero rows is also what a + // check wired to nothing prints, so the row count is published beside it. + console.log( + `live-elsewhere: ${report.elsewhereChecked} entr(ies) carry the verdict (dead here, enforced in a sibling repo), ` + + `${report.elsewhereChecked - report.elsewhereMalformed.length - report.elsewhereExpired.length} with a foreign pointer, ` + + `cross-repo scope, and an attestation ≤${ELSEWHERE_MAX_AGE_DAYS}d old` + + (report.elsewhereMalformed.length ? `, ${report.elsewhereMalformed.length} MALFORMED` : '') + + (report.elsewhereExpired.length ? `, ${report.elsewhereExpired.length} EXPIRED` : '') + '.', + ); + if (report.elsewhereMalformed.length) { + console.log(`\n✗ ${report.elsewhereMalformed.length} live-elsewhere entr(ies) missing the criteria the verdict requires:`); + report.elsewhereMalformed.forEach((s: string) => console.log(` ${s}`)); + console.log('\n' + ELSEWHERE_GUIDANCE.map((l) => (l ? ` ${l}` : '')).join('\n')); + } + if (report.elsewhereExpired.length) { + console.log(`\n✗ ${report.elsewhereExpired.length} live-elsewhere attestation(s) older than the ${ELSEWHERE_MAX_AGE_DAYS}d window — re-attestation required:`); + report.elsewhereExpired.forEach((s: string) => console.log(` ${s}`)); + console.log('\n' + ELSEWHERE_EXPIRED_GUIDANCE.map((l) => (l ? ` ${l}` : '')).join('\n')); + } if (report.staleEvidence.length) { console.log(`\n✗ ${report.staleEvidence.length} ${EVIDENCE_SCANNED_LABEL} entr(ies) cite a file that is missing from THIS repo:`); report.staleEvidence.forEach((s: string) => console.log(` ${s}`)); @@ -1250,7 +1324,10 @@ if (asJson) { ' Fix the VALUE in packages/spec/liveness/.json — it is almost always a\n' + " misspelling of the verdict the author meant. ⛔ Never widen STATUS_COLUMNS to\n" + ' accept it: that vocabulary is what the generated artifact publishes as columns,\n' + - ' and a fifth name there changes the artifact (see the totals failure below).', + ' and a new name there changes the artifact (see the totals failure below). A\n' + + ' DELIBERATE vocabulary change is a different act with its own checklist —\n' + + " `live-elsewhere` (#13483) is the precedent: the column sites, both evidence-scan\n" + + ' sets, and the criteria the new verdict must carry, all moved together.', ); } if (report.ungoverned.length) { @@ -1478,7 +1555,8 @@ if (asJson) { `every container inheritance is declared, every ${EVIDENCE_SCANNED_LABEL} entry's repo-local evidence path ` + 'resolves, every `path:NNN` citation names a line that file actually has, every ' + '`path#symbol` anchor names a symbol its file contains, and every cited ' + - 'file names the property it is evidence for (or is a recorded exemption), all bound ' + + 'file names the property it is evidence for (or is a recorded exemption), every ' + + 'live-elsewhere verdict points at a named foreign enforcer under an unexpired attestation, all bound ' + 'high-risk proofs resolve, every dogfood `@proof:` tag on disk is registered in ' + 'proof-registry.mts, and the README state table carries a row ' + `for each of the ${report.readmeRowCount} governed type(s) it claims to index.`, diff --git a/packages/spec/scripts/liveness/check-liveness.test.ts b/packages/spec/scripts/liveness/check-liveness.test.ts index c74b79ebc4..bce2c7a53b 100644 --- a/packages/spec/scripts/liveness/check-liveness.test.ts +++ b/packages/spec/scripts/liveness/check-liveness.test.ts @@ -66,7 +66,7 @@ function setChildEvidence(root: string, type: string, prop: string, child: strin * set itself is pinned against the gate's source in the population block below — * so widening or narrowing the scan has to move both, deliberately. */ -const SCANNED_LABEL = "'live' / 'planned' / 'experimental'"; +const SCANNED_LABEL = "'live' / 'planned' / 'experimental' / 'live-elsewhere'"; function summaryLine(output: string): string { return output.split('\n').find((l) => l.startsWith('evidence paths:')) ?? ''; @@ -434,7 +434,7 @@ describe('check:liveness — the evidence-scan population (#13041)', () => { it('declares every status either scanned or explicitly unscanned, and prints the population', () => { const src = readFileSync(GATE, 'utf8'); expect(src).toContain( - "const EVIDENCE_SCANNED_STATUSES = new Set(['live', 'planned', 'experimental']);", + "const EVIDENCE_SCANNED_STATUSES = new Set(['live', 'planned', 'experimental', 'live-elsewhere']);", ); expect(src).toContain("const EVIDENCE_UNSCANNED_STATUSES = new Set(['dead']);"); @@ -446,6 +446,122 @@ describe('check:liveness — the evidence-scan population (#13041)', () => { }); }); +// #13483 — the `live-elsewhere` criteria. The status says "dead here by +// measurement, enforced in a sibling repo", and the gate cannot resolve the +// foreign file — so what it enforces is the SHAPE of the claim (a +// foreign-realm pointer, a declared cross-repo scope, a dated attestation) and +// its CLOCK (the attestation expires). Every case runs the REAL gate via +// `--ledger-root`, for the #5623 reason each block above states, and every +// mutation targets `manifest/runtime` — the row the status shipped with — so +// each run has exactly one cause for its verdict. +describe('check:liveness — the live-elsewhere criteria (#13483)', () => { + let tmp: string; + + beforeAll(() => { + tmp = mkdtempSync(path.join(tmpdir(), 'os-liveness-elsewhere-')); + }); + afterAll(() => rmSync(tmp, { recursive: true, force: true })); + + /** Copy the real ledgers and rewrite fields of one property in the copy. */ + function withProp(name: string, type: string, prop: string, edit: (entry: any) => void): string { + const root = path.join(tmp, name); + cpSync(LEDGERS, root, { recursive: true }); + const file = path.join(root, `${type}.json`); + const ledger = JSON.parse(readFileSync(file, 'utf8')); + edit(ledger.props[prop]); + writeFileSync(file, `${JSON.stringify(ledger, null, 2)}\n`); + return root; + } + + // The control, and the population line. The shipped `manifest.runtime` row is + // the day-one population, so a green run must show it counted AND satisfied — + // "no findings" over zero rows is also what a check wired to nothing prints. + it('is green on the shipped ledgers and publishes the population beside the verdict', () => { + const root = path.join(tmp, 'verbatim'); + cpSync(LEDGERS, root, { recursive: true }); + const { status, output } = runGate(root); + expect(status, output).toBe(0); + const line = output.split('\n').find((l) => l.startsWith('live-elsewhere:')) ?? ''; + const m = /live-elsewhere: (\d+) entr\(ies\) carry the verdict .*?, (\d+) with a foreign pointer/.exec(line); + expect(m, line).not.toBeNull(); + expect(Number(m![1])).toBeGreaterThanOrEqual(1); + expect(m![2]).toBe(m![1]); + expect(line).not.toContain('MALFORMED'); + expect(line).not.toContain('EXPIRED'); + }); + + it('FAILS when the evidence attributes no path to a foreign realm — the unverified label itself', () => { + const root = withProp('no-foreign', 'manifest', 'runtime', (e) => { + e.evidence = 'enforced at the cloud marketplace publish gate (trust the note)'; + }); + const { status, output } = runGate(root); + expect(status, output).toBe(1); + expect(output).toContain('live-elsewhere entr(ies) missing the criteria the verdict requires'); + expect(output).toContain('manifest/runtime → cites no foreign-attributed path'); + expect(output).toContain('Do not satisfy this from memory'); + }); + + it('FAILS when evidenceScope contradicts the verdict — in-repo is well-formed and wrong here', () => { + // `in-repo` passes the producer report's vocabulary check, so the exit code + // has exactly one cause: the elsewhere criterion. + const root = withProp('wrong-scope', 'manifest', 'runtime', (e) => { + e.evidenceScope = 'in-repo'; + }); + const { status, output } = runGate(root); + expect(status, output).toBe(1); + expect(output).toContain('manifest/runtime → evidenceScope is "in-repo"'); + }); + + it('FAILS when verifiedAt is absent — legal on every other status, unfalsifiable on this one', () => { + const root = withProp('undated', 'manifest', 'runtime', (e) => { + delete e.verifiedAt; + }); + const { status, output } = runGate(root); + expect(status, output).toBe(1); + expect(output).toContain('manifest/runtime → carries no verifiedAt'); + }); + + it('FAILS when the attestation outlives the window, demanding a re-reading', () => { + const root = withProp('expired', 'manifest', 'runtime', (e) => { + e.verifiedAt = '2026-01-01'; // fixed date, only ever further past the 180d window + }); + const { status, output } = runGate(root); + expect(status, output).toBe(1); + expect(output).toContain('live-elsewhere attestation(s) older than the 180d window — re-attestation required'); + expect(output).toContain('manifest/runtime → attested 2026-01-01'); + // The prescription must survive with the check: the wrong repair here is a + // bare re-stamp, which is the "trust the prose" downgrade the status ends. + expect(output).toContain('re-stamp without re-reading'); + expect(output).toContain('needs-user-decision'); + }); + + it('reports a MALFORMED verifiedAt once, under the verification heading — never twice', () => { + // One rot, one heading (the lineCountOf contract): the bad date fails the + // gate through the verification report; the elsewhere headings must not + // double-report it as undated or expired. + const root = withProp('malformed-date', 'manifest', 'runtime', (e) => { + e.verifiedAt = 'not-a-date'; + }); + const { status, output } = runGate(root); + expect(status, output).toBe(1); + expect(output).toContain('malformed `verifiedAt` value(s)'); + expect(output).not.toContain('live-elsewhere attestation(s) older'); + expect(output).not.toContain('carries no verifiedAt'); + }); + + it('holds a live-elsewhere entry\'s LOCAL citations to the scanned-status standard', () => { + // Membership in EVIDENCE_SCANNED_STATUSES, observed through behaviour: a + // repo-local path cited beside the foreign pointer must resolve. The `;` + // ends the realm's scope, so the rotted path is attributed to THIS repo. + const root = withProp('local-rot', 'manifest', 'runtime', (e) => { + e.evidence = `cloud: packages/service-cloud/src/plugin-permission-audit.ts#auditPluginPermissions; ${ROTTED}:10 (a local claim gone stale)`; + }); + const { status, output } = runGate(root); + expect(status, output).toBe(1); + expect(output).toContain(`manifest/runtime → ${ROTTED}`); + }); +}); + // The README state table is COMPLETE on a green tree (#7257 back-filled the two // rows that were missing), so `pnpm check:liveness` passing says nothing about // whether this direction can fire. Same argument as the evidence guard above, @@ -747,7 +863,7 @@ describe('check:liveness — an unrecognized ledger `status` (#13083)', () => { it("FAILS and names the row when a ledger `status` is misspelled", () => { const { status, output } = runGate(typodRoot('d1-names-the-row')); expect(status, output).toBe(1); - expect(output).toContain('whose `status` is not one of live / experimental / dead / planned'); + expect(output).toContain('whose `status` is not one of live / experimental / live-elsewhere / dead / planned'); expect(output).toContain('field/useGrouping → "planed"'); }); @@ -791,7 +907,7 @@ describe('check:liveness — an unrecognized ledger `status` (#13083)', () => { // real statuses are the entire population today, so an unmutated run must be // green AND must show neither heading. "Exits 0" alone would also be satisfied // by a guard wired to nothing. - it('stays GREEN on the real ledgers, where every status is one of the four', () => { + it('stays GREEN on the real ledgers, where every status is one of the published names', () => { const { status, output } = runGate(); expect(status, output).toBe(0); expect(output).not.toContain('whose `status` is not one of'); diff --git a/packages/spec/scripts/liveness/elsewhere.mts b/packages/spec/scripts/liveness/elsewhere.mts new file mode 100644 index 0000000000..c86fc6c821 --- /dev/null +++ b/packages/spec/scripts/liveness/elsewhere.mts @@ -0,0 +1,197 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// `live-elsewhere` — the fifth liveness verdict, and its executable criteria (#13483). +// +// WHY THIS STATUS EXISTS. `manifest.runtime` is dead HERE by measurement — the +// only local reads are two CLI lines that echo the value — and genuinely +// enforced in the closed cloud repo: the marketplace publish gate hard-rejects +// (HTTP 422) an unverified publisher requesting the `node` tier (#12400, +// measured 2026-08-29 on cloud @15f55df). Neither existing verdict can say +// that. `dead` is true only of the local half — read alone it licenses deleting +// a key with a real cross-repo consumer, and the maintainer ruling of +// 2026-08-30 (#11330) explicitly ruled that deletion OUT. `live` is refused by +// the gate itself: a live verdict's repo-local evidence must resolve against +// this checkout, and cloud's enforcer is not local. The stopgap was a +// qualifying sentence in the row's `note` — prose, which no check reads, i.e. +// the weakest protection this ledger knows. So: a status that SAYS the split — +// dead here, enforced there — and reads as NOT deletable. +// +// THE HARD CONSTRAINT (the card's own): check-liveness.mts deliberately forces +// a decision on unknown statuses rather than defaulting, so a fifth status must +// carry criteria the gate can EXECUTE — otherwise it just replaces `dead`'s lie +// with an unverified label. The gate cannot resolve another repo's file (that +// boundary is deliberate and load-bearing — see evidence.mts), so the +// executable surface is everything about the claim EXCEPT the foreign file's +// content: +// +// 1. FOREIGN POINTER — the `evidence` string must attribute at least one +// path to a foreign realm (`cloud: packages/…/file.ts#symbol` — the +// realm-marker grammar evidence.mts already machine-reads). A +// live-elsewhere verdict IS a pointer at another repo's enforcer; an entry +// with no such pointer is the unverified label the constraint names. +// 2. DECLARED SCOPE — `evidenceScope` must be `"cross-repo"`. The verdict is +// a cross-repo claim by definition; an `in-repo` or absent scope asserts +// elsewhere-ness that no look ever covered. +// 3. DATED ATTESTATION — `verifiedAt` must be present. For every other +// status an absent date is a worklist row, because the file/line/symbol/ +// key-mention checks keep watching the cited code; for THIS status no +// local check can ever observe the foreign consumer rot, so an undated +// claim would be unfalsifiable forever. +// 4. EXPIRY — the attestation must be younger than the window below. Age is +// a worklist everywhere else in this ledger and a MERGE GATE here, for +// the reason in 3: expiry is the only mechanical event this repo can +// generate about a claim it cannot re-measure. +// +// THE RE-VERIFICATION DISCIPLINE those criteria implement: `verifiedAt` on a +// live-elsewhere row records the date somebody with access to the named repo +// actually RE-READ the enforcer (the measured precedent: #10812 read cloud +// @5b5925a on 2026-08-24; #12400 read cloud @15f55df on 2026-08-29 — pin the +// foreign commit in the evidence prose, house style since `action.undoable`). +// This repo's CI never reads the foreign repo — reachability is measured to be +// seat-dependent (`add_repo` denied from some seats, the two readings above +// from others) — so the gate enforces the SHAPE and the CLOCK, and the +// re-reading itself happens wherever access exists. ⛔ Never re-stamp +// `verifiedAt` without re-reading the foreign enforcer: that is exactly the +// "trust the prose note" downgrade this status exists to end. When the window +// closes and nobody with access has re-attested, the gate goes red and STAYS +// red — that red is the escalation: either a fresh reading lands, or the +// maintainer re-rules the row (needs-user-decision), or the verdict +// re-classifies. Trust is time-boxed, never institutional. + +import { scanEvidence } from './evidence.mts'; +import { DEFAULT_STALE_DAYS, parseVerifiedAt, verificationAgeDays } from './verification.mts'; + +/** The status string a ledger row carries. */ +export const LIVE_ELSEWHERE_STATUS = 'live-elsewhere'; + +/** + * How old a live-elsewhere attestation may grow before the gate fails demanding + * a re-reading. Deliberately the SAME number as the ledger-wide staleness + * worklist threshold (`DEFAULT_STALE_DAYS`): "how fresh is fresh" stays decided + * in exactly one place, and what differs per status is the CONSEQUENCE — a + * worklist row where mechanical checks keep watching the citation, a merge gate + * where nothing can. + */ +export const ELSEWHERE_MAX_AGE_DAYS = DEFAULT_STALE_DAYS; + +/** The ledger fields the criteria read, plus the coordinate for messages. */ +export interface ElsewhereEntry { + /** `/` — the ledger coordinate. */ + key: string; + evidence?: unknown; + evidenceScope?: unknown; + verifiedAt?: unknown; +} + +export interface ElsewhereCheck { + /** + * Criteria 1–3: the row's SHAPE never carried the claim — no foreign-attributed + * path, wrong or missing scope, no attestation date. The repair is writing the + * entry the status requires. + */ + malformed: string[]; + /** + * Criterion 4: the row was well-formed and its attestation TIMED OUT. The + * repair is a re-reading (by someone with access to the named repo), a + * maintainer re-ruling, or a re-classification — never a bare re-stamp. + */ + expired: string[]; +} + +/** + * Execute the live-elsewhere criteria against one ledger row. Pure — `now` is + * injected so the gate, the tests, and any future sweep see the same arithmetic + * (the `buildVerificationReport` contract, one status over). + * + * A MALFORMED `verifiedAt` is deliberately NOT reported here: the verification + * report already fails the gate on it for every status, and reporting one rot + * twice under two headings teaches a reader to discount both lists (the + * `lineCountOf` contract in check-liveness.mts). Absent is this check's to + * catch, because for every OTHER status absent is legal. + */ +export function checkElsewhereEntry( + entry: ElsewhereEntry, + { now = new Date(), maxAgeDays = ELSEWHERE_MAX_AGE_DAYS }: { now?: Date; maxAgeDays?: number } = {}, +): ElsewhereCheck { + const malformed: string[] = []; + const expired: string[] = []; + + // 1. FOREIGN POINTER — at least one path attributed to another realm. + const foreign = typeof entry.evidence === 'string' ? scanEvidence(entry.evidence).foreign : []; + if (foreign.length === 0) { + malformed.push( + `${entry.key} → cites no foreign-attributed path — a live-elsewhere verdict IS a pointer at ` + + 'another repo\'s enforcer; name it with a realm marker (`cloud: packages/…/file.ts#symbol`) ' + + 'and pin the foreign commit in the prose', + ); + } + + // 2. DECLARED SCOPE — the claim is cross-repo by definition. + if (entry.evidenceScope !== 'cross-repo') { + malformed.push( + `${entry.key} → evidenceScope is ${entry.evidenceScope === undefined ? 'undeclared' : JSON.stringify(entry.evidenceScope)} ` + + '— a live-elsewhere verdict is a cross-repo claim by definition; declare "cross-repo" ' + + '(and mean it: the scope records that the named realm was actually walked)', + ); + } + + // 3 + 4. DATED ATTESTATION, and its EXPIRY. + if (entry.verifiedAt === undefined) { + malformed.push( + `${entry.key} → carries no verifiedAt — no local check can ever observe the foreign consumer ` + + 'rot, so an undated live-elsewhere claim is unfalsifiable forever; date the reading that ' + + 'closed the foreign call graph', + ); + } else { + const parsed = parseVerifiedAt(entry.verifiedAt, now); + // Malformed → the verification report owns that verdict (see the doc above). + if (parsed.ok) { + const ageDays = verificationAgeDays(parsed.date, now); + if (ageDays > maxAgeDays) { + expired.push( + `${entry.key} → attested ${String(entry.verifiedAt)} (${ageDays}d ago; the window is ${maxAgeDays}d)`, + ); + } + } + } + + return { malformed, expired }; +} + +/** The prescription printed under a shape failure (criteria 1–3). */ +export const ELSEWHERE_GUIDANCE = [ + 'A live-elsewhere verdict says: dead HERE by measurement, genuinely enforced in a', + 'sibling repo. The gate cannot resolve another repo\'s file — that boundary is', + 'deliberate — so what it holds instead is the SHAPE of the claim: a foreign-realm', + 'pointer at the enforcer (`cloud: packages/…/file.ts#symbol`, foreign commit pinned', + 'in the prose), `"evidenceScope": "cross-repo"`, and a dated attestation', + '(`verifiedAt` = the day someone with access actually read the enforcer). An entry', + 'missing any of these is not a verdict, it is a label nothing can falsify — the', + 'exact thing this status exists to replace the qualifying-note stopgap with.', + '', + '⛔ Do not satisfy this from memory or from the row\'s own note. The evidence for', + '"enforced there" is a reading of THAT repo, by a seat that can reach it.', +]; + +/** The prescription printed under an expired attestation (criterion 4). */ +export const ELSEWHERE_EXPIRED_GUIDANCE = [ + 'The attestation timed out. Everywhere else in this ledger, age is a worklist —', + 'the file/line/symbol/key-mention checks keep watching the cited code between', + 're-verifications. A live-elsewhere claim has no such watcher: nothing in this', + 'repo can observe the foreign enforcer rot, so expiry is the one mechanical event', + 'this repo can generate about it, and it is a merge gate on purpose.', + '', + 'Three repairs, and the first is the normal one:', + ' • RE-ATTEST — someone with access to the named repo re-reads the enforcer at', + ' its current head, updates the evidence (new commit pin, repaired path/symbol', + ' if it moved) and re-stamps `verifiedAt` with the reading\'s date. ⛔ Never', + ' re-stamp without re-reading — that is the "trust the prose" downgrade this', + ' status exists to end.', + ' • the enforcer is GONE from the foreign repo → the verdict is not', + ' live-elsewhere any more; re-classify (usually `dead`) under ADR-0049', + ' enforce-or-remove, with the reading as evidence.', + ' • nobody with access can be found before the window closes → this red IS the', + ' escalation: the row returns to the maintainer (needs-user-decision) to rule', + ' whether an unverifiable pointer still counts as evidence. Do not silence it', + ' by widening the window or re-stamping the date.', +]; diff --git a/packages/spec/scripts/liveness/elsewhere.test.ts b/packages/spec/scripts/liveness/elsewhere.test.ts new file mode 100644 index 0000000000..781f466a26 --- /dev/null +++ b/packages/spec/scripts/liveness/elsewhere.test.ts @@ -0,0 +1,156 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Unit tests for the `live-elsewhere` criteria (#13483) — the PURE half. +// The grading (which findings reach `process.exit(1)`, what the summary line +// claims) lives in check-liveness.mts and is pinned there through the real gate +// via `--ledger-root`, for the #5623 reason: a helper test cannot pin an exit +// code. What THIS file pins is the arithmetic and the boundaries of +// `checkElsewhereEntry` itself, with `now` injected so the cases cannot rot as +// wall time advances. + +import { describe, it, expect } from 'vitest'; +import { + ELSEWHERE_EXPIRED_GUIDANCE, + ELSEWHERE_GUIDANCE, + ELSEWHERE_MAX_AGE_DAYS, + LIVE_ELSEWHERE_STATUS, + checkElsewhereEntry, +} from './elsewhere.mts'; +import { DEFAULT_STALE_DAYS } from './verification.mts'; + +const NOW = new Date('2026-09-01T00:00:00Z'); + +/** A row satisfying every criterion, in the shipped `manifest.runtime` shape. */ +function goodEntry() { + return { + key: 'manifest/runtime', + evidence: + 'cloud: packages/service-cloud/src/plugin-permission-audit.ts#auditPluginPermissions @15f55df — the publish gate (HTTP 422).', + evidenceScope: 'cross-repo', + verifiedAt: '2026-08-29', + }; +} + +describe('checkElsewhereEntry — the executable criteria', () => { + it('passes an entry with a foreign pointer, cross-repo scope, and a fresh attestation', () => { + const r = checkElsewhereEntry(goodEntry(), { now: NOW }); + expect(r.malformed).toEqual([]); + expect(r.expired).toEqual([]); + }); + + it('FAILS an entry whose evidence attributes no path to a foreign realm', () => { + // Prose that CLAIMS elsewhere-ness without pointing anywhere — the + // unverified label the card's hard constraint names. + const r = checkElsewhereEntry( + { ...goodEntry(), evidence: 'enforced at the cloud marketplace publish gate (trust the note)' }, + { now: NOW }, + ); + expect(r.malformed).toHaveLength(1); + expect(r.malformed[0]).toContain('manifest/runtime'); + expect(r.malformed[0]).toContain('no foreign-attributed path'); + }); + + it('FAILS an entry with no evidence at all — a bare status is a label, not a verdict', () => { + const { evidence: _dropped, ...rest } = goodEntry(); + const r = checkElsewhereEntry(rest, { now: NOW }); + expect(r.malformed.some((m) => m.includes('no foreign-attributed path'))).toBe(true); + }); + + it('a LOCAL path does not satisfy the foreign criterion — that is what `live` is for', () => { + const r = checkElsewhereEntry( + { ...goodEntry(), evidence: 'packages/spec/scripts/liveness/evidence.mts (a local consumer claim)' }, + { now: NOW }, + ); + expect(r.malformed.some((m) => m.includes('no foreign-attributed path'))).toBe(true); + }); + + it('a foreign-PREFIX path (service-ai) satisfies the foreign criterion without a realm marker', () => { + // `packages/services/service-ai/…` is always-foreign by prefix in + // evidence.mts — the closed cloud runtime cited repo-rooted. The criterion + // reads the scan's `foreign` bucket, so both attribution grammars count. + const r = checkElsewhereEntry( + { ...goodEntry(), evidence: 'packages/services/service-ai/src/enforcer.ts#enforceTier @abc1234' }, + { now: NOW }, + ); + expect(r.malformed).toEqual([]); + }); + + it('FAILS an undeclared evidenceScope, naming it undeclared', () => { + const { evidenceScope: _dropped, ...rest } = goodEntry(); + const r = checkElsewhereEntry(rest, { now: NOW }); + expect(r.malformed).toHaveLength(1); + expect(r.malformed[0]).toContain('undeclared'); + }); + + it('FAILS an in-repo evidenceScope — well-formed, and contradicting the verdict', () => { + const r = checkElsewhereEntry({ ...goodEntry(), evidenceScope: 'in-repo' }, { now: NOW }); + expect(r.malformed).toHaveLength(1); + expect(r.malformed[0]).toContain('"in-repo"'); + expect(r.malformed[0]).toContain('cross-repo claim by definition'); + }); + + it('FAILS an absent verifiedAt — undated elsewhere-claims are unfalsifiable forever', () => { + const { verifiedAt: _dropped, ...rest } = goodEntry(); + const r = checkElsewhereEntry(rest, { now: NOW }); + expect(r.malformed).toHaveLength(1); + expect(r.malformed[0]).toContain('no verifiedAt'); + expect(r.expired).toEqual([]); + }); + + it('SKIPS a malformed verifiedAt — the verification report owns that verdict', () => { + // The lineCountOf contract one field over: one rot, one heading. A malformed + // date already fails the gate for every status; reporting it here too would + // teach a reader to discount both lists. + const r = checkElsewhereEntry({ ...goodEntry(), verifiedAt: 'not-a-date' }, { now: NOW }); + expect(r.malformed).toEqual([]); + expect(r.expired).toEqual([]); + }); + + it('passes at exactly the window edge and FAILS one day past it', () => { + const atEdge = checkElsewhereEntry( + { ...goodEntry(), verifiedAt: '2026-03-05' }, // 180 days before NOW + { now: NOW }, + ); + expect(atEdge.expired).toEqual([]); + + const pastEdge = checkElsewhereEntry( + { ...goodEntry(), verifiedAt: '2026-03-04' }, // 181 days before NOW + { now: NOW }, + ); + expect(pastEdge.expired).toHaveLength(1); + expect(pastEdge.expired[0]).toContain('manifest/runtime'); + expect(pastEdge.expired[0]).toContain('2026-03-04'); + expect(pastEdge.expired[0]).toContain('181d ago'); + expect(pastEdge.expired[0]).toContain(`${ELSEWHERE_MAX_AGE_DAYS}d`); + expect(pastEdge.malformed).toEqual([]); + }); + + it('reports every missing criterion at once, so one round repairs the row', () => { + const r = checkElsewhereEntry({ key: 'manifest/runtime' }, { now: NOW }); + expect(r.malformed).toHaveLength(3); + }); +}); + +describe('the live-elsewhere constants and prescriptions', () => { + it('shares the ledger-wide freshness threshold — one policy, decided in one place', () => { + expect(ELSEWHERE_MAX_AGE_DAYS).toBe(DEFAULT_STALE_DAYS); + }); + + it('spells the status the way the ledger rows and STATUS_COLUMNS spell it', () => { + expect(LIVE_ELSEWHERE_STATUS).toBe('live-elsewhere'); + }); + + it('the shape prescription forbids satisfying the criteria from the note', () => { + const text = ELSEWHERE_GUIDANCE.join('\n'); + expect(text).toContain('dead HERE by measurement'); + expect(text).toContain('Do not satisfy this from memory'); + }); + + it('the expiry prescription demands a re-reading, and names the escalation', () => { + const text = ELSEWHERE_EXPIRED_GUIDANCE.join('\n'); + expect(text).toContain('Never'); + expect(text).toContain('re-stamp without re-reading'); + expect(text).toContain('needs-user-decision'); + expect(text).toContain('Do not silence it'); + }); +}); diff --git a/packages/spec/scripts/liveness/readme-table.mts b/packages/spec/scripts/liveness/readme-table.mts index 6b091ebeea..141214603d 100644 --- a/packages/spec/scripts/liveness/readme-table.mts +++ b/packages/spec/scripts/liveness/readme-table.mts @@ -265,8 +265,17 @@ export const STATE_COUNTS_PATH = `packages/spec/liveness/${STATE_COUNTS_FILE}`; /** The one command that rewrites it. Named in every failure below. */ export const STATE_COUNTS_GEN_COMMAND = 'pnpm --filter @objectstack/spec gen:liveness-counts'; -/** The four status columns the table published, in the order it published them. */ -export const STATUS_COLUMNS = ['live', 'experimental', 'dead', 'planned'] as const; +/** + * The status columns the table publishes, in the order it publishes them. + * `live-elsewhere` is the deliberate fifth (#13483): dead here by measurement, + * genuinely enforced in a sibling repo — a verdict that must read as NOT + * deletable and must not satisfy `live`'s local-evidence rules (its own + * executable criteria live in elsewhere.mts). Widening this list is an + * artifact-shape decision (#7377): `StateCountsRow`, `foldStateCounts` and + * `renderStateCounts` name every column by hand — move all of them together + * with this line, then regenerate. + */ +export const STATUS_COLUMNS = ['live', 'experimental', 'live-elsewhere', 'dead', 'planned'] as const; export type StatusColumn = (typeof STATUS_COLUMNS)[number]; /** One governed type's counts, exactly as `types..byStatus` reports them. */ @@ -274,6 +283,7 @@ export interface StateCountsRow { type: string; live: number; experimental: number; + 'live-elsewhere': number; dead: number; planned: number; } @@ -298,6 +308,7 @@ export function foldStateCounts( type, live: b.live ?? 0, experimental: b.experimental ?? 0, + 'live-elsewhere': b['live-elsewhere'] ?? 0, dead: b.dead ?? 0, planned: b.planned ?? 0, }; @@ -307,7 +318,7 @@ export function foldStateCounts( /** * The fold's own blind spot, made ARITHMETIC (#13083). * - * `foldStateCounts` above reads four names and nothing else, so a `byStatus` + * `foldStateCounts` above reads the published names and nothing else, so a `byStatus` * bucket it cannot name — a ledger row written `"status": "planed"` — is dropped * on the floor. Every check downstream then agrees with every other, because * they are all reading the same understated fold: `renderStateCounts` computes @@ -376,10 +387,10 @@ export function reconcileStateCountTotals({ /** The prescription printed under a total that does not reconcile. */ export const STATE_COUNTS_TOTALS_GUIDANCE = [ - 'The count columns are a FOLD of four status names, and this is the arithmetic that', - 'says the fold dropped something (#13083). It is NOT a stale-artifact failure, and', - `\`${STATE_COUNTS_GEN_COMMAND}\` is not the repair: the generator folds through`, - 'exactly the same four names, so it would only re-publish the same understated total.', + `The count columns are a FOLD of the ${STATUS_COLUMNS.length} published status names, and this is the`, + 'arithmetic that says the fold dropped something (#13083). It is NOT a stale-artifact', + `failure, and \`${STATE_COUNTS_GEN_COMMAND}\` is not the repair: the generator folds through`, + 'exactly the same names, so it would only re-publish the same understated total.', '', 'Read the buckets named above:', '', @@ -389,9 +400,10 @@ export const STATE_COUNTS_TOTALS_GUIDANCE = [ '', ' • a status DELIBERATELY added to STATUS_COLUMNS — then the vocabulary grew and', ' the fold did not. `StateCountsRow`, `foldStateCounts` and `renderStateCounts`', - ' all name the four columns by hand, and a fifth one publishes as a COLUMN, which', + ' all name every column by hand, and a new one publishes as a COLUMN, which', ' changes what the artifact contains. That is an artifact-shape decision (#7377):', - ' make it deliberately, move all four sites together, and regenerate.', + ' make it deliberately, move all the named sites together, and regenerate —', + ' `live-elsewhere` (#13483) is the precedent to copy.', '', '⛔ Never satisfy this by editing the artifact. The number it publishes is not the', 'one in dispute — the population behind it is.', @@ -413,14 +425,16 @@ export function renderStateCounts(rows: readonly StateCountsRow[]): string { type: 'total', live: a.live + r.live, experimental: a.experimental + r.experimental, + 'live-elsewhere': a['live-elsewhere'] + r['live-elsewhere'], dead: a.dead + r.dead, planned: a.planned + r.planned, }), - { type: 'total', live: 0, experimental: 0, dead: 0, planned: 0 }, + { type: 'total', live: 0, experimental: 0, 'live-elsewhere': 0, dead: 0, planned: 0 }, ); + const classifiedOf = (r: StateCountsRow) => STATUS_COLUMNS.reduce((a, c) => a + r[c], 0); const body = rows.map( - (r) => `| \`${r.type}\` | ${r.live} | ${r.experimental} | ${r.dead} | ${r.planned} | ${r.live + r.experimental + r.dead + r.planned} |`, + (r) => `| \`${r.type}\` | ${r.live} | ${r.experimental} | ${r['live-elsewhere']} | ${r.dead} | ${r.planned} | ${classifiedOf(r)} |`, ); return [ @@ -451,10 +465,10 @@ export function renderStateCounts(rows: readonly StateCountsRow[]): string { 'spreads `MetadataProtectionFields`. See the README\'s counting-method section', 'for both corollaries.', '', - '| Type | live | exp | dead | planned | classified |', - '|---|---|---|---|---|---|', + '| Type | live | exp | elsewhere | dead | planned | classified |', + '|---|---|---|---|---|---|---|', ...body, - `| **total** | **${total.live}** | **${total.experimental}** | **${total.dead}** | **${total.planned}** | **${total.live + total.experimental + total.dead + total.planned}** |`, + `| **total** | **${total.live}** | **${total.experimental}** | **${total['live-elsewhere']}** | **${total.dead}** | **${total.planned}** | **${classifiedOf(total)}** |`, '', ].join('\n'); } diff --git a/packages/spec/scripts/liveness/readme-table.test.ts b/packages/spec/scripts/liveness/readme-table.test.ts index b9e96daceb..e90e8c6061 100644 --- a/packages/spec/scripts/liveness/readme-table.test.ts +++ b/packages/spec/scripts/liveness/readme-table.test.ts @@ -223,9 +223,9 @@ describe('the prescriptions', () => { */ const COUNTS = [ - { type: 'object', live: 49, experimental: 0, dead: 0, planned: 1 }, - { type: 'field', live: 66, experimental: 0, dead: 0, planned: 0 }, - { type: 'api', live: 25, experimental: 0, dead: 0, planned: 2 }, + { type: 'object', live: 49, experimental: 0, 'live-elsewhere': 0, dead: 0, planned: 1 }, + { type: 'field', live: 66, experimental: 0, 'live-elsewhere': 0, dead: 0, planned: 0 }, + { type: 'api', live: 25, experimental: 0, 'live-elsewhere': 0, dead: 0, planned: 2 }, ]; /** The 2-column README the split produced — prose only, no numbers. */ @@ -240,7 +240,16 @@ describe('foldStateCounts', () => { field: { live: 66 }, }); expect(rows.map((r) => r.type)).toEqual(['field', 'object']); - expect(rows[1]).toEqual({ type: 'object', live: 49, experimental: 0, dead: 0, planned: 1 }); + expect(rows[1]).toEqual({ type: 'object', live: 49, experimental: 0, 'live-elsewhere': 0, dead: 0, planned: 1 }); + }); + + // The fifth column (#13483) folds like the other four — a bucket the fold + // could not name is exactly the #13083 shape this file exists to prevent. + it('folds a live-elsewhere bucket into its own column', () => { + const rows = foldStateCounts(['manifest'], { + manifest: { live: 22, 'live-elsewhere': 1, dead: 15 }, + }); + expect(rows[0]).toEqual({ type: 'manifest', live: 22, experimental: 0, 'live-elsewhere': 1, dead: 15, planned: 0 }); }); // "Not measured" and "measured as nothing" must not render the same. A skipped @@ -249,15 +258,26 @@ describe('foldStateCounts', () => { it('renders a type the report does not carry as zeroes rather than skipping it', () => { const rows = foldStateCounts(['object', 'ghost'], { object: { live: 49 } }); expect(rows.map((r) => r.type)).toEqual(['object', 'ghost']); - expect(rows[1]).toEqual({ type: 'ghost', live: 0, experimental: 0, dead: 0, planned: 0 }); + expect(rows[1]).toEqual({ type: 'ghost', live: 0, experimental: 0, 'live-elsewhere': 0, dead: 0, planned: 0 }); }); }); describe('renderStateCounts', () => { it('publishes a row per type, a classified column, and a total', () => { const out = renderStateCounts(COUNTS); - expect(out).toContain('| `object` | 49 | 0 | 0 | 1 | 50 |'); - expect(out).toContain('| **total** | **140** | **0** | **0** | **3** | **143** |'); + expect(out).toContain('| Type | live | exp | elsewhere | dead | planned | classified |'); + expect(out).toContain('| `object` | 49 | 0 | 0 | 0 | 1 | 50 |'); + expect(out).toContain('| **total** | **140** | **0** | **0** | **0** | **3** | **143** |'); + }); + + // The fifth column counts into `classified` like the other four (#13483) — + // an elsewhere-verdict is a CLASSIFIED property, precisely not a gap. + it('counts live-elsewhere into the row and total classified sums', () => { + const out = renderStateCounts([ + { type: 'manifest', live: 22, experimental: 0, 'live-elsewhere': 1, dead: 15, planned: 0 }, + ]); + expect(out).toContain('| `manifest` | 22 | 0 | 1 | 15 | 0 | 38 |'); + expect(out).toContain('| **total** | **22** | **0** | **1** | **15** | **0** | **38** |'); }); it('says it is generated and names the one command that rewrites it', () => { @@ -385,7 +405,7 @@ describe('reconcileStateCountTotals — the fold must not lose a property', () = classified: { object: 50, field: 66 }, }; - it('stays quiet when every property sits in one of the four published columns', () => { + it('stays quiet when every property sits in one of the published columns', () => { expect(reconcileStateCountTotals(HONEST)).toEqual([]); }); @@ -454,7 +474,7 @@ describe('reconcileStateCountTotals — the fold must not lose a property', () = // the fold that drops the value must not be able to disagree about the four // names. Pinned because a second copy is exactly how the two come apart. it('measures against the published vocabulary, not a second copy of it', () => { - expect([...STATUS_COLUMNS]).toEqual(['live', 'experimental', 'dead', 'planned']); + expect([...STATUS_COLUMNS]).toEqual(['live', 'experimental', 'live-elsewhere', 'dead', 'planned']); const errors = reconcileStateCountTotals({ governed: ['object'], byStatus: { object: { live: 1, planed: 1 } }, @@ -472,7 +492,7 @@ describe('the totals prescription', () => { const text = STATE_COUNTS_TOTALS_GUIDANCE.join('\n'); expect(text).toContain(STATE_COUNTS_GEN_COMMAND); expect(text).toContain('is not the repair'); - expect(text).toContain('the same four names'); + expect(text).toContain('exactly the same names'); }); it('forbids widening the vocabulary to get green, and forbids editing the artifact', () => {