@openwop/openwop-conformance 1.21.0 → 1.24.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 (38) hide show
  1. package/CHANGELOG.md +43 -2
  2. package/README.md +61 -63
  3. package/api/asyncapi.yaml +108 -38
  4. package/api/openapi.yaml +34 -6
  5. package/coverage.md +389 -202
  6. package/fixtures/connection-packs/connection-pack-github.json +31 -0
  7. package/fixtures.md +120 -101
  8. package/package.json +1 -1
  9. package/schemas/README.md +4 -0
  10. package/schemas/capabilities.schema.json +127 -0
  11. package/schemas/connection-pack-manifest.schema.json +161 -0
  12. package/schemas/export-bundle.schema.json +66 -0
  13. package/schemas/goal.schema.json +104 -0
  14. package/schemas/proposal.schema.json +84 -0
  15. package/schemas/run-event-payloads.schema.json +86 -7
  16. package/schemas/run-event.schema.json +17 -3
  17. package/schemas/run-options.schema.json +1 -2
  18. package/schemas/run-snapshot.schema.json +2 -1
  19. package/schemas/suspend-request.schema.json +5 -0
  20. package/src/scenarios/connection-pack-manifest-valid.test.ts +122 -0
  21. package/src/scenarios/connection-pack-no-credential-material.test.ts +125 -0
  22. package/src/scenarios/connection-pack-reach-exclusive.test.ts +85 -0
  23. package/src/scenarios/connection-pack-write-reconsent.test.ts +91 -0
  24. package/src/scenarios/connection-provider-resolution.test.ts +153 -0
  25. package/src/scenarios/cross-host-traceparent-propagation.test.ts +3 -3
  26. package/src/scenarios/export-bundle-portability.test.ts +120 -0
  27. package/src/scenarios/fixtures-valid.test.ts +34 -0
  28. package/src/scenarios/goal-standing-continuation.test.ts +139 -0
  29. package/src/scenarios/grpc-transport.test.ts +108 -0
  30. package/src/scenarios/i18n-negotiation.test.ts +181 -0
  31. package/src/scenarios/interrupt-token-matrix.test.ts +2 -2
  32. package/src/scenarios/media-url-inline-cap.test.ts +5 -3
  33. package/src/scenarios/proposal-reviewable-learning.test.ts +129 -0
  34. package/src/scenarios/spec-corpus-validity.test.ts +107 -0
  35. package/src/scenarios/stream-text-fixture.test.ts +212 -0
  36. package/src/scenarios/version-fold.test.ts +193 -0
  37. package/src/scenarios/wasm-pack-memory-cap.test.ts +4 -2
  38. package/src/scenarios/webhook-tenant-isolation.test.ts +184 -0
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Connection-pack provider resolution — `connection-packs.md` §Manifest
3
+ * clauses 6 + 8 (RFC 0095 §B.6/§B.8) — behavioral.
4
+ *
5
+ * Capability-gated on `capabilities.connections.packsSupported: true`
6
+ * (soft-skips when unadvertised; hard-fails under
7
+ * `OPENWOP_REQUIRE_BEHAVIOR=true`). Drives the host through the
8
+ * `POST /v1/host/sample/connection-packs/{install,resolve}` test seams
9
+ * (`host-sample-test-seams.md`); hosts that haven't wired the seams
10
+ * soft-skip (404).
11
+ *
12
+ * 1. INSTALL + RESOLVE (§B.6) — installing the `connection-pack-github`
13
+ * fixture makes `provider: "github"` resolve with `source: "pack"`.
14
+ * 2. UNRESOLVED (§B.6) — a provider with no installed pack and no
15
+ * built-in fails with `connection_provider_unresolved`.
16
+ * 3. PRERELEASE CONFLICT (§B.6, SemVer §11) — an installed prerelease
17
+ * (`1.0.0-alpha.1`) does NOT outrank a built-in `1.0.0` (prerelease <
18
+ * release); the host MUST surface `connection_provider_conflict`
19
+ * rather than silently choosing.
20
+ * 4. REJECTION ISOLATION (§B.8) — a rejected manifest (credential
21
+ * material) means NOT INSTALLED, nothing more: a subsequent valid
22
+ * install on the same host MUST still succeed (one bad pack never
23
+ * takes down the install path).
24
+ *
25
+ * @see spec/v1/connection-packs.md
26
+ * @see spec/v1/host-sample-test-seams.md
27
+ * @see RFCS/0095-connection-packs-portable-provider-definitions.md
28
+ */
29
+
30
+ import { describe, it, expect } from 'vitest';
31
+ import { readFileSync } from 'node:fs';
32
+ import { join } from 'node:path';
33
+ import { FIXTURES_DIR } from '../lib/paths.js';
34
+ import { driver } from '../lib/driver.js';
35
+ import { behaviorGate } from '../lib/behavior-gate.js';
36
+ import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
37
+
38
+ const FIXTURE_PATH = join(FIXTURES_DIR, 'connection-packs', 'connection-pack-github.json');
39
+
40
+ type Manifest = Record<string, unknown> & {
41
+ provider: Record<string, unknown> & { id: string };
42
+ };
43
+
44
+ interface InstallResult {
45
+ installed?: boolean;
46
+ errors?: Array<{ code?: string; path?: string }>;
47
+ }
48
+
49
+ interface ResolveResult {
50
+ resolved?: boolean;
51
+ source?: 'pack' | 'builtin';
52
+ version?: string;
53
+ code?: string;
54
+ }
55
+
56
+ function fixture(): Manifest {
57
+ return JSON.parse(readFileSync(FIXTURE_PATH, 'utf8')) as Manifest;
58
+ }
59
+
60
+ async function gate(): Promise<boolean> {
61
+ const connections = await readCapabilityFamily<{ packsSupported?: boolean }>('connections');
62
+ return behaviorGate('connections.packsSupported', connections?.packsSupported === true);
63
+ }
64
+
65
+ describe('connection-provider-resolution (RFC 0095 §B.6/§B.8)', () => {
66
+ it('an installed pack resolves its provider id; an unknown provider is unresolved', async () => {
67
+ if (!(await gate())) return;
68
+
69
+ const install = await driver.post('/v1/host/sample/connection-packs/install', { manifest: fixture() });
70
+ if (install.status === 404 || install.status === 403) return; // seam unwired — soft-skip
71
+ const installed = install.json as InstallResult | undefined;
72
+ expect(
73
+ installed?.installed,
74
+ driver.describe('connection-packs.md §Manifest clause 1', 'a well-formed connection pack MUST install'),
75
+ ).toBe(true);
76
+
77
+ const hit = await driver.post('/v1/host/sample/connection-packs/resolve', { provider: 'github' });
78
+ const resolved = hit.json as ResolveResult | undefined;
79
+ expect(
80
+ resolved?.resolved,
81
+ driver.describe('connection-packs.md §Manifest clause 6', 'provider "github" MUST resolve against the installed pack whose provider.id matches'),
82
+ ).toBe(true);
83
+ expect(
84
+ resolved?.source,
85
+ driver.describe('connection-packs.md §Manifest clause 6', 'the installed pack is the resolution source'),
86
+ ).toBe('pack');
87
+
88
+ const miss = await driver.post('/v1/host/sample/connection-packs/resolve', {
89
+ provider: 'conformance-nonexistent-provider-xyz',
90
+ });
91
+ const unresolved = miss.json as ResolveResult | undefined;
92
+ expect(
93
+ unresolved?.resolved,
94
+ driver.describe('connection-packs.md §Manifest clause 6', 'a provider with no installed pack and no built-in MUST NOT resolve'),
95
+ ).toBe(false);
96
+ expect(
97
+ unresolved?.code,
98
+ driver.describe('connection-packs.md §Manifest clause 6', 'the refusal code MUST be connection_provider_unresolved'),
99
+ ).toBe('connection_provider_unresolved');
100
+ });
101
+
102
+ it('an installed prerelease does not outrank a built-in release — conflict surfaces (SemVer §11)', async () => {
103
+ if (!(await gate())) return;
104
+
105
+ const prerelease = { ...fixture(), version: '1.0.0-alpha.1' };
106
+ prerelease.provider = { ...prerelease.provider, id: 'conformance-prerelease-probe' };
107
+ const install = await driver.post('/v1/host/sample/connection-packs/install', { manifest: prerelease });
108
+ if (install.status === 404 || install.status === 403) return; // seam unwired — soft-skip
109
+ expect(
110
+ (install.json as InstallResult | undefined)?.installed,
111
+ driver.describe('connection-packs.md §Manifest clause 1', 'a prerelease-versioned pack is shape-valid and MUST install'),
112
+ ).toBe(true);
113
+
114
+ const res = await driver.post('/v1/host/sample/connection-packs/resolve', {
115
+ provider: 'conformance-prerelease-probe',
116
+ simulateBuiltinVersion: '1.0.0',
117
+ });
118
+ if (res.status === 404 || res.status === 403) return; // simulate knob unwired — soft-skip
119
+ const body = res.json as ResolveResult | undefined;
120
+ expect(
121
+ body?.code,
122
+ driver.describe(
123
+ 'connection-packs.md §Manifest clause 6',
124
+ 'SemVer §11: 1.0.0-alpha.1 < 1.0.0 — the installed pack is NOT greater-or-equal, so the host MUST surface connection_provider_conflict rather than silently choosing',
125
+ ),
126
+ ).toBe('connection_provider_conflict');
127
+ });
128
+
129
+ it('rejection isolation: one rejected pack never takes down the install path (§B.8)', async () => {
130
+ if (!(await gate())) return;
131
+
132
+ const leaky = fixture();
133
+ leaky.provider = { ...leaky.provider, id: 'conformance-isolation-probe' };
134
+ (leaky.provider.auth as Record<string, unknown>).clientSecret = 'ghs_conformance_canary';
135
+ const bad = await driver.post('/v1/host/sample/connection-packs/install', { manifest: leaky });
136
+ if (bad.status === 404 || bad.status === 403) return; // seam unwired — soft-skip
137
+ expect(
138
+ (bad.json as InstallResult | undefined)?.installed,
139
+ driver.describe('connection-packs.md §Manifest clause 2', 'the credential-carrying manifest MUST NOT install'),
140
+ ).toBe(false);
141
+
142
+ const good = { ...fixture() };
143
+ good.provider = { ...good.provider, id: 'conformance-isolation-survivor' };
144
+ const after = await driver.post('/v1/host/sample/connection-packs/install', { manifest: good });
145
+ expect(
146
+ (after.json as InstallResult | undefined)?.installed,
147
+ driver.describe(
148
+ 'connection-packs.md §Manifest clause 8',
149
+ 'a rejected pack means NOT INSTALLED — nothing more; a subsequent valid install MUST succeed',
150
+ ),
151
+ ).toBe(true);
152
+ });
153
+ });
@@ -35,10 +35,10 @@
35
35
 
36
36
  import { describe, it } from 'vitest';
37
37
 
38
- // Behavioral assertions in this file are currently `it.todo` placeholders;
38
+ // Behavioral assertions in this file are currently `it.skip` placeholders;
39
39
  // the cross-host MCP / A2A peer harness (gated on OPENWOP_MCP_REAL_SERVER_URL
40
40
  // / OPENWOP_A2A_REAL_PEER_URL) hasn't landed yet. When it does, the
41
- // `it.todo` calls flip back to runnable `it(...)` bodies that read discovery
41
+ // `it.skip` calls flip back to runnable `it(...)` bodies that read discovery
42
42
  // (via `driver.get('/.well-known/openwop')`), gate on `Phase 3` advertisement,
43
43
  // and drive the workflow through the configured real peer.
44
44
 
@@ -48,7 +48,7 @@ describe('cross-host-traceparent-propagation: behavioral (RFC 0040 §B)', () =>
48
48
  // OPENWOP_MCP_REAL_SERVER_URL) records inbound headers; the test reads
49
49
  // the recorded headers and asserts `traceparent` is present + matches
50
50
  // the format `00-{traceId}-{spanId}-{flags}` per W3C tracecontext.
51
- // Until the peer harness lands, the assertion is surfaced as `todo` so
51
+ // Until the peer harness lands, the assertion is surfaced as `it.skip` so
52
52
  // test reporters track the gap rather than reporting a vacuous PASS.
53
53
  // Marked out of stable profile via RFC 0042 §B (experimental tier):
54
54
  // RFC 0040 remains Active. Hosts that wire Phase 3 cross-host causation
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Agent-platform portability — export bundle + tenant import (RFC 0098;
3
+ * `portability.md`). Public test for the protocol-tier SECURITY invariant
4
+ * `export-bundle-no-credential-material`.
5
+ *
6
+ * Two layers:
7
+ *
8
+ * A. Always-on, server-free schema legs — the capability block (incl. the
9
+ * `import ⇒ dryRun` if/then), the `export-bundle.schema.json` shape (no
10
+ * credential-named field admitted), and the content-free `import.applied`
11
+ * event payload.
12
+ *
13
+ * B. Capability-gated behavioral legs — on a host advertising
14
+ * `capabilities.portability` that exposes the `/v1/host/sample/import`
15
+ * seam: a bundle carrying a literal credential value is rejected (422),
16
+ * and a dry-run import makes zero writes. Hosts without the seam soft-skip
17
+ * (404); unadvertised hosts skip via the behavior gate.
18
+ *
19
+ * @see spec/v1/portability.md
20
+ * @see SECURITY/invariants.yaml id: export-bundle-no-credential-material
21
+ * @see RFCS/0098-agent-platform-portability-export-bundle-and-import.md
22
+ */
23
+
24
+ import { describe, it, expect } from 'vitest';
25
+ import { readFileSync } from 'node:fs';
26
+ import { join } from 'node:path';
27
+ import Ajv2020 from 'ajv/dist/2020.js';
28
+ import addFormats from 'ajv-formats';
29
+ import { SCHEMAS_DIR } from '../lib/paths.js';
30
+ import { driver } from '../lib/driver.js';
31
+ import { behaviorGate } from '../lib/behavior-gate.js';
32
+ import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
33
+
34
+ const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
35
+ function loadSchema(name: string): Record<string, unknown> {
36
+ return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
37
+ }
38
+
39
+ const CRED_NAMES = ['clientSecret', 'client_secret', 'apiKey', 'api_key', 'accessToken', 'refreshToken', 'password', 'privateKey'] as const;
40
+
41
+ describe('export-bundle-portability: capability advertisement (RFC 0098 §A, server-free)', () => {
42
+ const caps = loadSchema('capabilities.schema.json');
43
+ const portability = (caps.properties as Record<string, { properties?: Record<string, unknown>; if?: unknown; then?: unknown }>).portability;
44
+
45
+ it('capabilities schema declares portability with its sub-flags + the import⇒dryRun if/then', () => {
46
+ expect(portability, why('capabilities.md §portability', 'portability MUST be declared')).toBeDefined();
47
+ for (const flag of ['export', 'import', 'kinds', 'dryRun']) {
48
+ expect(portability?.properties?.[flag], why('RFC 0098 §A', `portability.${flag} MUST be declared`)).toBeDefined();
49
+ }
50
+ expect(portability?.if, why('RFC 0098 §A', 'a JSON-Schema if/then MUST enforce dryRun:true when import:true')).toBeDefined();
51
+ expect(portability?.then, why('RFC 0098 §A', 'the then-branch MUST require dryRun')).toBeDefined();
52
+ });
53
+ });
54
+
55
+ describe('export-bundle-portability: ExportBundle shape (RFC 0098 §B, server-free)', () => {
56
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
57
+ addFormats(ajv);
58
+ const validate = ajv.compile(loadSchema('export-bundle.schema.json'));
59
+
60
+ const good = {
61
+ bundleVersion: '1',
62
+ source: { origin: 'https://host-a.example', exportedAt: '2026-06-13T00:00:00Z' },
63
+ items: [
64
+ { kind: 'prompt-template', ref: 'tpl-1', payload: { templateId: 'welcome', version: '1.0.0' } },
65
+ { kind: 'connection-ref', ref: 'conn-1', dependsOn: ['tpl-1'], payload: { provider: 'slack', credentialRef: 'cred:abc' } },
66
+ ],
67
+ };
68
+
69
+ it('validates a conforming bundle with refs only', () => {
70
+ expect(validate(good), why('RFC 0098 §B', `a conforming bundle MUST validate. Errors: ${JSON.stringify(validate.errors)}`)).toBe(true);
71
+ });
72
+
73
+ it('rejects a wrong bundleVersion, an unknown kind, and a missing item ref', () => {
74
+ expect(validate({ ...good, bundleVersion: '2' }), why('RFC 0098 §B', 'an unsupported bundleVersion MUST be rejected')).toBe(false);
75
+ expect(validate({ ...good, items: [{ kind: 'mystery', ref: 'x', payload: {} }] }), why('RFC 0098 §B', 'an unknown item kind MUST be rejected')).toBe(false);
76
+ expect(validate({ ...good, items: [{ kind: 'agent', payload: {} }] }), why('RFC 0098 §B', 'an item without a ref MUST be rejected')).toBe(false);
77
+ });
78
+
79
+ it('the bundle envelope admits no credential-named field at the root or source level (additionalProperties:false)', () => {
80
+ for (const name of CRED_NAMES) {
81
+ expect(validate({ ...good, [name]: 'xxx' }), why('SECURITY invariant export-bundle-no-credential-material', `a "${name}" field at the bundle root MUST NOT validate`)).toBe(false);
82
+ expect(validate({ ...good, source: { ...good.source, [name]: 'xxx' } }), why('SECURITY invariant export-bundle-no-credential-material', `a "${name}" field under source MUST NOT validate`)).toBe(false);
83
+ }
84
+ });
85
+ });
86
+
87
+ describe('export-bundle-portability: content-free event (RFC 0098 §D, server-free)', () => {
88
+ const runEvent = loadSchema('run-event.schema.json');
89
+ const payloads = loadSchema('run-event-payloads.schema.json');
90
+ const ajv = new Ajv2020({ strict: false, allErrors: true });
91
+ addFormats(ajv);
92
+ ajv.addSchema(payloads, 'payloads');
93
+
94
+ it('import.applied is in the RunEventType enum and is content-free (counts + refs only)', () => {
95
+ const en = (runEvent.$defs as Record<string, { enum?: string[] }>).RunEventType?.enum ?? [];
96
+ expect(en).toContain('import.applied');
97
+ const applied = ajv.getSchema('payloads#/$defs/importApplied')!;
98
+ expect(applied({ bundleOrigin: 'https://host-a.example', counts: { created: 2, skipped: 1 }, secretsToRebind: ['anthropic'] }), why('RFC 0098 §D', 'a content-free import.applied MUST validate')).toBe(true);
99
+ expect(applied({ bundleOrigin: 'h', counts: { created: 1 }, items: [{ payload: {} }] }), why('SECURITY invariant export-bundle-no-credential-material', 'import.applied MUST NOT carry item payloads')).toBe(false);
100
+ });
101
+ });
102
+
103
+ describe('export-bundle-portability: behavioral (RFC 0098 §E, capability-gated)', () => {
104
+ it('importing a bundle with a literal credential value is rejected (422)', async () => {
105
+ const portability = await readCapabilityFamily<{ import?: boolean }>('portability');
106
+ if (!behaviorGate('portability', portability !== undefined && portability.import === true)) return;
107
+
108
+ const leaky = {
109
+ bundleVersion: '1',
110
+ source: { origin: 'adapter:conformance' },
111
+ items: [{ kind: 'connection-ref', ref: 'c1', payload: { provider: 'anthropic', apiKey: 'sk-conformance-canary' } }],
112
+ };
113
+ const res = await driver.post('/v1/host/sample/import?dryRun=true', { bundle: leaky });
114
+ if (res.status === 404 || res.status === 403) return; // seam unwired — soft-skip
115
+ expect(
116
+ res.status,
117
+ driver.describe('portability.md §Invariants clause 1', 'a bundle carrying a literal credential value MUST be rejected (422)'),
118
+ ).toBe(422);
119
+ });
120
+ });
@@ -154,6 +154,40 @@ describe('fixtures: node-pack-manifest schema validity', () => {
154
154
  });
155
155
  });
156
156
 
157
+ describe('fixtures: connection-pack-manifest schema validity', () => {
158
+ // Connection-pack fixtures live in `fixtures/connection-packs/` (RFC 0095)
159
+ // so the node-pack validator above doesn't apply the wrong schema. They are
160
+ // schema-level proof points AND the canonical install payloads for the
161
+ // capability-gated `connection-provider-resolution` behavioral scenario.
162
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
163
+ addFormats(ajv);
164
+ const schema = JSON.parse(readFileSync(join(SCHEMAS_DIR, 'connection-pack-manifest.schema.json'), 'utf8'));
165
+ const validate = ajv.compile(schema);
166
+
167
+ const dir = join(FIXTURES_DIR, 'connection-packs');
168
+ const files = readdirSync(dir)
169
+ .filter((f) => f.endsWith('.json'))
170
+ .sort();
171
+
172
+ it('finds at least one connection-pack fixture (RFC 0095 coverage)', () => {
173
+ expect(files.length).toBeGreaterThan(0);
174
+ });
175
+
176
+ for (const file of files) {
177
+ it(`connection-packs/${file} validates against connection-pack-manifest.schema.json`, () => {
178
+ const data = JSON.parse(readFileSync(join(dir, file), 'utf8'));
179
+ const ok = validate(data);
180
+ const errors = (validate.errors ?? [])
181
+ .map((e: ErrorObject) => `${e.instancePath || '/'}: ${e.message}`)
182
+ .join('\n');
183
+ expect(
184
+ ok,
185
+ `Fixture connection-packs/${file} fails connection-pack-manifest schema:\n${errors}`,
186
+ ).toBe(true);
187
+ });
188
+ }
189
+ });
190
+
157
191
  describe('fixtures: prompt-template schema validity', () => {
158
192
  // PromptTemplate fixtures live in `fixtures/prompt-templates/` per
159
193
  // RFC 0027 §A. Like pack manifests, they're schema-level proof points,
@@ -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,108 @@
1
+ /**
2
+ * grpc-transport — `spec/v1/grpc-transport.md` advertisement-shape scenario
3
+ * (capability-gated on `capabilities.grpc`; the schema block lands with
4
+ * RFC 0094 §H on this branch).
5
+ *
6
+ * SHAPE-ONLY by design: this scenario validates the advertised
7
+ * `capabilities.grpc` block against the doc's MUSTs without dialing gRPC
8
+ * (the suite ships no gRPC client — see grpc-transport.md §Implementation
9
+ * notes). The end-to-end legs (GetCapabilities equivalence, run
10
+ * round-trip, error-envelope mapping, auth metadata) are listed in
11
+ * grpc-transport.md §Conformance and stay future work for a host-side
12
+ * gRPC harness.
13
+ *
14
+ * Gated via `behaviorGate('openwop-grpc-transport', grpc !== undefined)`
15
+ * — soft-skip when the block is absent (absent ⇒ no gRPC transport,
16
+ * which is conformant), hard-fail under `OPENWOP_REQUIRE_BEHAVIOR=true`
17
+ * unless honestly opted out (root-first family read per RFC 0073).
18
+ *
19
+ * Asserted MUSTs (grpc-transport.md §"Field semantics"):
20
+ * - `supported` is a boolean;
21
+ * - `service` is the canonical `openwop.v1.Engine` for v1;
22
+ * - `tls` ∈ {"required", "optional", "disabled"};
23
+ * - `endpoint`, when present, is a `grpc://` or `grpcs://` URI;
24
+ * - when `supported: true`, root `supportedTransports` includes
25
+ * `"grpc"` ("presence required when gRPC is exposed");
26
+ * - a production-profile claimant (root `production` block advertised)
27
+ * MUST set `tls: "required"`.
28
+ *
29
+ * @see spec/v1/grpc-transport.md §"Field semantics" + §Conformance
30
+ * @see RFCS/0094-wire-shape-reconciliation.md §H
31
+ */
32
+
33
+ import { describe, it, expect } from 'vitest';
34
+ import { driver } from '../lib/driver.js';
35
+ import { behaviorGate } from '../lib/behavior-gate.js';
36
+ import { capabilityFamily } from '../lib/discovery-capabilities.js';
37
+
38
+ const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
39
+
40
+ const TLS_VALUES = ['required', 'optional', 'disabled'];
41
+ const V1_SERVICE = 'openwop.v1.Engine';
42
+
43
+ interface GrpcCap {
44
+ readonly supported?: unknown;
45
+ readonly endpoint?: unknown;
46
+ readonly service?: unknown;
47
+ readonly tls?: unknown;
48
+ }
49
+
50
+ describe.skipIf(HTTP_SKIP)('grpc-transport: advertisement shape (grpc-transport.md §Field semantics)', () => {
51
+ it('an advertised capabilities.grpc block satisfies every doc MUST', async () => {
52
+ const res = await driver.get('/.well-known/openwop');
53
+ if (res.status !== 200) return;
54
+ const grpc = capabilityFamily<GrpcCap>(res.json, 'grpc');
55
+ if (!behaviorGate('openwop-grpc-transport', grpc !== undefined)) return;
56
+
57
+ expect(
58
+ typeof grpc!.supported,
59
+ driver.describe('grpc-transport.md §Field semantics', 'capabilities.grpc.supported MUST be a boolean'),
60
+ ).toBe('boolean');
61
+
62
+ expect(
63
+ grpc!.service,
64
+ driver.describe('grpc-transport.md §Field semantics', 'v1 hosts MUST use the canonical service name openwop.v1.Engine'),
65
+ ).toBe(V1_SERVICE);
66
+
67
+ expect(
68
+ TLS_VALUES,
69
+ driver.describe(
70
+ 'grpc-transport.md §Field semantics',
71
+ `capabilities.grpc.tls MUST be one of ${TLS_VALUES.join(' / ')} (got ${String(grpc!.tls)})`,
72
+ ),
73
+ ).toContain(grpc!.tls);
74
+
75
+ if (grpc!.endpoint !== undefined) {
76
+ expect(
77
+ typeof grpc!.endpoint === 'string' && /^grpcs?:\/\/.+/.test(grpc!.endpoint),
78
+ driver.describe(
79
+ 'grpc-transport.md §Field semantics',
80
+ `capabilities.grpc.endpoint MUST be a grpc:// (cleartext) or grpcs:// (TLS) URI (got ${String(grpc!.endpoint)})`,
81
+ ),
82
+ ).toBe(true);
83
+ }
84
+
85
+ if (grpc!.supported === true) {
86
+ const transports = capabilityFamily<unknown>(res.json, 'supportedTransports');
87
+ expect(
88
+ Array.isArray(transports) && transports.includes('grpc'),
89
+ driver.describe(
90
+ 'grpc-transport.md §Field semantics',
91
+ 'supportedTransports MUST include "grpc" when the gRPC surface is exposed',
92
+ ),
93
+ ).toBe(true);
94
+ }
95
+
96
+ // Production-profile claimants MUST require TLS on the gRPC listener.
97
+ const production = capabilityFamily<unknown>(res.json, 'production');
98
+ if (production !== undefined && grpc!.supported === true) {
99
+ expect(
100
+ grpc!.tls,
101
+ driver.describe(
102
+ 'grpc-transport.md §Field semantics',
103
+ 'production hosts MUST set capabilities.grpc.tls: "required"',
104
+ ),
105
+ ).toBe('required');
106
+ }
107
+ });
108
+ });