@openwop/openwop-conformance 2.33.2 → 2.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # `@openwop/openwop-conformance` Changelog
2
2
 
3
+ ## [2.34.0] — 2026-09-21 — the rung and the recovery bound, in the bundle RFC 0158 said they were in
4
+
5
+ - **RFC 0158 §E publishes a host's rung and recovery bound in its certification bundle instead of in discovery — and the bundle had no seat for either.** `certification-bundle.schema.json` is closed; a reader saw five pass/fail rows and could neither tell which rung was claimed nor recompute the bound, whose terms lived only on a non-normative seam route. Found by reading the RFC's first acceptance criterion at the moment of flipping it, with every row green.
6
+ - **Evidence rides on ROWS, because that is the only place it is signed.** The attestation covers `{ witnessSha256, host.build, suite.version, discovery.sha256 }` and the witness digest covers rows, so a root-level block would be editable after signing on a bundle that still verifies. A row gains an OPTIONAL closed `evidence` object that enters the digest **only when present** — every earlier bundle digests byte-identically, pinned against the three committed host bundles.
7
+ - `0158.bound-is-derived` → `evidence.recoveryBounds[]` of `{ class, bound, terms[{ name, ms }] }`, **per recovery class, no aggregate** (Unresolved Question 1: a scalar would have to be the maximum, which overstates recovery for every faster class).
8
+ - `0158.kill-after-accept`, `0158.kill-during-execution` → `evidence.recovery { class, boundMs, observedMs }`. The kill → resumption interval was always measured and appeared only in a FAILURE message; a passing row recorded nothing.
9
+ - **`durability.rung` is a claim, and the verifier does not trust it.** It sits outside the signature, which is sound for the reason `claimedProfiles[].certified` is: `--verify` re-derives it from signed rows — all five rows `executed-pass`, each kill row's `class` naming a declared entry whose `bound` equals its `boundMs` and is not exceeded by its `observedMs`, each entry's terms summing to its bound — and rejects what it cannot derive (new rejection `rung-not-derivable`). Only `durable-single-instance` is derivable; `peer-resume` evidence is not carried yet, so a higher claim is **refused rather than assumed**. `--certify` claims a rung only when the same derivation supports it, and says on stderr which rung it derived and why.
10
+ - **`class` and term names are opaque, host-chosen, and pattern-constrained** (`^[a-z][A-Za-z0-9._-]{0,63}$`, ≤ 16 classes × ≤ 16 terms). Never an enum: "leased" names one host's mechanism and "boot re-entry" another's. Only `{ name, ms }` survives from a term — free text a host attaches to its seam response never reaches a published bundle.
11
+ - **What class-binding does NOT catch, stated and pinned by a test so nobody reads more into it.** It refuses an undeclared class, a bound label that disagrees with its class, and a resumption outside the bound. It does not refuse a seam that kills *before* the execution claim is held and labels the exercise with the slower class — a tier-1 host shipped exactly that (11.6 s against a 750 s leased class, every row green), and no arithmetic separates it from a fast recovery. The evidence makes it **visible** where before nothing was recorded; killing only once the claim is held stays the host's obligation.
12
+ - **Seam contract (non-normative):** the bound response may be `{ classes: [{ class, bound, terms[] }] }`, a map keyed by class, or the flat `{ bound, terms[], class? }` two hosts already serve. A top-level scalar beside `classes` is ignored. A flat response that names no class is recorded as `default`, which will not match a kill response naming a real class — so the rung is not derivable until the bound says which class it governs.
13
+ - **`--verify` gains a `rung:` line and a newer-suite notice.** A verifier older than the bundle it reads recomputes a different witness digest and fails closed, correctly — but `witness-digest` reads as tampering when the truth is "upgrade the verifier".
14
+ - Additive. No scenario added or removed; no host that passed before fails now. `bundleVersion` stays `"3"`.
15
+
3
16
  ## [2.33.2] — 2026-09-21 — `v2-run-snapshot-etag` took its tag from a run that was still moving
4
17
 
5
18
  - **The scenario slept a fixed 1 s after `POST /runs`, took the snapshot's `ETag`, and demanded `304`.** A host whose ETag tracks the run's event-log sequence — the strong tag `runs.md` §Snapshot asks for — is still appending while a noop executes, so between the two GETs the representation **changes** and `200` with a new tag is the only correct answer; a `304` there would be a stale-cache bug. **Measured on a tier-2 production host, 4 of 4:** immediately after create `etag1 ≠ etag2` and the conditional GET answers `200`; after the run settles the same request answers `304` every time. The row had passed three earlier cuts of that host and failed the fourth — a timing lottery, won whenever the run finished inside the sleep.
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  # --legacy-peer-deps is REQUIRED, not optional: the exact peer pin is what npm's
12
12
  # default resolver refuses. npm 10.9 fails outright with
13
13
  # "Cannot read properties of null (reading 'edgesOut')" — use npm >= 11.
14
- npm install --legacy-peer-deps @openwop/openwop-conformance@2.33.2 @openwop/spec-artifacts@2.33.2
14
+ npm install --legacy-peer-deps @openwop/openwop-conformance@2.34.0 @openwop/spec-artifacts@2.34.0
15
15
  # or run without install:
16
16
  npx @openwop/openwop-conformance --base-url https://api.example.com --api-key hk_test_...
17
17
  ```
package/coverage.md CHANGED
@@ -145,7 +145,7 @@ The scenario groups in the table below (one row per group; count the rows — th
145
145
  | `egress-audience-binding.test.ts` | `capabilities.httpClient.egressPolicy.supported` (RFC 0079 §C, `host-capabilities.md`) + `SECURITY/invariants.yaml` `egress-credential-audience-bound` | A (KEYSTONE — the §C confused-deputy MUST via `POST /v1/host/sample/egress/decide`: an out-of-audience egress is `denied`/`downgraded` with `reason:"out-of-audience"` and the credential is NOT attached (`credentialAttached !== true`); a provenance-unevaluable egress fails closed `denied`+`reason:"provenance-unevaluable"`; decision/reason ∈ the closed enums) | `host-pending` | `behaviorGate('openwop-egress-audience-binding', …)`. Seam-gated; soft-skips on 404. **This is the RFC 0079 → Accepted bar** (the `egress-credential-audience-bound` invariant graduates reference-impl → protocol tier when this passes against a host). First adopter: MyndHyve `httpClient.egressPolicy`. |
146
146
  | `egress-decision-content-free.test.ts` | `capabilities.httpClient.egressPolicy.supported` (RFC 0079 §F / SR-1) | A (the secret non-leak — a `canary` credential's sentinel never surfaces in the decision (`canaryLeaked !== true`), the `egress.decided` payload carries no forbidden content key, and `reason` stays in the CLOSED vocabulary so no blocked destination spills into a free-form field) | `host-pending` | `behaviorGate('openwop-egress-decision-content-free', …)`. Seam-gated; soft-skips on 404. **Part of the RFC 0079 → Accepted bar.** First adopter: MyndHyve `httpClient.egressPolicy`. |
147
147
  | `v2-webhook-egress-refusal.test.ts` | the `webhooks` family (capability-gated) | A (`webhooks.md` §SSRF at MAJOR 2 — eight registration probes, one per class the sentence names: non-`https`, IPv4/IPv6 loopback, `localhost`, two RFC 1918 ranges, link-local / cloud metadata, IPv6 ULA; each MUST answer `400 webhook_url_rejected`. Until 2.33.0 no major-2 row asserted the guard refuses anything — the refusal scenarios were major-1 only. THREE OUTCOMES: all refused → `executed-pass`; a probe accepted with a `webhooks.*` relaxation DECLARED in `OPENWOP_HOST_RELAXATIONS` → `inapplicable`, and the declared relaxation denies the owning profile; a probe accepted with NOTHING declared → `executed-fail`, which the emitter and verifier read as an OBSERVED undeclared relaxation and deny every profile built on `webhooks` (`undeclared-relaxation-observed`). Registration-time only: no probe is a destination the suite owns, nothing is delivered, an accepted registration is deleted at once) |
148
- | `v2-durability-recovery.test.ts` | the RFC 0158 host-extension durability seam `POST /host/durability/kill` (non-normative; §E mints no capability field, so seam PRESENCE is how a host says it claims a rung) | A (all five `durable-single-instance` rows: `kill-after-accept` as a HOLD-DISPATCH exercise, `kill-during-execution` asserting that work executing at a real process death is never observable as completed without re-execution, `duplicate-delivery` counted AT THE EFFECT'S DESTINATION — a receiver the suite owns, `effectUrl` — because a ledger keyed on effect identity admits one row per identity by construction and cannot show a double-fire (2.32.0), `bound-is-derived` as an explicitly-labelled paper check, and `poison-exhaustion` ported from the major-1 scenario — at major 2 it reads the canonical JSON log read `GET /runs/{runId}/events/poll` (NOT `/events`, which is SSE-only at major 2 — reading it as JSON made this row pass vacuously on a host serving exactly the OpenAPI; 2.32.0), so unlike its v1 twin it needs NO seam and can never record `blocked` for want of one a host did not wire) | `host-pending` | **Disposition rule, and it protects fleet certification:** no seam route at all ⇒ `inapplicable` (the host claims no rung); seam present but an operator precondition unmet — a restart supervisor, which a black-box suite cannot supply — ⇒ `blocked` with the precondition NAMED. Collapsing the two would deny certification to every host the day these rows entered the lane. `peer-resume` is deliberately NOT written: §E makes it bundle-witnessed, so a scenario could only ever record `blocked`. **This is the RFC 0158 → Accepted bar, and it is now complete at major 2** — before the port, a major-2 bundle could carry at most four of the five rows and the rung was unwitnessable no matter what a host did, and its witness is openwop-app, which the RFC records as having observed `kill-during-execution` across a real `SIGKILL`. |
148
+ | `v2-durability-recovery.test.ts` | the RFC 0158 host-extension durability seam `POST /host/durability/kill` (non-normative; §E mints no capability field, so seam PRESENCE is how a host says it claims a rung) | A (all five `durable-single-instance` rows: `kill-after-accept` as a HOLD-DISPATCH exercise, `kill-during-execution` asserting that work executing at a real process death is never observable as completed without re-execution, `duplicate-delivery` counted AT THE EFFECT'S DESTINATION — a receiver the suite owns, `effectUrl` — because a ledger keyed on effect identity admits one row per identity by construction and cannot show a double-fire (2.32.0), `bound-is-derived` as an explicitly-labelled paper check, and `poison-exhaustion` ported from the major-1 scenario — at major 2 it reads the canonical JSON log read `GET /runs/{runId}/events/poll` (NOT `/events`, which is SSE-only at major 2 — reading it as JSON made this row pass vacuously on a host serving exactly the OpenAPI; 2.32.0), so unlike its v1 twin it needs NO seam and can never record `blocked` for want of one a host did not wire) | `host-pending` | **2.34.0 — the rows now WRITE evidence into the bundle (RFC 0158 §E):** `bound-is-derived` records `evidence.recoveryBounds[]` per recovery class and each kill row records `evidence.recovery { class, boundMs, observedMs }` — the interval was always measured and, on a pass, never recorded. Both ride on the ROW so the witness digest, and through it the signature, covers them; the bundle claims `durability.rung` only when `deriveRung` supports it and `--verify` rejects a claim it cannot re-derive (`rung-not-derivable`). A kill row records nothing unless the seam NAMES the class it exercised (`recoveryClass`) and the bound response declares that class — no name, no rung, rather than a guessed one. **Disposition rule, and it protects fleet certification:** no seam route at all ⇒ `inapplicable` (the host claims no rung); seam present but an operator precondition unmet — a restart supervisor, which a black-box suite cannot supply — ⇒ `blocked` with the precondition NAMED. Collapsing the two would deny certification to every host the day these rows entered the lane. `peer-resume` is deliberately NOT written: §E makes it bundle-witnessed, so a scenario could only ever record `blocked`. **This is the RFC 0158 → Accepted bar, and it is now complete at major 2** — before the port, a major-2 bundle could carry at most four of the five rows and the rung was unwitnessable no matter what a host did, and its witness is openwop-app, which the RFC records as having observed `kill-during-execution` across a real `SIGKILL`. |
149
149
  | `memory-degraded-projection.test.ts` | `capabilities.agents.manifestRuntime.supported` + `capabilities.memory.supported` (RFC 0080 §C, `agent-memory.md`) | A (the §C iff-contract on the NORMATIVE `GET /v1/agents`: a degraded entry MUST carry `memoryDegraded:true` + a non-empty, unique `degradedMemoryDimensions[]` drawn from the closed §A-name enum [read/write/search/long-term-durability/compaction/attribution/replay-snapshot/retention]; a non-degraded entry MUST NOT carry a non-empty list; the inventory is non-empty; the degraded branch runs non-vacuously when `OPENWOP_DEGRADED_AGENT_ID` names a known-degraded agent) | `host-pending` | `behaviorGate('openwop-memory-degraded', …)`. Black-box on the normative path (no POST seam); soft-skips on 404 / when the host computes no degradation. **This is the RFC 0080 → Accepted bar.** First adopter: MyndHyve `memory`. |
150
150
  | `budget-enforcement.test.ts` | `capabilities.budget.supported` (RFC 0084 §C/§D, `budget-policy.md`) + `SECURITY/invariants.yaml` `budget-no-pricing-leak` | A (the §C/§D enforcement via `POST /v1/host/sample/budget/run` + the test event-log seam: a `hard-cost-exhaust` run emits the strict-ordered `budget.reserved → budget.consumed → budget.threshold.crossed{percent} → budget.exhausted → cap.breached{kind:"budget-cost"} → run.failed{error:"budget_exhausted"}` chain; a `model-denied` run is refused `budget_model_denied` BEFORE the provider call (fail-closed); an `advisory` host emits the `budget.*` events without stopping; every `budget.*` payload content-free — no pricing/rate) | `host-pending` | `behaviorGate('openwop-budget-enforcement', …)`. Seam-gated; soft-skips on 404. **This is the RFC 0084 → Accepted bar.** First adopter: MyndHyve `budget`. |
151
151
  | `agent-platform-aggregate-evidence.test.ts` | `openwop-agent-platform` claim — live discovery `profiles[]` includes it (RFC 0085 §C, `agent-platform-profile.md`) | A (the §C/§D honest-advertisement on live `/.well-known/openwop`: a host claiming `openwop-agent-platform` MUST satisfy the §B floor predicate (`isAgentPlatformPartial` → `partial`/`full`, never `none`), the claim backed by per-capability evidence not the profile string; `OPENWOP_AGENT_PLATFORM_TIER=full` forces the full-predicate bar — all governance terms + tenant installScope + all 16 §D terms) | `host-pending` | `behaviorGate('openwop-agent-platform', …)`. Black-box on the discovery doc (no POST seam); soft-skips until a host claims the profile. **This is the RFC 0085 → Accepted bar.** First adopter: MyndHyve (after the memory batch surfaces the floor's `memory.supported`). |
package/dist/cli.js CHANGED
@@ -41,6 +41,7 @@ import Ajv2020 from 'ajv/dist/2020.js';
41
41
  import addFormats from 'ajv-formats';
42
42
  import { SCHEMAS_DIR } from './lib/paths.js';
43
43
  import { readLedgerFile } from './lib/requirement-ledger.js';
44
+ import { deriveRung, emittedByNewerSuite } from './lib/durability-evidence.js';
44
45
  import { deriveRequirementDispositions } from './lib/scenario-disposition.js';
45
46
  import { scrubEvidence, evidenceSecretsFromEnv, verifyBundleV2 } from './lib/certification-bundle-verify.js';
46
47
  import { publicKeyFromPrivate, signBundleV3, verifierSign, verifyBundleV3, witnessDigest } from './lib/certification-bundle-v3.js';
@@ -619,7 +620,15 @@ async function runCertify(args, baseUrl, apiKey) {
619
620
  process.stderr.write('openwop-conformance --certify: a v3 bundle needs --host-build <kind>:<id> (or OPENWOP_HOST_BUILD), --signing-key <pem> (or OPENWOP_BUNDLE_SIGNING_KEY) and --signing-key-id (or OPENWOP_BUNDLE_SIGNING_KEY_ID) — an unsigned bundle does not exist in v3 (RFC 0168 §E.2).\n');
620
621
  process.exit(2);
621
622
  }
622
- const rows3 = derived.requirements.map((r) => ({ id: r.requirementId, scenario: r.scenarioId, result: r.disposition, ...(r.assertionCount === undefined ? {} : { assertions: r.assertionCount }), ...(r.detail === undefined ? {} : { detail: r.detail }) }));
623
+ const evidenceById = new Map();
624
+ for (const e of ledgerEntries)
625
+ if (e.evidence !== undefined && e.disposition === 'executed-pass')
626
+ evidenceById.set(e.requirementId, e.evidence);
627
+ const rows3 = derived.requirements.map((r) => ({ id: r.requirementId, scenario: r.scenarioId, result: r.disposition, ...(r.assertionCount === undefined ? {} : { assertions: r.assertionCount }), ...(r.detail === undefined ? {} : { detail: r.detail }),
628
+ // RFC 0158 §E: structured evidence rides on the ROW, so the witness digest —
629
+ // and through it the signature — covers it. Lifted from the raw ledger by
630
+ // requirement id, and only onto a row that is itself `executed-pass`.
631
+ ...(r.disposition === 'executed-pass' && evidenceById.has(r.requirementId) ? { evidence: evidenceById.get(r.requirementId) } : {}) }));
623
632
  const totals3 = derived.totals;
624
633
  const doc3 = document;
625
634
  const protocolVersions = Array.isArray(doc3['protocolVersions']) ? doc3['protocolVersions'] : [String(doc3['protocolVersion'] ?? '')];
@@ -672,6 +681,10 @@ async function runCertify(args, baseUrl, apiKey) {
672
681
  const lockPath = resolvePath(conformanceRoot, 'dist', 'spec-artifacts.lock.json');
673
682
  const lock = existsSync(lockPath) ? JSON.parse(readFileSync(lockPath, 'utf8')) : undefined;
674
683
  const nonPass = rows3.filter((r) => r.result !== 'executed-pass');
684
+ const rung3 = deriveRung(rows3);
685
+ if (rows3.some((r) => r.id.startsWith('openwop.requirement.0158.') && r.result === 'executed-pass' && r.id !== 'openwop.requirement.0158.poison-exhaustion')) {
686
+ process.stderr.write(`openwop-conformance --certify: RFC 0158 rung — ${rung3.rung ?? 'NONE'} (${rung3.why})\n`);
687
+ }
675
688
  const unsigned = {
676
689
  bundleVersion: '3',
677
690
  generatedAt: new Date().toISOString(),
@@ -687,6 +700,10 @@ async function runCertify(args, baseUrl, apiKey) {
687
700
  witnessSha256: witnessDigest(rows3),
688
701
  assertionCount: rows3.reduce((n, r) => n + (r.assertions ?? 0), 0),
689
702
  ...(nonPass.length ? { detail: { nonPass: nonPass.map((r) => ({ id: r.id, result: r.result, reason: r.detail ?? '' })) } } : {}),
703
+ // RFC 0158 §D: claimed ONLY when these rows support it. The verifier
704
+ // re-derives the same answer from the same signed rows, so an emitter that
705
+ // claimed more would be writing a bundle its own `--verify` rejects.
706
+ ...(rung3.rung === null ? {} : { durability: { rung: rung3.rung } }),
690
707
  };
691
708
  const signature = signBundleV3(unsigned, signingKeyPem, keyId);
692
709
  const v3 = { ...unsigned, signature };
@@ -889,6 +906,8 @@ async function main() {
889
906
  `suite: ${bundle.suite?.version ?? '?'} (this CLI is ${suiteVersion()})`,
890
907
  `totals: executedPass=${t.executedPass ?? '?'} executedFail=${t.executedFail ?? '?'} blocked=${t.blocked ?? '?'} inapplicable=${t.inapplicable ?? '?'} skipped=${t.skipped ?? '?'}`,
891
908
  `certified: ${verdict.certifiedProfiles.length > 0 ? verdict.certifiedProfiles.join(', ') : '(none)'}`,
909
+ // RFC 0158 §D/§E: the rung is a CLAIM; a claim the signed rows do not support is a rejection below.
910
+ `rung: ${bundle.durability?.rung ?? '(none claimed)'}`,
892
911
  '',
893
912
  'What this command does NOT do:',
894
913
  ' · It does not re-run anything. A host that measured itself wrongly, and signed',
@@ -899,6 +918,9 @@ async function main() {
899
918
  ' an older bundle measured less, and whose fact that is belongs to its emitter.',
900
919
  '',
901
920
  ];
921
+ if (emittedByNewerSuite(bundle.suite?.version, suiteVersion())) {
922
+ out.push(`NOTE \u2014 this bundle was emitted by suite ${String(bundle.suite?.version)}, NEWER than this verifier (${suiteVersion()}).`, ' A newer suite may digest row members this verifier does not know. If a `witness-digest`', ' rejection follows, upgrade the verifier before reading it as tampering.', '');
923
+ }
902
924
  if (verdict.rejections.length > 0) {
903
925
  out.push(`REJECTED \u2014 ${verdict.rejections.length} problem(s):`);
904
926
  for (const r of verdict.rejections)
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import { createHash, createPrivateKey, createPublicKey, sign as edSign, verify as edVerify } from 'node:crypto';
21
21
  import { profileDerivable } from './profiles.js';
22
+ import { checkRungClaim } from './durability-evidence.js';
22
23
  import { profilesDeniedByObservedRelaxation, profilesRelaxedBy, v2RegistryAvailable } from './v2-profiles.js';
23
24
  export const SIGNATURE_OVER = ['witnessSha256', 'host.build', 'suite.version', 'discovery.sha256'];
24
25
  /** Deterministic JSON: keys sorted at every level, no whitespace. */
@@ -32,7 +33,9 @@ export function canonicalJSON(value) {
32
33
  }
33
34
  /** RFC 0148 §C — the digest over the reporter record (the requirement rows). */
34
35
  export function witnessDigest(rows) {
35
- const canonicalRows = [...rows].sort((a, b) => a.id.localeCompare(b.id)).map((r) => ({ id: r.id, scenario: r.scenario, result: r.result, ...(r.assertions === undefined ? {} : { assertions: r.assertions }), ...(r.detail === undefined ? {} : { detail: r.detail }) }));
36
+ const canonicalRows = [...rows].sort((a, b) => a.id.localeCompare(b.id)).map((r) => ({ id: r.id, scenario: r.scenario, result: r.result, ...(r.assertions === undefined ? {} : { assertions: r.assertions }), ...(r.detail === undefined ? {} : { detail: r.detail }),
37
+ // ONLY WHEN PRESENT: every bundle cut before 2.34.0 has no `evidence` and digests byte-identically.
38
+ ...(r.evidence === undefined ? {} : { evidence: r.evidence }) }));
36
39
  return createHash('sha256').update(canonicalJSON(canonicalRows), 'utf8').digest('hex');
37
40
  }
38
41
  /** The bytes the attestation covers. */
@@ -120,6 +123,11 @@ export function verifyBundleV3(bundle, opts = {}) {
120
123
  const relaxed = new Set((bundle.host?.relaxations ?? []).map((r) => r.obligation.split('.')[0]));
121
124
  // Ownership comes from the profile registry, not from the profile's name — see profilesRelaxedBy.
122
125
  const relaxedProfiles = profilesRelaxedBy((bundle.host?.relaxations ?? []).map((r) => r.obligation), (bundle.claimedProfiles ?? []).map((p) => p.id));
126
+ // RFC 0158 §D: a rung is CLAIMED at the top level, outside the signature, and
127
+ // is therefore only ever as good as its re-derivation from the signed rows.
128
+ const rung = checkRungClaim(bundle.durability?.rung, rows);
129
+ if (!rung.ok)
130
+ rejections.push({ kind: 'rung-not-derivable', detail: rung.detail });
123
131
  // An OBSERVED relaxation nobody declared: the bundle's own results show the
124
132
  // host accepting a destination its egress guard MUST refuse. Same denial as a
125
133
  // declared one, so declaring nothing is not a way around the rule.
@@ -0,0 +1,182 @@
1
+ /**
2
+ * RFC 0158 §E — the rung and the recovery bound, IN THE BUNDLE.
3
+ *
4
+ * §E.10 mints no discovery capability: "a qualification ladder is evidence about
5
+ * behaviour that already exists, so the useful place for a rung and a recovery
6
+ * bound is the host's conformance evidence bundle — where a claim without
7
+ * evidence is already a defect." Until 2.34.0 that sentence had nothing behind
8
+ * it. `certification-bundle.schema.json` had no seat for a rung, a bound or its
9
+ * terms; a bundle showed five pass/fail rows, and a reader could neither tell
10
+ * which rung was claimed nor recompute the bound. The terms lived only on a
11
+ * non-normative seam route. Found by READING acceptance criterion 1 at the
12
+ * moment of flipping the RFC, with every row already green.
13
+ *
14
+ * ── Why the evidence rides on ROWS ───────────────────────────────────────────
15
+ * The attestation covers exactly `{ witnessSha256, host.build, suite.version,
16
+ * discovery.sha256 }` (`conformance.md` §Bundle v3), and `witnessSha256` digests
17
+ * the requirement rows. A top-level block would therefore be UNSIGNED — a bound
18
+ * or a rung editable after signing on a bundle that still verifies. Evidence
19
+ * carried on a row is inside the witness digest, and so inside the signature.
20
+ * It enters the digest ONLY WHEN PRESENT, so every bundle cut before 2.34.0
21
+ * digests byte-identically and still verifies (pinned against the three
22
+ * committed bundles in `durability-evidence.test.ts`).
23
+ *
24
+ * ── Why the rung claim is NOT trusted ────────────────────────────────────────
25
+ * `durability.rung` sits at the top level, outside the signature, and that is
26
+ * sound for the same reason `claimedProfiles[].certified` is: the verifier
27
+ * RE-DERIVES it from signed rows and rejects a claim it cannot derive. §D: "a
28
+ * host MAY claim a rung only with the evidence named for it."
29
+ *
30
+ * ── Why a kill row names its recovery CLASS ──────────────────────────────────
31
+ * Unresolved Question 1 resolved PER WORK CLASS, not a scalar. A host with an
32
+ * outbox lane (65 s) and a dispatch lease (750 s) has two bounds, so each kill
33
+ * row records `{ class, boundMs, observedMs }`: the class MUST name a declared
34
+ * entry, `boundMs` MUST equal that entry's `bound`, and `observedMs` MUST NOT
35
+ * exceed it.
36
+ *
37
+ * WHAT THAT CATCHES, and what it cannot. It refuses an undeclared class, a bound
38
+ * label that disagrees with its class, and a resumption outside the bound. It
39
+ * does NOT catch a host that names the WRONG DECLARED class while resuming
40
+ * inside that class's (longer) bound — which a tier-1 host actually shipped: the
41
+ * seam killed before the execution claim was held, the 65 s lane rescued the run
42
+ * in 11.6 s, and the exercise was labelled leased / 750 s, every row green. No
43
+ * arithmetic separates that from a fast leased recovery. What the evidence
44
+ * changes is that it becomes VISIBLE — 11.6 s recorded against a class whose own
45
+ * terms say 720 s of lease — where before nothing was recorded at all. Killing
46
+ * only once the claim is held stays the HOST's obligation (§E).
47
+ *
48
+ * `class` is an OPAQUE host-chosen string, never an enum: "leased" / "unleased"
49
+ * name one host's mechanisms and boot re-entry is another's. An enum would
50
+ * select for an architecture, which this RFC refuses to do everywhere else.
51
+ * There is NO scalar "overall bound": that is UQ1's lie by aggregation with a
52
+ * friendlier name, and a reader would use it.
53
+ *
54
+ * What this block proves: ARITHMETIC (Σ terms = bound) and, through the kill
55
+ * rows' `observedMs`, that the mechanism RAN at least once inside the bound.
56
+ * `bound-is-derived` alone remains a paper check and MUST NOT be cited for
57
+ * liveness.
58
+ */
59
+ /** Host-chosen identifiers enter a PUBLISHED bundle: short, plain, no free text. */
60
+ export const EVIDENCE_NAME_PATTERN = /^[a-z][A-Za-z0-9._-]{0,63}$/;
61
+ export const MAX_RECOVERY_CLASSES = 16;
62
+ export const MAX_TERMS_PER_CLASS = 16;
63
+ export const RUNGS = ['durable-single-instance', 'durable-multi-instance', 'multi-region-qualified'];
64
+ const R = 'openwop.requirement.0158.';
65
+ /** The rows §Acceptance names for the lowest rung. */
66
+ export const SINGLE_INSTANCE_ROWS = ['kill-after-accept', 'kill-during-execution', 'duplicate-delivery', 'poison-exhaustion', 'bound-is-derived'].map((r) => R + r);
67
+ const KILL_ROWS = [R + 'kill-after-accept', R + 'kill-during-execution'];
68
+ const BOUND_ROW = R + 'bound-is-derived';
69
+ const isCount = (n) => typeof n === 'number' && Number.isInteger(n) && n >= 0;
70
+ const isName = (s) => typeof s === 'string' && EVIDENCE_NAME_PATTERN.test(s);
71
+ /** Normalise what a host's seam returned into bundle evidence, or say exactly why it cannot be. */
72
+ export function parseRecoveryBounds(raw) {
73
+ if (!Array.isArray(raw) || raw.length === 0)
74
+ return { ok: false, why: 'no recovery classes were declared' };
75
+ if (raw.length > MAX_RECOVERY_CLASSES)
76
+ return { ok: false, why: `more than ${MAX_RECOVERY_CLASSES} recovery classes` };
77
+ const bounds = [];
78
+ const seen = new Set();
79
+ for (const entry of raw) {
80
+ const cls = entry?.['class'];
81
+ if (!isName(cls))
82
+ return { ok: false, why: `a recovery class name does not match ${String(EVIDENCE_NAME_PATTERN)}` };
83
+ if (seen.has(cls))
84
+ return { ok: false, why: `recovery class ${cls} is declared twice` };
85
+ seen.add(cls);
86
+ const terms = entry['terms'];
87
+ if (!Array.isArray(terms) || terms.length === 0 || terms.length > MAX_TERMS_PER_CLASS)
88
+ return { ok: false, why: `class ${cls}: terms[] MUST carry 1..${MAX_TERMS_PER_CLASS} entries — a total alone cannot be recomputed` };
89
+ const clean = [];
90
+ for (const t of terms) {
91
+ if (!isName(t?.['name']) || !isCount(t['ms']))
92
+ return { ok: false, why: `class ${cls}: every term is { name, ms } with ms a non-negative integer` };
93
+ clean.push({ name: t['name'], ms: t['ms'] });
94
+ }
95
+ if (!isCount(entry['bound']))
96
+ return { ok: false, why: `class ${cls}: bound is a non-negative integer of milliseconds` };
97
+ const sum = clean.reduce((a, t) => a + t.ms, 0);
98
+ if (sum !== entry['bound'])
99
+ return { ok: false, why: `class ${cls}: terms sum to ${sum} ms but the declared bound is ${String(entry['bound'])} ms — a host that states a bound its terms do not produce fails §B.5` };
100
+ bounds.push({ class: cls, bound: entry['bound'], terms: clean });
101
+ }
102
+ return { ok: true, bounds };
103
+ }
104
+ /**
105
+ * The highest rung these SIGNED rows support, and — when it is lower than a
106
+ * reader might hope — the first reason why. Only `durable-single-instance` is
107
+ * derivable today: `peer-resume` is bundle-witnessed by a per-boot incarnation
108
+ * token this revision does not yet carry (§E), so a higher claim is REFUSED
109
+ * rather than waved through.
110
+ */
111
+ export function deriveRung(rows) {
112
+ const byId = new Map(rows.map((r) => [r.id, r]));
113
+ for (const id of SINGLE_INSTANCE_ROWS) {
114
+ const row = byId.get(id);
115
+ if (row === undefined)
116
+ return { rung: null, why: `${id} is absent from the bundle` };
117
+ if (row.result !== 'executed-pass')
118
+ return { rung: null, why: `${id} is ${row.result}, not executed-pass` };
119
+ }
120
+ const declared = parseRecoveryBounds(byId.get(BOUND_ROW)?.evidence?.recoveryBounds);
121
+ if (!declared.ok)
122
+ return { rung: null, why: `${BOUND_ROW} carries no usable evidence.recoveryBounds — ${declared.why}` };
123
+ const bounds = new Map(declared.bounds.map((b) => [b.class, b.bound]));
124
+ for (const id of KILL_ROWS) {
125
+ const obs = byId.get(id)?.evidence?.recovery;
126
+ if (obs === undefined)
127
+ return { rung: null, why: `${id} passed but recorded no evidence.recovery — the class it exercised and the interval it observed are unknown` };
128
+ if (!isName(obs.class) || !isCount(obs.boundMs) || !isCount(obs.observedMs))
129
+ return { rung: null, why: `${id}: evidence.recovery is malformed` };
130
+ const bound = bounds.get(obs.class);
131
+ if (bound === undefined)
132
+ return { rung: null, why: `${id} exercised recovery class "${obs.class}", which ${BOUND_ROW} does not declare (declared: ${[...bounds.keys()].join(', ')})` };
133
+ if (obs.boundMs !== bound)
134
+ return { rung: null, why: `${id} was judged against ${obs.boundMs} ms but class "${obs.class}" declares ${bound} ms — the exercise was labelled with a bound that does not govern it` };
135
+ if (obs.observedMs > bound)
136
+ return { rung: null, why: `${id} resumed after ${obs.observedMs} ms, outside the ${bound} ms bound of class "${obs.class}"` };
137
+ }
138
+ return { rung: 'durable-single-instance', why: 'all five rows executed-pass; each kill row names a declared class and resumed inside its bound' };
139
+ }
140
+ /** Is the rung a bundle CLAIMS supported by its own signed rows? */
141
+ export function checkRungClaim(claimed, rows) {
142
+ if (claimed === undefined)
143
+ return { ok: true };
144
+ if (typeof claimed !== 'string' || !RUNGS.includes(claimed))
145
+ return { ok: false, detail: `durability.rung ${JSON.stringify(claimed)} is not one of ${RUNGS.join(' | ')}` };
146
+ const derived = deriveRung(rows);
147
+ if (derived.rung === null)
148
+ return { ok: false, detail: `durability.rung claims ${claimed}, but the signed rows do not support any rung: ${derived.why}` };
149
+ if (claimed !== derived.rung)
150
+ return { ok: false, detail: `durability.rung claims ${claimed}, but the signed rows support only ${derived.rung} — a higher rung is bundle-witnessed by evidence this revision does not yet carry (RFC 0158 §E, peer-resume), so it is refused rather than assumed` };
151
+ return { ok: true };
152
+ }
153
+ /**
154
+ * Was this bundle cut by a NEWER suite than the verifier reading it?
155
+ *
156
+ * Row evidence enters the witness digest, so a verifier older than 2.34.0 that
157
+ * meets a bundle carrying it recomputes a different digest and reports
158
+ * `witness-digest` — it FAILS CLOSED, which is right, but the message reads as
159
+ * tampering when the truth is "upgrade the verifier". Every later digested
160
+ * member will repeat that. The notice is advice; it never changes a verdict.
161
+ */
162
+ export function emittedByNewerSuite(bundleSuite, verifierSuite) {
163
+ const parse = (v) => {
164
+ const m = typeof v === 'string' ? /^(\d+)\.(\d+)\.(\d+)/.exec(v) : null;
165
+ return m === null ? null : [Number(m[1]), Number(m[2]), Number(m[3])];
166
+ };
167
+ const b = parse(bundleSuite);
168
+ const mine = parse(verifierSuite);
169
+ if (b === null || mine === null)
170
+ return false;
171
+ for (let i = 0; i < 3; i++) {
172
+ if (b[i] !== mine[i])
173
+ return b[i] > mine[i];
174
+ }
175
+ return false;
176
+ }
177
+ // ── the scenario → setup.ts → ledger channel ─────────────────────────────────
178
+ let pending = null;
179
+ /** Called by a scenario inside an `it`; setup.ts attaches it to that test's ledger row. Later notes merge. */
180
+ export function noteEvidence(evidence) { pending = { ...(pending ?? {}), ...evidence }; }
181
+ /** setup.ts reads and clears it after each test. */
182
+ export function takeNotedEvidence() { const e = pending; pending = null; return e; }
@@ -64,6 +64,7 @@ export function recordRequirement(requirementId, disposition, detail, extras) {
64
64
  ...(detail === undefined ? {} : { detail }),
65
65
  ...(extras?.assertionCount === undefined ? {} : { assertionCount: extras.assertionCount }),
66
66
  ...(extras?.scenarioFile === undefined ? {} : { scenarioFile: extras.scenarioFile }),
67
+ ...(extras?.evidence === undefined || disposition !== 'executed-pass' ? {} : { evidence: extras.evidence }),
67
68
  };
68
69
  ledger.set(requirementId, entry);
69
70
  journal.push(entry);
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "package": "@openwop/spec-artifacts",
3
- "version": "2.33.2",
4
- "stampSha256": "005732c466b247dadf5ee3d8c68186c6ce4f586febbaa260b7906fd496153965"
3
+ "version": "2.34.0",
4
+ "stampSha256": "68cccf1a2b742ba8666acfa944d92ef209dd5cef3a7b067b06a5d36dbac31d03"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.33.2",
3
+ "version": "2.34.0",
4
4
  "description": "Production-ready black-box conformance suite for OpenWOP v1.0 compliant servers.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -56,6 +56,6 @@
56
56
  "@openwop/spec-artifacts": "file:../spec-artifacts"
57
57
  },
58
58
  "peerDependencies": {
59
- "@openwop/spec-artifacts": "2.33.2"
59
+ "@openwop/spec-artifacts": "2.34.0"
60
60
  }
61
61
  }
package/requirements.json CHANGED
@@ -26650,7 +26650,7 @@
26650
26650
  {
26651
26651
  "id": "openwop.it.v2-durability-recovery.accepted-work-survives-a-kill-before-dispatch-and-dispatches-on-resume",
26652
26652
  "file": "v2-durability-recovery.test.ts",
26653
- "line": 254,
26653
+ "line": 283,
26654
26654
  "title": "accepted work survives a kill before dispatch and dispatches on resume",
26655
26655
  "explicitId": "openwop.requirement.0158.kill-after-accept",
26656
26656
  "citations": [
@@ -26664,7 +26664,7 @@
26664
26664
  {
26665
26665
  "id": "openwop.it.v2-durability-recovery.work-executing-at-a-real-process-death-is-never-reported-complete-and-resumes",
26666
26666
  "file": "v2-durability-recovery.test.ts",
26667
- "line": 299,
26667
+ "line": 337,
26668
26668
  "title": "work executing at a real process death is never reported complete, and resumes",
26669
26669
  "explicitId": "openwop.requirement.0158.kill-during-execution",
26670
26670
  "citations": [
@@ -26683,7 +26683,7 @@
26683
26683
  {
26684
26684
  "id": "openwop.it.v2-durability-recovery.the-same-accepted-work-delivered-twice-fires-each-effect-exactly-once",
26685
26685
  "file": "v2-durability-recovery.test.ts",
26686
- "line": 346,
26686
+ "line": 393,
26687
26687
  "title": "the same accepted work delivered twice fires each effect exactly once",
26688
26688
  "explicitId": "openwop.requirement.0158.duplicate-delivery",
26689
26689
  "citations": [
@@ -26702,7 +26702,7 @@
26702
26702
  {
26703
26703
  "id": "openwop.it.v2-durability-recovery.the-declared-recovery-bound-is-derived-from-the-mechanism-that-enforces-it",
26704
26704
  "file": "v2-durability-recovery.test.ts",
26705
- "line": 420,
26705
+ "line": 467,
26706
26706
  "title": "the declared recovery bound is derived from the mechanism that enforces it",
26707
26707
  "explicitId": "openwop.requirement.0158.bound-is-derived",
26708
26708
  "citations": [
@@ -26716,7 +26716,7 @@
26716
26716
  {
26717
26717
  "id": "openwop.it.v2-durability-recovery.deterministically-failing-work-reaches-a-terminal-state-and-stops-being-retried",
26718
26718
  "file": "v2-durability-recovery.test.ts",
26719
- "line": 450,
26719
+ "line": 498,
26720
26720
  "title": "deterministically failing work reaches a terminal state and stops being retried",
26721
26721
  "explicitId": "openwop.requirement.0158.poison-exhaustion",
26722
26722
  "citations": [
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "_comment": "Provenance of @openwop/spec-artifacts (RFC 0168 §D.2). files: SHA-256 per file; the conformance suite compares the installed peer against dist/spec-artifacts.lock.json at start.",
3
3
  "package": "@openwop/spec-artifacts",
4
- "version": "2.33.2",
5
- "corpusTag": "v2.33.0",
4
+ "version": "2.34.0",
5
+ "corpusTag": "v2.34.0",
6
6
  "files": {
7
7
  "api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
8
8
  "api/asyncapi.yaml": "d5ecb9ee6114582be3b1f662c84bfac9ae96dae7bacb853e461168f70a8e1c7d",
9
9
  "api/grpc/openwop.proto": "c3e72bb17cba514ee98feb6434e6c9b6ea6795bfd086489ec69fd882dd1ad977",
10
10
  "api/openapi.yaml": "39081c59fb696159806b0f2f9a42e7e9ff830d622fcf2ad4159b21357580a955",
11
11
  "api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
12
- "api/seams-v2.yaml": "975b6cd87fa679857df9fcdc60b6a2bed98510b831e82a5eff5a114e3b263f6e",
13
- "api/v2/asyncapi.yaml": "96ba13176119bbf54a94bd41d789ebaa919f67c81d529bdc3c66ffb3f419ee36",
14
- "api/v2/openapi.yaml": "428ab881d0a8d08d388dd89d4b4457f3454cd0433b665674180d3e85821b978d",
12
+ "api/seams-v2.yaml": "d26d906b044e53fbb6ba742be90c853b9dcf14af4005bb6376e354d618fbdd5a",
13
+ "api/v2/asyncapi.yaml": "eda3679b6902a3ab931e2dab55a04f6b6bb2b9b664748de38d8a104e53e93c76",
14
+ "api/v2/openapi.yaml": "313fc72043a3730fbb98fdf47f4539773564bf9c5bed54f1f180efb50cf57410",
15
15
  "api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
16
16
  "schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
17
17
  "schemas/a2a-task-state.schema.json": "c9365918f993f943b4b619d42551eb066a1ed33a08d895d51b395432a5b1f1bc",
@@ -116,7 +116,7 @@
116
116
  "schemas/v2/audit-verify-result.schema.json": "a748dfadb5155adfde8a91045e76b0db6695005fe559035d71670fdd55edb3ba",
117
117
  "schemas/v2/budget-policy.schema.json": "c7449daeb6e1d95e7a047b8c2814d3d54748697c27046972834874867d9b5f4c",
118
118
  "schemas/v2/capabilities.schema.json": "e62d44f23c21c8f64b273fcdb083636e801c0de976cbd1f93333f99928dd1025",
119
- "schemas/v2/certification-bundle.schema.json": "ab9204e37f9e0bba79857275cd38be2da98e54d26b4ecb97469829efe6ebea28",
119
+ "schemas/v2/certification-bundle.schema.json": "46a5a07670f2457bf1b7af8f6df1f4f76edb3ff7e64d1b6ed85738270c95668e",
120
120
  "schemas/v2/channel-presence-payload.schema.json": "1c20ad810cb311d47aa0b95d928490826b2468fd161f884cd29e867053e6782f",
121
121
  "schemas/v2/channel-written-payload.schema.json": "ccecff3c71a3275ad8db035ac0e04db25e6f8d23cf091ddbcd5a60d1be1cbb6b",
122
122
  "schemas/v2/chat-card-pack-manifest.schema.json": "f7cf09b30d3e1d2251620d84ea9f65541438d015ab44aec10d1dcfe68d8bd496",
@@ -201,7 +201,7 @@
201
201
  "schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
202
202
  "spec/v1/alias-detectors.json": "40069d5976eeb6ba1384a648e57e5cfd673db3fce36175115fb8293bee9664d4",
203
203
  "spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
204
- "spec/v1/core-standard-manifest.json": "65a8576c2e5ea9deb9d463be5b02a8bfb0e125a8dcbe3fe2cf286a4fd6d5d53e",
204
+ "spec/v1/core-standard-manifest.json": "fce628a67bffe5923a01fd1bda89c44dc1fb5a679a609079b89030b5da7f116c",
205
205
  "spec/v1/deprecations.json": "307083ce29c23fd406015951f99a30d78d6187ff061d38dc9732f62191b40f3f",
206
206
  "spec/v1/deprecations.schema.json": "18c87e78bedc210431f795ae44c5b5d202f2f3317850d5cf86867d4f1fa1cdfb",
207
207
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
@@ -215,7 +215,7 @@
215
215
  "spec/v1/spec-gaps.json": "eb3bfcb9c7d05c9a6845d4a40ec9493cfaa5a671932daf1af561310136fdabe8",
216
216
  "spec/v2/README.md": "8dbfc17b10f373dba95bdd2e8fec0580f35b380a834d304be3e24e7498170185",
217
217
  "spec/v2/core/capabilities.md": "4c4e5577caf4ae20c84fe59d580de4c6599e76783471d3b59f9730fc3807de71",
218
- "spec/v2/core/conformance.md": "a451c0a2ba79d93c55e540ccef480eba7de9602d59bbb03eead05daafde0beac",
218
+ "spec/v2/core/conformance.md": "e5cb403f6ea6633eebc8ee4fa7bde5bf023bf4aee7c959b849e7105d37ab1f8b",
219
219
  "spec/v2/core/connection-packs.md": "466bdb9dde79d85615ad8281dacfb7bf923ca22d7fb3ed219749e59574c753ed",
220
220
  "spec/v2/core/conversation.md": "e425887c4ba199b8cdc5c99e792689f7bd7d46de492f28670de2e402fa6d9506",
221
221
  "spec/v2/core/errors.md": "0dff4a0ae102a86f6a17a9a3b16a203292588a0f422ed19d1c0d9213995aa01d",
@@ -229,7 +229,7 @@
229
229
  "spec/v2/core/interrupt.md": "3a926d84b942d4ee7daccc4443c93dc29f6a69f2936ba813182f2c1336db2cbb",
230
230
  "spec/v2/core/overview.md": "7dc00ace32d4e4ce929a383651f0029fc4b7091ec87487b1bf26ead1be209e0e",
231
231
  "spec/v2/core/packs.md": "1c0919059760f9a3940a93264a76b7e30f9a0f6f27ff19753cc88f15753d8088",
232
- "spec/v2/core/persistence.md": "9e241f84259e38848f4a2ad1c21debb0dffd5f8aafbad26408aa6cc8969bf93b",
232
+ "spec/v2/core/persistence.md": "b4f37792dddc651ec554ef92ce873c4fa14ad62be4500547a5078863609e16e5",
233
233
  "spec/v2/core/replay.md": "b980a66e2543107240e3dc4f58131b9f4ff52478737e79ade6d5777b26ad1119",
234
234
  "spec/v2/core/runs.md": "136319842bacbbb300178980e4b83444917d5b193cc5fdd92e39a4902ba8d3c0",
235
235
  "spec/v2/core/security-defaults.md": "2a54ceaa4cef02c26ee0fbcf44f822ceeea79537c7d5a51ed300f21aa4fea576",
@@ -279,8 +279,8 @@
279
279
  "spec/v2/path-manifest.json": "c123c9fd77dc1b3f9c2e9346ace80cdae007e6511138894956abda01e61ec1c6",
280
280
  "spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
281
281
  "spec/v2/profiles.json": "0636f19fceae625390003a347e70ef4797d84766b5c24ce8a02cea52aadebca4",
282
- "spec/v2/release.json": "94a3ea302324d9f7df87ce62a186b0b98936de6ce5ac5ec01a3fb3208c38efc3",
282
+ "spec/v2/release.json": "eaf652e3a01f02a1d9c4eee7d7ea2c2a0396fe5694b5dce4cb01ca2af2891cc5",
283
283
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624"
284
284
  },
285
- "corpusCommit": "65e40d60fbf43b1a0cd95a58e63d7464ed944ddb"
285
+ "corpusCommit": "9ccc722c0600fc4b01a2a80bad95c9a1a4a88ee5"
286
286
  }
package/src/cli.ts CHANGED
@@ -42,6 +42,7 @@ import Ajv2020 from 'ajv/dist/2020.js';
42
42
  import addFormats from 'ajv-formats';
43
43
  import { SCHEMAS_DIR } from './lib/paths.js';
44
44
  import { readLedgerFile } from './lib/requirement-ledger.js';
45
+ import { deriveRung, emittedByNewerSuite, type RowEvidence } from './lib/durability-evidence.js';
45
46
  import { deriveRequirementDispositions } from './lib/scenario-disposition.js';
46
47
  import { scrubEvidence, evidenceSecretsFromEnv, verifyBundleV2 } from './lib/certification-bundle-verify.js';
47
48
  import { publicKeyFromPrivate, signBundleV3, verifierSign, verifyBundleV3, witnessDigest, type BundleV3, type BundleV3Requirement } from './lib/certification-bundle-v3.js';
@@ -658,7 +659,13 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
658
659
  process.stderr.write('openwop-conformance --certify: a v3 bundle needs --host-build <kind>:<id> (or OPENWOP_HOST_BUILD), --signing-key <pem> (or OPENWOP_BUNDLE_SIGNING_KEY) and --signing-key-id (or OPENWOP_BUNDLE_SIGNING_KEY_ID) — an unsigned bundle does not exist in v3 (RFC 0168 §E.2).\n');
659
660
  process.exit(2);
660
661
  }
661
- const rows3: BundleV3Requirement[] = derived.requirements.map((r) => ({ id: r.requirementId, scenario: r.scenarioId, result: r.disposition as BundleV3Requirement['result'], ...(r.assertionCount === undefined ? {} : { assertions: r.assertionCount }), ...(r.detail === undefined ? {} : { detail: r.detail }) }));
662
+ const evidenceById = new Map<string, RowEvidence>();
663
+ for (const e of ledgerEntries) if (e.evidence !== undefined && e.disposition === 'executed-pass') evidenceById.set(e.requirementId, e.evidence);
664
+ const rows3: BundleV3Requirement[] = derived.requirements.map((r) => ({ id: r.requirementId, scenario: r.scenarioId, result: r.disposition as BundleV3Requirement['result'], ...(r.assertionCount === undefined ? {} : { assertions: r.assertionCount }), ...(r.detail === undefined ? {} : { detail: r.detail }),
665
+ // RFC 0158 §E: structured evidence rides on the ROW, so the witness digest —
666
+ // and through it the signature — covers it. Lifted from the raw ledger by
667
+ // requirement id, and only onto a row that is itself `executed-pass`.
668
+ ...(r.disposition === 'executed-pass' && evidenceById.has(r.requirementId) ? { evidence: evidenceById.get(r.requirementId) as RowEvidence } : {}) }));
662
669
  const totals3 = derived.totals;
663
670
  const doc3 = document as Record<string, unknown>;
664
671
  const protocolVersions = Array.isArray(doc3['protocolVersions']) ? (doc3['protocolVersions'] as string[]) : [String(doc3['protocolVersion'] ?? '')];
@@ -701,6 +708,10 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
701
708
  const lockPath = resolvePath(conformanceRoot, 'dist', 'spec-artifacts.lock.json');
702
709
  const lock = existsSync(lockPath) ? (JSON.parse(readFileSync(lockPath, 'utf8')) as { version: string; stampSha256: string }) : undefined;
703
710
  const nonPass = rows3.filter((r) => r.result !== 'executed-pass');
711
+ const rung3 = deriveRung(rows3);
712
+ if (rows3.some((r) => r.id.startsWith('openwop.requirement.0158.') && r.result === 'executed-pass' && r.id !== 'openwop.requirement.0158.poison-exhaustion')) {
713
+ process.stderr.write(`openwop-conformance --certify: RFC 0158 rung — ${rung3.rung ?? 'NONE'} (${rung3.why})\n`);
714
+ }
704
715
  const unsigned: Omit<BundleV3, 'signature'> = {
705
716
  bundleVersion: '3',
706
717
  generatedAt: new Date().toISOString(),
@@ -716,6 +727,10 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
716
727
  witnessSha256: witnessDigest(rows3),
717
728
  assertionCount: rows3.reduce((n, r) => n + (r.assertions ?? 0), 0),
718
729
  ...(nonPass.length ? { detail: { nonPass: nonPass.map((r) => ({ id: r.id, result: r.result, reason: r.detail ?? '' })) } } : {}),
730
+ // RFC 0158 §D: claimed ONLY when these rows support it. The verifier
731
+ // re-derives the same answer from the same signed rows, so an emitter that
732
+ // claimed more would be writing a bundle its own `--verify` rejects.
733
+ ...(rung3.rung === null ? {} : { durability: { rung: rung3.rung } }),
719
734
  };
720
735
  const signature = signBundleV3(unsigned, signingKeyPem, keyId);
721
736
  const v3: BundleV3 = { ...unsigned, signature };
@@ -942,6 +957,8 @@ async function main(): Promise<never> {
942
957
  `suite: ${bundle.suite?.version ?? '?'} (this CLI is ${suiteVersion()})`,
943
958
  `totals: executedPass=${t.executedPass ?? '?'} executedFail=${t.executedFail ?? '?'} blocked=${t.blocked ?? '?'} inapplicable=${t.inapplicable ?? '?'} skipped=${t.skipped ?? '?'}`,
944
959
  `certified: ${verdict.certifiedProfiles.length > 0 ? verdict.certifiedProfiles.join(', ') : '(none)'}`,
960
+ // RFC 0158 §D/§E: the rung is a CLAIM; a claim the signed rows do not support is a rejection below.
961
+ `rung: ${bundle.durability?.rung ?? '(none claimed)'}`,
945
962
  '',
946
963
  'What this command does NOT do:',
947
964
  ' · It does not re-run anything. A host that measured itself wrongly, and signed',
@@ -952,6 +969,11 @@ async function main(): Promise<never> {
952
969
  ' an older bundle measured less, and whose fact that is belongs to its emitter.',
953
970
  '',
954
971
  ];
972
+ if (emittedByNewerSuite(bundle.suite?.version, suiteVersion())) {
973
+ out.push(`NOTE \u2014 this bundle was emitted by suite ${String(bundle.suite?.version)}, NEWER than this verifier (${suiteVersion()}).`,
974
+ ' A newer suite may digest row members this verifier does not know. If a `witness-digest`',
975
+ ' rejection follows, upgrade the verifier before reading it as tampering.', '');
976
+ }
955
977
  if (verdict.rejections.length > 0) {
956
978
  out.push(`REJECTED \u2014 ${verdict.rejections.length} problem(s):`);
957
979
  for (const r of verdict.rejections) out.push(` [${r.kind}]${r.profile ? ` (${r.profile})` : ''} ${r.detail}`);
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import { createHash, createPrivateKey, createPublicKey, sign as edSign, verify as edVerify, type KeyObject } from 'node:crypto';
21
21
  import { profileDerivable, type DiscoveryPayload } from './profiles.js';
22
+ import { checkRungClaim, type DurabilityRung, type RowEvidence } from './durability-evidence.js';
22
23
  import { profilesDeniedByObservedRelaxation, profilesRelaxedBy, v2RegistryAvailable } from './v2-profiles.js';
23
24
 
24
25
  export type BundleV3Result = 'executed-pass' | 'executed-fail' | 'skipped' | 'inapplicable' | 'blocked';
@@ -29,6 +30,8 @@ export interface BundleV3Requirement {
29
30
  readonly result: BundleV3Result;
30
31
  readonly assertions?: number;
31
32
  readonly detail?: string;
33
+ /** RFC 0158 §E — structured evidence. Inside the witness digest, hence inside the signature. */
34
+ readonly evidence?: RowEvidence;
32
35
  }
33
36
  export interface BundleV3Profile {
34
37
  readonly id: string;
@@ -67,6 +70,8 @@ export interface BundleV3 {
67
70
  witnessSha256: string;
68
71
  assertionCount: number;
69
72
  detail?: { nonPass: { id: string; result: string; reason: string }[] };
73
+ /** RFC 0158 §D — a CLAIM, outside the signature and never trusted: the verifier re-derives it from the signed rows. */
74
+ durability?: { rung: DurabilityRung };
70
75
  signature: BundleV3Signature;
71
76
  verifierSignature?: { alg: 'ed25519'; keyId: string; sig: string };
72
77
  }
@@ -84,7 +89,9 @@ export function canonicalJSON(value: unknown): string {
84
89
 
85
90
  /** RFC 0148 §C — the digest over the reporter record (the requirement rows). */
86
91
  export function witnessDigest(rows: readonly BundleV3Requirement[]): string {
87
- const canonicalRows = [...rows].sort((a, b) => a.id.localeCompare(b.id)).map((r) => ({ id: r.id, scenario: r.scenario, result: r.result, ...(r.assertions === undefined ? {} : { assertions: r.assertions }), ...(r.detail === undefined ? {} : { detail: r.detail }) }));
92
+ const canonicalRows = [...rows].sort((a, b) => a.id.localeCompare(b.id)).map((r) => ({ id: r.id, scenario: r.scenario, result: r.result, ...(r.assertions === undefined ? {} : { assertions: r.assertions }), ...(r.detail === undefined ? {} : { detail: r.detail }),
93
+ // ONLY WHEN PRESENT: every bundle cut before 2.34.0 has no `evidence` and digests byte-identically.
94
+ ...(r.evidence === undefined ? {} : { evidence: r.evidence }) }));
88
95
  return createHash('sha256').update(canonicalJSON(canonicalRows), 'utf8').digest('hex');
89
96
  }
90
97
 
@@ -181,6 +188,11 @@ export function verifyBundleV3(bundle: BundleV3, opts: VerifyV3Options = {}): V3
181
188
  const relaxed = new Set((bundle.host?.relaxations ?? []).map((r) => r.obligation.split('.')[0]));
182
189
  // Ownership comes from the profile registry, not from the profile's name — see profilesRelaxedBy.
183
190
  const relaxedProfiles = profilesRelaxedBy((bundle.host?.relaxations ?? []).map((r) => r.obligation), (bundle.claimedProfiles ?? []).map((p) => p.id));
191
+ // RFC 0158 §D: a rung is CLAIMED at the top level, outside the signature, and
192
+ // is therefore only ever as good as its re-derivation from the signed rows.
193
+ const rung = checkRungClaim(bundle.durability?.rung, rows);
194
+ if (!rung.ok) rejections.push({ kind: 'rung-not-derivable', detail: rung.detail });
195
+
184
196
  // An OBSERVED relaxation nobody declared: the bundle's own results show the
185
197
  // host accepting a destination its egress guard MUST refuse. Same denial as a
186
198
  // declared one, so declaring nothing is not a way around the rule.
@@ -0,0 +1,175 @@
1
+ /**
2
+ * RFC 0158 §E — the rung and the recovery bound, IN THE BUNDLE.
3
+ *
4
+ * §E.10 mints no discovery capability: "a qualification ladder is evidence about
5
+ * behaviour that already exists, so the useful place for a rung and a recovery
6
+ * bound is the host's conformance evidence bundle — where a claim without
7
+ * evidence is already a defect." Until 2.34.0 that sentence had nothing behind
8
+ * it. `certification-bundle.schema.json` had no seat for a rung, a bound or its
9
+ * terms; a bundle showed five pass/fail rows, and a reader could neither tell
10
+ * which rung was claimed nor recompute the bound. The terms lived only on a
11
+ * non-normative seam route. Found by READING acceptance criterion 1 at the
12
+ * moment of flipping the RFC, with every row already green.
13
+ *
14
+ * ── Why the evidence rides on ROWS ───────────────────────────────────────────
15
+ * The attestation covers exactly `{ witnessSha256, host.build, suite.version,
16
+ * discovery.sha256 }` (`conformance.md` §Bundle v3), and `witnessSha256` digests
17
+ * the requirement rows. A top-level block would therefore be UNSIGNED — a bound
18
+ * or a rung editable after signing on a bundle that still verifies. Evidence
19
+ * carried on a row is inside the witness digest, and so inside the signature.
20
+ * It enters the digest ONLY WHEN PRESENT, so every bundle cut before 2.34.0
21
+ * digests byte-identically and still verifies (pinned against the three
22
+ * committed bundles in `durability-evidence.test.ts`).
23
+ *
24
+ * ── Why the rung claim is NOT trusted ────────────────────────────────────────
25
+ * `durability.rung` sits at the top level, outside the signature, and that is
26
+ * sound for the same reason `claimedProfiles[].certified` is: the verifier
27
+ * RE-DERIVES it from signed rows and rejects a claim it cannot derive. §D: "a
28
+ * host MAY claim a rung only with the evidence named for it."
29
+ *
30
+ * ── Why a kill row names its recovery CLASS ──────────────────────────────────
31
+ * Unresolved Question 1 resolved PER WORK CLASS, not a scalar. A host with an
32
+ * outbox lane (65 s) and a dispatch lease (750 s) has two bounds, so each kill
33
+ * row records `{ class, boundMs, observedMs }`: the class MUST name a declared
34
+ * entry, `boundMs` MUST equal that entry's `bound`, and `observedMs` MUST NOT
35
+ * exceed it.
36
+ *
37
+ * WHAT THAT CATCHES, and what it cannot. It refuses an undeclared class, a bound
38
+ * label that disagrees with its class, and a resumption outside the bound. It
39
+ * does NOT catch a host that names the WRONG DECLARED class while resuming
40
+ * inside that class's (longer) bound — which a tier-1 host actually shipped: the
41
+ * seam killed before the execution claim was held, the 65 s lane rescued the run
42
+ * in 11.6 s, and the exercise was labelled leased / 750 s, every row green. No
43
+ * arithmetic separates that from a fast leased recovery. What the evidence
44
+ * changes is that it becomes VISIBLE — 11.6 s recorded against a class whose own
45
+ * terms say 720 s of lease — where before nothing was recorded at all. Killing
46
+ * only once the claim is held stays the HOST's obligation (§E).
47
+ *
48
+ * `class` is an OPAQUE host-chosen string, never an enum: "leased" / "unleased"
49
+ * name one host's mechanisms and boot re-entry is another's. An enum would
50
+ * select for an architecture, which this RFC refuses to do everywhere else.
51
+ * There is NO scalar "overall bound": that is UQ1's lie by aggregation with a
52
+ * friendlier name, and a reader would use it.
53
+ *
54
+ * What this block proves: ARITHMETIC (Σ terms = bound) and, through the kill
55
+ * rows' `observedMs`, that the mechanism RAN at least once inside the bound.
56
+ * `bound-is-derived` alone remains a paper check and MUST NOT be cited for
57
+ * liveness.
58
+ */
59
+
60
+ /** Host-chosen identifiers enter a PUBLISHED bundle: short, plain, no free text. */
61
+ export const EVIDENCE_NAME_PATTERN = /^[a-z][A-Za-z0-9._-]{0,63}$/;
62
+ export const MAX_RECOVERY_CLASSES = 16;
63
+ export const MAX_TERMS_PER_CLASS = 16;
64
+
65
+ export interface RecoveryTerm { readonly name: string; readonly ms: number }
66
+ export interface RecoveryBound { readonly class: string; readonly bound: number; readonly terms: readonly RecoveryTerm[] }
67
+ export interface RecoveryObservation { readonly class: string; readonly boundMs: number; readonly observedMs: number }
68
+ /** Closed. One optional member per KIND of structured evidence a row may carry. */
69
+ export interface RowEvidence { readonly recovery?: RecoveryObservation; readonly recoveryBounds?: readonly RecoveryBound[] }
70
+
71
+ export type DurabilityRung = 'durable-single-instance' | 'durable-multi-instance' | 'multi-region-qualified';
72
+ export const RUNGS: readonly DurabilityRung[] = ['durable-single-instance', 'durable-multi-instance', 'multi-region-qualified'];
73
+
74
+ const R = 'openwop.requirement.0158.';
75
+ /** The rows §Acceptance names for the lowest rung. */
76
+ export const SINGLE_INSTANCE_ROWS: readonly string[] = ['kill-after-accept', 'kill-during-execution', 'duplicate-delivery', 'poison-exhaustion', 'bound-is-derived'].map((r) => R + r);
77
+ const KILL_ROWS: readonly string[] = [R + 'kill-after-accept', R + 'kill-during-execution'];
78
+ const BOUND_ROW = R + 'bound-is-derived';
79
+
80
+ const isCount = (n: unknown): n is number => typeof n === 'number' && Number.isInteger(n) && n >= 0;
81
+ const isName = (s: unknown): s is string => typeof s === 'string' && EVIDENCE_NAME_PATTERN.test(s);
82
+
83
+ /** Normalise what a host's seam returned into bundle evidence, or say exactly why it cannot be. */
84
+ export function parseRecoveryBounds(raw: unknown): { ok: true; bounds: RecoveryBound[] } | { ok: false; why: string } {
85
+ if (!Array.isArray(raw) || raw.length === 0) return { ok: false, why: 'no recovery classes were declared' };
86
+ if (raw.length > MAX_RECOVERY_CLASSES) return { ok: false, why: `more than ${MAX_RECOVERY_CLASSES} recovery classes` };
87
+ const bounds: RecoveryBound[] = [];
88
+ const seen = new Set<string>();
89
+ for (const entry of raw as Array<Record<string, unknown>>) {
90
+ const cls = entry?.['class'];
91
+ if (!isName(cls)) return { ok: false, why: `a recovery class name does not match ${String(EVIDENCE_NAME_PATTERN)}` };
92
+ if (seen.has(cls)) return { ok: false, why: `recovery class ${cls} is declared twice` };
93
+ seen.add(cls);
94
+ const terms = entry['terms'];
95
+ if (!Array.isArray(terms) || terms.length === 0 || terms.length > MAX_TERMS_PER_CLASS) return { ok: false, why: `class ${cls}: terms[] MUST carry 1..${MAX_TERMS_PER_CLASS} entries — a total alone cannot be recomputed` };
96
+ const clean: RecoveryTerm[] = [];
97
+ for (const t of terms as Array<Record<string, unknown>>) {
98
+ if (!isName(t?.['name']) || !isCount(t['ms'])) return { ok: false, why: `class ${cls}: every term is { name, ms } with ms a non-negative integer` };
99
+ clean.push({ name: t['name'] as string, ms: t['ms'] as number });
100
+ }
101
+ if (!isCount(entry['bound'])) return { ok: false, why: `class ${cls}: bound is a non-negative integer of milliseconds` };
102
+ const sum = clean.reduce((a, t) => a + t.ms, 0);
103
+ if (sum !== entry['bound']) return { ok: false, why: `class ${cls}: terms sum to ${sum} ms but the declared bound is ${String(entry['bound'])} ms — a host that states a bound its terms do not produce fails §B.5` };
104
+ bounds.push({ class: cls, bound: entry['bound'] as number, terms: clean });
105
+ }
106
+ return { ok: true, bounds };
107
+ }
108
+
109
+ interface Row { readonly id: string; readonly result: string; readonly evidence?: RowEvidence }
110
+
111
+ /**
112
+ * The highest rung these SIGNED rows support, and — when it is lower than a
113
+ * reader might hope — the first reason why. Only `durable-single-instance` is
114
+ * derivable today: `peer-resume` is bundle-witnessed by a per-boot incarnation
115
+ * token this revision does not yet carry (§E), so a higher claim is REFUSED
116
+ * rather than waved through.
117
+ */
118
+ export function deriveRung(rows: readonly Row[]): { rung: DurabilityRung | null; why: string } {
119
+ const byId = new Map(rows.map((r) => [r.id, r]));
120
+ for (const id of SINGLE_INSTANCE_ROWS) {
121
+ const row = byId.get(id);
122
+ if (row === undefined) return { rung: null, why: `${id} is absent from the bundle` };
123
+ if (row.result !== 'executed-pass') return { rung: null, why: `${id} is ${row.result}, not executed-pass` };
124
+ }
125
+ const declared = parseRecoveryBounds(byId.get(BOUND_ROW)?.evidence?.recoveryBounds);
126
+ if (!declared.ok) return { rung: null, why: `${BOUND_ROW} carries no usable evidence.recoveryBounds — ${declared.why}` };
127
+ const bounds = new Map(declared.bounds.map((b) => [b.class, b.bound]));
128
+ for (const id of KILL_ROWS) {
129
+ const obs = byId.get(id)?.evidence?.recovery;
130
+ if (obs === undefined) return { rung: null, why: `${id} passed but recorded no evidence.recovery — the class it exercised and the interval it observed are unknown` };
131
+ if (!isName(obs.class) || !isCount(obs.boundMs) || !isCount(obs.observedMs)) return { rung: null, why: `${id}: evidence.recovery is malformed` };
132
+ const bound = bounds.get(obs.class);
133
+ if (bound === undefined) return { rung: null, why: `${id} exercised recovery class "${obs.class}", which ${BOUND_ROW} does not declare (declared: ${[...bounds.keys()].join(', ')})` };
134
+ if (obs.boundMs !== bound) return { rung: null, why: `${id} was judged against ${obs.boundMs} ms but class "${obs.class}" declares ${bound} ms — the exercise was labelled with a bound that does not govern it` };
135
+ if (obs.observedMs > bound) return { rung: null, why: `${id} resumed after ${obs.observedMs} ms, outside the ${bound} ms bound of class "${obs.class}"` };
136
+ }
137
+ return { rung: 'durable-single-instance', why: 'all five rows executed-pass; each kill row names a declared class and resumed inside its bound' };
138
+ }
139
+
140
+ /** Is the rung a bundle CLAIMS supported by its own signed rows? */
141
+ export function checkRungClaim(claimed: unknown, rows: readonly Row[]): { ok: true } | { ok: false; detail: string } {
142
+ if (claimed === undefined) return { ok: true };
143
+ if (typeof claimed !== 'string' || !(RUNGS as readonly string[]).includes(claimed)) return { ok: false, detail: `durability.rung ${JSON.stringify(claimed)} is not one of ${RUNGS.join(' | ')}` };
144
+ const derived = deriveRung(rows);
145
+ if (derived.rung === null) return { ok: false, detail: `durability.rung claims ${claimed}, but the signed rows do not support any rung: ${derived.why}` };
146
+ if (claimed !== derived.rung) return { ok: false, detail: `durability.rung claims ${claimed}, but the signed rows support only ${derived.rung} — a higher rung is bundle-witnessed by evidence this revision does not yet carry (RFC 0158 §E, peer-resume), so it is refused rather than assumed` };
147
+ return { ok: true };
148
+ }
149
+
150
+ /**
151
+ * Was this bundle cut by a NEWER suite than the verifier reading it?
152
+ *
153
+ * Row evidence enters the witness digest, so a verifier older than 2.34.0 that
154
+ * meets a bundle carrying it recomputes a different digest and reports
155
+ * `witness-digest` — it FAILS CLOSED, which is right, but the message reads as
156
+ * tampering when the truth is "upgrade the verifier". Every later digested
157
+ * member will repeat that. The notice is advice; it never changes a verdict.
158
+ */
159
+ export function emittedByNewerSuite(bundleSuite: unknown, verifierSuite: string): boolean {
160
+ const parse = (v: unknown): number[] | null => {
161
+ const m = typeof v === 'string' ? /^(\d+)\.(\d+)\.(\d+)/.exec(v) : null;
162
+ return m === null ? null : [Number(m[1]), Number(m[2]), Number(m[3])];
163
+ };
164
+ const b = parse(bundleSuite); const mine = parse(verifierSuite);
165
+ if (b === null || mine === null) return false;
166
+ for (let i = 0; i < 3; i++) { if ((b[i] as number) !== (mine[i] as number)) return (b[i] as number) > (mine[i] as number); }
167
+ return false;
168
+ }
169
+
170
+ // ── the scenario → setup.ts → ledger channel ─────────────────────────────────
171
+ let pending: RowEvidence | null = null;
172
+ /** Called by a scenario inside an `it`; setup.ts attaches it to that test's ledger row. Later notes merge. */
173
+ export function noteEvidence(evidence: RowEvidence): void { pending = { ...(pending ?? {}), ...evidence }; }
174
+ /** setup.ts reads and clears it after each test. */
175
+ export function takeNotedEvidence(): RowEvidence | null { const e = pending; pending = null; return e; }
@@ -16,6 +16,7 @@
16
16
  * That is the whole mechanism. Everything else here is bookkeeping.
17
17
  */
18
18
 
19
+ import type { RowEvidence } from './durability-evidence.js';
19
20
  import { appendFileSync, readFileSync, existsSync } from 'node:fs';
20
21
 
21
22
  /** RFC 0148 §A. Exactly one of these per requirement, per run. */
@@ -66,6 +67,10 @@ export interface LedgerEntry {
66
67
  * the row to a file and drops it — which is how every explicit requirement id
67
68
  * went missing from bundle v3 while the per-`it` ids came through. */
68
69
  readonly scenarioFile?: string;
70
+ /** RFC 0158 §E — structured evidence the scenario noted for this row
71
+ * (`durability-evidence.ts`). Recorded for `executed-pass` only: a row that
72
+ * failed, blocked or skipped witnessed nothing to describe. */
73
+ readonly evidence?: RowEvidence;
69
74
  }
70
75
 
71
76
  const ledger = new Map<string, LedgerEntry>();
@@ -87,7 +92,7 @@ export function recordRequirement(
87
92
  requirementId: string,
88
93
  disposition: Disposition,
89
94
  detail?: string,
90
- extras?: { assertionCount?: number; scenarioFile?: string },
95
+ extras?: { assertionCount?: number; scenarioFile?: string; evidence?: RowEvidence },
91
96
  ): void {
92
97
  const prior = ledger.get(requirementId);
93
98
  if (prior !== undefined && prior.disposition !== disposition) {
@@ -108,6 +113,7 @@ export function recordRequirement(
108
113
  ...(detail === undefined ? {} : { detail }),
109
114
  ...(extras?.assertionCount === undefined ? {} : { assertionCount: extras.assertionCount }),
110
115
  ...(extras?.scenarioFile === undefined ? {} : { scenarioFile: extras.scenarioFile }),
116
+ ...(extras?.evidence === undefined || disposition !== 'executed-pass' ? {} : { evidence: extras.evidence }),
111
117
  };
112
118
  ledger.set(requirementId, entry);
113
119
  journal.push(entry);
@@ -74,6 +74,7 @@ import { pollUntilTerminal, scaledTimeoutMs } from '../lib/polling.js';
74
74
  import { req } from '../lib/requirement-ids.js';
75
75
  import { receiverBinding, resolveRegistrationUrl } from '../lib/webhook-receiver.js';
76
76
  import { watchForResumption, type Observation, type Watch } from '../lib/durability-watch.js';
77
+ import { noteEvidence, parseRecoveryBounds, EVIDENCE_NAME_PATTERN } from '../lib/durability-evidence.js';
77
78
 
78
79
  const FIXTURE = 'conformance-noop';
79
80
  const FAILURE_FIXTURE = 'conformance-failure';
@@ -186,6 +187,32 @@ const KILL_ROW_TIMEOUT_MS = OBSERVATION_CEILING_MS + 120_000;
186
187
  /** Used only when the host serves no bound to read; named in the row's detail. */
187
188
  const UNDECLARED_BOUND_FALLBACK_MS = 60_000;
188
189
 
190
+ /**
191
+ * The declared recovery bounds, PER CLASS, read off the seam and normalised.
192
+ *
193
+ * Three response shapes are accepted, because the seam is non-normative and two
194
+ * hosts built it before this was written down:
195
+ * { classes: [{ class, bound, terms[] }] } — the preferred shape
196
+ * { classes: { <name>: { bound, terms[] } } } — a map keyed by class
197
+ * { bound, terms[], class? } — one class; named `class`
198
+ * or, absent that, `default`
199
+ * A top-level scalar `bound` beside `classes` is IGNORED: Unresolved Question 1
200
+ * resolved per class, and a convenience total is the aggregation it rejects.
201
+ * Only `{ name, ms }` survives from a term — free text a host attaches (the
202
+ * reference host's `enforcedBy`) never reaches a published bundle.
203
+ */
204
+ function readClasses(json: unknown): unknown[] | null {
205
+ const body = (json as { classes?: unknown; class?: unknown; bound?: unknown; terms?: unknown } | null) ?? {};
206
+ const strip = (cls: unknown, e: { bound?: unknown; terms?: unknown }): unknown => ({
207
+ class: cls, bound: e.bound,
208
+ terms: Array.isArray(e.terms) ? (e.terms as Array<{ name?: unknown; ms?: unknown }>).map((t) => ({ name: t?.name, ms: t?.ms })) : e.terms,
209
+ });
210
+ if (Array.isArray(body.classes)) return (body.classes as Array<{ class?: unknown; bound?: unknown; terms?: unknown }>).map((e) => strip(e?.class, e ?? {}));
211
+ if (body.classes !== null && typeof body.classes === 'object') return Object.entries(body.classes as Record<string, { bound?: unknown; terms?: unknown }>).map(([k, e]) => strip(k, e ?? {}));
212
+ if (body.bound !== undefined || body.terms !== undefined) return [strip(typeof body.class === 'string' ? body.class : 'default', body)];
213
+ return null;
214
+ }
215
+
189
216
  /**
190
217
  * How long to keep observing: the host's OWN declared recovery bound.
191
218
  *
@@ -199,19 +226,21 @@ const UNDECLARED_BOUND_FALLBACK_MS = 60_000;
199
226
  * bound is used as a CEILING FOR WAITING only; it is never asserted as a
200
227
  * scalar here (`bound-is-derived` owns the arithmetic).
201
228
  */
202
- async function declaredBoundMs(fired: unknown): Promise<{ ms: number; declared: boolean }> {
229
+ async function declaredBoundMs(fired: unknown): Promise<{ ms: number; declared: boolean; recoveryClass: string | null }> {
230
+ const named = (fired as { recoveryClass?: unknown } | null)?.recoveryClass;
231
+ const recoveryClass = typeof named === 'string' && EVIDENCE_NAME_PATTERN.test(named) ? named : null;
203
232
  // A host whose bound is PER CLASS (Unresolved Question 1: unleased work waits
204
233
  // out an outbox lease, leased work a dispatch lease — 65 s against 750 s on
205
234
  // one measured host) names the figure that governs THIS work on the seam's
206
235
  // own response. The bare read below returns one class and would report the
207
236
  // interval against a bound that does not govern the staged run.
208
237
  const governing = (fired as { recoveryBoundMs?: unknown } | null)?.recoveryBoundMs;
209
- if (typeof governing === 'number' && Number.isFinite(governing) && governing > 0) return { ms: governing, declared: true };
238
+ if (typeof governing === 'number' && Number.isFinite(governing) && governing > 0) return { ms: governing, declared: true, recoveryClass };
210
239
  const r = await driver.get('/host/durability/bound');
211
240
  const bound = (r.json as { bound?: unknown } | null)?.bound;
212
241
  return r.status === 200 && typeof bound === 'number' && Number.isFinite(bound) && bound > 0
213
- ? { ms: bound, declared: true }
214
- : { ms: UNDECLARED_BOUND_FALLBACK_MS, declared: false };
242
+ ? { ms: bound, declared: true, recoveryClass }
243
+ : { ms: UNDECLARED_BOUND_FALLBACK_MS, declared: false, recoveryClass };
215
244
  }
216
245
 
217
246
  /**
@@ -294,6 +323,15 @@ describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-sin
294
323
  w.resumedAfterMs !== null,
295
324
  req('openwop.requirement.0158.kill-after-accept', 'RFC 0158 §B.4', `work accepted before a real process death MUST dispatch on resume within the declared recovery bound — service answered again after ${backIn}ms, then observed for ${w.waitedMs}ms against a ${bound.declared ? `declared ${bound.ms}ms bound` : `${bound.ms}ms fallback (the host serves no /host/durability/bound)`}: ${w.last.runStarted} run.started, ${w.last.nodeStarted} node.started, ${w.last.restored} restored${w.resumedAfterMs !== null ? `; dispatch first observed ${backIn + w.resumedAfterMs}ms after the kill` : ''}`),
296
325
  ).toBe(true);
326
+ // RFC 0158 §E, 2.34.0: the interval this row MEASURED is evidence, and until
327
+ // now it existed only inside a failure message — a passing row recorded
328
+ // nothing, so a bundle could not show what was observed or against which
329
+ // class. Recorded only when the seam NAMED the class it exercised: without
330
+ // that the bundle cannot bind this exercise to a declared bound, the rung is
331
+ // not derivable, and saying so is more honest than guessing a class.
332
+ if (bound.declared && bound.recoveryClass !== null && w.resumedAfterMs !== null) {
333
+ noteEvidence({ recovery: { class: bound.recoveryClass, boundMs: Math.round(bound.ms), observedMs: backIn + w.resumedAfterMs } });
334
+ }
297
335
  }, KILL_ROW_TIMEOUT_MS);
298
336
 
299
337
  it('work executing at a real process death is never reported complete, and resumes', async () => {
@@ -341,6 +379,15 @@ describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-sin
341
379
  w.resumedAfterMs !== null,
342
380
  req('openwop.requirement.0158.kill-during-execution', 'RFC 0158 §B.4 / §E item 11', `work executing at a real process death MUST resume within the declared recovery bound; §B.4 measures kill → RESUMPTION, never kill → terminal — service answered again after ${backIn}ms, then observed for ${w.waitedMs}ms against a ${bound.declared ? `declared ${bound.ms}ms bound` : `${bound.ms}ms fallback (the host serves no /host/durability/bound)`}: ${w.last.runStarted} run.started, ${w.last.restored} restored${w.resumedAfterMs !== null ? `; resumption first observed ${backIn + w.resumedAfterMs}ms after the kill` : ''}`),
343
381
  ).toBe(true);
382
+ // RFC 0158 §E, 2.34.0: the interval this row MEASURED is evidence, and until
383
+ // now it existed only inside a failure message — a passing row recorded
384
+ // nothing, so a bundle could not show what was observed or against which
385
+ // class. Recorded only when the seam NAMED the class it exercised: without
386
+ // that the bundle cannot bind this exercise to a declared bound, the rung is
387
+ // not derivable, and saying so is more honest than guessing a class.
388
+ if (bound.declared && bound.recoveryClass !== null && w.resumedAfterMs !== null) {
389
+ noteEvidence({ recovery: { class: bound.recoveryClass, boundMs: Math.round(bound.ms), observedMs: backIn + w.resumedAfterMs } });
390
+ }
344
391
  }, KILL_ROW_TIMEOUT_MS);
345
392
 
346
393
  it('the same accepted work delivered twice fires each effect exactly once', async () => {
@@ -427,24 +474,25 @@ describe('v2-durability-recovery (RFC 0158 §B.4, §D.9, §E — the durable-sin
427
474
  if (terms.status === 404 || terms.status === 405) {
428
475
  return softSkip('blocked', 'the host exposes the durability seam but serves no recovery-bound terms — §E puts the per-class arithmetic in the RFC 0148 evidence bundle where a reader can recompute it, and there is nothing here to recompute');
429
476
  }
430
- const body = (terms.json as { bound?: unknown; terms?: unknown } | null) ?? {};
431
- const bound = typeof body.bound === 'number' ? body.bound : null;
432
- const parts = Array.isArray(body.terms) ? (body.terms as unknown[]) : null;
433
- if (bound === null || parts === null || parts.length === 0) {
434
- return softSkip('blocked', 'the recovery-bound response names no { bound, terms[] } — §E asks for the per-class arithmetic, not a single total (Unresolved Question 1), and a total alone cannot be recomputed');
477
+ const classes = readClasses(terms.json);
478
+ if (classes === null) {
479
+ return softSkip('blocked', 'the recovery-bound response names no classes and no { bound, terms[] } — §E asks for the per-class arithmetic, not a single total (Unresolved Question 1), and a total alone cannot be recomputed');
435
480
  }
436
- const summed = parts.reduce<number>((acc, t) => acc + (typeof (t as { ms?: unknown }).ms === 'number' ? (t as { ms: number }).ms : Number.NaN), 0);
437
481
  // THIS ROW IS A PAPER CHECK BY CONSTRUCTION AND THE RFC SAYS SO. It checks
438
- // that the declared number follows from the stated mechanism. It cannot
482
+ // that each declared number follows from the stated mechanism. It cannot
439
483
  // check that the mechanism RUNS: a host whose sweeper wedges has a
440
484
  // derivation that stays perfectly correct while the bound is not produced
441
485
  // at all — a run sat unclaimed for 16 minutes against a derived bound of
442
486
  // 12.5 with every isolated check of the mechanism passing. Only the kill
443
487
  // rows above witness liveness, and this row MUST NOT be cited for it.
488
+ const parsed = parseRecoveryBounds(classes);
444
489
  expect(
445
- Number.isFinite(summed) && Math.abs(summed - bound) <= 1,
446
- req('openwop.requirement.0158.bound-is-derived', 'RFC 0158 §B.5', `the declared recovery bound MUST follow from the per-class terms that produce it — declared ${bound}, terms sum to ${summed}. A host that states a bound it cannot produce fails; a host whose sweeper wedges still passes, which is why this row MUST NOT be read as evidence that the mechanism runs`),
490
+ parsed.ok,
491
+ req('openwop.requirement.0158.bound-is-derived', 'RFC 0158 §B.5', `every declared recovery bound MUST follow from the per-class terms that produce it, each term { name, ms } — ${parsed.ok ? '' : parsed.why}. A host that states a bound it cannot produce fails; this checks the ARITHMETIC only, never that the mechanism runs`),
447
492
  ).toBe(true);
493
+ // The derivation goes INTO THE BUNDLE, per class, where a reader can
494
+ // recompute it (§E) — until 2.34.0 it lived only on this seam route.
495
+ if (parsed.ok) noteEvidence({ recoveryBounds: parsed.bounds });
448
496
  }, 120_000);
449
497
 
450
498
  it('deterministically failing work reaches a terminal state and stops being retried', async () => {
package/src/setup.ts CHANGED
@@ -43,6 +43,7 @@ import { SPEC_COHERENCE_SCENARIOS, SPEC_COHERENCE_DETAIL } from './lib/spec-cohe
43
43
  import type { DiscoveryPayload } from './lib/profiles.js';
44
44
  import { targetMajor } from './lib/seams.js';
45
45
  import { softSkip } from './lib/soft-skip.js';
46
+ import { takeNotedEvidence } from './lib/durability-evidence.js';
46
47
 
47
48
  // 20 s, not 5: a Cloud Run cold start routinely exceeds 5 s, and a discovery
48
49
  // fetch that aborted at init used to turn every fixture-gated scenario into a
@@ -457,7 +458,8 @@ afterEach(({ task }) => {
457
458
  detail = rec.detail;
458
459
  }
459
460
  try {
460
- recordRequirement(itId, disposition, detail, { assertionCount: calls, scenarioFile: file });
461
+ const evidence = takeNotedEvidence();
462
+ recordRequirement(itId, disposition, detail, { assertionCount: calls, scenarioFile: file, ...(evidence === null ? {} : { evidence }) });
461
463
  } catch {
462
464
  /* never fail a test for bookkeeping */
463
465
  }