@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.
@@ -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. The fixture itself is a no-op workflow the scenario verifies host behavior at the agent-dispatch surface for `core.openwop.agent-examples.structured-fixture` (the canonical handoff-schema fixture agent). The agent's manifest carries `handoff.taskSchemaRef` + `handoff.returnSchemaRef`; the host MUST validate dispatch payloads against the task schema (RFC 0003 §D) BEFORE invoking the agent, and return payloads against the return schema BEFORE persistence. The test drives the dispatch surface directly with three scenario branches (valid-task / invalid-task / mock-return-violation) per agentPackHandoffSchemaValidation.test.ts.",
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": "Test invokes the agent-dispatch surface directly with three scenarios per inputs.scenario; the workflow is a noop placeholder so it validates against workflow-definition.schema.json without requiring a dispatch typeId not yet in the in-tree node catalog."
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "1.68.2",
3
+ "version": "1.71.0",
4
4
  "description": "Production-ready black-box conformance suite for OpenWOP v1.0 compliant servers.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
- * Capability-gated: skips when host doesn't advertise
17
- * `capabilities.agents.supported: true` AND `capabilities.agents.dispatch: true`.
18
- * Fixture-gated: requires `conformance-agent-pack-handoff-schema-validation`.
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 packs/core.openwop.agent-examples/agents[structured-fixture]
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
- // The fixture workflow dispatches `core.openwop.agent-examples.structured-fixture`
36
- // with a VALID task payload matching schemas/structured-fixture.task.schema.json
37
- // (`{ text: string, extractionFields: string[], language?: string }`).
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
- scenario: 'valid-task',
42
- text: 'Acme Corp invoiced $1,200 on 2026-04-15 for Q2 consulting.',
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
- let snap: { status: string } | undefined;
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
- it('invalid task payload (missing required field) is rejected before dispatch with structured error', async () => {
63
- const create = await driver.post('/v1/runs', {
64
- workflowId: FIXTURE,
65
- inputs: {
66
- scenario: 'invalid-task',
67
- // intentionally missing required `extractionFields`
68
- text: 'Some input text',
69
- },
70
- });
71
- expect(create.status).toBe(201);
72
- const runId = (create.json as { runId: string }).runId;
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
- const events = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events`);
87
- const list = (events.json as { events?: Array<{ type: string; payload?: Record<string, unknown> }> })
88
- .events ?? [];
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
- const validationFailure = list.find(
91
- (e) =>
92
- e.type === 'node.failed' &&
93
- typeof e.payload?.error === 'object' &&
94
- ((e.payload?.error as Record<string, unknown>)?.code === 'handoff_task_schema_violation' ||
95
- (e.payload?.error as Record<string, unknown>)?.code === 'agent_dispatch_validation_failed'),
96
- );
97
- expect(
98
- validationFailure,
99
- 'HV-1b: failure event payload MUST carry a recognizable handoff-validation error code',
100
- ).toBeDefined();
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
- let snap: { status: string } | undefined;
115
- for (let i = 0; i < 40; i++) {
116
- const res = await driver.get(`/v1/runs/${encodeURIComponent(runId)}`);
117
- const body = res.json as { status: string };
118
- if (['completed', 'failed'].includes(body.status)) {
119
- snap = body;
120
- break;
121
- }
122
- await new Promise((r) => setTimeout(r, 100));
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
- const events = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events`);
131
- const list = (events.json as { events?: Array<{ type: string; payload?: Record<string, unknown> }> })
132
- .events ?? [];
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
- const returnViolation = list.find(
135
- (e) =>
136
- (e.type === 'node.failed' || e.type === 'agent.error') &&
137
- typeof e.payload?.error === 'object' &&
138
- ((e.payload?.error as Record<string, unknown>)?.code === 'handoff_return_schema_violation' ||
139
- (e.payload?.error as Record<string, unknown>)?.code === 'agent_return_validation_failed'),
140
- );
141
- expect(
142
- returnViolation,
143
- 'HV-1c: off-schema return payload MUST surface a structured violation event before persistence',
144
- ).toBeDefined();
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;