@openwop/openwop-conformance 2.42.1 → 2.42.3
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 +37 -0
- package/README.md +1 -1
- package/coverage.md +1 -0
- package/dist/lib/certification-bundle-verify.js +13 -0
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +91 -55
- package/schemas/CORPUS-STAMP.json +15 -15
- package/src/lib/carries-payload.ts +42 -0
- package/src/lib/certification-bundle-verify.ts +13 -0
- package/src/lib/effect-refire.ts +34 -0
- package/src/scenarios/certification-bundle-redaction.test.ts +1 -1
- package/src/scenarios/inbound-credential-no-passthrough.test.ts +13 -2
- package/src/scenarios/v2-a2ui-v09-surface.test.ts +8 -12
- package/src/scenarios/v2-advertised-fixtures-exist.test.ts +17 -3
- package/src/scenarios/v2-artifact-a2a-shape.test.ts +8 -2
- package/src/scenarios/v2-bound-id-kinds.test.ts +13 -4
- package/src/scenarios/v2-effect-identity-business-key.test.ts +48 -10
- package/src/scenarios/v2-effect-seam-manifest.test.ts +3 -9
- package/src/scenarios/v2-effect-seam-no-refire.test.ts +18 -5
- package/src/scenarios/v2-era-2-append-vocabulary.test.ts +52 -9
- package/src/scenarios/v2-interop-trace-context.test.ts +23 -0
- package/src/scenarios/v2-mcp-tasks.test.ts +16 -3
- package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +11 -0
- package/src/scenarios/v2-poll-cursor-v2.test.ts +28 -5
- package/src/scenarios/v2-profiles-derived-only.test.ts +9 -2
- package/src/scenarios/v2-webhook-delivery-isolation.test.ts +28 -8
- package/src/scenarios/v2-webhook-durable-delivery.test.ts +16 -0
- package/src/scenarios/v2-webhook-endpoint-verification.test.ts +25 -6
- package/src/scenarios/v2-webhook-message-id-stable.test.ts +10 -2
|
@@ -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.42.
|
|
5
|
-
"corpusTag": "v2.42.
|
|
4
|
+
"version": "2.42.3",
|
|
5
|
+
"corpusTag": "v2.42.3",
|
|
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
|
-
"api/openapi.yaml": "
|
|
10
|
+
"api/openapi.yaml": "69464bc0e1e71ef3b543a73d4fa4f67202343d2603c80af8033f6c3619ef1b30",
|
|
11
11
|
"api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
|
|
12
|
-
"api/seams-v2.yaml": "
|
|
13
|
-
"api/v2/asyncapi.yaml": "
|
|
14
|
-
"api/v2/openapi.yaml": "
|
|
12
|
+
"api/seams-v2.yaml": "6824903e9a7d33a6913878d46ad0a1e25746b596c6ab2ae7aa6c31d72babfa63",
|
|
13
|
+
"api/v2/asyncapi.yaml": "45ee1825660a8d6336e145042e6e6e7f69d63d823fb7a6af8f5e9c46ec93c45f",
|
|
14
|
+
"api/v2/openapi.yaml": "d45f0d99b6d57b45a30640583c0aa828163ba778768dd58835246ed5dad212b1",
|
|
15
15
|
"api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
|
|
16
16
|
"schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
|
|
17
17
|
"schemas/a2a-task-state.schema.json": "75d5049dea8bd873ff0e7546f1c60c8d36c219bec7084264360be54a8be30ae1",
|
|
@@ -132,7 +132,7 @@
|
|
|
132
132
|
"schemas/v2/credential-reference.schema.json": "ea699ce30f58fb2c32d4279158b3f730f70be7b2dc8c3037693a76e66eb3e001",
|
|
133
133
|
"schemas/v2/debug-bundle.schema.json": "0bfa4b1d91fefbc12c95670dba9d5eacb72b879bb9a5c074e821ca9ed0fee6fd",
|
|
134
134
|
"schemas/v2/dispatch-config.schema.json": "5718c1ed7d95672e618bcb1f1da8e38180e144b12151307afc3aba1314dfcef0",
|
|
135
|
-
"schemas/v2/effect-ledger-projection.schema.json": "
|
|
135
|
+
"schemas/v2/effect-ledger-projection.schema.json": "0d118d385a43921118a76888449c5c3c5618eab1b80914b84a0a1b48d18822c1",
|
|
136
136
|
"schemas/v2/effect-seam-manifest.schema.json": "197ef9944ab027a50d1b9352f317cbbc12c8ad955fdb14650fb90477052ffef0",
|
|
137
137
|
"schemas/v2/envelopes/clarification.request.schema.json": "9e39d7b09f0b3ce7902d6aa19ca0eb52867b634efc78a881469c4d44fb3333e7",
|
|
138
138
|
"schemas/v2/envelopes/error.schema.json": "6a11fa3b9d61736fcb5675e6ac9825919aa609dceba13ef5615993d54647802b",
|
|
@@ -204,17 +204,17 @@
|
|
|
204
204
|
"schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
|
|
205
205
|
"spec/v1/alias-detectors.json": "2401fcb1c18cdd688c018b3d716ae6bca85c5e356220ee2b793bd9d81872412d",
|
|
206
206
|
"spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
|
|
207
|
-
"spec/v1/core-standard-manifest.json": "
|
|
207
|
+
"spec/v1/core-standard-manifest.json": "4271942ecca7c7455cb6318572bdb5a53ddc34f8d8914e2a68c64b091d131a0f",
|
|
208
208
|
"spec/v1/deprecations.json": "2d03f4729810280147ea08c630ea65434f2dc5370567d0aac41c3595f8337d40",
|
|
209
209
|
"spec/v1/deprecations.schema.json": "3e393c405d2a41b467d8c5e3c468549078df1ce6a6d2588fc488097b95d9b55b",
|
|
210
210
|
"spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
|
|
211
211
|
"spec/v1/event-codemap.schema.json": "d05933b2e88103aff51a2f774df97b9bda065e0fc88dbd9b114cc47a32189174",
|
|
212
212
|
"spec/v1/extensions.json": "79a60754aa16cbdbdb604f8e5c00038af535a1690a4d07bd5ff7c38f0256a7bc",
|
|
213
|
-
"spec/v1/gaps.json": "
|
|
213
|
+
"spec/v1/gaps.json": "361fb29a27dc933c2462a64f32a5a6a6ecf8e6d9fb46039550cb5cfa2eb0ab13",
|
|
214
214
|
"spec/v1/gaps.schema.json": "8fd83259f556553c9df0f53e7a82ca8c2a4771d471197f69b4e2ae0ceeacfd66",
|
|
215
215
|
"spec/v1/migrations.json": "8839497d6a4830b53fa7867014a8336f0d725ef2346352e54fb580b7947897af",
|
|
216
216
|
"spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
|
|
217
|
-
"spec/v1/operation-path-manifest.json": "
|
|
217
|
+
"spec/v1/operation-path-manifest.json": "fada0342815fb129c1ae61f1829bd6e34417717408ce86fcc54280f373c19e74",
|
|
218
218
|
"spec/v1/spec-gaps.json": "ea9612af55bea9dae3e82223d4f714243f8f590b0193f1188257b13491a38afc",
|
|
219
219
|
"spec/v2/README.md": "ccfed7d978ce7293a6d8f3c0f6045835dfb7e488f2b67a68f6b04d67c4b0cfc9",
|
|
220
220
|
"spec/v2/core/capabilities.md": "e22381fed3a93376ad478fa7f07571fdad9653aefd7c05f189a2ef45a49f6c63",
|
|
@@ -224,7 +224,7 @@
|
|
|
224
224
|
"spec/v2/core/errors.md": "870f1a9c9481fb49c8bd39170f0656c2ae46124afd4472fb7a3c18c67be0e561",
|
|
225
225
|
"spec/v2/core/events.md": "3df1866a89fb765c78adf79b8d51f96c59584dee9aa88d59179b88a681d827da",
|
|
226
226
|
"spec/v2/core/form-content-packs.md": "55ea021b5f676f5851f420b3597262fbf6642108bddeb726922f8c5dd96c968b",
|
|
227
|
-
"spec/v2/core/headers.md": "
|
|
227
|
+
"spec/v2/core/headers.md": "10ec6a6c3e0437b1501cb224273e86c2f6aede92fd469605f3353904d3ebd230",
|
|
228
228
|
"spec/v2/core/host-services.md": "c3ff93201d642edf835e9ae84f06a17edd207f9d351f8bfd70572be05bbfd894",
|
|
229
229
|
"spec/v2/core/idempotency.md": "b61d4a200526485937897dd7ebca092e68424a80624e0be7a0b7db8cdbd54403",
|
|
230
230
|
"spec/v2/core/identity.md": "d15667552019f64316be9b65c6e5696c10766581405f05da3907fb47f36e6459",
|
|
@@ -291,12 +291,12 @@
|
|
|
291
291
|
"spec/v2/interop-map.schema.json": "0017c882df34ee215e2e0729de051490e09d43aac3d2c949285698ed51832f12",
|
|
292
292
|
"spec/v2/migrations.json": "651954938a1e154c0b6f1e3dd6e105889d30de3bb65789f21de7f77a12869e9e",
|
|
293
293
|
"spec/v2/migrations.schema.json": "b91ade6320ad1b1185c9495b9424f3aa63438ed8e412c45171c227d82c0afd86",
|
|
294
|
-
"spec/v2/path-manifest.json": "
|
|
294
|
+
"spec/v2/path-manifest.json": "17298a245c5da9a70aa7ab0b7f2db4277e06209d6fcdd7187a8b4cea179c08f3",
|
|
295
295
|
"spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
|
|
296
296
|
"spec/v2/profiles.json": "0636f19fceae625390003a347e70ef4797d84766b5c24ce8a02cea52aadebca4",
|
|
297
|
-
"spec/v2/release.json": "
|
|
297
|
+
"spec/v2/release.json": "1b3dde14cbaf3cfc4eab1ae9a461aab6d357c9c4cda511047087c0b57b400055",
|
|
298
298
|
"spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
|
|
299
|
-
"spec/v2/surface-baseline.json": "
|
|
299
|
+
"spec/v2/surface-baseline.json": "dc52941e7f3e19da5151bb5c994af9b18b137a5a3b34ee0b63bc9cb2789dc52a"
|
|
300
300
|
},
|
|
301
|
-
"corpusCommit": "
|
|
301
|
+
"corpusCommit": "d310f781e2dcb00d670e26fc23668c2d61548304"
|
|
302
302
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does any event's payload carry `sent`, as JSON — member order ignored?
|
|
3
|
+
*
|
|
4
|
+
* `v2-a2ui-v09-surface` asserted RFC 0209 §C.11 ("recorded surface envelopes
|
|
5
|
+
* are returned as recorded") by comparing `JSON.stringify(subtree)` with
|
|
6
|
+
* `JSON.stringify(sent)`. That comparison is ORDER-sensitive, and RFC 8259 §4
|
|
7
|
+
* gives object member order no meaning. A Postgres JSONB host returns every
|
|
8
|
+
* member and every value but re-sorts keys (shorter first, then bytewise):
|
|
9
|
+
* measured on pg16, `{version,catalogId,surfaceId,messages}` came back as
|
|
10
|
+
* `{version,messages,catalogId,surfaceId}`. So a conforming host could never
|
|
11
|
+
* pass the leg (suite 2.42.2 correction).
|
|
12
|
+
*
|
|
13
|
+
* The comparison is now between RFC 8785 (JCS) forms, the corpus's canonical
|
|
14
|
+
* JSON (`lib/jcs.ts`; conformance.md §"Canonical JSON"). JCS sorts members and
|
|
15
|
+
* fixes number and string serialization, so two values are JCS-equal exactly
|
|
16
|
+
* when they are the same JSON value. What §C.11 forbids, a regenerated or
|
|
17
|
+
* altered surface, still fails: a changed value, a dropped or added member, or
|
|
18
|
+
* a reordered ARRAY (message order is meaningful) all change the JCS form.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { canonicalJSON } from './jcs.js';
|
|
22
|
+
|
|
23
|
+
/** Every subtree of `v`, depth-first. */
|
|
24
|
+
function* subtrees(v: unknown): Generator<unknown> {
|
|
25
|
+
yield v;
|
|
26
|
+
if (Array.isArray(v)) for (const x of v) yield* subtrees(x);
|
|
27
|
+
else if (v && typeof v === 'object') for (const x of Object.values(v)) yield* subtrees(x);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** JCS form, or null for a value JCS refuses (not I-JSON), which then matches nothing. */
|
|
31
|
+
function jcs(v: unknown): string | null {
|
|
32
|
+
try { return canonicalJSON(v); } catch { return null; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function carriesPayload(events: readonly unknown[], sent: unknown): boolean {
|
|
36
|
+
const want = jcs(sent);
|
|
37
|
+
if (want === null) return false;
|
|
38
|
+
return events.some((e) => {
|
|
39
|
+
const payload = e !== null && typeof e === 'object' ? (e as Record<string, unknown>)['payload'] : undefined;
|
|
40
|
+
return [...subtrees(payload)].some((s) => jcs(s) === want);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -298,6 +298,9 @@ export interface ScrubResult<T> {
|
|
|
298
298
|
readonly redactedAt: readonly string[];
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
/** A whole-string hex digest: `sha256:`-prefixed or bare, 32+ hex characters. */
|
|
302
|
+
const HEX_DIGEST = /^(sha256:)?[0-9a-f]{32,}$/i;
|
|
303
|
+
|
|
301
304
|
/**
|
|
302
305
|
* Replace every occurrence of every secret in every string of `value` (keys
|
|
303
306
|
* included) with `redactionMarker(secret)`. Empty / whitespace-only secrets
|
|
@@ -312,6 +315,16 @@ export function scrubEvidence<T>(value: T, secrets: readonly string[]): ScrubRes
|
|
|
312
315
|
const redactedAt: string[] = [];
|
|
313
316
|
if (live.length === 0) return { value, redactedAt };
|
|
314
317
|
const scrubString = (s: string, path: string): string => {
|
|
318
|
+
// A string that is wholly a hex digest (a sha256, a key fingerprint) is
|
|
319
|
+
// scrubbed only when it IS a secret, never for a secret found inside it.
|
|
320
|
+
// A secret inside a digest is a coincidence, not a leak: the digest does
|
|
321
|
+
// not disclose it. Rewriting it only corrupts the evidence. Suite 2.40.3
|
|
322
|
+
// floored the env sweep after openwop-app's `…_ROTATION_OVERLAP_S=60`
|
|
323
|
+
// rewrote the "60" inside `discovery.sha256`, but an explicitly handed
|
|
324
|
+
// credential is still scrubbed whatever its shape, so a short one (or any
|
|
325
|
+
// hex one) could still hit a digest at random. This closes that class at
|
|
326
|
+
// the scrub instead of at each source of secrets.
|
|
327
|
+
if (HEX_DIGEST.test(s) && !live.includes(s)) return s;
|
|
315
328
|
let out = s;
|
|
316
329
|
let hit = false;
|
|
317
330
|
for (const secret of live) {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which rows of a replay fork's effect ledger record an attempt the PARENT
|
|
3
|
+
* never made, i.e. a re-fire (RFC 0173 §C.2; replay.md §Suppression rule 1)?
|
|
4
|
+
*
|
|
5
|
+
* `v2-effect-seam-no-refire` used to assert `fork count ≤ parent count`. The
|
|
6
|
+
* seam fires ONE attempt on the source run, so a host that re-fires on replay
|
|
7
|
+
* records one row on the fork and reads 1 ≤ 1: the leg passed over exactly the
|
|
8
|
+
* defect it exists for. Measured: openwop-app with `sideEffecting` dropped
|
|
9
|
+
* still passed (suite 2.42.2 correction).
|
|
10
|
+
*
|
|
11
|
+
* A fork's projection may legitimately carry nothing (a host that keys the
|
|
12
|
+
* ledger per run: its fork's own ledger is empty when nothing re-fired) or the
|
|
13
|
+
* parent's rows as fixed history (a host that projects inherited attempts). So
|
|
14
|
+
* the witness is not a count. It is whether any fork row records an attempt
|
|
15
|
+
* that is not one of the parent's: an inherited row repeats a parent attempt's
|
|
16
|
+
* `(nodeId, attempt, at)`, while a re-fire is a new attempt with a new `at`.
|
|
17
|
+
* The multiset match means N inherited rows cover at most N parent rows.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface EffectRow { readonly nodeId?: unknown; readonly attempt?: unknown; readonly at?: unknown }
|
|
21
|
+
|
|
22
|
+
const key = (r: EffectRow): string => `${String(r.nodeId)}|${String(r.attempt)}|${String(r.at)}`;
|
|
23
|
+
|
|
24
|
+
export function refiredAttempts(parent: readonly EffectRow[], fork: readonly EffectRow[]): EffectRow[] {
|
|
25
|
+
const available = new Map<string, number>();
|
|
26
|
+
for (const r of parent) available.set(key(r), (available.get(key(r)) ?? 0) + 1);
|
|
27
|
+
const out: EffectRow[] = [];
|
|
28
|
+
for (const r of fork) {
|
|
29
|
+
const n = available.get(key(r)) ?? 0;
|
|
30
|
+
if (n > 0) available.set(key(r), n - 1);
|
|
31
|
+
else out.push(r);
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
@@ -158,7 +158,7 @@ describe('RFC 0148 §C — certification-bundle-redaction: secret canaries never
|
|
|
158
158
|
// Suite 2.40.2: openwop-app set OPENWOP_WEBHOOK_SECRET_ROTATION_OVERLAP_S=60,
|
|
159
159
|
// the name matched SECRET, and scrubEvidence rewrote the "60" inside
|
|
160
160
|
// discovery.sha256 — the bundle failed ^[0-9a-f]{64}$ and main could not deploy.
|
|
161
|
-
const digest = '
|
|
161
|
+
const digest = '4b8d99c002a061a0255e8224ee683160007febadd251b2d171b47c0849695315';
|
|
162
162
|
const env = {
|
|
163
163
|
OPENWOP_WEBHOOK_SECRET_ROTATION_OVERLAP_S: '60',
|
|
164
164
|
OPENWOP_TOKEN_TTL_SECONDS_KEY: '86400000',
|
|
@@ -42,6 +42,7 @@ import { softSkip } from '../lib/soft-skip.js';
|
|
|
42
42
|
import { req } from '../lib/requirement-ids.js';
|
|
43
43
|
import { targetMajor } from '../lib/seams.js';
|
|
44
44
|
import { startModalReceiver } from '../lib/webhook-receiver.js';
|
|
45
|
+
import { deliveriesFor } from '../lib/standard-webhooks.js';
|
|
45
46
|
import { projectBoundId } from '../lib/bound-id.js';
|
|
46
47
|
|
|
47
48
|
const DOC = 'spec/v1/auth.md §"Onward hops"; spec/v2/core/security-defaults.md §"Onward hops" (RFC 0200 §E)';
|
|
@@ -93,9 +94,19 @@ describe('RFC 0200 §E — inbound-credential-no-passthrough (the caller\'s cred
|
|
|
93
94
|
});
|
|
94
95
|
if (created.status !== 201) return softSkip('blocked', `could not start ${FIXTURE} (${created.status}) — no run, no delivery, nothing to inspect`);
|
|
95
96
|
|
|
96
|
-
|
|
97
|
+
// Unfailable-leg audit, 2026-09-26: the wait accepted ANY non-verification
|
|
98
|
+
// hit, so a foreign run.completed (a parallel scenario's run) could end it
|
|
99
|
+
// before THIS run's delivery — the one carrying the canaries' run — arrived.
|
|
100
|
+
// The wait now requires a delivery for this run; every hit is still scanned.
|
|
101
|
+
const runId = (created.json as { runId?: unknown } | null)?.runId;
|
|
102
|
+
if (typeof runId !== 'string' || runId === '') return softSkip('blocked', `POST ${runsPath()} answered 201 without a runId — this run's delivery cannot be told apart from another run's`);
|
|
103
|
+
const ours = (): number => (subscriptionId !== null
|
|
104
|
+
? deliveriesFor(rx.hits, subscriptionId, runId)
|
|
105
|
+
: rx.hits.filter((h) => { if (h.verification) return false; try { return (JSON.parse(h.body) as { runId?: unknown }).runId === runId; } catch { return false; } })
|
|
106
|
+
).length;
|
|
107
|
+
const delivered = await waitFor(() => ours() > 0, WAIT_MS);
|
|
97
108
|
// A hop that never happened leaks nothing: this is `blocked`, not a pass.
|
|
98
|
-
if (!delivered) return softSkip('blocked', `no delivery reached the suite-owned receiver within ${WAIT_MS}ms — an unmade hop cannot witness that a credential did not cross it`);
|
|
109
|
+
if (!delivered) return softSkip('blocked', `no delivery for run ${runId} reached the suite-owned receiver within ${WAIT_MS}ms (${rx.hits.filter((h) => !h.verification).length} other capture(s)) — an unmade hop cannot witness that a credential did not cross it`);
|
|
99
110
|
|
|
100
111
|
const canaries: Array<[string, string]> = [['the suite\'s own API key', env.apiKey], ['the Cookie canary', c1], ['the Proxy-Authorization canary', c2]];
|
|
101
112
|
const found: string[] = [];
|
|
@@ -42,6 +42,7 @@ import { readErrorCode } from '../lib/error-envelope.js';
|
|
|
42
42
|
import { FIXTURES_DIR } from '../lib/paths.js';
|
|
43
43
|
import { softSkip } from '../lib/soft-skip.js';
|
|
44
44
|
import { req } from '../lib/requirement-ids.js';
|
|
45
|
+
import { carriesPayload } from '../lib/carries-payload.js';
|
|
45
46
|
|
|
46
47
|
type Json = Record<string, unknown>;
|
|
47
48
|
const KIND = 'ui.a2ui-surface';
|
|
@@ -117,16 +118,9 @@ async function gate(want: (floor: number) => boolean, wantText: string): Promise
|
|
|
117
118
|
}
|
|
118
119
|
async function done(runId: string): Promise<void> { await driver.post(`/runs/${encodeURIComponent(runId)}/cancel`, {}); }
|
|
119
120
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (Array.isArray(v)) for (const x of v) yield* subtrees(x);
|
|
124
|
-
else if (v && typeof v === 'object') for (const x of Object.values(v)) yield* subtrees(x);
|
|
125
|
-
}
|
|
126
|
-
const carries = (events: unknown[], payload: unknown): boolean => {
|
|
127
|
-
const want = JSON.stringify(payload);
|
|
128
|
-
return events.some((e) => [...subtrees((e as Json)['payload'])].some((s) => JSON.stringify(s) === want));
|
|
129
|
-
};
|
|
121
|
+
// RFC 0209 §C.11 compares as JSON, not as text: member order carries no meaning
|
|
122
|
+
// (RFC 8259 §4), and a JSONB host re-sorts it. See lib/carries-payload.ts.
|
|
123
|
+
const carries = carriesPayload;
|
|
130
124
|
async function eventsOf(runId: string): Promise<unknown[]> {
|
|
131
125
|
const r = await driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`);
|
|
132
126
|
const ev = (r.json as { events?: unknown } | null)?.events;
|
|
@@ -215,6 +209,8 @@ describe('RFC 0209 §A.2 — the cross-field rules are checked at admission (sea
|
|
|
215
209
|
});
|
|
216
210
|
|
|
217
211
|
describe('RFC 0209 §C.11 — recorded surfaces are returned as recorded (seam-gated)', () => {
|
|
212
|
+
// "byte-equal" in these two titles means JCS-equal (lib/carries-payload.ts). The titles
|
|
213
|
+
// are kept because they mint the rows' title ids.
|
|
218
214
|
it('a recorded version-2 surface is returned byte-equal on poll and carried by a fork at a later fromSeq', async () => {
|
|
219
215
|
const g = await gate((f) => f >= 2, 'a floor of at least 2');
|
|
220
216
|
if (!g.ok) return softSkip(g.kind, g.reason);
|
|
@@ -225,7 +221,7 @@ describe('RFC 0209 §C.11 — recorded surfaces are returned as recorded (seam-g
|
|
|
225
221
|
expect(r.status, req('openwop.it.v2-a2ui-v09-surface.recorded-as-recorded', 'RFC 0209 §C.11', `the positive surface MUST be admitted, got ${code(r)}`)).toBe(201);
|
|
226
222
|
const sequence = (r.json as { sequence: number }).sequence;
|
|
227
223
|
const events = await eventsOf(runId);
|
|
228
|
-
expect(carries(events, payload), req('openwop.it.v2-a2ui-v09-surface.recorded-as-recorded', 'RFC 0209 §C.11', 'the run\'s poll MUST return the recorded surface payload
|
|
224
|
+
expect(carries(events, payload), req('openwop.it.v2-a2ui-v09-surface.recorded-as-recorded', 'RFC 0209 §C.11', 'the run\'s poll MUST return the recorded surface payload unchanged as JSON (JCS-equal; member order carries no meaning) — no surface is regenerated')).toBe(true);
|
|
229
225
|
// The surface is the last event of the suspended run, so `sequence + 1`
|
|
230
226
|
// names no event and runs.md §Fork REQUIRES 422 fork_point_invalid for it.
|
|
231
227
|
// Record one more envelope on the same surface and fork AT that event:
|
|
@@ -250,7 +246,7 @@ describe('RFC 0209 §C.11 — recorded surfaces are returned as recorded (seam-g
|
|
|
250
246
|
const r = await emit(runId, envelope(1, V1_SURFACE));
|
|
251
247
|
expect(r.status, req('openwop.requirement.0209.legacy-readable', 'RFC 0209 §C.11', `a version-1 surface at floor 1 MUST be admitted, got ${code(r)}`)).toBe(201);
|
|
252
248
|
const sequence = (r.json as { sequence: number }).sequence;
|
|
253
|
-
expect(carries(await eventsOf(runId), V1_SURFACE), req('openwop.requirement.0209.legacy-readable', 'RFC 0209 §C.11', 'the poll MUST return the version-1 surface
|
|
249
|
+
expect(carries(await eventsOf(runId), V1_SURFACE), req('openwop.requirement.0209.legacy-readable', 'RFC 0209 §C.11', 'the poll MUST return the version-1 surface unchanged as JSON (JCS-equal)')).toBe(true);
|
|
254
250
|
// As in the version-2 leg: fork at a recorded event after the surface, never at `sequence + 1` (no such event; 422 fork_point_invalid).
|
|
255
251
|
const later = await emit(runId, envelope(1, V1_LATER));
|
|
256
252
|
expect(later.status, req('openwop.requirement.0209.legacy-readable', 'RFC 0209 §C.11', `a second version-1 surface at floor 1 MUST be admitted (it supplies the fork point), got ${code(later)}`)).toBe(201);
|
|
@@ -65,9 +65,16 @@ describe('v2-advertised-fixtures-exist (RFC 0168 §C.3)', () => {
|
|
|
65
65
|
const ids = advertisedIds(doc);
|
|
66
66
|
if (ids.length === 0) return softSkip('inapplicable', 'the host advertises no fixtures[]');
|
|
67
67
|
|
|
68
|
-
// Deterministic sample
|
|
68
|
+
// Deterministic sample spread across the sorted list, so a failure is
|
|
69
69
|
// reproducible and a host cannot pass by luck of ordering.
|
|
70
|
-
|
|
70
|
+
// Unfailable-leg audit, 2026-09-26: the sample was the first N by sort
|
|
71
|
+
// order, so an unseeded fixture sorting 6th or later was never attempted.
|
|
72
|
+
// Indices round(i*(n-1)/(SAMPLE-1)), deduped, reach both ends and between.
|
|
73
|
+
const sorted = [...new Set(ids)].sort();
|
|
74
|
+
const picks = sorted.length <= SAMPLE
|
|
75
|
+
? sorted.map((_, i) => i)
|
|
76
|
+
: Array.from({ length: SAMPLE }, (_, i) => Math.round((i * (sorted.length - 1)) / (SAMPLE - 1)));
|
|
77
|
+
const sample = [...new Set(picks)].map((i) => sorted[i]!);
|
|
71
78
|
const unreachable: string[] = [];
|
|
72
79
|
let attempted = 0;
|
|
73
80
|
for (const id of sample) {
|
|
@@ -79,7 +86,14 @@ describe('v2-advertised-fixtures-exist (RFC 0168 §C.3)', () => {
|
|
|
79
86
|
// 201 is the claim honoured. A 4xx that names the workflow as unknown is
|
|
80
87
|
// the drift this leg exists to catch; any other status is a different
|
|
81
88
|
// problem and is not judged here.
|
|
82
|
-
|
|
89
|
+
// The comment above always said only an unknown-workflow refusal is
|
|
90
|
+
// judged, but the code counted ANY non-201. The 2026-09-26 audit's wider
|
|
91
|
+
// sample then hit fixtures that need an Idempotency-Key or specific
|
|
92
|
+
// inputs (a validation 4xx) and failed a conforming host. v2 has no
|
|
93
|
+
// dedicated unknown-workflow code, so the signal is 404, or a 4xx whose
|
|
94
|
+
// details.field names workflowId.
|
|
95
|
+
const field = (res.json as { details?: { field?: unknown } } | null)?.details?.field;
|
|
96
|
+
if (res.status === 404 || (res.status >= 400 && res.status < 500 && field === 'workflowId')) unreachable.push(`${id} → ${res.status}`);
|
|
83
97
|
}
|
|
84
98
|
if (attempted === 0) return softSkip('blocked', 'no advertised fixture could be attempted');
|
|
85
99
|
expect(
|
|
@@ -118,12 +118,18 @@ describe('RFC 0205 §A.3 — a url Part does not resolve beyond the caller\'s au
|
|
|
118
118
|
const tenantB = process.env['OPENWOP_TEST_TENANT_B_API_KEY'];
|
|
119
119
|
if (!tenantB) return softSkip('blocked', 'the artifact carries a url Part but OPENWOP_TEST_TENANT_B_API_KEY is not supplied — the second-tenant leg cannot run');
|
|
120
120
|
const base = loadEnv().baseUrl;
|
|
121
|
+
// unfailable-leg audit, 2026-09-26: the probes use redirect:'manual' and
|
|
122
|
+
// only failed on a 2xx, so a url that answered 302 to a public pre-signed
|
|
123
|
+
// object-store URL — readable by tenant B and by anyone — passed. A 3xx is
|
|
124
|
+
// the host resolving the url for that caller; the only answers that do not
|
|
125
|
+
// resolve it are a refusal (401/403) or a non-disclosing 404.
|
|
126
|
+
const REFUSED = new Set([401, 403, 404]);
|
|
121
127
|
for (const u of urls) {
|
|
122
128
|
const abs = new URL(u, base).toString();
|
|
123
129
|
const asB = await fetch(abs, { headers: { Authorization: `Bearer ${tenantB}` }, redirect: 'manual' });
|
|
124
|
-
expect(
|
|
130
|
+
expect(REFUSED.has(asB.status), req('openwop.requirement.0205.artifact-url-part-scoped', 'RFC 0205 §A.3', `${abs} MUST NOT resolve for tenant B's credential: expected 401/403/404, got ${asB.status}${asB.status >= 300 && asB.status < 400 ? ` (a redirect to ${asB.headers.get('location') ?? 'no Location'} resolves the url)` : ''}`)).toBe(true);
|
|
125
131
|
const anon = await fetch(abs, { redirect: 'manual' });
|
|
126
|
-
expect(
|
|
132
|
+
expect(REFUSED.has(anon.status), req('openwop.requirement.0205.artifact-url-part-scoped', 'RFC 0205 §A.3', `${abs} MUST NOT resolve with no credential: expected 401/403/404, got ${anon.status}${anon.status >= 300 && anon.status < 400 ? ` (a redirect to ${anon.headers.get('location') ?? 'no Location'} resolves the url)` : ''}`)).toBe(true);
|
|
127
133
|
}
|
|
128
134
|
});
|
|
129
135
|
});
|
|
@@ -221,9 +221,14 @@ describe('v2 bound-id kinds (identity.md §5, RFC 0187 §A)', () => {
|
|
|
221
221
|
const res = await http(() => driver.get(`/runs/${projectBoundId(runId)}/effects`));
|
|
222
222
|
if (res === null) return softSkip('blocked', 'GET /runs/{runId}/effects unreachable (fetch failed)');
|
|
223
223
|
if (res.status === 404 || res.status === 405) return softSkip('inapplicable', `the host advertises idempotency but serves no effect ledger at /runs/{runId}/effects (${res.status})`);
|
|
224
|
-
expect(res.status, req(PER_KIND, 'security-defaults.md §Layer-2 effect identity', 'GET /runs/{runId}/effects MUST answer 200')).toBe(200);
|
|
225
224
|
const effects = ((res.json as { effects?: Array<Record<string, unknown>> } | null)?.effects ?? []);
|
|
226
|
-
|
|
225
|
+
// Unfailable-leg audit (2026-09-26): the status assert used to precede this
|
|
226
|
+
// empty check, so on every host (the noop fixture records no effects) the
|
|
227
|
+
// leg asserted 200 then soft-skipped — `executed-pass` partial-witness folded
|
|
228
|
+
// into the shared per-kind row with no effectId ever read. The empty 200 now
|
|
229
|
+
// returns before any assertion.
|
|
230
|
+
if (res.status === 200 && effects.length === 0) return softSkip('inapplicable', 'the noop fixture records no effects — no effectId on the wire to read (a host with an effect-producing fixture witnesses this leg)');
|
|
231
|
+
expect(res.status, req(PER_KIND, 'security-defaults.md §Layer-2 effect identity', 'GET /runs/{runId}/effects MUST answer 200')).toBe(200);
|
|
227
232
|
for (const e of effects) expectBound(e['effectId'], 'effectId', 'GET /runs/{runId}/effects', PER_KIND);
|
|
228
233
|
});
|
|
229
234
|
|
|
@@ -244,9 +249,13 @@ describe('v2 bound-id kinds (identity.md §5, RFC 0187 §A)', () => {
|
|
|
244
249
|
try {
|
|
245
250
|
const res = await http(() => driver.get(`/webhooks/${projectBoundId(webhookId)}/dead-letters`));
|
|
246
251
|
if (res === null) return softSkip('blocked', 'GET /webhooks/{webhookId}/dead-letters unreachable (fetch failed)');
|
|
247
|
-
expect(res.status, req(PER_KIND, 'RFC 0188 §A.1', 'a host advertising webhooks.deadLetter MUST serve the dead-letter read (200)')).toBe(200);
|
|
248
252
|
const rows = ((res.json as { deliveries?: Array<Record<string, unknown>> } | null)?.deliveries ?? []);
|
|
249
|
-
|
|
253
|
+
// Unfailable-leg audit (2026-09-26): as with effectId above, the 200 assert
|
|
254
|
+
// preceded the empty check, so a fresh subscription (always empty) recorded
|
|
255
|
+
// `executed-pass` partial-witness with no deliveryId read. Empty-200 now
|
|
256
|
+
// returns before any assertion.
|
|
257
|
+
if (res.status === 200 && rows.length === 0) return softSkip('inapplicable', 'the subscription has no dead-lettered delivery in this run — the read is served and the shape is unwitnessed here; v2-webhook-durable-delivery drives an exhaustion and asserts the record');
|
|
258
|
+
expect(res.status, req(PER_KIND, 'RFC 0188 §A.1', 'a host advertising webhooks.deadLetter MUST serve the dead-letter read (200)')).toBe(200);
|
|
250
259
|
for (const r of rows) expectBound(r['deliveryId'], 'deliveryId', 'GET /webhooks/{webhookId}/dead-letters', PER_KIND);
|
|
251
260
|
} finally {
|
|
252
261
|
await http(() => driver.delete(`/webhooks/${projectBoundId(webhookId)}`));
|
|
@@ -53,24 +53,36 @@ describe('RFC 0173 §B — effect-identity-business-key (gated on idempotency)',
|
|
|
53
53
|
const fixtures = Array.isArray(doc['fixtures']) ? (doc['fixtures'] as unknown[]) : [];
|
|
54
54
|
if (!fixtures.includes(FIXTURE)) return softSkip('inapplicable', `${FIXTURE} fixture not advertised — no run to read`);
|
|
55
55
|
|
|
56
|
+
// Unfailable-leg audit (2026-09-26): the noop fixture issues no effects, so
|
|
57
|
+
// the per-row loop asserted nothing on any host, and the trailing
|
|
58
|
+
// `softSkip('inapplicable')` (called without `return`, after four passing
|
|
59
|
+
// setup asserts) recorded `executed-pass` with a `partial-witness:` detail —
|
|
60
|
+
// a host that mis-keyed every effect passed this row. Now no expect passes
|
|
61
|
+
// before the empty-ledger check: a failing setup read still fails, and an
|
|
62
|
+
// empty but well-formed ledger returns `inapplicable` with zero assertions.
|
|
56
63
|
const create = await driver.post('/runs', { workflowId: FIXTURE });
|
|
57
|
-
|
|
64
|
+
if (create.status !== 201) {
|
|
65
|
+
expect(create.status, req('openwop.requirement.0173.effect-identity-business-key', 'runs.md §Create', 'POST /runs MUST answer 201 for the noop fixture')).toBe(201);
|
|
66
|
+
}
|
|
58
67
|
const runId = (create.json as { runId: string }).runId;
|
|
59
68
|
await waitTerminal(runId, 10_000);
|
|
60
69
|
|
|
61
70
|
const res = await driver.get(`/runs/${encodeURIComponent(runId)}/effects`);
|
|
71
|
+
const check = v2Validator('effect-ledger-projection')(res.json);
|
|
72
|
+
const body = res.json as { runId?: unknown; effects?: Array<{ effectId?: unknown; keying?: unknown; providerKey?: unknown }> } | null;
|
|
73
|
+
const effects = body?.effects ?? [];
|
|
74
|
+
if (res.status === 200 && check.ok && body?.runId === runId && effects.length === 0) {
|
|
75
|
+
return softSkip('inapplicable', 'the noop fixture issued no external effect — the ledger read is well-formed but the per-row keying leg had no rows (an effect-issuing fixture would exercise it)');
|
|
76
|
+
}
|
|
62
77
|
expect(
|
|
63
78
|
res.status,
|
|
64
79
|
req('openwop.requirement.0173.effect-identity-business-key', 'security-defaults.md §Layer-2 effect identity', 'a host advertising `idempotency` MUST serve GET /runs/{runId}/effects with 200 (RFC 0173 §B)'),
|
|
65
80
|
).toBe(200);
|
|
66
|
-
const check = v2Validator('effect-ledger-projection')(res.json);
|
|
67
81
|
expect(
|
|
68
82
|
check.ok,
|
|
69
83
|
req('openwop.requirement.0173.effect-identity-business-key', 'effect-ledger-projection.schema.json', `the ledger projection MUST validate: ${check.errors}`),
|
|
70
84
|
).toBe(true);
|
|
71
|
-
|
|
72
|
-
expect(body.runId, req('openwop.requirement.0173.effect-identity-business-key', 'effect-ledger-projection.schema.json runId', 'runId MUST echo the run read')).toBe(runId);
|
|
73
|
-
const effects = body.effects ?? [];
|
|
85
|
+
expect(body?.runId, req('openwop.requirement.0173.effect-identity-business-key', 'effect-ledger-projection.schema.json runId', 'runId MUST echo the run read')).toBe(runId);
|
|
74
86
|
const ids = new Set<string>();
|
|
75
87
|
for (const e of effects) {
|
|
76
88
|
expect(
|
|
@@ -90,7 +102,6 @@ describe('RFC 0173 §B — effect-identity-business-key (gated on idempotency)',
|
|
|
90
102
|
).toBe(false);
|
|
91
103
|
}
|
|
92
104
|
}
|
|
93
|
-
if (effects.length === 0) softSkip('inapplicable', 'the noop fixture issued no external effect — the per-row keying leg had no rows (an effect-issuing fixture would exercise it)');
|
|
94
105
|
});
|
|
95
106
|
|
|
96
107
|
it('the same provider key is presented across two transport retries', async () => {
|
|
@@ -117,16 +128,43 @@ describe('RFC 0173 §B — effect-identity-business-key (gated on idempotency)',
|
|
|
117
128
|
if (attempts.length < 2) {
|
|
118
129
|
return softSkip('blocked', `the seam produced ${attempts.length} ledger row(s) for effect ${String(body.effectId)} — a cross-retry assertion needs at least two attempts`);
|
|
119
130
|
}
|
|
120
|
-
|
|
131
|
+
// Unfailable-leg audit (2026-09-26): `providerKey` is optional in the ledger
|
|
132
|
+
// schema and `?? ''` folded a missing key to '', so a host that recorded no
|
|
133
|
+
// provider key on any attempt showed "one distinct key" and passed. Every
|
|
134
|
+
// attempt now MUST carry a non-empty key. Remaining limit: the witness is
|
|
135
|
+
// still the host's own ledger; a suite-owned receiver counting the
|
|
136
|
+
// Idempotency-Key header on the wire (lib/effect-receiver.ts) is the
|
|
137
|
+
// stronger follow-up, not done here.
|
|
138
|
+
for (const a of attempts) {
|
|
139
|
+
expect(
|
|
140
|
+
typeof a['providerKey'] === 'string' && (a['providerKey'] as string).length > 0,
|
|
141
|
+
req('openwop.requirement.0173.effect-identity-business-key.retry', 'spec/v2/core/idempotency.md §Layer 2: effect identity', `every attempt of one effect MUST record the provider key it presented — attempt ${String(a['attempt'])} recorded ${JSON.stringify(a['providerKey'] ?? null)}`),
|
|
142
|
+
).toBe(true);
|
|
143
|
+
}
|
|
144
|
+
const keys = new Set(attempts.map((e) => String(e['providerKey'])));
|
|
121
145
|
expect(
|
|
122
146
|
keys.size,
|
|
123
147
|
req('openwop.requirement.0173.effect-identity-business-key.retry', 'spec/v2/core/idempotency.md §Layer 2: effect identity', `every attempt of one effect MUST present the same provider key across a transport retry — ${attempts.length} attempt(s) presented ${keys.size} distinct key(s)`),
|
|
124
148
|
).toBe(1);
|
|
149
|
+
// idempotency.md §Layer 2: business identity is the rule, and the activity
|
|
150
|
+
// recipe is "the fallback for a provider with no business key". The seam's
|
|
151
|
+
// effect is a POST to a suite-chosen providerUrl, which has no business key,
|
|
152
|
+
// so `activity-recipe` is the CONFORMANT keying here. Until 2.42.2 this leg
|
|
153
|
+
// demanded `business-identity` on every attempt, which is stricter than the
|
|
154
|
+
// spec, and a host honestly declaring the fallback failed. What the spec does
|
|
155
|
+
// require of a retry is one effect, one key: every attempt carries a documented
|
|
156
|
+
// keying, and the SAME one. A host that switches modes between attempts has
|
|
157
|
+
// re-derived the effect's identity mid-flight.
|
|
158
|
+
const keyings = new Set(attempts.map((a) => String(a['keying'])));
|
|
125
159
|
for (const a of attempts) {
|
|
126
160
|
expect(
|
|
127
|
-
|
|
128
|
-
req('openwop.requirement.0173.effect-identity-business-key.retry', 'spec/v2/core/idempotency.md §Layer 2: effect identity', `
|
|
129
|
-
).
|
|
161
|
+
KEYING,
|
|
162
|
+
req('openwop.requirement.0173.effect-identity-business-key.retry', 'spec/v2/core/idempotency.md §Layer 2: effect identity', `every attempt MUST declare a documented keying — business-identity, or activity-recipe for a provider with no business key (attempt ${String(a['attempt'])} declares ${String(a['keying'])})`),
|
|
163
|
+
).toContain(a['keying']);
|
|
130
164
|
}
|
|
165
|
+
expect(
|
|
166
|
+
keyings.size,
|
|
167
|
+
req('openwop.requirement.0173.effect-identity-business-key.retry', 'spec/v2/core/idempotency.md §Layer 2: effect identity', `every attempt of one effect MUST declare the same keying — ${attempts.length} attempt(s) declared ${[...keyings].join(', ')}`),
|
|
168
|
+
).toBe(1);
|
|
131
169
|
});
|
|
132
170
|
});
|
|
@@ -95,15 +95,9 @@ describe('RFC 0173 §C.1 — effect-seam-manifest (gated on replay)', () => {
|
|
|
95
95
|
).toBe(true);
|
|
96
96
|
}
|
|
97
97
|
}
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
for (const kind of declared) {
|
|
102
|
-
expect(
|
|
103
|
-
rows.filter((r) => r.kind === kind).length,
|
|
104
|
-
req('openwop.requirement.0173.effect-seam-manifest', 'RFC 0173 §C.1', `at least one manifest row per declared kind (${kind})`),
|
|
105
|
-
).toBeGreaterThanOrEqual(1);
|
|
106
|
-
}
|
|
98
|
+
// Unfailable-leg audit (2026-09-26): a "one row per declared kind" loop stood
|
|
99
|
+
// here, but it built the declared set FROM the rows, so every host passed it.
|
|
100
|
+
// The manifest has no kind declaration independent of its rows; removed.
|
|
107
101
|
// RFC 0140 G6: a `branch` re-fire is stated as a permission, and a permission
|
|
108
102
|
// for a mode the host does not offer is a claim about nothing.
|
|
109
103
|
const modes = Array.isArray(replay['modes']) ? (replay['modes'] as unknown[]) : [];
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
* on `replay`, driven through the seams profile).
|
|
4
4
|
*
|
|
5
5
|
* A guarded manifest row that states `branchReFires: false` MUST NOT be fired
|
|
6
|
-
* again by a replay fork: the fork's Layer-2 effect ledger
|
|
7
|
-
* parent
|
|
6
|
+
* again by a replay fork: the fork's Layer-2 effect ledger records no attempt
|
|
7
|
+
* the parent did not make (suite 2.42.2; until then "cannot grow past the
|
|
8
|
+
* parent's", which a one-attempt source let a re-firing host pass at 1 ≤ 1). The witness is the host's own ledger, reached by
|
|
8
9
|
* firing a named manifest row inside a run through the catalogued seam
|
|
9
10
|
* (`api/seams-v2.yaml` `fireEffectSeam`), forking in `replay` mode, and reading
|
|
10
11
|
* `GET /runs/{runId}/effects` on both.
|
|
@@ -28,6 +29,7 @@ import { v2Discovery, gateFamily } from '../lib/v2.js';
|
|
|
28
29
|
import { seamsProfileAdvertised, SEAMS_PREFIX } from '../lib/seams.js';
|
|
29
30
|
import { softSkip } from '../lib/soft-skip.js';
|
|
30
31
|
import { req } from '../lib/requirement-ids.js';
|
|
32
|
+
import { refiredAttempts, type EffectRow } from '../lib/effect-refire.js';
|
|
31
33
|
|
|
32
34
|
const MANIFEST_PATH = '/host/effect-seams';
|
|
33
35
|
|
|
@@ -115,9 +117,20 @@ describe('RFC 0173 §C.2 — effect-seam-no-refire (gated on replay, seam-driven
|
|
|
115
117
|
}
|
|
116
118
|
const forkEffects = await driver.get(`/runs/${encodeURIComponent(String(forkBody.runId))}/effects`);
|
|
117
119
|
if (forkEffects.status !== 200) return softSkip('blocked', `GET /runs/{runId}/effects answered ${forkEffects.status} on the replay fork`);
|
|
120
|
+
// Not a count comparison. The seam fires ONE attempt, so a host that
|
|
121
|
+
// re-fires on replay reads 1 on the fork and `fork <= parent` passed it:
|
|
122
|
+
// measured, openwop-app with `sideEffecting` dropped still passed (suite
|
|
123
|
+
// 2.42.2). The witness is whether the fork records any attempt the parent
|
|
124
|
+
// never made (lib/effect-refire.ts): inherited history repeats a parent
|
|
125
|
+
// attempt, a re-fire is a new one.
|
|
126
|
+
const rowsOf = (r: OpenWOPResponse): EffectRow[] => {
|
|
127
|
+
const b = r.json as { effects?: unknown } | null;
|
|
128
|
+
return Array.isArray(b?.effects) ? (b.effects as EffectRow[]) : [];
|
|
129
|
+
};
|
|
130
|
+
const refired = refiredAttempts(rowsOf(parentEffects), rowsOf(forkEffects));
|
|
118
131
|
expect(
|
|
119
|
-
|
|
120
|
-
req('openwop.requirement.0173.effect-seam-no-refire', 'spec/v2/core/replay.md §Suppression', `seam ${String(target.seam)} is guarded, so a replay fork MUST NOT issue a further attempt through it — suppression is unconditional for mode: replay (§Suppression rule 1) and does not depend on branchReFires, which states only what a BRANCH may re-fire (§Branch: a host "MUST NOT report that as replay suppression"). The fork's
|
|
121
|
-
).
|
|
132
|
+
refired.length,
|
|
133
|
+
req('openwop.requirement.0173.effect-seam-no-refire', 'spec/v2/core/replay.md §Suppression', `seam ${String(target.seam)} is guarded, so a replay fork MUST NOT issue a further attempt through it — suppression is unconditional for mode: replay (§Suppression rule 1) and does not depend on branchReFires, which states only what a BRANCH may re-fire (§Branch: a host "MUST NOT report that as replay suppression"). The fork's ledger records ${refired.length} attempt(s) the parent never made: ${JSON.stringify(refired).slice(0, 300)}`),
|
|
134
|
+
).toBe(0);
|
|
122
135
|
});
|
|
123
136
|
});
|