@openwop/openwop-conformance 1.151.0 → 1.153.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # `@openwop/openwop-conformance` Changelog
2
2
 
3
+ ## [1.153.0] — 2026-09-02 — per-`it` requirement rows and the requirement registry (RFC 0148 §A / G3; v2 charter Phase 1)
4
+
5
+ No new scenario file. Three packed changes, all additive to bundle v2:
6
+
7
+ - **Every test records its own ledger row.** `setup.ts` now records one RFC 0148 §A disposition per `it()` under `openwop.it.<file-stem>.<title-slug>` (`src/lib/requirement-ids.ts`), next to the retained file-level row the floors key on. A test that passes with ≥1 assertion is `executed-pass`; a failure is `executed-fail` with the first error message; a pass with zero assertions takes the gate reason recorded during that test (`softSkip` / `seamAbsent` / `behaviorGate`) or resolves to `blocked`. This is the durable fix for certification gap G8: a file that asserted a positive control and then soft-skipped the requirement no longer certifies the requirement, because the requirement's own row says `skipped`/`inapplicable`/`blocked`.
8
+ - **`--certify` emits the per-`it` rows** in `results.requirements[]`, attributed to their scenario file. Existing verifiers accept them (the row shape is unchanged; ids are new). Expect bundles to grow from ~470 rows to ~2,400.
9
+ - **`conformance/requirements.json`** (packed) is the generated registry: one record per test with its id, file, line, title, and the `driver.describe` / `req()` citations found in its body. `scripts/generate-requirement-registry.mjs --check` runs in `openwop:check`; a reworded title needs a row in `requirement-aliases.json` or the check fails, so a bundle that cited the old id still resolves. Measured at generation: 1,950 tests in 468 files, 1,927 stable ids, 23 interpolated titles (those rows exist at run time keyed by the rendered title and map to the registry by file+line).
10
+ - New `req(id, section, requirement)` helper: a scenario may attach a hand-authored registry id to the current test; it doubles as the assertion message.
11
+
12
+ ## [1.152.0] — 2026-09-02 — v2 charter Phase 0: `--certify` defaults to bundle v2; the RFC 0050 reference suite leaves the scenario file
13
+
14
+ No new scenario file. Two packed changes:
15
+
16
+ - **`--certify` emits certification bundle v2 by default** (RFC 0148). `--bundle-version 1` still works through the RFC 0148 migration window (ends 2026-11-10) and prints a deprecation notice; the v1 schema file stays in the package so v1 bundles remain parseable, and is removed at v2.0 (`spec/v1/deprecations.json`).
17
+ - **The RFC 0050 §A synthetic-IdP reference suite moved** from `scenarios/auth-saml-profile.test.ts` to `src/lib/saml-idp.test.ts` (RFC 0163 gap G5): it proves the bundled fixture, not a host, and inside a scenario file it recorded an `executed-pass` in every host bundle (certification gap G8). The scenario keeps its host-facing legs; `coverage.md` grades it `host-pending` on the opt-in host-ACS leg. Hosts whose bundles previously counted this file as a pass will see it become `skipped`/`inapplicable` unless they opt into the seam — that is the honest reading.
18
+
3
19
  ## [1.151.0] — 2026-09-02 — RFC 0164: the subject-link scenarios gate on the profile pair; the opted-out shape fails
4
20
 
5
21
  No new scenario file. `auth-subject-link.test.ts` and `auth-subject-link-key-class.test.ts` re-gate every leg on the host advertising **both** `openwop-auth-saml` and `openwop-auth-scim`, not on `capabilities.auth.subjectLinking`, which RFC 0164 makes a derived advertisement. The RFC 0159 advertisement block gains a leg: both profiles advertised ⇒ `subjectLinking === true` and `subjectLinkKey ∈ {opaque-idp, configured-immutable}`, else `executed-fail` citing RFC 0164 §A.3. That is the shape a combined host that opted out used to present, and it used to read `inapplicable` — a host shipping the leaver bypass was invisible to the suite. `src/lib/capabilities-auth-subject-link.test.ts` pins the new schema conditional (both profiles with the flag absent, `false`, or without a key are rejected; with `true` + a class accepted; profile order irrelevant; single-profile hosts unaffected). `capabilities.schema.json` composes the RFC 0163 and RFC 0164 conditionals under `allOf`; `coverage.md` rows re-worded. No production host advertises both profiles, so no host's recorded evidence changes.
package/coverage.md CHANGED
@@ -115,7 +115,7 @@ The scenario groups in the table below (one row per group; count the rows — th
115
115
  | `cross-workspace-isolation.test.ts` | RFC 0048 §C/§D (`auth.md` §Identity claims, `rest-endpoints.md` `run_forbidden`) | A (owner-echo shape if a sample run is readable; §D isolation MUST-NOT via optional `POST /v1/host/sample/identity/cross-workspace-read` seam — cross-workspace read fails closed with `run_forbidden`/`not_found`) | `host-pending` | Behavioral probe soft-skips on 404; no host advertises run ownership yet (RFC 0048 `Draft`). |
116
116
  | `authorization-roles-shape.test.ts` | `capabilities.authorization` (RFC 0049 §A, `auth.md` §"Role-based authorization") | A (advertisement shape always — `supported` boolean; `failClosed` const true; every `roles[].role` non-empty + `scopes` array) | `host-pending` | Always runs; asserts the block is absent or well-formed. |
117
117
  | `authorization-fail-closed.test.ts` | `capabilities.authorization` (RFC 0049 §C) + `SECURITY/invariants.yaml` `authorization-fail-closed` | A (advertisement `failClosed===true` always; fail-closed MUST-NOT via optional `POST /v1/host/sample/authorization/decide` seam — an unseeded-role principal resolves `allowed:false`) | `host-pending` | Capability-gated on `authorization.supported`; behavioral probe soft-skips on 404. Scope-match + denial-audited scenarios deferred to a host. |
118
- | `auth-saml-profile.test.ts` | `openwop-auth-saml` (RFC 0050, `auth-profiles.md` §`openwop-auth-saml`) | A+B (profile-advertisement shape always; **1-positive + 6-negative reference suite runs server-free** via the bundled synthetic IdP `conformance/src/lib/saml-idp.ts` — `alg:none`/unsigned/bad-sig/expired/not-yet-valid/wrapping; host-ACS validation opt-in via `OPENWOP_TEST_SAML_IDP_URL` + the `auth/saml/validate` seam) | host-pass (server-free reference) | Synthetic IdP bundled (`node:crypto`, no deps). Host-ACS pass is the remaining graduation gate. |
118
+ | `auth-saml-profile.test.ts` | `openwop-auth-saml` (RFC 0050, `auth-profiles.md` §`openwop-auth-saml`) | A+B (profile-advertisement shape always; host-ACS validation opt-in via `OPENWOP_TEST_SAML_IDP_URL` + the `auth/saml/validate` seam. The **1-positive + 6-negative reference suite** against the bundled synthetic IdP `conformance/src/lib/saml-idp.ts` — `alg:none`/unsigned/bad-sig/expired/not-yet-valid/wrapping — moved to `src/lib/saml-idp.test.ts` on 2026-09-02 (RFC 0163 G5): it proves the fixture, so it no longer records a scenario ledger row) | host-pending (host-ACS leg, opt-in) | Synthetic IdP bundled (`node:crypto`, no deps); its self-tests live in `src/lib`. Host-ACS pass over the seam is the witness that credits a host. |
119
119
  | `auth-scim-profile.test.ts` | `openwop-auth-scim` (RFC 0050, `auth-profiles.md` §`openwop-auth-scim`) | B (profile-advertisement shape always; SCIM user/group provisioning → principal/role roundtrip opt-in via `OPENWOP_TEST_SCIM_URL` + the `auth/scim/provision` seam) | `host-pending` | Behavior opt-in (operator-supplied SCIM endpoint); deactivate ⇒ subsequent-deny assertion deferred to a host. |
120
120
  | `auth-subject-link.test.ts` | `openwop-auth-saml` + `openwop-auth-scim` advertised together (RFC 0159 + RFC 0164, `auth-profiles.md` §Subject linking; `subjectLinking` is derived) | B (advertisement gate always — `subjectLinking:true` only alongside both profiles, AND both profiles ⇒ `subjectLinking:true` + `subjectLinkKey` (RFC 0164 vulnerable-shape leg, `executed-fail` never `inapplicable`); cross-lane leaver deny + mutable-key negative opt-in via `OPENWOP_TEST_SAML_IDP_URL` + `OPENWOP_TEST_SCIM_URL` and the `auth/scim/provision` + `auth/saml/validate` seams) | host-pass (tier-1 in-process witness, openwop-app #3581) | Invariant `subject-link-leaver-deny`. No production host advertises `subjectLinking`. |
121
121
  | `auth-subject-link-key-class.test.ts` | as above (RFC 0163; gated on the profile pair since RFC 0164) | B (`subjectLinkKey` ∈ `{opaque-idp, configured-immutable}` when `subjectLinking:true`, unaided; same-IdP control + cross-IdP collision negative opt-in via `OPENWOP_TEST_SAML_IDP_URL` + `OPENWOP_TEST_SAML_IDP_URL_B` + `OPENWOP_TEST_SCIM_URL`) | host-pass (tier-1 in-process witness, openwop-app #3614) | Invariants `subject-link-key-class-declared`, `subject-link-same-trust-root`. Fixture self-test lives in `src/lib/saml-idp.test.ts`, not here (G8). |
package/dist/cli.js CHANGED
@@ -53,7 +53,10 @@ function parseArgs(argv) {
53
53
  let impl;
54
54
  let implVersion;
55
55
  let certify;
56
- let bundleVersion = '1';
56
+ // Suite 1.152.0: bundle v2 (RFC 0148) is the default. v1 stays reachable via
57
+ // `--bundle-version 1` through the RFC 0148 migration window (ends
58
+ // 2026-11-10) and is removed at v2.0 (spec/v1/deprecations.json).
59
+ let bundleVersion = '2';
57
60
  let maxWorkers = parseMaxWorkers(process.env.OPENWOP_MAX_WORKERS, 'OPENWOP_MAX_WORKERS');
58
61
  for (let i = 0; i < argv.length; i++) {
59
62
  const arg = argv[i] ?? '';
@@ -103,6 +106,9 @@ function parseArgs(argv) {
103
106
  process.exit(2);
104
107
  }
105
108
  bundleVersion = v;
109
+ if (v === '1') {
110
+ process.stderr.write('openwop-conformance: --bundle-version 1 is DEPRECATED (RFC 0148). A v1 bundle ceases to substantiate a new certification after 2026-11-10 and the format is removed at v2.0; omit the flag to emit bundle v2.\n');
111
+ }
106
112
  break;
107
113
  }
108
114
  case '--certify':
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Per-`it` requirement ids (RFC 0148 §A, gap G3; v2 charter Phase 1).
3
+ *
4
+ * The ledger has recorded ONE row per scenario FILE (`openwop.floor.<file>` /
5
+ * `openwop.scenario.<file>`). That granularity is what produces certification
6
+ * gap G8: a file that asserted a positive control and then soft-skipped the
7
+ * requirement still resolves to `executed-pass` for the whole file. The durable
8
+ * fix named in `scenario-disposition.ts` is per-`it` recording — this module
9
+ * supplies the ids for it.
10
+ *
11
+ * Why the id derives from the TEST TITLE and not from the `driver.describe`
12
+ * text: 190 of 1,756 citations interpolate the requirement text at run time
13
+ * (`docs/REQUIREMENT-REGISTRY-FEASIBILITY.md`), so no text-derived id can be
14
+ * stable for them. Test titles are literals in every scenario file, stable
15
+ * across hosts, and already the unit vitest reports on. The registry
16
+ * (`conformance/requirements.json`, generated) maps each id back to the
17
+ * citations found inside that test's body, so a bundle reader can still ask
18
+ * "which spec section did this row witness".
19
+ *
20
+ * Grammar: `openwop.it.<file-stem>.<title-slug>` where the stem is the scenario
21
+ * file name without `.test.ts` and the slug is the title lower-cased, every run
22
+ * of non-alphanumerics folded to one `-`, trimmed, and capped at 80 characters.
23
+ * A second test in the same file whose title slugs identically gets `~2`,
24
+ * `~3`, … (vitest allows duplicate titles; the ledger does not allow duplicate
25
+ * ids). A scenario MAY override the derived id for the current test with
26
+ * `req()` when it wants a hand-authored, registry-listed id.
27
+ */
28
+ export const IT_ID_PREFIX = 'openwop.it.';
29
+ export const IT_ID_GRAMMAR = /^openwop\.it\.[a-z0-9]+(?:-[a-z0-9]+)*\.[a-z0-9]+(?:-[a-z0-9]+)*(?:~[2-9][0-9]*)?$/;
30
+ const MAX_SLUG = 80;
31
+ /** Title → slug: lower-case, non-alphanumeric runs → `-`, trimmed, capped. */
32
+ export function slugTitle(title) {
33
+ const s = title
34
+ .toLowerCase()
35
+ .replace(/[^a-z0-9]+/g, '-')
36
+ .replace(/^-+|-+$/g, '');
37
+ const capped = s.length > MAX_SLUG ? s.slice(0, MAX_SLUG).replace(/-+$/g, '') : s;
38
+ return capped.length > 0 ? capped : 'untitled';
39
+ }
40
+ /** `auth-subject-link.test.ts` → `auth-subject-link`. */
41
+ export function fileStem(scenarioFile) {
42
+ return scenarioFile.replace(/\.test\.ts$/, '');
43
+ }
44
+ /** Derived id for one test, before collision suffixing. */
45
+ export function itRequirementId(scenarioFile, title) {
46
+ return `${IT_ID_PREFIX}${fileStem(scenarioFile)}.${slugTitle(title)}`;
47
+ }
48
+ /** The scenario file a per-`it` id belongs to, or null when the id is not one. */
49
+ export function scenarioFileOfItId(requirementId) {
50
+ if (!requirementId.startsWith(IT_ID_PREFIX))
51
+ return null;
52
+ const rest = requirementId.slice(IT_ID_PREFIX.length);
53
+ const dot = rest.indexOf('.');
54
+ if (dot <= 0)
55
+ return null;
56
+ return `${rest.slice(0, dot)}.test.ts`;
57
+ }
58
+ /**
59
+ * Collision suffixing: the first occurrence keeps the bare id, later ones get
60
+ * `~2`, `~3`, … Keyed per file so a worker running many files never crosses
61
+ * streams. Reset per file by `setup.ts` when the file finishes.
62
+ */
63
+ export class ItIdAllocator {
64
+ seen = new Map();
65
+ allocate(scenarioFile, title) {
66
+ const base = itRequirementId(scenarioFile, title);
67
+ const n = (this.seen.get(base) ?? 0) + 1;
68
+ this.seen.set(base, n);
69
+ return n === 1 ? base : `${base}~${n}`;
70
+ }
71
+ reset() {
72
+ this.seen.clear();
73
+ }
74
+ }
75
+ // ---------------------------------------------------------------------------
76
+ // Explicit override for the current test.
77
+ // ---------------------------------------------------------------------------
78
+ let explicitId = null;
79
+ /**
80
+ * Attach a hand-authored requirement id to the CURRENT test. The per-`it`
81
+ * ledger row for this test is recorded under `id` instead of the derived
82
+ * title id. Returns the usual `driver.describe`-style message so the call
83
+ * doubles as the assertion message:
84
+ *
85
+ * expect(x, req('openwop.auth.subject-link.leaver-deny', 'auth-profiles.md §Subject linking', 'deactivation MUST deny')).toBe(true)
86
+ *
87
+ * The id MUST be listed in `conformance/requirements.json` (the generator
88
+ * collects `req(` first-argument literals); an unlisted id fails the registry
89
+ * check, so a hand id cannot drift from the registry silently.
90
+ */
91
+ export function req(id, specSection, requirement) {
92
+ explicitId = id;
93
+ return `${specSection}: ${requirement}`;
94
+ }
95
+ /** setup.ts reads and clears the override after each test. */
96
+ export function takeExplicitRequirementId() {
97
+ const id = explicitId;
98
+ explicitId = null;
99
+ return id;
100
+ }
@@ -25,6 +25,7 @@
25
25
  * Pure functions, no I/O, so `runner-ledger.test.ts` can pin them without a
26
26
  * host or a vitest subprocess.
27
27
  */
28
+ import { scenarioFileOfItId } from './requirement-ids.js';
28
29
  import { PROFILE_FLOOR_SCENARIOS } from './profiles.js';
29
30
  import { requirementIdForScenario, requirementIdForPrefix, requirementsFor } from './requirement-registry.js';
30
31
  import { UNCLASSIFIED_RETURN_DETAIL } from './soft-skip.js';
@@ -233,6 +234,27 @@ document) {
233
234
  }
234
235
  rows.push(row);
235
236
  }
237
+ // Per-`it` rows (suite 1.153.0): every ledger entry keyed `openwop.it.<file>.<slug>`
238
+ // becomes its own bundle row, attributed to its scenario file. Additive — the
239
+ // file-level and prefix rows above are unchanged, and the floors still key on
240
+ // them. This is the granularity RFC 0148 §A describes and the G8 fix.
241
+ const emitted = new Set(rows.map((r) => r.requirementId));
242
+ for (const e of [...ledger].sort((a, b) => a.requirementId.localeCompare(b.requirementId))) {
243
+ const file = scenarioFileOfItId(e.requirementId);
244
+ // Attribute only to files this run reported on: a worker's ledger can carry
245
+ // rows from files outside the certified set (the suite's own lib tests, or
246
+ // a filtered run), and those are not evidence about the host.
247
+ if (file === null || emitted.has(e.requirementId) || !reportStates.has(file))
248
+ continue;
249
+ emitted.add(e.requirementId);
250
+ rows.push({
251
+ requirementId: e.requirementId,
252
+ scenarioId: file,
253
+ disposition: e.disposition,
254
+ ...(e.detail === undefined ? {} : { detail: e.detail }),
255
+ ...(e.assertionCount === undefined ? {} : { assertionCount: e.assertionCount }),
256
+ });
257
+ }
236
258
  const totals = { executedPass: 0, executedFail: 0, skipped: 0, inapplicable: 0, blocked: 0 };
237
259
  for (const r of rows) {
238
260
  if (r.disposition === 'executed-pass')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "1.151.0",
3
+ "version": "1.153.0",
4
4
  "description": "Production-ready black-box conformance suite for OpenWOP v1.0 compliant servers.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,6 +18,8 @@
18
18
  "fixtures",
19
19
  "fixtures.md",
20
20
  "coverage.md",
21
+ "requirements.json",
22
+ "requirement-aliases.json",
21
23
  "api",
22
24
  "schemas",
23
25
  "vectors",
@@ -0,0 +1,4 @@
1
+ {
2
+ "$comment": "old id → new id. Add a row when a test title is reworded so bundles citing the old id still resolve.",
3
+ "aliases": {}
4
+ }