@openwop/openwop-conformance 1.68.2 → 1.71.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 +22 -2
- package/fixtures/conformance-agent-pack-handoff-schema-validation.json +2 -2
- package/package.json +1 -1
- package/schemas/capabilities.schema.json +1 -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 +220 -0
- package/src/scenarios/artifact-type-store-emission.test.ts +44 -1
- package/src/scenarios/spec-corpus-validity.test.ts +43 -4
- 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
|
@@ -1792,6 +1792,7 @@
|
|
|
1792
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
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
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." },
|
|
1795
1796
|
"store": { "type": "boolean" },
|
|
1796
1797
|
"render": { "type": "boolean" },
|
|
1797
1798
|
"export": { "type": "array", "items": { "type": "string" } }
|
|
@@ -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,220 @@
|
|
|
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
|
+
* LEG B (RFC 0145 G1) closes the gap this file originally carried open. It asserts a host's
|
|
17
|
+
* advertised `registrationSource` matches what it emits on `artifact.created` (requirement 3).
|
|
18
|
+
* It was deliberately NOT built at first: against a corpus where no host advertised the facet
|
|
19
|
+
* it would have gone green by finding nothing. A host now advertises it, so the leg has
|
|
20
|
+
* something real to compare and can no longer pass vacuously.
|
|
21
|
+
*
|
|
22
|
+
* PROFILE = 'openwop-artifact-type-store' is shared with RFC 0142 ON PURPOSE — see the gating
|
|
23
|
+
* note on leg B for why this leg deliberately does NOT add its own advertise-and-skip gate.
|
|
24
|
+
*
|
|
25
|
+
* @see schemas/capabilities.schema.json §artifactTypes.types
|
|
26
|
+
* @see spec/v1/artifact-type-packs.md §"Per-type facets" + §"Schema distribution"
|
|
27
|
+
* @see RFCS/0145-registration-source-per-type-facet.md
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { describe, it, expect } from 'vitest';
|
|
31
|
+
import { readFileSync } from 'node:fs';
|
|
32
|
+
import { join } from 'node:path';
|
|
33
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
34
|
+
import addFormats from 'ajv-formats';
|
|
35
|
+
import { SCHEMAS_DIR, V1_DIR } from '../lib/paths.js';
|
|
36
|
+
import { driver } from '../lib/driver.js';
|
|
37
|
+
import { behaviorGatePresent } from '../lib/behavior-gate.js';
|
|
38
|
+
import { readArtifactTypesCap } from '../lib/artifactTypes.js';
|
|
39
|
+
|
|
40
|
+
const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
|
|
41
|
+
const CAPS = join(SCHEMAS_DIR, 'capabilities.schema.json');
|
|
42
|
+
const EVENT_PAYLOADS = join(SCHEMAS_DIR, 'run-event-payloads.schema.json');
|
|
43
|
+
const readDoc = (name: string): string => (V1_DIR ? readFileSync(join(V1_DIR, name), 'utf8') : '');
|
|
44
|
+
|
|
45
|
+
function loadSchema(path: string): Record<string, unknown> {
|
|
46
|
+
return JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The per-type entry subschema — `artifactTypes.types.additionalProperties`. */
|
|
50
|
+
function perTypeEntry(): Record<string, unknown> {
|
|
51
|
+
const caps = loadSchema(CAPS);
|
|
52
|
+
const props = caps.properties as Record<string, Record<string, unknown>>;
|
|
53
|
+
const types = (props.artifactTypes.properties as Record<string, Record<string, unknown>>).types;
|
|
54
|
+
return types.additionalProperties as Record<string, unknown>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('artifact-type-registration-source (RFC 0145, always-on)', () => {
|
|
58
|
+
it('A1 — the per-type entry declares `registrationSource` as an OPTIONAL pack|host enum', () => {
|
|
59
|
+
const entry = perTypeEntry();
|
|
60
|
+
const props = entry.properties as Record<string, Record<string, unknown>>;
|
|
61
|
+
|
|
62
|
+
expect(props.registrationSource, why('§artifactTypes.types', 'facet is declared')).toBeDefined();
|
|
63
|
+
expect(
|
|
64
|
+
props.registrationSource.enum,
|
|
65
|
+
why('§artifactTypes.types', 'enum is exactly [pack, host]'),
|
|
66
|
+
).toEqual(['pack', 'host']);
|
|
67
|
+
expect(
|
|
68
|
+
(entry.required as string[] | undefined)?.includes('registrationSource') ?? false,
|
|
69
|
+
why('§artifactTypes.types', 'facet is OPTIONAL — absent ⇒ unspecified provenance, not a default'),
|
|
70
|
+
).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('A2 — a valid provenance validates; an out-of-enum value does NOT', () => {
|
|
74
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
75
|
+
addFormats(ajv);
|
|
76
|
+
const validate = ajv.compile(perTypeEntry());
|
|
77
|
+
|
|
78
|
+
expect(
|
|
79
|
+
validate({ validated: true, registrationSource: 'host', schemaVersion: 1 }),
|
|
80
|
+
why('§artifactTypes.types', `"host" validates: ${JSON.stringify(validate.errors)}`),
|
|
81
|
+
).toBe(true);
|
|
82
|
+
expect(
|
|
83
|
+
validate({ validated: true, registrationSource: 'pack' }),
|
|
84
|
+
why('§artifactTypes.types', `"pack" validates: ${JSON.stringify(validate.errors)}`),
|
|
85
|
+
).toBe(true);
|
|
86
|
+
// Absent stays legal — requirement 2.
|
|
87
|
+
expect(
|
|
88
|
+
validate({ validated: true }),
|
|
89
|
+
why('§artifactTypes.types', `absent validates: ${JSON.stringify(validate.errors)}`),
|
|
90
|
+
).toBe(true);
|
|
91
|
+
// A third provenance is a wire error, NOT a hint to ignore. This is where the facet
|
|
92
|
+
// parts company with RFC 0136's `format`, and the reason is in the docblock.
|
|
93
|
+
expect(
|
|
94
|
+
validate({ validated: true, registrationSource: 'registry' }),
|
|
95
|
+
why('§artifactTypes.types', 'an undefined provenance is REJECTED'),
|
|
96
|
+
).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('A3 — the facet mirrors the vocabulary `artifact.created` already carries', () => {
|
|
100
|
+
const payloads = loadSchema(EVENT_PAYLOADS);
|
|
101
|
+
const defs = payloads.$defs as Record<string, Record<string, unknown>>;
|
|
102
|
+
const eventProps = defs.artifactCreated.properties as Record<string, Record<string, unknown>>;
|
|
103
|
+
const entryProps = perTypeEntry().properties as Record<string, Record<string, unknown>>;
|
|
104
|
+
|
|
105
|
+
// Requirement 3 says the two surfaces MUST NOT disagree. They cannot even be compared
|
|
106
|
+
// unless they share a vocabulary, so pin that first — a drift here would let a host
|
|
107
|
+
// advertise a provenance it could never emit.
|
|
108
|
+
expect(
|
|
109
|
+
entryProps.registrationSource.enum,
|
|
110
|
+
why('RFC 0145 req 3', 'discovery enum matches artifact.created enum'),
|
|
111
|
+
).toEqual(eventProps.registrationSource.enum);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it.skipIf(V1_DIR === null)('A4 — both prose sites list the facet, so schema and normative surface cannot drift', () => {
|
|
115
|
+
// The RFC 0144 defect, restated: a wire field with no prose behind it is exactly what
|
|
116
|
+
// this corpus keeps producing. Two docs carry the per-type facet list; both must name it.
|
|
117
|
+
for (const name of ['artifact-type-packs.md', 'host-capabilities.md']) {
|
|
118
|
+
const facetList = readDoc(name).match(
|
|
119
|
+
/validated, validation, schemaVersion, store, render, export[^}]*}/,
|
|
120
|
+
);
|
|
121
|
+
expect(facetList, why(`${name} §"Per-type facets"`, 'per-type facet list present')).not.toBeNull();
|
|
122
|
+
expect(
|
|
123
|
+
facetList?.[0].includes('registrationSource'),
|
|
124
|
+
why(`${name} §"Per-type facets"`, 'facet list names registrationSource'),
|
|
125
|
+
).toBe(true);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it.skipIf(V1_DIR === null)('A5 — the spec states the MUST/SHOULD asymmetry the facet exists to disclose', () => {
|
|
130
|
+
const doc = readDoc('artifact-type-packs.md');
|
|
131
|
+
expect(
|
|
132
|
+
/Serving is a MUST for host-registered/.test(doc),
|
|
133
|
+
why('§Schema distribution', 'serving is a MUST for no-pack types'),
|
|
134
|
+
).toBe(true);
|
|
135
|
+
expect(
|
|
136
|
+
/serving stays a SHOULD for them/.test(doc),
|
|
137
|
+
why('§Schema distribution', 'serving stays a SHOULD for pack-backed types'),
|
|
138
|
+
).toBe(true);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const PROFILE = 'openwop-artifact-type-store';
|
|
143
|
+
|
|
144
|
+
/** True when this type emits at all — `store` at per-type scope, else the capability default. */
|
|
145
|
+
function emitsForType(cap: Record<string, unknown> | null, id: string): boolean {
|
|
146
|
+
if (!cap) return false;
|
|
147
|
+
const entry = (cap['types'] as Record<string, unknown> | undefined)?.[id];
|
|
148
|
+
if (entry && typeof entry === 'object' && 'store' in (entry as Record<string, unknown>)) {
|
|
149
|
+
return (entry as Record<string, unknown>)['store'] === true;
|
|
150
|
+
}
|
|
151
|
+
return cap['store'] === true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* First per-type id advertising `registrationSource` AND emitting, else null.
|
|
156
|
+
*
|
|
157
|
+
* BOTH conditions are required, and the second is the subtle one: `registrationSource` is
|
|
158
|
+
* meaningful on a type the host never emits for (a consumer still learns which schema-resolution
|
|
159
|
+
* regime applies), but requirement 3 is a statement about AGREEMENT BETWEEN TWO SURFACES, and a
|
|
160
|
+
* type with no emission has only one. Asserting against it would red a host that is telling the
|
|
161
|
+
* truth on every surface it actually has.
|
|
162
|
+
*/
|
|
163
|
+
function comparableType(cap: Record<string, unknown> | null): { id: string; advertised: string } | null {
|
|
164
|
+
const types = cap?.['types'];
|
|
165
|
+
if (!types || typeof types !== 'object') return null;
|
|
166
|
+
for (const [id, t] of Object.entries(types as Record<string, unknown>)) {
|
|
167
|
+
if (!t || typeof t !== 'object') continue;
|
|
168
|
+
const advertised = (t as Record<string, unknown>)['registrationSource'];
|
|
169
|
+
if (typeof advertised !== 'string') continue;
|
|
170
|
+
if (!emitsForType(cap, id)) continue;
|
|
171
|
+
return { id, advertised };
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
describe('artifact-type-registration-source: the advert agrees with the event (RFC 0145 leg B, requirement 3)', () => {
|
|
177
|
+
it('the emitted registrationSource equals the advertised one for that type', async () => {
|
|
178
|
+
const cap = await readArtifactTypesCap();
|
|
179
|
+
const target = comparableType(cap);
|
|
180
|
+
// INAPPLICABLE, not gated. `registrationSource` is OPTIONAL (requirement 2) and strict mode
|
|
181
|
+
// must not coerce a host into advertising it — the same call RFC 0142 makes for `store`.
|
|
182
|
+
if (target === null) return;
|
|
183
|
+
|
|
184
|
+
const started = await driver.post('/v1/host/sample/artifacttypes/runproduce', {
|
|
185
|
+
artifactTypeId: target.id,
|
|
186
|
+
});
|
|
187
|
+
// DELIBERATELY NOT a behaviorGate on seam presence. Reaching here means the type advertises
|
|
188
|
+
// `store`, so `store: true` + no seam is ALREADY strict-red under RFC 0142 leg B — the
|
|
189
|
+
// scenario that owns that enforcement. Gating again here would double-report one defect,
|
|
190
|
+
// and gating on the seam for a 0145 advert would coerce hosts into wiring 0142's host-sample
|
|
191
|
+
// surface in order to advertise a facet that has nothing to do with it.
|
|
192
|
+
if (started.status === 404 || started.status === 405) return; // seam absent — 0142 reports it
|
|
193
|
+
expect(
|
|
194
|
+
started.status >= 200 && started.status < 300,
|
|
195
|
+
driver.describe('coverage.md §"Open seams"', 'runproduce starts a real run producing one artifact of the requested registered type'),
|
|
196
|
+
).toBe(true);
|
|
197
|
+
const runId = (started.json as Record<string, unknown> | undefined)?.['runId'];
|
|
198
|
+
if (!behaviorGatePresent(PROFILE, typeof runId === 'string' ? runId : null)) return;
|
|
199
|
+
|
|
200
|
+
const events = await driver.get(`/v1/runs/${runId}/events/poll?timeout=5`);
|
|
201
|
+
expect(
|
|
202
|
+
events.status,
|
|
203
|
+
driver.describe('run-events surface', 'the run event log is readable over the standard poll endpoint'),
|
|
204
|
+
).toBe(200);
|
|
205
|
+
const list = ((events.json as Record<string, unknown>)?.['events'] ?? []) as Array<Record<string, unknown>>;
|
|
206
|
+
const created = list.filter((e) => e['type'] === 'artifact.created');
|
|
207
|
+
// Emission itself is RFC 0142's MUST, reported by its own leg. Reaching here without an
|
|
208
|
+
// event means that leg is already red; don't restate its finding as a 0145 failure.
|
|
209
|
+
if (created.length === 0) return;
|
|
210
|
+
|
|
211
|
+
const payload = (created[0]?.['payload'] ?? created[0]?.['data'] ?? {}) as Record<string, unknown>;
|
|
212
|
+
expect(
|
|
213
|
+
payload['registrationSource'],
|
|
214
|
+
driver.describe(
|
|
215
|
+
'RFC 0145 requirement 3',
|
|
216
|
+
`the host advertises registrationSource: "${target.advertised}" for ${target.id}, so that is the value it MUST emit — an advert of one provenance against an event carrying another (or carrying none, which asserts UNSPECIFIED provenance and therefore disagrees) is a false advertisement, not a permitted divergence`,
|
|
217
|
+
),
|
|
218
|
+
).toBe(target.advertised);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
@@ -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;
|