@openwop/openwop-conformance 2.46.34 → 2.46.36
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 +30 -0
- package/README.md +3 -3
- package/coverage.md +4 -5
- package/dist/spec-artifacts.lock.json +2 -2
- package/fixtures/conformance-interrupt-form-refused.json +108 -0
- package/fixtures/conformance-interrupt-form-unbound.json +108 -0
- package/fixtures/conformance-interrupt-form.json +107 -0
- package/fixtures/conformance-stream-text.json +1 -2
- package/fixtures.md +44 -4
- package/package.json +2 -2
- package/requirement-aliases.json +98 -1
- package/requirements.json +663 -804
- package/scenario-majors.json +16 -19
- package/schemas/CORPUS-STAMP.json +72 -85
- package/src/lib/callai-attempts-witness.ts +13 -1
- package/src/lib/deployment-lifecycle-witness.ts +308 -0
- package/src/lib/family-advert-witness.ts +37 -50
- package/src/scenarios/stream-text-fixture.test.ts +13 -5
- package/src/scenarios/v2-deployment-lifecycle.test.ts +526 -0
- package/src/scenarios/v2-envelope-surfaces-absent.test.ts +55 -0
- package/src/scenarios/v2-interrupt-form-rules.test.ts +143 -0
- package/src/scenarios/v2-limits-clarification-rounds-advertisement.test.ts +42 -0
- package/src/scenarios/v2-peer-dependency-declared.test.ts +1 -1
- package/src/scenarios/v2-provider-output-reliability.test.ts +94 -0
- package/src/scenarios/v2-structured-output-advertisement.test.ts +82 -0
- package/src/lib/envelope-schema-static.ts +0 -167
- package/src/scenarios/v2-a2ui-v09-surface.test.ts +0 -323
- package/src/scenarios/v2-ai-envelope-advertisement.test.ts +0 -43
- package/src/scenarios/v2-envelope-contracts-advertisement.test.ts +0 -48
- package/src/scenarios/v2-envelope-reasoning-shape.test.ts +0 -81
- package/src/scenarios/v2-envelope-tier-one-subset-static.test.ts +0 -64
- package/src/scenarios/v2-envelopes-advertisement.test.ts +0 -85
- package/src/scenarios/v2-limits-envelope-caps-advertisement.test.ts +0 -61
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — the agent deployment lifecycle on the wire (RFC 0082 §B, §C, §E, §F;
|
|
3
|
+
* the transition rules of RFC 0254). The rules are the `agents.deployment`
|
|
4
|
+
* facet description in `spec/v2/facets/agents.schema.json` ("Transitions
|
|
5
|
+
* (RFC 0254)"), `schemas/v2/agent-deployment.schema.json`,
|
|
6
|
+
* `agent-deployment-transition.schema.json`, `listAgentDeployments` /
|
|
7
|
+
* `transitionAgentDeployment` in `api/v2/openapi.yaml` and the registered
|
|
8
|
+
* `409 deployment_state_conflict` in `spec/v2/errors.json`.
|
|
9
|
+
*
|
|
10
|
+
* Gated on `agents.deployment` and on the fixture `conformance-agent-channel-dispatch`
|
|
11
|
+
* in discovery `fixtures`. At major 2, listing it states that the host holds an
|
|
12
|
+
* `active` record of `core.conformance.channel-agent` on `stable` and a `draft`
|
|
13
|
+
* one, and that the conformance credential's transitions of that agent pass no
|
|
14
|
+
* approval or eval gate (contract in `conformance/fixtures.md`). Absent `states`
|
|
15
|
+
* means all seven. The suite then, once per worker:
|
|
16
|
+
* list → run the fixture (R1) → unscoped promote → illegal pause / deprecate →
|
|
17
|
+
* promote draft straight to active → a transition into an unadvertised state →
|
|
18
|
+
* promote one step at a time to active on `stable` at 100 (the first step
|
|
19
|
+
* without `toState`) → run the fixture again (R2) → replay R1 → adjust-canary →
|
|
20
|
+
* rollback → pause and resume the version active on `stable` → read each
|
|
21
|
+
* accepted transition's management run.
|
|
22
|
+
* Judges in `lib/deployment-lifecycle-witness.ts`.
|
|
23
|
+
* - `0082.list-valid` — the list is 200 and every record validates.
|
|
24
|
+
* - `0082.pin-recorded` — R1's `agent.invocation-started` carries
|
|
25
|
+
* `resolvedChannel: stable` and a version active on it.
|
|
26
|
+
* - `0082.scope-fail-closed` — `OPENWOP_TEST_LOW_SCOPE_KEY` (no `deploy:*`)
|
|
27
|
+
* is refused `403 forbidden`, a `WWW-Authenticate` challenge if present
|
|
28
|
+
* names `scope="deploy:*"`, and the record does not move. Unset key: `blocked`.
|
|
29
|
+
* - `0082.transition-illegal-refused` — `pause` and `deprecate` of a draft
|
|
30
|
+
* (each when its target state is advertised; else `adjust-canary` at 100)
|
|
31
|
+
* are `409 deployment_state_conflict` with `details.state` and
|
|
32
|
+
* `details.transition`, record unchanged.
|
|
33
|
+
* - `0082.promote-skip-refused` — promote of the draft with `toState: active`
|
|
34
|
+
* when `active` is not the next state is the same 409, record unchanged.
|
|
35
|
+
* - `0082.state-unadvertised-refused` — an otherwise legal transition into a
|
|
36
|
+
* state outside `states` is `400 validation_error`, record unchanged.
|
|
37
|
+
* Every state advertised, or `states` absent: `inapplicable`.
|
|
38
|
+
* - `0082.transition-legal` — each step over the advertised states is 200
|
|
39
|
+
* with a valid record in the next state, and the version ends `active` on
|
|
40
|
+
* `stable`. An advertised subset without `draft` and `active` fails here.
|
|
41
|
+
* - `0082.replay-pin-reread` — after the walk moved `stable` (R2 resolves the
|
|
42
|
+
* new version), a replay of R1 still records R1's version.
|
|
43
|
+
* - `0082.canary-adjusted` / `0082.canary-unsupported-refused` — by `canary`.
|
|
44
|
+
* - `0082.rollback-pointer` — with `rollback`, the version ends `rolled-back`
|
|
45
|
+
* and its `rollbackPointer` names the incumbent, listed `active` again.
|
|
46
|
+
* - `0082.resume-from-paused` — with `paused`, a pause then a `promote` of the
|
|
47
|
+
* version active on `stable` (the incumbent after a rollback) each answer
|
|
48
|
+
* 200, ending `paused` then `active`.
|
|
49
|
+
* - `0082.transition-run-linked` — every 200 transition record carries
|
|
50
|
+
* `transitionRunId`.
|
|
51
|
+
* - `0082.transition-event-recorded` — that run's events include the
|
|
52
|
+
* transition's `deployment.*` event, valid against `run-event-payloads`
|
|
53
|
+
* (content-free: SECURITY `deployment-event-no-content-leak`).
|
|
54
|
+
*
|
|
55
|
+
* Not witnessed here: `authorization.decided` on the management run, the 202 of
|
|
56
|
+
* a gated transition (no gated fixture), that a refused transition creates no
|
|
57
|
+
* run (not portably observable), and a rollback with no prior version.
|
|
58
|
+
*
|
|
59
|
+
* @see RFCS/0082-agent-deployment-lifecycle.md
|
|
60
|
+
* @see RFCS/0254-deployment-transitions-at-v2.md
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
import { describe, it, expect } from 'vitest';
|
|
64
|
+
import { driver, type OpenWOPRequestInit } from '../lib/driver.js';
|
|
65
|
+
import { v2Discovery, v2RefValidator, v2Validator } from '../lib/v2.js';
|
|
66
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
67
|
+
import { req } from '../lib/requirement-ids.js';
|
|
68
|
+
import { majorProfile } from '../lib/major-profile.js';
|
|
69
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
70
|
+
import { isRecord, pollTerminal, readEvents, type Ev } from '../lib/fixture-run-observer.js';
|
|
71
|
+
import {
|
|
72
|
+
ALL_STATES, activeOn, expectedEvents, firstInvocation, judgeBound, judgeCanaryAdjusted, judgeEventRecorded, judgeList,
|
|
73
|
+
judgePinned, judgeReplayPinned, judgeResumed, judgeRolledBack, judgeRunLinked, judgeScopeRefused, judgeStateConflict, judgeStep,
|
|
74
|
+
judgeValidationRefused, pickDraft, promotionPath, recordOf, runIdOf, type Accepted, type Answer, type DeployFinding, type Validate,
|
|
75
|
+
} from '../lib/deployment-lifecycle-witness.js';
|
|
76
|
+
|
|
77
|
+
const PROFILE = majorProfile(2);
|
|
78
|
+
const DOC = 'agents.schema.json §deployment, agent-deployment.schema.json, transitionAgentDeployment (RFC 0082, RFC 0254)';
|
|
79
|
+
const FIXTURE = 'conformance-agent-channel-dispatch';
|
|
80
|
+
const AGENT = 'core.conformance.channel-agent';
|
|
81
|
+
const CHANNEL = 'stable';
|
|
82
|
+
const CANARY = 50;
|
|
83
|
+
const DEPLOYMENTS = `/agents/${encodeURIComponent(AGENT)}/deployments`;
|
|
84
|
+
const INVOCATION_STARTED = PROFILE.eventType('agent.invocation.started') ?? 'agent.invocation-started';
|
|
85
|
+
const ID_LIST = 'openwop.requirement.0082.list-valid';
|
|
86
|
+
const ID_PIN = 'openwop.requirement.0082.pin-recorded';
|
|
87
|
+
const ID_SCOPE = 'openwop.requirement.0082.scope-fail-closed';
|
|
88
|
+
const ID_ILLEGAL = 'openwop.requirement.0082.transition-illegal-refused';
|
|
89
|
+
const ID_SKIP = 'openwop.requirement.0082.promote-skip-refused';
|
|
90
|
+
const ID_UNADVERTISED = 'openwop.requirement.0082.state-unadvertised-refused';
|
|
91
|
+
const ID_LEGAL = 'openwop.requirement.0082.transition-legal';
|
|
92
|
+
const ID_REPLAY = 'openwop.requirement.0082.replay-pin-reread';
|
|
93
|
+
const ID_CANARY = 'openwop.requirement.0082.canary-adjusted';
|
|
94
|
+
const ID_CANARY_REFUSED = 'openwop.requirement.0082.canary-unsupported-refused';
|
|
95
|
+
const ID_ROLLBACK = 'openwop.requirement.0082.rollback-pointer';
|
|
96
|
+
const ID_RESUME = 'openwop.requirement.0082.resume-from-paused';
|
|
97
|
+
const ID_RUN_LINKED = 'openwop.requirement.0082.transition-run-linked';
|
|
98
|
+
const ID_EVENT = 'openwop.requirement.0082.transition-event-recorded';
|
|
99
|
+
|
|
100
|
+
type Rec = Record<string, unknown>;
|
|
101
|
+
type Skip = { skip: [Parameters<typeof softSkip>[0], string] };
|
|
102
|
+
interface Gate { facet: Rec; states: readonly string[] | null; replay: boolean }
|
|
103
|
+
/** A refusal and the list read after it. */
|
|
104
|
+
interface Refusal { readonly before: Rec; readonly answer: Answer | null; readonly after: unknown }
|
|
105
|
+
|
|
106
|
+
interface Drive {
|
|
107
|
+
list0: Answer | null;
|
|
108
|
+
draft?: string | undefined;
|
|
109
|
+
activeAtR1: readonly string[];
|
|
110
|
+
r1?: { runId: string; pin: Rec | undefined } | string | undefined;
|
|
111
|
+
scope?: Refusal | string | undefined;
|
|
112
|
+
illegal?: Array<{ label: string; transition: string } & Refusal> | undefined;
|
|
113
|
+
skip?: Refusal | string | undefined;
|
|
114
|
+
unadvertised?: ({ state: string } & Refusal) | string | undefined;
|
|
115
|
+
walk?: { steps: Array<{ toState: string; answer: Answer | null }>; after: unknown } | string | undefined;
|
|
116
|
+
replay?: { source: string; moved: string; pin: Rec | undefined } | string | undefined;
|
|
117
|
+
canary?: { answer: Answer | null; before: Rec; after: unknown } | string | undefined;
|
|
118
|
+
rollback?: { answer: Answer | null; after: unknown } | string | undefined;
|
|
119
|
+
resume?: { version: string; pause: Answer | null; resume: Answer | null; after: unknown } | string | undefined;
|
|
120
|
+
/** Every transition answered 200, in order. */
|
|
121
|
+
accepted: Accepted[];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function ask(method: string, path: string, body?: unknown, bearer?: string): Promise<Answer | null> {
|
|
125
|
+
const init: OpenWOPRequestInit = {
|
|
126
|
+
...(body === undefined ? {} : { body }),
|
|
127
|
+
...(bearer === undefined ? {} : { authenticated: false, headers: { Authorization: `Bearer ${bearer}` } }),
|
|
128
|
+
};
|
|
129
|
+
try {
|
|
130
|
+
const r = await driver.request(method, path, init);
|
|
131
|
+
return { status: r.status, json: r.json, wwwAuthenticate: r.headers.get('www-authenticate') ?? undefined };
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const list = async (): Promise<unknown> => { const a = await ask('GET', DEPLOYMENTS); return a?.status === 200 ? a.json : undefined; };
|
|
137
|
+
const transition = (body: Rec, bearer?: string): Promise<Answer | null> => ask('POST', DEPLOYMENTS, body, bearer);
|
|
138
|
+
|
|
139
|
+
async function gate(): Promise<Skip | Gate> {
|
|
140
|
+
let doc: Rec | null;
|
|
141
|
+
try { doc = await v2Discovery(); } catch { doc = null; }
|
|
142
|
+
if (!doc) return { skip: ['blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0'] };
|
|
143
|
+
const agents = doc['agents'];
|
|
144
|
+
if (!isRecord(agents) || !isRecord(agents['deployment'])) return { skip: ['inapplicable', 'the host does not advertise agents.deployment'] };
|
|
145
|
+
if (!isFixtureAdvertised(FIXTURE)) return { skip: ['inapplicable', `the host does not advertise fixture ${FIXTURE}`] };
|
|
146
|
+
const facet = agents['deployment'];
|
|
147
|
+
const states = Array.isArray(facet['states']) ? facet['states'].filter((s): s is string => typeof s === 'string') : null;
|
|
148
|
+
const replay = isRecord(doc['replay']) && Array.isArray(doc['replay']['modes']) && (doc['replay']['modes'] as unknown[]).includes('replay');
|
|
149
|
+
return { facet, states, replay };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Absent `states` means all seven (RFC 0254 §B). */
|
|
153
|
+
const advertises = (g: Gate, state: string): boolean => g.states === null || g.states.includes(state);
|
|
154
|
+
|
|
155
|
+
/** The §B violation of an advertised subset without `draft` and `active`, or null. */
|
|
156
|
+
function subsetInvalid(g: Gate): string | null {
|
|
157
|
+
const missing = ['draft', 'active'].filter((s) => !advertises(g, s));
|
|
158
|
+
return missing.length === 0 ? null : `agents.deployment.states ${JSON.stringify(g.states)} omits ${missing.join(' and ')}; an advertised subset MUST include draft and active`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Why the forward walk cannot run on this host, or null. */
|
|
162
|
+
function walkInapplicable(g: Gate): string | null {
|
|
163
|
+
const invalid = subsetInvalid(g);
|
|
164
|
+
if (invalid !== null) return invalid;
|
|
165
|
+
const channels = Array.isArray(g.facet['channels']) ? g.facet['channels'] : [];
|
|
166
|
+
if (!channels.includes(CHANNEL)) return `agents.deployment.channels does not list ${CHANNEL}`;
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
const rollbackServed = (g: Gate): boolean => g.facet['rollback'] === true && advertises(g, 'rolled-back');
|
|
170
|
+
/** The state a promote of a draft moves to over the advertised states. */
|
|
171
|
+
const nextAfterDraft = (g: Gate): string | undefined => promotionPath(g.states)[1];
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Transitions a draft has no edge for, whose target the host advertises (an
|
|
175
|
+
* unadvertised target is the 400 checked first): `pause` and `deprecate`, or,
|
|
176
|
+
* when neither state is advertised, `adjust-canary` at 100.
|
|
177
|
+
*/
|
|
178
|
+
function illegalProbes(g: Gate): Array<{ label: string; body: { transition: string } & Rec }> {
|
|
179
|
+
const out = (['pause', 'deprecate'] as const)
|
|
180
|
+
.filter((t) => advertises(g, t === 'pause' ? 'paused' : 'deprecated'))
|
|
181
|
+
.map((t) => ({ label: `${t} of a draft`, body: { transition: t } }));
|
|
182
|
+
return out.length > 0 ? out : [{ label: 'adjust-canary of a draft', body: { transition: 'adjust-canary', canaryPercent: 100 } }];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* A transition that the state machine allows but whose target state is not
|
|
187
|
+
* advertised, so the only ground for refusing it is `states`: promote a draft
|
|
188
|
+
* into `test`, or pause, deprecate or roll back the active incumbent.
|
|
189
|
+
*/
|
|
190
|
+
const PROBES = [
|
|
191
|
+
{ state: 'test', on: 'draft', transition: 'promote' },
|
|
192
|
+
{ state: 'paused', on: 'active', transition: 'pause' },
|
|
193
|
+
{ state: 'deprecated', on: 'active', transition: 'deprecate' },
|
|
194
|
+
{ state: 'rolled-back', on: 'active', transition: 'rollback' },
|
|
195
|
+
] as const;
|
|
196
|
+
function unadvertisedProbe(states: readonly string[] | null): (typeof PROBES)[number] | undefined {
|
|
197
|
+
return states === null ? undefined : PROBES.find((p) => !states.includes(p.state));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function runFixture(): Promise<{ runId: string; pin: Rec | undefined } | string> {
|
|
201
|
+
const created = await ask('POST', PROFILE.runsPath, { workflowId: FIXTURE });
|
|
202
|
+
const runId = isRecord(created?.json) && typeof created.json['runId'] === 'string' ? created.json['runId'] : undefined;
|
|
203
|
+
if ((created?.status !== 201 && created?.status !== 202) || runId === undefined) return `createRun of ${FIXTURE} answered ${created?.status ?? 'nothing'}`;
|
|
204
|
+
const end = await pollTerminal(PROFILE, runId, { timeoutMs: 30_000 });
|
|
205
|
+
if (end.snapshot['status'] !== 'completed') return `run ${runId} of ${FIXTURE} ended ${String(end.snapshot['status'])}, not completed`;
|
|
206
|
+
return { runId, pin: firstInvocation(await readEvents(PROFILE, runId), INVOCATION_STARTED) };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function replayOf(runId: string): Promise<Rec | undefined | string> {
|
|
210
|
+
const fork = await ask('POST', `${PROFILE.runsPath}/${PROFILE.idSegment(runId)}:fork`, { mode: 'replay' });
|
|
211
|
+
const forkId = isRecord(fork?.json) && typeof fork.json['runId'] === 'string' ? fork.json['runId'] : undefined;
|
|
212
|
+
if (fork?.status !== 201 || forkId === undefined) return `POST /runs/{runId}:fork {mode: replay} answered ${fork?.status ?? 'nothing'}`;
|
|
213
|
+
await pollTerminal(PROFILE, forkId, { timeoutMs: 30_000 });
|
|
214
|
+
return firstInvocation(await readEvents(PROFILE, forkId), INVOCATION_STARTED);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function refuse(version: string, body: Rec, bearer?: string): Promise<Refusal | string> {
|
|
218
|
+
const before = recordOf(await list(), version);
|
|
219
|
+
if (before === undefined) return `the record for ${version} is no longer listed`;
|
|
220
|
+
const answer = await transition(body, bearer);
|
|
221
|
+
return { before, answer, after: await list() };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const typeOf = (v1: string): string => PROFILE.eventType(v1) ?? v1;
|
|
225
|
+
|
|
226
|
+
/** A management run's events, read until the transition's `deployment.*` event appears or 10 s pass. */
|
|
227
|
+
async function managementEvents(x: Accepted, runId: string): Promise<Ev[] | undefined> {
|
|
228
|
+
const want = expectedEvents(x);
|
|
229
|
+
const deadline = Date.now() + 10_000;
|
|
230
|
+
for (;;) {
|
|
231
|
+
const r = await ask('GET', `${PROFILE.runsPath}/${PROFILE.idSegment(runId)}/events/poll`);
|
|
232
|
+
if (r?.status !== 200) return undefined;
|
|
233
|
+
const events = await readEvents(PROFILE, runId);
|
|
234
|
+
if (want.some((w) => events.some((e) => e.type === typeOf(w.v1) && w.matches(e.payload))) || Date.now() > deadline) return events;
|
|
235
|
+
await new Promise((res) => setTimeout(res, 250));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let once: Promise<Drive> | undefined;
|
|
240
|
+
/** One lifecycle per worker: every leg judges a slice of it. */
|
|
241
|
+
function lifecycle(g: Gate): Promise<Drive> {
|
|
242
|
+
once ??= drive(g);
|
|
243
|
+
return once;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function drive(g: Gate): Promise<Drive> {
|
|
247
|
+
const list0 = await ask('GET', DEPLOYMENTS);
|
|
248
|
+
const records = list0?.status === 200 ? list0.json : undefined;
|
|
249
|
+
const activeAtR1 = activeOn(records, CHANNEL);
|
|
250
|
+
const d: Drive = { list0, draft: pickDraft(records), activeAtR1, accepted: [] };
|
|
251
|
+
d.r1 = activeAtR1.length === 0 ? `the host lists ${FIXTURE} but no ${AGENT} record is active on ${CHANNEL}` : await runFixture();
|
|
252
|
+
|
|
253
|
+
/** Send a transition by the conformance credential; a 200 joins `accepted`. */
|
|
254
|
+
const send = async (label: string, body: Rec & { version: string; transition: string }): Promise<Answer | null> => {
|
|
255
|
+
const fromState = recordOf(await list(), body.version)?.['state'];
|
|
256
|
+
const answer = await transition(body);
|
|
257
|
+
if (answer?.status === 200) d.accepted.push({ label, transition: body.transition, version: body.version, fromState: String(fromState), answer });
|
|
258
|
+
return answer;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
if (d.draft !== undefined) await forward(g, d, d.draft, send);
|
|
262
|
+
d.resume = await resume(g, d, send);
|
|
263
|
+
return d;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
let eventsOnce: Promise<Map<string, Ev[] | undefined>> | undefined;
|
|
267
|
+
/** The events of every accepted transition's management run, read concurrently, once. */
|
|
268
|
+
function managementRuns(d: Drive): Promise<Map<string, Ev[] | undefined>> {
|
|
269
|
+
eventsOnce ??= (async () => {
|
|
270
|
+
const linked = d.accepted.flatMap((x) => { const id = runIdOf(x); return id === undefined ? [] : [{ x, id }]; });
|
|
271
|
+
const read = await Promise.all(linked.map(async ({ x, id }) => [id, await managementEvents(x, id)] as const));
|
|
272
|
+
return new Map(read);
|
|
273
|
+
})();
|
|
274
|
+
return eventsOnce;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
type Send = (label: string, body: Rec & { version: string; transition: string }) => Promise<Answer | null>;
|
|
278
|
+
|
|
279
|
+
/** The refusals, the walk, the replay, canary and rollback, on the fixture's draft. */
|
|
280
|
+
async function forward(g: Gate, d: Drive, draft: string, send: Send): Promise<void> {
|
|
281
|
+
const low = process.env['OPENWOP_TEST_LOW_SCOPE_KEY']?.trim();
|
|
282
|
+
d.scope = !low
|
|
283
|
+
? 'OPENWOP_TEST_LOW_SCOPE_KEY (a key without any deploy:* scope) is not set'
|
|
284
|
+
: await refuse(draft, { version: draft, transition: 'promote' }, low);
|
|
285
|
+
|
|
286
|
+
d.illegal = [];
|
|
287
|
+
for (const p of illegalProbes(g)) {
|
|
288
|
+
const r = await refuse(draft, { version: draft, ...p.body });
|
|
289
|
+
if (typeof r !== 'string') d.illegal.push({ label: p.label, transition: p.body.transition, ...r });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (walkInapplicable(g) === null && nextAfterDraft(g) !== 'active') {
|
|
293
|
+
d.skip = await refuse(draft, { version: draft, transition: 'promote', toState: 'active' });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const probe = unadvertisedProbe(g.states);
|
|
297
|
+
if (probe !== undefined) {
|
|
298
|
+
const version = probe.on === 'draft' ? draft : d.activeAtR1[0];
|
|
299
|
+
if (version === undefined) d.unadvertised = `no ${AGENT} record is active on ${CHANNEL} to ${probe.transition}`;
|
|
300
|
+
else {
|
|
301
|
+
const r = await refuse(version, { version, transition: probe.transition, ...(probe.on === 'draft' ? { toState: probe.state } : {}) });
|
|
302
|
+
d.unadvertised = typeof r === 'string' ? r : { state: probe.state, ...r };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const why = walkInapplicable(g);
|
|
307
|
+
if (why !== null) { d.walk = why; return; }
|
|
308
|
+
const current = recordOf(await list(), draft)?.['state'];
|
|
309
|
+
if (current !== 'draft') { d.walk = `${draft} is no longer listed as a draft (now ${JSON.stringify(current)})`; return; }
|
|
310
|
+
const path = promotionPath(g.states).slice(1);
|
|
311
|
+
const steps: Array<{ toState: string; answer: Answer | null }> = [];
|
|
312
|
+
for (const [i, toState] of path.entries()) {
|
|
313
|
+
const last = toState === 'active';
|
|
314
|
+
// The first step omits toState: absent means the next state.
|
|
315
|
+
const body = { version: draft, transition: 'promote', ...(i === 0 ? {} : { toState }), ...(last ? { channel: CHANNEL, canaryPercent: 100 } : {}) };
|
|
316
|
+
const answer = await send(`promote ${draft} to ${toState}`, body);
|
|
317
|
+
steps.push({ toState, answer });
|
|
318
|
+
if (answer?.status !== 200) break;
|
|
319
|
+
}
|
|
320
|
+
d.walk = { steps, after: await list() };
|
|
321
|
+
if (steps.length < path.length || steps.some((s) => s.answer?.status !== 200)) return;
|
|
322
|
+
|
|
323
|
+
if (!g.replay) d.replay = 'the host does not advertise replay.modes including "replay"';
|
|
324
|
+
else if (typeof d.r1 !== 'object') d.replay = d.r1 ?? 'R1 did not run';
|
|
325
|
+
else if (typeof d.r1.pin?.['resolvedAgentVersion'] !== 'string') d.replay = 'R1 recorded no resolvedAgentVersion to re-read';
|
|
326
|
+
else {
|
|
327
|
+
const source = d.r1.pin['resolvedAgentVersion'] as string;
|
|
328
|
+
const r2 = await runFixture();
|
|
329
|
+
const moved = typeof r2 === 'string' ? undefined : r2.pin?.['resolvedAgentVersion'];
|
|
330
|
+
if (typeof r2 === 'string') d.replay = r2;
|
|
331
|
+
else if (moved !== draft || moved === source) d.replay = `the channel move is not observable: a fresh run resolved ${JSON.stringify(moved)} after ${draft} went active on ${CHANNEL} at 100`;
|
|
332
|
+
else {
|
|
333
|
+
const pin = await replayOf(d.r1.runId);
|
|
334
|
+
d.replay = typeof pin === 'string' ? pin : { source, moved: draft, pin };
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const beforeCanary = recordOf(await list(), draft);
|
|
339
|
+
if (beforeCanary === undefined) d.canary = `the record for ${draft} is no longer listed`;
|
|
340
|
+
else {
|
|
341
|
+
const answer = await send(`adjust-canary of ${draft} to ${CANARY}`, { version: draft, transition: 'adjust-canary', canaryPercent: CANARY });
|
|
342
|
+
d.canary = { answer, before: beforeCanary, after: await list() };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (!rollbackServed(g)) d.rollback = 'the host does not advertise agents.deployment.rollback with rolled-back in states';
|
|
346
|
+
else if (d.activeAtR1.length === 0) d.rollback = `no version was active on ${CHANNEL} before the walk, so there is nothing to restore`;
|
|
347
|
+
else {
|
|
348
|
+
const answer = await send(`rollback of ${draft}`, { version: draft, transition: 'rollback', reason: 'conformance' });
|
|
349
|
+
d.rollback = { answer, after: await list() };
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Pause, then promote, the version active on `stable`: the incumbent when it
|
|
355
|
+
* is active again (after a rollback, or never displaced), else the walked
|
|
356
|
+
* version. Runs last, so no later leg depends on what it leaves.
|
|
357
|
+
*/
|
|
358
|
+
async function resume(g: Gate, d: Drive, send: Send): Promise<Drive['resume']> {
|
|
359
|
+
if (!advertises(g, 'paused')) return 'agents.deployment.states does not advertise paused';
|
|
360
|
+
const active = activeOn(await list(), CHANNEL);
|
|
361
|
+
const version = active.find((v) => d.activeAtR1.includes(v)) ?? active[0];
|
|
362
|
+
if (version === undefined) return `no ${AGENT} record is active on ${CHANNEL} to pause`;
|
|
363
|
+
const pause = await send(`pause of ${version}`, { version, transition: 'pause' });
|
|
364
|
+
const back = pause?.status === 200 ? await send(`promote of paused ${version}`, { version, transition: 'promote' }) : null;
|
|
365
|
+
return { version, pause, resume: back, after: await list() };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function expectAll(id: string, findings: readonly DeployFinding[]): void {
|
|
369
|
+
for (const x of findings) expect(x.ok, req(id, DOC, x.message)).toBe(true);
|
|
370
|
+
}
|
|
371
|
+
const NO_DRAFT = `the host lists ${FIXTURE} but holds no draft record of ${AGENT}`;
|
|
372
|
+
const record = v2Validator('agent-deployment');
|
|
373
|
+
const PAYLOADS: Record<string, Validate> = Object.fromEntries(
|
|
374
|
+
['deploymentPromoted', 'deploymentStateChanged', 'deploymentCanaryAdjusted', 'deploymentRolledBack']
|
|
375
|
+
.map((def) => [def, v2RefValidator(`run-event-payloads.schema.json#/$defs/${def}`)]),
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
describe('v2 RFC 0082 — the agent deployment lifecycle (host)', () => {
|
|
379
|
+
it('lists valid deployment records of the fixture agent', async () => {
|
|
380
|
+
const g = await gate();
|
|
381
|
+
if ('skip' in g) return softSkip(...g.skip);
|
|
382
|
+
const d = await lifecycle(g);
|
|
383
|
+
expectAll(ID_LIST, judgeList(d.list0, AGENT, record));
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('a channel-bound run records the channel and the version it resolved', async () => {
|
|
387
|
+
const g = await gate();
|
|
388
|
+
if ('skip' in g) return softSkip(...g.skip);
|
|
389
|
+
const d = await lifecycle(g);
|
|
390
|
+
if (d.r1 === undefined || typeof d.r1 === 'string') return softSkip('blocked', d.r1 ?? 'the fixture run did not start');
|
|
391
|
+
expectAll(ID_PIN, judgePinned(d.r1.pin, CHANNEL, d.activeAtR1));
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('a transition without the deploy:* scope is refused 403 forbidden and moves nothing', async () => {
|
|
395
|
+
const g = await gate();
|
|
396
|
+
if ('skip' in g) return softSkip(...g.skip);
|
|
397
|
+
const d = await lifecycle(g);
|
|
398
|
+
if (d.draft === undefined) return softSkip('blocked', NO_DRAFT);
|
|
399
|
+
if (d.scope === undefined || typeof d.scope === 'string') return softSkip('blocked', d.scope ?? 'the unscoped transition was not sent');
|
|
400
|
+
expectAll(ID_SCOPE, judgeScopeRefused(d.scope.answer, d.scope.before, d.scope.after));
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('a pause or deprecate of a draft is 409 deployment_state_conflict and moves nothing', async () => {
|
|
404
|
+
const g = await gate();
|
|
405
|
+
if ('skip' in g) return softSkip(...g.skip);
|
|
406
|
+
const d = await lifecycle(g);
|
|
407
|
+
if (d.draft === undefined) return softSkip('blocked', NO_DRAFT);
|
|
408
|
+
if (d.illegal === undefined || d.illegal.length === 0) return softSkip('blocked', `the draft ${d.draft} was not listed when the illegal transitions were sent`);
|
|
409
|
+
expectAll(ID_ILLEGAL, d.illegal.flatMap((x) => judgeStateConflict(x.label, x.transition, x.answer, x.before, x.after)));
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('a promote of a draft straight to active is 409 deployment_state_conflict and moves nothing', async () => {
|
|
413
|
+
const g = await gate();
|
|
414
|
+
if ('skip' in g) return softSkip(...g.skip);
|
|
415
|
+
const why = walkInapplicable(g);
|
|
416
|
+
if (why !== null) return softSkip('inapplicable', why);
|
|
417
|
+
if (nextAfterDraft(g) === 'active') return softSkip('inapplicable', 'the host advertises neither test nor staged, so active is the next state after draft and the promote is legal');
|
|
418
|
+
const d = await lifecycle(g);
|
|
419
|
+
if (d.draft === undefined) return softSkip('blocked', NO_DRAFT);
|
|
420
|
+
if (d.skip === undefined || typeof d.skip === 'string') return softSkip('blocked', d.skip ?? 'the skipping promote was not sent');
|
|
421
|
+
expectAll(ID_SKIP, judgeStateConflict('promote of a draft straight to active', 'promote', d.skip.answer, d.skip.before, d.skip.after));
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('a transition into a state the host does not advertise is a validation_error', async () => {
|
|
425
|
+
const g = await gate();
|
|
426
|
+
if ('skip' in g) return softSkip(...g.skip);
|
|
427
|
+
const states = g.states;
|
|
428
|
+
if (states === null) return softSkip('inapplicable', 'agents.deployment.states is absent, which means all seven, so no state is unadvertised');
|
|
429
|
+
if (ALL_STATES.every((s) => states.includes(s))) return softSkip('inapplicable', 'the host advertises all seven states, so no state is unadvertised');
|
|
430
|
+
if (unadvertisedProbe(states) === undefined) return softSkip('inapplicable', 'every unadvertised state is one no legal transition from a draft or the active incumbent enters, so a refusal could not be told apart from an illegal-transition refusal');
|
|
431
|
+
const d = await lifecycle(g);
|
|
432
|
+
if (d.draft === undefined) return softSkip('blocked', NO_DRAFT);
|
|
433
|
+
if (d.unadvertised === undefined || typeof d.unadvertised === 'string') return softSkip('blocked', d.unadvertised ?? 'the probe was not sent');
|
|
434
|
+
expectAll(ID_UNADVERTISED, judgeValidationRefused(`a transition into unadvertised ${d.unadvertised.state}`, d.unadvertised.answer, d.unadvertised.before, d.unadvertised.after));
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('a draft promotes one step at a time over the advertised states to active on a channel', async () => {
|
|
438
|
+
const g = await gate();
|
|
439
|
+
if ('skip' in g) return softSkip(...g.skip);
|
|
440
|
+
const invalid = subsetInvalid(g);
|
|
441
|
+
expectAll(ID_LEGAL, invalid === null ? [] : [{ ok: false, message: invalid }]);
|
|
442
|
+
const why = walkInapplicable(g);
|
|
443
|
+
if (why !== null) return softSkip('inapplicable', why);
|
|
444
|
+
const d = await lifecycle(g);
|
|
445
|
+
if (d.draft === undefined) return softSkip('blocked', NO_DRAFT);
|
|
446
|
+
if (d.walk === undefined || typeof d.walk === 'string') return softSkip('blocked', d.walk ?? 'the walk did not run');
|
|
447
|
+
const version = d.draft;
|
|
448
|
+
const findings = d.walk.steps.flatMap((s) => judgeStep(s.answer, version, s.toState, record));
|
|
449
|
+
const done = d.walk.steps.length === promotionPath(g.states).length - 1 && d.walk.steps.every((s) => s.answer?.status === 200);
|
|
450
|
+
expectAll(ID_LEGAL, done ? [...findings, ...judgeBound(d.walk.after, version, CHANNEL)] : findings);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('a replay re-reads the recorded version after the channel moved', async () => {
|
|
454
|
+
const g = await gate();
|
|
455
|
+
if ('skip' in g) return softSkip(...g.skip);
|
|
456
|
+
if (!g.replay) return softSkip('inapplicable', 'the host does not advertise replay.modes including "replay"');
|
|
457
|
+
const why = walkInapplicable(g);
|
|
458
|
+
if (why !== null) return softSkip('inapplicable', `the channel cannot be moved: ${why}`);
|
|
459
|
+
const d = await lifecycle(g);
|
|
460
|
+
if (d.replay === undefined || typeof d.replay === 'string') return softSkip('blocked', d.replay ?? (d.draft === undefined ? NO_DRAFT : 'the walk did not reach active, so the channel never moved'));
|
|
461
|
+
expectAll(ID_REPLAY, judgeReplayPinned(d.replay.source, d.replay.moved, d.replay.pin));
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('adjust-canary sets the share on a canary host', async () => {
|
|
465
|
+
const g = await gate();
|
|
466
|
+
if ('skip' in g) return softSkip(...g.skip);
|
|
467
|
+
if (g.facet['canary'] !== true) return softSkip('inapplicable', 'the host does not advertise agents.deployment.canary');
|
|
468
|
+
const why = walkInapplicable(g);
|
|
469
|
+
if (why !== null) return softSkip('inapplicable', `no active version to adjust: ${why}`);
|
|
470
|
+
const d = await lifecycle(g);
|
|
471
|
+
if (d.draft === undefined) return softSkip('blocked', NO_DRAFT);
|
|
472
|
+
if (d.canary === undefined || typeof d.canary === 'string') return softSkip('blocked', d.canary ?? 'the walk did not reach active');
|
|
473
|
+
expectAll(ID_CANARY, judgeCanaryAdjusted(d.canary.answer, d.draft, CANARY, d.canary.after, record));
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it('a host without canary refuses a share below 100', async () => {
|
|
477
|
+
const g = await gate();
|
|
478
|
+
if ('skip' in g) return softSkip(...g.skip);
|
|
479
|
+
if (g.facet['canary'] === true) return softSkip('inapplicable', 'the host advertises agents.deployment.canary');
|
|
480
|
+
const why = walkInapplicable(g);
|
|
481
|
+
if (why !== null) return softSkip('inapplicable', `no active version to adjust: ${why}`);
|
|
482
|
+
const d = await lifecycle(g);
|
|
483
|
+
if (d.draft === undefined) return softSkip('blocked', NO_DRAFT);
|
|
484
|
+
if (d.canary === undefined || typeof d.canary === 'string') return softSkip('blocked', d.canary ?? 'the walk did not reach active');
|
|
485
|
+
expectAll(ID_CANARY_REFUSED, judgeValidationRefused(`adjust-canary to ${CANARY} without canary`, d.canary.answer, d.canary.before, d.canary.after));
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('a rollback restores the incumbent and points at it', async () => {
|
|
489
|
+
const g = await gate();
|
|
490
|
+
if ('skip' in g) return softSkip(...g.skip);
|
|
491
|
+
if (!rollbackServed(g)) return softSkip('inapplicable', 'the host does not advertise agents.deployment.rollback with rolled-back in states');
|
|
492
|
+
const why = walkInapplicable(g);
|
|
493
|
+
if (why !== null) return softSkip('inapplicable', `no active version to roll back: ${why}`);
|
|
494
|
+
const d = await lifecycle(g);
|
|
495
|
+
if (d.draft === undefined) return softSkip('blocked', NO_DRAFT);
|
|
496
|
+
if (d.rollback === undefined || typeof d.rollback === 'string') return softSkip('blocked', d.rollback ?? 'the walk did not reach active');
|
|
497
|
+
expectAll(ID_ROLLBACK, judgeRolledBack(d.rollback.answer, d.draft, d.activeAtR1, d.rollback.after, record));
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it('a paused version resumes to active by promote', async () => {
|
|
501
|
+
const g = await gate();
|
|
502
|
+
if ('skip' in g) return softSkip(...g.skip);
|
|
503
|
+
if (!advertises(g, 'paused')) return softSkip('inapplicable', 'agents.deployment.states does not advertise paused');
|
|
504
|
+
const d = await lifecycle(g);
|
|
505
|
+
if (d.resume === undefined || typeof d.resume === 'string') return softSkip('blocked', d.resume ?? 'the pause was not sent');
|
|
506
|
+
expectAll(ID_RESUME, judgeResumed(d.resume.pause, d.resume.resume, d.resume.version, d.resume.after, record));
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it('every accepted transition names its deployment-management run', async () => {
|
|
510
|
+
const g = await gate();
|
|
511
|
+
if ('skip' in g) return softSkip(...g.skip);
|
|
512
|
+
const d = await lifecycle(g);
|
|
513
|
+
if (d.accepted.length === 0) return softSkip('blocked', 'no transition answered 200, so no record to read transitionRunId from');
|
|
514
|
+
expectAll(ID_RUN_LINKED, judgeRunLinked(d.accepted));
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('the management run records the transition\'s content-free deployment event', async () => {
|
|
518
|
+
const g = await gate();
|
|
519
|
+
if ('skip' in g) return softSkip(...g.skip);
|
|
520
|
+
const d = await lifecycle(g);
|
|
521
|
+
const linked = d.accepted.filter((x) => runIdOf(x) !== undefined);
|
|
522
|
+
if (linked.length === 0) return softSkip('blocked', 'no 200 transition record named a transitionRunId, so there is no management run to read');
|
|
523
|
+
const events = await managementRuns(d);
|
|
524
|
+
expectAll(ID_EVENT, linked.flatMap((x) => judgeEventRecorded(x, events.get(runIdOf(x) ?? ''), typeOf, PAYLOADS)));
|
|
525
|
+
});
|
|
526
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v2 — the AI Envelope is absent (RFC 0255 §A). Unaided: it reads only the
|
|
3
|
+
* host's v2 discovery document.
|
|
4
|
+
*
|
|
5
|
+
* RFC 0255 deleted the families `aiEnvelope`, `supportedEnvelopes`,
|
|
6
|
+
* `schemaVersions`, `envelopeStrictness`, `envelopeContracts`, `envelopes` and
|
|
7
|
+
* the ext family `a2uiSurface`, and the `limits` facets `schemaRounds` and
|
|
8
|
+
* `envelopesPerTurn`. The discovery root is closed, so a host still publishing
|
|
9
|
+
* one of them publishes a document the v2 schema refuses; this leg names which
|
|
10
|
+
* key it is. `envelopes.reasoning`, `.tierOneSubsetCompliance` and
|
|
11
|
+
* `.reliability` now live on `aiProviders` (`host-services.md` §Structured
|
|
12
|
+
* output), so a host that moved them passes.
|
|
13
|
+
*
|
|
14
|
+
* Disposition: discovery unreadable ⇒ `blocked`. Every readable document is
|
|
15
|
+
* judged; there is no `inapplicable` state, because absence is the requirement.
|
|
16
|
+
*
|
|
17
|
+
* @see RFCS/0255-delete-the-ai-envelope.md §A
|
|
18
|
+
* @see spec/v2/corrections.json openwop.correction.v2.5
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, it, expect } from 'vitest';
|
|
22
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
23
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
24
|
+
import { req } from '../lib/requirement-ids.js';
|
|
25
|
+
|
|
26
|
+
const ID = 'openwop.requirement.0255.surfaces-absent';
|
|
27
|
+
const SECTION = 'RFC 0255 §A';
|
|
28
|
+
const UNREADABLE = 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0';
|
|
29
|
+
const DELETED_ROOT_KEYS = ['aiEnvelope', 'supportedEnvelopes', 'schemaVersions', 'envelopeStrictness', 'envelopeContracts', 'envelopes', 'a2uiSurface'] as const;
|
|
30
|
+
const DELETED_LIMITS = ['schemaRounds', 'envelopesPerTurn'] as const;
|
|
31
|
+
|
|
32
|
+
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
33
|
+
|
|
34
|
+
/** Every deleted surface the document still publishes, as a dotted path. */
|
|
35
|
+
function deletedSurfaces(doc: Record<string, unknown>): string[] {
|
|
36
|
+
const found: string[] = DELETED_ROOT_KEYS.filter((k) => k in doc).map((k) => k);
|
|
37
|
+
const limits = doc['limits'];
|
|
38
|
+
if (isRecord(limits)) for (const k of DELETED_LIMITS) if (k in limits) found.push(`limits.${k}`);
|
|
39
|
+
const extensions = doc['extensions'];
|
|
40
|
+
if (isRecord(extensions)) {
|
|
41
|
+
for (const [org, rec] of Object.entries(extensions)) {
|
|
42
|
+
if (isRecord(rec) && ('a2ui-surface' in rec || 'a2uiSurface' in rec)) found.push(`extensions.${org}.a2ui-surface`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return found;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('v2-envelope-surfaces-absent (RFC 0255 §A)', () => {
|
|
49
|
+
it('the discovery document publishes no deleted envelope family or limit', async () => {
|
|
50
|
+
let doc: Record<string, unknown> | null = null;
|
|
51
|
+
try { doc = await v2Discovery(); } catch { doc = null; }
|
|
52
|
+
if (!doc) return softSkip('blocked', UNREADABLE);
|
|
53
|
+
expect(deletedSurfaces(doc), req(ID, SECTION, 'a v2 discovery document MUST NOT publish a family or limit RFC 0255 deleted')).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
});
|