@openwop/openwop-conformance 2.43.0 → 2.43.2
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 +24 -0
- package/README.md +3 -3
- package/dist/cli.js +15 -12
- package/dist/lib/certification-bundle-v3.js +19 -0
- package/dist/lib/scenario-disposition.js +30 -0
- package/dist/spec-artifacts.lock.json +2 -2
- package/fixtures/conformance-approval-reject-loopback.json +33 -0
- package/fixtures/conformance-approval-reject-routed.json +32 -0
- package/fixtures/conformance-approval-timeout-approve.json +46 -0
- package/fixtures/conformance-approval-timeout.json +28 -0
- package/fixtures/conformance-replay-ordinal-loop.json +31 -0
- package/fixtures.md +69 -1
- package/package.json +2 -2
- package/requirements.json +243 -30
- package/scenario-majors.json +11 -2
- package/schemas/CORPUS-STAMP.json +47 -47
- package/src/cli.ts +15 -12
- package/src/lib/certification-bundle-v3.ts +25 -0
- package/src/lib/scenario-disposition.ts +40 -0
- package/src/lib/standard-webhooks.ts +53 -0
- package/src/scenarios/agent-org-chart-scoping.test.ts +63 -0
- package/src/scenarios/v2-agent-org-chart-served-shape.test.ts +63 -0
- package/src/scenarios/v2-approval-reject-disposition.test.ts +106 -6
- package/src/scenarios/v2-error-registry.test.ts +59 -0
- package/src/scenarios/v2-manifest-ceiling-refused.test.ts +21 -9
- package/src/scenarios/v2-manifest-hatch-carried.test.ts +23 -7
- package/src/scenarios/v2-pack-isolation.test.ts +10 -6
- package/src/scenarios/v2-peer-dependency-declared.test.ts +18 -6
- package/src/scenarios/v2-replay-suppression-ordinal.test.ts +108 -0
- package/src/scenarios/v2-run-fork-ancestry.test.ts +83 -0
- package/src/scenarios/v2-webhook-secret-rotation.test.ts +28 -11
- package/src/setup.ts +25 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# `@openwop/openwop-conformance` Changelog
|
|
2
2
|
|
|
3
|
+
## [2.43.2] — 2026-09-28 — three Class 3 corrections, and a failing scenario hook no longer hides its test
|
|
4
|
+
|
|
5
|
+
- **The 2.43.2 cycle opens.** #1724 changes the packed `@openwop/spec-artifacts` tree (`spec/v2/core/interrupt.md`, `spec/v1/gaps.json`) after `v2.43.1` was tagged. No scenario changes in this entry: `approval.rejected` goes from SHOULD to MAY (Class 3), and no leg asserted the SHOULD.
|
|
6
|
+
- **The replay outcome key uses the execution ordinal** (#1718, Class 3). New `v2-replay-suppression-ordinal.test.ts` (target major 2), row `openwop.requirement.replay.suppression-execution-ordinal`, fixture `conformance-replay-ordinal-loop` (start → effect → wait → effect). The suite cancels the source after `effect`'s first execution, then forks `mode: "replay"`. `effect`'s second execution has no recorded outcome for n = 2 and MUST fail closed with `replay_source_missing`; a host keyed on `(nodeId, attempt)`, or on the latest outcome, completes it instead. `inapplicable` unless the host runs cycles and advertises the fixture. No host witnesses it yet.
|
|
7
|
+
- **Suite `2.43.2`**. `@openwop/spec-artifacts` moves in lockstep at the same exact pin.
|
|
8
|
+
- **A scenario hook that fails no longer hides its test** (#1753). vitest runs a file's `afterEach` hooks in stack order, so a scenario's own cleanup runs before the runner's recorder. If it threw or timed out, the recorder never saw the test and the file row read "no test executed and no disposition recorded" (`blocked`). `setup.ts`'s `afterAll` now recovers such tests from vitest's task results (`unrecordedTests` in `lib/scenario-disposition.ts`, self-test `lib/unrecorded-tests.test.ts`), so the row is `executed-fail` and names the error.
|
|
9
|
+
- **A fork has no ancestry parent** (Class 3). New `v2-run-fork-ancestry.test.ts` (target major 2), row `openwop.requirement.0040.fork-has-no-ancestry-parent`. It is gated on `replay` and on `multiAgent.executionModel.crossHostCausation.ancestryEndpointSupported`, and records `inapplicable` without them. It creates a `conformance-noop` run and checks its ancestry `parent` is `null` as a control. It then makes a branch fork at `fromSeq: 0`, checks the `201` names the source as `sourceRunId`, and asserts the fork's ancestry `parent` is `null` (`runs.md` §Diff and ancestry). It is a file of its own so that most v2 hosts, which do not advertise ancestry, do not get a `partial-witness` row on `v2-run-fork-refusals`. Measured on the v2 reference host (openwop-examples #124) with a temporary ancestry advertisement: `executed-pass`. With its previous `cause: "core.subWorkflow"` line restored the row fails, and a host reporting a parent for every run fails the control. As shipped, with nothing advertised, it records `inapplicable`. openwop-app also derives ancestry from the `parentRunId` its forks set, so it fails this row wherever it advertises ancestry.
|
|
10
|
+
|
|
11
|
+
## [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
|
|
12
|
+
|
|
13
|
+
- **`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.
|
|
14
|
+
- **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.
|
|
15
|
+
- `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.
|
|
16
|
+
- `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.
|
|
17
|
+
- 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.
|
|
18
|
+
- **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.
|
|
19
|
+
- **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.
|
|
20
|
+
- **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`.
|
|
21
|
+
- **`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`.
|
|
22
|
+
- **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`.
|
|
23
|
+
- **`--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.
|
|
24
|
+
- **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.
|
|
25
|
+
- **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.
|
|
26
|
+
|
|
3
27
|
## [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
28
|
|
|
5
29
|
- **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.
|
|
14
|
+
npm install --legacy-peer-deps @openwop/openwop-conformance@2.43.2 @openwop/spec-artifacts@2.43.2
|
|
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
|
|
138
|
+
The current suite has 573 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:
|
|
484
|
+
Current source tree: 573 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,
|
|
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
|
|
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
|
-
|
|
711
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -142,6 +142,36 @@ testName) {
|
|
|
142
142
|
return { disposition: 'blocked', detail: 'unclassified return: the test passed with zero assertions and recorded no reason — RFC 0148 §A resolves it to blocked, never to a pass' };
|
|
143
143
|
return { disposition: 'skipped', detail: 'vitest skipped the test (ctx.skip / it.skip) without a recorded gate reason' };
|
|
144
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* The tests the per-test recorder never saw, as file states and failures.
|
|
147
|
+
*
|
|
148
|
+
* vitest runs a file's `afterEach` hooks in stack order, so a scenario's own
|
|
149
|
+
* `afterEach` (unregistering webhooks, closing a receiver) runs BEFORE the
|
|
150
|
+
* recorder `setup.ts` registers. If that hook throws or times out, vitest
|
|
151
|
+
* fails the test and skips the remaining hooks, so the recorder never runs.
|
|
152
|
+
* The file then has no recorded state, and its row read "no test executed and
|
|
153
|
+
* no disposition recorded" (`blocked`). That row gives no reason and hides a
|
|
154
|
+
* real failure. One tier-2 cut of `v2-webhook-message-id-stable` produced it
|
|
155
|
+
* on 2026-09-28.
|
|
156
|
+
*
|
|
157
|
+
* Only tests that finished `pass` or `fail` are recovered. A skipped test
|
|
158
|
+
* never reaches the recorder either way, and the existing rules already cover it.
|
|
159
|
+
*/
|
|
160
|
+
export function unrecordedTests(tests, recordedIds) {
|
|
161
|
+
const states = [];
|
|
162
|
+
const failures = [];
|
|
163
|
+
for (const t of tests) {
|
|
164
|
+
if (recordedIds.has(t.id))
|
|
165
|
+
continue;
|
|
166
|
+
if (t.state === 'pass')
|
|
167
|
+
states.push('pass');
|
|
168
|
+
else if (t.state === 'fail') {
|
|
169
|
+
states.push('fail');
|
|
170
|
+
failures.push({ name: t.name, message: `the test or its afterEach hook failed before the runner recorded it: ${t.message ?? 'no message'}` });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { states, failures };
|
|
174
|
+
}
|
|
145
175
|
/**
|
|
146
176
|
* The `executed-fail` detail for a file row: WHICH cases failed, and what the
|
|
147
177
|
* first one said.
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "conformance-replay-ordinal-loop",
|
|
3
|
+
"name": "Conformance: Replay outcome keyed on the execution ordinal",
|
|
4
|
+
"version": "1.0",
|
|
5
|
+
"description": "openwop#1718. start → effect (a side-effecting node) → wait (core.delay) → back to effect, bounded by settings.maxLoopbackIterations 2, so effect executes twice. The suite cancels the source run inside wait, after effect's first execution completed and before its second, then forks mode:\"replay\" at effect's first node.started. In the fork, effect's first execution resolves from the recorded outcome for n = 1, and its second execution has no recorded outcome for n = 2, so it MUST fail closed with replay_source_missing (replay.md §Suppression rule 2). A host keying on (nodeId, attempt) or selecting the latest outcome resolves the second execution from the first and completes it. core.conformance.side-effect is the reserved side-effecting typeId (see conformance-replay-side-effect). Advertised only by a host that runs cycles and suppresses side effects in replay.",
|
|
6
|
+
"nodes": [
|
|
7
|
+
{ "id": "start", "typeId": "core.noop", "name": "Start", "position": { "x": 0, "y": 0 }, "config": {}, "inputs": {} },
|
|
8
|
+
{ "id": "effect", "typeId": "core.conformance.side-effect", "name": "Side Effect", "position": { "x": 200, "y": 0 }, "config": {}, "inputs": {} },
|
|
9
|
+
{
|
|
10
|
+
"id": "wait",
|
|
11
|
+
"typeId": "core.delay",
|
|
12
|
+
"name": "Wait",
|
|
13
|
+
"position": { "x": 400, "y": 0 },
|
|
14
|
+
"config": {},
|
|
15
|
+
"inputs": { "delayMs": { "type": "variable", "variableName": "delayMs" } }
|
|
16
|
+
}
|
|
17
|
+
],
|
|
18
|
+
"edges": [
|
|
19
|
+
{ "id": "start-effect", "sourceNodeId": "start", "targetNodeId": "effect", "triggerRule": "any_success" },
|
|
20
|
+
{ "id": "effect-wait", "sourceNodeId": "effect", "targetNodeId": "wait" },
|
|
21
|
+
{ "id": "wait-effect", "sourceNodeId": "wait", "targetNodeId": "effect", "triggerRule": "any_success" }
|
|
22
|
+
],
|
|
23
|
+
"triggers": [
|
|
24
|
+
{ "id": "manual", "type": "manual", "enabled": true }
|
|
25
|
+
],
|
|
26
|
+
"variables": [
|
|
27
|
+
{ "name": "delayMs", "type": "number", "defaultValue": 3000 }
|
|
28
|
+
],
|
|
29
|
+
"metadata": { "tags": ["conformance", "replay"] },
|
|
30
|
+
"settings": { "timeout": 0, "maxLoopbackIterations": 2 }
|
|
31
|
+
}
|
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) |
|
|
@@ -57,6 +61,7 @@ All fixtures MUST advertise:
|
|
|
57
61
|
| Idempotent | `conformance-idempotent` | Verifies `Idempotency-Key` cache | `completed` | ≤ 5s |
|
|
58
62
|
| Cancellable | `conformance-cancellable` | Verifies `:cancel` endpoint mid-run | `cancelled` after cancel | ≤ 60s (input-controlled) |
|
|
59
63
|
| Replay Side-Effect Suppression | `conformance-replay-side-effect` | RFC 0140. Verifies a `mode:"replay"` fork does NOT re-fire a side-effecting node | fork `failed` (`error.code='replay_source_missing'`) | ≤ 60s (input-controlled) |
|
|
64
|
+
| Replay ordinal loop | `conformance-replay-ordinal-loop` | openwop#1718 — a side-effecting node executed twice in a loop; a replay fork keys each execution by its ordinal `n` | `cancelled` (source); fork: execution 2 fails `replay_source_missing` | ~3 s + fork |
|
|
60
65
|
| Capability Missing | `conformance-capability-missing` | Verifies dispatch refusal on unsatisfied `requires` | `failed` (`error.code='capability_not_provided'`) | ≤ 5s |
|
|
61
66
|
| Prompt End-to-End | `conformance-prompt-end-to-end` | RFC 0027 + RFC 0029 end-to-end. Single `mock-ai` node with `config.systemPromptRef` set; host MUST emit `agent.promptResolved` + `prompt.composed` events during dispatch, then complete. Capability-gated on `capabilities.prompts.supported`. | `completed` | ≤ 10s |
|
|
62
67
|
| Prompt All Four Kinds | `conformance-prompt-all-four-kinds` | RFC 0027 §A four-kind dispatch coverage with a MULTI-ENTRY `fewShotPromptRefs[]` array. Single `mock-ai` node with one ref per singular-kind slot (`systemPromptRef`, `userPromptRef`, `schemaHintPromptRef`) + two distinct templateIds in `fewShotPromptRefs[]`; host MUST emit 5 `agent.promptResolved` events (one per slot) AND 5 `prompt.composed` events. Multi-entry few-shot is the regression pin for `fewShotPromptRefs[slotIndex]` per-index resolution — a host that hard-codes `[0]` would emit the same template twice in the few-shot events and fail the per-templateId assertion. Capability-gated on `capabilities.prompts.supported`. | `completed` | ≤ 10s |
|
|
@@ -211,6 +216,54 @@ The `messages`-mode stream fixture (AI token streaming) is covered by the determ
|
|
|
211
216
|
5. An `edit-accept` resolution supplying no `editedArtifactData` MUST be refused.
|
|
212
217
|
- **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
218
|
|
|
219
|
+
### `conformance-approval-reject-routed`
|
|
220
|
+
|
|
221
|
+
- **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).
|
|
222
|
+
- **Inputs**: none.
|
|
223
|
+
- **Behavior**:
|
|
224
|
+
1. Run starts and reaches the `gate` (`core.approvalGate`, actions `accept | reject`).
|
|
225
|
+
2. Run status MUST be `waiting-approval`.
|
|
226
|
+
3. Client POSTs `{action: 'reject'}` to the interrupt.
|
|
227
|
+
4. `gate` MUST emit `node.failed` with `error.code` `approval_rejected` and `retryable: false`.
|
|
228
|
+
5. `on-reject` (incoming edge `triggerRule: any_failed`) MUST run to `node.completed`.
|
|
229
|
+
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.
|
|
230
|
+
7. Run reaches `completed`.
|
|
231
|
+
- **Terminal status (after reject)**: `completed`.
|
|
232
|
+
- **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.
|
|
233
|
+
|
|
234
|
+
### `conformance-approval-timeout`
|
|
235
|
+
|
|
236
|
+
- **Purpose**: witness the timeout disposition (RFC 0223 §A, gap G2).
|
|
237
|
+
- **Inputs**: none.
|
|
238
|
+
- **Behavior**:
|
|
239
|
+
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.
|
|
240
|
+
2. Run status MUST be `waiting-approval`.
|
|
241
|
+
3. Nobody resolves it. After the deadline, the host MUST resolve the gate itself: `interrupt.resolved` with `action: "timeout"`, `decision: "rejected"`, `reason: "timeout"`.
|
|
242
|
+
4. `gate` MUST emit `node.failed` with `error.code` `approval_rejected`, and `run.failed` MUST carry `approval_rejected` with `failedNodeId: "gate"`.
|
|
243
|
+
- **Terminal status**: `failed`.
|
|
244
|
+
- **Timing**: the suite waits up to 20 s (scaled) for the terminal state; a host's timer granularity is its own.
|
|
245
|
+
|
|
246
|
+
### `conformance-approval-timeout-approve`
|
|
247
|
+
|
|
248
|
+
- **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).
|
|
249
|
+
- **Inputs**: none.
|
|
250
|
+
- **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.
|
|
251
|
+
- **Terminal status**: `failed`.
|
|
252
|
+
- **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.
|
|
253
|
+
|
|
254
|
+
### `conformance-approval-reject-loopback`
|
|
255
|
+
|
|
256
|
+
- **Purpose**: witness that `key` is per visit (`spec/v2/core/interrupt.md` §Re-entry and resume values, openwop#1697).
|
|
257
|
+
- **Inputs**: none.
|
|
258
|
+
- **Graph**: `start` (`core.noop`) → `gate` (`any_success`); `gate` → `revise` (`core.noop`, `any_failed`); `revise` → `gate` (`any_success`). `settings.maxLoopbackIterations: 2`.
|
|
259
|
+
- **Behavior**:
|
|
260
|
+
1. Run reaches `gate` and MUST be `waiting-approval`.
|
|
261
|
+
2. Client POSTs `{action: 'reject'}`. `gate` fails with `approval_rejected`, and `revise` runs.
|
|
262
|
+
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.
|
|
263
|
+
4. The first rejection MUST NOT be replayed into the second visit: only one `interrupt.resolved` is on the log.
|
|
264
|
+
- **Terminal status**: none reached by the suite, which cancels the run.
|
|
265
|
+
- **Advertise only if** the host runs cycles. A host that does not MUST NOT advertise the fixture, and the row is `inapplicable`.
|
|
266
|
+
|
|
214
267
|
### `conformance-clarification`
|
|
215
268
|
|
|
216
269
|
- **Purpose**: verify HITL clarification interrupt + resume.
|
|
@@ -251,6 +304,17 @@ The `messages`-mode stream fixture (AI token streaming) is covered by the determ
|
|
|
251
304
|
3. Server emits `run.cancelled` within 5s.
|
|
252
305
|
4. Subsequent `GET /v1/runs/{runId}` MUST return `status: "cancelled"`.
|
|
253
306
|
|
|
307
|
+
### `conformance-replay-ordinal-loop`
|
|
308
|
+
|
|
309
|
+
- **Purpose**: witness that a replay fork keys a side-effecting node's recorded outcome on `(sourceRunId, nodeId, n)`, where `n` counts the node's `node.started` events through this execution (`spec/v2/core/replay.md` §Suppression rule 2, openwop#1718).
|
|
310
|
+
- **Shape**: `start` (`core.noop`) → `effect` (`core.conformance.side-effect`, `any_success`) → `wait` (`core.delay`) → `effect` (`any_success`); `settings.maxLoopbackIterations: 2`, so `effect` executes twice.
|
|
311
|
+
- **Inputs**: `delayMs` (integer, default 3000), long enough to cancel inside `wait`.
|
|
312
|
+
- **Expected behavior**:
|
|
313
|
+
1. The source run completes `effect`'s first execution. The client cancels it inside `wait`, before the second execution.
|
|
314
|
+
2. The client forks `mode: "replay"` at `effect`'s first `node.started`.
|
|
315
|
+
3. In the fork, execution 1 (n = 1) resolves from the recorded outcome. Execution 2 (n = 2) has none, so it MUST fail closed with `replay_source_missing` and MUST NOT complete.
|
|
316
|
+
- **Advertise only if** the host runs cycles and maps `core.conformance.side-effect` to a side-effecting node (see `conformance-replay-side-effect`).
|
|
317
|
+
|
|
254
318
|
### `conformance-replay-side-effect`
|
|
255
319
|
|
|
256
320
|
- **Purpose**: verify RFC 0140 — a `mode:"replay"` fork MUST NOT re-perform a side-effecting node's effect.
|
|
@@ -524,7 +588,7 @@ The fixtures reference these typeIds:
|
|
|
524
588
|
| `core.noop` | noop, multi-node, approval, clarification, cancellable | Immediate completion, no output |
|
|
525
589
|
| `core.identity` | identity | Echo `input.payload` to `output.payload` |
|
|
526
590
|
| `core.delay` | delay, cancellable | Sleep `config.delayMs` ms |
|
|
527
|
-
| `core.fail` | failure | Throw with `code: "
|
|
591
|
+
| `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
592
|
| `core.approvalGate` | approval | Call `ctx.interrupt({kind: 'approval', ...})` |
|
|
529
593
|
| `core.clarificationGate` | clarification | Call `ctx.interrupt({kind: 'clarification', ...})` |
|
|
530
594
|
| `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 +652,10 @@ conformance/
|
|
|
588
652
|
conformance-approval.json
|
|
589
653
|
conformance-approval-refine.json
|
|
590
654
|
conformance-approval-edit-accept.json
|
|
655
|
+
conformance-approval-reject-routed.json
|
|
656
|
+
conformance-approval-timeout.json
|
|
657
|
+
conformance-approval-timeout-approve.json
|
|
658
|
+
conformance-approval-reject-loopback.json
|
|
591
659
|
conformance-clarification.json
|
|
592
660
|
conformance-multi-node.json
|
|
593
661
|
conformance-idempotent.json
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwop/openwop-conformance",
|
|
3
|
-
"version": "2.43.
|
|
3
|
+
"version": "2.43.2",
|
|
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.
|
|
59
|
+
"@openwop/spec-artifacts": "2.43.2"
|
|
60
60
|
}
|
|
61
61
|
}
|