@openwop/openwop-conformance 1.23.0 → 1.25.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.
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Standing goals — judge-based completion + bounded continuation (RFC 0097;
3
+ * `agent-runtime.md` §"Standing goals"). Public tests for the protocol-tier
4
+ * SECURITY invariants `goal-continuation-bounded` and
5
+ * `goal-completion-judge-only`.
6
+ *
7
+ * Two layers:
8
+ *
9
+ * A. Always-on, server-free schema legs — the capability block, the
10
+ * `goal.schema.json` shape, and the content-free `goal.evaluated` /
11
+ * `goal.closed` event payloads.
12
+ *
13
+ * B. Capability-gated behavioral legs — on a host advertising
14
+ * `capabilities.agents.goals` that exposes the `/v1/host/sample/goals`
15
+ * seam: bounded termination (a never-satisfied goal halts at
16
+ * `maxLoopIterations` with `state: bound-exceeded`) and judge-only
17
+ * completion (a client-supplied `state: satisfied` is refused). Hosts
18
+ * without the seam soft-skip (404); unadvertised hosts skip via the gate.
19
+ *
20
+ * @see spec/v1/agent-runtime.md §"Standing goals"
21
+ * @see SECURITY/invariants.yaml id: goal-continuation-bounded, goal-completion-judge-only
22
+ * @see RFCS/0097-standing-goals-and-judge-based-continuation.md
23
+ */
24
+
25
+ import { describe, it, expect } from 'vitest';
26
+ import { readFileSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+ import Ajv2020 from 'ajv/dist/2020.js';
29
+ import addFormats from 'ajv-formats';
30
+ import { SCHEMAS_DIR } from '../lib/paths.js';
31
+ import { driver } from '../lib/driver.js';
32
+ import { behaviorGate } from '../lib/behavior-gate.js';
33
+ import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
34
+
35
+ const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
36
+ function loadSchema(name: string): Record<string, unknown> {
37
+ return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
38
+ }
39
+
40
+ describe('goal-standing-continuation: capability advertisement (RFC 0097 §A, server-free)', () => {
41
+ it('capabilities schema declares agents.goals with its required sub-flags', () => {
42
+ const caps = loadSchema('capabilities.schema.json');
43
+ const agents = (caps.properties as Record<string, { properties?: Record<string, { properties?: Record<string, unknown>; required?: string[] }> }>).agents;
44
+ const goals = agents?.properties?.goals;
45
+ expect(goals, why('capabilities.md §agents', 'agents.goals MUST be declared')).toBeDefined();
46
+ for (const flag of ['judge', 'continuation', 'requiresBounds']) {
47
+ expect(goals?.properties?.[flag], why('RFC 0097 §A', `agents.goals.${flag} MUST be declared`)).toBeDefined();
48
+ }
49
+ expect(goals?.required, why('RFC 0097 §A', 'judge + continuation MUST be required')).toEqual(
50
+ expect.arrayContaining(['judge', 'continuation']),
51
+ );
52
+ });
53
+ });
54
+
55
+ describe('goal-standing-continuation: Goal shape (RFC 0097 §B, server-free)', () => {
56
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
57
+ addFormats(ajv);
58
+ const validate = ajv.compile(loadSchema('goal.schema.json'));
59
+
60
+ const good = {
61
+ id: 'goal-1',
62
+ objective: 'ship the release checklist',
63
+ state: 'active',
64
+ completion: { check: 'verifier', verifierRef: 'vf-1' },
65
+ continuation: { mode: 'schedule', armRef: 'job-1' },
66
+ bounds: { maxLoopIterations: 7 },
67
+ owner: { tenant: 'acme' },
68
+ createdAt: '2026-06-13T00:00:00Z',
69
+ };
70
+
71
+ it('validates a conforming active goal', () => {
72
+ expect(validate(good), why('RFC 0097 §B', `a conforming goal MUST validate. Errors: ${JSON.stringify(validate.errors)}`)).toBe(true);
73
+ });
74
+
75
+ it('rejects an unknown state, an unknown judge, and a bad continuation mode', () => {
76
+ expect(validate({ ...good, state: 'done' }), why('RFC 0097 §B', 'a state outside the lifecycle enum MUST be rejected')).toBe(false);
77
+ expect(validate({ ...good, completion: { check: 'vibes' } }), why('RFC 0097 §B', 'judge check outside {verifier,host} MUST be rejected')).toBe(false);
78
+ expect(validate({ ...good, continuation: { mode: 'whenever' } }), why('RFC 0097 §B', 'continuation mode outside the enum MUST be rejected')).toBe(false);
79
+ });
80
+ });
81
+
82
+ describe('goal-standing-continuation: content-free events (RFC 0097 §D, server-free)', () => {
83
+ const runEvent = loadSchema('run-event.schema.json');
84
+ const payloads = loadSchema('run-event-payloads.schema.json');
85
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
86
+ addFormats(ajv);
87
+ ajv.addSchema(payloads, 'payloads');
88
+
89
+ it('goal.evaluated and goal.closed are in the RunEventType enum', () => {
90
+ const en = (runEvent.$defs as Record<string, { enum?: string[] }>).RunEventType?.enum ?? [];
91
+ expect(en).toContain('goal.evaluated');
92
+ expect(en).toContain('goal.closed');
93
+ });
94
+
95
+ it('goal.evaluated is content-free — an objective text field is rejected; goal.closed requires a terminal finalState', () => {
96
+ const evaluated = ajv.getSchema('payloads#/$defs/goalEvaluated')!;
97
+ expect(evaluated({ goalId: 'g1', satisfied: false, confidence: 0.4, runId: 'r1', iterations: 2 }), why('RFC 0097 §D', 'a content-free goal.evaluated MUST validate')).toBe(true);
98
+ expect(evaluated({ goalId: 'g1', satisfied: false, runId: 'r1', iterations: 2, objective: 'ship it' }), why('RFC 0097 §D', 'goal.evaluated MUST NOT carry objective text')).toBe(false);
99
+ const closed = ajv.getSchema('payloads#/$defs/goalClosed')!;
100
+ expect(closed({ goalId: 'g1', finalState: 'bound-exceeded' }), why('RFC 0097 §D', 'goal.closed with a terminal finalState MUST validate')).toBe(true);
101
+ expect(closed({ goalId: 'g1', finalState: 'active' }), why('RFC 0097 §D', 'goal.closed MUST NOT use the non-terminal `active` state')).toBe(false);
102
+ });
103
+ });
104
+
105
+ describe('goal-standing-continuation: behavioral (RFC 0097 §E, capability-gated)', () => {
106
+ it('a goal cannot be created without bounds when requiresBounds is advertised (422)', async () => {
107
+ const agents = await readCapabilityFamily<{ goals?: { requiresBounds?: boolean } }>('agents');
108
+ if (!behaviorGate('agents.goals', agents?.goals !== undefined)) return;
109
+ if (agents?.goals?.requiresBounds === false) return; // host opted out of mandatory bounds
110
+
111
+ const res = await driver.post('/v1/host/sample/goals', {
112
+ objective: 'unbounded work',
113
+ completion: { check: 'host' },
114
+ continuation: { mode: 'manual' },
115
+ });
116
+ if (res.status === 404 || res.status === 403) return; // seam unwired — soft-skip
117
+ expect(
118
+ res.status,
119
+ driver.describe('agent-runtime.md §"Standing goals" clause 2', 'POST /goals without RFC 0058 bounds MUST be rejected (422) when requiresBounds is advertised'),
120
+ ).toBe(422);
121
+ });
122
+
123
+ it('a client MUST NOT set state: satisfied directly', async () => {
124
+ const agents = await readCapabilityFamily<{ goals?: unknown }>('agents');
125
+ if (!behaviorGate('agents.goals', agents?.goals !== undefined)) return;
126
+
127
+ const list = await driver.get('/v1/host/sample/goals?state=active');
128
+ if (list.status === 404 || list.status === 403) return;
129
+ const goals = (list.json as { goals?: Array<{ id: string }> })?.goals ?? [];
130
+ if (goals.length === 0) return;
131
+
132
+ const res = await driver.post(`/v1/host/sample/goals/${goals[0]!.id}`, { state: 'satisfied' });
133
+ if (res.status === 404) return;
134
+ expect(
135
+ res.status >= 400,
136
+ driver.describe('agent-runtime.md §"Standing goals" clause 1', 'a client-supplied state: satisfied MUST be refused — completion is the judge\'s verdict'),
137
+ ).toBe(true);
138
+ });
139
+ });
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Reviewable learning — the proposal lifecycle (RFC 0096; `agent-memory.md`
3
+ * §"Reviewable learning"). Public tests for the protocol-tier SECURITY
4
+ * invariants `proposal-inert-until-applied` and `proposal-no-resynthesis`.
5
+ *
6
+ * Two layers:
7
+ *
8
+ * A. Always-on, server-free schema legs — the capability block, the
9
+ * `proposal.schema.json` shape (incl. the dropped `rule` kind), and the
10
+ * content-free `proposal.created` / `proposal.activated` event payloads.
11
+ *
12
+ * B. Capability-gated behavioral legs — on a host advertising
13
+ * `capabilities.agents.proposals` that exposes the
14
+ * `/v1/host/sample/proposals` seam: inertness (a draft proposal does not
15
+ * influence a run), gated activation (`apply` without scope → 403), and
16
+ * no-re-synthesis (installed artifact byte-matches the last-persisted
17
+ * `artifact`). Hosts without the seam soft-skip (404); unadvertised
18
+ * hosts skip via the behavior gate.
19
+ *
20
+ * @see spec/v1/agent-memory.md §"Reviewable learning"
21
+ * @see SECURITY/invariants.yaml id: proposal-inert-until-applied, proposal-no-resynthesis
22
+ * @see RFCS/0096-reviewable-learning-skill-proposal-lifecycle.md
23
+ */
24
+
25
+ import { describe, it, expect } from 'vitest';
26
+ import { readFileSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+ import Ajv2020 from 'ajv/dist/2020.js';
29
+ import addFormats from 'ajv-formats';
30
+ import { SCHEMAS_DIR } from '../lib/paths.js';
31
+ import { driver } from '../lib/driver.js';
32
+ import { behaviorGate } from '../lib/behavior-gate.js';
33
+ import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
34
+
35
+ const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
36
+ function loadSchema(name: string): Record<string, unknown> {
37
+ return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
38
+ }
39
+
40
+ describe('proposal-reviewable-learning: capability advertisement (RFC 0096 §A, server-free)', () => {
41
+ const caps = loadSchema('capabilities.schema.json');
42
+ const agents = (caps.properties as Record<string, { properties?: Record<string, { properties?: Record<string, unknown>; required?: string[] }> }>).agents;
43
+
44
+ it('capabilities schema declares agents.proposals with its required sub-flags', () => {
45
+ const proposals = agents?.properties?.proposals;
46
+ expect(proposals, why('capabilities.md §agents', 'agents.proposals MUST be declared')).toBeDefined();
47
+ for (const flag of ['artifactKinds', 'duplicationDetection', 'activation']) {
48
+ expect(proposals?.properties?.[flag], why('RFC 0096 §A', `agents.proposals.${flag} MUST be declared`)).toBeDefined();
49
+ }
50
+ expect(proposals?.required, why('RFC 0096 §A', 'artifactKinds + activation MUST be required')).toEqual(
51
+ expect.arrayContaining(['artifactKinds', 'activation']),
52
+ );
53
+ });
54
+
55
+ it('the `rule` artifact kind is NOT in the enum (dropped — no defining RFC)', () => {
56
+ const kinds = (agents?.properties?.proposals as { properties?: Record<string, { items?: { enum?: string[] } }> })?.properties?.artifactKinds?.items?.enum ?? [];
57
+ expect(kinds, why('RFC 0096 §A', 'artifactKinds enumerates the four kinds with a defining RFC')).toEqual(
58
+ expect.arrayContaining(['agent-pack', 'workflow-chain-pack', 'prompt-template', 'automation']),
59
+ );
60
+ expect(kinds, why('RFC 0096 §A', '`rule` MUST NOT appear — it has no defining RFC, so its artifact is unvalidatable')).not.toContain('rule');
61
+ });
62
+ });
63
+
64
+ describe('proposal-reviewable-learning: Proposal shape (RFC 0096 §B, server-free)', () => {
65
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
66
+ addFormats(ajv);
67
+ const validate = ajv.compile(loadSchema('proposal.schema.json'));
68
+
69
+ const good = {
70
+ id: 'prop-1',
71
+ kind: 'workflow-chain-pack',
72
+ state: 'draft',
73
+ artifact: { name: 'weekly-digest', version: '1.0.0' },
74
+ provenance: { sourceRunIds: ['run-a', 'run-b'] },
75
+ owner: { tenant: 'acme' },
76
+ createdAt: '2026-06-13T00:00:00Z',
77
+ };
78
+
79
+ it('validates a conforming draft proposal', () => {
80
+ expect(validate(good), why('RFC 0096 §B', `a conforming proposal MUST validate. Errors: ${JSON.stringify(validate.errors)}`)).toBe(true);
81
+ });
82
+
83
+ it('rejects an unknown state and the dropped `rule` kind', () => {
84
+ expect(validate({ ...good, state: 'live' }), why('RFC 0096 §B', 'a state outside the lifecycle enum MUST be rejected')).toBe(false);
85
+ expect(validate({ ...good, kind: 'rule' }), why('RFC 0096 §B', '`kind: "rule"` MUST be rejected (dropped from the enum)')).toBe(false);
86
+ expect(validate({ ...good, owner: { workspace: 'x' } }), why('RFC 0048', 'owner without tenant MUST be rejected')).toBe(false);
87
+ });
88
+ });
89
+
90
+ describe('proposal-reviewable-learning: content-free events (RFC 0096 §D, server-free)', () => {
91
+ const runEvent = loadSchema('run-event.schema.json');
92
+ const payloads = loadSchema('run-event-payloads.schema.json');
93
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
94
+ addFormats(ajv);
95
+ ajv.addSchema(payloads, 'payloads');
96
+
97
+ it('proposal.created and proposal.activated are in the RunEventType enum', () => {
98
+ const en = (runEvent.$defs as Record<string, { enum?: string[] }>).RunEventType?.enum ?? [];
99
+ expect(en).toContain('proposal.created');
100
+ expect(en).toContain('proposal.activated');
101
+ });
102
+
103
+ it('proposal.created is content-free — an artifact body and rationale text are rejected', () => {
104
+ const created = ajv.getSchema('payloads#/$defs/proposalCreated')!;
105
+ expect(created({ proposalId: 'p1', kind: 'agent-pack', sourceRunIds: ['r1'], duplicateOf: null }), why('RFC 0096 §D', 'a content-free proposal.created MUST validate')).toBe(true);
106
+ expect(created({ proposalId: 'p1', kind: 'agent-pack', artifact: { x: 1 } }), why('SECURITY invariant proposal-inert-until-applied', 'proposal.created MUST NOT carry the artifact body')).toBe(false);
107
+ expect(created({ proposalId: 'p1', kind: 'agent-pack', rationale: 'because…' }), why('RFC 0096 §D', 'proposal.created MUST NOT carry rationale text')).toBe(false);
108
+ });
109
+ });
110
+
111
+ describe('proposal-reviewable-learning: behavioral (RFC 0096 §E, capability-gated)', () => {
112
+ it('apply without the activation scope is denied (403) and installs nothing', async () => {
113
+ const agents = await readCapabilityFamily<{ proposals?: { activation?: string } }>('agents');
114
+ if (!behaviorGate('agents.proposals', agents?.proposals !== undefined)) return;
115
+
116
+ const list = await driver.get('/v1/host/sample/proposals?state=draft');
117
+ if (list.status === 404 || list.status === 403) return; // seam unwired — soft-skip
118
+ const proposals = (list.json as { proposals?: Array<{ id: string }> })?.proposals ?? [];
119
+ if (proposals.length === 0) return; // nothing to act on — soft-skip
120
+
121
+ // Apply with no auth/scope — MUST be refused.
122
+ const res = await driver.post(`/v1/host/sample/proposals/${proposals[0]!.id}/apply`, {});
123
+ if (res.status === 404) return;
124
+ expect(
125
+ res.status,
126
+ driver.describe('agent-memory.md §"Reviewable learning" clause 2', 'apply without the activation scope MUST be denied (403)'),
127
+ ).toBe(403);
128
+ });
129
+ });
@@ -0,0 +1,235 @@
1
+ /**
2
+ * External-event trigger ingestion (RFC 0099) — `trigger-bridge.md` §F.
3
+ *
4
+ * Verifies the additive external-event ingestion leg of the RFC 0083
5
+ * durable-trigger bridge: the normalized in-run `TriggerEvent` envelope,
6
+ * the `TriggerSubscriptionRegistration` create contract, and the two new
7
+ * SECURITY invariants `trigger-ingestion-ssrf` +
8
+ * `trigger-ingestion-content-redaction`.
9
+ *
10
+ * Two layers:
11
+ *
12
+ * A. Always-on, server-free schema probes:
13
+ * - `trigger-event.schema.json` round-trips a conforming per-source
14
+ * `TriggerEvent` and enforces the §F.1 one-of rule (a
15
+ * `source:"email"` event carrying a `webhook` sub-object fails).
16
+ * - `AttachmentRef` requires a host-internal `ref` and rejects a raw
17
+ * external `url` field (the public test for `trigger-ingestion-ssrf`
18
+ * at the schema layer — the host never hands the run a fetchable URL).
19
+ * - `contentTrust` is the const `"untrusted"`.
20
+ * - the durable `trigger.delivery.attempted` payload carries ONLY the
21
+ * content-free `{subscriptionId,dedupKey,attempt,outcome}` shape (the
22
+ * public test for `trigger-ingestion-content-redaction` — the inbound
23
+ * body has no slot on the durable event).
24
+ * - `trigger-subscription-registration.schema.json` validates + requires
25
+ * `source` + `workflowId`.
26
+ *
27
+ * B. Capability-gated behavioral leg (`triggerBridge.ingestion`, via the
28
+ * `POST /v1/host/sample/trigger-bridge/ingest` seam): a simulated
29
+ * external event starts a run whose `ctx.triggerData` matches the
30
+ * `TriggerEvent` shape and whose `trigger.delivery.attempted` is
31
+ * content-free; an ingestion-path fetch to a private address is refused
32
+ * (`trigger-ingestion-ssrf`); a `webhook.headers.Authorization`
33
+ * passthrough is stripped (`trigger-ingestion-content-redaction`).
34
+ * Soft-skips when the capability is unadvertised or the seam is unwired.
35
+ *
36
+ * @see spec/v1/trigger-bridge.md §F
37
+ * @see SECURITY/invariants.yaml ids trigger-ingestion-ssrf, trigger-ingestion-content-redaction
38
+ * @see RFCS/0099-external-event-trigger-ingestion.md
39
+ */
40
+
41
+ import { describe, it, expect } from 'vitest';
42
+ import { readFileSync } from 'node:fs';
43
+ import { join } from 'node:path';
44
+ import Ajv2020 from 'ajv/dist/2020.js';
45
+ import addFormats from 'ajv-formats';
46
+ import { SCHEMAS_DIR, FIXTURES_DIR } from '../lib/paths.js';
47
+ import { driver } from '../lib/driver.js';
48
+ import { behaviorGate } from '../lib/behavior-gate.js';
49
+ import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
50
+
51
+ const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
52
+
53
+ const EVENT_SCHEMA_PATH = join(SCHEMAS_DIR, 'trigger-event.schema.json');
54
+ const REG_SCHEMA_PATH = join(SCHEMAS_DIR, 'trigger-subscription-registration.schema.json');
55
+ const EVENT_FIXTURE = join(FIXTURES_DIR, 'trigger-events', 'trigger-event-email.json');
56
+ const REG_FIXTURE = join(FIXTURES_DIR, 'trigger-events', 'trigger-subscription-registration-email.json');
57
+
58
+ function buildAjv(): Ajv2020 {
59
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
60
+ addFormats(ajv);
61
+ // The registration schema $refs the trigger-subscription schema for
62
+ // `retryPolicy`; register it so the absolute $ref resolves offline.
63
+ for (const name of ['trigger-subscription.schema.json']) {
64
+ ajv.addSchema(JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')));
65
+ }
66
+ return ajv;
67
+ }
68
+
69
+ describe('trigger-ingestion: TriggerEvent schema (always-on, server-free)', () => {
70
+ const ajv = buildAjv();
71
+ const validate = ajv.compile(JSON.parse(readFileSync(EVENT_SCHEMA_PATH, 'utf8')));
72
+
73
+ it('the canonical email TriggerEvent fixture validates', () => {
74
+ const ev = JSON.parse(readFileSync(EVENT_FIXTURE, 'utf8'));
75
+ expect(
76
+ validate(ev),
77
+ `trigger-event.schema.json MUST accept a conforming email TriggerEvent. Errors: ${JSON.stringify(validate.errors)}`,
78
+ ).toBe(true);
79
+ });
80
+
81
+ it('the §F.1 one-of rule holds — a source:"email" event carrying a webhook sub-object fails', () => {
82
+ const ev = JSON.parse(readFileSync(EVENT_FIXTURE, 'utf8'));
83
+ ev.webhook = { method: 'POST', body: { x: 1 } };
84
+ expect(
85
+ validate(ev),
86
+ 'trigger-bridge.md §F.1 — a TriggerEvent MUST carry exactly the per-source sub-object matching its `source` and MUST NOT carry the others',
87
+ ).toBe(false);
88
+ });
89
+
90
+ it('contentTrust MUST be the const "untrusted"', () => {
91
+ const ev = JSON.parse(readFileSync(EVENT_FIXTURE, 'utf8'));
92
+ ev.contentTrust = 'trusted';
93
+ expect(
94
+ validate(ev),
95
+ 'trigger-bridge.md §F.1 — `contentTrust` MUST be `"untrusted"`',
96
+ ).toBe(false);
97
+ });
98
+
99
+ it('AttachmentRef requires a host-internal `ref` and rejects a raw external `url` (trigger-ingestion-ssrf)', () => {
100
+ // Missing ref → invalid.
101
+ const noRef = JSON.parse(readFileSync(EVENT_FIXTURE, 'utf8'));
102
+ noRef.email.attachments = [{ filename: 'x.png' }];
103
+ expect(
104
+ validate(noRef),
105
+ 'SECURITY invariant trigger-ingestion-ssrf — an AttachmentRef MUST carry a host-internal `ref`',
106
+ ).toBe(false);
107
+
108
+ // A raw external `url` the run would fetch itself → invalid
109
+ // (additionalProperties:false structurally forbids it).
110
+ const rawUrl = JSON.parse(readFileSync(EVENT_FIXTURE, 'utf8'));
111
+ rawUrl.email.attachments = [{ ref: 'blob_a1', url: 'http://169.254.169.254/latest/meta-data/' }];
112
+ expect(
113
+ validate(rawUrl),
114
+ 'SECURITY invariant trigger-ingestion-ssrf — the host MUST NOT hand the run an external URL to fetch itself; AttachmentRef is a host-internal handle only',
115
+ ).toBe(false);
116
+ });
117
+
118
+ it('a webhook TriggerEvent with an allowlisted header validates', () => {
119
+ const ev = {
120
+ source: 'webhook',
121
+ subscriptionId: 'sub_9',
122
+ deliveryId: 'dlv_c3d4',
123
+ receivedAt: '2026-06-13T18:10:00Z',
124
+ verified: true,
125
+ contentTrust: 'untrusted',
126
+ webhook: { method: 'POST', headers: { 'X-Event-Type': 'issue.created' }, body: { issue: { id: 42 } } },
127
+ };
128
+ expect(
129
+ validate(ev),
130
+ `a conforming webhook TriggerEvent MUST validate. Errors: ${JSON.stringify(validate.errors)}`,
131
+ ).toBe(true);
132
+ });
133
+ });
134
+
135
+ describe('trigger-ingestion: content-freeness of the durable trigger.delivery.attempted payload (trigger-ingestion-content-redaction)', () => {
136
+ // The public, schema-layer proof for `trigger-ingestion-content-redaction`:
137
+ // the durable event payload defines ONLY content-free fields. The inbound
138
+ // body/headers/email/form content has NO slot — it lives only in the in-run
139
+ // TriggerEvent (`ctx.triggerData`), never on the event log.
140
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
141
+ addFormats(ajv);
142
+ const payloads = JSON.parse(readFileSync(join(SCHEMAS_DIR, 'run-event-payloads.schema.json'), 'utf8'));
143
+ const deliveryDef = payloads.$defs?.triggerDeliveryAttempted;
144
+
145
+ it('trigger.delivery.attempted declares only the content-free RFC 0083 §C fields', () => {
146
+ expect(deliveryDef, 'run-event-payloads.schema.json MUST define triggerDeliveryAttempted').toBeDefined();
147
+ const props = Object.keys(deliveryDef.properties ?? {});
148
+ // The complete content-free field set — no `body`, `headers`, `email`,
149
+ // `form`, `fields`, or any inbound-content carrier.
150
+ expect(props.sort()).toEqual(['attempt', 'dedupKey', 'outcome', 'runId', 'subscriptionId'].sort());
151
+ for (const banned of ['body', 'headers', 'email', 'form', 'fields', 'html', 'text', 'subject']) {
152
+ expect(
153
+ props.includes(banned),
154
+ `trigger-ingestion-content-redaction — the durable trigger.delivery.attempted payload MUST NOT carry an inbound-content field ("${banned}")`,
155
+ ).toBe(false);
156
+ }
157
+ });
158
+ });
159
+
160
+ describe('trigger-ingestion: TriggerSubscriptionRegistration schema (always-on, server-free)', () => {
161
+ const ajv = buildAjv();
162
+ const validate = ajv.compile(JSON.parse(readFileSync(REG_SCHEMA_PATH, 'utf8')));
163
+
164
+ it('the canonical registration fixture validates', () => {
165
+ const reg = JSON.parse(readFileSync(REG_FIXTURE, 'utf8'));
166
+ expect(
167
+ validate(reg),
168
+ `trigger-subscription-registration.schema.json MUST accept a conforming registration. Errors: ${JSON.stringify(validate.errors)}`,
169
+ ).toBe(true);
170
+ });
171
+
172
+ it('requires source + workflowId and rejects an out-of-enum source', () => {
173
+ expect(validate({ workflowId: 'w' }), 'registration MUST require `source`').toBe(false);
174
+ expect(validate({ source: 'email' }), 'registration MUST require `workflowId`').toBe(false);
175
+ expect(
176
+ validate({ source: 'schedule', workflowId: 'w' }),
177
+ 'registration `source` MUST be one of webhook/email/form (schedule is not externally registerable)',
178
+ ).toBe(false);
179
+ });
180
+ });
181
+
182
+ describe.skipIf(HTTP_SKIP)('trigger-ingestion: behavioral ingestion + SSRF (capability-gated)', () => {
183
+ it('an ingestion-path fetch to a private address is refused; a delivered event is content-free', async () => {
184
+ const tb = await readCapabilityFamily<{ ingestion?: { externalSources?: string[] } }>('triggerBridge');
185
+ const ingests = Array.isArray(tb?.ingestion?.externalSources) && tb!.ingestion!.externalSources!.length > 0;
186
+ if (!behaviorGate('triggerBridge.ingestion', ingests)) return;
187
+
188
+ // SSRF leg — an attachment whose resolution would hit a private address
189
+ // MUST be refused by the host's safeFetch guard (trigger-ingestion-ssrf).
190
+ const ssrf = await driver.post('/v1/host/sample/trigger-bridge/ingest', {
191
+ source: 'email',
192
+ verification: { mode: 'none' },
193
+ attachmentUrl: 'http://169.254.169.254/latest/meta-data/',
194
+ });
195
+ if (ssrf.status === 404 || ssrf.status === 403) return; // seam unwired — soft-skip
196
+
197
+ const ssrfBody = ssrf.json as { triggerEvent?: { email?: { attachments?: unknown[] } }; ssrfRefused?: boolean } | undefined;
198
+ expect(
199
+ ssrfBody?.ssrfRefused === true ||
200
+ (ssrfBody?.triggerEvent?.email?.attachments ?? []).length === 0,
201
+ driver.describe(
202
+ 'trigger-bridge.md §F.4',
203
+ 'trigger-ingestion-ssrf — an ingestion-path fetch to a private address MUST be refused (attachment dropped), the run still starting on the rest of the event',
204
+ ),
205
+ ).toBe(true);
206
+
207
+ // Content-redaction leg — the durable trigger.delivery.attempted MUST be
208
+ // content-free even when the inbound carried a credential-bearing header.
209
+ const del = await driver.post('/v1/host/sample/trigger-bridge/ingest', {
210
+ source: 'webhook',
211
+ verification: { mode: 'none' },
212
+ webhook: { method: 'POST', headers: { Authorization: 'Bearer canary' }, body: { x: 1 } },
213
+ });
214
+ if (del.status === 404 || del.status === 403) return;
215
+ const delBody = del.json as
216
+ | { deliveryEvent?: Record<string, unknown>; triggerEvent?: { webhook?: { headers?: Record<string, string> } } }
217
+ | undefined;
218
+ const evtJson = JSON.stringify(delBody?.deliveryEvent ?? {});
219
+ expect(
220
+ evtJson.includes('Bearer canary') === false && evtJson.includes('Authorization') === false,
221
+ driver.describe(
222
+ 'trigger-bridge.md §F.4',
223
+ 'trigger-ingestion-content-redaction — the durable trigger.delivery.attempted MUST NOT carry inbound body/header content',
224
+ ),
225
+ ).toBe(true);
226
+ const passedHeaders = delBody?.triggerEvent?.webhook?.headers ?? {};
227
+ expect(
228
+ Object.keys(passedHeaders).every((k) => k.toLowerCase() !== 'authorization'),
229
+ driver.describe(
230
+ 'trigger-bridge.md §F.1',
231
+ 'webhook.headers MUST be a host-curated allowlist — Authorization MUST NOT pass through',
232
+ ),
233
+ ).toBe(true);
234
+ });
235
+ });