@openwop/openwop-conformance 1.106.1 → 1.124.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/coverage.md +6 -0
- package/dist/cli.js +89 -45
- package/dist/lib/certification-bundle-verify.js +274 -0
- package/dist/lib/profiles.js +102 -19
- package/dist/lib/requirement-ledger.js +182 -0
- package/dist/lib/requirement-registry.js +110 -0
- package/dist/lib/scenario-disposition.js +249 -0
- package/dist/lib/soft-skip.js +82 -0
- package/fixtures/conformance-a2a-task-roundtrip.json +1 -1
- package/fixtures.md +1 -1
- package/package.json +1 -1
- package/schemas/CORPUS-STAMP.json +2 -2
- package/schemas/README.md +1 -0
- package/schemas/capabilities.schema.json +2 -2
- package/schemas/compensation-policy.schema.json +64 -0
- package/schemas/run-snapshot.schema.json +5 -0
- package/schemas/workflow-chain-pack-manifest.schema.json +125 -0
- package/schemas/workflow-definition.schema.json +4 -0
- package/src/cli.ts +99 -45
- package/src/lib/a2a-fake-peer.ts +290 -110
- package/src/lib/certification-bundle-verify.ts +356 -0
- package/src/lib/error-envelope.ts +77 -0
- package/src/lib/llm-cache-key-recipe.ts +6 -5
- package/src/lib/mcp-fake-server.ts +235 -72
- package/src/lib/profiles.ts +132 -19
- package/src/lib/requirement-ledger.ts +98 -1
- package/src/lib/requirement-registry.ts +47 -4
- package/src/lib/scenario-disposition.ts +291 -0
- package/src/lib/soft-skip.ts +89 -0
- package/src/lib/workflow-chain-expansion.ts +189 -0
- package/src/scenarios/a2a-1-0-agent-card.test.ts +179 -0
- package/src/scenarios/a2a-1-0-task-roundtrip.test.ts +109 -0
- package/src/scenarios/a2a-card-runtime-consistency.test.ts +167 -0
- package/src/scenarios/a2a-peer-authority.test.ts +99 -0
- package/src/scenarios/a2a-task-roundtrip.test.ts +18 -6
- package/src/scenarios/agent-loop-iteration-monotonic.test.ts +3 -2
- package/src/scenarios/agent-loop-stateful-resume.test.ts +3 -2
- package/src/scenarios/agent-loop-version5-shape.test.ts +2 -1
- package/src/scenarios/agent-loop-workspace-snapshot.test.ts +4 -3
- package/src/scenarios/approval-gate-flow.test.ts +5 -4
- package/src/scenarios/artifact-type-store-without-render.test.ts +2 -1
- package/src/scenarios/auth-scim-profile.test.ts +5 -4
- package/src/scenarios/authorization-fail-closed.test.ts +4 -3
- package/src/scenarios/certification-bundle-non-vacuous.test.ts +249 -0
- package/src/scenarios/certification-bundle-redaction.test.ts +182 -0
- package/src/scenarios/certification-floor-enforcement.test.ts +30 -6
- package/src/scenarios/chain-compensation-expansion.test.ts +243 -0
- package/src/scenarios/commitment-fired.test.ts +3 -2
- package/src/scenarios/compensation-behavior.test.ts +63 -0
- package/src/scenarios/compensation-profile.test.ts +144 -1
- package/src/scenarios/conformance-advertised-seam-required.test.ts +118 -0
- package/src/scenarios/conformance-execution-witness.test.ts +104 -0
- package/src/scenarios/conversationCapabilityNegotiation.test.ts +3 -2
- package/src/scenarios/core-manifest-and-extension-registry.test.ts +26 -0
- package/src/scenarios/credential-payload-redaction.test.ts +4 -3
- package/src/scenarios/credentials-capability-shape.test.ts +4 -3
- package/src/scenarios/cross-engine-append-behavior.test.ts +5 -4
- package/src/scenarios/cross-engine-append-ordering.test.ts +3 -2
- package/src/scenarios/cross-host-ancestry-endpoint.test.ts +5 -4
- package/src/scenarios/cross-host-causation-shape.test.ts +3 -2
- package/src/scenarios/cross-workspace-isolation.test.ts +4 -3
- package/src/scenarios/data-residency-admission.test.ts +6 -6
- package/src/scenarios/deadletter-retry-exhaustion.test.ts +5 -4
- package/src/scenarios/distillation-index-roundtrip.test.ts +3 -2
- package/src/scenarios/distillation-secret-carryforward.test.ts +3 -2
- package/src/scenarios/distillation-shape.test.ts +2 -1
- package/src/scenarios/distillation-stable-archive.test.ts +4 -3
- package/src/scenarios/distillation-token-budget.test.ts +4 -3
- package/src/scenarios/envelope-recovery-applied.test.ts +3 -2
- package/src/scenarios/envelope-refusal-shape.test.ts +4 -3
- package/src/scenarios/error-envelope-canonical-shape.test.ts +64 -0
- package/src/scenarios/feedback-correction-redaction.test.ts +4 -3
- package/src/scenarios/feedback-cross-tenant-isolation.test.ts +4 -3
- package/src/scenarios/feedback-fork-not-copied.test.ts +6 -5
- package/src/scenarios/feedback-on-terminal-run.test.ts +4 -3
- package/src/scenarios/feedback-record-and-list.test.ts +4 -3
- package/src/scenarios/feedback-unsupported-501.test.ts +3 -2
- package/src/scenarios/fixtures-valid.test.ts +16 -18
- package/src/scenarios/fs-path-traversal.test.ts +3 -2
- package/src/scenarios/heartbeat-capability-shape.test.ts +2 -1
- package/src/scenarios/heartbeat-fires-once-per-tick.test.ts +4 -3
- package/src/scenarios/heartbeat-idempotent-no-spam.test.ts +6 -5
- package/src/scenarios/heartbeat-runtime-bound.test.ts +4 -3
- package/src/scenarios/mcp-2026-07-28-discover.test.ts +207 -0
- package/src/scenarios/mcp-cache-tenant-scope.test.ts +66 -0
- package/src/scenarios/mcp-current-auth-boundary.test.ts +55 -0
- package/src/scenarios/mcp-extension-opacity.test.ts +79 -0
- package/src/scenarios/mcp-mrtr-roundtrip.test.ts +140 -0
- package/src/scenarios/mcp-stateless-request.test.ts +77 -0
- package/src/scenarios/mcp-tool-roundtrip.test.ts +4 -3
- package/src/scenarios/mcp-toolcall-redaction.test.ts +2 -1
- package/src/scenarios/memory-attribution-emits-on-write.test.ts +6 -5
- package/src/scenarios/memory-attribution-no-content.test.ts +4 -3
- package/src/scenarios/memory-attribution-replay-stable.test.ts +6 -5
- package/src/scenarios/memory-attribution-tenant-scoped.test.ts +4 -3
- package/src/scenarios/memory-compaction-event-emitted.test.ts +3 -2
- package/src/scenarios/memory-compaction-provenance-tag.test.ts +6 -5
- package/src/scenarios/memory-compaction-sr1-carry-forward.test.ts +4 -3
- package/src/scenarios/memory-consolidation-idempotent.test.ts +4 -3
- package/src/scenarios/memory-injection-budget.test.ts +4 -3
- package/src/scenarios/metric-emission.test.ts +4 -3
- package/src/scenarios/multi-agent-confidence-escalation.test.ts +5 -4
- package/src/scenarios/multi-agent-handoff-state-machine.test.ts +4 -3
- package/src/scenarios/multi-agent-memory-lifecycle.test.ts +5 -4
- package/src/scenarios/multi-party-conversation-behavioral.test.ts +3 -2
- package/src/scenarios/multi-region-idempotency-behavior.test.ts +7 -6
- package/src/scenarios/multi-region-idempotency.test.ts +4 -3
- package/src/scenarios/normative-example-extraction.test.ts +242 -0
- package/src/scenarios/oauth-authorization-code-roundtrip.test.ts +4 -3
- package/src/scenarios/oauth-capability-shape.test.ts +4 -3
- package/src/scenarios/oauth-connector-redaction.test.ts +4 -3
- package/src/scenarios/openapi-asyncapi-sdk-parity.test.ts +309 -0
- package/src/scenarios/org-position-no-authority-escalation.test.ts +2 -1
- package/src/scenarios/otel-emission-grpc.test.ts +4 -3
- package/src/scenarios/otel-emission.test.ts +4 -3
- package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +5 -4
- package/src/scenarios/otel-trace-propagation.test.ts +4 -3
- package/src/scenarios/pack-registry-isolation.test.ts +4 -3
- package/src/scenarios/pack-registry-publish.test.ts +65 -3
- package/src/scenarios/pack-registry.test.ts +22 -0
- package/src/scenarios/profile-discovery-core-alias.test.ts +137 -0
- package/src/scenarios/profileDerivation.test.ts +2 -1
- package/src/scenarios/provider-usage.test.ts +2 -2
- package/src/scenarios/rate-limit-envelope.test.ts +2 -1
- package/src/scenarios/registry-public.test.ts +6 -5
- package/src/scenarios/replay-divergence-at-refusal.test.ts +9 -8
- package/src/scenarios/replay-llm-cache-key-portable.test.ts +19 -13
- package/src/scenarios/replay-llm-cache-key.test.ts +120 -47
- package/src/scenarios/replay-observable-sequence-determinism.test.ts +5 -4
- package/src/scenarios/replay-side-effect-suppression.test.ts +20 -1
- package/src/scenarios/requirement-ledger.test.ts +29 -6
- package/src/scenarios/runner-ledger.test.ts +279 -0
- package/src/scenarios/runtime-requires-install-gate.test.ts +6 -5
- package/src/scenarios/sandbox-memory-cap.test.ts +3 -2
- package/src/scenarios/sandbox-mvp-behavior.test.ts +11 -10
- package/src/scenarios/sandbox-no-host-fs-escape.test.ts +2 -1
- package/src/scenarios/sandbox-timeout-cap.test.ts +3 -2
- package/src/scenarios/stream-text-fixture.test.ts +5 -4
- package/src/scenarios/subrun-approval-fail-closed.test.ts +3 -2
- package/src/scenarios/subrun-approval-gate.test.ts +4 -3
- package/src/scenarios/subrun-attestation-shape.test.ts +2 -1
- package/src/scenarios/subrun-checksum-stable.test.ts +4 -3
- package/src/scenarios/table-schema-enforcement.test.ts +2 -2
- package/src/scenarios/tool-hooks-authorization-fail-closed.test.ts +3 -2
- package/src/scenarios/tool-hooks-content-free.test.ts +3 -2
- package/src/scenarios/tool-hooks-rate-limit.test.ts +3 -2
- package/src/scenarios/tool-hooks-secret-redaction.test.ts +3 -2
- package/src/scenarios/tool-hooks-shape.test.ts +2 -1
- package/src/scenarios/trigger-bridge-delivery.test.ts +3 -2
- package/src/scenarios/version-fold.test.ts +5 -4
- package/src/scenarios/voice-streamref-tenant-bound.test.ts +2 -1
- package/src/scenarios/voice-transcription-streaming.test.ts +2 -1
- package/src/scenarios/voice-transcription-unadvertised.test.ts +2 -1
- package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +5 -4
- package/src/scenarios/wasm-pack-invoke-completed.test.ts +3 -2
- package/src/scenarios/wasm-pack-invoke-suspended.test.ts +3 -2
- package/src/scenarios/wasm-pack-load.test.ts +4 -3
- package/src/scenarios/wasm-pack-memory-cap.test.ts +4 -3
- package/src/scenarios/wasm-pack-replay-determinism.test.ts +3 -2
- package/src/scenarios/webhook-signed-delivery.test.ts +4 -3
- package/src/scenarios/workflow-primary-output-annotation.test.ts +13 -23
- package/src/scenarios/workload-identity-behavior.test.ts +7 -4
- package/src/scenarios/workspace-cross-tenant-isolation-blackbox.test.ts +3 -2
- package/src/scenarios/workspace-cross-tenant-isolation.test.ts +3 -2
- package/src/setup.ts +95 -1
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0148 §A / acceptance item 2 (S6) — file-level requirement recording and
|
|
3
|
+
* the runner's derivation.
|
|
4
|
+
*
|
|
5
|
+
* Two halves that meet through the ledger file sink:
|
|
6
|
+
*
|
|
7
|
+
* 1. **In the vitest worker** (`setup.ts` hooks): every scenario FILE records
|
|
8
|
+
* exactly one disposition for its own requirement id when it finishes —
|
|
9
|
+
* `executed-fail` if any test failed, `executed-pass` if any test passed and
|
|
10
|
+
* none failed, and for a file whose tests ALL skipped: `inapplicable` /
|
|
11
|
+
* `skipped` if a `behaviorGate` inside the file recorded one of those for the
|
|
12
|
+
* profile it gates on (the honest reason the tests did not run), else
|
|
13
|
+
* `blocked` — an all-skipped file with no recorded reason is an unclassified
|
|
14
|
+
* return, and §A resolves it to `blocked`, never to a pass.
|
|
15
|
+
*
|
|
16
|
+
* 2. **In the `--certify` runner** (`deriveRequirementDispositions`): every
|
|
17
|
+
* scenario file's requirement is taken FROM THE LEDGER when present, falling
|
|
18
|
+
* back to the vitest per-file report only for pass/fail (which the ledger
|
|
19
|
+
* would agree with) and to `blocked` (unclassified) otherwise; the floor's
|
|
20
|
+
* prefix requirements (`openwop.floor.any.<prefix>`) are derived from the
|
|
21
|
+
* files that match; and a claimed profile whose floor contains ANY requirement
|
|
22
|
+
* with no ledger entry is flagged `unclassified` so the runner can REJECT the
|
|
23
|
+
* certification rather than round the silence up.
|
|
24
|
+
*
|
|
25
|
+
* Pure functions, no I/O, so `runner-ledger.test.ts` can pin them without a
|
|
26
|
+
* host or a vitest subprocess.
|
|
27
|
+
*/
|
|
28
|
+
import { PROFILE_FLOOR_SCENARIOS } from './profiles.js';
|
|
29
|
+
import { requirementIdForScenario, requirementIdForPrefix, requirementsFor } from './requirement-registry.js';
|
|
30
|
+
import { UNCLASSIFIED_RETURN_DETAIL } from './soft-skip.js';
|
|
31
|
+
import { CERTIFIABLE } from './requirement-ledger.js';
|
|
32
|
+
/** All scenario basenames that appear in some profile's runtime floor. */
|
|
33
|
+
export function floorScenarioFiles() {
|
|
34
|
+
const out = new Set();
|
|
35
|
+
for (const floor of Object.values(PROFILE_FLOOR_SCENARIOS)) {
|
|
36
|
+
for (const f of floor.required)
|
|
37
|
+
out.add(f);
|
|
38
|
+
for (const c of floor.conditional ?? [])
|
|
39
|
+
for (const f of c.required)
|
|
40
|
+
out.add(f);
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
/** The requirement id a scenario FILE records under: the §A floor id when the
|
|
45
|
+
* file is part of a floor, else the runner's per-scenario id. */
|
|
46
|
+
export function requirementIdForFile(basename) {
|
|
47
|
+
return floorScenarioFiles().has(basename)
|
|
48
|
+
? requirementIdForScenario(basename)
|
|
49
|
+
: `openwop.scenario.${basename.replace(/\.test\.ts$/, '')}`;
|
|
50
|
+
}
|
|
51
|
+
/** Worker half: fold a file's per-test states (+ any gate-recorded reason) into
|
|
52
|
+
* the ONE disposition the file records. */
|
|
53
|
+
export function fileDisposition(states, gateReason, assertionCount) {
|
|
54
|
+
if (states.some((s) => s === 'fail'))
|
|
55
|
+
return { disposition: 'executed-fail', detail: 'one or more assertions in the file failed' };
|
|
56
|
+
if (states.some((s) => s === 'pass')) {
|
|
57
|
+
// A test that early-returned through `behaviorGate` is reported by vitest
|
|
58
|
+
// as a pass with zero assertions. When EVERY passing test in the file did
|
|
59
|
+
// that (assertionCount 0) and the gate recorded why, the file's honest
|
|
60
|
+
// disposition is the gate's — `inapplicable` / `skipped` with its reason —
|
|
61
|
+
// not a vacuous executed-pass. Without a gate reason a zero-assertion pass
|
|
62
|
+
// stays `executed-pass` with `assertionCount: 0`, which certification
|
|
63
|
+
// rejects as unclassified (RFC 0148 §A).
|
|
64
|
+
if (assertionCount === 0 && gateReason === 'inapplicable') {
|
|
65
|
+
return { disposition: 'inapplicable', detail: 'every test returned early through behaviorGate with zero assertions: profile not advertised in the captured discovery set' };
|
|
66
|
+
}
|
|
67
|
+
if (assertionCount === 0 && gateReason === 'skipped') {
|
|
68
|
+
return { disposition: 'skipped', detail: 'every test returned early through behaviorGate with zero assertions: operator opted the profile out (OPENWOP_OPTED_OUT_PROFILES)' };
|
|
69
|
+
}
|
|
70
|
+
return { disposition: 'executed-pass' };
|
|
71
|
+
}
|
|
72
|
+
if (gateReason === 'inapplicable')
|
|
73
|
+
return { disposition: 'inapplicable', detail: 'every test skipped: profile not advertised in the captured discovery set (behaviorGate)' };
|
|
74
|
+
if (gateReason === 'skipped')
|
|
75
|
+
return { disposition: 'skipped', detail: 'every test skipped: operator opted the profile out (OPENWOP_OPTED_OUT_PROFILES)' };
|
|
76
|
+
return {
|
|
77
|
+
disposition: 'blocked',
|
|
78
|
+
detail: states.length === 0
|
|
79
|
+
? 'no test executed and no disposition recorded — unclassified return (RFC 0148 §A resolves it to blocked)'
|
|
80
|
+
: 'every test skipped with no recorded reason — unclassified return (RFC 0148 §A resolves it to blocked)',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The runner's file-level record (RFC 0148 §A), as `setup.ts` computes it in
|
|
85
|
+
* `afterAll`. Pure so `conformance-execution-witness.test.ts` can pin the rule
|
|
86
|
+
* the hooks apply:
|
|
87
|
+
* - a failed test ⇒ executed-fail; a witnessed pass ⇒ executed-pass;
|
|
88
|
+
* - a zero-assertion "pass" ⇒ the file's noted reason (`softSkip` /
|
|
89
|
+
* `seamAbsent`: inapplicable | skipped | blocked) or a behaviorGate reason;
|
|
90
|
+
* - a zero-assertion "pass" with NO reason ⇒ `blocked` + the marker detail —
|
|
91
|
+
* an early return can never become a pass.
|
|
92
|
+
*/
|
|
93
|
+
export function resolveFileRecord(states, gateReason, assertionCount, noted) {
|
|
94
|
+
let { disposition, detail } = fileDisposition(states, gateReason, assertionCount);
|
|
95
|
+
if (disposition === 'executed-pass' && assertionCount === 0) {
|
|
96
|
+
if (noted !== null) {
|
|
97
|
+
disposition = noted.kind;
|
|
98
|
+
detail = noted.reason;
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
disposition = 'blocked';
|
|
102
|
+
detail = UNCLASSIFIED_RETURN_DETAIL;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return detail === undefined ? { disposition } : { disposition, detail };
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Runner half. `reportStates` is what vitest's JSON report said per file
|
|
109
|
+
* (`passed`/`failed`/`skipped`); `ledger` is what the workers recorded.
|
|
110
|
+
*/
|
|
111
|
+
export function deriveRequirementDispositions(reportStates, ledger, claimedProfiles,
|
|
112
|
+
/** The captured discovery document — needed to evaluate a discovery-conditional floor (G7). */
|
|
113
|
+
document) {
|
|
114
|
+
const byId = new Map(ledger.map((e) => [e.requirementId, e]));
|
|
115
|
+
const ledgerPresent = ledger.length > 0;
|
|
116
|
+
const rows = [];
|
|
117
|
+
const perFile = new Map();
|
|
118
|
+
for (const [file, state] of [...reportStates.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
119
|
+
const id = requirementIdForFile(file);
|
|
120
|
+
const rec = byId.get(id);
|
|
121
|
+
let row;
|
|
122
|
+
if (rec !== undefined) {
|
|
123
|
+
row = {
|
|
124
|
+
requirementId: id,
|
|
125
|
+
scenarioId: file,
|
|
126
|
+
disposition: rec.disposition,
|
|
127
|
+
...(rec.detail === undefined ? {} : { detail: rec.detail }),
|
|
128
|
+
...(rec.assertionCount === undefined ? {} : { assertionCount: rec.assertionCount }),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
else if (state === 'passed') {
|
|
132
|
+
row = ledgerPresent
|
|
133
|
+
? { requirementId: id, scenarioId: file, disposition: 'executed-pass', detail: 'report-derived: vitest passed the file but no disposition was recorded (assertion count unknown) — unclassified for a claimed floor' }
|
|
134
|
+
: { requirementId: id, scenarioId: file, disposition: 'executed-pass' };
|
|
135
|
+
}
|
|
136
|
+
else if (state === 'failed') {
|
|
137
|
+
row = { requirementId: id, scenarioId: file, disposition: 'executed-fail', detail: 'the scenario executed and failed (report-derived; no ledger entry)' };
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
row = {
|
|
141
|
+
requirementId: id,
|
|
142
|
+
scenarioId: file,
|
|
143
|
+
disposition: 'blocked',
|
|
144
|
+
detail: ledgerPresent
|
|
145
|
+
? 'unclassified return: the file recorded no disposition — RFC 0148 §A resolves it to blocked, never to a pass'
|
|
146
|
+
: 'runner cannot classify a skipped file without a ledger; RFC 0148 §A resolves an unclassifiable requirement to blocked',
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
rows.push(row);
|
|
150
|
+
perFile.set(file, row);
|
|
151
|
+
}
|
|
152
|
+
// Prefix requirements: derived from the matching files.
|
|
153
|
+
const prefixIds = new Set();
|
|
154
|
+
for (const floor of Object.values(PROFILE_FLOOR_SCENARIOS))
|
|
155
|
+
for (const p of floor.requiredAnyPrefix ?? [])
|
|
156
|
+
prefixIds.add(p);
|
|
157
|
+
for (const prefix of [...prefixIds].sort()) {
|
|
158
|
+
const matching = [...perFile.entries()].filter(([f]) => f.startsWith(prefix)).map(([, r]) => r);
|
|
159
|
+
const id = requirementIdForPrefix(prefix);
|
|
160
|
+
let row;
|
|
161
|
+
if (matching.some((r) => r.disposition === 'executed-pass')) {
|
|
162
|
+
// Witnessed by the matching passes: the summary row carries their
|
|
163
|
+
// combined assertion count so a consumer reading only this row still
|
|
164
|
+
// sees a witnessed pass (RFC 0148 §C `assertionCount`).
|
|
165
|
+
const witnessed = matching.filter((r) => r.disposition === 'executed-pass').reduce((n, r) => n + (r.assertionCount ?? 0), 0);
|
|
166
|
+
row = { requirementId: id, scenarioId: `${prefix}*`, disposition: 'executed-pass', assertionCount: witnessed };
|
|
167
|
+
}
|
|
168
|
+
else if (matching.some((r) => r.disposition === 'executed-fail')) {
|
|
169
|
+
row = { requirementId: id, scenarioId: `${prefix}*`, disposition: 'executed-fail', detail: `no ${prefix}* scenario passed and at least one failed` };
|
|
170
|
+
}
|
|
171
|
+
else if (matching.length === 0) {
|
|
172
|
+
row = { requirementId: id, scenarioId: `${prefix}*`, disposition: 'blocked', detail: `no ${prefix}* scenario ran — unclassified return` };
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
// all matching files are skipped/inapplicable/blocked: the prefix requirement is met by ANY pass, so none ⇒ blocked
|
|
176
|
+
row = { requirementId: id, scenarioId: `${prefix}*`, disposition: 'blocked', detail: `no ${prefix}* scenario executed a passing assertion (${matching.map((r) => r.disposition).join(', ')})` };
|
|
177
|
+
}
|
|
178
|
+
rows.push(row);
|
|
179
|
+
}
|
|
180
|
+
const totals = { executedPass: 0, executedFail: 0, skipped: 0, inapplicable: 0, blocked: 0 };
|
|
181
|
+
for (const r of rows) {
|
|
182
|
+
if (r.disposition === 'executed-pass')
|
|
183
|
+
totals.executedPass++;
|
|
184
|
+
else if (r.disposition === 'executed-fail')
|
|
185
|
+
totals.executedFail++;
|
|
186
|
+
else if (r.disposition === 'skipped')
|
|
187
|
+
totals.skipped++;
|
|
188
|
+
else if (r.disposition === 'inapplicable')
|
|
189
|
+
totals.inapplicable++;
|
|
190
|
+
else
|
|
191
|
+
totals.blocked++;
|
|
192
|
+
}
|
|
193
|
+
const rowById = new Map(rows.map((r) => [r.requirementId, r]));
|
|
194
|
+
const verdicts = [];
|
|
195
|
+
for (const profile of claimedProfiles) {
|
|
196
|
+
const ids = requirementsFor(profile, document);
|
|
197
|
+
if (ids === null) {
|
|
198
|
+
const floor = PROFILE_FLOOR_SCENARIOS[profile];
|
|
199
|
+
const why = floor === undefined ? `(no floor defined for ${profile})` : `(discovery-conditional floor for ${profile} is unevaluable without the discovery document)`;
|
|
200
|
+
verdicts.push({ profile, unclassified: [], blocking: [why], certifiable: false, runtimeDerived: false, held: false });
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const unclassified = [];
|
|
204
|
+
const blocking = [];
|
|
205
|
+
let witnessedPasses = 0;
|
|
206
|
+
for (const id of ids) {
|
|
207
|
+
const r = rowById.get(id);
|
|
208
|
+
const fromLedger = byId.has(id) || (r !== undefined && r.scenarioId.endsWith('*'));
|
|
209
|
+
if (r !== undefined && r.disposition === 'executed-pass' && (r.assertionCount ?? 0) > 0)
|
|
210
|
+
witnessedPasses += 1;
|
|
211
|
+
// Unclassified: no row, or a report-derived blocked (nothing recorded), or a
|
|
212
|
+
// VACUOUS pass — executed-pass with assertionCount 0 is a witness of nothing
|
|
213
|
+
// (RFC 0148 §A: "a required behavior MUST NOT be certified without a target
|
|
214
|
+
// execution witness"), so for a claimed floor it counts as unclassified.
|
|
215
|
+
const vacuous = r !== undefined &&
|
|
216
|
+
((r.disposition === 'executed-pass' && r.assertionCount === 0) ||
|
|
217
|
+
// the runner's own §A resolution of a zero-assertion file that noted no
|
|
218
|
+
// reason: honest as a row, still an unclassified return for a floor
|
|
219
|
+
(r.disposition === 'blocked' && r.detail === UNCLASSIFIED_RETURN_DETAIL));
|
|
220
|
+
// With a ledger present, ANY floor row that did not come from the ledger is
|
|
221
|
+
// unclassified: silence is evidence of nothing (RFC 0148 §A). Without a
|
|
222
|
+
// ledger only report-blocked rows are unclassified (the pre-S6 reading).
|
|
223
|
+
const silent = !fromLedger && (ledgerPresent || r?.disposition === 'blocked');
|
|
224
|
+
if (r === undefined || silent || vacuous)
|
|
225
|
+
unclassified.push(id);
|
|
226
|
+
// Unclassified always blocks: a requirement nobody recorded cannot certify.
|
|
227
|
+
if (r === undefined || silent || vacuous || !CERTIFIABLE.includes(r.disposition))
|
|
228
|
+
blocking.push(id);
|
|
229
|
+
}
|
|
230
|
+
// discoveryOnly floors have ids.length === 0 and certify by design here (the
|
|
231
|
+
// requirement-ledger's verifyProfileRequirements is stricter; the runner
|
|
232
|
+
// consults PROFILE_FLOOR_SCENARIOS.discoveryOnly separately).
|
|
233
|
+
const discoveryOnly = PROFILE_FLOOR_SCENARIOS[profile]?.discoveryOnly === true;
|
|
234
|
+
const runtimeDerived = PROFILE_FLOOR_SCENARIOS[profile]?.runtimeDerived === true;
|
|
235
|
+
// A runtime-derived profile is HELD only when every floor row is a witnessed
|
|
236
|
+
// pass ("derivable from which scenarios pass" — profiles.md). Anything else
|
|
237
|
+
// means the host does not hold it: not a rejection, not a blocked claim.
|
|
238
|
+
const held = ids.length > 0 && witnessedPasses === ids.length;
|
|
239
|
+
verdicts.push({
|
|
240
|
+
profile,
|
|
241
|
+
unclassified: runtimeDerived && !held ? [] : unclassified,
|
|
242
|
+
blocking: runtimeDerived && !held ? ids.filter((id) => rowById.get(id)?.disposition !== 'executed-pass' || (rowById.get(id)?.assertionCount ?? 0) === 0) : blocking,
|
|
243
|
+
certifiable: runtimeDerived ? held : discoveryOnly || (ids.length > 0 && blocking.length === 0),
|
|
244
|
+
runtimeDerived,
|
|
245
|
+
held,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
return { requirements: rows, totals, verdicts, rejectUnclassified: verdicts.some((v) => v.unclassified.length > 0), ledgerPresent };
|
|
249
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0148 §A — say WHY a scenario returned early.
|
|
3
|
+
*
|
|
4
|
+
* A test that returns before its first `expect` reports to vitest as a pass
|
|
5
|
+
* with zero assertions. Under the ledger that is an UNCLASSIFIED return: the
|
|
6
|
+
* runner cannot tell "capability not advertised" from "seam not mounted" from
|
|
7
|
+
* "operator opted out". `behaviorGate` records the first two cases for
|
|
8
|
+
* profile-gated files; every other early return in the corpus was silent.
|
|
9
|
+
*
|
|
10
|
+
* `softSkip(kind, reason)` notes the reason for the CURRENT test file (from
|
|
11
|
+
* vitest's `expect.getState().testPath`) and returns `undefined`, so an
|
|
12
|
+
* early return becomes one expression:
|
|
13
|
+
*
|
|
14
|
+
* if (!(await advertised())) return softSkip('inapplicable', 'host does not advertise X');
|
|
15
|
+
* if (res.status === 404) return softSkip('blocked', 'seam /v1/host/sample/... not mounted');
|
|
16
|
+
*
|
|
17
|
+
* `setup.ts` reads the notes when it records the file: a file whose passes are
|
|
18
|
+
* all zero-assertion takes the noted disposition (`inapplicable` / `skipped` /
|
|
19
|
+
* `blocked`, worst-first if mixed) with the joined reasons; a file with real
|
|
20
|
+
* assertions is `executed-pass` regardless of notes. A zero-assertion file with
|
|
21
|
+
* NO note at all is recorded `blocked` — "unclassified return; RFC 0148 §A
|
|
22
|
+
* resolves it to blocked" — and stays UNCLASSIFIED for certification (a floor
|
|
23
|
+
* row with that disposition still rejects), so the honest bundle row and the
|
|
24
|
+
* pressure to say why both survive.
|
|
25
|
+
*/
|
|
26
|
+
import { expect } from 'vitest';
|
|
27
|
+
import { basename } from 'node:path';
|
|
28
|
+
/** Detail marker the runner writes for a zero-assertion file that noted nothing. */
|
|
29
|
+
export const UNCLASSIFIED_RETURN_DETAIL = 'every test returned early with zero assertions and no recorded reason — unclassified return; RFC 0148 §A resolves it to blocked (add softSkip(kind, reason) at the early return)';
|
|
30
|
+
const notes = new Map();
|
|
31
|
+
function currentFile() {
|
|
32
|
+
try {
|
|
33
|
+
const p = expect.getState().testPath;
|
|
34
|
+
return typeof p === 'string' && p.length > 0 ? basename(p) : null;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Note an early return for the current file. Returns undefined so `return softSkip(...)` reads naturally. */
|
|
41
|
+
export function softSkip(kind, reason) {
|
|
42
|
+
const file = currentFile();
|
|
43
|
+
if (file === null)
|
|
44
|
+
return undefined;
|
|
45
|
+
const arr = notes.get(file) ?? [];
|
|
46
|
+
if (!arr.some((n) => n.kind === kind && n.reason === reason))
|
|
47
|
+
arr.push({ kind, reason });
|
|
48
|
+
notes.set(file, arr);
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* An advertised capability whose observation seam is absent or refused
|
|
53
|
+
* (404 / 403 / `null` from a seam helper): the host made a claim the suite
|
|
54
|
+
* cannot check. Default mode notes `blocked` with the reason (RFC 0148 §A);
|
|
55
|
+
* under `OPENWOP_REQUIRE_BEHAVIOR=true` it FAILS (RFC 0148 §B / RFC 0139 G14
|
|
56
|
+
* flip) — advertised behaviour MUST be present. Use as `return seamAbsent(...)`.
|
|
57
|
+
*/
|
|
58
|
+
export function seamAbsent(reason) {
|
|
59
|
+
if (process.env['OPENWOP_REQUIRE_BEHAVIOR'] === 'true') {
|
|
60
|
+
throw new Error(`RFC 0148 §B: advertised behaviour is not observable — ${reason} (OPENWOP_REQUIRE_BEHAVIOR=true fails an advertised-missing seam)`);
|
|
61
|
+
}
|
|
62
|
+
return softSkip('blocked', reason);
|
|
63
|
+
}
|
|
64
|
+
const RANK = { blocked: 0, skipped: 1, inapplicable: 2 };
|
|
65
|
+
/**
|
|
66
|
+
* The noted disposition for a file, worst-first when mixed (`blocked` beats
|
|
67
|
+
* `skipped` beats `inapplicable` — a file that could not check one thing is
|
|
68
|
+
* not certifiable on the strength of another thing being inapplicable), with
|
|
69
|
+
* the reasons joined. `null` when nothing was noted.
|
|
70
|
+
*/
|
|
71
|
+
export function softSkipDisposition(file) {
|
|
72
|
+
const arr = notes.get(file);
|
|
73
|
+
if (arr === undefined || arr.length === 0)
|
|
74
|
+
return null;
|
|
75
|
+
const kind = [...arr].sort((a, b) => RANK[a.kind] - RANK[b.kind])[0].kind;
|
|
76
|
+
const reason = arr.map((n) => (arr.length > 1 ? `[${n.kind}] ${n.reason}` : n.reason)).join('; ');
|
|
77
|
+
return { kind, reason };
|
|
78
|
+
}
|
|
79
|
+
/** Test hook. */
|
|
80
|
+
export function resetSoftSkips() {
|
|
81
|
+
notes.clear();
|
|
82
|
+
}
|
package/fixtures.md
CHANGED
|
@@ -103,7 +103,7 @@ All fixtures MUST advertise:
|
|
|
103
103
|
| Orchestrator Terminate | `conformance-orchestrator-terminate` | Phase 5 / CO-3 — terminate decision is final | `completed` | ≤ 30s |
|
|
104
104
|
| Orchestrator Low-Confidence | `conformance-orchestrator-low-confidence` | Phase 5 / CP-1 — supervisor low-confidence suspend | `waiting-approval` (suspends) | unbounded (suspends) |
|
|
105
105
|
| MCP Tool Roundtrip | `conformance-mcp-tool-roundtrip` | Track 6 — host invokes a tool on the conformance suite's synthetic MCP server; trust-boundary visibility in the event log | `completed` | ≤ 30s |
|
|
106
|
-
| A2A Task Roundtrip | `conformance-a2a-task-roundtrip` | Track 6 — host consumes the conformance suite's synthetic A2A peer; covers drift points #3 (`AUTH_REQUIRED`) and #4 (`REJECTED`) | `failed` or `waiting-input` (per `driftScenario` input) | ≤ 30s |
|
|
106
|
+
| A2A Task Roundtrip | `conformance-a2a-task-roundtrip` | Track 6 — host consumes the conformance suite's synthetic A2A peer; covers drift points #3 (`AUTH_REQUIRED`) and #4 (`REJECTED`). Node `core.conformance.a2a-invoke` is a **conformance-RESERVED** typeId (renamed 2026-08-16 from `core.a2a.invoke`, a multi-segment `core.*` id no host shipped and the corpus never defined): a host that consumes A2A peers MUST map it to its A2A bridge node (a host-extension node — `a2a-integration.md` §"State projection"); a host that does not consume A2A MUST NOT advertise this fixture | `failed` or `waiting-input` (per `driftScenario` input) | ≤ 30s |
|
|
107
107
|
| WASM Pack Roundtrip | `conformance-wasm-pack-roundtrip` | RFC 0008 — invokes `vendor.openwop.rust-hello.greet` (loaded WASM pack); exercises required exports + at least one import | `completed` | ≤ 10s |
|
|
108
108
|
| WASM Pack Memory-Cap Breach | `conformance-wasm-pack-memory-cap-breach` | RFC 0008 §K — invokes the deliberately-misbehaving `vendor.openwop.misbehaving.memory-bomb` pack (allocates 1 GiB beyond the host's `memoryPagesMax`). Host MUST emit `cap.breached` with `kind: "wasm-memory"` and drive the run to terminal `failed`. Misbehaving pack lives at `openwop-examples:examples/packs/rust-misbehaving-memory/` (repo-qualified per the 2026-06 monorepo split — the `openwop-examples` sibling repo) and is fixture-only (NOT signed for registry publication). | `failed` (with `cap.breached`) | ≤ 10s |
|
|
109
109
|
| Configurable Schema | `conformance-configurable-schema` | Track 13 — workflow declares `configurableSchema` (`additionalProperties: false`, `recursionLimit: integer ≥ 1`). Suite verifies `GET /v1/workflows/{id}` surfaces the schema AND `POST /v1/runs` with a mismatched `configurable` returns `validation_error`. | `completed` (with accepted overlay) | ≤ 5s |
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_comment": "Provenance of this vendored schemas/ copy. See conformance/README.md \u00a7\"Resolving the contract\". Compare against the stamp in your installed @openwop/openwop-conformance to detect a stale hand-copied contract.",
|
|
3
|
-
"suiteVersion": "1.
|
|
4
|
-
"corpusCommit": "
|
|
3
|
+
"suiteVersion": "1.124.0",
|
|
4
|
+
"corpusCommit": "7dffa37f3864f170141130aa0360765ba89b2e7e"
|
|
5
5
|
}
|
package/schemas/README.md
CHANGED
|
@@ -89,6 +89,7 @@
|
|
|
89
89
|
| `workflow-definition.schema.json` | `channels-and-reducers.md` + `node-packs.md` | DAG of nodes + edges + triggers + variables + channels |
|
|
90
90
|
| `certification-bundle-v2.schema.json` | `profiles.md` + RFC 0148 §C | Certification bundle v2 — per-requirement dispositions instead of pass/fail/skip file lists. `blocked` is a REQUIRED total because `blocked: 0` asserted differs from `blocked` unstated, and `assertionCount` makes an `executed-pass` that ran nothing visible in the artifact |
|
|
91
91
|
| `workload-identity.schema.json` | `auth.md` + RFC 0154 §A/§B | Verified workload identity + delegated actor chain. Closed by design: raw certificates, tokens, proofs, and credentials MUST NOT enter these objects — `subject` is opaque and `proofRef`/`thumbprintRef` are digest references, because these objects reach events, spans, and audit records |
|
|
92
|
+
| `compensation-policy.schema.json` | `compensation.md` §B + RFC 0151 | The reserved `settings.compensation` workflow key — WHEN an unwind starts (`triggers`, closed: `node-failure`/`run-cancel`/`cap-breach`/`operator-request`) and HOW it runs (`orderingModel`, `retry`/`timeoutMs` defaults, `exhaustedDisposition`, escalate-only `approvalScope`, `onParentCancel`). Authored, not per-run; `additionalProperties:false`. A non-advertising host MUST refuse it with `capability_required` rather than accept a policy it will never honour |
|
|
92
93
|
| `workspace-file.schema.json` | `agent-workspace.md` + `RFCS/0059` | RFC 0059 — a versioned workspace file (`{path, content, version, etag, updatedAt}`); response of `GET/PUT /v1/host/workspace/files/{path}`. |
|
|
93
94
|
| `workspace-file-create.schema.json` | `agent-workspace.md` + `RFCS/0059` | RFC 0059 — `PUT /v1/host/workspace/files/{path}` request body (content + optional contentType; path from the URL, version/etag host-assigned). |
|
|
94
95
|
|
|
@@ -2864,11 +2864,11 @@
|
|
|
2864
2864
|
"agentCardUrl": {
|
|
2865
2865
|
"type": "string",
|
|
2866
2866
|
"format": "uri",
|
|
2867
|
-
"description": "The A2A
|
|
2867
|
+
"description": "The A2A well-known agent card URL (`/.well-known/agent-card.json` \u2014 the same path in 0.3 and 1.0; the protocol version is discovered INSIDE the 1.0 card via `supportedInterfaces[].protocolVersion`, RFC 0152 \u00a7C)."
|
|
2868
2868
|
},
|
|
2869
2869
|
"streaming": {
|
|
2870
2870
|
"type": "boolean",
|
|
2871
|
-
"description": "Host supports `message/stream` + `tasks/resubscribe` (A2A `capabilities.streaming
|
|
2871
|
+
"description": "Host supports `message/stream` + `tasks/resubscribe` (0.3 names) / `SendStreamingMessage` + `SubscribeToTask` (1.0 names) \u2014 A2A `capabilities.streaming`. Gates the RFC 0100 \u00a73 resubscribe re-attach; MUST equal the Agent Card's `capabilities.streaming` (RFC 0152 \u00a7C)."
|
|
2872
2872
|
},
|
|
2873
2873
|
"pushNotifications": {
|
|
2874
2874
|
"type": "boolean",
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://openwop.dev/spec/v1/compensation-policy.schema.json",
|
|
4
|
+
"title": "CompensationPolicy",
|
|
5
|
+
"description": "RFC 0151 §B — the workflow-level compensation policy, carried as the reserved `settings.compensation` key on `WorkflowDefinition` (`workflow-definition.schema.json` §WorkflowSettings). Node-level `compensation` declarations say WHAT the inverse action is; this policy says WHEN the host starts an unwind and HOW it runs one — which failures qualify (`triggers`), the ordering model, retry/timeout defaults for inverse actions, what happens when an inverse action exhausts its retries, whether approval is escalated, and what a parent cancel does to an active unwind. Closed and additive. A host that advertises `capabilities.compensation` MUST validate the policy at workflow registration and refuse one that names an `orderingModel` or `profileVersion` it does not advertise (`validation_error`). A host that does NOT advertise the family MUST refuse a workflow that carries this key with `capability_required` (`details.requiredCapability: \"compensation\"`, per `capabilities.md` §\"Unsupported capability — refusal contract\") rather than accept it silently: accepting a policy the host will never honour tells the author an unwind will happen when it will not, which is RFC 0148 §B's advertise-and-opt-out failure with the sign flipped. Nothing here can WEAKEN a node's own declaration — a node's `compensation.requiresApproval: true` and `retry` bounds always win over the policy's defaults. Policy is authored, not per-run: there is deliberately no run-options overlay, because a per-run caller who could lower approval scope or drop a trigger would be authorizing their own unwind (RFC 0147 R9).",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"required": ["triggers"],
|
|
9
|
+
"properties": {
|
|
10
|
+
"profileVersion": {
|
|
11
|
+
"type": "string",
|
|
12
|
+
"pattern": "^[1-9][0-9]*$",
|
|
13
|
+
"description": "The RFC 0151 profile version this policy was authored against. OPTIONAL; when present it MUST equal the host's advertised `capabilities.compensation.profileVersion` or registration fails, because `profileVersion` is part of the inverse-action identity (§C) and a workflow authored under one ordering rule MUST NOT mint identities under another."
|
|
14
|
+
},
|
|
15
|
+
"orderingModel": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"enum": ["reverse-completion", "dependency-graph"],
|
|
18
|
+
"default": "reverse-completion",
|
|
19
|
+
"description": "RFC 0151 §A/§C. `reverse-completion` (default) orders inverse actions by descending durable forward-completion sequence; `dependency-graph` MUST be a DAG and preserve reverse dependency order. The value MUST be one of the host's advertised `capabilities.compensation.orderingModels[]`; a host MUST refuse the workflow at registration otherwise, so an unwind never discovers at failure time that its ordering rule is unimplemented."
|
|
20
|
+
},
|
|
21
|
+
"triggers": {
|
|
22
|
+
"type": "array",
|
|
23
|
+
"minItems": 1,
|
|
24
|
+
"uniqueItems": true,
|
|
25
|
+
"items": {
|
|
26
|
+
"type": "string",
|
|
27
|
+
"enum": ["node-failure", "run-cancel", "cap-breach", "operator-request"]
|
|
28
|
+
},
|
|
29
|
+
"description": "Which events start an unwind. REQUIRED and closed — a policy that names no trigger is not a policy, and an open string here would let a host and an author disagree about a word. `node-failure`: a node reaches terminal failure after its own RFC 0009 retry policy and the run cannot continue. `run-cancel`: an RFC 0094 cancel is accepted while committed effects exist. `cap-breach`: an RFC 0058 / RFC 0084 `cap.breached` hard stop. `operator-request`: an authorized operator explicitly requests an unwind (§E) — MUST be authorized per RFC 0049, binding tenant, principal, action, and plan version. A trigger not listed here does NOT start an unwind; the run ends with its committed effects in place and `compensationStatus: none`, which is the honest shape — no generic rollback is inferred from an undeclared trigger (RFC 0151 §Compatibility)."
|
|
30
|
+
},
|
|
31
|
+
"retry": {
|
|
32
|
+
"type": "object",
|
|
33
|
+
"additionalProperties": false,
|
|
34
|
+
"description": "Defaults for inverse actions whose node-level `compensation.retry` is absent. A node's own bounds always win. Retries reuse the inverse action's stable identity (§C) — never a fresh one, or a transient refund failure mints a second refund.",
|
|
35
|
+
"properties": {
|
|
36
|
+
"maxAttempts": { "type": "integer", "minimum": 1 },
|
|
37
|
+
"backoffMs": { "type": "integer", "minimum": 0 }
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"timeoutMs": {
|
|
41
|
+
"type": "integer",
|
|
42
|
+
"minimum": 1,
|
|
43
|
+
"description": "Wall-clock ceiling per inverse-action attempt, in milliseconds. A timed-out attempt counts against `retry.maxAttempts`; it is not a distinct outcome."
|
|
44
|
+
},
|
|
45
|
+
"exhaustedDisposition": {
|
|
46
|
+
"type": "string",
|
|
47
|
+
"enum": ["record-outcome", "manual-intervention"],
|
|
48
|
+
"default": "record-outcome",
|
|
49
|
+
"description": "RFC 0151 §E — what the host does when an inverse action exhausts its retries. Either way the run MUST route to RFC 0053 dead-letter handling and the run's `compensationStatus` follows the §D fold (`partial` or `failed`). `record-outcome` (default): record the failure and continue unwinding the remaining actions. `manual-intervention`: additionally emit `compensation.manual_intervention_required` and stop issuing further inverse actions until an authorized operator retries, skips with recorded justification, substitutes a registered action, or terminates as uncompensated — the rollup reads `manual` while that is unresolved. Requires `capabilities.compensation.manualIntervention: true`; a host that does not advertise it MUST refuse a policy that selects it."
|
|
50
|
+
},
|
|
51
|
+
"approvalScope": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"enum": ["declared", "all"],
|
|
54
|
+
"default": "declared",
|
|
55
|
+
"description": "RFC 0151 §E. `declared` (default): only inverse actions whose node sets `compensation.requiresApproval: true` create an RFC 0051 approval interrupt. `all`: every inverse action does, regardless of the node's declaration. This value can only ESCALATE — there is no `none`, because a policy MUST NOT strip an approval a node declared for itself (RFC 0147 R9: an inverse action can itself be harmful)."
|
|
56
|
+
},
|
|
57
|
+
"onParentCancel": {
|
|
58
|
+
"type": "string",
|
|
59
|
+
"enum": ["continue", "pause", "manual"],
|
|
60
|
+
"default": "continue",
|
|
61
|
+
"description": "RFC 0151 §C — cancellation of the parent run MUST NOT silently abandon an active compensation. `continue`: the unwind runs to its terminal rollup. `pause`: emit `compensation.paused` and hold for authorized intervention (the run's own `status` carries the wait). `manual`: emit `compensation.manual_intervention_required` and stop; rollup reads `manual`. `pause`/`manual` require `capabilities.compensation.manualIntervention: true`."
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -33,6 +33,11 @@
|
|
|
33
33
|
],
|
|
34
34
|
"description": "Current run state. `waiting-external` MUST be used when the suspended interrupt's `kind` is `external-event` per `interrupt-profiles.md §openwop-interrupt-external-event` — distinguishes external-event waits from HITL waits at the wire level. `cancelling` (RFC 0094 §B) is the transitional state between a cancel request being accepted and the terminal `cancelled` — `rest-endpoints.md` and the OpenAPI cancel responses already document the transition; a snapshot read during the cancel cascade carries it. Forward-compat: future statuses MAY be added; readers SHOULD treat unknown values as terminal-unknown rather than throw."
|
|
35
35
|
},
|
|
36
|
+
"compensationStatus": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"enum": ["none", "pending", "running", "completed", "partial", "failed", "manual"],
|
|
39
|
+
"description": "RFC 0151 §D — the run's compensation (unwind) rollup, kept separate from `status` on purpose: `status` is the FORWARD execution state and a closed union exported by the SDK, so RFC 0151 forbids reinterpreting it (there is deliberately no `compensating` run status). OPTIONAL and capability-gated — a host that does not advertise `capabilities.compensation` MUST omit this field; a host that advertises it MUST include it on every snapshot, `none` when no compensation was ever requested for the run. Presence is therefore a wire witness of the advert, and a `none` cannot be read as \"unwind is monitored here\" from a host that would never unwind. Values are the deterministic fold of the six `compensation.*` events over the persisted plan, defined normatively in `spec/v1/compensation.md §\"Run rollup: compensationStatus\"`: `none` (no `compensation.requested`), `pending` (requested, no `started`), `running` (started, plan active — a §E approval pause does NOT change it; the run's own `status: waiting-approval` carries the wait), `completed` (every inverse action completed), `partial` (plan inactive; some completed and some did not — reported, never rounded), `failed` (plan inactive; none completed), `manual` (`manual_intervention_required` recorded and unresolved; takes precedence over `partial`/`failed` while unresolved). Content-free by construction. Resolves RFC 0151 UQ3: `RunSnapshot` (`GET /v1/runs/{runId}`) is the sole owner; debug bundles and the AsyncAPI `run.snapshot` reuse it by `$ref`."
|
|
40
|
+
},
|
|
36
41
|
"owner": {
|
|
37
42
|
"type": "object",
|
|
38
43
|
"description": "RFC 0048. The identity triple that owns this run. Redaction-safe — `principal` is an opaque identifier, never PII or credential material. Optional: single-tenant hosts omit it. A principal scoped to one `workspace` MUST NOT read a run owned by another (`run_forbidden`).",
|