@openwop/openwop-conformance 2.45.6 → 2.45.8

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,31 @@
1
1
  # `@openwop/openwop-conformance` Changelog
2
2
 
3
+ ## [2.45.8] — 2026-10-02 — RFC 0229 Accepted on MyndHyve's production cut; a v2 witness for tableStorage schema enforcement
4
+
5
+ - **The 2.45.8 cycle opens.** RFC 0229's and RFC 0228's gap registers change the packed `spec/v1/gaps.json`.
6
+ - **New scenario `v2-table-schema-enforcement`** (major 2, unaided; RFC 0228 gap G4). The v1 twin `table-schema-enforcement` drives a test seam; this port runs the new `conformance-table-schema-probe` fixture three times, each on a fresh table declared `{ k: string, n: number }`. Logic in `lib/table-schema-witness.ts`.
7
+ - `openwop.requirement.storage.table-schema-control-insert-completes`: a well-typed insert completes. It is the positive control.
8
+ - `openwop.requirement.storage.table-schema-insert-mistyped-refused`: an insert with `n: "not-a-number"` fails the probe node `validation_error` with `details.service: tableStorage`.
9
+ - `openwop.requirement.storage.table-schema-update-mistyped-refused`: an update setting `n` to a string fails the same way.
10
+
11
+ **Dispositions.** `tableStorage` not advertised, or the fixture not advertised: `inapplicable`. The fixture is the opt-in, as for the budget and safeFetch witnesses. A fixture run that cannot be created, finish or be read: `blocked`. A failed control: the control row fails and the two refusal rows are `blocked`, since a host that refuses every insert would pass them vacuously.
12
+
13
+ **Sabotage.** `lib/table-schema-witness.test.ts`, eight cases against the scratch host. A host that accepts a mistyped insert, or a mistyped update, fails only that row. v1's `table_schema_violation` fails both refusal rows. `validation_error` without `details.service` fails and cites `errors.md` §Host-service refusals. A probe that refuses everything fails the control.
14
+
15
+ **Host impact: none today.** No host advertises `tableStorage` at major 2, so all three rows record `inapplicable`.
16
+ - **New fixture `conformance-table-schema-probe`** (`fixtures.md` §The tableStorage schema probe fixture). Its node `core.conformance.table-schema-probe` is conformance-RESERVED and calls the host's own `ctx.storage.table`. A host MUST NOT advertise it unless it advertises `tableStorage`.
17
+ - **`trigger-bridge-delivery` runs every witness path a host offers (RFC 0230).** Until now the signed public-ingest path ran only when the delivery seams were absent. With the seams mounted it never ran. Without them, the seam-only leg 4 was red, so the profile did not certify, and an uncertified bundle is not acceptance evidence (RFC 0174 §B.1). Legs 1–3 now run the seam path whenever the seams are served, and the signed-ingest path whenever `triggerBridge.ingestion.inboundSigning` is advertised. A host that offers both must pass both, and each row notes which paths ran.
18
+ - **`trigger-refused-event-keeps-subscription`** records `inapplicable`, not seam-absent, on a host with no seams that advertises `inboundSigning`. The dead-letter leg above already witnesses the same rule on its signed ingest, and in strict mode this file used to fail such a host right after that leg had observed it.
19
+ - **Proof.** On openwop-app's in-process harness, with the seams on and `OPENWOP_TRIGGER_INBOUND_SIGNING=true`, all four legs pass. A sabotaged dedup assertion on the signed path fails leg 1, so that path now runs on a host that also serves the seams.
20
+
21
+ ## [2.45.7] — 2026-10-02 — a strict-mode host that does not advertise `workspace` is not failed; RFC 0231 Accepted
22
+
23
+ - **Fix: `v2-workspace-scope-from-identity` no longer fails a strict-mode host that does not advertise `workspace`.** The file gated with `gateFamily('workspace')`, which under `--require-behavior` fails an unadvertised family unless the operator opts it out. Its own header says an unadvertised family is `inapplicable`, and its sibling isolation scenarios (`v2-storage-`, `v2-queue-cross-tenant-isolation`) read with `familyAdvertised` and recorded `inapplicable` in the same run. It now reads with `familyAdvertised`.
24
+ - **Found by** MyndHyve's 2.45.5 cut: both `0059.workspace-scope-*` rows `executed-fail` on a host that does not serve `workspace`.
25
+ - **Proof.** Against a scratch host that does not advertise the family, in strict mode: both legs failed before the change; the three rows are `inapplicable` after it. A host that advertises `workspace` is unchanged (the v2 reference host passes both rows on its certified 2.45.6 cut).
26
+ - **Workaround on 2.45.3–2.45.6:** `OPENWOP_OPTED_OUT_PROFILES=family.workspace`, which records the rows `skipped`.
27
+ - **The 2.45.7 cycle opens.** RFC 0231's gap register changes the packed `spec/v1/gaps.json`: G3 and G4 close, and G2 and G7 are `externally-gated`. No scenario changes.
28
+
3
29
  ## [2.45.6] — 2026-10-02 — RFC 0231 (Active): a host says which budget exhaustion behaviours it serves
4
30
 
5
31
  - **The 2.45.6 cycle opens.** RFC 0231's gap register changes the packed `spec/v1/gaps.json`.
package/README.md CHANGED
@@ -135,7 +135,7 @@ Exit code is non-zero on any failed assertion. `--certify` distinguishes: `0`
135
135
 
136
136
  ## What's Covered
137
137
 
138
- The current suite has 590 scenario files under `src/scenarios/`.
138
+ The current suite has 591 scenario files under `src/scenarios/`.
139
139
  - 2026-09-29 (suite 2.45.0, openwop#1763): NEW `v2-cors-preflight.test.ts` — `headers.md` §Cross-origin preflight. For every operation it preflights with `Origin`, the method, and the operation's declared request headers (plus `authorization` / `content-type` where they apply, from `spec/v2/path-manifest.json`), and reads the answer as the Fetch standard's CORS-preflight check does. Self-gated: `inapplicable` when no operation's preflight grants `OPENWOP_CORS_ORIGIN` (default `https://conformance.invalid`).
140
140
  - 2026-09-23 (suite 2.37.0 cycle, RFC 0213): NEW `v2-sse-last-event-id-cursor.test.ts` (a `Last-Event-ID` past the log is an exclusive cursor; a malformed id, when refused, is `400 validation_error`; the cursor never changes the answer for an unknown or foreign-tenant run — public test of `event-cursor-after-authorization`), `v2-idempotency-in-flight.test.ts` (five concurrent same-key creates yield one run; each loser is a marked replay or `409 idempotency_in_flight` with no retry timing in `details`; `partial-witness` when no loser was refused in flight) and `v2-interrupt-resolve-terminal.test.ts` (a run-scoped resolve after cancel or completion is `409 interrupt_already_resolved`, never `interrupt_cancelled`). All three sit off the core-standard floor until measured on the three bundle hosts.
141
141
  - 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.
@@ -482,7 +482,7 @@ Server-required (added in 1.7.0):
482
482
  | ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
483
483
  | **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. |
484
484
 
485
- Current source tree: 590 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
485
+ Current source tree: 591 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
486
486
 
487
487
  ## Remaining Gaps
488
488
 
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "package": "@openwop/spec-artifacts",
3
- "version": "2.45.6",
4
- "stampSha256": "8ace8621112fabc9df41fdd55225e6e7baf022a0f51f845db41158d65058f16c"
3
+ "version": "2.45.8",
4
+ "stampSha256": "59536621c519684607bda9bfb0f0ddbb5a31cdf2b3f9131a856a9dfde93a6f39"
5
5
  }
@@ -0,0 +1,24 @@
1
+ {
2
+ "id": "conformance-table-schema-probe",
3
+ "name": "Conformance: v2 tableStorage schema probe",
4
+ "version": "1.0",
5
+ "description": "Fixture for `v2-table-schema-enforcement.test.ts` (spec/v2/core/storage.md §`tableStorage`: rows MUST conform to the table's declared schema). One node, `core.conformance.table-schema-probe` (conformance-RESERVED): a host that advertises `tableStorage` maps it to a node that calls its OWN `ctx.storage.table` for the run's tenant with the run's inputs and nothing else. Every action first calls `createTable(table, { k: \"string\", n: \"number\" })`. `action: control` then inserts `{ k: \"control\", n: 1 }` and completes with `outputs.result = { inserted: true, rowId }`. `action: insert-mistyped` inserts `{ k: \"mistyped\", n: \"not-a-number\" }`; if the host accepts it the node completes with `{ inserted: true, rowId }`. `action: update-mistyped` inserts `{ k: \"to-update\", n: 2 }`, then calls `update(table, rowId, { n: \"not-a-number\" })`; if the host accepts it the node completes with `{ updated: true, rowId }`. The node does no type checking of its own. A rejection fails the node with the rejection's code and details, unchanged. A host that does not advertise `tableStorage` MUST NOT advertise this fixture.",
6
+ "nodes": [
7
+ {
8
+ "id": "table-schema-probe",
9
+ "typeId": "core.conformance.table-schema-probe",
10
+ "name": "Probe ctx.storage.table schema enforcement",
11
+ "position": { "x": 0, "y": 0 },
12
+ "config": {},
13
+ "inputs": {}
14
+ }
15
+ ],
16
+ "edges": [],
17
+ "triggers": [{ "id": "manual", "type": "manual", "enabled": true }],
18
+ "variables": [
19
+ { "name": "action", "type": "string", "defaultValue": "control" },
20
+ { "name": "table", "type": "string" }
21
+ ],
22
+ "metadata": { "tags": ["conformance", "storage", "tableStorage"] },
23
+ "settings": { "timeout": 0 }
24
+ }
package/fixtures.md CHANGED
@@ -118,6 +118,7 @@ All fixtures MUST advertise:
118
118
  | v2 `ctx.mcp` Client | `conformance-mcp-client` | RFC 0204 — one `core.conformance.mcp-client` node (conformance-RESERVED) calls the host's `ctx.mcp.<method>` with the run's inputs and completes with `outputs.result` = the resolved value verbatim; a rejection fails the node with its code. See §"The `ctx.mcp` fixture (RFC 0204)" | `completed`, or `failed` (`not_found`) for an unknown `serverId` | ≤ 30s |
119
119
  | v2 Storage Tenant-Isolation Probe | `conformance-storage-probe` | `storage.md` §Shared rules — one `core.conformance.storage-probe` node (conformance-RESERVED) calls the host's own storage `family` for the run's tenant: `write` stores `value` at `key`, `read` returns every value and key the family's isolated reads return for `key`, `delete` removes it. See §"The storage probe fixtures" | `completed`, or `failed` (`forbidden` / `not_found`) for a refused read | ≤ 30s |
120
120
  | v2 fs Sandbox Probe | `conformance-fs-probe` | `storage.md` §`fs` — one `core.conformance.fs-probe` node (conformance-RESERVED) calls the host's own `ctx.fs.read` / `write` with the run's `path`, unchanged; a rejection fails the node with its code and details. Operator symlink `<sandboxRoot>/conformance/escape-link` points outside the root. See §"The storage probe fixtures" | `completed` inside the root; `failed` (`forbidden`, `details.reason: path-outside-sandbox`) for an escape | ≤ 30s |
121
+ | v2 tableStorage Schema Probe | `conformance-table-schema-probe` | `storage.md` §`tableStorage` — one `core.conformance.table-schema-probe` node (conformance-RESERVED) creates a table `{ k: string, n: number }` with the host's own `ctx.storage.table`, then inserts a well-typed row (`control`), a row whose `n` is a string (`insert-mistyped`), or updates `n` to a string (`update-mistyped`). See §"The tableStorage schema probe fixture" | `completed` for `control`; `failed` (`validation_error`, `details.service: tableStorage`) for a mistyped write | ≤ 30s |
121
122
  | v2 safeFetch SSRF Probe | `conformance-safefetch-probe` | `host-services.md` §`httpClient` — one `core.conformance.safefetch-probe` node (conformance-RESERVED) calls the host's own `ctx.http.safeFetch(url)` with the run's `url`, unchanged; completes with the response `status` only; a rejection fails the node with its code and details. See §"The safeFetch probe fixture" | `failed` (`egress_denied`, `details.reason: ssrf-blocked`) for a refused target | ≤ 60s |
122
123
  | A2A Task Roundtrip | `conformance-a2a-task-roundtrip` | Track 6 — host consumes the conformance suite's synthetic A2A peer; covers drift points #3 (`AUTH_REQUIRED`) and #4 (`REJECTED`). Node `core.conformance.a2a-invoke` is a **conformance-RESERVED** typeId (renamed 2026-08-16 from `core.a2a.invoke`, a multi-segment `core.*` id no host shipped and the corpus never defined): a host that consumes A2A peers MUST map it to its A2A bridge node (a host-extension node — `a2a-integration.md` §"State projection"); a host that does not consume A2A MUST NOT advertise this fixture | `failed` or `waiting-input` (per `driftScenario` input) | ≤ 30s |
123
124
  | WASM Pack Roundtrip | `conformance-wasm-pack-roundtrip` | RFC 0008 — invokes `vendor.openwop.rust-hello.greet` (loaded WASM pack); exercises required exports + at least one import | `completed` | ≤ 10s |
@@ -725,6 +726,28 @@ For `v2-tool-catalog-annotations`' unclassified-MCP leg, a host that advertises
725
726
 
726
727
  ---
727
728
 
729
+ ## The tableStorage schema probe fixture (storage.md)
730
+
731
+ `conformance-table-schema-probe` is what `v2-table-schema-enforcement` drives. Its node, `core.conformance.table-schema-probe`, is conformance-RESERVED: a host that advertises `tableStorage` maps it to a node that calls its OWN `ctx.storage.table` (`spec/v2/core/storage.md` §`tableStorage`) for the run's tenant, and nothing else. It is a fixture, not a seam: it runs through `POST /runs` under the caller's own credential.
732
+
733
+ **Inputs.** `action` (`control` | `insert-mistyped` | `update-mistyped`) and `table`, a fresh name per run.
734
+
735
+ **Every action** first calls `createTable(table, { k: "string", n: "number" })`.
736
+
737
+ | `action` | Then | Completes with |
738
+ |---|---|---|
739
+ | `control` | `insert(table, { k: "control", n: 1 })` | `outputs.result = { inserted: true, rowId }` |
740
+ | `insert-mistyped` | `insert(table, { k: "mistyped", n: "not-a-number" })` | `{ inserted: true, rowId }`, only if the host accepts it |
741
+ | `update-mistyped` | `insert(table, { k: "to-update", n: 2 })`, then `update(table, rowId, { n: "not-a-number" })` | `{ updated: true, rowId }`, only if the host accepts it |
742
+
743
+ - **Rejection.** The node fails with the rejection's code and details unchanged (`errors.md` §Host-service refusals). A schema divergence is `validation_error` with `details.service: tableStorage`.
744
+ - **No checking of its own.** The node passes the row through, so the host's own schema enforcement is what is exercised.
745
+ - **Cleanup.** None: each run names a fresh table. A host that caps the number of tables MAY drop probe tables on its own schedule.
746
+
747
+ A host MUST NOT advertise the fixture unless it advertises `tableStorage`.
748
+
749
+ ---
750
+
728
751
  ## The safeFetch probe fixture (host-services.md)
729
752
 
730
753
  `conformance-safefetch-probe` is what `v2-safefetch-ssrf-refused` drives. Its node, `core.conformance.safefetch-probe`, is conformance-RESERVED: a host that advertises `httpClient.safeFetch` maps it to a node that calls its OWN `ctx.http.safeFetch(url)` (`spec/v2/core/host-services.md` §`httpClient`) with the run's `url` input unchanged, method `GET`, no headers and no body, and nothing else. It is a fixture, not a seam: it runs through `POST /runs` under the caller's own credential.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.45.6",
3
+ "version": "2.45.8",
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.45.6"
59
+ "@openwop/spec-artifacts": "2.45.8"
60
60
  }
61
61
  }
package/requirements.json CHANGED
@@ -2,11 +2,11 @@
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": 665,
6
- "tests": 2542,
7
- "withStableId": 2542,
5
+ "files": 666,
6
+ "tests": 2545,
7
+ "withStableId": 2545,
8
8
  "interpolatedTitles": 0,
9
- "explicitIds": 2418
9
+ "explicitIds": 2421
10
10
  },
11
11
  "records": [
12
12
  {
@@ -26311,7 +26311,7 @@
26311
26311
  {
26312
26312
  "id": "openwop.it.trigger-bridge-delivery.de-dups-by-dedupkey-a-repeated-delivery-is-effectively-once",
26313
26313
  "file": "trigger-bridge-delivery.test.ts",
26314
- "line": 149,
26314
+ "line": 158,
26315
26315
  "title": "de-dups by dedupKey: a repeated delivery is effectively-once",
26316
26316
  "explicitId": "openwop.requirement.0083.trigger-delivery.dedup",
26317
26317
  "citations": [
@@ -26347,7 +26347,7 @@
26347
26347
  {
26348
26348
  "id": "openwop.it.trigger-bridge-delivery.dead-letters-an-exhausted-or-refused-delivery-without-starting-a-run",
26349
26349
  "file": "trigger-bridge-delivery.test.ts",
26350
- "line": 172,
26350
+ "line": 182,
26351
26351
  "title": "dead-letters an exhausted or refused delivery without starting a run",
26352
26352
  "explicitId": "openwop.requirement.0083.trigger-delivery.dead-letter",
26353
26353
  "citations": [
@@ -26380,7 +26380,7 @@
26380
26380
  {
26381
26381
  "id": "openwop.it.trigger-bridge-delivery.links-delivery-run-causation-on-run-started",
26382
26382
  "file": "trigger-bridge-delivery.test.ts",
26383
- "line": 194,
26383
+ "line": 205,
26384
26384
  "title": "links delivery→run causation on run.started",
26385
26385
  "explicitId": "openwop.requirement.0083.trigger-delivery.causation",
26386
26386
  "citations": [
@@ -26416,7 +26416,7 @@
26416
26416
  {
26417
26417
  "id": "openwop.it.trigger-bridge-delivery.keeps-the-run-less-trigger-events-content-free",
26418
26418
  "file": "trigger-bridge-delivery.test.ts",
26419
- "line": 222,
26419
+ "line": 234,
26420
26420
  "title": "keeps the run-less trigger events content-free",
26421
26421
  "explicitId": null,
26422
26422
  "citations": []
@@ -26732,7 +26732,7 @@
26732
26732
  {
26733
26733
  "id": "openwop.it.trigger-refused-event-keeps-subscription.a-refused-event-dead-letters-only-its-delivery-the-subscription-stays-active-and",
26734
26734
  "file": "trigger-refused-event-keeps-subscription.test.ts",
26735
- "line": 55,
26735
+ "line": 57,
26736
26736
  "title": "a refused event dead-letters only its delivery; the subscription stays active and a verified event then delivers",
26737
26737
  "explicitId": "openwop.requirement.trigger-bridge.refused-event-keeps-subscription",
26738
26738
  "citations": [
@@ -34929,6 +34929,48 @@
34929
34929
  }
34930
34930
  ]
34931
34931
  },
34932
+ {
34933
+ "id": "openwop.it.v2-table-schema-enforcement.a-well-typed-insert-completes-positive-control",
34934
+ "file": "v2-table-schema-enforcement.test.ts",
34935
+ "line": 65,
34936
+ "title": "a well-typed insert completes (positive control)",
34937
+ "explicitId": "openwop.requirement.storage.table-schema-control-insert-completes",
34938
+ "citations": [
34939
+ {
34940
+ "section": null,
34941
+ "requirement": null,
34942
+ "interpolated": true
34943
+ }
34944
+ ]
34945
+ },
34946
+ {
34947
+ "id": "openwop.it.v2-table-schema-enforcement.an-insert-whose-column-type-diverges-from-the-schema-is-refused-validation-error",
34948
+ "file": "v2-table-schema-enforcement.test.ts",
34949
+ "line": 71,
34950
+ "title": "an insert whose column type diverges from the schema is refused validation_error",
34951
+ "explicitId": "openwop.requirement.storage.table-schema-insert-mistyped-refused",
34952
+ "citations": [
34953
+ {
34954
+ "section": null,
34955
+ "requirement": null,
34956
+ "interpolated": true
34957
+ }
34958
+ ]
34959
+ },
34960
+ {
34961
+ "id": "openwop.it.v2-table-schema-enforcement.an-update-whose-column-type-diverges-from-the-schema-is-refused-validation-error",
34962
+ "file": "v2-table-schema-enforcement.test.ts",
34963
+ "line": 77,
34964
+ "title": "an update whose column type diverges from the schema is refused validation_error",
34965
+ "explicitId": "openwop.requirement.storage.table-schema-update-mistyped-refused",
34966
+ "citations": [
34967
+ {
34968
+ "section": null,
34969
+ "requirement": null,
34970
+ "interpolated": true
34971
+ }
34972
+ ]
34973
+ },
34932
34974
  {
34933
34975
  "id": "openwop.it.v2-terminal-event-once.every-run-observed-to-a-terminal-state-has-exactly-one-terminal-event-and-no-for",
34934
34976
  "file": "v2-terminal-event-once.test.ts",
@@ -36065,7 +36107,7 @@
36065
36107
  {
36066
36108
  "id": "openwop.it.v2-workspace-scope-from-identity.a-second-tenant-cannot-read-or-list-a-file-the-first-tenant-wrote",
36067
36109
  "file": "v2-workspace-scope-from-identity.test.ts",
36068
- "line": 100,
36110
+ "line": 107,
36069
36111
  "title": "a second tenant cannot read or list a file the first tenant wrote",
36070
36112
  "explicitId": "openwop.requirement.0059.workspace-scope-from-identity",
36071
36113
  "citations": [
@@ -36099,7 +36141,7 @@
36099
36141
  {
36100
36142
  "id": "openwop.it.v2-workspace-scope-from-identity.naming-the-first-tenant-s-scope-in-the-request-does-not-widen-the-second-tenant",
36101
36143
  "file": "v2-workspace-scope-from-identity.test.ts",
36102
- "line": 122,
36144
+ "line": 129,
36103
36145
  "title": "naming the first tenant's scope in the request does not widen the second tenant's scope",
36104
36146
  "explicitId": "openwop.requirement.0059.workspace-scope-not-request-named",
36105
36147
  "citations": [
@@ -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": 590,
4
+ "files": 591,
5
5
  "v1": 454,
6
- "v2": 151
6
+ "v2": 152
7
7
  },
8
8
  "majors": {
9
9
  "a2a-1-0-agent-card.test.ts": [
@@ -1620,6 +1620,9 @@
1620
1620
  "v2-subject-link-record.test.ts": [
1621
1621
  2
1622
1622
  ],
1623
+ "v2-table-schema-enforcement.test.ts": [
1624
+ 2
1625
+ ],
1623
1626
  "v2-terminal-event-once.test.ts": [
1624
1627
  2
1625
1628
  ],
@@ -1,17 +1,17 @@
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.45.6",
5
- "corpusTag": "v2.45.6",
4
+ "version": "2.45.8",
5
+ "corpusTag": "v2.45.8",
6
6
  "files": {
7
7
  "api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
8
8
  "api/asyncapi.yaml": "d5ecb9ee6114582be3b1f662c84bfac9ae96dae7bacb853e461168f70a8e1c7d",
9
9
  "api/grpc/openwop.proto": "c3e72bb17cba514ee98feb6434e6c9b6ea6795bfd086489ec69fd882dd1ad977",
10
10
  "api/openapi.yaml": "ad37a5a5c732ad46100fc03c3f4243fdd4f2976e330f02a8791b6b5b10a8a0c1",
11
11
  "api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
12
- "api/seams-v2.yaml": "41ce056ad21053d69a824b28d94ac798cc3ed68aa5117f853ae5aaa017648fc6",
13
- "api/v2/asyncapi.yaml": "34b82d051f6d1fa20a2abc5a39b822730e22c2eaade1eee1e0975dfe07ff998c",
14
- "api/v2/openapi.yaml": "2a59f358e951a33b89bea3a7e4c996ca5f30d96ce1438ee99787b31f54f13f1e",
12
+ "api/seams-v2.yaml": "9d9fd7ed63a5514f48eb525b85400bdf3672c228b378fd976142514296cbf7b8",
13
+ "api/v2/asyncapi.yaml": "e68b4dbe4a0426d8887e3d38dcc67fdbe1a8a4e25360abc44e30fca2323e0d77",
14
+ "api/v2/openapi.yaml": "eec20ade7ef2d68836e0f91fb756afa63a27dd89c2b8f8daae26c2dbb16be91c",
15
15
  "api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
16
16
  "schemas/README.md": "0c0b737ffcf8f30e7d2809cec8a498232de710f41443212922ad8337cdde0b51",
17
17
  "schemas/a2a-task-state.schema.json": "75d5049dea8bd873ff0e7546f1c60c8d36c219bec7084264360be54a8be30ae1",
@@ -204,13 +204,13 @@
204
204
  "schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
205
205
  "spec/v1/alias-detectors.json": "0b2f28808ffe0b9d98a187775038c3406bb70919747a788a6e4f866e293ae6d3",
206
206
  "spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
207
- "spec/v1/core-standard-manifest.json": "535fef79758a01575c5f49db8690f0b4fe4ebb713564739b974818d6b9b0949b",
207
+ "spec/v1/core-standard-manifest.json": "e7939af18e6c8d189f60e47df9fadd2da93c4ae96109a023283687dc7ba83b9f",
208
208
  "spec/v1/deprecations.json": "a3293072ea951857395d05f0437264e88965f1e9019ff61c55bacfa9c1aaa17c",
209
209
  "spec/v1/deprecations.schema.json": "3e393c405d2a41b467d8c5e3c468549078df1ce6a6d2588fc488097b95d9b55b",
210
210
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
211
211
  "spec/v1/event-codemap.schema.json": "d05933b2e88103aff51a2f774df97b9bda065e0fc88dbd9b114cc47a32189174",
212
212
  "spec/v1/extensions.json": "79a60754aa16cbdbdb604f8e5c00038af535a1690a4d07bd5ff7c38f0256a7bc",
213
- "spec/v1/gaps.json": "10c8f4c5bcf312b543dc4139eff23948cb01f6bfaa309b523719ef779e7bec89",
213
+ "spec/v1/gaps.json": "fa1b564fe59db6a708b9488d15aab445919fcc3621e8cf8121901b90b7b4c234",
214
214
  "spec/v1/gaps.schema.json": "8fd83259f556553c9df0f53e7a82ca8c2a4771d471197f69b4e2ae0ceeacfd66",
215
215
  "spec/v1/migrations.json": "4c50760be70cb644d02427bcaf5749606700edf4513f423903cf3b1d12f5c300",
216
216
  "spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
@@ -303,9 +303,9 @@
303
303
  "spec/v2/path-manifest.json": "27c2307b84376b2f22bea4e2213fc7e33ef3bf8fd1d18039f7955edb00dc9c9a",
304
304
  "spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
305
305
  "spec/v2/profiles.json": "180beb9ef2d161766fa23b2987e7e829851e3d316273147cf6a4a3f324befd6b",
306
- "spec/v2/release.json": "345ed102cf6fee40e78a5deea9d9d8512e8b03a3fea18887e93487e0faf5f9ab",
306
+ "spec/v2/release.json": "a9e139b57c3bcf8e888dfe58aef34a9b72813d2c30132364df3e21fe3a27d628",
307
307
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
308
- "spec/v2/surface-baseline.json": "61c1628690fa95d4ab4b6c746b62d3a021c6485d4e2a2cdcca283489e31c03d9"
308
+ "spec/v2/surface-baseline.json": "7d6134b5693b3fb25afa7e54715f00cf0bf419e0bcb5b8802571a263c4ef019d"
309
309
  },
310
- "corpusCommit": "ef7ea3078b23d6d488c6c131a305d339eb287afc"
310
+ "corpusCommit": "c5b85d858212ed6497ae6b2ae7584e5e2d77d016"
311
311
  }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * The tableStorage schema witness (`spec/v2/core/storage.md` §`tableStorage`:
3
+ * "an insert or update whose column types diverge from it MUST be refused").
4
+ *
5
+ * Unaided: the suite runs the `conformance-table-schema-probe` fixture three
6
+ * times, each on a fresh table declared `{ k: string, n: number }`:
7
+ *
8
+ * control a well-typed insert MUST complete. Without it, a host
9
+ * whose probe refuses everything passes the other legs.
10
+ * insert-mistyped an insert with `n: "not-a-number"` MUST be refused.
11
+ * update-mistyped an update setting `n` to a string MUST be refused.
12
+ *
13
+ * A refusal is `validation_error` with `details.service: tableStorage`
14
+ * (`errors.md` §Host-service refusals; RFC 0228 maps v1's
15
+ * `table_schema_violation` to it).
16
+ *
17
+ * Two halves, as in `budget-witness.ts`: {@link drive} observes and asserts
18
+ * nothing; {@link judge} is pure.
19
+ */
20
+
21
+ import { randomUUID } from 'node:crypto';
22
+ import { driver, type OpenWOPResponse } from './driver.js';
23
+ import { readErrorCode } from './error-envelope.js';
24
+ import { isFixtureAdvertised } from './fixtures.js';
25
+
26
+ export const TABLE_FIXTURE = 'conformance-table-schema-probe';
27
+ export const NODE_ID = 'table-schema-probe';
28
+ export type ProbeAction = 'control' | 'insert-mistyped' | 'update-mistyped';
29
+ export const ACTIONS: readonly ProbeAction[] = ['control', 'insert-mistyped', 'update-mistyped'];
30
+ const TERMINAL = new Set(['completed', 'failed', 'cancelled']);
31
+ const DOC = 'spec/v2/core/storage.md §tableStorage';
32
+ const REFUSAL_DOC = 'spec/v2/core/errors.md §Host-service refusals';
33
+
34
+ export interface ProbeOutcome {
35
+ readonly action: ProbeAction;
36
+ readonly status: string;
37
+ readonly code: string | null;
38
+ readonly service: unknown;
39
+ }
40
+ export type TableRun =
41
+ | { readonly kind: 'skip'; readonly disposition: 'inapplicable' | 'blocked'; readonly reason: string }
42
+ | { readonly kind: 'observed'; readonly outcomes: Readonly<Record<ProbeAction, ProbeOutcome>> };
43
+
44
+ export interface TableFinding {
45
+ readonly rule: ProbeAction;
46
+ readonly ok: boolean;
47
+ readonly doc: string;
48
+ readonly message: string;
49
+ }
50
+
51
+ const isRecord = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v);
52
+ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse | null> {
53
+ try { return await fn(); } catch { return null; }
54
+ }
55
+ const enc = (id: string): string => encodeURIComponent(id);
56
+
57
+ async function probe(action: ProbeAction, timeoutMs: number): Promise<ProbeOutcome | string> {
58
+ const table = `conformance_schema_${randomUUID().replace(/-/g, '').slice(0, 16)}`;
59
+ const created = await http(() => driver.post('/runs', { workflowId: TABLE_FIXTURE, inputs: { action, table } }));
60
+ if (created === null) return 'POST /runs unreachable (fetch failed)';
61
+ if (created.status === 429) return 'POST /runs answered 429 — the run budget of the host, not the wire';
62
+ const runId = (created.json as { runId?: unknown } | undefined)?.runId;
63
+ if (created.status !== 201 || typeof runId !== 'string') return `POST /runs {workflowId: ${TABLE_FIXTURE}} answered ${created.status} ${readErrorCode(created.json) ?? ''} — the fixture run was refused`.trim();
64
+
65
+ const deadline = Date.now() + timeoutMs;
66
+ let status = '';
67
+ for (;;) {
68
+ const snap = await http(() => driver.get(`/runs/${enc(runId)}`));
69
+ status = String((snap?.json as { status?: unknown } | undefined)?.status ?? '');
70
+ if (TERMINAL.has(status)) break;
71
+ if (Date.now() > deadline) {
72
+ await http(() => driver.post(`/runs/${enc(runId)}/cancel`, {}));
73
+ return `the ${action} run did not reach a terminal status within ${timeoutMs} ms (last: ${status || 'unreadable'})`;
74
+ }
75
+ await new Promise((r) => setTimeout(r, 200));
76
+ }
77
+ const ev = await http(() => driver.get(`/runs/${enc(runId)}/events/poll?timeout=1`));
78
+ const events = (ev?.json as { events?: unknown } | undefined)?.events;
79
+ if (ev?.status !== 200 || !Array.isArray(events)) return `GET /runs/{runId}/events/poll answered ${ev?.status ?? 'nothing'} — the run ended but its log could not be read`;
80
+ const failed = (events as Array<{ type?: unknown; payload?: unknown }>).find((e) => e.type === 'node.failed' && isRecord(e.payload) && e.payload['nodeId'] === NODE_ID)?.payload as Record<string, unknown> | undefined;
81
+ const error = isRecord(failed?.['error']) ? failed['error'] : null;
82
+ const details = isRecord(error?.['details']) ? error['details'] : null;
83
+ return { action, status, code: typeof error?.['code'] === 'string' ? error['code'] : null, service: details?.['service'] };
84
+ }
85
+
86
+ /** Gate, then one fixture run per action. Asserts nothing. */
87
+ export async function drive(doc: unknown, timeoutMs = 30_000): Promise<TableRun> {
88
+ const family = isRecord(doc) ? doc['tableStorage'] : undefined;
89
+ if (!isRecord(family)) return { kind: 'skip', disposition: 'inapplicable', reason: 'tableStorage is not advertised in the v2 discovery root' };
90
+ if (!isFixtureAdvertised(TABLE_FIXTURE)) return { kind: 'skip', disposition: 'inapplicable', reason: `${TABLE_FIXTURE} fixture not advertised — tableStorage has no protocol path, and the host has not opted in to the unaided schema witness` };
91
+ const outcomes: Partial<Record<ProbeAction, ProbeOutcome>> = {};
92
+ for (const action of ACTIONS) {
93
+ const o = await probe(action, timeoutMs);
94
+ if (typeof o === 'string') return { kind: 'skip', disposition: 'blocked', reason: o };
95
+ outcomes[action] = o;
96
+ }
97
+ return { kind: 'observed', outcomes: outcomes as Record<ProbeAction, ProbeOutcome> };
98
+ }
99
+
100
+ const show = (o: ProbeOutcome): string => `${o.status}${o.code ? ` ${o.code}` : ''}${o.service !== undefined ? ` (details.service ${JSON.stringify(o.service)})` : ''}`;
101
+
102
+ /** What each run must show. Pure. */
103
+ export function judge(outcomes: Readonly<Record<ProbeAction, ProbeOutcome>>): TableFinding[] {
104
+ const c = outcomes.control;
105
+ const out: TableFinding[] = [{
106
+ rule: 'control',
107
+ ok: c.status === 'completed',
108
+ doc: 'conformance/fixtures.md §The tableStorage schema probe fixture',
109
+ message: `a well-typed insert into a table declared { k: string, n: number } MUST complete (got ${show(c)})`,
110
+ }];
111
+ for (const action of ['insert-mistyped', 'update-mistyped'] as const) {
112
+ const o = outcomes[action];
113
+ const verb = action === 'insert-mistyped' ? 'an insert' : 'an update';
114
+ const refused = o.status === 'failed' && o.code === 'validation_error';
115
+ out.push({
116
+ rule: action,
117
+ ok: refused && o.service === 'tableStorage',
118
+ doc: refused ? REFUSAL_DOC : DOC,
119
+ message: refused
120
+ ? `a refused tableStorage call with the generic code validation_error MUST carry details.service: tableStorage (got ${show(o)})`
121
+ : `${verb} whose column types diverge from the declared schema MUST be refused, as validation_error (got ${show(o)})`,
122
+ });
123
+ }
124
+ return out;
125
+ }
@@ -16,16 +16,24 @@
16
16
  * (a dead-lettered attempt, `trigger.subscription.state.changed`) carry no
17
17
  * inbound content.
18
18
  *
19
- * TWO WITNESS PATHS. The SEAM path (primary, whenever the host serves them)
20
- * drives `POST /v1/host/sample/trigger-bridge/deliver` and reads the test
21
- * event-log seam. When the seams are absent — as they are on every production
22
- * host — legs 1–3 run the NORMATIVE-SURFACE path of RFC 0230 if the host
23
- * advertises `triggerBridge.ingestion.inboundSigning: ["standard-webhooks-1"]`:
24
- * register a webhook subscription and POST Standard-Webhooks-signed bodies to its
25
- * `ingestUrl` with no OpenWOP credential. Each row notes which path ran
26
- * (`observed:`). Leg 4 has no normative-surface path — the run-less events are
27
- * on no run's log — so it stays seam-witnessed and records seam-absent
28
- * otherwise; it remains in the floor (the floor never narrows).
19
+ * TWO WITNESS PATHS, and a host is measured on EVERY path it offers (2.45.8).
20
+ * The SEAM path drives `POST /v1/host/sample/trigger-bridge/deliver` and reads
21
+ * the test event-log seam; it runs whenever the host serves the seams. The
22
+ * NORMATIVE-SURFACE path of RFC 0230 registers a webhook subscription and POSTs
23
+ * Standard-Webhooks-signed bodies to its `ingestUrl` with no OpenWOP credential;
24
+ * legs 1–3 run it whenever the host advertises
25
+ * `triggerBridge.ingestion.inboundSigning: ["standard-webhooks-1"]`. A host that
26
+ * offers both must pass both, and each row notes which ran (`observed:`).
27
+ *
28
+ * Until 2.45.8 the normative-surface path ran only when the seams were absent.
29
+ * That left it unwitnessable on a certified bundle: with the seams mounted it
30
+ * never ran, and without them leg 4 (seam-only) is red, the profile does not
31
+ * certify, and an uncertified bundle supplies no acceptance evidence
32
+ * (RFC 0174 §B.1).
33
+ *
34
+ * Leg 4 has no normative-surface path — the run-less events are on no run's
35
+ * log — so it stays seam-witnessed and records seam-absent otherwise; it
36
+ * remains in the floor (the floor never narrows).
29
37
  *
30
38
  * Spec references:
31
39
  * - spec/v1/trigger-bridge.md (§C, §F.2, §F.6)
@@ -65,13 +73,17 @@ function expectContentFree(id: string, payload: Record<string, unknown>, where:
65
73
  }
66
74
  }
67
75
 
68
- type Path = 'seam' | 'normative' | null;
76
+ /** The witness paths this host offers. Both are run when both are offered. */
77
+ async function witnessPaths(): Promise<{ seam: boolean; normative: boolean }> {
78
+ return { seam: await isEventLogSeamAvailable(), normative: await inboundSigningAdvertised() };
79
+ }
69
80
 
70
- /** Which witness path this host supports: the seams when present, else RFC 0230's. */
71
- async function witnessPath(): Promise<Path> {
72
- if (await isEventLogSeamAvailable()) return 'seam';
73
- if (await inboundSigningAdvertised()) return 'normative';
74
- return null;
81
+ /** `ran`, or `absent` when the delivery seam turned out to be unwired; the leg then records it with `seamAbsent`. */
82
+ const SEAM_UNWIRED = 'host advertises openwop-trigger-bridge but the delivery seam is unwired';
83
+ type SeamLeg = 'ran' | 'absent';
84
+
85
+ function notePaths(seam: boolean, normative: boolean, extra = ''): void {
86
+ noteObservation([seam ? 'seam path' : '', normative ? 'normative-surface path (RFC 0230)' : ''].filter((x) => x !== '').join(' + ') + extra);
75
87
  }
76
88
 
77
89
  const NO_PATH = 'host advertises openwop-trigger-bridge but neither the event-log seam nor an RFC 0230 inboundSigning ingest is available';
@@ -82,14 +94,14 @@ function bareRunId(runId: string): string {
82
94
  }
83
95
 
84
96
  /** The seam witness path (primary when the host serves the seams). */
85
- async function dedupViaSeam(): Promise<undefined> {
97
+ async function dedupViaSeam(): Promise<SeamLeg> {
86
98
  // The dedupKey is MINTED PER EXERCISE, and that is load-bearing (2.37.0):
87
99
  // §C-1's dedup window is a ≥24h floor, so a literal key collides with an
88
100
  // earlier run the same day and convicts a conformant host. The repetition
89
101
  // §C-1 is about happens INSIDE `driveDelivery`'s `scenario: 'dedup'`.
90
102
  const dedupKey = freshDedupKey('queue');
91
103
  const dedup = await driveDelivery({ scenario: 'dedup', dedupKey, source: 'queue' });
92
- if (dedup === null) return seamAbsent('host advertises openwop-trigger-bridge but the delivery seam is unwired');
104
+ if (dedup === null) return 'absent';
93
105
  const dedupEvents = requireEvents(
94
106
  await queryTestEvents(dedup.runId ?? '__dedup__', { type: 'trigger.delivery.attempted' }),
95
107
  'trigger.delivery.attempted (dedup)',
@@ -100,15 +112,14 @@ async function dedupViaSeam(): Promise<undefined> {
100
112
  expect(typeof e.payload.outcome === 'string' && DELIVERY_OUTCOMES.includes(e.payload.outcome as string), req(R_DEDUP, 'run-event-payloads.schema.json#triggerDeliveryAttempted', 'outcome MUST be delivered|retrying|dead-lettered')).toBe(true);
101
113
  expectContentFree(R_DEDUP, e.payload, 'trigger.delivery.attempted');
102
114
  }
103
- noteObservation('seam path');
104
115
  await resetTestSeam();
105
- return undefined;
116
+ return 'ran';
106
117
  }
107
118
 
108
119
  /** The seam witness path (primary when the host serves the seams). */
109
- async function deadLetterViaSeam(): Promise<undefined> {
120
+ async function deadLetterViaSeam(): Promise<SeamLeg> {
110
121
  const exhaust = await driveDelivery({ scenario: 'exhaust', source: 'webhook' });
111
- if (exhaust === null) return seamAbsent('host advertises openwop-trigger-bridge but the delivery seam is unwired');
122
+ if (exhaust === null) return 'absent';
112
123
  const exKey = exhaust.runId ?? '__exhaust__';
113
124
  const exhaustEvents = requireEvents(await queryTestEvents(exKey, { type: 'trigger.delivery.attempted' }), 'trigger.delivery.attempted (exhaust)');
114
125
  expect(exhaustEvents.length >= 1, req(R_DEAD_LETTER, 'trigger-bridge.md §C-2', 'an exhausted delivery MUST emit ≥1 trigger.delivery.attempted')).toBe(true);
@@ -120,13 +131,12 @@ async function deadLetterViaSeam(): Promise<undefined> {
120
131
  for (const e of stateEvents) {
121
132
  expect(typeof e.payload.toState === 'string' && SUBSCRIPTION_STATES.includes(e.payload.toState as string), req(R_DEAD_LETTER, 'trigger-bridge.md §B', 'toState MUST be in the four-state vocabulary')).toBe(true);
122
133
  }
123
- noteObservation('seam path');
124
134
  await resetTestSeam();
125
- return undefined;
135
+ return 'ran';
126
136
  }
127
137
 
128
138
  /** The seam witness path (primary when the host serves the seams). */
129
- async function causationViaSeam(): Promise<undefined> {
139
+ async function causationViaSeam(): Promise<SeamLeg> {
130
140
  const delivered = await driveDelivery({ scenario: 'deliver', source: 'schedule' });
131
141
  expect(delivered !== null && typeof delivered.runId === 'string' && (delivered.runId as string).length > 0, req(R_CAUSATION, 'trigger-bridge.md §C', 'a successful delivery MUST create a run')).toBe(true);
132
142
  const deliveredRunId = delivered!.runId as string;
@@ -140,18 +150,18 @@ async function causationViaSeam(): Promise<undefined> {
140
150
  typeof runStarted.causationId === 'string' && (runStarted.causationId as string).length > 0 && runStarted.causationId === deliveredEvent!.eventId,
141
151
  req(R_CAUSATION, 'trigger-bridge.md §C / RFC 0040', 'run.started.causationId MUST EQUAL the delivery id (the trigger.delivery.attempted{delivered} eventId) — resolvable via /ancestry'),
142
152
  ).toBe(true);
143
- noteObservation('seam path');
144
153
  await resetTestSeam();
145
- return undefined;
154
+ return 'ran';
146
155
  }
147
156
 
148
157
  describe('trigger-bridge-delivery (RFC 0083 §C)', () => {
149
158
  it('de-dups by dedupKey: a repeated delivery is effectively-once', async () => {
150
159
  if (!behaviorGate('openwop-trigger-bridge', await isTriggerBridgeProfileAdvertised())) return;
151
- const path = await witnessPath();
152
- if (path === null) return seamAbsent(NO_PATH);
160
+ const paths = await witnessPaths();
161
+ if (!paths.seam && !paths.normative) return seamAbsent(NO_PATH);
153
162
 
154
- if (path === 'seam') return dedupViaSeam();
163
+ if (paths.seam && (await dedupViaSeam()) === 'absent') return seamAbsent(SEAM_UNWIRED);
164
+ if (!paths.normative) return notePaths(true, false);
155
165
 
156
166
  // Normative-surface path (RFC 0230 §C Identity + §D).
157
167
  const sub = await registerSignedWebhook('none');
@@ -166,15 +176,16 @@ describe('trigger-bridge-delivery (RFC 0083 §C)', () => {
166
176
  expect(again.status, req(R_DEDUP, 'trigger-bridge.md §F.6 / §C-1', `a repeated webhook-id MUST answer 200 duplicate, starting no new run (got ${again.status})`)).toBe(200);
167
177
  expect(again.json?.outcome, req(R_DEDUP, 'trigger-bridge.md §F.6', 'the dedup no-op MUST say outcome "duplicate"')).toBe('duplicate');
168
178
  expect(again.json?.runId, req(R_DEDUP, 'trigger-bridge.md §F.4', 'a dedup no-op MUST return the PRIOR runId')).toBe(runId);
169
- noteObservation('normative-surface path (RFC 0230)');
179
+ notePaths(paths.seam, true);
170
180
  });
171
181
 
172
182
  it('dead-letters an exhausted or refused delivery without starting a run', async () => {
173
183
  if (!behaviorGate('openwop-trigger-bridge', await isTriggerBridgeProfileAdvertised())) return;
174
- const path = await witnessPath();
175
- if (path === null) return seamAbsent(NO_PATH);
184
+ const paths = await witnessPaths();
185
+ if (!paths.seam && !paths.normative) return seamAbsent(NO_PATH);
176
186
 
177
- if (path === 'seam') return deadLetterViaSeam();
187
+ if (paths.seam && (await deadLetterViaSeam()) === 'absent') return seamAbsent(SEAM_UNWIRED);
188
+ if (!paths.normative) return notePaths(true, false);
178
189
 
179
190
  // Normative-surface path (RFC 0230 §C Verification + §C.1).
180
191
  const sub = await registerSignedWebhook('required');
@@ -188,15 +199,16 @@ describe('trigger-bridge-delivery (RFC 0083 §C)', () => {
188
199
  expect(state, req(R_DEAD_LETTER, 'trigger-bridge.md §F.6 (RFC 0230 §C.1)', `a refused post MUST NOT change the subscription's state (got ${String(state)})`)).toBe('active');
189
200
  const ok = await signedIngest(sub, body);
190
201
  expect(ok.status, req(R_DEAD_LETTER, 'trigger-bridge.md §F.6 (RFC 0230 §C.1)', `after a refused post the subscription MUST still deliver a correctly signed one (got ${ok.status})`)).toBe(202);
191
- noteObservation('normative-surface path (RFC 0230)');
202
+ notePaths(paths.seam, true);
192
203
  });
193
204
 
194
205
  it('links delivery→run causation on run.started', async () => {
195
206
  if (!behaviorGate('openwop-trigger-bridge', await isTriggerBridgeProfileAdvertised())) return;
196
- const path = await witnessPath();
197
- if (path === null) return seamAbsent(NO_PATH);
207
+ const paths = await witnessPaths();
208
+ if (!paths.seam && !paths.normative) return seamAbsent(NO_PATH);
198
209
 
199
- if (path === 'seam') return causationViaSeam();
210
+ if (paths.seam && (await causationViaSeam()) === 'absent') return seamAbsent(SEAM_UNWIRED);
211
+ if (!paths.normative) return notePaths(true, false);
200
212
 
201
213
  // Normative-surface path: a signed delivery, then the run's own event log.
202
214
  const sub = await registerSignedWebhook('none');
@@ -216,7 +228,7 @@ describe('trigger-bridge-delivery (RFC 0083 §C)', () => {
216
228
  expect(causationId, req(R_CAUSATION, 'trigger-bridge.md §C / RFC 0040', 'run.started.causationId MUST EQUAL the delivered attempt event id')).toBe(deliveredEvent.eventId);
217
229
  expectContentFree(R_CAUSATION, (deliveredEvent.payload ?? {}) as Record<string, unknown>, 'trigger.delivery.attempted');
218
230
  }
219
- noteObservation(`normative-surface path (RFC 0230)${deliveredEvent === undefined ? '; the delivered attempt event is not on the run log, so equality was not checked' : ''}`);
231
+ notePaths(paths.seam, true, deliveredEvent === undefined ? '; on the normative-surface path the delivered attempt event is not on the run log, so equality was not checked there' : '');
220
232
  });
221
233
 
222
234
  it('keeps the run-less trigger events content-free', async () => {
@@ -24,7 +24,9 @@
24
24
  *
25
25
  * Gated on the `openwop-trigger-bridge` profile and on
26
26
  * `triggerBridge.ingestion.externalSources` listing `webhook`. The uncredentialed
27
- * public path (RFC 0230, `inboundSigning`) is witnessed separately.
27
+ * public path (RFC 0230, `inboundSigning`) is witnessed separately, by the
28
+ * dead-letter leg of `trigger-bridge-delivery`. A host with no seams that
29
+ * advertises `inboundSigning` therefore records `inapplicable` here.
28
30
  *
29
31
  * @see spec/v1/trigger-bridge.md §F.2
30
32
  * @see spec/v2/core/webhooks.md §Inbound triggers
@@ -36,7 +38,7 @@ import { driver } from '../lib/driver.js';
36
38
  import { seamAbsent, softSkip } from '../lib/soft-skip.js';
37
39
  import { behaviorGate } from '../lib/behavior-gate.js';
38
40
  import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
39
- import { isTriggerBridgeProfileAdvertised } from '../lib/triggerBridge.js';
41
+ import { inboundSigningAdvertised, isTriggerBridgeProfileAdvertised } from '../lib/triggerBridge.js';
40
42
  import { queryTestEvents, requireEvents, isEventLogSeamAvailable } from '../lib/event-log-query.js';
41
43
  import { req } from '../lib/requirement-ids.js';
42
44
 
@@ -59,7 +61,14 @@ describe('trigger-refused-event-keeps-subscription (trigger-bridge.md §F.2)', (
59
61
  if (!sources.includes('webhook')) {
60
62
  return softSkip('inapplicable', 'triggerBridge.ingestion.externalSources does not list webhook, so the host verifies no inbound webhook event');
61
63
  }
62
- if (!(await isEventLogSeamAvailable())) return seamAbsent('host ingests external webhooks but the event-log seam is absent');
64
+ if (!(await isEventLogSeamAvailable())) {
65
+ // A host with no seams that serves RFC 0230's signed ingest is measured on this same rule
66
+ // there: the dead-letter leg of `trigger-bridge-delivery` posts a badly signed event, reads the
67
+ // subscription back `active`, and delivers a signed one (RFC 0230 §C.1). Until 2.45.8 this
68
+ // file failed such a host in strict mode as "not observable" right after that leg observed it.
69
+ if (await inboundSigningAdvertised()) return softSkip('inapplicable', 'no delivery seam on this host; the rule is witnessed on its signed public ingest by openwop.requirement.0083.trigger-delivery.dead-letter (RFC 0230 §C.1)');
70
+ return seamAbsent('host ingests external webhooks but the event-log seam is absent');
71
+ }
63
72
 
64
73
  // ---- 1. The refused event -------------------------------------------
65
74
  const refusedRes = await driver.post(SEAM, { scenario: 'refused', source: 'webhook' });
@@ -0,0 +1,82 @@
1
+ /**
2
+ * v2 — a `tableStorage` row MUST conform to the table's declared schema
3
+ * (`spec/v2/core/storage.md` §`tableStorage`). The v1 twin is
4
+ * `table-schema-enforcement`, which drives the `/v1/host/sample/test/surface`
5
+ * seam; this port is unaided, through the `conformance-table-schema-probe`
6
+ * fixture. The logic is `lib/table-schema-witness.ts`.
7
+ *
8
+ * control a well-typed insert into `{ k: string, n: number }`
9
+ * completes (the positive control).
10
+ * insert-mistyped an insert with `n: "not-a-number"` fails the probe node
11
+ * `validation_error`, `details.service: tableStorage`.
12
+ * update-mistyped an update setting `n` to a string fails the same way.
13
+ *
14
+ * The refusal code is v2's: RFC 0228 maps v1's `table_schema_violation` to
15
+ * `validation_error`, and a generic code carries `details.service`
16
+ * (`errors.md` §Host-service refusals).
17
+ *
18
+ * Dispositions: `tableStorage` not in the v2 root, or the fixture not
19
+ * advertised ⇒ `inapplicable`. The fixture is the opt-in, as for the budget
20
+ * and safeFetch witnesses. A fixture run that cannot be created or read ⇒
21
+ * `blocked`. A failed control ⇒ the control row fails and the two refusal
22
+ * rows are not asserted, since a host that refuses every insert would pass
23
+ * them vacuously.
24
+ *
25
+ * Proven both ways against the scratch host in
26
+ * `lib/table-schema-witness.test.ts`.
27
+ *
28
+ * @see spec/v2/core/storage.md §tableStorage
29
+ * @see conformance/fixtures.md §The tableStorage schema probe fixture
30
+ */
31
+
32
+ import { describe, it, expect } from 'vitest';
33
+ import { v2Discovery } from '../lib/v2.js';
34
+ import { softSkip } from '../lib/soft-skip.js';
35
+ import { req } from '../lib/requirement-ids.js';
36
+ import { drive, judge, type TableFinding, type TableRun } from '../lib/table-schema-witness.js';
37
+
38
+ const ID_CONTROL = 'openwop.requirement.storage.table-schema-control-insert-completes';
39
+ const ID_INSERT = 'openwop.requirement.storage.table-schema-insert-mistyped-refused';
40
+ const ID_UPDATE = 'openwop.requirement.storage.table-schema-update-mistyped-refused';
41
+
42
+ /** One set of probe runs per file: the three legs read it. */
43
+ let once: Promise<TableRun> | undefined;
44
+ function tableRun(): Promise<TableRun> {
45
+ once ??= (async (): Promise<TableRun> => {
46
+ let doc: Record<string, unknown> | null;
47
+ try { doc = await v2Discovery(); } catch { doc = null; }
48
+ if (!doc) return { kind: 'skip', disposition: 'blocked', reason: 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0' };
49
+ return drive(doc);
50
+ })();
51
+ return once;
52
+ }
53
+
54
+ type Leg = { readonly skip: readonly ['inapplicable' | 'blocked', string] } | { readonly finding: TableFinding };
55
+ async function leg(rule: TableFinding['rule']): Promise<Leg> {
56
+ const r = await tableRun();
57
+ if (r.kind === 'skip') return { skip: [r.disposition, r.reason] };
58
+ const findings = judge(r.outcomes);
59
+ const control = findings.find((f) => f.rule === 'control')!;
60
+ if (rule !== 'control' && !control.ok) return { skip: ['blocked', `the control insert did not complete, so a refusal here would prove nothing (${control.message})`] };
61
+ return { finding: findings.find((f) => f.rule === rule)! };
62
+ }
63
+
64
+ describe('v2 tableStorage schema enforcement (storage.md §tableStorage)', () => {
65
+ it('a well-typed insert completes (positive control)', async () => {
66
+ const l = await leg('control');
67
+ if ('skip' in l) return softSkip(...l.skip);
68
+ expect(l.finding.ok, req(ID_CONTROL, l.finding.doc, l.finding.message)).toBe(true);
69
+ }, 120_000);
70
+
71
+ it('an insert whose column type diverges from the schema is refused validation_error', async () => {
72
+ const l = await leg('insert-mistyped');
73
+ if ('skip' in l) return softSkip(...l.skip);
74
+ expect(l.finding.ok, req(ID_INSERT, l.finding.doc, l.finding.message)).toBe(true);
75
+ }, 120_000);
76
+
77
+ it('an update whose column type diverges from the schema is refused validation_error', async () => {
78
+ const l = await leg('update-mistyped');
79
+ if ('skip' in l) return softSkip(...l.skip);
80
+ expect(l.finding.ok, req(ID_UPDATE, l.finding.doc, l.finding.message)).toBe(true);
81
+ }, 120_000);
82
+ });
@@ -24,7 +24,9 @@
24
24
  * request instead of the credential serves A's file here.
25
25
  *
26
26
  * Dispositions: family or seams profile not advertised ⇒ `inapplicable`
27
- * (lib/seams.ts: the host has not claimed the instrument). Advertised, but the
27
+ * (lib/seams.ts: the host has not claimed the instrument). That holds in
28
+ * strict mode too: the family is read with `familyAdvertised`, so no
29
+ * `family.workspace` opt-out is needed from a host that does not serve it. Advertised, but the
28
30
  * seam answers 404/405/501, the second-tenant key is absent / identical /
29
31
  * refused, or (leg 2) A's scope cannot be read from a run ⇒ `blocked` — the host
30
32
  * took the obligation on and the suite could not measure it.
@@ -33,7 +35,7 @@ import { describe, it, expect } from 'vitest';
33
35
  import { randomUUID } from 'node:crypto';
34
36
  import { driver, type OpenWOPResponse } from '../lib/driver.js';
35
37
  import { loadEnv } from '../lib/env.js';
36
- import { v2Discovery, gateFamily } from '../lib/v2.js';
38
+ import { v2Discovery, familyAdvertised } from '../lib/v2.js';
37
39
  import { seamsProfileAdvertised, SEAMS_PREFIX } from '../lib/seams.js';
38
40
  import { isFixtureAdvertised } from '../lib/fixtures.js';
39
41
  import { softSkip, seamAbsent } from '../lib/soft-skip.js';
@@ -55,7 +57,12 @@ async function http(fn: () => Promise<OpenWOPResponse>): Promise<OpenWOPResponse
55
57
  async function gate(): Promise<Gate> {
56
58
  const doc = await v2Discovery();
57
59
  if (!doc) return { ok: false, kind: 'blocked', reason: 'v2 discovery unreachable — /.well-known/openwop did not answer 200 JSON under OpenWOP-Version: 2.0' };
58
- if (!(await gateFamily('workspace'))) return { ok: false, kind: 'inapplicable', reason: 'workspace family not advertised (gate recorded under openwop.family.workspace)' };
60
+ // `familyAdvertised`, not `gateFamily`: `workspace` is optional, and a host that does not advertise it has
61
+ // taken on no obligation here. Until 2.45.7 this was `gateFamily`, which in strict mode
62
+ // (`--require-behavior`) FAILS an unadvertised family unless the operator opts it out — so this file
63
+ // recorded `executed-fail` on a host its own header calls `inapplicable` (MyndHyve, 2.45.5), while
64
+ // its sibling isolation scenarios recorded `inapplicable` in the same run.
65
+ if (!(await familyAdvertised('workspace'))) return { ok: false, kind: 'inapplicable', reason: 'workspace family not advertised — no obligation' };
59
66
  if (!seamsProfileAdvertised(doc)) return { ok: false, kind: 'inapplicable', reason: 'workspace files have no protocol path; the only observation surface is the seams profile (conformance.seamsProfile = openwop-conformance-seams-v2), which is not advertised' };
60
67
  const probe = await http(() => driver.get(FILES));
61
68
  if (probe === null) return { ok: false, kind: 'blocked', reason: `GET ${FILES} unreachable (fetch failed)` };