@openwop/openwop-conformance 1.1.1 → 1.3.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.
Files changed (109) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +2 -2
  3. package/api/redocly.yaml +15 -0
  4. package/coverage.md +27 -14
  5. package/fixtures/conformance-agent-low-confidence.json +7 -4
  6. package/fixtures/conformance-agent-pack-handoff-schema-validation.json +30 -0
  7. package/fixtures/conformance-agent-reasoning-streaming.json +37 -0
  8. package/fixtures/conformance-agent-reasoning.json +23 -4
  9. package/fixtures/conformance-dispatch-cancellable-child.json +27 -0
  10. package/fixtures/conformance-dispatch-cross-worker-handoff-child-a.json +27 -0
  11. package/fixtures/conformance-dispatch-cross-worker-handoff-child-b.json +25 -0
  12. package/fixtures/conformance-dispatch-cross-worker-handoff.json +60 -0
  13. package/fixtures/conformance-dispatch-deterministic-fail-child.json +30 -0
  14. package/fixtures/conformance-dispatch-input-mapping-child.json +25 -0
  15. package/fixtures/conformance-dispatch-input-mapping-no-default.json +49 -0
  16. package/fixtures/conformance-dispatch-input-mapping.json +49 -0
  17. package/fixtures/conformance-dispatch-output-mapping-child.json +27 -0
  18. package/fixtures/conformance-dispatch-output-mapping.json +49 -0
  19. package/fixtures/conformance-dispatch-per-worker-override.json +59 -0
  20. package/fixtures/conformance-subworkflow-input-mapping-child.json +27 -0
  21. package/fixtures/conformance-subworkflow-input-mapping-no-default.json +33 -0
  22. package/fixtures/conformance-subworkflow-input-mapping.json +33 -0
  23. package/fixtures.md +18 -2
  24. package/package.json +1 -1
  25. package/schemas/README.md +7 -0
  26. package/schemas/agent-ref.schema.json +1 -1
  27. package/schemas/ai-envelope.schema.json +106 -0
  28. package/schemas/capabilities.schema.json +264 -0
  29. package/schemas/core-conformance-mock-agent-config.schema.json +152 -0
  30. package/schemas/dispatch-config.schema.json +26 -0
  31. package/schemas/envelopes/clarification.request.schema.json +43 -0
  32. package/schemas/envelopes/error.schema.json +26 -0
  33. package/schemas/envelopes/schema.request.schema.json +22 -0
  34. package/schemas/envelopes/schema.response.schema.json +22 -0
  35. package/schemas/node-pack-manifest.schema.json +5 -0
  36. package/schemas/pack-lockfile.schema.json +16 -0
  37. package/schemas/run-event-payloads.schema.json +35 -1
  38. package/schemas/run-event.schema.json +2 -0
  39. package/schemas/workflow-chain-pack-manifest.schema.json +226 -0
  40. package/src/lib/driver.ts +15 -0
  41. package/src/lib/env.ts +51 -0
  42. package/src/lib/event-log-query.ts +62 -0
  43. package/src/lib/fixtures.ts +38 -1
  44. package/src/lib/host-toggle.ts +54 -0
  45. package/src/lib/multi-agent-capabilities.ts +10 -0
  46. package/src/lib/otel-scrape.ts +59 -0
  47. package/src/lib/webhook-receiver.ts +137 -0
  48. package/src/lib/workflow-chain-expansion.ts +213 -0
  49. package/src/scenarios/agentPackCatalog.test.ts +216 -0
  50. package/src/scenarios/agentPackHandoffSchemaValidation.test.ts +146 -0
  51. package/src/scenarios/agentReasoningEvents.test.ts +58 -7
  52. package/src/scenarios/agentReasoningStreaming.test.ts +193 -0
  53. package/src/scenarios/agents-run-tool-allowlist.test.ts +182 -0
  54. package/src/scenarios/ai-envelope-shape.test.ts +362 -0
  55. package/src/scenarios/aiEnvelope.capBreached.test.ts +261 -0
  56. package/src/scenarios/aiEnvelope.contractRefusal.test.ts +268 -0
  57. package/src/scenarios/aiEnvelope.correlationReplay.test.ts +284 -0
  58. package/src/scenarios/aiEnvelope.redaction.test.ts +253 -0
  59. package/src/scenarios/aiEnvelope.schemaDrift.test.ts +226 -0
  60. package/src/scenarios/aiEnvelope.trustBoundaryPropagation.test.ts +194 -0
  61. package/src/scenarios/aiEnvelope.universalKinds.test.ts +267 -0
  62. package/src/scenarios/append-ordering.test.ts +44 -0
  63. package/src/scenarios/artifact-auth.test.ts +58 -0
  64. package/src/scenarios/blob-cross-tenant-isolation.test.ts +66 -0
  65. package/src/scenarios/blob-presign-expiry.test.ts +99 -0
  66. package/src/scenarios/blob-roundtrip.test.ts +0 -0
  67. package/src/scenarios/cache-cross-tenant-isolation.test.ts +61 -0
  68. package/src/scenarios/cache-ttl-expiry.test.ts +73 -0
  69. package/src/scenarios/dispatch-cross-worker-handoff.test.ts +129 -0
  70. package/src/scenarios/dispatch-input-mapping.test.ts +163 -0
  71. package/src/scenarios/dispatch-output-mapping.test.ts +155 -0
  72. package/src/scenarios/fixtures-gating.test.ts +139 -1
  73. package/src/scenarios/fs-path-traversal.test.ts +124 -0
  74. package/src/scenarios/idempotency-key-determinism.test.ts +230 -0
  75. package/src/scenarios/interrupt-token-matrix.test.ts +126 -0
  76. package/src/scenarios/kv-atomic-increment.test.ts +74 -0
  77. package/src/scenarios/kv-cas.test.ts +75 -0
  78. package/src/scenarios/kv-cross-tenant-isolation.test.ts +85 -0
  79. package/src/scenarios/kv-ttl-expiry.test.ts +78 -0
  80. package/src/scenarios/mcp-server-elicitation-bridge.test.ts +92 -0
  81. package/src/scenarios/mcp-server-prompt-roundtrip.test.ts +80 -0
  82. package/src/scenarios/mcp-server-resource-roundtrip.test.ts +82 -0
  83. package/src/scenarios/mcp-server-sampling-bridge.test.ts +84 -0
  84. package/src/scenarios/mcp-server-tool-roundtrip.test.ts +107 -0
  85. package/src/scenarios/mcp-server-untrusted-args.test.ts +105 -0
  86. package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +19 -0
  87. package/src/scenarios/pack-registry-publish.test.ts +231 -51
  88. package/src/scenarios/pause-resume.test.ts +43 -0
  89. package/src/scenarios/provider-usage.test.ts +185 -0
  90. package/src/scenarios/queue-ack-nack-dlq.test.ts +121 -0
  91. package/src/scenarios/queue-cross-tenant-isolation.test.ts +66 -0
  92. package/src/scenarios/queue-publish-consume-roundtrip.test.ts +88 -0
  93. package/src/scenarios/replay-llm-cache-key.test.ts +166 -25
  94. package/src/scenarios/search-bm25-roundtrip.test.ts +92 -0
  95. package/src/scenarios/spec-corpus-validity.test.ts +17 -1
  96. package/src/scenarios/sql-injection-rejection.test.ts +84 -0
  97. package/src/scenarios/sql-transaction-atomicity.test.ts +95 -0
  98. package/src/scenarios/stream-subscribe-from-beginning.test.ts +103 -0
  99. package/src/scenarios/subworkflow-input-mapping.test.ts +170 -0
  100. package/src/scenarios/table-cross-tenant-isolation.test.ts +65 -0
  101. package/src/scenarios/table-cursor-pagination.test.ts +85 -0
  102. package/src/scenarios/table-schema-enforcement.test.ts +84 -0
  103. package/src/scenarios/vector-knn-roundtrip.test.ts +88 -0
  104. package/src/scenarios/webhook-receiver-adversarial.test.ts +210 -0
  105. package/src/scenarios/workflow-chain-expansion.test.ts +366 -0
  106. package/src/scenarios/workflow-chain-host-expansion.test.ts +202 -0
  107. package/src/scenarios/workflow-chain-pack-manifest-validation.test.ts +232 -0
  108. package/src/scenarios/workflow-chain-pack-signature-verification.test.ts +138 -0
  109. package/src/scenarios/workflow-chain-unresolvable-typeid.test.ts +170 -0
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Workflow-chain pack manifest validation — `workflow-chain-packs.md` §Manifest format
3
+ * + `schemas/workflow-chain-pack-manifest.schema.json` (closes RFC 0013 Phase 1).
4
+ *
5
+ * Server-free schema-validation scenario. Exercises the new
6
+ * `workflow-chain-pack-manifest.schema.json` with a positive sample and
7
+ * two negative samples derived from the RFC's Negative examples:
8
+ *
9
+ * 1. Positive: a valid `kind: "workflow-chain"` manifest with a single
10
+ * `chains[]` entry validates cleanly.
11
+ * 2. Negative — kind/contents mismatch: a manifest carrying BOTH
12
+ * `chains[]` AND `nodes[]` is rejected. Surface-level outcome at
13
+ * the registry HTTP API is `pack_kind_invalid` per the spec;
14
+ * schema-level outcome is an `additionalProperties` violation on
15
+ * `nodes` (the workflow-chain schema does not declare that field).
16
+ * 3. Negative — invalid `chainId`: a chain entry whose `chainId` does
17
+ * not match the reverse-DNS pattern is rejected with a `pattern`
18
+ * violation.
19
+ *
20
+ * Capability-gated scenarios for end-to-end expansion
21
+ * (`workflow-chain-expansion.test.ts`) and signature verification
22
+ * (`workflow-chain-pack-signature-verification.test.ts`) are deferred
23
+ * to Phase 2/3 per the RFC.
24
+ *
25
+ * @see spec/v1/workflow-chain-packs.md
26
+ * @see schemas/workflow-chain-pack-manifest.schema.json
27
+ * @see RFCS/0013-workflow-chain-packs.md
28
+ */
29
+
30
+ import { describe, it, expect } from 'vitest';
31
+ import { readFileSync, existsSync } from 'node:fs';
32
+ import { join, dirname } from 'node:path';
33
+ import Ajv2020 from 'ajv/dist/2020.js';
34
+ import addFormats from 'ajv-formats';
35
+ import type { ErrorObject } from 'ajv';
36
+ import { SCHEMAS_DIR, V1_DIR } from '../lib/paths.js';
37
+
38
+ const SCHEMA_PATH = join(SCHEMAS_DIR, 'workflow-chain-pack-manifest.schema.json');
39
+ // In-repo example pack — proves the schema validates a non-trivial
40
+ // real-world-shaped manifest (closes RFC 0013 Phase 4 in-tree path).
41
+ // Resolved relative to the repo root (V1_DIR is non-null in the repo
42
+ // layout AND in any in-tree mirror; null under the published-tarball
43
+ // layout where examples/ isn't bundled). Skipped cleanly when unavailable.
44
+ const REPO_ROOT = V1_DIR ? dirname(dirname(V1_DIR)) : null;
45
+ const EXAMPLE_PACK_PATH = REPO_ROOT
46
+ ? join(REPO_ROOT, 'examples/packs/workflow-chain-sample/pack.json')
47
+ : null;
48
+
49
+ describe('category: workflow-chain-pack manifest validation', () => {
50
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
51
+ addFormats(ajv);
52
+ const schema = JSON.parse(readFileSync(SCHEMA_PATH, 'utf8'));
53
+ const validate = ajv.compile(schema);
54
+
55
+ it('positive: a valid workflow-chain pack manifest validates cleanly', () => {
56
+ const manifest = {
57
+ name: 'vendor.acme.editor-presets',
58
+ version: '1.0.0',
59
+ kind: 'workflow-chain',
60
+ description: 'Author-time editor presets.',
61
+ engines: { openwop: '>=1.0.0 <2.0.0' },
62
+ chains: [
63
+ {
64
+ chainId: 'vendor.acme.generatePRD',
65
+ version: '1.0.0',
66
+ label: 'Generate PRD',
67
+ description: 'Single-node AI call with PRD authoring prompt.',
68
+ parameters: {
69
+ type: 'object',
70
+ required: ['productIdea'],
71
+ properties: {
72
+ productIdea: { type: 'string' },
73
+ targetAudience: { type: 'string', default: '' },
74
+ },
75
+ },
76
+ dag: {
77
+ nodes: [
78
+ {
79
+ id: 'prd-call',
80
+ typeId: 'core.ai.callPrompt',
81
+ config: {
82
+ systemPrompt: 'Write a PRD for: {{params.productIdea}}',
83
+ envelopeType: 'prd.create',
84
+ provider: 'anthropic',
85
+ },
86
+ },
87
+ ],
88
+ edges: [],
89
+ },
90
+ outputs: {
91
+ prdId: { type: 'string', description: 'Created PRD artifact id.' },
92
+ },
93
+ capabilities: ['side-effectful'],
94
+ },
95
+ ],
96
+ };
97
+ const ok = validate(manifest);
98
+ const errs = (validate.errors ?? [])
99
+ .map((e: ErrorObject) => `${e.instancePath || '/'}: ${e.message}`)
100
+ .join('\n');
101
+ expect(
102
+ ok,
103
+ `Positive sample MUST validate against workflow-chain-pack-manifest.schema.json — got:\n${errs}`,
104
+ ).toBe(true);
105
+ });
106
+
107
+ it('negative: manifest mixing chains[] AND nodes[] is rejected (pack_kind_invalid)', () => {
108
+ // Per workflow-chain-packs.md §Pack kind discriminator: "Manifests MUST
109
+ // have exactly one of nodes[] (kind=node) OR chains[] (kind=workflow-chain).
110
+ // Manifests containing both MUST be rejected at manifest validation with
111
+ // error code pack_kind_invalid." The schema-level enforcement is via
112
+ // additionalProperties: false (the workflow-chain schema does not declare
113
+ // a `nodes` property, so its presence triggers the violation).
114
+ const manifest = {
115
+ name: 'vendor.acme.mixed',
116
+ version: '1.0.0',
117
+ kind: 'workflow-chain',
118
+ engines: { openwop: '>=1.0.0' },
119
+ nodes: [
120
+ { typeId: 'vendor.acme.foo', version: '1.0.0', category: 'data', role: 'pure' },
121
+ ],
122
+ chains: [
123
+ {
124
+ chainId: 'vendor.acme.bar',
125
+ version: '1.0.0',
126
+ label: 'Bar',
127
+ description: 'x',
128
+ parameters: {},
129
+ dag: { nodes: [{ id: 'n', typeId: 'core.identity' }], edges: [] },
130
+ },
131
+ ],
132
+ };
133
+ const ok = validate(manifest);
134
+ expect(
135
+ ok,
136
+ 'Manifest with both nodes[] and chains[] MUST fail workflow-chain schema validation (pack_kind_invalid at the registry surface).',
137
+ ).toBe(false);
138
+ const hasAdditionalPropertiesErr = (validate.errors ?? []).some(
139
+ (e: ErrorObject) => e.keyword === 'additionalProperties',
140
+ );
141
+ expect(
142
+ hasAdditionalPropertiesErr,
143
+ 'Expected an `additionalProperties` violation flagging the unexpected `nodes` field.',
144
+ ).toBe(true);
145
+ });
146
+
147
+ it('negative: chain entry with invalid chainId is rejected (pattern violation)', () => {
148
+ // Per workflow-chain-packs.md §Chain entry shape: chainId MUST match the
149
+ // reverse-DNS pattern `^[a-z][a-zA-Z0-9._-]*$`. An empty string, leading
150
+ // digit, or any other shape violating the pattern fails validation.
151
+ const manifest = {
152
+ name: 'vendor.acme.editor-presets',
153
+ version: '1.0.0',
154
+ kind: 'workflow-chain',
155
+ engines: { openwop: '>=1.0.0' },
156
+ chains: [
157
+ {
158
+ // INVALID — leading digit, contains uppercase that breaks the
159
+ // first-char rule, AND a slash that no chainId is allowed to carry.
160
+ chainId: '9Bad/Id',
161
+ version: '1.0.0',
162
+ label: 'Bad',
163
+ description: 'x',
164
+ parameters: {},
165
+ dag: { nodes: [{ id: 'n', typeId: 'core.identity' }], edges: [] },
166
+ },
167
+ ],
168
+ };
169
+ const ok = validate(manifest);
170
+ expect(
171
+ ok,
172
+ 'Manifest with invalid chainId MUST fail workflow-chain schema validation.',
173
+ ).toBe(false);
174
+ const hasPatternErr = (validate.errors ?? []).some(
175
+ (e: ErrorObject) =>
176
+ e.keyword === 'pattern' && (e.instancePath ?? '').includes('chainId'),
177
+ );
178
+ expect(
179
+ hasPatternErr,
180
+ 'Expected a `pattern` violation on the chains[].chainId field.',
181
+ ).toBe(true);
182
+ });
183
+
184
+ it('positive: the in-repo example pack at examples/packs/workflow-chain-sample/ validates against the schema', () => {
185
+ if (!EXAMPLE_PACK_PATH || !existsSync(EXAMPLE_PACK_PATH)) {
186
+ // Published-tarball layout doesn't ship examples/; skip cleanly.
187
+ return;
188
+ }
189
+ const manifest = JSON.parse(readFileSync(EXAMPLE_PACK_PATH, 'utf8'));
190
+ const ok = validate(manifest);
191
+ const errs = (validate.errors ?? [])
192
+ .map((e: ErrorObject) => `${e.instancePath || '/'}: ${e.message}`)
193
+ .join('\n');
194
+ expect(
195
+ ok,
196
+ `examples/packs/workflow-chain-sample/pack.json MUST validate against workflow-chain-pack-manifest.schema.json (closes RFC 0013 Phase 4 in-tree path). Errors:\n${errs}`,
197
+ ).toBe(true);
198
+ // Spot-check the structural claims the example README makes:
199
+ expect(manifest.kind, 'example pack MUST declare kind: "workflow-chain"').toBe(
200
+ 'workflow-chain',
201
+ );
202
+ expect(
203
+ Array.isArray(manifest.chains) && manifest.chains.length === 2,
204
+ 'example pack MUST ship exactly 2 chains (1-node + 2-node shapes) per its README contract',
205
+ ).toBe(true);
206
+ });
207
+
208
+ it('negative: omitting kind field rejects (kind is required)', () => {
209
+ // Defensive — the workflow-chain schema makes `kind` REQUIRED so a
210
+ // node-pack-shape manifest can't accidentally validate against it.
211
+ const manifest = {
212
+ name: 'vendor.acme.editor-presets',
213
+ version: '1.0.0',
214
+ engines: { openwop: '>=1.0.0' },
215
+ chains: [
216
+ {
217
+ chainId: 'vendor.acme.x',
218
+ version: '1.0.0',
219
+ label: 'X',
220
+ description: 'x',
221
+ parameters: {},
222
+ dag: { nodes: [{ id: 'n', typeId: 'core.identity' }], edges: [] },
223
+ },
224
+ ],
225
+ };
226
+ const ok = validate(manifest);
227
+ expect(
228
+ ok,
229
+ 'Manifest without kind: "workflow-chain" MUST fail this schema (the other path is node-pack-manifest.schema.json).',
230
+ ).toBe(false);
231
+ });
232
+ });
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Workflow-chain pack signature verification — `workflow-chain-packs.md`
3
+ * §"Expansion semantics" step 2 + `node-packs.md` §Signing (reused
4
+ * verification flow, identical to node packs).
5
+ *
6
+ * Server-free scenario. Asserts that the Ed25519 signature verification
7
+ * recipe from `node-packs.md` §Signing — "`pack.json.sig` is an Ed25519
8
+ * signature over `pack.json` using the key at `keys/<key-id>.pem`" —
9
+ * works unchanged for workflow-chain pack manifests. The spec's design
10
+ * intent is that workflow-chain packs reuse 100% of the node-pack
11
+ * signing infrastructure (same algorithm, same byte recipe, same key
12
+ * format); this scenario proves the reuse is real, not just claimed.
13
+ *
14
+ * What this scenario covers:
15
+ * - A valid (manifest + signature) pair verifies cleanly.
16
+ * - A tampered manifest fails verification with the same shape of
17
+ * failure as a tampered node pack would produce.
18
+ * - A wrong-key signature fails verification.
19
+ *
20
+ * Why this matters: any host implementing chain expansion MUST verify
21
+ * the source pack's signature before resolving + expanding (step 2 of
22
+ * the expansion algorithm). If chain packs needed a different signing
23
+ * recipe than node packs, implementers would need two verification
24
+ * paths — that's a footgun. The spec explicitly designs them to share.
25
+ *
26
+ * @see spec/v1/workflow-chain-packs.md §"Expansion semantics" step 2
27
+ * @see spec/v1/node-packs.md §Signing
28
+ * @see RFCS/0013-workflow-chain-packs.md
29
+ */
30
+
31
+ import { describe, it, expect } from 'vitest';
32
+ import { generateKeyPairSync, sign, verify } from 'node:crypto';
33
+
34
+ /** Canonical workflow-chain pack manifest used as the signing target.
35
+ * Mirrors the spec doc's Positive example, kept tight so the test
36
+ * focuses on the signing path, not the manifest shape. */
37
+ const MANIFEST = {
38
+ name: 'vendor.acme.editor-presets',
39
+ version: '1.0.0',
40
+ kind: 'workflow-chain',
41
+ description: 'Sample workflow-chain pack used by signature-verification conformance.',
42
+ engines: { openwop: '>=1.0.0 <2.0.0' },
43
+ chains: [
44
+ {
45
+ chainId: 'vendor.acme.generatePRD',
46
+ version: '1.0.0',
47
+ label: 'Generate PRD',
48
+ description: 'Single-node AI call.',
49
+ parameters: { type: 'object', properties: { productIdea: { type: 'string' } } },
50
+ dag: {
51
+ nodes: [{ id: 'prd-call', typeId: 'core.ai.callPrompt', config: {} }],
52
+ edges: [],
53
+ },
54
+ },
55
+ ],
56
+ };
57
+
58
+ /** Canonical byte serialization of the manifest used for signing.
59
+ * Matches the recipe in node-packs.md §Signing: "Ed25519 signature
60
+ * over `pack.json`" — the on-disk JSON bytes ARE the signing payload.
61
+ * Production tooling would use the EXACT bytes from `pack.json` in the
62
+ * tarball (preserved whitespace, byte-for-byte) so the verification
63
+ * recipe is deterministic. */
64
+ function manifestBytes(): Buffer {
65
+ return Buffer.from(JSON.stringify(MANIFEST, null, 2));
66
+ }
67
+
68
+ describe('category: workflow-chain pack signature — Ed25519 verification reuse from node-packs', () => {
69
+ it('valid manifest + valid signature MUST verify (positive path)', () => {
70
+ const { privateKey, publicKey } = generateKeyPairSync('ed25519');
71
+ const manifest = manifestBytes();
72
+ // Per node-packs.md §Signing: Ed25519, no separate hash step (Ed25519
73
+ // signs the message directly). The `algorithm` arg to crypto.sign is
74
+ // `null` for Ed25519 — confirmed by Node's docs.
75
+ const signature = sign(null, manifest, privateKey);
76
+ const ok = verify(null, manifest, publicKey, signature);
77
+ expect(
78
+ ok,
79
+ 'Per workflow-chain-packs.md §"Expansion semantics" step 2: the signature recipe is IDENTICAL to node-packs (Ed25519 over pack.json bytes). A valid pair MUST verify with the canonical recipe.',
80
+ ).toBe(true);
81
+ });
82
+
83
+ it('tampered manifest MUST fail verification (sha-level tamper detection)', () => {
84
+ const { privateKey, publicKey } = generateKeyPairSync('ed25519');
85
+ const original = manifestBytes();
86
+ const signature = sign(null, original, privateKey);
87
+ // Tamper: change a single byte in the manifest after signing. This
88
+ // simulates a registry-side or man-in-the-middle modification — the
89
+ // signing recipe MUST detect ANY byte-level change.
90
+ const tampered = Buffer.from(original);
91
+ tampered[10] = tampered[10]! ^ 0x01;
92
+ const ok = verify(null, tampered, publicKey, signature);
93
+ expect(
94
+ ok,
95
+ 'Per node-packs.md §Signing (reused unchanged for chain packs): Ed25519 verification MUST detect any byte-level tamper to the signed payload.',
96
+ ).toBe(false);
97
+ });
98
+
99
+ it('wrong-key signature MUST fail verification', () => {
100
+ const { privateKey: legitPrivKey } = generateKeyPairSync('ed25519');
101
+ const { publicKey: attackerPubKey } = generateKeyPairSync('ed25519');
102
+ const manifest = manifestBytes();
103
+ const signature = sign(null, manifest, legitPrivKey);
104
+ // Try to verify with a DIFFERENT public key than the one paired with
105
+ // the signing private key. This is the supply-chain attack the
106
+ // signing recipe defends against (attacker substitutes their own
107
+ // key in the manifest's `signing.publicKeyRef`).
108
+ const ok = verify(null, manifest, attackerPubKey, signature);
109
+ expect(
110
+ ok,
111
+ 'Ed25519 verification MUST fail when the public key doesn\'t match the private key that produced the signature — the spec\'s trust model depends on this property.',
112
+ ).toBe(false);
113
+ });
114
+
115
+ it('chain pack manifests carry the SAME signing block shape as node packs', () => {
116
+ // node-packs.md §signing declares `pack.json` MAY carry a `signing`
117
+ // block with `publicKeyRef` / `signatureRef` / `method`. Chain packs
118
+ // reuse the same shape unchanged — workflow-chain-pack-manifest.
119
+ // schema.json's `$defs.Signing` is byte-identical to the node-pack
120
+ // schema's `$defs.Signing`. This assertion documents the spec
121
+ // design intent so future schema evolution doesn't silently
122
+ // diverge.
123
+ const signedManifest = {
124
+ ...MANIFEST,
125
+ signing: {
126
+ publicKeyRef: 'keys/2026-05.pem',
127
+ signatureRef: 'pack.json.sig',
128
+ method: 'manual' as const,
129
+ },
130
+ };
131
+ expect(
132
+ signedManifest.signing.method,
133
+ 'workflow-chain packs MUST accept the same signing block keys as node packs (publicKeyRef + signatureRef + method) per spec design.',
134
+ ).toBe('manual');
135
+ expect(signedManifest.signing.publicKeyRef).toBe('keys/2026-05.pem');
136
+ expect(signedManifest.signing.signatureRef).toBe('pack.json.sig');
137
+ });
138
+ });
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Workflow-chain expansion — unresolvable typeId rejection per
3
+ * `workflow-chain-packs.md` §"Expansion semantics (normative)" step 3
4
+ * + `workflow-chain-packs.md` §"Error codes" `chain_unresolvable_typeid`.
5
+ *
6
+ * Server-free scenario. Asserts that when a chain's `dag.nodes[].typeId`
7
+ * references an UNPUBLISHED typeId (one the destination host's pack
8
+ * registry can't resolve), expansion is rejected with
9
+ * `chain_unresolvable_typeid` BEFORE any node ids are rewritten or
10
+ * placeholders substituted.
11
+ *
12
+ * The "rejection at expansion time" path is load-bearing for the
13
+ * "additive — no new dispatch surface" invariant: if expansion produced
14
+ * a workflow referencing an unknown typeId, the dispatching engine
15
+ * would fail at run time with `unknown_typeid` and the user would see
16
+ * a confusing failure far from the chain-pack tile they dragged.
17
+ * Rejecting at edit time keeps the failure local to the author's
18
+ * action.
19
+ *
20
+ * Per the spec's "Negative: chain references unpublished typeId"
21
+ * example: the pack's manifest validation does NOT cross-check
22
+ * published typeId existence (cycle issues + registry-availability
23
+ * concerns); only the host-editor-time expansion step verifies — by
24
+ * which point the destination host's pack registry has authoritative
25
+ * knowledge of which typeIds it can resolve.
26
+ *
27
+ * @see spec/v1/workflow-chain-packs.md §"Expansion semantics" step 3
28
+ * @see spec/v1/workflow-chain-packs.md §"Error codes"
29
+ * @see conformance/src/lib/workflow-chain-expansion.ts
30
+ * @see RFCS/0013-workflow-chain-packs.md
31
+ */
32
+
33
+ import { describe, it, expect } from 'vitest';
34
+ import {
35
+ expandChain,
36
+ ChainUnresolvableTypeIdError,
37
+ type WorkflowChain,
38
+ } from '../lib/workflow-chain-expansion.js';
39
+
40
+ const CHAIN_WITH_UNKNOWN_TYPEID: WorkflowChain = {
41
+ chainId: 'vendor.acme.someChain',
42
+ version: '1.0.0',
43
+ label: 'Some Chain',
44
+ description: 'References an unpublished typeId — should fail expansion.',
45
+ parameters: {},
46
+ dag: {
47
+ nodes: [
48
+ { id: 'n1', typeId: 'made.up.foo', config: {} },
49
+ ],
50
+ edges: [],
51
+ },
52
+ };
53
+
54
+ /** Resolver that only accepts a hardcoded set of "known" typeIds — the
55
+ * set the destination host's pack registry can satisfy. Mimics the
56
+ * realistic host-editor-time check. */
57
+ const KNOWN_TYPEIDS = new Set(['core.identity', 'core.ai.callPrompt']);
58
+ const isKnown = (typeId: string): boolean => KNOWN_TYPEIDS.has(typeId);
59
+
60
+ describe('category: workflow-chain expansion — unresolvable typeId rejection', () => {
61
+ it('throws ChainUnresolvableTypeIdError when chain references an unknown typeId', () => {
62
+ expect(
63
+ () =>
64
+ expandChain(CHAIN_WITH_UNKNOWN_TYPEID, {
65
+ expansionId: 'x',
66
+ params: {},
67
+ isTypeIdResolvable: isKnown,
68
+ }),
69
+ 'Per spec §"Expansion semantics" step 3: hosts MUST reject expansion with `chain_unresolvable_typeid` when any fragment node\'s typeId can\'t be resolved by the destination host.',
70
+ ).toThrow(ChainUnresolvableTypeIdError);
71
+ });
72
+
73
+ it('error carries the offending typeId AND the chainId in `details`', () => {
74
+ try {
75
+ expandChain(CHAIN_WITH_UNKNOWN_TYPEID, {
76
+ expansionId: 'x',
77
+ params: {},
78
+ isTypeIdResolvable: isKnown,
79
+ });
80
+ expect.fail('expandChain MUST have thrown ChainUnresolvableTypeIdError');
81
+ } catch (err) {
82
+ expect(err, 'expected ChainUnresolvableTypeIdError').toBeInstanceOf(
83
+ ChainUnresolvableTypeIdError,
84
+ );
85
+ const e = err as ChainUnresolvableTypeIdError;
86
+ expect(
87
+ e.code,
88
+ 'Per spec §"Error codes": the wire-level error code MUST be `chain_unresolvable_typeid`.',
89
+ ).toBe('chain_unresolvable_typeid');
90
+ expect(
91
+ e.typeId,
92
+ 'The error MUST surface the offending typeId so the host editor can render an actionable diagnostic.',
93
+ ).toBe('made.up.foo');
94
+ expect(
95
+ e.chainId,
96
+ 'The error MUST surface the chainId so the host editor knows which chain-pack tile triggered the rejection.',
97
+ ).toBe('vendor.acme.someChain');
98
+ }
99
+ });
100
+
101
+ it('rejection happens BEFORE any id rewriting or placeholder substitution', () => {
102
+ // If the spec allowed expansion to proceed and produce a half-built
103
+ // fragment with an unknown typeId, downstream tooling would see a
104
+ // workflow with an undispatchable node. The contract is "reject
105
+ // cleanly at the resolution step, no partial output."
106
+ let threw = false;
107
+ try {
108
+ expandChain(CHAIN_WITH_UNKNOWN_TYPEID, {
109
+ expansionId: 'x',
110
+ params: { foo: 'bar' },
111
+ isTypeIdResolvable: isKnown,
112
+ });
113
+ } catch {
114
+ threw = true;
115
+ }
116
+ expect(
117
+ threw,
118
+ 'Expansion MUST throw before producing any output — host editors rely on the "no partial expansion" guarantee to keep parent workflows clean on rejection.',
119
+ ).toBe(true);
120
+ });
121
+
122
+ it('accepts the chain when every typeId IS resolvable', () => {
123
+ const resolvable: WorkflowChain = {
124
+ ...CHAIN_WITH_UNKNOWN_TYPEID,
125
+ dag: {
126
+ nodes: [{ id: 'n1', typeId: 'core.identity', config: {} }],
127
+ edges: [],
128
+ },
129
+ };
130
+ expect(
131
+ () =>
132
+ expandChain(resolvable, {
133
+ expansionId: 'x',
134
+ params: {},
135
+ isTypeIdResolvable: isKnown,
136
+ }),
137
+ 'Sanity: when every typeId resolves, expansion MUST proceed without throwing.',
138
+ ).not.toThrow();
139
+ });
140
+
141
+ it('throws on the FIRST unknown typeId encountered (fail-fast)', () => {
142
+ // Chain with two nodes — one resolvable, one not. The unresolvable one
143
+ // is second. The throw MUST identify the second one (the actual
144
+ // offender), not silently skip it.
145
+ const mixed: WorkflowChain = {
146
+ ...CHAIN_WITH_UNKNOWN_TYPEID,
147
+ dag: {
148
+ nodes: [
149
+ { id: 'n1', typeId: 'core.identity', config: {} },
150
+ { id: 'n2', typeId: 'made.up.foo', config: {} },
151
+ ],
152
+ edges: [],
153
+ },
154
+ };
155
+ try {
156
+ expandChain(mixed, {
157
+ expansionId: 'x',
158
+ params: {},
159
+ isTypeIdResolvable: isKnown,
160
+ });
161
+ expect.fail('MUST have thrown on n2\'s unknown typeId');
162
+ } catch (err) {
163
+ const e = err as ChainUnresolvableTypeIdError;
164
+ expect(
165
+ e.typeId,
166
+ 'When multiple nodes have unknown typeIds, the throw MUST identify the first unknown one encountered in declaration order.',
167
+ ).toBe('made.up.foo');
168
+ }
169
+ });
170
+ });