@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/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, signBundleV3, verifierSign, verifyBundleV3, witnessDigest, type BundleV3, type BundleV3Requirement } from './lib/certification-bundle-v3.js';
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 unsigned: Omit<BundleV3, 'signature'> = {
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
- const signature = signBundleV3(unsigned, signingKeyPem, keyId);
742
- const v3: BundleV3 = { ...unsigned, signature };
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
- v3.verifierSignature = verifierSign(unsigned, vk, vkId);
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. The timeout disposition is not tested: no registered fixture carries a
21
- * gate with a short `timeoutMs`, and the suite cannot make a host's timer fire.
22
- * The routed case (an `all_complete` / `any_failed` edge) is not tested either:
23
- * `conformance-approval` has no edges, and widening a fixture every host serves
24
- * would force a re-registration (fixtures.md §conformance-approval-refine).
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 `packs` family (RFC 0169 §A.2: presence is the claim) and on
18
- * the seams profile (`conformance.seamsProfile: openwop-conformance-seams-v2`);
19
- * a host without the profile records `blocked` — the write API is the only
20
- * install path the suite can drive.
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, gateFamily } from '../lib/v2.js';
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 softSkip('blocked', 'packs-test publish seam answered 404 — seams profile advertised but the seam is not mounted');
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 softSkip('blocked', 'packs-test publish seam answered 404 — seams profile advertised but the seam is not mounted');
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 softSkip('blocked', 'packs-test publish seam answered 404 — seams profile advertised but the seam is not mounted');
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
  });