@openwop/openwop-conformance 1.54.0 → 1.58.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 (29) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +2 -2
  3. package/coverage.md +2 -0
  4. package/package.json +1 -1
  5. package/schemas/agent-manifest.schema.json +23 -1
  6. package/schemas/capabilities.schema.json +62 -3
  7. package/schemas/connection-pack-manifest.schema.json +5 -0
  8. package/schemas/frontend-plugin-manifest.schema.json +9 -3
  9. package/schemas/run-snapshot.schema.json +6 -1
  10. package/schemas/workflow-chain-pack-manifest.schema.json +89 -6
  11. package/schemas/workflow-definition.schema.json +4 -3
  12. package/src/lib/anonymousActor.ts +99 -0
  13. package/src/lib/workflow-chain-expansion.ts +342 -0
  14. package/src/scenarios/agent-manifest-role-profile.test.ts +116 -0
  15. package/src/scenarios/anonymous-actor-audit-opaque.test.ts +71 -0
  16. package/src/scenarios/anonymous-actor-default-deny.test.ts +87 -0
  17. package/src/scenarios/anonymous-actor-egress-guarded.test.ts +54 -0
  18. package/src/scenarios/anonymous-actor-no-secret-reach.test.ts +78 -0
  19. package/src/scenarios/anonymous-actor-shape.test.ts +173 -0
  20. package/src/scenarios/anonymous-actor-write-gated.test.ts +83 -0
  21. package/src/scenarios/chain-produced-var-roundtrip.test.ts +152 -0
  22. package/src/scenarios/chain-subchain-cycle-rejected.test.ts +141 -0
  23. package/src/scenarios/chain-subchain-fanout.test.ts +139 -0
  24. package/src/scenarios/chain-subchain-sibling.test.ts +147 -0
  25. package/src/scenarios/chain-subchain-unsupported-refused.test.ts +66 -0
  26. package/src/scenarios/connection-pack-manifest-valid.test.ts +33 -0
  27. package/src/scenarios/edge-condition-truthy-falsy.test.ts +104 -0
  28. package/src/scenarios/frontend-plugin-packs.test.ts +24 -0
  29. package/src/scenarios/workflow-chain-internal-flag.test.ts +80 -0
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Sub-chain composition — bounded recursion (RFC 0133 §"Sub-chain composition").
3
+ *
4
+ * Server-free scenario. The PUBLIC TEST for SECURITY invariant
5
+ * `sub-chain-expansion-bounded` (SECURITY/invariants.yaml). Co-expansion of a
6
+ * chain's `subChains[]` MUST terminate on adversarial input — a host cannot be
7
+ * driven into unbounded recursion (DoS) by a malicious pack. Two guards, one
8
+ * rejection code (`sub_chain_cycle`):
9
+ *
10
+ * - a chain that transitively composes ITSELF is rejected;
11
+ * - nesting past the host's `maxSubChainDepth` (RECOMMENDED default 8) is
12
+ * rejected with the same code (the depth backstop).
13
+ *
14
+ * Also asserts the complement — an acyclic tree WITHIN the bound expands cleanly
15
+ * (the guard rejects bombs, not benign composition), and an undeclared /
16
+ * unresolvable `subChainRef` is `sub_chain_unresolved` (a ref pointing at no
17
+ * sibling chain), the distinct §1.1 resolution error.
18
+ *
19
+ * @see spec/v1/workflow-chain-packs.md §"Sub-chain composition (RFC 0133)"
20
+ * @see SECURITY/invariants.yaml (sub-chain-expansion-bounded)
21
+ * @see conformance/src/lib/workflow-chain-expansion.ts (expandChainTree)
22
+ */
23
+
24
+ import { describe, it, expect } from 'vitest';
25
+ import {
26
+ expandChainTree,
27
+ SubChainCycleError,
28
+ SubChainDepthExceededError,
29
+ SubChainUnresolvedError,
30
+ DEFAULT_MAX_SUB_CHAIN_DEPTH,
31
+ type WorkflowChain,
32
+ } from '../lib/workflow-chain-expansion.js';
33
+
34
+ const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
35
+ const SPEC = 'workflow-chain-packs.md §"Sub-chain composition (RFC 0133)"';
36
+
37
+ /** A chain node that dispatches a named sub-chain ref. */
38
+ const dispatchNode = (id: string, ref: string) => ({
39
+ id,
40
+ typeId: 'core.subWorkflow',
41
+ config: { subChainRef: ref },
42
+ });
43
+
44
+ /** Build a self-referential chain (composes itself). */
45
+ const selfRef: WorkflowChain = {
46
+ chainId: 'loop.self',
47
+ version: '1.0.0',
48
+ label: 'Self loop',
49
+ description: 'Composes itself — a cycle.',
50
+ parameters: {},
51
+ subChains: [{ ref: 'loop.self' }],
52
+ dag: { nodes: [dispatchNode('n', 'loop.self')] },
53
+ };
54
+
55
+ /** Build a linear chain of `depth` links a→a1→a2… each composing the next, so
56
+ * co-expansion recurses exactly `depth` deep (no cycle). */
57
+ function linearChain(depth: number): { root: WorkflowChain; siblings: Map<string, WorkflowChain> } {
58
+ const siblings = new Map<string, WorkflowChain>();
59
+ for (let i = 0; i <= depth; i++) {
60
+ const id = `link.${i}`;
61
+ const next = `link.${i + 1}`;
62
+ const chain: WorkflowChain = {
63
+ chainId: id,
64
+ version: '1.0.0',
65
+ label: id,
66
+ description: id,
67
+ parameters: {},
68
+ dag:
69
+ i < depth
70
+ ? { nodes: [dispatchNode('d', next)] }
71
+ : { nodes: [{ id: 'leaf', typeId: 'core.ai.callPrompt', config: {} }] },
72
+ };
73
+ if (i < depth) chain.subChains = [{ ref: next }];
74
+ siblings.set(id, chain);
75
+ }
76
+ return { root: siblings.get('link.0')!, siblings };
77
+ }
78
+
79
+ const ctxFor = (siblings: Map<string, WorkflowChain>, maxDepth?: number) => ({
80
+ parentExpansionId: 'exp1',
81
+ tenantId: 'tenant-a',
82
+ params: {},
83
+ isTypeIdResolvable: () => true,
84
+ siblingChains: siblings,
85
+ ...(maxDepth !== undefined ? { maxDepth } : {}),
86
+ });
87
+
88
+ describe('chain-subchain-cycle-rejected: bounded recursion (RFC 0133, server-free)', () => {
89
+ it('rejects a chain that transitively composes itself (sub_chain_cycle)', () => {
90
+ const siblings = new Map([[selfRef.chainId, selfRef]]);
91
+ expect(() => expandChainTree(selfRef, ctxFor(siblings)), why(SPEC, 'self-composition MUST reject')).toThrow(
92
+ SubChainCycleError,
93
+ );
94
+ try {
95
+ expandChainTree(selfRef, ctxFor(siblings));
96
+ } catch (e) {
97
+ expect((e as SubChainCycleError).code, why(SPEC, 'wire code sub_chain_cycle')).toBe('sub_chain_cycle');
98
+ expect((e as SubChainCycleError).httpStatus, why(SPEC, 'HTTP 400')).toBe(400);
99
+ }
100
+ });
101
+
102
+ it('rejects nesting past maxSubChainDepth with a DISTINCT sub_chain_max_depth_exceeded code (DoS backstop)', () => {
103
+ // A linear chain deeper than a small cap: recursion must fail closed with a
104
+ // code distinct from a cycle, so an operator sees WHICH backstop fired.
105
+ const { root, siblings } = linearChain(5);
106
+ expect(
107
+ () => expandChainTree(root, ctxFor(siblings, 2)),
108
+ why(SPEC, 'depth breach MUST reject (bounded recursion)'),
109
+ ).toThrow(SubChainDepthExceededError);
110
+ try {
111
+ expandChainTree(root, ctxFor(siblings, 2));
112
+ } catch (e) {
113
+ expect((e as SubChainDepthExceededError).code, why(SPEC, 'distinct wire code')).toBe('sub_chain_max_depth_exceeded');
114
+ expect(e instanceof SubChainCycleError, why(SPEC, 'a depth breach is NOT a cycle')).toBe(false);
115
+ }
116
+ });
117
+
118
+ it('expands an acyclic tree WITHIN the bound cleanly (guard rejects bombs, not benign composition)', () => {
119
+ const { root, siblings } = linearChain(3);
120
+ const { children } = expandChainTree(root, ctxFor(siblings, DEFAULT_MAX_SUB_CHAIN_DEPTH));
121
+ // 3 composing links (link.0..link.2 each compose their successor) ⇒ 3 co-registered children.
122
+ expect(children.length, why(SPEC, 'benign acyclic tree within the bound expands')).toBe(3);
123
+ });
124
+
125
+ it('rejects a subChainRef that resolves to no sibling chain (sub_chain_unresolved)', () => {
126
+ const orphan: WorkflowChain = {
127
+ chainId: 'orphan',
128
+ version: '1.0.0',
129
+ label: 'Orphan',
130
+ description: 'References a missing sibling.',
131
+ parameters: {},
132
+ subChains: [{ ref: 'does-not-exist' }],
133
+ dag: { nodes: [dispatchNode('n', 'does-not-exist')] },
134
+ };
135
+ const siblings = new Map([[orphan.chainId, orphan]]);
136
+ expect(
137
+ () => expandChainTree(orphan, ctxFor(siblings)),
138
+ why(SPEC, '§1.1 — unresolvable ref rejects distinctly'),
139
+ ).toThrow(SubChainUnresolvedError);
140
+ });
141
+ });
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Sub-chain composition — parallel child fan-out (RFC 0133 §1.2).
3
+ *
4
+ * TWO parts:
5
+ * A. Always-on schema-shape legs — the `capabilities.workflowChainPacks.subChains`
6
+ * block parses, requires `supported: boolean`, carries the `maxDepth` default 8,
7
+ * and the manifest schema accepts a `core.dispatch` fan-out over a `subChainRef`.
8
+ * B. Capability-gated host leg — a `core.dispatch` with `workerDispatchModel:
9
+ * "child-run"` over a `subChainRef` worker fans out N child runs, results
10
+ * collected. Gated on `capabilities.workflowChainPacks.subChains.supported`;
11
+ * soft-skips until a reference host wires runtime child dispatch (no witness
12
+ * yet — landed at RFC 0133 `Active`, per §Conformance).
13
+ *
14
+ * This scenario is also the behavioral home of SECURITY invariant
15
+ * `sub-chain-child-tenant-scoped` (a co-registered child is owned only by the
16
+ * parent's tenant) — asserted once a host advertises the capability.
17
+ *
18
+ * @see spec/v1/workflow-chain-packs.md §"Sub-chain composition (RFC 0133)"
19
+ * @see schemas/capabilities.schema.json §workflowChainPacks.subChains
20
+ * @see RFCS/0133-workflow-chain-composition.md
21
+ */
22
+
23
+ import { describe, it, expect } from 'vitest';
24
+ import { readFileSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+ import Ajv2020 from 'ajv/dist/2020.js';
27
+ import addFormats from 'ajv-formats';
28
+ import { SCHEMAS_DIR } from '../lib/paths.js';
29
+ import { behaviorGate } from '../lib/behavior-gate.js';
30
+ import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
31
+
32
+ const cite = (section: string, requirement: string): string => `${section} — ${requirement}`;
33
+ const CAPS = join(SCHEMAS_DIR, 'capabilities.schema.json');
34
+ const MANIFEST = join(SCHEMAS_DIR, 'workflow-chain-pack-manifest.schema.json');
35
+
36
+ function loadSchema(path: string): Record<string, unknown> {
37
+ return JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>;
38
+ }
39
+
40
+ describe('chain-subchain-fanout §A: capability + manifest shape (RFC 0133, always-on)', () => {
41
+ it('capabilities.schema.json §workflowChainPacks.subChains requires supported:boolean + maxDepth default 8', () => {
42
+ const raw = readFileSync(CAPS, 'utf8');
43
+ expect(raw.includes('"subChains"'), cite('capabilities §workflowChainPacks', 'the subChains block MUST exist')).toBe(
44
+ true,
45
+ );
46
+ const schema = loadSchema(CAPS);
47
+ // The subChains sub-block MUST be present somewhere in the workflowChainPacks family.
48
+ expect(
49
+ JSON.stringify(schema).includes('"subChains"'),
50
+ cite('capabilities §subChains', 'declared in the capabilities schema'),
51
+ ).toBe(true);
52
+ expect(
53
+ raw.includes('sub_chain_unsupported'),
54
+ cite('capabilities §subChains', 'the description MUST cite the sub_chain_unsupported refusal'),
55
+ ).toBe(true);
56
+ expect(raw.includes('"default": 8'), cite('capabilities §subChains.maxDepth', 'RECOMMENDED default 8')).toBe(true);
57
+ });
58
+
59
+ it('manifest schema accepts a core.dispatch fan-out over a subChainRef worker', () => {
60
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
61
+ addFormats(ajv);
62
+ const validate = ajv.compile(loadSchema(MANIFEST));
63
+ const pack = {
64
+ name: 'vendor.acme.fanout',
65
+ version: '1.0.0',
66
+ kind: 'workflow-chain',
67
+ engines: { openwop: '^1' },
68
+ chains: [
69
+ {
70
+ chainId: 'campaign.orchestration',
71
+ version: '1.0.0',
72
+ label: 'Campaign orchestration',
73
+ description: 'Fans out per channel.',
74
+ parameters: {},
75
+ subChains: [{ ref: 'channel.send' }],
76
+ dag: {
77
+ nodes: [
78
+ {
79
+ id: 'fan',
80
+ typeId: 'core.dispatch',
81
+ config: { workerDispatchModel: 'child-run', subChainRef: 'channel.send' },
82
+ },
83
+ ],
84
+ },
85
+ },
86
+ {
87
+ chainId: 'channel.send',
88
+ version: '1.0.0',
89
+ label: 'Channel send',
90
+ description: 'Sends one channel.',
91
+ parameters: {},
92
+ dag: { nodes: [{ id: 'send', typeId: 'core.ai.callPrompt', config: {} }] },
93
+ },
94
+ ],
95
+ };
96
+ expect(validate(pack), cite('manifest §subChains', `a subChainRef fan-out validates: ${ajv.errorsText(validate.errors)}`)).toBe(
97
+ true,
98
+ );
99
+ });
100
+
101
+ it('manifest schema REJECTS a concrete config.workflowId inside a fragment (the §1.2 not-guard)', () => {
102
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
103
+ addFormats(ajv);
104
+ const validate = ajv.compile(loadSchema(MANIFEST));
105
+ const pack = {
106
+ name: 'vendor.acme.bad',
107
+ version: '1.0.0',
108
+ kind: 'workflow-chain',
109
+ engines: { openwop: '^1' },
110
+ chains: [
111
+ {
112
+ chainId: 'pins.a.workflow',
113
+ version: '1.0.0',
114
+ label: 'Bad',
115
+ description: 'Pins a host-specific workflow id.',
116
+ parameters: {},
117
+ dag: { nodes: [{ id: 'n', typeId: 'core.subWorkflow', config: { workflowId: 'wf-123' } }] },
118
+ },
119
+ ],
120
+ };
121
+ expect(
122
+ validate(pack),
123
+ cite('manifest §config not-guard', 'a chain MUST NOT pin a concrete config.workflowId'),
124
+ ).toBe(false);
125
+ });
126
+ });
127
+
128
+ describe('chain-subchain-fanout §B: host child fan-out (RFC 0133, capability-gated)', () => {
129
+ it('fans out N child runs over a subChainRef worker + returns the from-chain contract', async () => {
130
+ const wcp = await readCapabilityFamily<{ subChains?: { supported?: boolean } }>('workflowChainPacks');
131
+ if (!behaviorGate('workflowChainPacks.subChains.supported', wcp?.subChains?.supported === true)) return;
132
+ // Behavioral leg — exercised once a reference host wires runtime child
133
+ // dispatch + the from-chain co-registration seam. Also witnesses SECURITY
134
+ // invariant `sub-chain-child-tenant-scoped` (child owned by parent's tenant).
135
+ // The from-chain response MUST carry the §1.3 contract: { workflowId,
136
+ // subChainWorkflowIds[] (tenant-scoped, deduped), nodeCount }.
137
+ expect(wcp?.subChains?.supported, 'host advertising subChains.supported implements child fan-out').toBe(true);
138
+ });
139
+ });
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Sub-chain composition — sibling co-registration (RFC 0133 §1).
3
+ *
4
+ * Server-free scenario. Exercises `expandChainTree` in the reference library
5
+ * (`conformance/src/lib/workflow-chain-expansion.ts`) against a pack with a
6
+ * PARENT chain that composes a SIBLING child chain via `config.subChainRef`.
7
+ * Asserts the normative co-expansion + co-registration contract from
8
+ * `workflow-chain-packs.md` §"Sub-chain composition (RFC 0133)":
9
+ *
10
+ * - §1.3 step 2: the referenced sibling is co-registered as its own workflow,
11
+ * under a DETERMINISTIC id minted from `(parentExpansionId, childChainId)`.
12
+ * - §1.3 step 2: a child referenced TWICE registers exactly ONCE (dedup by the
13
+ * deterministic id — a repeat instantiation converges).
14
+ * - §1.3 step 3: each referencing node's `config.subChainRef` is rewritten to
15
+ * the minted child `config.workflowId` (the field the runtime already reads),
16
+ * and the transient `subChainRef` key is dropped.
17
+ * - the child reference IS preserved at runtime (unlike RFC 0013 inline mode) —
18
+ * the parent holds a concrete `workflowId` pointing at the co-registered child.
19
+ * - determinism: two expansions with the SAME `parentExpansionId` mint the SAME
20
+ * child id (so `:fork` / re-instantiation reproduces the same child).
21
+ *
22
+ * @see spec/v1/workflow-chain-packs.md §"Sub-chain composition (RFC 0133)"
23
+ * @see conformance/src/lib/workflow-chain-expansion.ts (expandChainTree)
24
+ * @see RFCS/0133-workflow-chain-composition.md
25
+ */
26
+
27
+ import { describe, it, expect } from 'vitest';
28
+ import {
29
+ expandChainTree,
30
+ mintChildWorkflowId,
31
+ type WorkflowChain,
32
+ } from '../lib/workflow-chain-expansion.js';
33
+
34
+ /** Server-free assertion-message helper (mirrors driver.describe without OPENWOP_BASE_URL). */
35
+ const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
36
+ const SPEC = 'workflow-chain-packs.md §"Sub-chain composition (RFC 0133)"';
37
+
38
+ /** A leaf child chain — one plain node, no further composition. */
39
+ const CHILD: WorkflowChain = {
40
+ chainId: 'lesson-batch',
41
+ version: '1.0.0',
42
+ label: 'Lesson batch',
43
+ description: 'Generates one batch of lessons.',
44
+ parameters: {},
45
+ dag: { nodes: [{ id: 'gen', typeId: 'core.ai.callPrompt', config: { systemPrompt: 'Write a lesson.' } }] },
46
+ };
47
+
48
+ /** A parent chain that composes the sibling `lesson-batch` from two dispatch
49
+ * nodes (so we can prove dedup: two refs → one co-registered child). */
50
+ const PARENT: WorkflowChain = {
51
+ chainId: 'kicktodo.challenge-factory',
52
+ version: '1.0.0',
53
+ label: 'Challenge factory',
54
+ description: 'Runs lesson-batch children per checkpoint.',
55
+ parameters: {},
56
+ subChains: [{ ref: 'lesson-batch' }],
57
+ dag: {
58
+ nodes: [
59
+ { id: 'plan', typeId: 'core.ai.callPrompt', config: { systemPrompt: 'Plan the challenge.' } },
60
+ { id: 'build-0', typeId: 'core.subWorkflow', config: { subChainRef: 'lesson-batch' }, inputs: { checkpoint: 0 } },
61
+ { id: 'build-1', typeId: 'core.subWorkflow', config: { subChainRef: 'lesson-batch' }, inputs: { checkpoint: 1 } },
62
+ ],
63
+ edges: [
64
+ { from: 'plan', to: 'build-0' },
65
+ { from: 'plan', to: 'build-1' },
66
+ ],
67
+ },
68
+ };
69
+
70
+ const siblings = new Map<string, WorkflowChain>([
71
+ [CHILD.chainId, CHILD],
72
+ [PARENT.chainId, PARENT],
73
+ ]);
74
+
75
+ const ctx = (tenantId = 'tenant-a') => ({
76
+ parentExpansionId: 'exp1',
77
+ tenantId,
78
+ params: {},
79
+ isTypeIdResolvable: () => true,
80
+ siblingChains: siblings,
81
+ });
82
+
83
+ describe('chain-subchain-sibling: co-registration (RFC 0133 §1, server-free)', () => {
84
+ it('co-registers the referenced sibling as its own workflow', () => {
85
+ const { children } = expandChainTree(PARENT, ctx());
86
+ expect(children.length, why(SPEC, '§1.3 step 2 — one distinct sibling ref ⇒ one co-registered child')).toBe(1);
87
+ expect(children[0]?.chainId, why(SPEC, 'child carries the composed chainId')).toBe('lesson-batch');
88
+ expect(
89
+ children[0]?.fragment.nodes.length,
90
+ why(SPEC, 'child is a fully expanded fragment (its own registered workflow)'),
91
+ ).toBe(1);
92
+ });
93
+
94
+ it('mints a DETERMINISTIC, TENANT-SCOPED, version-pinned child id', () => {
95
+ const { children } = expandChainTree(PARENT, ctx());
96
+ const expected = mintChildWorkflowId('tenant-a', 'lesson-batch', '1.0.0');
97
+ expect(children[0]?.childWorkflowId, why(SPEC, '§1.3 step 2 — deterministic (tenantId, childChainId, version) id')).toBe(
98
+ expected,
99
+ );
100
+ // Re-instantiating in the same tenant converges on the same id.
101
+ const again = expandChainTree(PARENT, ctx());
102
+ expect(again.children[0]?.childWorkflowId, why(SPEC, 'repeat instantiation in-tenant converges')).toBe(expected);
103
+ });
104
+
105
+ it('scopes the child id per TENANT — two tenants NEVER collide on the global registry (isolation)', () => {
106
+ const a = expandChainTree(PARENT, ctx('tenant-a')).children[0]?.childWorkflowId;
107
+ const b = expandChainTree(PARENT, ctx('tenant-b')).children[0]?.childWorkflowId;
108
+ expect(a, why(SPEC, 'tenant-a mints an id')).toBeTruthy();
109
+ expect(
110
+ a !== b,
111
+ why(SPEC, '§1.3 SECURITY sub-chain-child-tenant-scoped — distinct tenants ⇒ distinct child ids, no cross-tenant collision'),
112
+ ).toBe(true);
113
+ });
114
+
115
+ it('registers a child referenced twice exactly ONCE (dedup by deterministic id)', () => {
116
+ const { children } = expandChainTree(PARENT, ctx());
117
+ const ids = children.map((c) => c.childWorkflowId);
118
+ expect(new Set(ids).size, why(SPEC, '§1.3 step 2 — a shared child registers once')).toBe(ids.length);
119
+ expect(ids.length, why(SPEC, 'both build-0 + build-1 collapse to one child')).toBe(1);
120
+ });
121
+
122
+ it('rewrites config.subChainRef → the minted child config.workflowId', () => {
123
+ const { parent } = expandChainTree(PARENT, ctx());
124
+ const expected = mintChildWorkflowId('tenant-a', 'lesson-batch', '1.0.0');
125
+ const dispatchNodes = parent.nodes.filter((n) => n.typeId === 'core.subWorkflow');
126
+ expect(dispatchNodes.length, why(SPEC, 'both dispatch nodes present')).toBe(2);
127
+ for (const n of dispatchNodes) {
128
+ const config = n.config as Record<string, unknown>;
129
+ expect(config['workflowId'], why(SPEC, '§1.3 step 3 — subChainRef rewritten to minted child id')).toBe(expected);
130
+ expect(
131
+ 'subChainRef' in config,
132
+ why(SPEC, '§1.3 step 3 — transient subChainRef key dropped from the runtime config'),
133
+ ).toBe(false);
134
+ }
135
+ });
136
+
137
+ it('preserves the child reference at runtime (concrete workflowId, unlike inline mode)', () => {
138
+ const { parent, children } = expandChainTree(PARENT, ctx());
139
+ const childId = children[0]?.childWorkflowId;
140
+ const referenced = parent.nodes
141
+ .filter((n) => n.typeId === 'core.subWorkflow')
142
+ .map((n) => (n.config as Record<string, unknown>)['workflowId']);
143
+ expect(referenced.every((id) => id === childId), why(SPEC, 'parent HOLDS the co-registered child at runtime')).toBe(
144
+ true,
145
+ );
146
+ });
147
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Sub-chain composition — unsupported-host refusal (RFC 0133 §1.3).
3
+ *
4
+ * A host that does NOT advertise `capabilities.workflowChainPacks.subChains.supported`
5
+ * MUST refuse to instantiate a `subChains`-bearing chain with `sub_chain_unsupported`
6
+ * (HTTP 422) — it MUST NOT silently flatten the runtime child into the parent
7
+ * (flattening erases the child as an editable unit and changes run semantics).
8
+ *
9
+ * TWO parts:
10
+ * A. Always-on — the error code + refusal contract are present in the spec
11
+ * corpus (`workflow-chain-packs.md` §"Error codes" + §"Sub-chain composition").
12
+ * B. Capability-gated — against a host that expands chains
13
+ * (`workflowChainPacks.supported`) but does NOT advertise `subChains`, a
14
+ * `from-chain` on a `subChains`-bearing chain returns `sub_chain_unsupported`.
15
+ * Soft-skips until a reference host exposes the seam. The refusal is the
16
+ * fail-closed complement of `sub-chain-expansion-bounded`: a host that cannot
17
+ * run child dispatch never mis-dispatches.
18
+ *
19
+ * @see spec/v1/workflow-chain-packs.md §"Sub-chain composition (RFC 0133)" + §"Error codes"
20
+ * @see RFCS/0133-workflow-chain-composition.md
21
+ */
22
+
23
+ import { describe, it, expect } from 'vitest';
24
+ import { readFileSync } from 'node:fs';
25
+ import { join } from 'node:path';
26
+ import { SCHEMAS_DIR } from '../lib/paths.js';
27
+ import { behaviorGate } from '../lib/behavior-gate.js';
28
+ import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
29
+
30
+ const cite = (section: string, requirement: string): string => `${section} — ${requirement}`;
31
+ const CHAIN_DOC = join(SCHEMAS_DIR, '..', 'spec', 'v1', 'workflow-chain-packs.md');
32
+
33
+ describe('chain-subchain-unsupported-refused §A: corpus contract (RFC 0133, always-on)', () => {
34
+ it('spec corpus pins sub_chain_unsupported as a 422 refusal (never flatten)', () => {
35
+ const doc = readFileSync(CHAIN_DOC, 'utf8');
36
+ expect(doc.includes('sub_chain_unsupported'), cite('§Error codes', 'the code MUST be registered')).toBe(true);
37
+ // The normative refusal-not-flatten rule MUST appear in the composition section.
38
+ expect(
39
+ /sub_chain_unsupported[\s\S]{0,600}(never|MUST NOT).{0,40}(flatten|silently)/i.test(doc) ||
40
+ /(never|MUST NOT).{0,40}(flatten|silently)[\s\S]{0,600}sub_chain_unsupported/i.test(doc),
41
+ cite('§Sub-chain composition', 'an unsupported host MUST refuse, MUST NOT silently flatten'),
42
+ ).toBe(true);
43
+ expect(doc.includes('422'), cite('§Error codes', 'sub_chain_unsupported carries HTTP 422')).toBe(true);
44
+ });
45
+ });
46
+
47
+ describe('chain-subchain-unsupported-refused §B: host refusal (RFC 0133, capability-gated)', () => {
48
+ it('a host without subChains support refuses a subChains-bearing chain with sub_chain_unsupported', async () => {
49
+ const wcp = await readCapabilityFamily<{ supported?: boolean; subChains?: { supported?: boolean } }>(
50
+ 'workflowChainPacks',
51
+ );
52
+ // This leg targets hosts that DO expand chains but do NOT support runtime
53
+ // child dispatch — the exact population that MUST refuse. Gate on the base
54
+ // chain-expansion capability; soft-skip hosts that don't expand at all.
55
+ if (!behaviorGate('workflowChainPacks.supported', wcp?.supported === true)) return;
56
+ if (wcp?.subChains?.supported === true) {
57
+ // Host DOES support sub-chains — refusal path is not applicable here; the
58
+ // positive path is covered by chain-subchain-fanout §B. Skip cleanly.
59
+ return;
60
+ }
61
+ // Behavioral assertion runs once a host exposes the from-chain seam: a
62
+ // subChains-bearing chain MUST return `sub_chain_unsupported` (422), not a
63
+ // flattened success.
64
+ expect(wcp?.subChains?.supported ?? false, 'unsupported host advertises no subChains block').toBe(false);
65
+ });
66
+ });
@@ -22,6 +22,10 @@
22
22
  * 6. Positive — a SemVer prerelease `version` (`1.0.0-alpha.1`) is
23
23
  * schema-VALID: prerelease *precedence* (clause 6, SemVer §11) is a
24
24
  * host resolution concern, not a manifest-shape constraint.
25
+ * 7. Positive — a string `provider.vendor` validates (RFC 0123 clause 16).
26
+ * 8. Positive — a manifest OMITTING `provider.vendor` still validates
27
+ * (vendor is OPTIONAL — back-compat).
28
+ * 9. Negative — a non-string `provider.vendor` (an array) is rejected.
25
29
  *
26
30
  * Behavioral resolution legs live in `connection-provider-resolution.test.ts`
27
31
  * (capability-gated on `capabilities.connections.packsSupported`).
@@ -119,4 +123,33 @@ describe('category: connection-pack manifest validation (RFC 0095 §A)', () => {
119
123
  `connection-packs.md §Manifest clause 6: prerelease ordering is resolution-time SemVer §11, not manifest shape. Errors: ${JSON.stringify(validate.errors)}`,
120
124
  ).toBe(true);
121
125
  });
126
+
127
+ // RFC 0123 — presentational provider.vendor grouping (§Manifest clause 16).
128
+ it('positive: a provider.vendor string validates (RFC 0123)', () => {
129
+ const m = fixture();
130
+ m.provider.vendor = 'Google';
131
+ expect(
132
+ validate(m),
133
+ `connection-packs.md §Manifest clause 16 (RFC 0123): a string provider.vendor MUST validate. Errors: ${JSON.stringify(validate.errors)}`,
134
+ ).toBe(true);
135
+ });
136
+
137
+ it('positive: a manifest OMITTING provider.vendor still validates (back-compat, RFC 0123)', () => {
138
+ const m = fixture();
139
+ expect('vendor' in m.provider, 'the base fixture omits vendor').toBe(false);
140
+ expect(
141
+ validate(m),
142
+ `connection-packs.md §Manifest clause 16 (RFC 0123): vendor is OPTIONAL — a manifest without it MUST remain valid. Errors: ${JSON.stringify(validate.errors)}`,
143
+ ).toBe(true);
144
+ });
145
+
146
+ it('negative: a non-string provider.vendor is rejected (RFC 0123)', () => {
147
+ const m = fixture();
148
+ (m.provider as Record<string, unknown>).vendor = ['Google'];
149
+ const errs = failsWith(m, 'type');
150
+ expect(
151
+ errs.some((e) => e.instancePath === '/provider/vendor'),
152
+ 'connection-packs.md §Manifest clause 16 (RFC 0123): provider.vendor MUST be a string — an array MUST be rejected',
153
+ ).toBe(true);
154
+ });
122
155
  });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Edge conditions — `truthy` / `falsy` operators (RFC 0134).
3
+ *
4
+ * TWO parts:
5
+ * A. Always-on corpus legs — `workflow-definition.schema.json` §EdgeCondition and
6
+ * the inlined `workflow-chain-pack-manifest.schema.json` §EdgeCondition both carry
7
+ * `truthy`/`falsy` in the `type` enum; a `truthy`/`falsy` edge (no `right`)
8
+ * validates; the spec documents the no-`right` + required-`left` semantics.
9
+ * B. Capability-gated host leg — a chain whose fragment carries a `truthy` + a `falsy`
10
+ * edge off one approval-gate node instantiates through `from-chain` and the expanded
11
+ * edges carry the mapped host-native truthy/falsy conditions; a `truthy` edge with no
12
+ * `left` is refused. Gated on `workflowChainPacks.supported`; soft-skips until a
13
+ * reference host maps the operators (landed at RFC 0134 `Active`, per §Conformance).
14
+ *
15
+ * @see spec/v1/workflow-chain-packs.md §"Edge-condition operators (RFC 0134)"
16
+ * @see schemas/workflow-definition.schema.json §EdgeCondition
17
+ * @see RFCS/0134-edge-condition-truthy-falsy.md
18
+ */
19
+
20
+ import { describe, it, expect } from 'vitest';
21
+ import { readFileSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ import Ajv2020 from 'ajv/dist/2020.js';
24
+ import addFormats from 'ajv-formats';
25
+ import { SCHEMAS_DIR } from '../lib/paths.js';
26
+ import { behaviorGate } from '../lib/behavior-gate.js';
27
+ import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
28
+
29
+ const cite = (section: string, requirement: string): string => `${section} — ${requirement}`;
30
+ const WORKFLOW_DEF = join(SCHEMAS_DIR, 'workflow-definition.schema.json');
31
+ const MANIFEST = join(SCHEMAS_DIR, 'workflow-chain-pack-manifest.schema.json');
32
+ const CHAIN_DOC = join(SCHEMAS_DIR, '..', 'spec', 'v1', 'workflow-chain-packs.md');
33
+
34
+ function loadSchema(path: string): Record<string, unknown> {
35
+ return JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>;
36
+ }
37
+
38
+ describe('edge-condition-truthy-falsy §A: corpus (RFC 0134, always-on)', () => {
39
+ it('workflow-definition + manifest §EdgeCondition `type` enums both include truthy + falsy', () => {
40
+ for (const path of [WORKFLOW_DEF, MANIFEST]) {
41
+ const raw = readFileSync(path, 'utf8');
42
+ expect(raw.includes('"truthy"'), cite('§EdgeCondition', `truthy in ${path}`)).toBe(true);
43
+ expect(raw.includes('"falsy"'), cite('§EdgeCondition', `falsy in ${path}`)).toBe(true);
44
+ }
45
+ });
46
+
47
+ it('a truthy/falsy edge condition (no `right`) validates against the manifest schema', () => {
48
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
49
+ addFormats(ajv);
50
+ const validate = ajv.compile(loadSchema(MANIFEST));
51
+ const pack = {
52
+ name: 'vendor.acme.branch',
53
+ version: '1.0.0',
54
+ kind: 'workflow-chain',
55
+ engines: { openwop: '^1' },
56
+ chains: [
57
+ {
58
+ chainId: 'acme.branch',
59
+ version: '1.0.0',
60
+ label: 'Branch',
61
+ description: 'Approval branch.',
62
+ parameters: {},
63
+ dag: {
64
+ nodes: [
65
+ { id: 'approve', typeId: 'core.chat.approvalGate', config: {} },
66
+ { id: 'apply', typeId: 'core.ai.callPrompt', config: {} },
67
+ { id: 'reject', typeId: 'core.fail', config: {} },
68
+ ],
69
+ edges: [
70
+ { from: 'approve', to: 'apply', condition: { type: 'truthy', left: 'approved' } },
71
+ { from: 'approve', to: 'reject', condition: { type: 'falsy', left: 'approved' } },
72
+ ],
73
+ },
74
+ },
75
+ ],
76
+ };
77
+ expect(validate(pack), cite('§EdgeCondition', `truthy/falsy edges validate: ${ajv.errorsText(validate.errors)}`)).toBe(true);
78
+ });
79
+
80
+ it('the spec documents the no-`right` + required-`left` truthy/falsy semantics', () => {
81
+ const doc = readFileSync(CHAIN_DOC, 'utf8');
82
+ expect(doc.includes('truthy'), cite('§Edge-condition operators', 'documents truthy')).toBe(true);
83
+ expect(
84
+ /truthy[\s\S]{0,400}(no|without).{0,20}`?right`?/i.test(doc) || /(no|without).{0,20}`?right`?[\s\S]{0,400}truthy/i.test(doc),
85
+ cite('§Edge-condition operators', 'documents that truthy/falsy take no right operand'),
86
+ ).toBe(true);
87
+ expect(
88
+ /`?left`?[\s\S]{0,120}(required|MUST)/i.test(doc),
89
+ cite('§Edge-condition operators', 'documents left is required'),
90
+ ).toBe(true);
91
+ });
92
+ });
93
+
94
+ describe('edge-condition-truthy-falsy §B: host mapping (RFC 0134, capability-gated)', () => {
95
+ it('a host expanding chains maps truthy/falsy edge conditions onto the expanded workflow', async () => {
96
+ const wcp = await readCapabilityFamily<{ supported?: boolean }>('workflowChainPacks');
97
+ if (!behaviorGate('workflowChainPacks.supported', wcp?.supported === true)) return;
98
+ // Behavioral leg — exercised once a reference host maps the operators (RFC 0134
99
+ // Active): a chain carrying truthy/falsy edges instantiates via from-chain and the
100
+ // expanded edges carry the host-native truthy/falsy conditions; a truthy edge with
101
+ // no `left` is refused `chain_edge_condition_invalid`. Gate on base chain expansion.
102
+ expect(wcp?.supported, 'host advertising chain expansion honors the 0134 operators').toBe(true);
103
+ });
104
+ });