@openwop/openwop-conformance 2.0.0-rc.43 → 2.0.0-rc.45
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/README.md +2 -2
- package/dist/cli.js +11 -2
- package/dist/lib/requirement-registry.js +10 -0
- package/dist/lib/scenario-disposition.js +33 -11
- package/dist/spec-artifacts.lock.json +2 -2
- package/package.json +2 -2
- package/requirements.json +43 -9
- package/scenario-majors.json +8 -2
- package/schemas/CORPUS-STAMP.json +9 -9
- package/src/cli.ts +10 -2
- package/src/lib/requirement-registry.ts +11 -0
- package/src/lib/scenario-disposition.ts +37 -9
- package/src/scenarios/v2-created-run-readable.test.ts +98 -0
- package/src/scenarios/v2-effect-seam-manifest.test.ts +9 -53
- package/src/scenarios/v2-effect-seam-no-refire.test.ts +88 -0
package/README.md
CHANGED
|
@@ -112,7 +112,7 @@ Exit code is non-zero on any failed assertion. `--certify` distinguishes: `0`
|
|
|
112
112
|
|
|
113
113
|
## What's Covered
|
|
114
114
|
|
|
115
|
-
The current suite has
|
|
115
|
+
The current suite has 505 scenario files under `src/scenarios/`.
|
|
116
116
|
- 2026-09-03 (suite `1.157.0 -> 1.158.0`, gap G17): NEW `idempotency-concurrent-claim.test.ts` — drives the new `host-sample-test-seams.md` §25 concurrent duplicate-delivery seam for the RFC 0150 §B / `idempotency.md` §"Concurrent duplicates (Layer 2)" atomic-claim MUST, which is unconditional and had no witness of any kind. Asserts every executor mints the SAME `logicalInvocationId` **before** asserting `delivered === 1` — without the identity check a host passes by minting different ids and never colliding, one effect because nothing raced. Not profile-gated and so not opt-out-able (the obligation is unconditional); an unmounted seam records `blocked`, which is not certifiable. Graduates `layer2-invocation-claim-atomic` reference-impl -> protocol.
|
|
117
117
|
- 2026-08-19 (suite `1.137.0 → 1.138.0`): NEW `durability-poison-exhaustion.test.ts` — RFC 0158 §C.8, the FIRST row of that RFC's conformance table to land. Asserts what `failure-path.test.ts` cannot: not just that deterministically failing work reaches terminal, but that attempts STOP — counted on the log, re-counted after a scaled quiet window, asserted unchanged. A host still redelivering records more. Seam-gated on the existing event-log seam (`blocked` = unobservable, not unmet) and outside every profile floor.
|
|
118
118
|
- 2026-08-19 (suite `1.136.15 → 1.137.0`): NEW `replay-fanout-suppression.test.ts` — capability-gated on `webhooks.supported`, **outside every profile floor**; witnesses `replay.md` §"Host-initiated fan-out is an external effect", which was the largest normative MUST NOT on the replay surface with no scenario and no SECURITY invariant. Three legs in ONE `it` against ONE receiver and ONE subscription — a positive control, the MUST NOT, and a `branch` boundary leg — because "no delivery arrived" passes identically when delivery never worked, so absence is asserted only after presence is proven on that exact wiring. A host with an SSRF guard correctly refuses the loopback receiver and records `blocked`: **unobservable, not unmet.**
|
|
@@ -457,7 +457,7 @@ Server-required (added in 1.7.0):
|
|
|
457
457
|
| ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
458
458
|
| **Redaction** | [`capabilities.md`](../spec/v1/capabilities.md) §"Secrets" + NFR-7 + §"aiProviders" | Vendor-neutral assertions that the server doesn't leak secret material. Three scenario groups: (a) discovery shape contract — `secrets` + `aiProviders` advertisements are well-formed regardless of `secrets.supported`; when `supported === true`, scopes MUST be non-empty + `resolution === 'host-managed'`; `byok ⊆ supported`. (b) bearer-token redaction — invalid Bearer canary in `Authorization` header is not echoed in the 401 response body. (c) credentialRef echo control — gated on `secrets.supported === true`; canary planted in `configurable.ai.credentialRef` MUST NOT appear in any RunEvent payload (poll-based capture; transport-agnostic). Uses runtime-built canary fixtures (`lib/canaries.ts`) that defeat static secret scanners. 6 scenarios. |
|
|
459
459
|
|
|
460
|
-
Current source tree:
|
|
460
|
+
Current source tree: 505 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
|
|
461
461
|
|
|
462
462
|
## Remaining Gaps
|
|
463
463
|
|
package/dist/cli.js
CHANGED
|
@@ -607,15 +607,24 @@ async function runCertify(args, baseUrl, apiKey) {
|
|
|
607
607
|
const doc3 = document;
|
|
608
608
|
const protocolVersions = Array.isArray(doc3['protocolVersions']) ? doc3['protocolVersions'] : [String(doc3['protocolVersion'] ?? '')];
|
|
609
609
|
const preferredVersion = typeof doc3['preferredVersion'] === 'string' ? doc3['preferredVersion'] : (protocolVersions[0] ?? '');
|
|
610
|
-
// witnessCount: executed-pass rows on the profile's floor
|
|
610
|
+
// witnessCount: witnessed executed-pass rows on the profile's floor. At major
|
|
611
|
+
// 2 the verdict already counted them against the declaration's floor
|
|
612
|
+
// (`witnessedPasses`); the v1 hand table below knows no v2 file and printed
|
|
613
|
+
// 0 for every v2 profile until rc.45 — the third unjoined floor site.
|
|
614
|
+
const verdictFor = (profile) => derived.verdicts.find((v) => v.profile === profile);
|
|
611
615
|
const witnessCountFor = (profile) => {
|
|
616
|
+
if (target.major === 2)
|
|
617
|
+
return verdictFor(profile)?.witnessedPasses ?? 0;
|
|
612
618
|
const floor = PROFILE_FLOOR_SCENARIOS[profile];
|
|
613
619
|
if (!floor)
|
|
614
620
|
return 0;
|
|
615
621
|
const onFloor = (scenario) => floor.required.includes(scenario) || (floor.requiredAnyPrefix ?? []).some((pre) => scenario.startsWith(pre));
|
|
616
622
|
return derived.requirements.filter((r) => r.disposition === 'executed-pass' && onFloor(r.scenarioId)).length;
|
|
617
623
|
};
|
|
618
|
-
|
|
624
|
+
// `certified` IS the verdict (RFC 0148 §A; RFC 0168 §E.1 adds the bundle-wide
|
|
625
|
+
// blocked rule). Until rc.45 it was `!notHeld && !rejected && blocked === 0`
|
|
626
|
+
// and never read `certifiable` — an empty v2 floor certified on no evidence.
|
|
627
|
+
const claimed3 = claimedProfiles.filter((p) => !(p in DEPRECATED_PROFILE_ALIASES)).map((p) => ({ id: p, evidenceTier: args.evidenceTier, witnessCount: witnessCountFor(p), certified: (verdictFor(p)?.certifiable ?? false) && !notHeld.has(p) && !rejectedProfiles.some((v) => v.profile === p) && totals3.blocked === 0 }));
|
|
619
628
|
let relaxations;
|
|
620
629
|
if (process.env['OPENWOP_HOST_RELAXATIONS']) {
|
|
621
630
|
try {
|
|
@@ -93,6 +93,16 @@ let v2Floors = null;
|
|
|
93
93
|
export function setV2ProfileFloors(floors) {
|
|
94
94
|
v2Floors = floors;
|
|
95
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* True while a major-2 floor map is installed. The v1 hand table
|
|
98
|
+
* (`PROFILE_FLOOR_SCENARIOS`) MUST NOT be consulted for any per-profile fact
|
|
99
|
+
* (`discoveryOnly`, `runtimeDerived`, the floor itself, the witness count) while
|
|
100
|
+
* this is true — every one of those reads is a floor site, and rc.41 found two
|
|
101
|
+
* of four unjoined; rc.45 found the other two.
|
|
102
|
+
*/
|
|
103
|
+
export function v2FloorsActive() {
|
|
104
|
+
return v2Floors !== null;
|
|
105
|
+
}
|
|
96
106
|
export function requirementsFor(profile, document) {
|
|
97
107
|
if (v2Floors !== null) {
|
|
98
108
|
const files = v2Floors[profile];
|
|
@@ -30,7 +30,7 @@ import { PROFILE_FLOOR_SCENARIOS } from './profiles.js';
|
|
|
30
30
|
import { targetMajor } from './seams.js';
|
|
31
31
|
import { PKG_ROOT_PATH } from './paths.js';
|
|
32
32
|
import { v2ProfileFloorFiles } from './requirement-registry.js';
|
|
33
|
-
import { requirementIdForScenario, requirementIdForPrefix, requirementsFor } from './requirement-registry.js';
|
|
33
|
+
import { requirementIdForScenario, requirementIdForPrefix, requirementsFor, v2FloorsActive } from './requirement-registry.js';
|
|
34
34
|
import { UNCLASSIFIED_RETURN_DETAIL } from './soft-skip.js';
|
|
35
35
|
import { SPEC_COHERENCE_SCENARIOS, SPEC_COHERENCE_DETAIL } from './spec-coherence.js';
|
|
36
36
|
import { CERTIFIABLE } from './requirement-ledger.js';
|
|
@@ -220,11 +220,18 @@ document) {
|
|
|
220
220
|
rows.push(row);
|
|
221
221
|
perFile.set(file, row);
|
|
222
222
|
}
|
|
223
|
-
// Prefix requirements: derived from the matching files.
|
|
223
|
+
// Prefix requirements: derived from the matching files. These are the v1 hand
|
|
224
|
+
// table's `requiredAnyPrefix` groups (`interrupt-`); at major 2 the floors are
|
|
225
|
+
// the declaration's and have no prefix groups — and the v1 `interrupt-*` files
|
|
226
|
+
// never run at major 2, so until rc.45 every major-2 bundle carried one
|
|
227
|
+
// `openwop.floor.any.interrupt-` row recorded `blocked` ("no interrupt-*
|
|
228
|
+
// scenario ran"), which by RFC 0168 §E.1 denied certification to every
|
|
229
|
+
// profile on every major-2 bundle. The fifth unjoined floor site.
|
|
224
230
|
const prefixIds = new Set();
|
|
225
|
-
|
|
226
|
-
for (const
|
|
227
|
-
|
|
231
|
+
if (!v2FloorsActive())
|
|
232
|
+
for (const floor of Object.values(PROFILE_FLOOR_SCENARIOS))
|
|
233
|
+
for (const p of floor.requiredAnyPrefix ?? [])
|
|
234
|
+
prefixIds.add(p);
|
|
228
235
|
for (const prefix of [...prefixIds].sort()) {
|
|
229
236
|
const matching = [...perFile.entries()].filter(([f]) => f.startsWith(prefix)).map(([, r]) => r);
|
|
230
237
|
const id = requirementIdForPrefix(prefix);
|
|
@@ -293,12 +300,18 @@ document) {
|
|
|
293
300
|
if (ids === null) {
|
|
294
301
|
const floor = PROFILE_FLOOR_SCENARIOS[profile];
|
|
295
302
|
const why = floor === undefined ? `(no floor defined for ${profile})` : `(discovery-conditional floor for ${profile} is unevaluable without the discovery document)`;
|
|
296
|
-
verdicts.push({ profile, unclassified: [], blocking: [why], certifiable: false, runtimeDerived: false, held: false });
|
|
303
|
+
verdicts.push({ profile, unclassified: [], blocking: [why], certifiable: false, runtimeDerived: false, held: false, witnessedPasses: 0 });
|
|
297
304
|
continue;
|
|
298
305
|
}
|
|
299
306
|
const unclassified = [];
|
|
300
307
|
const blocking = [];
|
|
301
308
|
let witnessedPasses = 0;
|
|
309
|
+
// At major 2 the floor is the declaration's (`v2ProfileFloorFiles`), and
|
|
310
|
+
// the v1 hand table says nothing about these profiles: not their floor, not
|
|
311
|
+
// `discoveryOnly`, not `runtimeDerived`. Reading it here was the fourth
|
|
312
|
+
// unjoined floor site — `openwop-discovery-core` is `discoveryOnly` in v1
|
|
313
|
+
// terms, so at major 2 it certified whatever its v2 floor file said.
|
|
314
|
+
const atMajor2 = v2FloorsActive();
|
|
302
315
|
for (const id of ids) {
|
|
303
316
|
const r = rowById.get(id);
|
|
304
317
|
const fromLedger = byId.has(id) || (r !== undefined && r.scenarioId.endsWith('*'));
|
|
@@ -320,25 +333,34 @@ document) {
|
|
|
320
333
|
if (r === undefined || silent || vacuous)
|
|
321
334
|
unclassified.push(id);
|
|
322
335
|
// Unclassified always blocks: a requirement nobody recorded cannot certify.
|
|
323
|
-
|
|
336
|
+
// A `skipped` floor row at major 2 is an opt-in the host withheld — the
|
|
337
|
+
// suite was not allowed to look. That is not evidence the requirement
|
|
338
|
+
// holds; it blocks the floor (a v1 floor keeps the CERTIFIABLE reading).
|
|
339
|
+
if (r === undefined || silent || vacuous || !CERTIFIABLE.includes(r.disposition) || (atMajor2 && r.disposition === 'skipped'))
|
|
324
340
|
blocking.push(id);
|
|
325
341
|
}
|
|
326
342
|
// discoveryOnly floors have ids.length === 0 and certify by design here (the
|
|
327
343
|
// requirement-ledger's verifyProfileRequirements is stricter; the runner
|
|
328
|
-
// consults PROFILE_FLOOR_SCENARIOS.discoveryOnly separately).
|
|
329
|
-
|
|
330
|
-
const
|
|
344
|
+
// consults PROFILE_FLOOR_SCENARIOS.discoveryOnly separately). Neither flag
|
|
345
|
+
// exists at major 2.
|
|
346
|
+
const discoveryOnly = !atMajor2 && PROFILE_FLOOR_SCENARIOS[profile]?.discoveryOnly === true;
|
|
347
|
+
const runtimeDerived = !atMajor2 && PROFILE_FLOOR_SCENARIOS[profile]?.runtimeDerived === true;
|
|
331
348
|
// A runtime-derived profile is HELD only when every floor row is a witnessed
|
|
332
349
|
// pass ("derivable from which scenarios pass" — profiles.md). Anything else
|
|
333
350
|
// means the host does not hold it: not a rejection, not a blocked claim.
|
|
334
351
|
const held = ids.length > 0 && witnessedPasses === ids.length;
|
|
352
|
+
// Major 2: a floor certifies only on a witnessed pass. `inapplicable` rows
|
|
353
|
+
// are honest per file (capabilities.md §2 requires the omission) but a floor
|
|
354
|
+
// that is inapplicable end to end has witnessed nothing and certifies nothing.
|
|
355
|
+
const certifiableAt2 = ids.length > 0 && blocking.length === 0 && witnessedPasses >= 1;
|
|
335
356
|
verdicts.push({
|
|
336
357
|
profile,
|
|
337
358
|
unclassified: runtimeDerived && !held ? [] : unclassified,
|
|
338
359
|
blocking: runtimeDerived && !held ? ids.filter((id) => rowById.get(id)?.disposition !== 'executed-pass' || (rowById.get(id)?.assertionCount ?? 0) === 0) : blocking,
|
|
339
|
-
certifiable: runtimeDerived ? held : discoveryOnly || (ids.length > 0 && blocking.length === 0),
|
|
360
|
+
certifiable: atMajor2 ? certifiableAt2 : runtimeDerived ? held : discoveryOnly || (ids.length > 0 && blocking.length === 0),
|
|
340
361
|
runtimeDerived,
|
|
341
362
|
held,
|
|
363
|
+
witnessedPasses,
|
|
342
364
|
});
|
|
343
365
|
}
|
|
344
366
|
return { requirements: rows, totals, verdicts, rejectUnclassified: verdicts.some((v) => v.unclassified.length > 0), ledgerPresent };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
2
|
"package": "@openwop/spec-artifacts",
|
|
3
|
-
"version": "2.0.0-rc.
|
|
4
|
-
"stampSha256": "
|
|
3
|
+
"version": "2.0.0-rc.45",
|
|
4
|
+
"stampSha256": "3228773ffaa82d2502ca522050d29b33b0cfa3d70b3b2a66ca8dbe6d623e42b0"
|
|
5
5
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openwop/openwop-conformance",
|
|
3
|
-
"version": "2.0.0-rc.
|
|
3
|
+
"version": "2.0.0-rc.45",
|
|
4
4
|
"description": "Production-ready black-box conformance suite for OpenWOP v1.0 compliant servers.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -56,6 +56,6 @@
|
|
|
56
56
|
"@openwop/spec-artifacts": "file:../spec-artifacts"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
|
-
"@openwop/spec-artifacts": "2.0.0-rc.
|
|
59
|
+
"@openwop/spec-artifacts": "2.0.0-rc.45"
|
|
60
60
|
}
|
|
61
61
|
}
|
package/requirements.json
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
"$comment": "GENERATED by conformance/scripts/generate-requirement-registry.mjs — do not edit. One record per it()/test() in src/scenarios. Ids: openwop.it.<file-stem>.<title-slug>[~n] (src/lib/requirement-ids.ts). A record with id null has an interpolated title; its run-time row is keyed by the rendered title and maps here by file+line only. Renamed ids need a row in requirement-aliases.json.",
|
|
3
3
|
"generatedFrom": "src/scenarios/*.test.ts",
|
|
4
4
|
"counts": {
|
|
5
|
-
"files":
|
|
6
|
-
"tests":
|
|
7
|
-
"withStableId":
|
|
5
|
+
"files": 552,
|
|
6
|
+
"tests": 2127,
|
|
7
|
+
"withStableId": 2126,
|
|
8
8
|
"interpolatedTitles": 1,
|
|
9
9
|
"explicitIds": 2057
|
|
10
10
|
},
|
|
@@ -26311,6 +26311,40 @@
|
|
|
26311
26311
|
}
|
|
26312
26312
|
]
|
|
26313
26313
|
},
|
|
26314
|
+
{
|
|
26315
|
+
"id": "openwop.it.v2-created-run-readable.a-run-created-at-this-base-is-readable-and-pollable-at-this-base-by-the-id-it-re",
|
|
26316
|
+
"file": "v2-created-run-readable.test.ts",
|
|
26317
|
+
"line": 63,
|
|
26318
|
+
"title": "a run created at this base is readable and pollable at this base by the id it returned, and its links stay on this base",
|
|
26319
|
+
"explicitId": null,
|
|
26320
|
+
"citations": [
|
|
26321
|
+
{
|
|
26322
|
+
"section": "spec/v2/core/identity.md §5",
|
|
26323
|
+
"requirement": null,
|
|
26324
|
+
"interpolated": true
|
|
26325
|
+
},
|
|
26326
|
+
{
|
|
26327
|
+
"section": null,
|
|
26328
|
+
"requirement": null,
|
|
26329
|
+
"interpolated": true
|
|
26330
|
+
},
|
|
26331
|
+
{
|
|
26332
|
+
"section": "spec/v2/core/events.md §Poll",
|
|
26333
|
+
"requirement": null,
|
|
26334
|
+
"interpolated": true
|
|
26335
|
+
},
|
|
26336
|
+
{
|
|
26337
|
+
"section": null,
|
|
26338
|
+
"requirement": null,
|
|
26339
|
+
"interpolated": true
|
|
26340
|
+
},
|
|
26341
|
+
{
|
|
26342
|
+
"section": null,
|
|
26343
|
+
"requirement": null,
|
|
26344
|
+
"interpolated": true
|
|
26345
|
+
}
|
|
26346
|
+
]
|
|
26347
|
+
},
|
|
26314
26348
|
{
|
|
26315
26349
|
"id": "openwop.it.v2-dual-stack-negotiation.a-run-created-through-v1-runs-with-no-header-is-readable-through-runs-under-open",
|
|
26316
26350
|
"file": "v2-dual-stack-negotiation.test.ts",
|
|
@@ -26468,7 +26502,7 @@
|
|
|
26468
26502
|
{
|
|
26469
26503
|
"id": "openwop.it.v2-effect-seam-manifest.get-host-effect-seams-validates-and-every-row-is-guarded",
|
|
26470
26504
|
"file": "v2-effect-seam-manifest.test.ts",
|
|
26471
|
-
"line":
|
|
26505
|
+
"line": 53,
|
|
26472
26506
|
"title": "GET /host/effect-seams validates and every row is guarded",
|
|
26473
26507
|
"explicitId": "openwop.requirement.0173.effect-seam-manifest",
|
|
26474
26508
|
"citations": [
|
|
@@ -26515,7 +26549,7 @@
|
|
|
26515
26549
|
{
|
|
26516
26550
|
"id": "openwop.it.v2-effect-seam-manifest.the-replay-facet-names-the-manifest-address-as-the-constant-host-effect-seams",
|
|
26517
26551
|
"file": "v2-effect-seam-manifest.test.ts",
|
|
26518
|
-
"line":
|
|
26552
|
+
"line": 108,
|
|
26519
26553
|
"title": "the replay facet names the manifest address as the constant /host/effect-seams",
|
|
26520
26554
|
"explicitId": "openwop.requirement.0173.effect-seam-manifest.facet",
|
|
26521
26555
|
"citations": [
|
|
@@ -26526,11 +26560,11 @@
|
|
|
26526
26560
|
]
|
|
26527
26561
|
},
|
|
26528
26562
|
{
|
|
26529
|
-
"id": "openwop.it.v2-effect-seam-
|
|
26530
|
-
"file": "v2-effect-seam-
|
|
26531
|
-
"line":
|
|
26563
|
+
"id": "openwop.it.v2-effect-seam-no-refire.driving-one-seam-of-each-kind-observes-no-re-fire-on-replay",
|
|
26564
|
+
"file": "v2-effect-seam-no-refire.test.ts",
|
|
26565
|
+
"line": 42,
|
|
26532
26566
|
"title": "driving one seam of each kind observes no re-fire on replay",
|
|
26533
|
-
"explicitId": "openwop.requirement.0173.effect-seam-
|
|
26567
|
+
"explicitId": "openwop.requirement.0173.effect-seam-no-refire",
|
|
26534
26568
|
"citations": [
|
|
26535
26569
|
{
|
|
26536
26570
|
"section": "spec/v2/core/replay.md §The effect-seam manifest",
|
package/scenario-majors.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$comment": "GENERATED by conformance/scripts/generate-scenario-majors.mjs (RFC 0168 §D.3). Do not edit; add a file to BOTH_MAJORS in the generator to target both majors.",
|
|
3
3
|
"counts": {
|
|
4
|
-
"files":
|
|
4
|
+
"files": 505,
|
|
5
5
|
"v1": 445,
|
|
6
|
-
"v2":
|
|
6
|
+
"v2": 61
|
|
7
7
|
},
|
|
8
8
|
"majors": {
|
|
9
9
|
"a2a-1-0-agent-card.test.ts": [
|
|
@@ -1264,6 +1264,9 @@
|
|
|
1264
1264
|
"v2-configurable-closed.test.ts": [
|
|
1265
1265
|
2
|
|
1266
1266
|
],
|
|
1267
|
+
"v2-created-run-readable.test.ts": [
|
|
1268
|
+
2
|
|
1269
|
+
],
|
|
1267
1270
|
"v2-dual-stack-negotiation.test.ts": [
|
|
1268
1271
|
2
|
|
1269
1272
|
],
|
|
@@ -1273,6 +1276,9 @@
|
|
|
1273
1276
|
"v2-effect-seam-manifest.test.ts": [
|
|
1274
1277
|
2
|
|
1275
1278
|
],
|
|
1279
|
+
"v2-effect-seam-no-refire.test.ts": [
|
|
1280
|
+
2
|
|
1281
|
+
],
|
|
1276
1282
|
"v2-enum-growth-rule.test.ts": [
|
|
1277
1283
|
2
|
|
1278
1284
|
],
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_comment": "Provenance of @openwop/spec-artifacts (RFC 0168 §D.2). files: SHA-256 per file; the conformance suite compares the installed peer against dist/spec-artifacts.lock.json at start.",
|
|
3
3
|
"package": "@openwop/spec-artifacts",
|
|
4
|
-
"version": "2.0.0-rc.
|
|
4
|
+
"version": "2.0.0-rc.45",
|
|
5
5
|
"corpusTag": null,
|
|
6
6
|
"files": {
|
|
7
7
|
"api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
"api/grpc/openwop.proto": "c3e72bb17cba514ee98feb6434e6c9b6ea6795bfd086489ec69fd882dd1ad977",
|
|
10
10
|
"api/openapi.yaml": "39081c59fb696159806b0f2f9a42e7e9ff830d622fcf2ad4159b21357580a955",
|
|
11
11
|
"api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
|
|
12
|
-
"api/seams-v2.yaml": "
|
|
13
|
-
"api/v2/asyncapi.yaml": "
|
|
14
|
-
"api/v2/openapi.yaml": "
|
|
12
|
+
"api/seams-v2.yaml": "d1d2b8bb291cb2f4675470d3fe89e36fd861e8bd31e2e227d3a075ba83c214c0",
|
|
13
|
+
"api/v2/asyncapi.yaml": "493f45912c431503c1eb1d4bbe5179820f71206270f786f3284b79be1a5e64a1",
|
|
14
|
+
"api/v2/openapi.yaml": "dbcb3ec60ca4f3c7b7e2d374dcb6e851a7edb5062e3ad8ac25b5f643e193775c",
|
|
15
15
|
"api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
|
|
16
16
|
"schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
|
|
17
17
|
"schemas/a2a-task-state.schema.json": "c9365918f993f943b4b619d42551eb066a1ed33a08d895d51b395432a5b1f1bc",
|
|
@@ -199,7 +199,7 @@
|
|
|
199
199
|
"schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
|
|
200
200
|
"spec/v1/alias-detectors.json": "fee4594ef49953953ffcd0b3813300067d16b3e65ebff2aac722034ac9b3f545",
|
|
201
201
|
"spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
|
|
202
|
-
"spec/v1/core-standard-manifest.json": "
|
|
202
|
+
"spec/v1/core-standard-manifest.json": "c105757fc9d32b779b87639eaa6a83e95c5b6e855ca8fcf12f30ca0b400f3050",
|
|
203
203
|
"spec/v1/deprecations.json": "1d5acb69a9b8ccb57275a95605f74aef1d920685f8407c9d382a46b59dc803bb",
|
|
204
204
|
"spec/v1/deprecations.schema.json": "18c87e78bedc210431f795ae44c5b5d202f2f3317850d5cf86867d4f1fa1cdfb",
|
|
205
205
|
"spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
|
|
@@ -211,7 +211,7 @@
|
|
|
211
211
|
"spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
|
|
212
212
|
"spec/v1/operation-path-manifest.json": "5f5f4e3842669371730ebbd1aace3fb794192615f0018dc144f484ba5db82ac3",
|
|
213
213
|
"spec/v1/spec-gaps.json": "6cc9962c6b969f632e07a78b52a4f61447ff579e2990cbae989866f584a86042",
|
|
214
|
-
"spec/v2/declaration.json": "
|
|
214
|
+
"spec/v2/declaration.json": "c15a6a8f08ebd326d1b60398d93d7f7d5abe5c1b8284874fdb13b59f44443210",
|
|
215
215
|
"spec/v2/declaration.schema.json": "b0881a76228dfbd1b85a2ec7b7259a60197f768344535cdb475db6ff719b0e54",
|
|
216
216
|
"spec/v2/errors.json": "c2dd3fa0401efa0dc78da3b21e32b58a89ed5114cebdde96d3eb999a702118f0",
|
|
217
217
|
"spec/v2/event-codemap.json": "37869a03cdfd8758b57e6f350641a6ebbd383231cebda51f5473ad85f94ab74c",
|
|
@@ -229,8 +229,8 @@
|
|
|
229
229
|
"spec/v2/id-field-bindings.json": "965814020c462ad13840b41b26f6830ac472734a1e2e5a67140fad92174c0370",
|
|
230
230
|
"spec/v2/path-manifest.json": "034152e09b1458c66810d4050e20a273b2b9b8fe2d92b66e8b819477de58a1be",
|
|
231
231
|
"spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
|
|
232
|
-
"spec/v2/profiles.json": "
|
|
233
|
-
"spec/v2/release.json": "
|
|
232
|
+
"spec/v2/profiles.json": "5019ac8209540bfbeee9c3232530d4e599be0ab926d817ff48edcec8419eb209",
|
|
233
|
+
"spec/v2/release.json": "fac42f53fe422b035cff2c0cb0a33165dbb5353fa834592ef89ff233a939798f"
|
|
234
234
|
},
|
|
235
|
-
"corpusCommit": "
|
|
235
|
+
"corpusCommit": "6b79036187e096bc80a79964e1c290b0ff3c5361"
|
|
236
236
|
}
|
package/src/cli.ts
CHANGED
|
@@ -641,14 +641,22 @@ async function runCertify(args: ParsedArgs, baseUrl: string, apiKey: string): Pr
|
|
|
641
641
|
const doc3 = document as Record<string, unknown>;
|
|
642
642
|
const protocolVersions = Array.isArray(doc3['protocolVersions']) ? (doc3['protocolVersions'] as string[]) : [String(doc3['protocolVersion'] ?? '')];
|
|
643
643
|
const preferredVersion = typeof doc3['preferredVersion'] === 'string' ? (doc3['preferredVersion'] as string) : (protocolVersions[0] ?? '');
|
|
644
|
-
// witnessCount: executed-pass rows on the profile's floor
|
|
644
|
+
// witnessCount: witnessed executed-pass rows on the profile's floor. At major
|
|
645
|
+
// 2 the verdict already counted them against the declaration's floor
|
|
646
|
+
// (`witnessedPasses`); the v1 hand table below knows no v2 file and printed
|
|
647
|
+
// 0 for every v2 profile until rc.45 — the third unjoined floor site.
|
|
648
|
+
const verdictFor = (profile: string) => derived.verdicts.find((v) => v.profile === profile);
|
|
645
649
|
const witnessCountFor = (profile: string): number => {
|
|
650
|
+
if (target.major === 2) return verdictFor(profile)?.witnessedPasses ?? 0;
|
|
646
651
|
const floor = PROFILE_FLOOR_SCENARIOS[profile];
|
|
647
652
|
if (!floor) return 0;
|
|
648
653
|
const onFloor = (scenario: string): boolean => floor.required.includes(scenario) || (floor.requiredAnyPrefix ?? []).some((pre) => scenario.startsWith(pre));
|
|
649
654
|
return derived.requirements.filter((r) => r.disposition === 'executed-pass' && onFloor(r.scenarioId)).length;
|
|
650
655
|
};
|
|
651
|
-
|
|
656
|
+
// `certified` IS the verdict (RFC 0148 §A; RFC 0168 §E.1 adds the bundle-wide
|
|
657
|
+
// blocked rule). Until rc.45 it was `!notHeld && !rejected && blocked === 0`
|
|
658
|
+
// and never read `certifiable` — an empty v2 floor certified on no evidence.
|
|
659
|
+
const claimed3 = claimedProfiles.filter((p) => !(p in DEPRECATED_PROFILE_ALIASES)).map((p) => ({ id: p, evidenceTier: args.evidenceTier, witnessCount: witnessCountFor(p), certified: (verdictFor(p)?.certifiable ?? false) && !notHeld.has(p) && !rejectedProfiles.some((v) => v.profile === p) && totals3.blocked === 0 }));
|
|
652
660
|
let relaxations: BundleV3['host']['relaxations'];
|
|
653
661
|
if (process.env['OPENWOP_HOST_RELAXATIONS']) { try { relaxations = JSON.parse(process.env['OPENWOP_HOST_RELAXATIONS']) as BundleV3['host']['relaxations']; } catch { process.stderr.write('openwop-conformance --certify: OPENWOP_HOST_RELAXATIONS is not JSON\n'); process.exit(2); } }
|
|
654
662
|
const lockPath = resolvePath(conformanceRoot, 'dist', 'spec-artifacts.lock.json');
|
|
@@ -97,6 +97,17 @@ export function setV2ProfileFloors(floors: Readonly<Record<string, readonly stri
|
|
|
97
97
|
v2Floors = floors;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* True while a major-2 floor map is installed. The v1 hand table
|
|
102
|
+
* (`PROFILE_FLOOR_SCENARIOS`) MUST NOT be consulted for any per-profile fact
|
|
103
|
+
* (`discoveryOnly`, `runtimeDerived`, the floor itself, the witness count) while
|
|
104
|
+
* this is true — every one of those reads is a floor site, and rc.41 found two
|
|
105
|
+
* of four unjoined; rc.45 found the other two.
|
|
106
|
+
*/
|
|
107
|
+
export function v2FloorsActive(): boolean {
|
|
108
|
+
return v2Floors !== null;
|
|
109
|
+
}
|
|
110
|
+
|
|
100
111
|
export function requirementsFor(profile: string, document?: Readonly<Record<string, unknown>>): readonly string[] | null {
|
|
101
112
|
if (v2Floors !== null) {
|
|
102
113
|
const files = v2Floors[profile];
|
|
@@ -31,7 +31,7 @@ import { PROFILE_FLOOR_SCENARIOS } from './profiles.js';
|
|
|
31
31
|
import { targetMajor } from './seams.js';
|
|
32
32
|
import { PKG_ROOT_PATH } from './paths.js';
|
|
33
33
|
import { v2ProfileFloorFiles } from './requirement-registry.js';
|
|
34
|
-
import { requirementIdForScenario, requirementIdForPrefix, requirementsFor } from './requirement-registry.js';
|
|
34
|
+
import { requirementIdForScenario, requirementIdForPrefix, requirementsFor, v2FloorsActive } from './requirement-registry.js';
|
|
35
35
|
import { UNCLASSIFIED_RETURN_DETAIL } from './soft-skip.js';
|
|
36
36
|
import { SPEC_COHERENCE_SCENARIOS, SPEC_COHERENCE_DETAIL } from './spec-coherence.js';
|
|
37
37
|
import { CERTIFIABLE, type Disposition, type LedgerEntry } from './requirement-ledger.js';
|
|
@@ -213,6 +213,13 @@ export interface DerivedProfileVerdict {
|
|
|
213
213
|
readonly runtimeDerived: boolean;
|
|
214
214
|
/** For runtime-derived profiles: every floor row is a witnessed executed-pass. */
|
|
215
215
|
readonly held: boolean;
|
|
216
|
+
/**
|
|
217
|
+
* Floor rows that are `executed-pass` with `assertionCount > 0` — the number
|
|
218
|
+
* of things actually witnessed. At major 2 this IS `witnessCount` on the
|
|
219
|
+
* bundle, and a profile with zero of them is not certifiable however its
|
|
220
|
+
* other rows read (RFC 0148 §A: no certification without an execution witness).
|
|
221
|
+
*/
|
|
222
|
+
readonly witnessedPasses: number;
|
|
216
223
|
}
|
|
217
224
|
|
|
218
225
|
export interface Derivation {
|
|
@@ -274,9 +281,15 @@ export function deriveRequirementDispositions(
|
|
|
274
281
|
perFile.set(file, row);
|
|
275
282
|
}
|
|
276
283
|
|
|
277
|
-
// Prefix requirements: derived from the matching files.
|
|
284
|
+
// Prefix requirements: derived from the matching files. These are the v1 hand
|
|
285
|
+
// table's `requiredAnyPrefix` groups (`interrupt-`); at major 2 the floors are
|
|
286
|
+
// the declaration's and have no prefix groups — and the v1 `interrupt-*` files
|
|
287
|
+
// never run at major 2, so until rc.45 every major-2 bundle carried one
|
|
288
|
+
// `openwop.floor.any.interrupt-` row recorded `blocked` ("no interrupt-*
|
|
289
|
+
// scenario ran"), which by RFC 0168 §E.1 denied certification to every
|
|
290
|
+
// profile on every major-2 bundle. The fifth unjoined floor site.
|
|
278
291
|
const prefixIds = new Set<string>();
|
|
279
|
-
for (const floor of Object.values(PROFILE_FLOOR_SCENARIOS)) for (const p of floor.requiredAnyPrefix ?? []) prefixIds.add(p);
|
|
292
|
+
if (!v2FloorsActive()) for (const floor of Object.values(PROFILE_FLOOR_SCENARIOS)) for (const p of floor.requiredAnyPrefix ?? []) prefixIds.add(p);
|
|
280
293
|
for (const prefix of [...prefixIds].sort()) {
|
|
281
294
|
const matching = [...perFile.entries()].filter(([f]) => f.startsWith(prefix)).map(([, r]) => r);
|
|
282
295
|
const id = requirementIdForPrefix(prefix);
|
|
@@ -339,12 +352,18 @@ export function deriveRequirementDispositions(
|
|
|
339
352
|
if (ids === null) {
|
|
340
353
|
const floor = PROFILE_FLOOR_SCENARIOS[profile];
|
|
341
354
|
const why = floor === undefined ? `(no floor defined for ${profile})` : `(discovery-conditional floor for ${profile} is unevaluable without the discovery document)`;
|
|
342
|
-
verdicts.push({ profile, unclassified: [], blocking: [why], certifiable: false, runtimeDerived: false, held: false });
|
|
355
|
+
verdicts.push({ profile, unclassified: [], blocking: [why], certifiable: false, runtimeDerived: false, held: false, witnessedPasses: 0 });
|
|
343
356
|
continue;
|
|
344
357
|
}
|
|
345
358
|
const unclassified: string[] = [];
|
|
346
359
|
const blocking: string[] = [];
|
|
347
360
|
let witnessedPasses = 0;
|
|
361
|
+
// At major 2 the floor is the declaration's (`v2ProfileFloorFiles`), and
|
|
362
|
+
// the v1 hand table says nothing about these profiles: not their floor, not
|
|
363
|
+
// `discoveryOnly`, not `runtimeDerived`. Reading it here was the fourth
|
|
364
|
+
// unjoined floor site — `openwop-discovery-core` is `discoveryOnly` in v1
|
|
365
|
+
// terms, so at major 2 it certified whatever its v2 floor file said.
|
|
366
|
+
const atMajor2 = v2FloorsActive();
|
|
348
367
|
for (const id of ids) {
|
|
349
368
|
const r = rowById.get(id);
|
|
350
369
|
const fromLedger = byId.has(id) || (r !== undefined && r.scenarioId.endsWith('*'));
|
|
@@ -365,24 +384,33 @@ export function deriveRequirementDispositions(
|
|
|
365
384
|
const silent = !fromLedger && (ledgerPresent || r?.disposition === 'blocked');
|
|
366
385
|
if (r === undefined || silent || vacuous) unclassified.push(id);
|
|
367
386
|
// Unclassified always blocks: a requirement nobody recorded cannot certify.
|
|
368
|
-
|
|
387
|
+
// A `skipped` floor row at major 2 is an opt-in the host withheld — the
|
|
388
|
+
// suite was not allowed to look. That is not evidence the requirement
|
|
389
|
+
// holds; it blocks the floor (a v1 floor keeps the CERTIFIABLE reading).
|
|
390
|
+
if (r === undefined || silent || vacuous || !CERTIFIABLE.includes(r.disposition) || (atMajor2 && r.disposition === 'skipped')) blocking.push(id);
|
|
369
391
|
}
|
|
370
392
|
// discoveryOnly floors have ids.length === 0 and certify by design here (the
|
|
371
393
|
// requirement-ledger's verifyProfileRequirements is stricter; the runner
|
|
372
|
-
// consults PROFILE_FLOOR_SCENARIOS.discoveryOnly separately).
|
|
373
|
-
|
|
374
|
-
const
|
|
394
|
+
// consults PROFILE_FLOOR_SCENARIOS.discoveryOnly separately). Neither flag
|
|
395
|
+
// exists at major 2.
|
|
396
|
+
const discoveryOnly = !atMajor2 && PROFILE_FLOOR_SCENARIOS[profile]?.discoveryOnly === true;
|
|
397
|
+
const runtimeDerived = !atMajor2 && PROFILE_FLOOR_SCENARIOS[profile]?.runtimeDerived === true;
|
|
375
398
|
// A runtime-derived profile is HELD only when every floor row is a witnessed
|
|
376
399
|
// pass ("derivable from which scenarios pass" — profiles.md). Anything else
|
|
377
400
|
// means the host does not hold it: not a rejection, not a blocked claim.
|
|
378
401
|
const held = ids.length > 0 && witnessedPasses === ids.length;
|
|
402
|
+
// Major 2: a floor certifies only on a witnessed pass. `inapplicable` rows
|
|
403
|
+
// are honest per file (capabilities.md §2 requires the omission) but a floor
|
|
404
|
+
// that is inapplicable end to end has witnessed nothing and certifies nothing.
|
|
405
|
+
const certifiableAt2 = ids.length > 0 && blocking.length === 0 && witnessedPasses >= 1;
|
|
379
406
|
verdicts.push({
|
|
380
407
|
profile,
|
|
381
408
|
unclassified: runtimeDerived && !held ? [] : unclassified,
|
|
382
409
|
blocking: runtimeDerived && !held ? ids.filter((id) => rowById.get(id)?.disposition !== 'executed-pass' || (rowById.get(id)?.assertionCount ?? 0) === 0) : blocking,
|
|
383
|
-
certifiable: runtimeDerived ? held : discoveryOnly || (ids.length > 0 && blocking.length === 0),
|
|
410
|
+
certifiable: atMajor2 ? certifiableAt2 : runtimeDerived ? held : discoveryOnly || (ids.length > 0 && blocking.length === 0),
|
|
384
411
|
runtimeDerived,
|
|
385
412
|
held,
|
|
413
|
+
witnessedPasses,
|
|
386
414
|
});
|
|
387
415
|
}
|
|
388
416
|
return { requirements: rows, totals, verdicts, rejectUnclassified: verdicts.some((v) => v.unclassified.length > 0), ledgerPresent };
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `spec/v2/core/runs.md` §Create + `versioning.md` §1.2 — a run created at a
|
|
3
|
+
* base is readable at that base by the id the base returned, and the links in
|
|
4
|
+
* the create response stay on that base (suite 2.0.0, target major 2; unaided;
|
|
5
|
+
* creates one run).
|
|
6
|
+
*
|
|
7
|
+
* This is the assertion the suite deferred to a soft-skip. `v2-id-grammar`
|
|
8
|
+
* reads the run it just created and, on a non-200, records `blocked` — the
|
|
9
|
+
* honest word for "the host did not let me look". It is the wrong word when
|
|
10
|
+
* the host DID answer and the answer was `404 No route matches this request`,
|
|
11
|
+
* because that is not evidence going unread; it is a routing hole, and a
|
|
12
|
+
* `blocked` row over a routing hole is a vacuous pass one step removed.
|
|
13
|
+
*
|
|
14
|
+
* Measured 2026-09-05 on a tier-1 host through its public origin: a run
|
|
15
|
+
* created at `https://app.openwop.dev` under major 2 could not be read,
|
|
16
|
+
* polled, cancelled or streamed at `https://app.openwop.dev` — the hosting
|
|
17
|
+
* layer decoded the tenant-bound id's `%2F` to `/` before forwarding, and the
|
|
18
|
+
* backend correctly had no route for a literal slash. One hop behind, the
|
|
19
|
+
* direct service URL answered every read with 200. Every bound id was
|
|
20
|
+
* unreachable through the front door, and every bound-id scenario recorded
|
|
21
|
+
* `blocked`. The same create response linked `eventsUrl` and `statusUrl` to
|
|
22
|
+
* `http://<the direct service host>/…` — a client that follows them leaves the
|
|
23
|
+
* origin it discovered on and downgrades to plain http.
|
|
24
|
+
*
|
|
25
|
+
* Two hard assertions, then: the read-back MUST be 200 at the same base, and
|
|
26
|
+
* the links MUST resolve under the request's origin without a scheme
|
|
27
|
+
* downgrade — a relative path satisfies both.
|
|
28
|
+
*
|
|
29
|
+
* @see spec/v2/core/runs.md §Create
|
|
30
|
+
* @see spec/v2/core/versioning.md §1.2
|
|
31
|
+
* @see spec/v2/core/identity.md §5
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { describe, it, expect } from 'vitest';
|
|
35
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
36
|
+
import { loadEnv } from '../lib/env.js';
|
|
37
|
+
import { v2Discovery } from '../lib/v2.js';
|
|
38
|
+
import { readErrorCode } from '../lib/error-envelope.js';
|
|
39
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
40
|
+
import { req } from '../lib/requirement-ids.js';
|
|
41
|
+
|
|
42
|
+
const ID = 'openwop.requirement.0172.created-run-readable';
|
|
43
|
+
const DOC = 'spec/v2/core/runs.md §Create';
|
|
44
|
+
const NOOP_WORKFLOW_ID = 'conformance-noop';
|
|
45
|
+
const RUN_ID = /^[A-Za-z0-9._~-]{1,128}\/[A-Za-z0-9._~-]{16,128}$/;
|
|
46
|
+
|
|
47
|
+
async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
|
|
48
|
+
try { return await fn(); } catch { return null; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A link is on-base when it is relative, or absolute under the request origin with the same scheme. */
|
|
52
|
+
function linkOnBase(link: unknown, baseUrl: string): { ok: boolean; why: string } {
|
|
53
|
+
if (typeof link !== 'string' || link.length === 0) return { ok: false, why: 'not a non-empty string' };
|
|
54
|
+
if (link.startsWith('/')) return { ok: true, why: 'relative' };
|
|
55
|
+
let l: URL; let b: URL;
|
|
56
|
+
try { l = new URL(link); b = new URL(baseUrl); } catch { return { ok: false, why: 'not a URL' }; }
|
|
57
|
+
if (l.protocol === 'http:' && b.protocol === 'https:') return { ok: false, why: `scheme downgrade ${b.protocol} → ${l.protocol}` };
|
|
58
|
+
if (l.origin !== b.origin) return { ok: false, why: `origin ${l.origin} is not the request origin ${b.origin}` };
|
|
59
|
+
return { ok: true, why: 'same-origin absolute' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('v2 created-run-readable (runs.md §Create)', () => {
|
|
63
|
+
it('a run created at this base is readable and pollable at this base by the id it returned, and its links stay on this base', async () => {
|
|
64
|
+
try { if (!(await v2Discovery())) return softSkip('blocked', 'v2 discovery unreachable'); } catch { return softSkip('blocked', 'v2 discovery unreachable'); }
|
|
65
|
+
const { baseUrl } = loadEnv();
|
|
66
|
+
const created = await http(() => driver.post('/runs', { workflowId: NOOP_WORKFLOW_ID }));
|
|
67
|
+
if (created === null) return softSkip('blocked', 'POST /runs unreachable (fetch failed)');
|
|
68
|
+
if (created.status === 429) return softSkip('blocked', 'POST /runs answered 429 — the run budget, not the wire');
|
|
69
|
+
const body = (created.json ?? {}) as { runId?: unknown; eventsUrl?: unknown; statusUrl?: unknown };
|
|
70
|
+
if (created.status !== 201 || typeof body.runId !== 'string') return softSkip('blocked', `POST /runs {workflowId: ${NOOP_WORKFLOW_ID}} answered ${created.status} ${readErrorCode(created.json) ?? ''} — the smallest valid create was refused (fixture not seeded?)`.trim());
|
|
71
|
+
const runId = body.runId;
|
|
72
|
+
expect(RUN_ID.test(runId), req(ID, 'spec/v2/core/identity.md §5', `the created runId MUST be tenant-bound (got ${runId})`)).toBe(true);
|
|
73
|
+
|
|
74
|
+
// HARD, not soft: the base that minted the id MUST resolve it. A 404 here is
|
|
75
|
+
// not evidence going unread; it is the front door not routing its own ids.
|
|
76
|
+
const read = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}`));
|
|
77
|
+
expect(
|
|
78
|
+
read?.status ?? null,
|
|
79
|
+
req(ID, DOC, `GET /runs/{runId} at the base that created the run MUST answer 200 by the id it returned — got ${read?.status ?? 'no response'} ${readErrorCode(read?.json) ?? ''}. A 404 "No route matches" for a tenant-bound id is a hosting layer decoding %2F to / before the backend sees the path: every bound id is unreachable through this base`.trim()),
|
|
80
|
+
).toBe(200);
|
|
81
|
+
const poll = await http(() => driver.get(`/runs/${encodeURIComponent(runId)}/events/poll?timeout=1`));
|
|
82
|
+
expect(
|
|
83
|
+
poll?.status ?? null,
|
|
84
|
+
req(ID, 'spec/v2/core/events.md §Poll', `GET /runs/{runId}/events/poll at the base that created the run MUST answer 200 — got ${poll?.status ?? 'no response'} ${readErrorCode(poll?.json) ?? ''}`.trim()),
|
|
85
|
+
).toBe(200);
|
|
86
|
+
|
|
87
|
+
// The links in the create response MUST keep the client on this base.
|
|
88
|
+
const ev = linkOnBase(body.eventsUrl, baseUrl);
|
|
89
|
+
expect(
|
|
90
|
+
ev.ok,
|
|
91
|
+
req(ID, DOC, `eventsUrl MUST resolve under the origin the create was made to, without a scheme downgrade — a relative path satisfies both (got ${JSON.stringify(body.eventsUrl)}: ${ev.why}). A link to a different host leaves the origin the client discovered on; http on an https origin is a downgrade`),
|
|
92
|
+
).toBe(true);
|
|
93
|
+
if (body.statusUrl !== undefined) {
|
|
94
|
+
const st = linkOnBase(body.statusUrl, baseUrl);
|
|
95
|
+
expect(st.ok, req(ID, DOC, `statusUrl, when present, MUST resolve under the request origin without a scheme downgrade (got ${JSON.stringify(body.statusUrl)}: ${st.why})`)).toBe(true);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -12,11 +12,14 @@
|
|
|
12
12
|
* 1. the manifest validates; every row is guarded; at least one row exists
|
|
13
13
|
* for every `kind` the host declares (the manifest is the host's own
|
|
14
14
|
* enumeration — RFC 0140 G7 answered by making the host list it);
|
|
15
|
-
* 2. the facet constant equals the served address
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
15
|
+
* 2. the facet constant equals the served address.
|
|
16
|
+
*
|
|
17
|
+
* Both legs are unaided (a `GET` and a schema), which is what lets this file sit
|
|
18
|
+
* on the `openwop-core-standard` floor as the `replay` family's witness. The
|
|
19
|
+
* seam-driven no-re-fire leg lived here until rc.45 and moved to
|
|
20
|
+
* `v2-effect-seam-no-refire` (seams-v2 floor): a file's disposition is
|
|
21
|
+
* worst-first, so one seam-gated leg made the whole manifest witness read
|
|
22
|
+
* `blocked` on any host without the seams surface.
|
|
20
23
|
*
|
|
21
24
|
* Completeness of the manifest (a seam outside it) is negative-existence and is
|
|
22
25
|
* not asserted here (RFC 0173 falsifiability table, §C.1 row).
|
|
@@ -26,9 +29,8 @@
|
|
|
26
29
|
*/
|
|
27
30
|
|
|
28
31
|
import { describe, it, expect } from 'vitest';
|
|
29
|
-
import { driver
|
|
32
|
+
import { driver } from '../lib/driver.js';
|
|
30
33
|
import { v2Discovery, gateFamily, v2Validator } from '../lib/v2.js';
|
|
31
|
-
import { seamsProfileAdvertised, SEAMS_PREFIX } from '../lib/seams.js';
|
|
32
34
|
import { softSkip } from '../lib/soft-skip.js';
|
|
33
35
|
import { req } from '../lib/requirement-ids.js';
|
|
34
36
|
|
|
@@ -114,50 +116,4 @@ describe('RFC 0173 §C.1 — effect-seam-manifest (gated on replay)', () => {
|
|
|
114
116
|
).toBe(MANIFEST_PATH);
|
|
115
117
|
});
|
|
116
118
|
|
|
117
|
-
it('driving one seam of each kind observes no re-fire on replay', async () => {
|
|
118
|
-
const doc = await discovery();
|
|
119
|
-
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
120
|
-
const replay = await gateFamily('replay');
|
|
121
|
-
if (!replay) return softSkip('inapplicable', 'replay family not advertised (gate recorded under openwop.family.replay)');
|
|
122
|
-
if (!seamsProfileAdvertised(doc)) {
|
|
123
|
-
return softSkip('blocked', `the no-re-fire leg is seam-gated (RFC 0173 falsifiability §B replay row is witnessable-gated only through a seam that fires a named manifest row inside a run) — seams profile not advertised (conformance.seamsProfile)`);
|
|
124
|
-
}
|
|
125
|
-
// The seam is catalogued (api/seams-v2.yaml `fireEffectSeam`). The witness is
|
|
126
|
-
// the host's own Layer-2 ledger, not an externally reachable receiver: fire a
|
|
127
|
-
// guarded row that states `branchReFires: false`, fork the run in `replay`
|
|
128
|
-
// mode, and read GET /runs/{runId}/effects on the fork. Suppression means the
|
|
129
|
-
// fork issues no further attempt for that seam, so its effect ledger cannot
|
|
130
|
-
// grow past the parent's.
|
|
131
|
-
const manifest = await driver.get(MANIFEST_PATH);
|
|
132
|
-
const seamRows: SeamRow[] = manifest.status === 200 && manifest.json && typeof manifest.json === 'object'
|
|
133
|
-
? ((manifest.json as Manifest).seams ?? []) : [];
|
|
134
|
-
const target = seamRows.find((r) => r.guarded === true && r.branchReFires === false);
|
|
135
|
-
if (!target) return softSkip('inapplicable', `no manifest row is both guarded and branchReFires: false — nothing to witness suppression on (${seamRows.length} row(s) at ${MANIFEST_PATH})`);
|
|
136
|
-
const fired = await driver.post(`${SEAMS_PREFIX}/sample/effect-seams/fire`, { seam: String(target.seam) });
|
|
137
|
-
if (fired.status === 404 || fired.status === 403 || fired.status === 405) {
|
|
138
|
-
return softSkip('blocked', `the host advertises the seams profile but does not serve ${SEAMS_PREFIX}/sample/effect-seams/fire (answered ${fired.status}) — the no-re-fire leg cannot be driven`);
|
|
139
|
-
}
|
|
140
|
-
const firedBody = fired.json as { runId?: unknown } | null;
|
|
141
|
-
if (fired.status !== 201 || typeof firedBody?.runId !== 'string') {
|
|
142
|
-
return softSkip('blocked', `${SEAMS_PREFIX}/sample/effect-seams/fire answered ${fired.status} without { runId } — the seam contract in api/seams-v2.yaml is 201 { runId }`);
|
|
143
|
-
}
|
|
144
|
-
const parentId = firedBody.runId;
|
|
145
|
-
const parentEffects = await driver.get(`/runs/${parentId}/effects`);
|
|
146
|
-
if (parentEffects.status !== 200) return softSkip('blocked', `GET /runs/{runId}/effects answered ${parentEffects.status} on the fired run — the ledger is the witness for suppression (RFC 0173 §C.2)`);
|
|
147
|
-
const countOf = (r: OpenWOPResponse): number => {
|
|
148
|
-
const b = r.json as { effects?: unknown } | null;
|
|
149
|
-
return Array.isArray(b?.effects) ? (b.effects as unknown[]).length : 0;
|
|
150
|
-
};
|
|
151
|
-
const forked = await driver.post(`/runs/${parentId}:fork`, { mode: 'replay' });
|
|
152
|
-
const forkBody = forked.json as { runId?: unknown } | null;
|
|
153
|
-
if (forked.status !== 201 || typeof forkBody?.runId !== 'string') {
|
|
154
|
-
return softSkip('blocked', `POST /runs/{runId}:fork mode replay answered ${forked.status} on the fired run — suppression is witnessed on the fork`);
|
|
155
|
-
}
|
|
156
|
-
const forkEffects = await driver.get(`/runs/${String(forkBody.runId)}/effects`);
|
|
157
|
-
if (forkEffects.status !== 200) return softSkip('blocked', `GET /runs/{runId}/effects answered ${forkEffects.status} on the replay fork`);
|
|
158
|
-
expect(
|
|
159
|
-
countOf(forkEffects),
|
|
160
|
-
req('openwop.requirement.0173.effect-seam-manifest.no-re-fire', 'spec/v2/core/replay.md §The effect-seam manifest', `seam ${String(target.seam)} states branchReFires: false, so a replay fork MUST NOT issue a further attempt through it — the fork's effect ledger (${countOf(forkEffects)}) cannot exceed the parent's (${countOf(parentEffects)})`),
|
|
161
|
-
).toBeLessThanOrEqual(countOf(parentEffects));
|
|
162
|
-
});
|
|
163
119
|
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0173 §C.2 — `effect-seam-no-refire` (suite 2.0.0, target major 2; gated
|
|
3
|
+
* on `replay`, driven through the seams profile).
|
|
4
|
+
*
|
|
5
|
+
* A guarded manifest row that states `branchReFires: false` MUST NOT be fired
|
|
6
|
+
* again by a replay fork: the fork's Layer-2 effect ledger cannot grow past the
|
|
7
|
+
* parent's for that seam. The witness is the host's own ledger, reached by
|
|
8
|
+
* firing a named manifest row inside a run through the catalogued seam
|
|
9
|
+
* (`api/seams-v2.yaml` `fireEffectSeam`), forking in `replay` mode, and reading
|
|
10
|
+
* `GET /runs/{runId}/effects` on both.
|
|
11
|
+
*
|
|
12
|
+
* This leg lived in `v2-effect-seam-manifest` until rc.45. It is the one leg of
|
|
13
|
+
* that file that needs the seam, and a file's disposition is worst-first — so
|
|
14
|
+
* on a host without the seams surface the whole manifest file recorded
|
|
15
|
+
* `blocked`, and the manifest witness (unaided, a `GET` and a schema) could not
|
|
16
|
+
* sit on the `openwop-core-standard` floor without dragging a seam gate onto
|
|
17
|
+
* it. Split, the manifest file is a core-standard floor witness for the
|
|
18
|
+
* `replay` family and this file is a seams-v2 floor witness; each records its
|
|
19
|
+
* own truth.
|
|
20
|
+
*
|
|
21
|
+
* @see spec/v2/core/replay.md §The effect-seam manifest
|
|
22
|
+
* @see spec/v2/core/security-defaults.md §Replay suppression
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, it, expect } from 'vitest';
|
|
26
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
27
|
+
import { v2Discovery, gateFamily } from '../lib/v2.js';
|
|
28
|
+
import { seamsProfileAdvertised, SEAMS_PREFIX } from '../lib/seams.js';
|
|
29
|
+
import { softSkip } from '../lib/soft-skip.js';
|
|
30
|
+
import { req } from '../lib/requirement-ids.js';
|
|
31
|
+
|
|
32
|
+
const MANIFEST_PATH = '/host/effect-seams';
|
|
33
|
+
|
|
34
|
+
interface SeamRow { seam?: unknown; kind?: unknown; guarded?: unknown; guardedBy?: unknown; branchReFires?: unknown }
|
|
35
|
+
interface Manifest { manifestVersion?: unknown; seams?: SeamRow[] }
|
|
36
|
+
|
|
37
|
+
async function discovery(): Promise<Record<string, unknown> | null> {
|
|
38
|
+
try { return await v2Discovery(); } catch { return null; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('RFC 0173 §C.2 — effect-seam-no-refire (gated on replay, seam-driven)', () => {
|
|
42
|
+
it('driving one seam of each kind observes no re-fire on replay', async () => {
|
|
43
|
+
const doc = await discovery();
|
|
44
|
+
if (!doc) return softSkip('blocked', 'discovery unreachable');
|
|
45
|
+
const replay = await gateFamily('replay');
|
|
46
|
+
if (!replay) return softSkip('inapplicable', 'replay family not advertised (gate recorded under openwop.family.replay)');
|
|
47
|
+
if (!seamsProfileAdvertised(doc)) {
|
|
48
|
+
return softSkip('blocked', `the no-re-fire leg is seam-gated (RFC 0173 falsifiability §B replay row is witnessable-gated only through a seam that fires a named manifest row inside a run) — seams profile not advertised (conformance.seamsProfile)`);
|
|
49
|
+
}
|
|
50
|
+
// The seam is catalogued (api/seams-v2.yaml `fireEffectSeam`). The witness is
|
|
51
|
+
// the host's own Layer-2 ledger, not an externally reachable receiver: fire a
|
|
52
|
+
// guarded row that states `branchReFires: false`, fork the run in `replay`
|
|
53
|
+
// mode, and read GET /runs/{runId}/effects on the fork. Suppression means the
|
|
54
|
+
// fork issues no further attempt for that seam, so its effect ledger cannot
|
|
55
|
+
// grow past the parent's.
|
|
56
|
+
const manifest = await driver.get(MANIFEST_PATH);
|
|
57
|
+
const seamRows: SeamRow[] = manifest.status === 200 && manifest.json && typeof manifest.json === 'object'
|
|
58
|
+
? ((manifest.json as Manifest).seams ?? []) : [];
|
|
59
|
+
const target = seamRows.find((r) => r.guarded === true && r.branchReFires === false);
|
|
60
|
+
if (!target) return softSkip('inapplicable', `no manifest row is both guarded and branchReFires: false — nothing to witness suppression on (${seamRows.length} row(s) at ${MANIFEST_PATH})`);
|
|
61
|
+
const fired = await driver.post(`${SEAMS_PREFIX}/sample/effect-seams/fire`, { seam: String(target.seam) });
|
|
62
|
+
if (fired.status === 404 || fired.status === 403 || fired.status === 405) {
|
|
63
|
+
return softSkip('blocked', `the host advertises the seams profile but does not serve ${SEAMS_PREFIX}/sample/effect-seams/fire (answered ${fired.status}) — the no-re-fire leg cannot be driven`);
|
|
64
|
+
}
|
|
65
|
+
const firedBody = fired.json as { runId?: unknown } | null;
|
|
66
|
+
if (fired.status !== 201 || typeof firedBody?.runId !== 'string') {
|
|
67
|
+
return softSkip('blocked', `${SEAMS_PREFIX}/sample/effect-seams/fire answered ${fired.status} without { runId } — the seam contract in api/seams-v2.yaml is 201 { runId }`);
|
|
68
|
+
}
|
|
69
|
+
const parentId = firedBody.runId;
|
|
70
|
+
const parentEffects = await driver.get(`/runs/${encodeURIComponent(parentId)}/effects`);
|
|
71
|
+
if (parentEffects.status !== 200) return softSkip('blocked', `GET /runs/{runId}/effects answered ${parentEffects.status} on the fired run — the ledger is the witness for suppression (RFC 0173 §C.2)`);
|
|
72
|
+
const countOf = (r: OpenWOPResponse): number => {
|
|
73
|
+
const b = r.json as { effects?: unknown } | null;
|
|
74
|
+
return Array.isArray(b?.effects) ? (b.effects as unknown[]).length : 0;
|
|
75
|
+
};
|
|
76
|
+
const forked = await driver.post(`/runs/${encodeURIComponent(parentId)}:fork`, { mode: 'replay' });
|
|
77
|
+
const forkBody = forked.json as { runId?: unknown } | null;
|
|
78
|
+
if (forked.status !== 201 || typeof forkBody?.runId !== 'string') {
|
|
79
|
+
return softSkip('blocked', `POST /runs/{runId}:fork mode replay answered ${forked.status} on the fired run — suppression is witnessed on the fork`);
|
|
80
|
+
}
|
|
81
|
+
const forkEffects = await driver.get(`/runs/${encodeURIComponent(String(forkBody.runId))}/effects`);
|
|
82
|
+
if (forkEffects.status !== 200) return softSkip('blocked', `GET /runs/{runId}/effects answered ${forkEffects.status} on the replay fork`);
|
|
83
|
+
expect(
|
|
84
|
+
countOf(forkEffects),
|
|
85
|
+
req('openwop.requirement.0173.effect-seam-no-refire', 'spec/v2/core/replay.md §The effect-seam manifest', `seam ${String(target.seam)} states branchReFires: false, so a replay fork MUST NOT issue a further attempt through it — the fork's effect ledger (${countOf(forkEffects)}) cannot exceed the parent's (${countOf(parentEffects)})`),
|
|
86
|
+
).toBeLessThanOrEqual(countOf(parentEffects));
|
|
87
|
+
});
|
|
88
|
+
});
|