@openwop/openwop-conformance 1.25.0 → 1.28.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.
- package/CHANGELOG.md +34 -0
- package/README.md +2 -2
- package/api/openapi.yaml +215 -0
- package/coverage.md +11 -0
- package/package.json +1 -1
- package/schemas/README.md +5 -0
- package/schemas/capabilities.schema.json +49 -0
- package/schemas/envelopes/ui.a2ui-surface.schema.json +154 -0
- package/schemas/localized-content-language-settings.schema.json +26 -0
- package/schemas/localized-content-page-response.schema.json +60 -0
- package/schemas/localized-content-page.schema.json +62 -0
- package/schemas/localized-content-section.schema.json +51 -0
- package/schemas/suspend-request.schema.json +20 -0
- package/src/scenarios/a2ui-surface-degrades.test.ts +54 -0
- package/src/scenarios/a2ui-surface-replay.test.ts +84 -0
- package/src/scenarios/a2ui-surface-shape.test.ts +162 -0
- package/src/scenarios/a2ui-surface-version-refusal.test.ts +77 -0
- package/src/scenarios/a2ui-untrusted-blocks-approval.test.ts +68 -0
- package/src/scenarios/i18n-negotiation.test.ts +30 -3
- package/src/scenarios/interrupt-approver-routing.test.ts +166 -0
- package/src/scenarios/localized-content-delivery.test.ts +221 -0
- package/src/scenarios/spec-corpus-validity.test.ts +1 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* a2ui-surface-shape — RFC 0102 §A wire-shape conformance (server-free).
|
|
3
|
+
*
|
|
4
|
+
* Asserts (always-on, Ajv2020, no host required):
|
|
5
|
+
* 1. `schemas/envelopes/ui.a2ui-surface.schema.json` compiles under Ajv2020.
|
|
6
|
+
* 2. A positive surface payload (closed catalog components, advertised
|
|
7
|
+
* `catalogVersion`, a confined resume/exchange action) validates.
|
|
8
|
+
* 3. Negatives fail per the closed schema:
|
|
9
|
+
* - missing required `catalogVersion` / `surface`
|
|
10
|
+
* - an out-of-catalog component object (additionalProperties:false)
|
|
11
|
+
* - a component carrying an extra/script-bearing property
|
|
12
|
+
* - a `catalogVersion` the schema does not enumerate
|
|
13
|
+
* - an `action.button` whose `action.target` is outside
|
|
14
|
+
* `enum["resume","exchange"]` (the structural half of
|
|
15
|
+
* `a2ui-action-confinement`), or carries an arbitrary URL field.
|
|
16
|
+
*
|
|
17
|
+
* The closed `anyOf` `surface` shape is the enabling precondition for the
|
|
18
|
+
* render-side invariants `a2ui-surface-no-code-exec` /
|
|
19
|
+
* `a2ui-surface-no-network-egress` (reference-app probes): a surface that
|
|
20
|
+
* smuggles a script-bearing field or an unconfined action target fails
|
|
21
|
+
* validation before it can reach a renderer.
|
|
22
|
+
*
|
|
23
|
+
* @see RFCS/0102-a2ui-agent-authored-interface-surfaces.md §A
|
|
24
|
+
* @see spec/v1/ai-envelope.md §"A2UI surfaces"
|
|
25
|
+
* @see schemas/envelopes/ui.a2ui-surface.schema.json
|
|
26
|
+
* @see SECURITY/invariants.yaml (a2ui-action-confinement)
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { describe, it, expect } from 'vitest';
|
|
30
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
31
|
+
import { readFileSync } from 'node:fs';
|
|
32
|
+
import { join } from 'node:path';
|
|
33
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
34
|
+
|
|
35
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
36
|
+
const schema = JSON.parse(
|
|
37
|
+
readFileSync(join(SCHEMAS_DIR, 'envelopes/ui.a2ui-surface.schema.json'), 'utf8'),
|
|
38
|
+
) as Record<string, unknown>;
|
|
39
|
+
|
|
40
|
+
function positivePayload(): Record<string, unknown> {
|
|
41
|
+
return {
|
|
42
|
+
catalogVersion: '0.9.1',
|
|
43
|
+
surface: {
|
|
44
|
+
title: 'Schedule the kickoff',
|
|
45
|
+
components: [
|
|
46
|
+
{ component: 'heading', text: 'Kickoff', level: 2 },
|
|
47
|
+
{ component: 'text', text: 'Pick a date and confirm.' },
|
|
48
|
+
{ component: 'field.text', id: 'name', label: 'Your name', required: true },
|
|
49
|
+
{ component: 'field.date', id: 'when', label: 'Date' },
|
|
50
|
+
{
|
|
51
|
+
component: 'field.select',
|
|
52
|
+
id: 'room',
|
|
53
|
+
label: 'Room',
|
|
54
|
+
options: [
|
|
55
|
+
{ value: 'a', label: 'Room A' },
|
|
56
|
+
{ value: 'b', label: 'Room B' },
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
{ component: 'field.checkbox', id: 'remind', label: 'Remind me', default: true },
|
|
60
|
+
{ component: 'action.button', id: 'go', label: 'Confirm', action: { target: 'resume' } },
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
reasoning: 'Collect the kickoff details.',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('a2ui-surface-shape: schema compile + round-trip (RFC 0102 §A)', () => {
|
|
68
|
+
const validate = ajv.compile(schema);
|
|
69
|
+
|
|
70
|
+
it('ui.a2ui-surface.schema.json compiles under Ajv2020', () => {
|
|
71
|
+
expect(
|
|
72
|
+
validate,
|
|
73
|
+
'RFC 0102 §A: the core ui.a2ui-surface payload schema MUST compile',
|
|
74
|
+
).toBeTypeOf('function');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('accepts a positive surface payload (closed catalog + confined action)', () => {
|
|
78
|
+
const ok = validate(positivePayload());
|
|
79
|
+
expect(
|
|
80
|
+
ok,
|
|
81
|
+
`RFC 0102 §A: positive surface MUST validate; errors: ${JSON.stringify(validate.errors)}`,
|
|
82
|
+
).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('accepts an exchange-targeted action (the other confined target)', () => {
|
|
86
|
+
const p = positivePayload();
|
|
87
|
+
(p.surface as { components: Array<Record<string, unknown>> }).components = [
|
|
88
|
+
{ component: 'action.button', id: 'send', label: 'Send', action: { target: 'exchange' } },
|
|
89
|
+
];
|
|
90
|
+
expect(validate(p)).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('rejects a payload missing required `catalogVersion`', () => {
|
|
94
|
+
const p = positivePayload();
|
|
95
|
+
delete p.catalogVersion;
|
|
96
|
+
expect(validate(p), 'RFC 0102 §A: `catalogVersion` is REQUIRED').toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('rejects a payload missing required `surface`', () => {
|
|
100
|
+
const p = positivePayload();
|
|
101
|
+
delete p.surface;
|
|
102
|
+
expect(validate(p), 'RFC 0102 §A: `surface` is REQUIRED').toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('rejects a `catalogVersion` the schema does not enumerate (unknown version)', () => {
|
|
106
|
+
const p = positivePayload();
|
|
107
|
+
p.catalogVersion = '9.9.9';
|
|
108
|
+
expect(
|
|
109
|
+
validate(p),
|
|
110
|
+
'RFC 0102 §A.3: an unadvertised catalogVersion MUST fail the enumerated set',
|
|
111
|
+
).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('rejects an out-of-catalog component object (additionalProperties:false)', () => {
|
|
115
|
+
const p = positivePayload();
|
|
116
|
+
(p.surface as { components: Array<Record<string, unknown>> }).components = [
|
|
117
|
+
{ component: 'iframe', src: 'https://evil.example/x' } as Record<string, unknown>,
|
|
118
|
+
];
|
|
119
|
+
expect(
|
|
120
|
+
validate(p),
|
|
121
|
+
'RFC 0102 §A.1: an out-of-catalog component MUST fail the closed anyOf',
|
|
122
|
+
).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('rejects a known component carrying an extra (script-bearing) property', () => {
|
|
126
|
+
const p = positivePayload();
|
|
127
|
+
(p.surface as { components: Array<Record<string, unknown>> }).components = [
|
|
128
|
+
{ component: 'heading', text: 'Hi', onClick: "fetch('https://evil')" } as Record<string, unknown>,
|
|
129
|
+
];
|
|
130
|
+
expect(
|
|
131
|
+
validate(p),
|
|
132
|
+
'RFC 0102 §A.1: additionalProperties:false MUST reject a smuggled code field',
|
|
133
|
+
).toBe(false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('rejects an action whose target is outside enum["resume","exchange"] (action confinement)', () => {
|
|
137
|
+
const p = positivePayload();
|
|
138
|
+
(p.surface as { components: Array<Record<string, unknown>> }).components = [
|
|
139
|
+
{ component: 'action.button', id: 'x', label: 'X', action: { target: 'http://evil.example' } },
|
|
140
|
+
];
|
|
141
|
+
expect(
|
|
142
|
+
validate(p),
|
|
143
|
+
'RFC 0102 §A.4 / SECURITY a2ui-action-confinement: an action target outside resume/exchange MUST be rejected',
|
|
144
|
+
).toBe(false);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('rejects an action carrying an arbitrary URL field (no unconfined egress target)', () => {
|
|
148
|
+
const p = positivePayload();
|
|
149
|
+
(p.surface as { components: Array<Record<string, unknown>> }).components = [
|
|
150
|
+
{
|
|
151
|
+
component: 'action.button',
|
|
152
|
+
id: 'x',
|
|
153
|
+
label: 'X',
|
|
154
|
+
action: { target: 'resume', url: 'https://evil.example/exfil' } as Record<string, unknown>,
|
|
155
|
+
},
|
|
156
|
+
];
|
|
157
|
+
expect(
|
|
158
|
+
validate(p),
|
|
159
|
+
'RFC 0102 §A.4: action additionalProperties:false MUST reject an arbitrary URL target',
|
|
160
|
+
).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* a2ui-surface-version-refusal — RFC 0102 §A.3 (C3): catalog version is a
|
|
3
|
+
* host-enumerated set, not a free string. A `catalogVersion` the host does
|
|
4
|
+
* not advertise MUST be refused with `unknown_schema_version`
|
|
5
|
+
* (ai-envelope.md §"Schema version advertisement"), and the stored surface
|
|
6
|
+
* MUST be self-contained for deterministic `:fork`/replay.
|
|
7
|
+
*
|
|
8
|
+
* Always-on (server-free): the core schema enumerates `catalogVersion`
|
|
9
|
+
* (closed set), so a non-advertised version fails validation; the schema
|
|
10
|
+
* carries no external `$ref` (surface is self-contained).
|
|
11
|
+
* Capability-gated (HTTP): a live host refuses an unadvertised version.
|
|
12
|
+
*
|
|
13
|
+
* @see RFCS/0102-a2ui-agent-authored-interface-surfaces.md §A.3
|
|
14
|
+
* @see spec/v1/ai-envelope.md §"A2UI surfaces", §"Schema version advertisement"
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect } from 'vitest';
|
|
18
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { driver } from '../lib/driver.js';
|
|
22
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
23
|
+
|
|
24
|
+
const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
|
|
25
|
+
const schema = JSON.parse(
|
|
26
|
+
readFileSync(join(SCHEMAS_DIR, 'envelopes/ui.a2ui-surface.schema.json'), 'utf8'),
|
|
27
|
+
) as Record<string, unknown>;
|
|
28
|
+
|
|
29
|
+
function hasAbsoluteRef(node: unknown): boolean {
|
|
30
|
+
if (!node || typeof node !== 'object') return false;
|
|
31
|
+
if (Array.isArray(node)) return node.some(hasAbsoluteRef);
|
|
32
|
+
const obj = node as Record<string, unknown>;
|
|
33
|
+
if (typeof obj['$ref'] === 'string' && !(obj['$ref'] as string).startsWith('#')) return true;
|
|
34
|
+
return Object.values(obj).some(hasAbsoluteRef);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('a2ui-surface-version-refusal: enumerated catalogVersion (RFC 0102 §A.3)', () => {
|
|
38
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
39
|
+
const validate = ajv.compile(schema);
|
|
40
|
+
|
|
41
|
+
it('catalogVersion is a closed enum — an unadvertised version fails validation', () => {
|
|
42
|
+
const ok = validate({ catalogVersion: '9.9.9', surface: { components: [] } });
|
|
43
|
+
expect(
|
|
44
|
+
ok,
|
|
45
|
+
'RFC 0102 §A.3: a catalogVersion outside the host-enumerated set MUST fail (→ unknown_schema_version at runtime)',
|
|
46
|
+
).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('surface payload is self-contained — no external $ref (replay determinism)', () => {
|
|
50
|
+
expect(
|
|
51
|
+
hasAbsoluteRef(schema),
|
|
52
|
+
'RFC 0102 §A.3: the surface MUST be self-contained, never a live external-catalog reference',
|
|
53
|
+
).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe.skipIf(HTTP_SKIP)('a2ui-surface-version-refusal: live host refuses unadvertised version (RFC 0102 §A.3)', () => {
|
|
58
|
+
it('ui.a2ui-surface with an unadvertised catalogVersion → refused', async () => {
|
|
59
|
+
const res = await driver.post('/v1/host/sample/envelope/accept', {
|
|
60
|
+
envelope: {
|
|
61
|
+
type: 'ui.a2ui-surface',
|
|
62
|
+
schemaVersion: 1,
|
|
63
|
+
envelopeId: 'env-a2ui-ver-1',
|
|
64
|
+
correlationId: 'run-a2ui:node-1:turn-0:ver',
|
|
65
|
+
payload: { catalogVersion: '9.9.9', surface: { components: [] } },
|
|
66
|
+
meta: { source: 'ai-generation', ts: '2026-06-15T10:00:00Z' },
|
|
67
|
+
},
|
|
68
|
+
hostSupportedEnvelopes: ['ui.a2ui-surface'],
|
|
69
|
+
});
|
|
70
|
+
if (res.status === 404) return; // seam absent — soft-skip
|
|
71
|
+
const body = res.json as { status?: string; reason?: string };
|
|
72
|
+
expect(
|
|
73
|
+
body.status === 'invalid' || body.status === 'refused',
|
|
74
|
+
driver.describe('RFC 0102 §A.3', 'an unadvertised catalogVersion MUST be refused'),
|
|
75
|
+
).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* a2ui-untrusted-blocks-approval — RFC 0102 §A.5: an untrusted-authored
|
|
3
|
+
* surface MUST NOT advance an approval gate.
|
|
4
|
+
*
|
|
5
|
+
* A `ui.a2ui-surface` emitted by a node that consumed untrusted MCP/A2A
|
|
6
|
+
* content carries `meta.contentTrust: 'untrusted'`; the existing
|
|
7
|
+
* `untrusted_content_blocks_approval` rule then blocks that surface from
|
|
8
|
+
* advancing an `approval` interrupt. This is a composition of the existing
|
|
9
|
+
* untrusted-content rule for the new kind, not a new taint primitive.
|
|
10
|
+
*
|
|
11
|
+
* Always-on (server-free): `ui.a2ui-surface` is an advertised (non-universal)
|
|
12
|
+
* kind, so the envelope trust-boundary machinery (`meta.contentTrust`) applies
|
|
13
|
+
* to it exactly as to any other envelope — the precondition for the block.
|
|
14
|
+
* Capability-gated (HTTP): an untrusted surface bound to an approval interrupt
|
|
15
|
+
* is refused.
|
|
16
|
+
*
|
|
17
|
+
* @see RFCS/0102-a2ui-agent-authored-interface-surfaces.md §A.5
|
|
18
|
+
* @see spec/v1/ai-envelope.md §"A2UI surfaces", §"Trust boundary"
|
|
19
|
+
* @see SECURITY/invariants.yaml (a2ui-untrusted-blocks-approval, untrusted_content_blocks_approval)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, it, expect } from 'vitest';
|
|
23
|
+
import { driver } from '../lib/driver.js';
|
|
24
|
+
|
|
25
|
+
const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
|
|
26
|
+
const UNIVERSAL_KINDS = ['clarification.request', 'schema.request', 'schema.response', 'error'];
|
|
27
|
+
|
|
28
|
+
describe('a2ui-untrusted-blocks-approval: trust machinery applies (RFC 0102 §A.5)', () => {
|
|
29
|
+
it('ui.a2ui-surface is an advertised kind subject to meta.contentTrust gating (not universal/always-allowed)', () => {
|
|
30
|
+
expect(
|
|
31
|
+
UNIVERSAL_KINDS.includes('ui.a2ui-surface'),
|
|
32
|
+
'ai-envelope.md §"A2UI surfaces" / §"Trust boundary": a ui.a2ui-surface is trust-gated like any advertised envelope, so an untrusted surface is subject to untrusted_content_blocks_approval',
|
|
33
|
+
).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe.skipIf(HTTP_SKIP)('a2ui-untrusted-blocks-approval: untrusted surface cannot drive an approval (RFC 0102 §A.5)', () => {
|
|
38
|
+
it('an untrusted-marked ui.a2ui-surface bound to an approval interrupt is refused', async () => {
|
|
39
|
+
const res = await driver.post('/v1/host/sample/envelope/accept', {
|
|
40
|
+
envelope: {
|
|
41
|
+
type: 'ui.a2ui-surface',
|
|
42
|
+
schemaVersion: 1,
|
|
43
|
+
envelopeId: 'env-a2ui-untrusted-1',
|
|
44
|
+
correlationId: 'run-a2ui:node-1:turn-0:unt',
|
|
45
|
+
payload: {
|
|
46
|
+
catalogVersion: '0.9.1',
|
|
47
|
+
surface: {
|
|
48
|
+
components: [
|
|
49
|
+
{ component: 'action.button', id: 'approve', label: 'Approve', action: { target: 'resume' } },
|
|
50
|
+
],
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
meta: { source: 'ai-generation', ts: '2026-06-15T10:00:00Z', contentTrust: 'untrusted' },
|
|
54
|
+
},
|
|
55
|
+
hostSupportedEnvelopes: ['ui.a2ui-surface'],
|
|
56
|
+
boundInterruptKind: 'approval',
|
|
57
|
+
});
|
|
58
|
+
if (res.status === 404) return; // seam absent — soft-skip
|
|
59
|
+
const body = res.json as { status?: string; reason?: string };
|
|
60
|
+
expect(
|
|
61
|
+
body.status === 'blocked' || (body.reason ?? '').includes('untrusted'),
|
|
62
|
+
driver.describe(
|
|
63
|
+
'RFC 0102 §A.5',
|
|
64
|
+
'an untrusted ui.a2ui-surface MUST NOT advance an approval interrupt (untrusted_content_blocks_approval)',
|
|
65
|
+
),
|
|
66
|
+
).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -166,16 +166,43 @@ describe.skipIf(HTTP_SKIP)('i18n-negotiation: behavioral (i18n.md §Accept-Langu
|
|
|
166
166
|
// Prefer a non-default supported locale so localization actually engages.
|
|
167
167
|
const negotiated = supported.find((t) => t !== defaultLocale) ?? defaultLocale;
|
|
168
168
|
|
|
169
|
+
// Baseline: the same probe under the host's DEFAULT locale. The i18n MUST is
|
|
170
|
+
// locale-INVARIANCE of the machine-readable code, not a specific vocabulary
|
|
171
|
+
// value. i18n.md §"`locale` field on `ErrorEnvelope.details`" requires the
|
|
172
|
+
// `error` code to "remain English / lowercase / underscore-cased regardless
|
|
173
|
+
// of locale" — it is an identifier, not human text. The spec does NOT pin a
|
|
174
|
+
// missing run to the literal `not_found`: error-envelope.schema.json says
|
|
175
|
+
// codes are SHOULD-snake_case, rest-endpoints.md §"Common error codes" is
|
|
176
|
+
// non-exhaustive, and `run_forbidden` (RFC 0048) sets precedent that hosts
|
|
177
|
+
// MAY use run-specific codes. Asserting a literal here would also pressure a
|
|
178
|
+
// conformant host into a COMPATIBILITY.md §2.2 breaking error-code change.
|
|
179
|
+
const baseRes = await driver.get(PROBE_PATH, {
|
|
180
|
+
headers: { 'Accept-Language': defaultLocale },
|
|
181
|
+
});
|
|
182
|
+
const baseCode = errCode(baseRes.json);
|
|
183
|
+
|
|
169
184
|
const res = await driver.get(PROBE_PATH, {
|
|
170
185
|
headers: { 'Accept-Language': negotiated },
|
|
171
186
|
});
|
|
172
187
|
expect(res.status).toBe(404);
|
|
188
|
+
const negotiatedCode = errCode(res.json);
|
|
189
|
+
|
|
190
|
+
// (a) the code is an English snake_case identifier (not localized prose)
|
|
191
|
+
expect(
|
|
192
|
+
typeof negotiatedCode === 'string' && /^[a-z][a-z0-9_]*$/.test(negotiatedCode),
|
|
193
|
+
driver.describe(
|
|
194
|
+
'i18n.md §"`locale` field on `ErrorEnvelope.details`"',
|
|
195
|
+
`the machine-readable error code MUST be an English lowercase snake_case identifier, not localized text (got ${String(negotiatedCode)} under ${negotiated})`,
|
|
196
|
+
),
|
|
197
|
+
).toBe(true);
|
|
198
|
+
|
|
199
|
+
// (b) it is byte-identical to the default-locale code — the actual invariance MUST
|
|
173
200
|
expect(
|
|
174
|
-
|
|
201
|
+
negotiatedCode,
|
|
175
202
|
driver.describe(
|
|
176
203
|
'i18n.md §"`locale` field on `ErrorEnvelope.details`"',
|
|
177
|
-
`the machine-readable error code MUST remain
|
|
204
|
+
`the machine-readable error code MUST remain identical regardless of the negotiated locale (default ${defaultLocale} → ${String(baseCode)}; negotiated ${negotiated})`,
|
|
178
205
|
),
|
|
179
|
-
).toBe(
|
|
206
|
+
).toBe(baseCode);
|
|
180
207
|
});
|
|
181
208
|
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portable HITL approver routing (RFC 0104; `spec/v1/interrupt.md` §`kind: "approval"`).
|
|
3
|
+
*
|
|
4
|
+
* Adds three OPTIONAL, ADVISORY fields to the approval `InterruptPayload` so
|
|
5
|
+
* group/role approver routing is portable + capability-gated across hosts:
|
|
6
|
+
* `approverGroupRefs`, `approverRoleRefs`, and an `audience` notification hint.
|
|
7
|
+
* `approversList` advisory semantics are unchanged; enforcement stays host-side;
|
|
8
|
+
* refs are opaque to the engine and decision-time-snapshotted for replay.
|
|
9
|
+
*
|
|
10
|
+
* Two layers:
|
|
11
|
+
*
|
|
12
|
+
* A. Always-on, server-free legs — the `interrupt.approverRouting` capability
|
|
13
|
+
* block shape, the additive optionality of the three fields on the
|
|
14
|
+
* `ApprovalData` schema, and the §"Portable approver routing" reference
|
|
15
|
+
* rule that `audience` DEFAULTS to the resolved eligibility union when
|
|
16
|
+
* omitted and OVERRIDES it when present (`notifyTargets`).
|
|
17
|
+
*
|
|
18
|
+
* B. Capability-gated advertisement-coherence leg — on a host advertising
|
|
19
|
+
* `capabilities.interrupt.approverRouting.supported`, the advertised shape
|
|
20
|
+
* MUST be honest: `refKinds` ⊆ {group, role}; `audience` boolean. Hosts
|
|
21
|
+
* that do not advertise the capability soft-skip via the gate (the fields
|
|
22
|
+
* are ignored and the host stays conformant).
|
|
23
|
+
*
|
|
24
|
+
* @see spec/v1/interrupt.md §"Portable approver routing (RFC 0104)"
|
|
25
|
+
* @see spec/v1/capabilities.md — interrupt.approverRouting.*
|
|
26
|
+
* @see schemas/suspend-request.schema.json — ApprovalData
|
|
27
|
+
* @see RFCS/0104-hitl-approver-routing.md
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { describe, it, expect } from 'vitest';
|
|
31
|
+
import { readFileSync } from 'node:fs';
|
|
32
|
+
import { join } from 'node:path';
|
|
33
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
34
|
+
import addFormats from 'ajv-formats';
|
|
35
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
36
|
+
import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
|
|
37
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
38
|
+
|
|
39
|
+
const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
|
|
40
|
+
function loadSchema(name: string): Record<string, unknown> {
|
|
41
|
+
return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ApproverRoutingCap {
|
|
45
|
+
supported?: boolean;
|
|
46
|
+
refKinds?: string[];
|
|
47
|
+
audience?: boolean;
|
|
48
|
+
}
|
|
49
|
+
interface InterruptCap {
|
|
50
|
+
approverRouting?: ApproverRoutingCap;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── §"Portable approver routing" reference rule — audience default/override ──
|
|
54
|
+
type ApprovalPayload = {
|
|
55
|
+
approversList?: string[];
|
|
56
|
+
approverGroupRefs?: string[];
|
|
57
|
+
approverRoleRefs?: string[];
|
|
58
|
+
audience?: { subjects?: string[]; groups?: string[]; roles?: string[] };
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* The advisory routing union a notifying host SHOULD target. Mirrors the
|
|
62
|
+
* normative rule: omitted `audience` ⇒ the eligibility union
|
|
63
|
+
* (`approversList` ∪ `approverGroupRefs` ∪ `approverRoleRefs`); present
|
|
64
|
+
* `audience` ⇒ its own union, overriding the default. Refs stay opaque —
|
|
65
|
+
* this composes refs, it does NOT resolve membership.
|
|
66
|
+
*/
|
|
67
|
+
function notifyTargets(p: ApprovalPayload): string[] {
|
|
68
|
+
const uniq = (xs: string[]): string[] => [...new Set(xs)];
|
|
69
|
+
if (p.audience) {
|
|
70
|
+
return uniq([...(p.audience.subjects ?? []), ...(p.audience.groups ?? []), ...(p.audience.roles ?? [])]);
|
|
71
|
+
}
|
|
72
|
+
return uniq([...(p.approversList ?? []), ...(p.approverGroupRefs ?? []), ...(p.approverRoleRefs ?? [])]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
76
|
+
// A. Server-free legs
|
|
77
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
78
|
+
|
|
79
|
+
describe('interrupt-approver-routing: capability advertisement shape (capabilities.md, server-free)', () => {
|
|
80
|
+
const caps = loadSchema('capabilities.schema.json');
|
|
81
|
+
const interrupt = (caps.properties as Record<string, { properties?: Record<string, { required?: string[]; properties?: Record<string, unknown> }> }>).interrupt;
|
|
82
|
+
|
|
83
|
+
it('capabilities schema declares interrupt.approverRouting', () => {
|
|
84
|
+
expect(interrupt, why('capabilities.schema.json §interrupt', 'the interrupt block MUST be declared')).toBeDefined();
|
|
85
|
+
const ar = interrupt?.properties?.approverRouting;
|
|
86
|
+
expect(ar, why('RFC 0104', 'interrupt.approverRouting MUST be declared')).toBeDefined();
|
|
87
|
+
expect(ar?.required, why('RFC 0104', 'approverRouting.supported MUST be required')).toEqual(expect.arrayContaining(['supported']));
|
|
88
|
+
for (const f of ['supported', 'refKinds', 'audience']) {
|
|
89
|
+
expect(ar?.properties?.[f], why('RFC 0104', `approverRouting.${f} MUST be declared`)).toBeDefined();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('interrupt-approver-routing: ApprovalData additive optionality (interrupt.md §approval, server-free)', () => {
|
|
95
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
96
|
+
addFormats(ajv);
|
|
97
|
+
const suspend = loadSchema('suspend-request.schema.json');
|
|
98
|
+
const validate = ajv.compile(suspend);
|
|
99
|
+
|
|
100
|
+
const baseApproval = {
|
|
101
|
+
kind: 'approval',
|
|
102
|
+
key: 'run:node:0',
|
|
103
|
+
data: { artifactId: 'a1', artifactType: 'prd', title: 'Approve budget', actions: ['accept', 'reject'] },
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
it('an approval payload WITHOUT the routing fields still validates (additive, optional)', () => {
|
|
107
|
+
expect(validate(baseApproval), why('RFC 0104 Compatibility', `the fields are optional — Errors: ${JSON.stringify(validate.errors)}`)).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
it('an approval payload WITH group/role refs + audience validates', () => {
|
|
110
|
+
const withRouting = {
|
|
111
|
+
...baseApproval,
|
|
112
|
+
data: {
|
|
113
|
+
...baseApproval.data,
|
|
114
|
+
approverGroupRefs: ['grp:finance-approvers'],
|
|
115
|
+
approverRoleRefs: ['role:controller'],
|
|
116
|
+
audience: { groups: ['grp:finance-approvers'], roles: ['role:controller'], subjects: ['user:cfo'] },
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
expect(validate(withRouting), why('RFC 0104 §Proposal', `routing fields MUST validate — Errors: ${JSON.stringify(validate.errors)}`)).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
it('an audience with an unknown key is rejected (audience object is closed)', () => {
|
|
122
|
+
const badAudience = { ...baseApproval, data: { ...baseApproval.data, audience: { teams: ['grp:x'] } } };
|
|
123
|
+
expect(validate(badAudience), why('RFC 0104', 'audience MUST be additionalProperties:false')).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('interrupt-approver-routing: audience default/override rule (interrupt.md §"Portable approver routing", server-free)', () => {
|
|
128
|
+
it('omitted audience ⇒ notify the resolved eligibility union', () => {
|
|
129
|
+
expect(
|
|
130
|
+
notifyTargets({ approversList: ['user:a'], approverGroupRefs: ['grp:fin'], approverRoleRefs: ['role:ctrl'] }),
|
|
131
|
+
why('RFC 0104', 'omitted audience MUST default to the eligibility union'),
|
|
132
|
+
).toEqual(['user:a', 'grp:fin', 'role:ctrl']);
|
|
133
|
+
});
|
|
134
|
+
it('present audience ⇒ overrides the eligibility union', () => {
|
|
135
|
+
expect(
|
|
136
|
+
notifyTargets({ approverGroupRefs: ['grp:fin'], audience: { subjects: ['user:cfo'], groups: ['grp:audit'] } }),
|
|
137
|
+
why('RFC 0104', 'present audience MUST override the default'),
|
|
138
|
+
).toEqual(['user:cfo', 'grp:audit']);
|
|
139
|
+
});
|
|
140
|
+
it('no eligibility refs and no audience ⇒ empty target set', () => {
|
|
141
|
+
expect(notifyTargets({}), why('RFC 0104', 'nothing to route when no refs')).toEqual([]);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
146
|
+
// B. Capability-gated advertisement-coherence leg
|
|
147
|
+
// ════════════════════════════════════════════════════════════════════════════
|
|
148
|
+
|
|
149
|
+
describe('interrupt-approver-routing: advertised shape is honest (capability-gated)', () => {
|
|
150
|
+
it('an advertising host advertises a coherent approverRouting block', async () => {
|
|
151
|
+
if (!process.env.OPENWOP_BASE_URL) return; // no live host → nothing to read
|
|
152
|
+
const interrupt = await readCapabilityFamily<InterruptCap>('interrupt');
|
|
153
|
+
const ar = interrupt?.approverRouting;
|
|
154
|
+
// Soft-skip: host does not advertise the capability (fields ignored; still conformant).
|
|
155
|
+
if (!behaviorGate('interrupt.approverRouting', ar?.supported === true)) return;
|
|
156
|
+
|
|
157
|
+
// Opt-in established (supported === true): the advertised shape MUST be honest.
|
|
158
|
+
const allowed = new Set(['group', 'role']);
|
|
159
|
+
for (const k of ar?.refKinds ?? []) {
|
|
160
|
+
expect(allowed.has(k), why('RFC 0104 capabilities.md', `refKinds MUST be a subset of {group, role} — saw ${k}`)).toBe(true);
|
|
161
|
+
}
|
|
162
|
+
if (ar?.audience !== undefined) {
|
|
163
|
+
expect(typeof ar.audience, why('RFC 0104 capabilities.md', 'approverRouting.audience MUST be boolean')).toBe('boolean');
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
});
|