@openwop/openwop-conformance 1.68.2 → 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.
@@ -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.70.2",
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,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;