@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.
Files changed (30) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +1 -1
  3. package/coverage.md +1 -0
  4. package/dist/lib/certification-bundle-verify.js +13 -0
  5. package/dist/spec-artifacts.lock.json +2 -2
  6. package/package.json +2 -2
  7. package/requirements.json +91 -55
  8. package/schemas/CORPUS-STAMP.json +15 -15
  9. package/src/lib/carries-payload.ts +42 -0
  10. package/src/lib/certification-bundle-verify.ts +13 -0
  11. package/src/lib/effect-refire.ts +34 -0
  12. package/src/scenarios/certification-bundle-redaction.test.ts +1 -1
  13. package/src/scenarios/inbound-credential-no-passthrough.test.ts +13 -2
  14. package/src/scenarios/v2-a2ui-v09-surface.test.ts +8 -12
  15. package/src/scenarios/v2-advertised-fixtures-exist.test.ts +17 -3
  16. package/src/scenarios/v2-artifact-a2a-shape.test.ts +8 -2
  17. package/src/scenarios/v2-bound-id-kinds.test.ts +13 -4
  18. package/src/scenarios/v2-effect-identity-business-key.test.ts +48 -10
  19. package/src/scenarios/v2-effect-seam-manifest.test.ts +3 -9
  20. package/src/scenarios/v2-effect-seam-no-refire.test.ts +18 -5
  21. package/src/scenarios/v2-era-2-append-vocabulary.test.ts +52 -9
  22. package/src/scenarios/v2-interop-trace-context.test.ts +23 -0
  23. package/src/scenarios/v2-mcp-tasks.test.ts +16 -3
  24. package/src/scenarios/v2-mrtr-rounds-ceiling.test.ts +11 -0
  25. package/src/scenarios/v2-poll-cursor-v2.test.ts +28 -5
  26. package/src/scenarios/v2-profiles-derived-only.test.ts +9 -2
  27. package/src/scenarios/v2-webhook-delivery-isolation.test.ts +28 -8
  28. package/src/scenarios/v2-webhook-durable-delivery.test.ts +16 -0
  29. package/src/scenarios/v2-webhook-endpoint-verification.test.ts +25 -6
  30. 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.1",
5
- "corpusTag": "v2.42.1",
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": "6366b5514be71098e9ea41fc81ff52bc39db1a547b353c0b344cc2a2bf5370aa",
10
+ "api/openapi.yaml": "69464bc0e1e71ef3b543a73d4fa4f67202343d2603c80af8033f6c3619ef1b30",
11
11
  "api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
12
- "api/seams-v2.yaml": "849b6c3d37336132343468e553b413a61a133fbc943d21ffc8e961948284db9f",
13
- "api/v2/asyncapi.yaml": "96bac052de733a0987c35b4834341d91633381ff2ae1e6087ddb2956ac1edad9",
14
- "api/v2/openapi.yaml": "eb39b2957737fc0f2e3187c33aac2f1475a3d2aa382a5c57686a1a15a5b643d4",
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": "928340b243c60aaf04c1b40adb09be8a90340d44f57be1dfff837be10f104822",
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": "bbcefda69bffa9fb67803c48ddc7e786866c655d8af36f73fc0e3b27e69a1917",
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": "d91c400b704b83e65dd40dbed1c0331a6d005fdb77d3facce0eacea89c4c7f14",
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": "968c2fceb27cf76acbb14f7d250798f913bbbd3e9789dd3fb2a32c1ef536e771",
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": "38c03524e3aeea30862432d203faffb8b0b05ec65136d223e96ab06941c3717b",
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": "ae9b56d03a701061fd48ad0b73cb0373493f6a4547da92763b422295b5e19535",
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": "c226854124edd701082c57b55c908cdf09926837611a59d00e306696a06a9b8a",
297
+ "spec/v2/release.json": "1b3dde14cbaf3cfc4eab1ae9a461aab6d357c9c4cda511047087c0b57b400055",
298
298
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
299
- "spec/v2/surface-baseline.json": "8411f06cd9888524b6d234e8d111896d8c90d06991453c9a192e7550d0b3cb9e"
299
+ "spec/v2/surface-baseline.json": "dc52941e7f3e19da5151bb5c994af9b18b137a5a3b34ee0b63bc9cb2789dc52a"
300
300
  },
301
- "corpusCommit": "c92bac3ebafda0fab5a6113df67422efe428d70d"
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 = '4b8d99c002a061a0255e8224ee683162007febadd251b2d171b47c0849695315';
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
- const delivered = await waitFor(() => rx.hits.some((h) => !h.verification), WAIT_MS);
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
- /** Every subtree of `v`, depth-first. */
121
- function* subtrees(v: unknown): Generator<unknown> {
122
- yield v;
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 byte-equal — no surface is regenerated')).toBe(true);
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 byte-equal')).toBe(true);
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: the first N by sort order, so a failure is
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
- const sample = [...ids].sort().slice(0, SAMPLE);
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
- if (res.status !== 201) unreachable.push(`${id} → ${res.status}`);
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(asB.status >= 200 && asB.status < 300, req('openwop.requirement.0205.artifact-url-part-scoped', 'RFC 0205 §A.3', `${abs} MUST NOT answer 2xx to tenant B's credential (got ${asB.status})`)).toBe(false);
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(anon.status >= 200 && anon.status < 300, req('openwop.requirement.0205.artifact-url-part-scoped', 'RFC 0205 §A.3', `${abs} MUST NOT answer 2xx with no credential (got ${anon.status})`)).toBe(false);
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
- if (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)');
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
- if (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');
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
- 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);
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
- const body = res.json as { runId?: unknown; effects?: Array<{ effectId?: unknown; keying?: unknown; providerKey?: unknown }> };
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
- const keys = new Set(attempts.map((e) => String(e['providerKey'] ?? '')));
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
- a['keying'],
128
- req('openwop.requirement.0173.effect-identity-business-key.retry', 'spec/v2/core/idempotency.md §Layer 2: effect identity', `a Layer-2 host keys a retried effect on business identity, not the activity recipe (attempt ${String(a['attempt'])})`),
129
- ).toBe('business-identity');
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
- // One row per kind the host declares: the set of kinds the manifest names is
99
- // the host's declaration, and each named kind is backed by at least one row.
100
- const declared = new Set(rows.map((r) => String(r.kind)));
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 cannot grow past the
7
- * parent's for that seam. The witness is the host's own ledger, reached by
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
- countOf(forkEffects),
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 effect ledger (${countOf(forkEffects)}) cannot exceed the parent's (${countOf(parentEffects)})`),
121
- ).toBeLessThanOrEqual(countOf(parentEffects));
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
  });