@openwop/openwop-conformance 1.68.1 → 1.70.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/README.md +2 -2
- package/fixtures/conformance-agent-pack-handoff-schema-validation.json +2 -2
- package/package.json +1 -1
- package/schemas/capabilities.schema.json +61 -0
- package/schemas/workflow-definition.schema.json +5 -0
- package/src/lib/multi-agent-capabilities.ts +28 -0
- package/src/scenarios/agentPackHandoffSchemaValidation.test.ts +108 -99
- package/src/scenarios/artifact-type-registration-source.test.ts +133 -0
- package/src/scenarios/artifact-type-store-emission.test.ts +44 -1
- package/src/scenarios/webhook-signed-delivery.test.ts +26 -11
- package/src/scenarios/workflow-variable-format.test.ts +155 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "conformance-agent-pack-handoff-schema-validation",
|
|
3
3
|
"name": "Conformance: Agent Pack Handoff Schema Validation",
|
|
4
4
|
"version": "1.0",
|
|
5
|
-
"description": "Phase 2 / HV-1.
|
|
5
|
+
"description": "Phase 2 / HV-1. A real workflow whose `metadata.requiresAgentId` binds `core.openwop.agent-examples.structured-fixture` (the canonical handoff-schema fixture agent, whose manifest carries `handoff.taskSchemaRef` + `handoff.returnSchemaRef`). The workflow executor fires the handoff gate off that binding BEFORE any node runs (RFC 0003 §D), so the single `core.identity` node is deliberately trivial — the gate, not the node, is under test. The host MUST validate run inputs against the task schema before dispatch and the agent's return against the return schema before persistence. Driven by plain `POST /v1/runs { workflowId, inputs }` with three branches (valid task → completed; invalid task → `handoff_task_schema_violation`; `scenario: mock-return-violation` → `handoff_return_schema_violation`) per agentPackHandoffSchemaValidation.test.ts.",
|
|
6
6
|
"nodes": [
|
|
7
7
|
{
|
|
8
8
|
"id": "noop",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"tags": ["conformance", "multi-agent", "phase-2", "handoff-validation"],
|
|
25
25
|
"requiresInstalledPack": "core.openwop.agent-examples",
|
|
26
26
|
"requiresAgentId": "core.openwop.agent-examples.structured-fixture",
|
|
27
|
-
"notes": "
|
|
27
|
+
"notes": "The gate is a no-op unless the host RESOLVES `requiresAgentId` off its agent registry with compiled handoff validators — i.e. `core.openwop.agent-examples` must be MOUNTED. A host that cannot resolve the agent MUST NOT advertise this fixture (the scenario skips on `isFixtureAdvertised`); a host that mounts the pack but does not advertise `agents.manifestRuntime.handoffValidation` runs HV-1a only (HV-1b/1c gate on that capability). The `core.identity` node is deliberately trivial: the gate fires pre-node off `requiresAgentId`, NOT off any dispatch typeId."
|
|
28
28
|
},
|
|
29
29
|
"settings": { "timeout": 5000 }
|
|
30
30
|
}
|
package/package.json
CHANGED
|
@@ -1773,6 +1773,67 @@
|
|
|
1773
1773
|
}
|
|
1774
1774
|
}
|
|
1775
1775
|
},
|
|
1776
|
+
"artifactTypes": {
|
|
1777
|
+
"type": "object",
|
|
1778
|
+
"description": "RFC 0071, amended by RFC 0075; declared by RFC 0144. Host artifact-type surface — an advertisement (no `ctx.artifactTypes.*` method) that changes how the host treats the artifact references already on the wire (`nodes[].artifact.typeId`, `WorkflowNode.artifactType`, `artifact.created.artifactType`). Carries the corpus's only emission MUST: a host advertising `store: true` MUST persist registered artifacts and emit `artifact.created`. See `host-capabilities.md` §host.artifactTypes and `artifact-type-packs.md` §\"Host capability\". The discovery key is the PLAIN family name at the document root (RFC 0137 G16); `host.artifactTypes` is the capability IDENTIFIER used in pack `peerDependencies`, not this key.",
|
|
1779
|
+
"additionalProperties": false,
|
|
1780
|
+
"properties": {
|
|
1781
|
+
"supported": { "type": "boolean", "description": "Host honors registered artifact types — validates an artifact whose type is registered (pack-installed or host-native) against that type's schema before emitting `artifact.created`, setting `registered: true` + `registrationSource`. Unregistered types stay accepted unvalidated with `registered: false`." },
|
|
1782
|
+
"store": { "type": "boolean", "description": "`true` ⇒ the host persists artifacts of registered types AND emits `artifact.created`. Advertising this is a commitment to emit; RFC 0142 leg B is the witness." },
|
|
1783
|
+
"render": { "type": "boolean", "description": "Advisory — the spec defines no rendering surface. A host advertising `false` for a type it can `store` MUST still accept and store the artifact and MUST NOT fail the run for lack of a renderer (the store-without-render negotiation guarantee)." },
|
|
1784
|
+
"export": { "type": "array", "items": { "type": "string" }, "description": "Export-format identifiers the host can materialize, drawn from declared `exportFormats`. Advisory." },
|
|
1785
|
+
"types": {
|
|
1786
|
+
"type": "object",
|
|
1787
|
+
"description": "RFC 0075 per-type facets, keyed by `artifactTypeId`. Each entry overrides the global object for that type; the global object is the fallback for any type not listed (absent ⇒ host-global semantics; any facet absent ⇒ the global default — additive).",
|
|
1788
|
+
"additionalProperties": {
|
|
1789
|
+
"type": "object",
|
|
1790
|
+
"additionalProperties": false,
|
|
1791
|
+
"properties": {
|
|
1792
|
+
"validated": { "type": "boolean", "description": "The runtime validation guarantee — `true` ⇒ the host validates this type before emit, so emits `registered: true`. Decoupled from `schemaVersions`, which is a version DECLARATION only." },
|
|
1793
|
+
"validation": { "type": "string", "enum": ["open", "closed"], "description": "Mirrors `ArtifactType.validation`, surfacing schema strictness in discovery so a consumer needn't fetch the schema. Default `\"open\"` per `COMPATIBILITY.md` §2.1." },
|
|
1794
|
+
"schemaVersion": { "type": "integer", "minimum": 0 },
|
|
1795
|
+
"registrationSource": { "type": "string", "enum": ["pack", "host"], "description": "RFC 0145. Provenance of this registered type: `pack` (an installed artifact-type pack backs it) or `host` (host-native, validated against a host-known schema with no pack). Mirrors `artifact.created.registrationSource` and MUST match what the host would emit for this type — the two surfaces MUST NOT disagree. OPTIONAL; absent ⇒ unspecified provenance, NOT a default of `pack`. Discloses which §\"Schema distribution\" regime applies: serving the canonical schema URL is a MUST for host-registered (no-pack) types and only a SHOULD for pack-backed ones, and nothing else in the advert reveals which. A host MUST NOT infer this from the identifier's shape — `vendor.*` is a registry namespace, not an installation claim." },
|
|
1796
|
+
"store": { "type": "boolean" },
|
|
1797
|
+
"render": { "type": "boolean" },
|
|
1798
|
+
"export": { "type": "array", "items": { "type": "string" } }
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
},
|
|
1804
|
+
"forms": {
|
|
1805
|
+
"type": "object",
|
|
1806
|
+
"description": "RFC 0137; declared by RFC 0144. Host form-content-pack surface — like `artifactTypes` it adds no `ctx.forms.*` method; it advertises that the host resolves registered form templates from installed `kind: \"form-content\"` packs and instantiates them through its own normal create path. See `host-capabilities.md` §host.forms and `form-content-packs.md`. The discovery key is the PLAIN family name at the document root (RFC 0137 G16); a host may additionally carry a deprecated dotted `\"host.forms\"` mirror during migration — root `additionalProperties` stays `true` so that document remains valid.",
|
|
1807
|
+
"additionalProperties": false,
|
|
1808
|
+
"properties": {
|
|
1809
|
+
"contentPacks": { "type": "boolean", "description": "`true` ⇒ the host instantiates registered form templates. Normative consequences (`form-content-packs.md` §\"Instantiation\"): create through the SAME path a hand-authored form uses; degrade an unrecognized field type to a plain text input rather than failing; keep the instantiated form fully editable; execute nothing from the pack; sanitize/validate/authorize submissions exactly as hand-typed input. Pack-authored strings are UNTRUSTED and MUST propagate `meta.contentTrust: \"untrusted\"` into interpolated prompt segments (invariant `form-content-pack-string-trust-boundary`) — a signature proves authorship, not content safety." }
|
|
1810
|
+
}
|
|
1811
|
+
},
|
|
1812
|
+
"aiEnvelope": {
|
|
1813
|
+
"type": "object",
|
|
1814
|
+
"description": "Declared by RFC 0144. Host typed-envelope generation surface (`ctx.aiEnvelope.generate`), routing the call through the host's BYOK provider layer. Opinionated about envelope shape, unlike the lower-level `aiProviders` capability which returns raw model output. See `host-capabilities.md` §host.aiEnvelope and `ai-envelope.md`.",
|
|
1815
|
+
"additionalProperties": false,
|
|
1816
|
+
"properties": {
|
|
1817
|
+
"supported": { "type": "boolean", "description": "`true` ⇒ `ctx.aiEnvelope.generate` is exposed. Absent/`false` ⇒ packs binding this surface fail with `host_capability_missing`." },
|
|
1818
|
+
"await": { "type": "boolean", "description": "`true` ⇒ `ctx.aiEnvelope.await` is ALSO exposed. `generate` is required whenever the family is advertised; `await` is required only when this sub-flag is." }
|
|
1819
|
+
}
|
|
1820
|
+
},
|
|
1821
|
+
"promptLibrary": {
|
|
1822
|
+
"type": "object",
|
|
1823
|
+
"description": "Declared by RFC 0144. Host prompt-lookup surface (`ctx.promptLibrary.get`), returning a prompt pinned to a specific version so replay stays deterministic. Consumed by `core.ai.callPrompt`. See `host-capabilities.md` §host.promptLibrary.",
|
|
1824
|
+
"additionalProperties": false,
|
|
1825
|
+
"properties": {
|
|
1826
|
+
"supported": { "type": "boolean", "description": "`true` ⇒ `ctx.promptLibrary.get(promptId)` resolves a `{ promptId, systemPrompt, version, envelopeType? }` record. The returned `version` MUST pin the prompt text for replay." }
|
|
1827
|
+
}
|
|
1828
|
+
},
|
|
1829
|
+
"agentRuntime": {
|
|
1830
|
+
"type": "object",
|
|
1831
|
+
"description": "Declared by RFC 0144. The heavyweight swarm/consensus SUPERSET of the agent surface — spawn, delegate, consensus, message-send, skill-invoke, swarm-execute over RFC 0002/0003/0007 primitives. Advertising it IMPLIES `agents.manifestRuntime` (RFC 0070 §B), since `spawn({ manifestId })` instantiates a manifest agent; hosts needing only single-agent or crew dispatch advertise that floor and omit this family. Per-tool authorization, rate limiting, and the content-free tool-call audit trail live in the top-level `toolHooks` block, NOT under this family. See `host-capabilities.md` §host.agentRuntime.",
|
|
1832
|
+
"additionalProperties": false,
|
|
1833
|
+
"properties": {
|
|
1834
|
+
"supported": { "type": "boolean", "description": "`true` ⇒ `ctx.agentRuntime.*` is exposed AND `agents.manifestRuntime` is implied (RFC 0070 §B). A multi-tenant host additionally advertises `agents.manifestRuntime.installScope: 'tenant'` (RFC 0074) so `GET /v1/agents` scopes to the caller's owner triple." }
|
|
1835
|
+
}
|
|
1836
|
+
},
|
|
1776
1837
|
"deadLetter": {
|
|
1777
1838
|
"type": "object",
|
|
1778
1839
|
"description": "RFC 0053 (`Draft`). Run-level dead-letter sink for terminally-failed runs/nodes. On retry exhaustion (RFC 0009), the run is routed to a durable, inspectable sink and a `run.dead_lettered` event is emitted; dead-lettered runs remain fork-eligible (RFC 0011) for the retention window. Distinct from `queueBus.deadLetterSupported`, which dead-letters transport *messages*, not *runs*.",
|
|
@@ -284,6 +284,11 @@
|
|
|
284
284
|
"type": "boolean",
|
|
285
285
|
"default": false,
|
|
286
286
|
"description": "When true, the engine masks this variable's value in persisted `variable.changed` events, `state.snapshot` projections, and `RunSnapshot.variables`. Reads inside NodeModule executors work normally; only persistence + external surfaces mask. See observability.md §Privacy classification (closes O5)."
|
|
287
|
+
},
|
|
288
|
+
"format": {
|
|
289
|
+
"type": "string",
|
|
290
|
+
"minLength": 1,
|
|
291
|
+
"description": "RFC 0136 (`Active`). ADVISORY presentational hint for a `type: \"string\"` variable, drawn from the JSON-Schema format vocabulary (`email`, `uri`, `date`, `date-time`, `time`, `duration` are the v1 recognised set). Hosts SHOULD use it to choose an input affordance (e.g. an email keyboard). It is NOT a validation contract: a host MUST NOT reject a run, refuse a variable write, or fail validation because a value does not match, and MUST NOT assume a value matches when reading it. DELIBERATELY NOT AN ENUM — an unrecognised value MUST round-trip and degrade to plain text rather than error (RFC 0136 requirement 2), which an enum would turn into a hard validation failure on a client-submitted (closed) shape. Ignored when `type` is not `\"string\"`. Orthogonal to `sensitive`: masking hides the VALUE on server-emitted surfaces, `format` describes the FIELD in the definition, so the two compose with no interaction."
|
|
287
292
|
}
|
|
288
293
|
},
|
|
289
294
|
"additionalProperties": false
|
|
@@ -37,6 +37,13 @@ interface AgentCaps {
|
|
|
37
37
|
memoryBackends: ReadonlySet<string>;
|
|
38
38
|
orchestrator: boolean;
|
|
39
39
|
dispatch: boolean;
|
|
40
|
+
/** RFC 0070 §B / RFC 0003 §D. `agents.manifestRuntime.handoffValidation`:
|
|
41
|
+
* the host validates inbound task payloads against `handoff.taskSchemaRef`
|
|
42
|
+
* before dispatch and outbound results against `handoff.returnSchemaRef`
|
|
43
|
+
* before persistence. When false/absent, manifests carrying handoff
|
|
44
|
+
* schemas are dispatched with OPAQUE payloads by design — so a scenario
|
|
45
|
+
* asserting a rejection MUST gate on this, not on coarse `supported`. */
|
|
46
|
+
handoffValidation: boolean;
|
|
40
47
|
reasoning:
|
|
41
48
|
| {
|
|
42
49
|
verbosity: 'summary' | 'full' | 'off' | undefined;
|
|
@@ -104,6 +111,15 @@ export function setMultiAgentCapabilities(c: DiscoveryPayload | null | undefined
|
|
|
104
111
|
memoryBackends: asStringSet(a.memoryBackends),
|
|
105
112
|
orchestrator: asBoolean(a.orchestrator),
|
|
106
113
|
dispatch: asBoolean(a.dispatch),
|
|
114
|
+
// RFC 0070 §B — nested read of `agents.manifestRuntime.handoffValidation`
|
|
115
|
+
// (default false per capabilities.schema.json). A host advertising
|
|
116
|
+
// `agents.supported: true` may still dispatch opaque payloads unless this
|
|
117
|
+
// is true; keeping it distinct from `supported` is what lets HV-1b/1c gate
|
|
118
|
+
// honestly instead of reddening a conformant opaque-dispatch host.
|
|
119
|
+
handoffValidation: (() => {
|
|
120
|
+
const mr = a.manifestRuntime;
|
|
121
|
+
return !!mr && typeof mr === 'object' && asBoolean((mr as Record<string, unknown>).handoffValidation);
|
|
122
|
+
})(),
|
|
107
123
|
reasoning,
|
|
108
124
|
};
|
|
109
125
|
} else {
|
|
@@ -171,6 +187,18 @@ export function isDispatchSupported(): boolean {
|
|
|
171
187
|
return _agentCaps?.dispatch === true;
|
|
172
188
|
}
|
|
173
189
|
|
|
190
|
+
/** RFC 0070 §B / RFC 0003 §D — host validates handoff task/return payloads
|
|
191
|
+
* against the manifest's `handoff.*SchemaRef` (`agents.manifestRuntime
|
|
192
|
+
* .handoffValidation: true`). A host without this dispatches opaque payloads
|
|
193
|
+
* by design, so the HV-1b/HV-1c rejection legs MUST gate on it — asserting a
|
|
194
|
+
* rejection against an opaque-dispatch host is a false red (the conformance-tier
|
|
195
|
+
* form of "a normative claim asserted without gating on the capability that
|
|
196
|
+
* declares its enforcement surface"). HV-1a (valid → completes) does NOT gate
|
|
197
|
+
* on it: a valid payload completes whether or not validation runs. */
|
|
198
|
+
export function hasHandoffValidation(): boolean {
|
|
199
|
+
return _agentCaps?.handoffValidation === true;
|
|
200
|
+
}
|
|
201
|
+
|
|
174
202
|
/** Diagnostic — returns the cached state or `null` if not yet set. */
|
|
175
203
|
export function getCachedAgentCaps(): AgentCaps | null {
|
|
176
204
|
return _agentCaps;
|
|
@@ -13,134 +13,143 @@
|
|
|
13
13
|
* persistence and surface a structured error rather than silently storing
|
|
14
14
|
* an off-contract result.
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* All three legs drive the WORKFLOW EXECUTOR via plain `POST /v1/runs`: the
|
|
17
|
+
* gate fires off `definition.metadata.requiresAgentId` naming an agent that
|
|
18
|
+
* declares `handoff.*SchemaRef`, BEFORE any node runs (the node is a trivial
|
|
19
|
+
* no-op; the gate is what's under test). The fixture references the existing
|
|
20
|
+
* `core.openwop.agent-examples.structured-fixture` agent — task schema
|
|
21
|
+
* `required: [text, extractionFields]`, return schema a success-XOR-error
|
|
22
|
+
* `oneOf` — so no schema authoring is needed suite-side; the host resolves the
|
|
23
|
+
* pack and compiles the validators at pack-load.
|
|
24
|
+
*
|
|
25
|
+
* Gating (two independent conditions, both necessary):
|
|
26
|
+
* 1. `isAgentSupported()` + `isFixtureAdvertised(FIXTURE)` — the host claims
|
|
27
|
+
* the agent surface AND advertises this fixture. A host that does not mount
|
|
28
|
+
* `core.openwop.agent-examples` cannot resolve the agent, so the gate would
|
|
29
|
+
* no-op (ok:true) and the run would complete regardless — such a host MUST
|
|
30
|
+
* NOT advertise the fixture, and the whole block skips cleanly.
|
|
31
|
+
* 2. HV-1b / HV-1c additionally gate on `hasHandoffValidation()`
|
|
32
|
+
* (`agents.manifestRuntime.handoffValidation: true`). A host advertising
|
|
33
|
+
* `agents.supported: true` but NOT `handoffValidation` dispatches opaque
|
|
34
|
+
* payloads BY DESIGN (capabilities.schema.json §manifestRuntime) — asserting
|
|
35
|
+
* a rejection there is a false red, the conformance-tier form of "a
|
|
36
|
+
* normative claim asserted without gating on the capability that declares
|
|
37
|
+
* its enforcement surface." HV-1a (valid → completes) does NOT gate on it.
|
|
38
|
+
*
|
|
39
|
+
* Non-vacuity: HV-1a asserts the valid payload is NOT rejected; HV-1b is the
|
|
40
|
+
* paired sabotage (same inputs minus the required `extractionFields` → the run
|
|
41
|
+
* MUST flip to `failed` with `handoff_task_schema_violation`). If HV-1b did not
|
|
42
|
+
* red on that removal, the gate is not firing and HV-1a is vacuous — so the two
|
|
43
|
+
* legs together prove the gate runs, not just that a no-op completed.
|
|
19
44
|
*
|
|
20
45
|
* @see RFCS/0003-agent-packs.md §D
|
|
21
46
|
* @see schemas/agent-manifest.schema.json #/properties/handoff
|
|
22
|
-
* @see
|
|
47
|
+
* @see schemas/capabilities.schema.json #/properties/agents/properties/manifestRuntime
|
|
23
48
|
*/
|
|
24
49
|
|
|
25
50
|
import { describe, it, expect } from 'vitest';
|
|
26
51
|
import { driver } from '../lib/driver.js';
|
|
27
52
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
28
|
-
import { isAgentSupported } from '../lib/multi-agent-capabilities.js';
|
|
53
|
+
import { isAgentSupported, hasHandoffValidation } from '../lib/multi-agent-capabilities.js';
|
|
29
54
|
|
|
30
55
|
const FIXTURE = 'conformance-agent-pack-handoff-schema-validation';
|
|
31
56
|
const SKIP = !isAgentSupported() || !isFixtureAdvertised(FIXTURE);
|
|
32
57
|
|
|
58
|
+
/** Poll a run to a terminal status (or timeout). */
|
|
59
|
+
async function settle(runId: string, terminal = ['completed', 'failed', 'waiting-approval']): Promise<{ status: string } | undefined> {
|
|
60
|
+
for (let i = 0; i < 40; i++) {
|
|
61
|
+
const res = await driver.get(`/v1/runs/${encodeURIComponent(runId)}`);
|
|
62
|
+
const body = res.json as { status: string };
|
|
63
|
+
if (terminal.includes(body.status)) return body;
|
|
64
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
33
69
|
describe.skipIf(SKIP)('agentPackHandoffSchemaValidation: handoff schema enforcement at dispatch', () => {
|
|
34
|
-
it('valid task payload that matches taskSchemaRef is dispatched and completes', async () => {
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
// (`
|
|
70
|
+
it('HV-1a: valid task payload that matches taskSchemaRef is dispatched and completes', async () => {
|
|
71
|
+
// Valid inputs for `structured-fixture` task schema (required: text,
|
|
72
|
+
// extractionFields). No `scenario` key → the gate takes the task probe,
|
|
73
|
+
// `validateTask(inputs)` passes, the gate is ok, the no-op node runs.
|
|
38
74
|
const create = await driver.post('/v1/runs', {
|
|
39
75
|
workflowId: FIXTURE,
|
|
40
76
|
inputs: {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
extractionFields: ['vendor', 'amount', 'date'],
|
|
77
|
+
text: 'Invoice 42 from Acme, total $1200',
|
|
78
|
+
extractionFields: ['vendor', 'total'],
|
|
44
79
|
},
|
|
45
80
|
});
|
|
46
81
|
expect(create.status).toBe(201);
|
|
47
82
|
const runId = (create.json as { runId: string }).runId;
|
|
48
83
|
|
|
49
|
-
|
|
50
|
-
for (let i = 0; i < 40; i++) {
|
|
51
|
-
const res = await driver.get(`/v1/runs/${encodeURIComponent(runId)}`);
|
|
52
|
-
const body = res.json as { status: string };
|
|
53
|
-
if (['completed', 'failed', 'waiting-approval'].includes(body.status)) {
|
|
54
|
-
snap = body;
|
|
55
|
-
break;
|
|
56
|
-
}
|
|
57
|
-
await new Promise((r) => setTimeout(r, 100));
|
|
58
|
-
}
|
|
84
|
+
const snap = await settle(runId);
|
|
59
85
|
expect(snap?.status, 'HV-1a: valid task payload should NOT be rejected by handoff-schema validation').toBe('completed');
|
|
60
86
|
});
|
|
61
87
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
let snap: { status: string } | undefined;
|
|
75
|
-
for (let i = 0; i < 40; i++) {
|
|
76
|
-
const res = await driver.get(`/v1/runs/${encodeURIComponent(runId)}`);
|
|
77
|
-
const body = res.json as { status: string };
|
|
78
|
-
if (['completed', 'failed'].includes(body.status)) {
|
|
79
|
-
snap = body;
|
|
80
|
-
break;
|
|
81
|
-
}
|
|
82
|
-
await new Promise((r) => setTimeout(r, 100));
|
|
83
|
-
}
|
|
84
|
-
expect(snap?.status, 'HV-1b: invalid task payload MUST cause the run to fail rather than silently dispatch off-contract').toBe('failed');
|
|
88
|
+
// HV-1b / HV-1c assert a REJECTION, which only a host performing handoff
|
|
89
|
+
// validation can produce — gate on the capability that declares that behavior.
|
|
90
|
+
describe.skipIf(!hasHandoffValidation())('rejection legs (agents.manifestRuntime.handoffValidation)', () => {
|
|
91
|
+
it('HV-1b: invalid task payload (missing required field) fails before dispatch with a structured violation', async () => {
|
|
92
|
+
// HV-1a's inputs minus the required `extractionFields` — the paired
|
|
93
|
+
// sabotage. `validateTask` fails `required` → the run MUST fail.
|
|
94
|
+
const create = await driver.post('/v1/runs', {
|
|
95
|
+
workflowId: FIXTURE,
|
|
96
|
+
inputs: { text: 'Invoice 42' },
|
|
97
|
+
});
|
|
98
|
+
expect(create.status).toBe(201);
|
|
99
|
+
const runId = (create.json as { runId: string }).runId;
|
|
85
100
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
101
|
+
const snap = await settle(runId, ['completed', 'failed']);
|
|
102
|
+
expect(
|
|
103
|
+
snap?.status,
|
|
104
|
+
'HV-1b: invalid task payload MUST cause the run to fail rather than silently dispatch off-contract',
|
|
105
|
+
).toBe('failed');
|
|
89
106
|
|
|
90
|
-
|
|
91
|
-
(
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
it('agent return payload that fails returnSchemaRef is rejected before persistence', async () => {
|
|
104
|
-
// The fixture's `mock-return-violation` scenario causes the agent runtime
|
|
105
|
-
// to emit a return payload that violates schemas/structured-fixture.return.schema.json
|
|
106
|
-
// (e.g., omits the required `extracted` field while not declaring `error`).
|
|
107
|
-
const create = await driver.post('/v1/runs', {
|
|
108
|
-
workflowId: FIXTURE,
|
|
109
|
-
inputs: { scenario: 'mock-return-violation' },
|
|
107
|
+
const events = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events`);
|
|
108
|
+
const list = (events.json as { events?: Array<{ type: string; payload?: Record<string, unknown> }> }).events ?? [];
|
|
109
|
+
const validationFailure = list.find(
|
|
110
|
+
(e) =>
|
|
111
|
+
e.type === 'node.failed' &&
|
|
112
|
+
typeof e.payload?.error === 'object' &&
|
|
113
|
+
((e.payload?.error as Record<string, unknown>)?.code === 'handoff_task_schema_violation' ||
|
|
114
|
+
(e.payload?.error as Record<string, unknown>)?.code === 'agent_dispatch_validation_failed'),
|
|
115
|
+
);
|
|
116
|
+
expect(
|
|
117
|
+
validationFailure,
|
|
118
|
+
'HV-1b: failure event payload MUST carry a recognizable handoff-validation error code',
|
|
119
|
+
).toBeDefined();
|
|
110
120
|
});
|
|
111
|
-
expect(create.status).toBe(201);
|
|
112
|
-
const runId = (create.json as { runId: string }).runId;
|
|
113
121
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
// Hosts MAY surface return-schema violations as either a failed run OR a
|
|
125
|
-
// run that completes with a flagged error envelope, but the persisted
|
|
126
|
-
// result MUST NOT carry an off-schema body. Tolerate both outcomes here;
|
|
127
|
-
// the strict assertion is that downstream readers can detect the violation.
|
|
128
|
-
expect(['completed', 'failed']).toContain(snap?.status);
|
|
122
|
+
it('HV-1c: agent return payload that fails returnSchemaRef surfaces a structured violation before persistence', async () => {
|
|
123
|
+
// `scenario: 'mock-return-violation'` routes to the return probe, which
|
|
124
|
+
// validates `{}` against the return schema's success-XOR-error `oneOf`
|
|
125
|
+
// (satisfies neither branch) → a return-schema violation.
|
|
126
|
+
const create = await driver.post('/v1/runs', {
|
|
127
|
+
workflowId: FIXTURE,
|
|
128
|
+
inputs: { scenario: 'mock-return-violation', text: 'x', extractionFields: ['a'] },
|
|
129
|
+
});
|
|
130
|
+
expect(create.status).toBe(201);
|
|
131
|
+
const runId = (create.json as { runId: string }).runId;
|
|
129
132
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
+
const snap = await settle(runId, ['completed', 'failed']);
|
|
134
|
+
// Hosts MAY surface return-schema violations as a failed run OR a run that
|
|
135
|
+
// completes with a flagged error envelope, but the persisted result MUST
|
|
136
|
+
// NOT carry an off-schema body. Tolerate both; the strict assertion is that
|
|
137
|
+
// downstream readers can detect the violation.
|
|
138
|
+
expect(['completed', 'failed']).toContain(snap?.status);
|
|
133
139
|
|
|
134
|
-
|
|
135
|
-
(
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
140
|
+
const events = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events`);
|
|
141
|
+
const list = (events.json as { events?: Array<{ type: string; payload?: Record<string, unknown> }> }).events ?? [];
|
|
142
|
+
const returnViolation = list.find(
|
|
143
|
+
(e) =>
|
|
144
|
+
(e.type === 'node.failed' || e.type === 'agent.error') &&
|
|
145
|
+
typeof e.payload?.error === 'object' &&
|
|
146
|
+
((e.payload?.error as Record<string, unknown>)?.code === 'handoff_return_schema_violation' ||
|
|
147
|
+
(e.payload?.error as Record<string, unknown>)?.code === 'agent_return_validation_failed'),
|
|
148
|
+
);
|
|
149
|
+
expect(
|
|
150
|
+
returnViolation,
|
|
151
|
+
'HV-1c: off-schema return payload MUST surface a structured violation event before persistence',
|
|
152
|
+
).toBeDefined();
|
|
153
|
+
});
|
|
145
154
|
});
|
|
146
155
|
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `registrationSource` as a per-type artifact capability facet (RFC 0145).
|
|
3
|
+
*
|
|
4
|
+
* Always-on corpus legs only. The facet discloses which §"Schema distribution" regime a
|
|
5
|
+
* registered artifact type falls under: serving the canonical schema URL is a MUST for
|
|
6
|
+
* host-registered (no-pack) types and only a SHOULD for pack-backed ones, so the two carry
|
|
7
|
+
* different resolution guarantees — and before this RFC nothing in discovery said which.
|
|
8
|
+
*
|
|
9
|
+
* WHY THE ENUM IS LOAD-BEARING HERE, UNLIKE RFC 0136's `format`. `format` is deliberately
|
|
10
|
+
* NOT an enum: it is an advisory hint on a client-submitted closed shape, so an
|
|
11
|
+
* unrecognised value must degrade to plain text rather than fail a POST. `registrationSource`
|
|
12
|
+
* is the opposite on both axes — it has exactly two meanings, it appears on a SERVER-emitted
|
|
13
|
+
* discovery document, and a third value is not a hint to ignore but a host claiming a
|
|
14
|
+
* provenance the protocol does not define. Leg A2 pins that asymmetry.
|
|
15
|
+
*
|
|
16
|
+
* NOT BUILT (carried as RFC 0145 G1): a leg asserting a host's advertised
|
|
17
|
+
* `registrationSource` matches what it emits on `artifact.created` (requirement 3). It needs
|
|
18
|
+
* a host that advertises the facet AND emits a matching artifact; against a host advertising
|
|
19
|
+
* nothing it would be vacuously green, which is the failure mode this suite keeps finding.
|
|
20
|
+
*
|
|
21
|
+
* @see schemas/capabilities.schema.json §artifactTypes.types
|
|
22
|
+
* @see spec/v1/artifact-type-packs.md §"Per-type facets" + §"Schema distribution"
|
|
23
|
+
* @see RFCS/0145-registration-source-per-type-facet.md
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { describe, it, expect } from 'vitest';
|
|
27
|
+
import { readFileSync } from 'node:fs';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
30
|
+
import addFormats from 'ajv-formats';
|
|
31
|
+
import { SCHEMAS_DIR, V1_DIR } from '../lib/paths.js';
|
|
32
|
+
|
|
33
|
+
const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
|
|
34
|
+
const CAPS = join(SCHEMAS_DIR, 'capabilities.schema.json');
|
|
35
|
+
const EVENT_PAYLOADS = join(SCHEMAS_DIR, 'run-event-payloads.schema.json');
|
|
36
|
+
const readDoc = (name: string): string => (V1_DIR ? readFileSync(join(V1_DIR, name), 'utf8') : '');
|
|
37
|
+
|
|
38
|
+
function loadSchema(path: string): Record<string, unknown> {
|
|
39
|
+
return JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The per-type entry subschema — `artifactTypes.types.additionalProperties`. */
|
|
43
|
+
function perTypeEntry(): Record<string, unknown> {
|
|
44
|
+
const caps = loadSchema(CAPS);
|
|
45
|
+
const props = caps.properties as Record<string, Record<string, unknown>>;
|
|
46
|
+
const types = (props.artifactTypes.properties as Record<string, Record<string, unknown>>).types;
|
|
47
|
+
return types.additionalProperties as Record<string, unknown>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('artifact-type-registration-source (RFC 0145, always-on)', () => {
|
|
51
|
+
it('A1 — the per-type entry declares `registrationSource` as an OPTIONAL pack|host enum', () => {
|
|
52
|
+
const entry = perTypeEntry();
|
|
53
|
+
const props = entry.properties as Record<string, Record<string, unknown>>;
|
|
54
|
+
|
|
55
|
+
expect(props.registrationSource, why('§artifactTypes.types', 'facet is declared')).toBeDefined();
|
|
56
|
+
expect(
|
|
57
|
+
props.registrationSource.enum,
|
|
58
|
+
why('§artifactTypes.types', 'enum is exactly [pack, host]'),
|
|
59
|
+
).toEqual(['pack', 'host']);
|
|
60
|
+
expect(
|
|
61
|
+
(entry.required as string[] | undefined)?.includes('registrationSource') ?? false,
|
|
62
|
+
why('§artifactTypes.types', 'facet is OPTIONAL — absent ⇒ unspecified provenance, not a default'),
|
|
63
|
+
).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('A2 — a valid provenance validates; an out-of-enum value does NOT', () => {
|
|
67
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
68
|
+
addFormats(ajv);
|
|
69
|
+
const validate = ajv.compile(perTypeEntry());
|
|
70
|
+
|
|
71
|
+
expect(
|
|
72
|
+
validate({ validated: true, registrationSource: 'host', schemaVersion: 1 }),
|
|
73
|
+
why('§artifactTypes.types', `"host" validates: ${JSON.stringify(validate.errors)}`),
|
|
74
|
+
).toBe(true);
|
|
75
|
+
expect(
|
|
76
|
+
validate({ validated: true, registrationSource: 'pack' }),
|
|
77
|
+
why('§artifactTypes.types', `"pack" validates: ${JSON.stringify(validate.errors)}`),
|
|
78
|
+
).toBe(true);
|
|
79
|
+
// Absent stays legal — requirement 2.
|
|
80
|
+
expect(
|
|
81
|
+
validate({ validated: true }),
|
|
82
|
+
why('§artifactTypes.types', `absent validates: ${JSON.stringify(validate.errors)}`),
|
|
83
|
+
).toBe(true);
|
|
84
|
+
// A third provenance is a wire error, NOT a hint to ignore. This is where the facet
|
|
85
|
+
// parts company with RFC 0136's `format`, and the reason is in the docblock.
|
|
86
|
+
expect(
|
|
87
|
+
validate({ validated: true, registrationSource: 'registry' }),
|
|
88
|
+
why('§artifactTypes.types', 'an undefined provenance is REJECTED'),
|
|
89
|
+
).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('A3 — the facet mirrors the vocabulary `artifact.created` already carries', () => {
|
|
93
|
+
const payloads = loadSchema(EVENT_PAYLOADS);
|
|
94
|
+
const defs = payloads.$defs as Record<string, Record<string, unknown>>;
|
|
95
|
+
const eventProps = defs.artifactCreated.properties as Record<string, Record<string, unknown>>;
|
|
96
|
+
const entryProps = perTypeEntry().properties as Record<string, Record<string, unknown>>;
|
|
97
|
+
|
|
98
|
+
// Requirement 3 says the two surfaces MUST NOT disagree. They cannot even be compared
|
|
99
|
+
// unless they share a vocabulary, so pin that first — a drift here would let a host
|
|
100
|
+
// advertise a provenance it could never emit.
|
|
101
|
+
expect(
|
|
102
|
+
entryProps.registrationSource.enum,
|
|
103
|
+
why('RFC 0145 req 3', 'discovery enum matches artifact.created enum'),
|
|
104
|
+
).toEqual(eventProps.registrationSource.enum);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it.skipIf(V1_DIR === null)('A4 — both prose sites list the facet, so schema and normative surface cannot drift', () => {
|
|
108
|
+
// The RFC 0144 defect, restated: a wire field with no prose behind it is exactly what
|
|
109
|
+
// this corpus keeps producing. Two docs carry the per-type facet list; both must name it.
|
|
110
|
+
for (const name of ['artifact-type-packs.md', 'host-capabilities.md']) {
|
|
111
|
+
const facetList = readDoc(name).match(
|
|
112
|
+
/validated, validation, schemaVersion, store, render, export[^}]*}/,
|
|
113
|
+
);
|
|
114
|
+
expect(facetList, why(`${name} §"Per-type facets"`, 'per-type facet list present')).not.toBeNull();
|
|
115
|
+
expect(
|
|
116
|
+
facetList?.[0].includes('registrationSource'),
|
|
117
|
+
why(`${name} §"Per-type facets"`, 'facet list names registrationSource'),
|
|
118
|
+
).toBe(true);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it.skipIf(V1_DIR === null)('A5 — the spec states the MUST/SHOULD asymmetry the facet exists to disclose', () => {
|
|
123
|
+
const doc = readDoc('artifact-type-packs.md');
|
|
124
|
+
expect(
|
|
125
|
+
/Serving is a MUST for host-registered/.test(doc),
|
|
126
|
+
why('§Schema distribution', 'serving is a MUST for no-pack types'),
|
|
127
|
+
).toBe(true);
|
|
128
|
+
expect(
|
|
129
|
+
/serving stays a SHOULD for them/.test(doc),
|
|
130
|
+
why('§Schema distribution', 'serving stays a SHOULD for pack-backed types'),
|
|
131
|
+
).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -42,7 +42,7 @@ import { join } from 'node:path';
|
|
|
42
42
|
import { driver } from '../lib/driver.js';
|
|
43
43
|
import { behaviorGate, behaviorGatePresent } from '../lib/behavior-gate.js';
|
|
44
44
|
import { readArtifactTypesCap } from '../lib/artifactTypes.js';
|
|
45
|
-
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
45
|
+
import { SCHEMAS_DIR, V1_DIR } from '../lib/paths.js';
|
|
46
46
|
|
|
47
47
|
const PROFILE = 'openwop-artifact-type-store';
|
|
48
48
|
|
|
@@ -72,6 +72,49 @@ describe('artifact-type-store-emission: the payload fact is pinned always-on (RF
|
|
|
72
72
|
});
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
+
describe('artifact-type-store-emission: what `store: true` claims is stated normatively (RFC 0142 scope ruling)', () => {
|
|
76
|
+
// Leg B can pass against a host on which only ONE path emits, if the seam happens to route
|
|
77
|
+
// through that path. What forecloses that is not a test but the scope of the advert itself,
|
|
78
|
+
// so the scope has to live in the document a host implements against — and be pinned, or a
|
|
79
|
+
// later edit quietly restores the permissive reading and leg B goes back to certifying a
|
|
80
|
+
// host the facet describes falsely.
|
|
81
|
+
const doc = V1_DIR ? readFileSync(join(V1_DIR, 'artifact-type-packs.md'), 'utf8') : '';
|
|
82
|
+
|
|
83
|
+
it.skipIf(V1_DIR === null)('the claim is quantified over artifacts of the type, not over the host\'s paths', () => {
|
|
84
|
+
expect(
|
|
85
|
+
/\*\*every\*\* path by which the host persists an artifact carrying that registered `artifactTypeId` MUST emit `artifact\.created`/.test(doc),
|
|
86
|
+
why(
|
|
87
|
+
'artifact-type-packs.md §"Sub-flags"',
|
|
88
|
+
'EVERY production path emits, not at least one — the permissive reading lets a host wire the leg-B seam through its single emitting path and pass while most of its production stays silent',
|
|
89
|
+
),
|
|
90
|
+
).toBe(true);
|
|
91
|
+
expect(
|
|
92
|
+
/MUST NOT advertise `store: true` for that type/.test(doc),
|
|
93
|
+
why('artifact-type-packs.md §"Sub-flags"', 'a host with a silent persistence path for the type is forbidden from advertising it, rather than merely discouraged'),
|
|
94
|
+
).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it.skipIf(V1_DIR === null)('the obligation is scoped to REGISTERED types, so the unregistered tier does not falsify an advert', () => {
|
|
98
|
+
expect(
|
|
99
|
+
/a path that persists only unregistered artifacts[^.]*is outside this facet and does not falsify the advert/.test(doc),
|
|
100
|
+
why(
|
|
101
|
+
'artifact-type-packs.md §"Sub-flags"',
|
|
102
|
+
'the narrowing is normative, not a courtesy — without it a host holds back an honest advert on account of a path the facet never bound',
|
|
103
|
+
),
|
|
104
|
+
).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it.skipIf(V1_DIR === null)('per-type is named as the instrument, including the case it cannot rescue', () => {
|
|
108
|
+
expect(
|
|
109
|
+
/cannot be advertised `store: true` at all until one of those two facts changes/.test(doc),
|
|
110
|
+
why(
|
|
111
|
+
'artifact-type-packs.md §"Sub-flags"',
|
|
112
|
+
'a single type produced through both an emitting and a non-emitting path is not advertisable — per-type resolves a heterogeneous fleet, not a heterogeneous type',
|
|
113
|
+
),
|
|
114
|
+
).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
75
118
|
/** Reads the `store` facet at BOTH scopes: capability-level and per-type. */
|
|
76
119
|
function storeAdvertised(cap: Record<string, unknown> | null): boolean {
|
|
77
120
|
if (!cap) return false;
|