@openwop/openwop-conformance 2.43.0 → 2.43.1
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 +16 -0
- package/README.md +3 -3
- package/dist/cli.js +15 -12
- package/dist/lib/certification-bundle-v3.js +19 -0
- package/dist/spec-artifacts.lock.json +2 -2
- package/fixtures/conformance-approval-reject-loopback.json +33 -0
- package/fixtures/conformance-approval-reject-routed.json +32 -0
- package/fixtures/conformance-approval-timeout-approve.json +46 -0
- package/fixtures/conformance-approval-timeout.json +28 -0
- package/fixtures.md +57 -1
- package/package.json +2 -2
- package/requirements.json +193 -30
- package/scenario-majors.json +5 -2
- package/schemas/CORPUS-STAMP.json +44 -44
- package/src/cli.ts +15 -12
- package/src/lib/certification-bundle-v3.ts +25 -0
- package/src/lib/standard-webhooks.ts +53 -0
- package/src/scenarios/agent-org-chart-scoping.test.ts +63 -0
- package/src/scenarios/v2-agent-org-chart-served-shape.test.ts +63 -0
- package/src/scenarios/v2-approval-reject-disposition.test.ts +106 -6
- package/src/scenarios/v2-error-registry.test.ts +59 -0
- package/src/scenarios/v2-manifest-ceiling-refused.test.ts +21 -9
- package/src/scenarios/v2-manifest-hatch-carried.test.ts +23 -7
- package/src/scenarios/v2-pack-isolation.test.ts +10 -6
- package/src/scenarios/v2-peer-dependency-declared.test.ts +18 -6
- package/src/scenarios/v2-webhook-secret-rotation.test.ts +28 -11
package/src/cli.ts
CHANGED
|
@@ -46,7 +46,7 @@ import { readLedgerFile } from './lib/requirement-ledger.js';
|
|
|
46
46
|
import { deriveRung, emittedByNewerSuite, type RowEvidence } from './lib/durability-evidence.js';
|
|
47
47
|
import { deriveRequirementDispositions } from './lib/scenario-disposition.js';
|
|
48
48
|
import { scrubEvidence, evidenceSecretsFromEnv, verifyBundleV2 } from './lib/certification-bundle-verify.js';
|
|
49
|
-
import { publicKeyFromPrivate,
|
|
49
|
+
import { publicKeyFromPrivate, sealBundleV3, verifierSign, verifyBundleV3, type BundleV3, type BundleV3Requirement } from './lib/certification-bundle-v3.js';
|
|
50
50
|
import { canonicalJSON, parseIJson } from './lib/jcs.js';
|
|
51
51
|
import {
|
|
52
52
|
deriveProfiles,
|
|
@@ -718,7 +718,7 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
|
|
|
718
718
|
if (rows3.some((r) => r.id.startsWith('openwop.requirement.0158.') && r.result === 'executed-pass' && r.id !== 'openwop.requirement.0158.poison-exhaustion')) {
|
|
719
719
|
process.stderr.write(`openwop-conformance --certify: RFC 0158 rung — ${rung3.rung ?? 'NONE'} (${rung3.why})\n`);
|
|
720
720
|
}
|
|
721
|
-
const
|
|
721
|
+
const draft: Omit<BundleV3, 'signature' | 'witnessSha256'> = {
|
|
722
722
|
bundleVersion: '3',
|
|
723
723
|
generatedAt: new Date().toISOString(),
|
|
724
724
|
suite: { name: '@openwop/openwop-conformance', version, targetMajor: target.major, specArtifactsVersion: lock?.version ?? 'repo-layout', ...(lock ? { stampSha256: lock.stampSha256 } : {}) },
|
|
@@ -730,7 +730,6 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
|
|
|
730
730
|
discovery: { url: discoveryUrl, sha256, protocolVersions, preferredVersion, document },
|
|
731
731
|
claimedProfiles: claimed3,
|
|
732
732
|
results: { totals: totals3, requirements: rows3 },
|
|
733
|
-
witnessSha256: witnessDigest(rows3, relaxations, args.colocatedCompanion ? 'colocated-companion' : undefined),
|
|
734
733
|
assertionCount: rows3.reduce((n, r) => n + (r.assertions ?? 0), 0),
|
|
735
734
|
...(nonPass.length ? { detail: { nonPass: nonPass.map((r) => ({ id: r.id, result: r.result, reason: r.detail ?? '' })) } } : {}),
|
|
736
735
|
// RFC 0158 §D: claimed ONLY when these rows support it. The verifier
|
|
@@ -738,20 +737,24 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
|
|
|
738
737
|
// claimed more would be writing a bundle its own `--verify` rejects.
|
|
739
738
|
...(rung3.rung === null ? {} : { durability: { rung: rung3.rung } }),
|
|
740
739
|
};
|
|
741
|
-
|
|
742
|
-
|
|
740
|
+
// The published keyId is never a secret, whichever variable carried it
|
|
741
|
+
// (evidenceSecretsFromEnv: the `_ID` exclusion is the rule, `except` is
|
|
742
|
+
// the belt for a host that names its env var differently). Scrubbed BEFORE
|
|
743
|
+
// the digest and the signature (sealBundleV3), so the signed rows are the
|
|
744
|
+
// written rows.
|
|
745
|
+
const secrets3 = evidenceSecretsFromEnv(process.env, [apiKey, signingKeyPem], [keyId]);
|
|
746
|
+
const sealed = sealBundleV3(draft, secrets3, signingKeyPem, keyId);
|
|
747
|
+
const { unsigned } = sealed;
|
|
748
|
+
const v3Out: BundleV3 = sealed.bundle;
|
|
749
|
+
if (sealed.redactedAt.length > 0) {
|
|
750
|
+
process.stderr.write(`openwop-conformance --certify: REDACTED ${sealed.redactedAt.length} evidence field(s) that carried a configured secret or the conformance canary: ${sealed.redactedAt.slice(0, 8).join(', ')}${sealed.redactedAt.length > 8 ? ', …' : ''}\n`);
|
|
751
|
+
}
|
|
743
752
|
if (args.evidenceTier === 'independent') {
|
|
744
753
|
const vk = args.verifierKeyPath ? readFileSync(args.verifierKeyPath, 'utf8') : process.env['OPENWOP_BUNDLE_VERIFIER_KEY'];
|
|
745
754
|
const vkId = args.verifierKeyId ?? process.env['OPENWOP_BUNDLE_VERIFIER_KEY_ID'];
|
|
746
755
|
if (!vk || !vkId) { process.stderr.write('openwop-conformance --certify: --evidence-tier independent needs --verifier-key and --verifier-key-id (RFC 0168 §E.2)\n'); process.exit(2); }
|
|
747
|
-
|
|
756
|
+
v3Out.verifierSignature = verifierSign(unsigned, vk, vkId);
|
|
748
757
|
}
|
|
749
|
-
// The published keyId is never a secret, whichever variable carried it
|
|
750
|
-
// (evidenceSecretsFromEnv: the `_ID` exclusion is the rule, `except` is
|
|
751
|
-
// the belt for a host that names its env var differently).
|
|
752
|
-
const secrets3 = evidenceSecretsFromEnv(process.env, [apiKey, signingKeyPem], [keyId]);
|
|
753
|
-
const scrubbed3 = scrubEvidence(v3, secrets3);
|
|
754
|
-
const v3Out = scrubbed3.value as BundleV3;
|
|
755
758
|
const audit3 = verifyBundleV3(v3Out, { hostPublicKeyPem: publicKeyFromPrivate(signingKeyPem) });
|
|
756
759
|
// `relaxed-profile-certified` is no longer excused: the emitter now computes
|
|
757
760
|
// the relaxation into `certified`, so if the verifier still finds that
|
|
@@ -22,6 +22,7 @@ import { profileDerivable, type DiscoveryPayload } from './profiles.js';
|
|
|
22
22
|
import { checkRungClaim, type DurabilityRung, type RowEvidence } from './durability-evidence.js';
|
|
23
23
|
import { profilesDeniedByObservedRelaxation, profilesRelaxedBy, v2RegistryAvailable } from './v2-profiles.js';
|
|
24
24
|
import { canonicalJSON, codeUnitCompare, JcsRefusal } from './jcs.js';
|
|
25
|
+
import { scrubEvidence } from './certification-bundle-verify.js';
|
|
25
26
|
|
|
26
27
|
export type BundleV3Result = 'executed-pass' | 'executed-fail' | 'skipped' | 'inapplicable' | 'blocked';
|
|
27
28
|
|
|
@@ -145,6 +146,30 @@ export function verifierSign(bundle: Pick<BundleV3, 'witnessSha256' | 'host' | '
|
|
|
145
146
|
return { alg: 'ed25519', keyId, sig: toBase64url(edSign(null, attestationPayload(bundle), key)) };
|
|
146
147
|
}
|
|
147
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Scrub, THEN digest, THEN sign (RFC 0148 §C + RFC 0168 §E.2).
|
|
151
|
+
*
|
|
152
|
+
* Until 2.43.1 the emitter computed `witnessSha256` over the raw rows, signed,
|
|
153
|
+
* and only then ran `scrubEvidence` over the finished bundle. A secret found in
|
|
154
|
+
* a row's `detail` or `evidence` was redacted AFTER the digest was taken, so
|
|
155
|
+
* the written rows no longer hashed to `witnessSha256` and the emitter's own
|
|
156
|
+
* self-verification refused the bundle (`[witness-digest]`, exit 2, no bundle)
|
|
157
|
+
* — openwop-app at major 1 on suite 2.43.0, 2026-09-28. The redaction was
|
|
158
|
+
* right and the check was right; the order was wrong. Everything the digest and
|
|
159
|
+
* the attestation cover is now scrubbed before either is computed, so the
|
|
160
|
+
* signed bytes are the shipped bytes.
|
|
161
|
+
*/
|
|
162
|
+
export function sealBundleV3(
|
|
163
|
+
draft: Omit<BundleV3, 'signature' | 'witnessSha256'>,
|
|
164
|
+
secrets: readonly string[],
|
|
165
|
+
privateKeyPem: string,
|
|
166
|
+
keyId: string,
|
|
167
|
+
): { unsigned: Omit<BundleV3, 'signature'>; bundle: BundleV3; redactedAt: readonly string[] } {
|
|
168
|
+
const { value: scrubbed, redactedAt } = scrubEvidence(draft, secrets);
|
|
169
|
+
const unsigned: Omit<BundleV3, 'signature'> = { ...scrubbed, witnessSha256: witnessDigest(scrubbed.results.requirements, scrubbed.host.relaxations, scrubbed.host.deployment) };
|
|
170
|
+
return { unsigned, bundle: { ...unsigned, signature: signBundleV3(unsigned, privateKeyPem, keyId) }, redactedAt };
|
|
171
|
+
}
|
|
172
|
+
|
|
148
173
|
export function publicKeyFromPrivate(privateKeyPem: string): string {
|
|
149
174
|
return createPublicKey(createPrivateKey(privateKeyPem)).export({ type: 'spki', format: 'pem' }) as string;
|
|
150
175
|
}
|
|
@@ -20,6 +20,7 @@ import { readErrorCode } from './error-envelope.js';
|
|
|
20
20
|
import type { SoftSkipKind } from './soft-skip.js';
|
|
21
21
|
import { projectBoundId } from './bound-id.js';
|
|
22
22
|
import { hitHeader, type ModalHit } from './webhook-receiver.js';
|
|
23
|
+
import { SEAMS_PREFIX } from './seams.js';
|
|
23
24
|
|
|
24
25
|
export const STANDARD_WEBHOOKS_ALG = 'standard-webhooks-1';
|
|
25
26
|
export const SW_FIXTURE = 'conformance-noop';
|
|
@@ -118,3 +119,55 @@ export function deliveriesFor(hits: readonly ModalHit[], webhookId: string, runI
|
|
|
118
119
|
export function standardWebhooksHeaderNames(hit: ModalHit): string[] {
|
|
119
120
|
return Object.keys(hit.headers).filter((k) => k.toLowerCase().startsWith('webhook-'));
|
|
120
121
|
}
|
|
122
|
+
|
|
123
|
+
export const ROTATION_OVERLAP_SEAM = `${SEAMS_PREFIX}/sample/webhooks/rotation-overlap`;
|
|
124
|
+
|
|
125
|
+
export type ShortenedOverlap =
|
|
126
|
+
| { readonly ok: true; readonly expiresAt: number }
|
|
127
|
+
| { readonly ok: false; readonly unserved: true; readonly reason: string }
|
|
128
|
+
| { readonly ok: false; readonly unserved: false; readonly reason: string };
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Shorten the rotation overlap in progress on ONE subscription
|
|
132
|
+
* (host-sample-test-seams.md §29, `shortenRotationOverlap`), so the RFC 0201
|
|
133
|
+
* §E.20 post-overlap leg can be observed on a host whose advertised
|
|
134
|
+
* `overlapSeconds` outlasts the wait cap. The seam only moves the stored expiry;
|
|
135
|
+
* which secret signs after it is the production signer's decision, and that is
|
|
136
|
+
* what the caller asserts on. UNSERVED (404/405/501) is reported apart from a
|
|
137
|
+
* served seam that breaks its contract: the seam is OPTIONAL and newer than the
|
|
138
|
+
* leg, so its absence falls back to the pre-seam partial witness.
|
|
139
|
+
*
|
|
140
|
+
* The contract checked here: `200 { webhookId, previousSecretExpiresAt }` with
|
|
141
|
+
* the expiry `overlapSeconds` after the request (±1 s), and a foreign-tenant id
|
|
142
|
+
* refused `403 id_tenant_mismatch` before the lookup, as `rotateWebhookSecret`
|
|
143
|
+
* refuses it. The real call goes first so that a 404 can only mean "unserved".
|
|
144
|
+
*/
|
|
145
|
+
export async function shortenRotationOverlap(webhookId: string, overlapSeconds: number, foreignWebhookId: string): Promise<ShortenedOverlap> {
|
|
146
|
+
const before = Date.now();
|
|
147
|
+
let res: OpenWOPResponse;
|
|
148
|
+
try { res = await driver.post(ROTATION_OVERLAP_SEAM, { webhookId, overlapSeconds }); } catch {
|
|
149
|
+
return { ok: false, unserved: false, reason: `${ROTATION_OVERLAP_SEAM} unreachable (fetch failed)` };
|
|
150
|
+
}
|
|
151
|
+
const after = Date.now();
|
|
152
|
+
if (res.status === 404 || res.status === 405 || res.status === 501) {
|
|
153
|
+
return { ok: false, unserved: true, reason: `${ROTATION_OVERLAP_SEAM} answered ${res.status} — the host does not serve the optional shortenRotationOverlap seam (host-sample-test-seams.md §29)` };
|
|
154
|
+
}
|
|
155
|
+
const body = (res.json ?? {}) as { webhookId?: unknown; previousSecretExpiresAt?: unknown };
|
|
156
|
+
const expiresAt = typeof body.previousSecretExpiresAt === 'string' ? Date.parse(body.previousSecretExpiresAt) : Number.NaN;
|
|
157
|
+
if (res.status !== 200 || body.webhookId !== webhookId || !Number.isFinite(expiresAt)) {
|
|
158
|
+
return { ok: false, unserved: false, reason: `${ROTATION_OVERLAP_SEAM} answered ${res.status} ${readErrorCode(res.json) ?? ''} without 200 { webhookId, previousSecretExpiresAt } for ${webhookId} — the seam contract (api/seams-v2.yaml shortenRotationOverlap) is not honoured`.replace(/ +/g, ' ') };
|
|
159
|
+
}
|
|
160
|
+
const lo = before + overlapSeconds * 1000 - 1000;
|
|
161
|
+
const hi = after + overlapSeconds * 1000 + 1000;
|
|
162
|
+
if (expiresAt < lo || expiresAt > hi) {
|
|
163
|
+
return { ok: false, unserved: false, reason: `${ROTATION_OVERLAP_SEAM} reported previousSecretExpiresAt ${body.previousSecretExpiresAt as string}, not ${overlapSeconds}s after the request (window ${new Date(lo).toISOString()}..${new Date(hi).toISOString()}) — the overlap it reports is not the one it was asked to set` };
|
|
164
|
+
}
|
|
165
|
+
let cross: OpenWOPResponse;
|
|
166
|
+
try { cross = await driver.post(ROTATION_OVERLAP_SEAM, { webhookId: foreignWebhookId, overlapSeconds }); } catch {
|
|
167
|
+
return { ok: false, unserved: false, reason: `${ROTATION_OVERLAP_SEAM} unreachable on the foreign-tenant probe (fetch failed)` };
|
|
168
|
+
}
|
|
169
|
+
if (cross.status !== 403 || readErrorCode(cross.json) !== 'id_tenant_mismatch') {
|
|
170
|
+
return { ok: false, unserved: false, reason: `${ROTATION_OVERLAP_SEAM} answered ${cross.status} ${readErrorCode(cross.json) ?? ''} for a foreign-tenant webhookId — the seam MUST be tenant-checked before the lookup (403 id_tenant_mismatch), exactly as rotateWebhookSecret`.replace(/ +/g, ' ') };
|
|
171
|
+
}
|
|
172
|
+
return { ok: true, expiresAt };
|
|
173
|
+
}
|
|
@@ -29,10 +29,31 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
import { describe, it, expect } from 'vitest';
|
|
32
|
+
import { readFileSync } from 'node:fs';
|
|
33
|
+
import { join } from 'node:path';
|
|
34
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
35
|
+
import addFormats from 'ajv-formats';
|
|
32
36
|
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
33
37
|
import { readOrgChartCap, getOrgChartResponse, getDepartmentView, type OrgChart } from '../lib/agentOrgChart.js';
|
|
38
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
34
39
|
import { req } from '../lib/requirement-ids.js';
|
|
35
40
|
|
|
41
|
+
function loadSchema(name: string): Record<string, unknown> {
|
|
42
|
+
return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A validator for `schemas/<name>.schema.json` (this file targets major 1;
|
|
46
|
+
* the major-2 twin is `v2-agent-org-chart-served-shape.test.ts`). The
|
|
47
|
+
* responsibility view `$ref`s the chart's `$defs`, so the chart schema is
|
|
48
|
+
* registered first. */
|
|
49
|
+
function servedShapeValidator(name: 'agent-org-chart' | 'org-chart-responsibility-view'): (d: unknown) => { ok: boolean; errors: string } {
|
|
50
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
51
|
+
addFormats(ajv);
|
|
52
|
+
if (name !== 'agent-org-chart') ajv.addSchema(loadSchema('agent-org-chart.schema.json'));
|
|
53
|
+
const validate = ajv.compile(loadSchema(`${name}.schema.json`));
|
|
54
|
+
return (d: unknown) => ({ ok: validate(d) as boolean, errors: ajv.errorsText(validate.errors, { separator: '; ' }) });
|
|
55
|
+
}
|
|
56
|
+
|
|
36
57
|
const ROSTER_ID_RE = /^host:[a-z0-9][a-z0-9._-]*$/;
|
|
37
58
|
|
|
38
59
|
describe('agent-org-chart-scoping (RFC 0087 §A/§C/§D)', () => {
|
|
@@ -159,3 +180,45 @@ describe('agent-org-chart-scoping (RFC 0087 §A/§C/§D)', () => {
|
|
|
159
180
|
}
|
|
160
181
|
});
|
|
161
182
|
});
|
|
183
|
+
|
|
184
|
+
// Org-chart served-shape leg (2026-09-28). Leg 1 above walks the chart's tree
|
|
185
|
+
// and member references but never validated the body against the schema, so a
|
|
186
|
+
// host serving its stored record (`{ tenantId, departments, members, updatedAt }`
|
|
187
|
+
// — `owner` missing, two extra keys on an `additionalProperties:false` object)
|
|
188
|
+
// passed at both majors; openwop-app did exactly that (fixed in openwop-app
|
|
189
|
+
// #4189). This leg validates the SERVED bodies of the normative read pair
|
|
190
|
+
// against `agent-org-chart.schema.json` / `org-chart-responsibility-view.schema.json`;
|
|
191
|
+
// `v2-agent-org-chart-served-shape.test.ts` is the same leg at major 2.
|
|
192
|
+
describe('agent-org-chart-scoping: served shape (RFC 0087 §A/§D)', () => {
|
|
193
|
+
it('serves the org-chart read pair in the exact schema shape', async () => {
|
|
194
|
+
const cap = await readOrgChartCap();
|
|
195
|
+
if (!behaviorGate('openwop-org-chart-scoping', cap?.supported === true)) return;
|
|
196
|
+
|
|
197
|
+
const chartRes = await getOrgChartResponse();
|
|
198
|
+
expect(
|
|
199
|
+
chartRes.status,
|
|
200
|
+
req('openwop.it.agent-org-chart-scoping.serves-the-org-chart-read-pair-in-the-exact-schema-shape', 'agent-org-chart.md §A / §E', 'a host advertising agents.orgChart.supported MUST serve GET /v1/agents/org-chart with 200'),
|
|
201
|
+
).toBe(200);
|
|
202
|
+
const chartCheck = servedShapeValidator('agent-org-chart')(chartRes.chart);
|
|
203
|
+
expect(
|
|
204
|
+
chartCheck.ok ? 'valid' : chartCheck.errors,
|
|
205
|
+
req('openwop.it.agent-org-chart-scoping.serves-the-org-chart-read-pair-in-the-exact-schema-shape', 'agent-org-chart.schema.json (RFC 0087 §A)', 'the served GET /v1/agents/org-chart body MUST validate against agent-org-chart.schema.json (owner required; no extra keys)'),
|
|
206
|
+
).toBe('valid');
|
|
207
|
+
|
|
208
|
+
const probeDeptId = chartRes.chart?.departments?.[0]?.departmentId;
|
|
209
|
+
// The view leg needs a department to read; an empty chart is still
|
|
210
|
+
// validated above (an empty chart is a conforming chart).
|
|
211
|
+
if (cap?.responsibilityView === true && typeof probeDeptId === 'string') {
|
|
212
|
+
const { status, view } = await getDepartmentView(probeDeptId);
|
|
213
|
+
expect(
|
|
214
|
+
status,
|
|
215
|
+
req('openwop.it.agent-org-chart-scoping.serves-the-org-chart-read-pair-in-the-exact-schema-shape', 'agent-org-chart.md §D / §E', "a host advertising agents.orgChart.responsibilityView MUST serve GET /v1/agents/org-chart/{departmentId} with 200 for a department in the caller's chart"),
|
|
216
|
+
).toBe(200);
|
|
217
|
+
const viewCheck = servedShapeValidator('org-chart-responsibility-view')(view);
|
|
218
|
+
expect(
|
|
219
|
+
viewCheck.ok ? 'valid' : viewCheck.errors,
|
|
220
|
+
req('openwop.it.agent-org-chart-scoping.serves-the-org-chart-read-pair-in-the-exact-schema-shape', 'org-chart-responsibility-view.schema.json (RFC 0087 §D)', 'the served GET /v1/agents/org-chart/{departmentId} body MUST validate against org-chart-responsibility-view.schema.json'),
|
|
221
|
+
).toBe('valid');
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0087 §A/§D — the SERVED bodies of the org-chart read pair validate against
|
|
3
|
+
* `schemas/v2/agent-org-chart.schema.json` and
|
|
4
|
+
* `schemas/v2/org-chart-responsibility-view.schema.json`. Target major 2; the
|
|
5
|
+
* major-1 twin is the served-shape leg of `agent-org-chart-scoping.test.ts`.
|
|
6
|
+
*
|
|
7
|
+
* Why this exists: until 2026-09-28 only a server-free probe compiled the
|
|
8
|
+
* schema. openwop-app served its stored record (`{ tenantId, departments,
|
|
9
|
+
* members, updatedAt }` — `owner` missing, two extra keys on an
|
|
10
|
+
* `additionalProperties:false` object) on `GET /agents/org-chart` at both
|
|
11
|
+
* majors and nothing in the suite noticed (fixed in openwop-app #4189).
|
|
12
|
+
*
|
|
13
|
+
* Gate: the v2 `agents` record carries `orgChart` (presence is the claim,
|
|
14
|
+
* capabilities.md; there is no `supported` seat), else `inapplicable` — never a
|
|
15
|
+
* strict-mode failure. The department leg runs when the record carries
|
|
16
|
+
* `responsibilityView: true` and the chart has a department to read.
|
|
17
|
+
*
|
|
18
|
+
* @see api/v2/openapi.yaml `/agents/org-chart`, `/agents/org-chart/{departmentId}`
|
|
19
|
+
* @see RFCS/0087-agent-org-chart.md
|
|
20
|
+
*/
|
|
21
|
+
import { describe, it, expect } from 'vitest';
|
|
22
|
+
import { driver } from '../lib/driver.js';
|
|
23
|
+
import { familyAdvertised, v2Validator } from '../lib/v2.js';
|
|
24
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
25
|
+
import { req } from '../lib/requirement-ids.js';
|
|
26
|
+
|
|
27
|
+
const ID = 'openwop.it.v2-agent-org-chart-served-shape.serves-the-org-chart-read-pair-in-the-exact-v2-schema-shape';
|
|
28
|
+
|
|
29
|
+
describe('RFC 0087 §A/§D — org-chart served shape (major 2, gated on agents.orgChart)', () => {
|
|
30
|
+
it('serves the org-chart read pair in the exact v2 schema shape', async () => {
|
|
31
|
+
const agents = await familyAdvertised('agents');
|
|
32
|
+
const orgChart = agents?.['orgChart'];
|
|
33
|
+
if (!orgChart || typeof orgChart !== 'object') return softSkip('inapplicable', 'agents.orgChart not advertised in the v2 discovery document');
|
|
34
|
+
|
|
35
|
+
const chartRes = await driver.get('/agents/org-chart');
|
|
36
|
+
expect(
|
|
37
|
+
chartRes.status,
|
|
38
|
+
req(ID, 'api/v2/openapi.yaml /agents/org-chart (RFC 0087 §D)', 'a host advertising agents.orgChart MUST serve GET /agents/org-chart with 200'),
|
|
39
|
+
).toBe(200);
|
|
40
|
+
const chartCheck = v2Validator('agent-org-chart')(chartRes.json);
|
|
41
|
+
expect(
|
|
42
|
+
chartCheck.ok ? 'valid' : chartCheck.errors,
|
|
43
|
+
req(ID, 'schemas/v2/agent-org-chart.schema.json (RFC 0087 §A)', 'the served GET /agents/org-chart body MUST validate against agent-org-chart.schema.json (owner required; no extra keys)'),
|
|
44
|
+
).toBe('valid');
|
|
45
|
+
|
|
46
|
+
const departments = (chartRes.json as { departments?: Array<{ departmentId?: unknown }> } | undefined)?.departments;
|
|
47
|
+
const probeDeptId = Array.isArray(departments) ? departments[0]?.departmentId : undefined;
|
|
48
|
+
// The view leg needs a department to read; an empty chart is still
|
|
49
|
+
// validated above (an empty chart is a conforming chart).
|
|
50
|
+
if ((orgChart as Record<string, unknown>)['responsibilityView'] === true && typeof probeDeptId === 'string') {
|
|
51
|
+
const view = await driver.get(`/agents/org-chart/${encodeURIComponent(probeDeptId)}`);
|
|
52
|
+
expect(
|
|
53
|
+
view.status,
|
|
54
|
+
req(ID, 'api/v2/openapi.yaml /agents/org-chart/{departmentId} (RFC 0087 §D)', "a host advertising agents.orgChart.responsibilityView MUST serve GET /agents/org-chart/{departmentId} with 200 for a department in the caller's chart"),
|
|
55
|
+
).toBe(200);
|
|
56
|
+
const viewCheck = v2Validator('org-chart-responsibility-view')(view.json);
|
|
57
|
+
expect(
|
|
58
|
+
viewCheck.ok ? 'valid' : viewCheck.errors,
|
|
59
|
+
req(ID, 'schemas/v2/org-chart-responsibility-view.schema.json (RFC 0087 §D)', 'the served GET /agents/org-chart/{departmentId} body MUST validate against org-chart-responsibility-view.schema.json'),
|
|
60
|
+
).toBe('valid');
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -14,14 +14,32 @@
|
|
|
14
14
|
* `retryable: false`, and `run.failed.failedNodeId` names the gate;
|
|
15
15
|
* 4. `conformance-interrupt-quorum` (requiredApprovals 3, `majority`): one
|
|
16
16
|
* reject leaves the run `waiting-approval`; a second decides the gate and
|
|
17
|
-
* the run terminates `failed` with `approval_rejected
|
|
17
|
+
* the run terminates `failed` with `approval_rejected`;
|
|
18
|
+
* 5. `conformance-approval-reject-routed` (gap G1, suite 2.43.1): rejected, the
|
|
19
|
+
* gate fails with `approval_rejected` / `retryable: false`, its
|
|
20
|
+
* `any_failed` target runs, its `all_success` target never runs, and the
|
|
21
|
+
* run completes;
|
|
22
|
+
* 6. `conformance-approval-timeout` (gap G2, suite 2.43.1): left unresolved
|
|
23
|
+
* past its 1500 ms `timeoutMs` with no `onTimeout`, the host resolves the
|
|
24
|
+
* gate itself (`action: timeout`, `decision: rejected`, `reason: timeout`),
|
|
25
|
+
* the gate fails with `approval_rejected` and `run.failed` names it;
|
|
26
|
+
* 7. `conformance-approval-timeout-approve` (openwop#1696, suite 2.43.1): the
|
|
27
|
+
* same gate with `onTimeout: "approve"` still resolves rejected — a
|
|
28
|
+
* timeout never grants — and the run fails, never completes;
|
|
29
|
+
* 8. `conformance-approval-reject-loopback` (openwop#1697, suite 2.43.1): a
|
|
30
|
+
* rejected gate routed back to itself through `revise` is asked again —
|
|
31
|
+
* a second `interrupt.requested` with a different `key`, the run back in
|
|
32
|
+
* `waiting-approval`, and no second `interrupt.resolved` (the first
|
|
33
|
+
* rejection is not replayed). `inapplicable` on a host that does not
|
|
34
|
+
* advertise the fixture, i.e. one that does not run cycles.
|
|
18
35
|
*
|
|
19
36
|
* The legs are separate rows so a host that misses one field fails that row
|
|
20
|
-
* only.
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
37
|
+
* only. Legs 5 and 6 each need their own fixture, because `conformance-approval`
|
|
38
|
+
* has no edges and no timer and widening a fixture every host serves would force
|
|
39
|
+
* a re-registration (fixtures.md §conformance-approval-refine); a host that
|
|
40
|
+
* does not advertise one records `inapplicable`. Leg 5 does not require
|
|
41
|
+
* `node.skipped`: interrupt.md says the `all_success` edge is not satisfied, not
|
|
42
|
+
* how a host records that, so the leg asserts only that the target never ran.
|
|
25
43
|
*
|
|
26
44
|
* The quorum leg presents distinct votes on one bearer through the resume
|
|
27
45
|
* value's `voter` member, as `interrupt-quorum-resolution` does at major 1. A
|
|
@@ -40,11 +58,16 @@ import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
|
40
58
|
import { readErrorCode } from '../lib/error-envelope.js';
|
|
41
59
|
import { softSkip } from '../lib/soft-skip.js';
|
|
42
60
|
import { req } from '../lib/requirement-ids.js';
|
|
61
|
+
import { scaledTimeoutMs } from '../lib/polling.js';
|
|
43
62
|
|
|
44
63
|
const DOC = 'spec/v2/core/interrupt.md §Rejection (RFC 0223)';
|
|
45
64
|
const R = (slug: string): string => `openwop.requirement.0223.${slug}`;
|
|
46
65
|
const FIXTURE = 'conformance-approval';
|
|
47
66
|
const QUORUM = 'conformance-interrupt-quorum';
|
|
67
|
+
const ROUTED = 'conformance-approval-reject-routed';
|
|
68
|
+
const TIMEOUT = 'conformance-approval-timeout';
|
|
69
|
+
const TIMEOUT_APPROVE = 'conformance-approval-timeout-approve';
|
|
70
|
+
const LOOPBACK = 'conformance-approval-reject-loopback';
|
|
48
71
|
const NODE_ID = 'gate';
|
|
49
72
|
const CODE = 'approval_rejected';
|
|
50
73
|
const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
|
|
@@ -159,4 +182,81 @@ describe('RFC 0223 — v2-approval-reject-disposition (gated on interrupt + conf
|
|
|
159
182
|
expect(terminal?.['status'], req(id, DOC, `two rejects of three under majority MUST resolve the gate rejected and fail the run (got ${String(terminal?.['status'])})`)).toBe('failed');
|
|
160
183
|
expect(errorCode(terminal?.['error']), req(id, DOC, 'the terminal snapshot error.code MUST be approval_rejected')).toBe(CODE);
|
|
161
184
|
}, 60_000);
|
|
185
|
+
|
|
186
|
+
it('a rejected gate routed over an any_failed edge fails as a node, its all_success sibling never runs, and the run completes', async () => {
|
|
187
|
+
if (!(await gateFamily('interrupt'))) return softSkip('inapplicable', 'interrupt family not advertised (gate recorded under openwop.family.interrupt)');
|
|
188
|
+
if (!isFixtureAdvertised(ROUTED)) return softSkip('inapplicable', `fixture ${ROUTED} is not advertised — the host does not claim the routed-reject fixture`);
|
|
189
|
+
const s = await suspended(ROUTED);
|
|
190
|
+
if ('reason' in s) return softSkip('blocked', s.reason);
|
|
191
|
+
const res = await resolve(s.runId, { action: 'reject' });
|
|
192
|
+
if (res === null || res.status < 200 || res.status >= 300) return softSkip('blocked', `the reject resolve answered ${res?.status ?? 'nothing'} ${readErrorCode(res?.json) ?? ''} — interrupt resolution owns that contract`);
|
|
193
|
+
const id = R('reject-routed');
|
|
194
|
+
const terminal = await waitStatus(s.runId, TERMINAL, 10_000);
|
|
195
|
+
const evs = await events(s.runId);
|
|
196
|
+
const ran = (nodeId: string, type: string): boolean => evs.some((e) => e.type === type && (e.payload?.['nodeId'] ?? e.nodeId) === nodeId);
|
|
197
|
+
const gateFailed = evs.find((e) => e.type === 'node.failed' && (e.payload?.['nodeId'] ?? e.nodeId) === NODE_ID);
|
|
198
|
+
const err = gateFailed?.payload?.['error'] as { code?: unknown; retryable?: unknown } | undefined;
|
|
199
|
+
expect({ code: err?.code, retryable: err?.retryable }, req(id, DOC, 'the routed gate still fails as a node: node.failed.error MUST carry approval_rejected and retryable false')).toEqual({ code: CODE, retryable: false });
|
|
200
|
+
expect(ran('on-reject', 'node.completed'), req(id, DOC, 'the any_failed edge admits the failed gate: on-reject MUST run to node.completed')).toBe(true);
|
|
201
|
+
expect(ran('on-accept', 'node.started') || ran('on-accept', 'node.completed'), req(id, DOC, 'a rejected gate MUST NOT satisfy an all_success edge: on-accept MUST NOT run')).toBe(false);
|
|
202
|
+
expect(terminal?.['status'], req(id, DOC, `a routed rejection does not fail the run: it MUST complete (got ${String(terminal?.['status'])})`)).toBe('completed');
|
|
203
|
+
}, 45_000);
|
|
204
|
+
|
|
205
|
+
it('an unresolved gate past its timeoutMs with no onTimeout is resolved rejected by the host and fails the run naming the gate', async () => {
|
|
206
|
+
if (!(await gateFamily('interrupt'))) return softSkip('inapplicable', 'interrupt family not advertised (gate recorded under openwop.family.interrupt)');
|
|
207
|
+
if (!isFixtureAdvertised(TIMEOUT)) return softSkip('inapplicable', `fixture ${TIMEOUT} is not advertised — the host does not claim the timeout fixture`);
|
|
208
|
+
const s = await suspended(TIMEOUT);
|
|
209
|
+
if ('reason' in s) return softSkip('blocked', s.reason);
|
|
210
|
+
const id = R('timeout-rejects');
|
|
211
|
+
const terminal = await waitStatus(s.runId, TERMINAL, scaledTimeoutMs(20_000));
|
|
212
|
+
const evs = await events(s.runId);
|
|
213
|
+
const resolved = evs.find((e) => e.type === 'interrupt.resolved');
|
|
214
|
+
expect(
|
|
215
|
+
{ action: resolved?.payload?.['action'], decision: resolved?.payload?.['decision'], reason: resolved?.payload?.['reason'] },
|
|
216
|
+
req(id, DOC, 'the host MUST resolve the timed-out gate itself, recording action "timeout", decision "rejected" and reason "timeout"'),
|
|
217
|
+
).toEqual({ action: 'timeout', decision: 'rejected', reason: 'timeout' });
|
|
218
|
+
const gateFailed = evs.find((e) => e.type === 'node.failed' && (e.payload?.['nodeId'] ?? e.nodeId) === NODE_ID);
|
|
219
|
+
expect(errorCode(gateFailed?.payload?.['error']), req(id, DOC, 'the timed-out gate MUST fail with approval_rejected')).toBe(CODE);
|
|
220
|
+
const runFailed = evs.find((e) => e.type === 'run.failed');
|
|
221
|
+
expect({ status: terminal?.['status'], code: errorCode(runFailed?.payload?.['error']), failedNodeId: runFailed?.payload?.['failedNodeId'] }, req(id, DOC, 'with no routing edge the run MUST terminate failed, run.failed carrying approval_rejected and failedNodeId naming the gate')).toEqual({ status: 'failed', code: CODE, failedNodeId: NODE_ID });
|
|
222
|
+
}, 60_000);
|
|
223
|
+
|
|
224
|
+
it('a gate whose onTimeout is approve still resolves rejected on timeout and fails the run: a timeout never grants', async () => {
|
|
225
|
+
if (!(await gateFamily('interrupt'))) return softSkip('inapplicable', 'interrupt family not advertised (gate recorded under openwop.family.interrupt)');
|
|
226
|
+
if (!isFixtureAdvertised(TIMEOUT_APPROVE)) return softSkip('inapplicable', `fixture ${TIMEOUT_APPROVE} is not advertised — the host does not claim the timeout-approve fixture`);
|
|
227
|
+
const s = await suspended(TIMEOUT_APPROVE);
|
|
228
|
+
if ('reason' in s) return softSkip('blocked', s.reason);
|
|
229
|
+
const id = R('timeout-never-grants');
|
|
230
|
+
const terminal = await waitStatus(s.runId, TERMINAL, scaledTimeoutMs(20_000));
|
|
231
|
+
const evs = await events(s.runId);
|
|
232
|
+
const resolved = evs.find((e) => e.type === 'interrupt.resolved');
|
|
233
|
+
expect(
|
|
234
|
+
{ action: resolved?.payload?.['action'], decision: resolved?.payload?.['decision'] },
|
|
235
|
+
req(id, DOC, 'whatever onTimeout holds, the host MUST resolve the timed-out gate rejected (action "timeout", decision "rejected") — onTimeout "approve" is treated as reject'),
|
|
236
|
+
).toEqual({ action: 'timeout', decision: 'rejected' });
|
|
237
|
+
const gateFailed = evs.find((e) => e.type === 'node.failed' && (e.payload?.['nodeId'] ?? e.nodeId) === NODE_ID);
|
|
238
|
+
expect(errorCode(gateFailed?.payload?.['error']), req(id, DOC, 'the timed-out gate MUST fail with approval_rejected')).toBe(CODE);
|
|
239
|
+
expect(terminal?.['status'], req(id, DOC, `a timeout MUST NOT grant a gate: with no routing edge the run MUST terminate failed, never completed (got ${String(terminal?.['status'])})`)).toBe('failed');
|
|
240
|
+
}, 60_000);
|
|
241
|
+
|
|
242
|
+
it('a rejected gate looped back to itself is asked again under a new key, and the first rejection is not replayed', async () => {
|
|
243
|
+
if (!(await gateFamily('interrupt'))) return softSkip('inapplicable', 'interrupt family not advertised (gate recorded under openwop.family.interrupt)');
|
|
244
|
+
if (!isFixtureAdvertised(LOOPBACK)) return softSkip('inapplicable', `fixture ${LOOPBACK} is not advertised — the host does not run cycles`);
|
|
245
|
+
const s = await suspended(LOOPBACK);
|
|
246
|
+
if ('reason' in s) return softSkip('blocked', s.reason);
|
|
247
|
+
const res = await resolve(s.runId, { action: 'reject' });
|
|
248
|
+
if (res === null || res.status < 200 || res.status >= 300) return softSkip('blocked', `the reject resolve answered ${res?.status ?? 'nothing'} ${readErrorCode(res?.json) ?? ''} — interrupt resolution owns that contract`);
|
|
249
|
+
const id = R('reject-loopback-reasks');
|
|
250
|
+
const gateRequests = (evs: Ev[]): Ev[] => evs.filter((e) => e.type === 'interrupt.requested' && (e.nodeId ?? e.payload?.['nodeId']) === NODE_ID);
|
|
251
|
+
const deadline = Date.now() + scaledTimeoutMs(10_000);
|
|
252
|
+
let evs = await events(s.runId);
|
|
253
|
+
while (gateRequests(evs).length < 2 && Date.now() < deadline) { await new Promise((r) => setTimeout(r, 250)); evs = await events(s.runId); }
|
|
254
|
+
const snap = await snapshot(s.runId);
|
|
255
|
+
const [first, second] = gateRequests(evs);
|
|
256
|
+
expect(second, req(id, 'spec/v2/core/interrupt.md §Re-entry and resume values', 'the second visit of the gate MUST raise a new interrupt.requested')).toBeDefined();
|
|
257
|
+
expect(second?.payload?.['key'] !== undefined && second?.payload?.['key'] !== first?.payload?.['key'], req(id, 'spec/v2/core/interrupt.md §Re-entry and resume values', `a later execution of the node MUST derive a different key (got ${String(first?.payload?.['key'])} then ${String(second?.payload?.['key'])})`)).toBe(true);
|
|
258
|
+
expect(snap?.['status'], req(id, 'spec/v2/core/interrupt.md §Re-entry and resume values', 'the second visit MUST wait for a new decision: the run is waiting-approval again')).toBe('waiting-approval');
|
|
259
|
+
expect(evs.filter((e) => e.type === 'interrupt.resolved').length, req(id, 'spec/v2/core/interrupt.md §Re-entry and resume values', 'an earlier visit\'s resumeValue MUST NOT be returned: only the one caller resolve is recorded')).toBe(1);
|
|
260
|
+
await http(() => driver.post(`/runs/${enc(s.runId)}/cancel`, {}));
|
|
261
|
+
}, 45_000);
|
|
162
262
|
});
|
|
@@ -10,6 +10,14 @@
|
|
|
10
10
|
* lives in `Retry-After` only, which a `429 rate_limited` MUST set. The
|
|
11
11
|
* deliberately bad request is `GET /runs/does-not-exist` (outside the runId
|
|
12
12
|
* grammar, so a host may answer `400 validation_error` or `404 not_found`).
|
|
13
|
+
*
|
|
14
|
+
* `event-code-registered` (openwop#1698, suite 2.43.1): errors.md §The
|
|
15
|
+
* registry binds `error.code` on `run.failed`, `node.failed` and the snapshot's
|
|
16
|
+
* `error` exactly as it binds an error response. The leg runs
|
|
17
|
+
* `conformance-failure` and checks every such code against the registry and the
|
|
18
|
+
* vendor pattern (first segment an org registered in `spec/v2/declaration.json`).
|
|
19
|
+
* ADVISORY for suite 2.43.1: a violation is recorded as a partial witness naming
|
|
20
|
+
* the codes, not a failure, while hosts remap (per-host rows in gaps.json).
|
|
13
21
|
*/
|
|
14
22
|
|
|
15
23
|
import { describe, it, expect } from 'vitest';
|
|
@@ -25,6 +33,8 @@ import { req } from '../lib/requirement-ids.js';
|
|
|
25
33
|
const DOC = 'spec/v2/core/errors.md §The envelope';
|
|
26
34
|
const ERRORS_PATH = join(SCHEMAS_DIR, '..', 'spec', 'v2', 'errors.json');
|
|
27
35
|
const RETRY_SPELLINGS = ['retryAfter', 'retryAfterMs', 'retryAfterSeconds'];
|
|
36
|
+
const DECLARATION_PATH = join(SCHEMAS_DIR, '..', 'spec', 'v2', 'declaration.json');
|
|
37
|
+
const FAILURE_FIXTURE = 'conformance-failure';
|
|
28
38
|
|
|
29
39
|
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
30
40
|
try { return await v2Discovery(); } catch { return null; }
|
|
@@ -40,6 +50,16 @@ function registryStatus(): Map<string, number> | null {
|
|
|
40
50
|
return new Map(rows.map((r) => [r.code, r.httpStatus]));
|
|
41
51
|
}
|
|
42
52
|
|
|
53
|
+
/** code → registered, or a vendor code whose org is registered; null when the registry or declaration is absent. */
|
|
54
|
+
function codeAccepted(): ((code: string) => boolean) | null {
|
|
55
|
+
const registry = registryStatus();
|
|
56
|
+
if (registry === null || !existsSync(DECLARATION_PATH)) return null;
|
|
57
|
+
const reg = JSON.parse(readFileSync(ERRORS_PATH, 'utf8')) as { vendorCodePattern?: string };
|
|
58
|
+
const pattern = new RegExp(reg.vendorCodePattern ?? '^(?!openwop\\.)[a-z][a-z0-9]*(-[a-z0-9]+)*\\.[a-z][a-z0-9_]*$');
|
|
59
|
+
const orgs = new Set(Object.keys((JSON.parse(readFileSync(DECLARATION_PATH, 'utf8')) as { extensions?: Record<string, unknown> }).extensions ?? {}));
|
|
60
|
+
return (code: string) => registry.has(code) || (pattern.test(code) && orgs.has(code.split('.')[0] ?? ''));
|
|
61
|
+
}
|
|
62
|
+
|
|
43
63
|
async function badRequest(): Promise<OpenWOPResponse | { reason: string }> {
|
|
44
64
|
if (!(await discovery())) return { reason: 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0' };
|
|
45
65
|
const res = await http(() => driver.get('/runs/does-not-exist'));
|
|
@@ -86,4 +106,43 @@ describe('v2 error-registry (RFC 0171 §B.1–§B.2)', () => {
|
|
|
86
106
|
expect(r.headers.get('retry-after'), req('openwop.requirement.0171.error-registry.retry-after', 'spec/v2/core/errors.md §Retry timing', 'a 429 rate_limited response MUST set Retry-After')).not.toBeNull();
|
|
87
107
|
}
|
|
88
108
|
});
|
|
109
|
+
|
|
110
|
+
it('an error code on run.failed, node.failed and the snapshot is registered or a vendor code (advisory in 2.43.1)', async () => {
|
|
111
|
+
if (!(await discovery())) return softSkip('blocked', 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0');
|
|
112
|
+
const accepted = codeAccepted();
|
|
113
|
+
if (accepted === null) return softSkip('inapplicable', 'spec/v2/errors.json or spec/v2/declaration.json is not present in this layout');
|
|
114
|
+
const fixtures = ((await discovery())?.['fixtures'] ?? []) as unknown[];
|
|
115
|
+
if (!fixtures.includes(FAILURE_FIXTURE)) return softSkip('blocked', `fixture ${FAILURE_FIXTURE} is not advertised — no failing run to read codes from`);
|
|
116
|
+
const create = await http(() => driver.post('/runs', { workflowId: FAILURE_FIXTURE }));
|
|
117
|
+
const runId = (create?.json as { runId?: unknown } | null)?.runId;
|
|
118
|
+
if (create === null || create.status !== 201 || typeof runId !== 'string') return softSkip('blocked', `POST /runs (${FAILURE_FIXTURE}) answered ${create?.status ?? 'nothing'}`);
|
|
119
|
+
const deadline = Date.now() + 10_000;
|
|
120
|
+
let snap: Record<string, unknown> | null = null;
|
|
121
|
+
for (;;) {
|
|
122
|
+
const r = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}`));
|
|
123
|
+
snap = r?.status === 200 ? (r.json as Record<string, unknown>) : null;
|
|
124
|
+
if (snap !== null && ['completed', 'failed', 'cancelled'].includes(String(snap['status']))) break;
|
|
125
|
+
if (Date.now() > deadline) return softSkip('blocked', `the ${FAILURE_FIXTURE} run did not reach a terminal state within 10 s`);
|
|
126
|
+
await new Promise((ok) => setTimeout(ok, 250));
|
|
127
|
+
}
|
|
128
|
+
const polled = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`));
|
|
129
|
+
const events = ((polled?.json as { events?: Array<{ type?: string; payload?: { error?: { code?: unknown } } }> } | null)?.events ?? []);
|
|
130
|
+
const seen: Array<{ where: string; code: string }> = [];
|
|
131
|
+
for (const e of events) {
|
|
132
|
+
const code = e.payload?.error?.code;
|
|
133
|
+
if ((e.type === 'run.failed' || e.type === 'node.failed') && typeof code === 'string') seen.push({ where: e.type, code });
|
|
134
|
+
}
|
|
135
|
+
const snapCode = (snap?.['error'] as { code?: unknown } | undefined)?.code;
|
|
136
|
+
if (typeof snapCode === 'string') seen.push({ where: 'snapshot.error', code: snapCode });
|
|
137
|
+
const ID = 'openwop.requirement.errors.event-code-registered';
|
|
138
|
+
expect(seen.length, req(ID, 'spec/v2/core/errors.md §The registry', `a failed ${FAILURE_FIXTURE} run MUST carry an error code on run.failed, node.failed or the snapshot (status ${String(snap?.['status'])})`)).toBeGreaterThan(0);
|
|
139
|
+
const bad = seen.filter((s) => !accepted(s.code));
|
|
140
|
+
if (bad.length > 0) {
|
|
141
|
+
// partial-witness-ok: ADVISORY for suite 2.43.1 (openwop#1698). The codes were
|
|
142
|
+
// observed and checked above; a violation is recorded, not failed, for one
|
|
143
|
+
// suite patch while hosts remap. Per-host gap rows track them.
|
|
144
|
+
return softSkip('inapplicable', `ADVISORY (suite 2.43.1, openwop#1698): unregistered, non-vendor error codes: ${bad.map((b) => `${b.where}=${b.code}`).join(', ')}`);
|
|
145
|
+
}
|
|
146
|
+
expect(bad, req(ID, 'spec/v2/core/errors.md §The registry', 'every error code on run.failed, node.failed and the snapshot MUST be registered or a vendor code')).toEqual([]);
|
|
147
|
+
}, 45_000);
|
|
89
148
|
});
|
|
@@ -14,10 +14,12 @@
|
|
|
14
14
|
* 2. `>=1.0.0 <2.0.0` (v1 ceiling) → refused, pack_engine_unsupported
|
|
15
15
|
* 3. `>=2.0.0 <3.0.0` (admits major 2) → installs (200/201)
|
|
16
16
|
*
|
|
17
|
-
* Gated on the
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* install path
|
|
17
|
+
* Gated on the seams profile (`conformance.seamsProfile:
|
|
18
|
+
* openwop-conformance-seams-v2`): its publish seam is the only install path the
|
|
19
|
+
* suite can drive. Not gated on `packs` since suite 2.43.1 (#1706): §A.1 binds
|
|
20
|
+
* at install on every path, and `packs` advertises registry resolution, which a
|
|
21
|
+
* host installing only local packs correctly omits. A seam 404 records `blocked`
|
|
22
|
+
* when `packs` is advertised and `inapplicable` when it is not.
|
|
21
23
|
*
|
|
22
24
|
* @see RFCS/0177-v2-registry-packs-and-extension-tail.md §A.1
|
|
23
25
|
* @see spec/v2/core/packs.md §"The engine range"
|
|
@@ -31,7 +33,7 @@ import { readErrorCode } from '../lib/error-envelope.js';
|
|
|
31
33
|
import { req } from '../lib/requirement-ids.js';
|
|
32
34
|
import { softSkip, type SoftSkipKind } from '../lib/soft-skip.js';
|
|
33
35
|
import { seamsProfileAdvertised, targetMajor } from '../lib/seams.js';
|
|
34
|
-
import { v2Discovery,
|
|
36
|
+
import { v2Discovery, familyAdvertised } from '../lib/v2.js';
|
|
35
37
|
|
|
36
38
|
const SECTION = 'packs.md §"The engine range" (RFC 0177 §A.1)';
|
|
37
39
|
|
|
@@ -87,6 +89,17 @@ async function publish(manifest: Record<string, unknown>) {
|
|
|
87
89
|
}
|
|
88
90
|
|
|
89
91
|
/** The reason this leg cannot run, or null when the seam is reachable. */
|
|
92
|
+
/**
|
|
93
|
+
* The publish seam answered 404. Whether that is a defect depends on `packs`
|
|
94
|
+
* (#1706): a host advertising `packs` claims an install path and must mount it
|
|
95
|
+
* (`blocked`); a host that omits `packs` may have no install path the suite can
|
|
96
|
+
* drive (`inapplicable`). §A.1 binds at install on every path, not on `packs`.
|
|
97
|
+
*/
|
|
98
|
+
async function seamMissing(): Promise<undefined> {
|
|
99
|
+
if (await familyAdvertised('packs')) return softSkip('blocked', 'packs-test publish seam answered 404 — packs and the seams profile are advertised but the seam is not mounted');
|
|
100
|
+
return softSkip('inapplicable', 'packs-test publish seam answered 404 and packs is not advertised — no install path the suite can drive');
|
|
101
|
+
}
|
|
102
|
+
|
|
90
103
|
async function preflight(): Promise<{ kind: SoftSkipKind; reason: string } | null> {
|
|
91
104
|
if (targetMajor() !== 2) return { kind: 'inapplicable', reason: 'suite 2.0.0 v2 scenario: OPENWOP_TARGET_MAJOR is not 2' };
|
|
92
105
|
let doc: Record<string, unknown> | null;
|
|
@@ -96,7 +109,6 @@ async function preflight(): Promise<{ kind: SoftSkipKind; reason: string } | nul
|
|
|
96
109
|
doc = null;
|
|
97
110
|
}
|
|
98
111
|
if (!doc) return { kind: 'blocked', reason: 'discovery unreachable — /.well-known/openwop (OpenWOP-Version: 2.0) did not answer 200 JSON' };
|
|
99
|
-
if (!(await gateFamily('packs'))) return { kind: 'inapplicable', reason: 'v2 discovery does not advertise the packs family (RFC 0169 §A.2)' };
|
|
100
112
|
if (!seamsProfileAdvertised(doc)) return { kind: 'inapplicable', reason: 'host does not advertise conformance.seamsProfile: openwop-conformance-seams-v2 — the packs-test publish seam is the only install path the suite can drive (RFC 0168 §C.1)' };
|
|
101
113
|
return null;
|
|
102
114
|
}
|
|
@@ -106,7 +118,7 @@ describe('v2-manifest-ceiling-refused (RFC 0177 §A.1)', () => {
|
|
|
106
118
|
const skip = await preflight();
|
|
107
119
|
if (skip) return softSkip(skip.kind, skip.reason);
|
|
108
120
|
const res = await publish(nodePack(freshName('unbounded'), '>=1.0.0'));
|
|
109
|
-
if (res.status === 404) return
|
|
121
|
+
if (res.status === 404) return seamMissing();
|
|
110
122
|
expect(res.status, req('openwop.requirement.0177.manifest-ceiling-refused.unbounded', SECTION, 'an unbounded range does not admit protocol major 2 and MUST be refused with 400 pack_engine_unsupported')).toBe(400);
|
|
111
123
|
expect(readErrorCode(res.json), req('openwop.requirement.0177.manifest-ceiling-refused.unbounded', SECTION, 'the refusal code MUST be pack_engine_unsupported (not pack_runtime_requirement_unmet)')).toBe('pack_engine_unsupported');
|
|
112
124
|
});
|
|
@@ -115,7 +127,7 @@ describe('v2-manifest-ceiling-refused (RFC 0177 §A.1)', () => {
|
|
|
115
127
|
const skip = await preflight();
|
|
116
128
|
if (skip) return softSkip(skip.kind, skip.reason);
|
|
117
129
|
const res = await publish(nodePack(freshName('v1-ceiling'), '>=1.0.0 <2.0.0'));
|
|
118
|
-
if (res.status === 404) return
|
|
130
|
+
if (res.status === 404) return seamMissing();
|
|
119
131
|
expect(res.status, req('openwop.requirement.0177.manifest-ceiling-refused.v1-ceiling', SECTION, 'a <2.0.0 ceiling does not admit protocol major 2 and MUST be refused with 400 pack_engine_unsupported')).toBe(400);
|
|
120
132
|
expect(readErrorCode(res.json), req('openwop.requirement.0177.manifest-ceiling-refused.v1-ceiling', SECTION, 'the refusal code MUST be pack_engine_unsupported')).toBe('pack_engine_unsupported');
|
|
121
133
|
});
|
|
@@ -124,7 +136,7 @@ describe('v2-manifest-ceiling-refused (RFC 0177 §A.1)', () => {
|
|
|
124
136
|
const skip = await preflight();
|
|
125
137
|
if (skip) return softSkip(skip.kind, skip.reason);
|
|
126
138
|
const res = await publish(nodePack(freshName('v2-installs'), '>=2.0.0 <3.0.0'));
|
|
127
|
-
if (res.status === 404) return
|
|
139
|
+
if (res.status === 404) return seamMissing();
|
|
128
140
|
expect([200, 201].includes(res.status), req('openwop.requirement.0177.manifest-ceiling-refused.v2-installs', SECTION, `a manifest whose range admits major 2 MUST install (got ${res.status} ${readErrorCode(res.json) ?? ''})`)).toBe(true);
|
|
129
141
|
});
|
|
130
142
|
});
|