@openwop/openwop-conformance 2.43.0 → 2.43.1

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,21 @@
1
1
  # `@openwop/openwop-conformance` Changelog
2
2
 
3
+ ## [2.43.1] — 2026-09-28 — major-1 certification works again, RFC 0223's routed and timed-out rejects are witnessed, and four Class 3 corrections
4
+
5
+ - **`v2-manifest-hatch-carried`'s agent fixture is schema-valid.** Its `agents[0]` had neither `systemPrompt` nor `systemPromptRef`, and `agent-manifest` (v1 and v2) requires exactly one. A conforming host therefore had to refuse the pack for a reason unrelated to the `x-` hatch, and `0177.manifest-hatch-carried.agents-x-field` could not pass on any host. The fixture now carries `systemPrompt`. Validated against `schemas/v2/agent-manifest.schema.json`: invalid before, valid after. Reported by MyndHyve.
6
+ - **RFC 0223 G1 and G2: the routed and the timed-out approval reject are witnessed** (#1700). Two new fixtures and two new rows in `v2-approval-reject-disposition.test.ts` (target major 2). Each row is `inapplicable` on a host that does not advertise its fixture.
7
+ - `conformance-approval-reject-routed` and `0223.reject-routed`: the gate has an `any_failed` edge to `on-reject` and an `all_success` edge to `on-accept`. Rejected, the gate still fails as a node (`approval_rejected`, `retryable: false`), `on-reject` completes, `on-accept` never runs, and the run completes. `node.skipped` is not required, because `interrupt.md` §Rejection says the edge is not satisfied, not how a host records it.
8
+ - `conformance-approval-timeout` and `0223.timeout-rejects`: the gate has a 1500 ms `timeoutMs` and no `onTimeout`. Left unresolved, the host resolves it with `action: "timeout"`, `decision: "rejected"`, `reason: "timeout"`, the gate fails with `approval_rejected`, and `run.failed` names it.
9
+ - Witnessed on the v2 reference host (openwop-examples `777b485`) with both fixtures added to its honoured set: all six rows `executed-pass`. Sabotaged, emptying the host's failure-admitting `triggerRule` set fails `0223.reject-routed`, and short-circuiting its timeout sweep fails `0223.timeout-rejects`. The host registers the two ids in a follow-up.
10
+ - **The pack isolation and manifest rows no longer gate on `packs`** (#1706, Class 3). `packs` advertises registry resolution, which a host that only loads local packs correctly omits, so these rows went unmeasured on exactly that host. `v2-pack-isolation` gates on `sandbox` alone. `v2-manifest-ceiling-refused`, `v2-manifest-hatch-carried` and `v2-peer-dependency-declared` gate on the seams-profile install path, and a publish-seam `404` records `inapplicable` when `packs` is absent and `blocked` when it is advertised. Requirement ids are unchanged.
11
+ - **A timeout never grants an approval gate** (#1696, Class 3). New fixture `conformance-approval-timeout-approve` (1500 ms `timeoutMs`, `onTimeout: "approve"`) and row `0223.timeout-never-grants` in `v2-approval-reject-disposition` (`inapplicable` without the fixture). The timed-out gate MUST resolve rejected (`action: timeout`, `decision: rejected`), fail with `approval_rejected`, and the run MUST fail rather than complete. Measured on the v2 reference host: `executed-fail` today, because its sweep skips a non-`reject` `onTimeout` and the run waits forever. It passes with that exclusion dropped, which is its follow-up.
12
+ - **An interrupt `key` is per visit** (#1697, Class 3). New fixture `conformance-approval-reject-loopback` (`start` → `gate`, `gate` → `revise` on `any_failed`, `revise` → `gate`) and row `0223.reject-loopback-reasks`. Rejected once, the gate is asked again under a different `key`, the run is `waiting-approval` again, and only one `interrupt.resolved` is recorded. The fixture is advertised only by a host that runs cycles; everyone else records `inapplicable`.
13
+ - **`v2-error-registry` checks the codes on events** (#1698, Class 3). New leg `openwop.requirement.errors.event-code-registered`. It runs `conformance-failure` and checks every `error.code` on `run.failed`, `node.failed` and the snapshot against the registry and the vendor pattern (the org must be registered in `declaration.json`). It is ADVISORY in 2.43.1: a violation records `executed-pass` with a `partial-witness` detail naming the codes, and becomes a failure in a later patch. On the v2 reference host it currently names `fixture_failure`. `fixtures.md`'s `core.fail` now throws `example.conformance_failure`.
14
+ - **RFC 0201 §E.20's post-overlap rule is witnessable on a long overlap.** New OPTIONAL seams-profile seam `POST /conformance/seams/sample/webhooks/rotation-overlap { webhookId, overlapSeconds }` (`host-sample-test-seams.md` §29; `api/seams-v2.yaml` `shortenRotationOverlap`) shortens the rotation overlap in progress on one subscription by moving the stored expiry the production signer reads. It is tenant-checked like `rotateWebhookSecret` and only ever shortens. `v2-webhook-secret-rotation` calls it only when the advertised `overlapSeconds` outlasts the wait cap: it sets 3 s, waits past the reported expiry, and asserts that only the new secret signs. Dispositions: seam served → real assertion; served but contract broken (wrong expiry, or a foreign-tenant id not refused `403 id_tenant_mismatch`) → `blocked`; not served, or no seams profile → the pre-seam partial witness, never `blocked`. Witnessed on the v2 reference host (openwop-examples#117) with a 3600 s overlap: `executed-pass` (17 assertions); seam unmounted → partial witness; a signer that ignores the expiry → `executed-fail`; a seam that misreports the expiry → `blocked`.
15
+ - **`--certify --bundle-version 3` no longer refuses its own bundle when a secret is redacted from a row.** The emitter digested and signed the raw requirement rows, then ran `scrubEvidence` over the finished bundle. A configured secret (the handed `--api-key`, an `OPENWOP_*` key/token/secret env value, or the canary) found in a row's `detail` or `evidence` was redacted AFTER `witnessSha256` was taken, so the written rows no longer hashed to it and self-verification exited 2 with `[witness-digest]` and no bundle. openwop-app hit it at major 1 on 2.43.0. The new `sealBundleV3` scrubs first, then digests and signs, so the signed rows are the shipped rows; the v3 path now also prints the `REDACTED … field(s)` line the v2 path always had. Reproduced on published 2.43.0 against the v1 SQLite reference host at major 1 with an `OPENWOP_*_SECRET` value that occurs in 274 row details: exit 2, `witnessSha256 0657c889365e` (the clean run's digest) against rows digesting to `803b02c17414`. With the fix the same run writes a bundle `--verify` accepts. New self-tests in `certification-bundle-v3.test.ts` pin both orders. Sabotaged, digesting the pre-scrub rows fails the new test. No wire, schema or digest-preimage change.
16
+ - **The org-chart read pair's served bodies are validated against their schemas** (RFC 0087 §A/§D). Only a server-free probe compiled `agent-org-chart.schema.json`; the live scenario walked the tree and member refs and never validated the body. openwop-app served its stored record (`{ tenantId, departments, members, updatedAt }`: `owner` missing, two extra keys on a closed object) at both majors, and every row passed (fixed in openwop-app #4189). New row `openwop.it.agent-org-chart-scoping.serves-the-org-chart-read-pair-in-the-exact-schema-shape` (major 1, same `openwop-org-chart-scoping` gate) and new file `v2-agent-org-chart-served-shape.test.ts` (major 2, `inapplicable` unless the v2 `agents` record carries `orgChart`) validate `GET /agents/org-chart` against `agent-org-chart.schema.json` and, when `responsibilityView` is advertised, `GET /agents/org-chart/{departmentId}` against `org-chart-responsibility-view.schema.json`. Witnessed on openwop-app `c4aad98c2` in-process (one seeded department): `executed-pass` at both majors; the pre-fix `routes/orgChart.ts` (`553355847`) → `executed-fail` at both majors on `owner` + extra keys while the old tree row still passed; an extra key on the department view alone → `executed-fail` at both majors. openwop-app does not advertise `agents` at major 2, so the v2 row records `inapplicable` there; the major-2 witness advertised `agents.orgChart` via a local, uncommitted discovery patch.
17
+ - **Suite `2.43.1`**. This adds scenario rows and fixtures, the same class as the 2.42.8 and 2.42.9 scenario changes. The only packed contract file that changes is `spec/v1/gaps.json`, where G1 and G2 close. No wire change. `@openwop/spec-artifacts` moves in lockstep at the same exact pin.
18
+
3
19
  ## [2.43.0] — 2026-09-28 — four RFCs: approval reject, registry operations, the audit anomaly shape, and a v2 home for audit-log integrity
4
20
 
5
21
  - **RFC 0222: v2 registry lifecycle and signing keys, witnessed against a registry.** New `v2-registry-lifecycle.test.ts` (target major 2). It reads a registry, not the host: it runs when `OPENWOP_REGISTRY_URL` names one or `OPENWOP_TEST_PUBLIC_REGISTRY=true` names `packs.openwop.dev`, records `inapplicable` otherwise, and resolves every path through `.well-known` `endpoints.v2`. Leg 1 (`openwop.requirement.0222.yanked-version-lifecycle`): every pack index agrees with its version manifests on `yanked` and `versionDeprecated`, never names a yanked version `latest` while an unyanked one exists, and a yanked version's tarball and signature stay served. Leg 2 (`openwop.requirement.0222.signing-keys-cover-served-versions`): every served version names a key `signingKeys[]` still lists, whose `permittedNamespaces` admit it, and its integrity and signature over the in-tarball `pack.json` verify, whatever the key's `status`. Passes on `packs.openwop.dev` (198 versions; leg 1 `partial-witness`, no v2 version is yanked) and on a local tree with a yanked version; fails on seven sabotages, including the registry's previous `build-index.mjs`.
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.43.0 @openwop/spec-artifacts@2.43.0
14
+ npm install --legacy-peer-deps @openwop/openwop-conformance@2.43.1 @openwop/spec-artifacts@2.43.1
15
15
  # or run without install:
16
16
  npx @openwop/openwop-conformance --base-url https://api.example.com --api-key hk_test_...
17
17
  ```
@@ -135,7 +135,7 @@ Exit code is non-zero on any failed assertion. `--certify` distinguishes: `0`
135
135
 
136
136
  ## What's Covered
137
137
 
138
- The current suite has 570 scenario files under `src/scenarios/`.
138
+ The current suite has 571 scenario files under `src/scenarios/`.
139
139
  - 2026-09-23 (suite 2.37.0 cycle, RFC 0213): NEW `v2-sse-last-event-id-cursor.test.ts` (a `Last-Event-ID` past the log is an exclusive cursor; a malformed id, when refused, is `400 validation_error`; the cursor never changes the answer for an unknown or foreign-tenant run — public test of `event-cursor-after-authorization`), `v2-idempotency-in-flight.test.ts` (five concurrent same-key creates yield one run; each loser is a marked replay or `409 idempotency_in_flight` with no retry timing in `details`; `partial-witness` when no loser was refused in flight) and `v2-interrupt-resolve-terminal.test.ts` (a run-scoped resolve after cancel or completion is `409 interrupt_already_resolved`, never `interrupt_cancelled`). All three sit off the core-standard floor until measured on the three bundle hosts.
140
140
  - 2026-09-03 (suite `1.157.0 -> 1.158.0`, gap G17): NEW `idempotency-concurrent-claim.test.ts` — drives the new `host-sample-test-seams.md` §25 concurrent duplicate-delivery seam for the RFC 0150 §B / `idempotency.md` §"Concurrent duplicates (Layer 2)" atomic-claim MUST, which is unconditional and had no witness of any kind. Asserts every executor mints the SAME `logicalInvocationId` **before** asserting `delivered === 1` — without the identity check a host passes by minting different ids and never colliding, one effect because nothing raced. Not profile-gated and so not opt-out-able (the obligation is unconditional); an unmounted seam records `blocked`, which is not certifiable. Graduates `layer2-invocation-claim-atomic` reference-impl -> protocol.
141
141
  - 2026-08-19 (suite `1.137.0 → 1.138.0`): NEW `durability-poison-exhaustion.test.ts` — RFC 0158 §C.8, the FIRST row of that RFC's conformance table to land. Asserts what `failure-path.test.ts` cannot: not just that deterministically failing work reaches terminal, but that attempts STOP — counted on the log, re-counted after a scaled quiet window, asserted unchanged. A host still redelivering records more. Seam-gated on the existing event-log seam (`blocked` = unobservable, not unmet) and outside every profile floor.
@@ -481,7 +481,7 @@ Server-required (added in 1.7.0):
481
481
  | ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
482
482
  | **Redaction** | [`capabilities.md`](../spec/v1/capabilities.md) §"Secrets" + NFR-7 + §"aiProviders" | Vendor-neutral assertions that the server doesn't leak secret material. Three scenario groups: (a) discovery shape contract — `secrets` + `aiProviders` advertisements are well-formed regardless of `secrets.supported`; when `supported === true`, scopes MUST be non-empty + `resolution === 'host-managed'`; `byok ⊆ supported`. (b) bearer-token redaction — invalid Bearer canary in `Authorization` header is not echoed in the 401 response body. (c) credentialRef echo control — gated on `secrets.supported === true`; canary planted in `configurable.ai.credentialRef` MUST NOT appear in any RunEvent payload (poll-based capture; transport-agnostic). Uses runtime-built canary fixtures (`lib/canaries.ts`) that defeat static secret scanners. 6 scenarios. |
483
483
 
484
- Current source tree: 570 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
484
+ Current source tree: 571 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
485
485
 
486
486
  ## Remaining Gaps
487
487
 
package/dist/cli.js CHANGED
@@ -45,7 +45,7 @@ import { readLedgerFile } from './lib/requirement-ledger.js';
45
45
  import { deriveRung, emittedByNewerSuite } from './lib/durability-evidence.js';
46
46
  import { deriveRequirementDispositions } from './lib/scenario-disposition.js';
47
47
  import { scrubEvidence, evidenceSecretsFromEnv, verifyBundleV2 } from './lib/certification-bundle-verify.js';
48
- import { publicKeyFromPrivate, signBundleV3, verifierSign, verifyBundleV3, witnessDigest } from './lib/certification-bundle-v3.js';
48
+ import { publicKeyFromPrivate, sealBundleV3, verifierSign, verifyBundleV3 } from './lib/certification-bundle-v3.js';
49
49
  import { canonicalJSON, parseIJson } from './lib/jcs.js';
50
50
  import { deriveProfiles, isCoreStandard, agentPlatformStatus, DEPRECATED_PROFILE_ALIASES, PROFILE_FLOOR_SCENARIOS, } from './lib/profiles.js';
51
51
  import { setV2ProfileFloors, v2ProfileFloorFiles } from './lib/requirement-registry.js';
@@ -687,7 +687,7 @@ async function runCertify(args, baseUrl, apiKey) {
687
687
  if (rows3.some((r) => r.id.startsWith('openwop.requirement.0158.') && r.result === 'executed-pass' && r.id !== 'openwop.requirement.0158.poison-exhaustion')) {
688
688
  process.stderr.write(`openwop-conformance --certify: RFC 0158 rung — ${rung3.rung ?? 'NONE'} (${rung3.why})\n`);
689
689
  }
690
- const unsigned = {
690
+ const draft = {
691
691
  bundleVersion: '3',
692
692
  generatedAt: new Date().toISOString(),
693
693
  suite: { name: '@openwop/openwop-conformance', version, targetMajor: target.major, specArtifactsVersion: lock?.version ?? 'repo-layout', ...(lock ? { stampSha256: lock.stampSha256 } : {}) },
@@ -699,7 +699,6 @@ async function runCertify(args, baseUrl, apiKey) {
699
699
  discovery: { url: discoveryUrl, sha256, protocolVersions, preferredVersion, document },
700
700
  claimedProfiles: claimed3,
701
701
  results: { totals: totals3, requirements: rows3 },
702
- witnessSha256: witnessDigest(rows3, relaxations, args.colocatedCompanion ? 'colocated-companion' : undefined),
703
702
  assertionCount: rows3.reduce((n, r) => n + (r.assertions ?? 0), 0),
704
703
  ...(nonPass.length ? { detail: { nonPass: nonPass.map((r) => ({ id: r.id, result: r.result, reason: r.detail ?? '' })) } } : {}),
705
704
  // RFC 0158 §D: claimed ONLY when these rows support it. The verifier
@@ -707,8 +706,18 @@ async function runCertify(args, baseUrl, apiKey) {
707
706
  // claimed more would be writing a bundle its own `--verify` rejects.
708
707
  ...(rung3.rung === null ? {} : { durability: { rung: rung3.rung } }),
709
708
  };
710
- const signature = signBundleV3(unsigned, signingKeyPem, keyId);
711
- const v3 = { ...unsigned, signature };
709
+ // The published keyId is never a secret, whichever variable carried it
710
+ // (evidenceSecretsFromEnv: the `_ID` exclusion is the rule, `except` is
711
+ // the belt for a host that names its env var differently). Scrubbed BEFORE
712
+ // the digest and the signature (sealBundleV3), so the signed rows are the
713
+ // written rows.
714
+ const secrets3 = evidenceSecretsFromEnv(process.env, [apiKey, signingKeyPem], [keyId]);
715
+ const sealed = sealBundleV3(draft, secrets3, signingKeyPem, keyId);
716
+ const { unsigned } = sealed;
717
+ const v3Out = sealed.bundle;
718
+ if (sealed.redactedAt.length > 0) {
719
+ process.stderr.write(`openwop-conformance --certify: REDACTED ${sealed.redactedAt.length} evidence field(s) that carried a configured secret or the conformance canary: ${sealed.redactedAt.slice(0, 8).join(', ')}${sealed.redactedAt.length > 8 ? ', …' : ''}\n`);
720
+ }
712
721
  if (args.evidenceTier === 'independent') {
713
722
  const vk = args.verifierKeyPath ? readFileSync(args.verifierKeyPath, 'utf8') : process.env['OPENWOP_BUNDLE_VERIFIER_KEY'];
714
723
  const vkId = args.verifierKeyId ?? process.env['OPENWOP_BUNDLE_VERIFIER_KEY_ID'];
@@ -716,14 +725,8 @@ async function runCertify(args, baseUrl, apiKey) {
716
725
  process.stderr.write('openwop-conformance --certify: --evidence-tier independent needs --verifier-key and --verifier-key-id (RFC 0168 §E.2)\n');
717
726
  process.exit(2);
718
727
  }
719
- v3.verifierSignature = verifierSign(unsigned, vk, vkId);
728
+ v3Out.verifierSignature = verifierSign(unsigned, vk, vkId);
720
729
  }
721
- // The published keyId is never a secret, whichever variable carried it
722
- // (evidenceSecretsFromEnv: the `_ID` exclusion is the rule, `except` is
723
- // the belt for a host that names its env var differently).
724
- const secrets3 = evidenceSecretsFromEnv(process.env, [apiKey, signingKeyPem], [keyId]);
725
- const scrubbed3 = scrubEvidence(v3, secrets3);
726
- const v3Out = scrubbed3.value;
727
730
  const audit3 = verifyBundleV3(v3Out, { hostPublicKeyPem: publicKeyFromPrivate(signingKeyPem) });
728
731
  // `relaxed-profile-certified` is no longer excused: the emitter now computes
729
732
  // the relaxation into `certified`, so if the verifier still finds that
@@ -22,6 +22,7 @@ import { profileDerivable } from './profiles.js';
22
22
  import { checkRungClaim } from './durability-evidence.js';
23
23
  import { profilesDeniedByObservedRelaxation, profilesRelaxedBy, v2RegistryAvailable } from './v2-profiles.js';
24
24
  import { canonicalJSON, codeUnitCompare, JcsRefusal } from './jcs.js';
25
+ import { scrubEvidence } from './certification-bundle-verify.js';
25
26
  export const SIGNATURE_OVER = ['witnessSha256', 'host.build', 'suite.version', 'discovery.sha256'];
26
27
  /**
27
28
  * RFC 0212 §A — the bytes every attestation and digest here covers are RFC 8785
@@ -82,6 +83,24 @@ export function verifierSign(bundle, privateKeyPem, keyId) {
82
83
  const key = createPrivateKey(privateKeyPem);
83
84
  return { alg: 'ed25519', keyId, sig: toBase64url(edSign(null, attestationPayload(bundle), key)) };
84
85
  }
86
+ /**
87
+ * Scrub, THEN digest, THEN sign (RFC 0148 §C + RFC 0168 §E.2).
88
+ *
89
+ * Until 2.43.1 the emitter computed `witnessSha256` over the raw rows, signed,
90
+ * and only then ran `scrubEvidence` over the finished bundle. A secret found in
91
+ * a row's `detail` or `evidence` was redacted AFTER the digest was taken, so
92
+ * the written rows no longer hashed to `witnessSha256` and the emitter's own
93
+ * self-verification refused the bundle (`[witness-digest]`, exit 2, no bundle)
94
+ * — openwop-app at major 1 on suite 2.43.0, 2026-09-28. The redaction was
95
+ * right and the check was right; the order was wrong. Everything the digest and
96
+ * the attestation cover is now scrubbed before either is computed, so the
97
+ * signed bytes are the shipped bytes.
98
+ */
99
+ export function sealBundleV3(draft, secrets, privateKeyPem, keyId) {
100
+ const { value: scrubbed, redactedAt } = scrubEvidence(draft, secrets);
101
+ const unsigned = { ...scrubbed, witnessSha256: witnessDigest(scrubbed.results.requirements, scrubbed.host.relaxations, scrubbed.host.deployment) };
102
+ return { unsigned, bundle: { ...unsigned, signature: signBundleV3(unsigned, privateKeyPem, keyId) }, redactedAt };
103
+ }
85
104
  export function publicKeyFromPrivate(privateKeyPem) {
86
105
  return createPublicKey(createPrivateKey(privateKeyPem)).export({ type: 'spki', format: 'pem' });
87
106
  }
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "package": "@openwop/spec-artifacts",
3
- "version": "2.43.0",
4
- "stampSha256": "be64f880013ebf7f3327443eafa05d0e5e9eb993843d745981cc72bf18313133"
3
+ "version": "2.43.1",
4
+ "stampSha256": "596087154d2220f81b333dc0567ed6a0ed90fbbd0ddf2e8981012800d954885b"
5
5
  }
@@ -0,0 +1,33 @@
1
+ {
2
+ "id": "conformance-approval-reject-loopback",
3
+ "name": "Conformance: Approval (reject loops back)",
4
+ "version": "1.0",
5
+ "description": "openwop#1697. A rejected approval gate routed over an any_failed edge to revise, which loops back to the gate (bounded by settings.maxLoopbackIterations 2). Rejected once, the gate is visited again: the host MUST raise a new interrupt.requested with a different key and wait for a new decision, never replaying the first rejection. Conformance scenario: v2-approval-reject-disposition.test.ts.",
6
+ "nodes": [
7
+ { "id": "start", "typeId": "core.noop", "name": "Start", "position": { "x": 0, "y": 0 }, "config": {}, "inputs": {} },
8
+ {
9
+ "id": "gate",
10
+ "typeId": "core.approvalGate",
11
+ "name": "Approval Gate",
12
+ "position": { "x": 200, "y": 0 },
13
+ "config": {
14
+ "title": "Conformance approval (reject loops back)",
15
+ "description": "Conformance suite — reject once; the gate is asked again.",
16
+ "actions": ["accept", "reject"]
17
+ },
18
+ "inputs": {}
19
+ },
20
+ { "id": "revise", "typeId": "core.noop", "name": "Revise", "position": { "x": 400, "y": 0 }, "config": {}, "inputs": {} }
21
+ ],
22
+ "edges": [
23
+ { "id": "start-gate", "sourceNodeId": "start", "targetNodeId": "gate", "triggerRule": "any_success" },
24
+ { "id": "gate-revise", "sourceNodeId": "gate", "targetNodeId": "revise", "triggerRule": "any_failed" },
25
+ { "id": "revise-gate", "sourceNodeId": "revise", "targetNodeId": "gate", "triggerRule": "any_success" }
26
+ ],
27
+ "triggers": [
28
+ { "id": "manual", "type": "manual", "enabled": true }
29
+ ],
30
+ "variables": [],
31
+ "metadata": { "tags": ["conformance", "hitl"] },
32
+ "settings": { "timeout": 0, "maxLoopbackIterations": 2 }
33
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "id": "conformance-approval-reject-routed",
3
+ "name": "Conformance: Approval (reject routed)",
4
+ "version": "1.0",
5
+ "description": "RFC 0223 G1. An approval gate with two outgoing edges: an any_failed edge to on-reject and an all_success edge to on-accept. Rejected, the gate fails with approval_rejected, on-reject runs, on-accept never runs, and the run completes. Conformance scenario: v2-approval-reject-disposition.test.ts.",
6
+ "nodes": [
7
+ {
8
+ "id": "gate",
9
+ "typeId": "core.approvalGate",
10
+ "name": "Approval Gate",
11
+ "position": { "x": 0, "y": 0 },
12
+ "config": {
13
+ "title": "Conformance approval (reject routed)",
14
+ "description": "Conformance suite — reject to take the any_failed route.",
15
+ "actions": ["accept", "reject"]
16
+ },
17
+ "inputs": {}
18
+ },
19
+ { "id": "on-reject", "typeId": "core.noop", "name": "On reject", "position": { "x": 200, "y": -80 }, "config": {}, "inputs": {} },
20
+ { "id": "on-accept", "typeId": "core.noop", "name": "On accept", "position": { "x": 200, "y": 80 }, "config": {}, "inputs": {} }
21
+ ],
22
+ "edges": [
23
+ { "id": "gate-on-reject", "sourceNodeId": "gate", "targetNodeId": "on-reject", "triggerRule": "any_failed" },
24
+ { "id": "gate-on-accept", "sourceNodeId": "gate", "targetNodeId": "on-accept", "triggerRule": "all_success" }
25
+ ],
26
+ "triggers": [
27
+ { "id": "manual", "type": "manual", "enabled": true }
28
+ ],
29
+ "variables": [],
30
+ "metadata": { "tags": ["conformance", "hitl"] },
31
+ "settings": { "timeout": 0 }
32
+ }
@@ -0,0 +1,46 @@
1
+ {
2
+ "id": "conformance-approval-timeout-approve",
3
+ "name": "Conformance: Approval (timeout, onTimeout approve)",
4
+ "version": "1.0",
5
+ "description": "RFC 0223 / openwop#1696. An approval gate with a 1500 ms timeoutMs and onTimeout: approve. Nobody resolves it: a timeout never grants, so the host's timer resolves the gate rejected (action timeout, decision rejected, reason timeout), the gate fails with approval_rejected and the run fails. Conformance scenario: v2-approval-reject-disposition.test.ts.",
6
+ "nodes": [
7
+ {
8
+ "id": "gate",
9
+ "typeId": "core.approvalGate",
10
+ "name": "Approval Gate",
11
+ "position": {
12
+ "x": 0,
13
+ "y": 0
14
+ },
15
+ "config": {
16
+ "title": "Conformance approval (timeout, onTimeout approve)",
17
+ "description": "Conformance suite — do not resolve; the gate times out and MUST NOT be granted.",
18
+ "actions": [
19
+ "accept",
20
+ "reject"
21
+ ],
22
+ "timeoutMs": 1500,
23
+ "onTimeout": "approve"
24
+ },
25
+ "inputs": {}
26
+ }
27
+ ],
28
+ "edges": [],
29
+ "triggers": [
30
+ {
31
+ "id": "manual",
32
+ "type": "manual",
33
+ "enabled": true
34
+ }
35
+ ],
36
+ "variables": [],
37
+ "metadata": {
38
+ "tags": [
39
+ "conformance",
40
+ "hitl"
41
+ ]
42
+ },
43
+ "settings": {
44
+ "timeout": 0
45
+ }
46
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "id": "conformance-approval-timeout",
3
+ "name": "Conformance: Approval (timeout)",
4
+ "version": "1.0",
5
+ "description": "RFC 0223 G2. An approval gate with a 1500 ms timeoutMs and no onTimeout. Nobody resolves it: the host's timer resolves the gate rejected (action timeout, decision rejected, reason timeout), the gate fails with approval_rejected and the run fails naming it. Conformance scenario: v2-approval-reject-disposition.test.ts.",
6
+ "nodes": [
7
+ {
8
+ "id": "gate",
9
+ "typeId": "core.approvalGate",
10
+ "name": "Approval Gate",
11
+ "position": { "x": 0, "y": 0 },
12
+ "config": {
13
+ "title": "Conformance approval (timeout)",
14
+ "description": "Conformance suite — do not resolve; the gate times out.",
15
+ "actions": ["accept", "reject"],
16
+ "timeoutMs": 1500
17
+ },
18
+ "inputs": {}
19
+ }
20
+ ],
21
+ "edges": [],
22
+ "triggers": [
23
+ { "id": "manual", "type": "manual", "enabled": true }
24
+ ],
25
+ "variables": [],
26
+ "metadata": { "tags": ["conformance", "hitl"] },
27
+ "settings": { "timeout": 0 }
28
+ }
package/fixtures.md CHANGED
@@ -49,6 +49,10 @@ All fixtures MUST advertise:
49
49
  | Approval | `conformance-approval` | Verifies HITL approval interrupt + resume | `completed` after resolve | unbounded (suspends) |
50
50
  | Approval (refine) | `conformance-approval-refine` | RFC 0183 — refine resolution carries action + refineFeedback |
51
51
  | Approval (edit-accept) | `conformance-approval-edit-accept` | RFC 0183 — edit-accept resolution carries action + editedArtifactData |
52
+ | Approval (reject routed) | `conformance-approval-reject-routed` | RFC 0223 G1 — a rejected gate is routed over an `any_failed` edge; its `all_success` sibling never runs | `completed` after reject | unbounded (suspends) |
53
+ | Approval (timeout) | `conformance-approval-timeout` | RFC 0223 G2 — a 1500 ms `timeoutMs`, no `onTimeout`: the host's timer resolves the gate rejected | `failed` (`approval_rejected`) after the timeout | ~1.5 s + the host's sweep |
54
+ | Approval (timeout, onTimeout approve) | `conformance-approval-timeout-approve` | openwop#1696 — a 1500 ms `timeoutMs` with `onTimeout: approve`: a timeout never grants, so the gate still resolves rejected | `failed` (`approval_rejected`) after the timeout | ~1.5 s + the host's sweep |
55
+ | Approval (reject loops back) | `conformance-approval-reject-loopback` | openwop#1697 — a rejected gate routed back to itself through `revise` is asked again under a new `key` | `waiting-approval` again after one reject (the suite cancels) | unbounded (suspends) |
52
56
  | Clarification | `conformance-clarification` | Verifies HITL clarification interrupt + resume | `completed` after resolve | unbounded (suspends) |
53
57
  | Clarification (nested schema) | `conformance-clarification-nested` | RFC 0199 §D.2(d) probe — an answer schema an MCP mount MUST NOT bridge in form mode | `completed` after resolve | unbounded (suspends) |
54
58
  | Clarification (sensitive field) | `conformance-clarification-sensitive` | RFC 0199 §D.2(d) probe — `format: "password"`; form mode MUST be refused | `completed` after resolve | unbounded (suspends) |
@@ -211,6 +215,54 @@ The `messages`-mode stream fixture (AI token streaming) is covered by the determ
211
215
  5. An `edit-accept` resolution supplying no `editedArtifactData` MUST be refused.
212
216
  - **Why it is separate**: same reason as `conformance-approval-refine` — widening a registered fixture's `actions` forces every host to re-register a test-only change.
213
217
 
218
+ ### `conformance-approval-reject-routed`
219
+
220
+ - **Purpose**: witness that a rejected approval gate is a failed source a workflow can route (RFC 0223 §A, gap G1; `spec/v2/core/interrupt.md` §Rejection).
221
+ - **Inputs**: none.
222
+ - **Behavior**:
223
+ 1. Run starts and reaches the `gate` (`core.approvalGate`, actions `accept | reject`).
224
+ 2. Run status MUST be `waiting-approval`.
225
+ 3. Client POSTs `{action: 'reject'}` to the interrupt.
226
+ 4. `gate` MUST emit `node.failed` with `error.code` `approval_rejected` and `retryable: false`.
227
+ 5. `on-reject` (incoming edge `triggerRule: any_failed`) MUST run to `node.completed`.
228
+ 6. `on-accept` (incoming edge `triggerRule: all_success`) MUST NOT run: no `node.started` or `node.completed` for it. A host MAY record `node.skipped`; the spec does not require it.
229
+ 7. Run reaches `completed`.
230
+ - **Terminal status (after reject)**: `completed`.
231
+ - **Why it is separate from `conformance-approval`**: that fixture has no edges, and giving it some would force every host to re-register a registered definition.
232
+
233
+ ### `conformance-approval-timeout`
234
+
235
+ - **Purpose**: witness the timeout disposition (RFC 0223 §A, gap G2).
236
+ - **Inputs**: none.
237
+ - **Behavior**:
238
+ 1. Run starts and reaches the `gate` (`core.approvalGate`, `config.timeoutMs: 1500`, no `onTimeout`). The host carries the node's `timeoutMs` onto the interrupt payload.
239
+ 2. Run status MUST be `waiting-approval`.
240
+ 3. Nobody resolves it. After the deadline, the host MUST resolve the gate itself: `interrupt.resolved` with `action: "timeout"`, `decision: "rejected"`, `reason: "timeout"`.
241
+ 4. `gate` MUST emit `node.failed` with `error.code` `approval_rejected`, and `run.failed` MUST carry `approval_rejected` with `failedNodeId: "gate"`.
242
+ - **Terminal status**: `failed`.
243
+ - **Timing**: the suite waits up to 20 s (scaled) for the terminal state; a host's timer granularity is its own.
244
+
245
+ ### `conformance-approval-timeout-approve`
246
+
247
+ - **Purpose**: witness that a timeout never grants a gate, whatever `onTimeout` holds (`spec/v2/core/interrupt.md` §Rejection; RFC 0223 §C as corrected by openwop#1696).
248
+ - **Inputs**: none.
249
+ - **Behavior**: as `conformance-approval-timeout`, with `config.onTimeout: "approve"`. After the deadline, the host MUST resolve the gate rejected (`action: "timeout"`, `decision: "rejected"`, `reason: "timeout"`), `gate` MUST fail with `approval_rejected`, and the run MUST NOT complete.
250
+ - **Terminal status**: `failed`.
251
+ - **Why it is separate**: `conformance-approval-timeout` witnesses the absent case; this one witnesses the value that used to read as permission to fail open.
252
+
253
+ ### `conformance-approval-reject-loopback`
254
+
255
+ - **Purpose**: witness that `key` is per visit (`spec/v2/core/interrupt.md` §Re-entry and resume values, openwop#1697).
256
+ - **Inputs**: none.
257
+ - **Graph**: `start` (`core.noop`) → `gate` (`any_success`); `gate` → `revise` (`core.noop`, `any_failed`); `revise` → `gate` (`any_success`). `settings.maxLoopbackIterations: 2`.
258
+ - **Behavior**:
259
+ 1. Run reaches `gate` and MUST be `waiting-approval`.
260
+ 2. Client POSTs `{action: 'reject'}`. `gate` fails with `approval_rejected`, and `revise` runs.
261
+ 3. `gate` runs again. The host MUST raise a second `interrupt.requested` for `gate` with a different `key`, and the run MUST be `waiting-approval` again.
262
+ 4. The first rejection MUST NOT be replayed into the second visit: only one `interrupt.resolved` is on the log.
263
+ - **Terminal status**: none reached by the suite, which cancels the run.
264
+ - **Advertise only if** the host runs cycles. A host that does not MUST NOT advertise the fixture, and the row is `inapplicable`.
265
+
214
266
  ### `conformance-clarification`
215
267
 
216
268
  - **Purpose**: verify HITL clarification interrupt + resume.
@@ -524,7 +576,7 @@ The fixtures reference these typeIds:
524
576
  | `core.noop` | noop, multi-node, approval, clarification, cancellable | Immediate completion, no output |
525
577
  | `core.identity` | identity | Echo `input.payload` to `output.payload` |
526
578
  | `core.delay` | delay, cancellable | Sleep `config.delayMs` ms |
527
- | `core.fail` | failure | Throw with `code: "conformance_test_failure"`, message: "Intentional conformance failure" |
579
+ | `core.fail` | failure | Throw with `code: "example.conformance_failure"` (a vendor code under the registered `example` org; errors.md §The registry, openwop#1698), message: "Intentional conformance failure" |
528
580
  | `core.approvalGate` | approval | Call `ctx.interrupt({kind: 'approval', ...})` |
529
581
  | `core.clarificationGate` | clarification | Call `ctx.interrupt({kind: 'clarification', ...})` |
530
582
  | `conformance.requiresMissing` | capability-missing | Declares `requires: ['conformance.never-provided']`; engine MUST refuse dispatch. Opt-in fixture registration is recommended so production deployments don't expose the fixture surface. |
@@ -588,6 +640,10 @@ conformance/
588
640
  conformance-approval.json
589
641
  conformance-approval-refine.json
590
642
  conformance-approval-edit-accept.json
643
+ conformance-approval-reject-routed.json
644
+ conformance-approval-timeout.json
645
+ conformance-approval-timeout-approve.json
646
+ conformance-approval-reject-loopback.json
591
647
  conformance-clarification.json
592
648
  conformance-multi-node.json
593
649
  conformance-idempotent.json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.43.0",
3
+ "version": "2.43.1",
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.43.0"
59
+ "@openwop/spec-artifacts": "2.43.1"
60
60
  }
61
61
  }