@openwop/openwop-conformance 2.45.7 → 2.45.9
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 +24 -0
- package/README.md +2 -2
- package/dist/spec-artifacts.lock.json +2 -2
- package/fixtures/conformance-table-schema-probe.json +24 -0
- package/fixtures.md +23 -0
- package/package.json +2 -2
- package/requirements.json +52 -10
- package/scenario-majors.json +5 -2
- package/schemas/CORPUS-STAMP.json +10 -10
- package/src/lib/table-schema-witness.ts +125 -0
- package/src/scenarios/trigger-bridge-delivery.test.ts +51 -39
- package/src/scenarios/trigger-refused-event-keeps-subscription.test.ts +12 -3
- package/src/scenarios/trigger-stream-cdc-sources.test.ts +11 -2
- package/src/scenarios/v2-table-schema-enforcement.test.ts +82 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# `@openwop/openwop-conformance` Changelog
|
|
2
2
|
|
|
3
|
+
## [2.45.9] — 2026-10-02 — a trigger leg that observed nothing is inapplicable, not blocked
|
|
4
|
+
|
|
5
|
+
- **Fix: `trigger-stream-cdc-sources` no longer records `blocked` on a host that serves neither `stream` nor `change`.** The ingest leg skipped both sources on such a host and then passed with zero assertions and no reason. RFC 0148 §A records that as `blocked`, and a `blocked` row denies a major-1 bundle (RFC 0168 §E.1). It now records `inapplicable`, with the reason. The dedup leg's pre-RFC 0127 branch said `blocked` for the same condition and now says `inapplicable`. An unwired seam is still `blocked`, and a host that advertises either source and refuses it still fails.
|
|
6
|
+
- **Found by** openwop-app's 2.45.8 seam-free run, ahead of its RFC 0230 certified cut. My own harness run with the seams on recorded the same row, so the seams play no part in it.
|
|
7
|
+
- **Proof.** On openwop-app's in-process harness, the row was `blocked` before the change and is `inapplicable` with the reason after it. All nine tests pass.
|
|
8
|
+
|
|
9
|
+
## [2.45.8] — 2026-10-02 — RFC 0229 Accepted on MyndHyve's production cut; a v2 witness for tableStorage schema enforcement
|
|
10
|
+
|
|
11
|
+
- **The 2.45.8 cycle opens.** RFC 0229's and RFC 0228's gap registers change the packed `spec/v1/gaps.json`.
|
|
12
|
+
- **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`.
|
|
13
|
+
- `openwop.requirement.storage.table-schema-control-insert-completes`: a well-typed insert completes. It is the positive control.
|
|
14
|
+
- `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`.
|
|
15
|
+
- `openwop.requirement.storage.table-schema-update-mistyped-refused`: an update setting `n` to a string fails the same way.
|
|
16
|
+
|
|
17
|
+
**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.
|
|
18
|
+
|
|
19
|
+
**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.
|
|
20
|
+
|
|
21
|
+
**Host impact: none today.** No host advertises `tableStorage` at major 2, so all three rows record `inapplicable`.
|
|
22
|
+
- **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`.
|
|
23
|
+
- **`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.
|
|
24
|
+
- **`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.
|
|
25
|
+
- **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.
|
|
26
|
+
|
|
3
27
|
## [2.45.7] — 2026-10-02 — a strict-mode host that does not advertise `workspace` is not failed; RFC 0231 Accepted
|
|
4
28
|
|
|
5
29
|
- **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`.
|
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
|
|
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:
|
|
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
|
|
|
@@ -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.
|
|
3
|
+
"version": "2.45.9",
|
|
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.
|
|
59
|
+
"@openwop/spec-artifacts": "2.45.9"
|
|
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":
|
|
6
|
-
"tests":
|
|
7
|
-
"withStableId":
|
|
5
|
+
"files": 666,
|
|
6
|
+
"tests": 2545,
|
|
7
|
+
"withStableId": 2545,
|
|
8
8
|
"interpolatedTitles": 0,
|
|
9
|
-
"explicitIds":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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": [
|
|
@@ -26916,7 +26916,7 @@
|
|
|
26916
26916
|
{
|
|
26917
26917
|
"id": "openwop.it.trigger-stream-cdc-sources.stream-dedup-the-same-broker-coordinates-delivered-twice-are-effectively-once-c",
|
|
26918
26918
|
"file": "trigger-stream-cdc-sources.test.ts",
|
|
26919
|
-
"line":
|
|
26919
|
+
"line": 210,
|
|
26920
26920
|
"title": "stream dedup — the same broker coordinates delivered twice are effectively-once (§C-1 floor)",
|
|
26921
26921
|
"explicitId": "openwop.it.trigger-stream-cdc-sources.stream-dedup-the-same-broker-coordinates-delivered-twice-are-effectively-once-c",
|
|
26922
26922
|
"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",
|
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": 591,
|
|
5
5
|
"v1": 454,
|
|
6
|
-
"v2":
|
|
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.
|
|
5
|
-
"corpusTag": "v2.45.
|
|
4
|
+
"version": "2.45.9",
|
|
5
|
+
"corpusTag": "v2.45.9",
|
|
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": "
|
|
13
|
-
"api/v2/asyncapi.yaml": "
|
|
14
|
-
"api/v2/openapi.yaml": "
|
|
12
|
+
"api/seams-v2.yaml": "1a3d28d791ba5316741a2863a04fc73aab2b00c6959964658ff1e3ad8523820f",
|
|
13
|
+
"api/v2/asyncapi.yaml": "8e6f4c64c5f06409f926d5cfc78c503e3733885ab09f5d563ade7143f071530f",
|
|
14
|
+
"api/v2/openapi.yaml": "da7e2a67630d825530726bcd2f170cf99735351c9f4872750bf179dd283cf1f2",
|
|
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": "
|
|
207
|
+
"spec/v1/core-standard-manifest.json": "f1e9f2551bd9695a8e16bb070e7180281378f4822921fa1aab7adcc60a5cca10",
|
|
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": "
|
|
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": "
|
|
306
|
+
"spec/v2/release.json": "db1ff3ccb0a8e5446734924122582f7909b7860eb0e097b8275753ff97681d37",
|
|
307
307
|
"spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
|
|
308
|
-
"spec/v2/surface-baseline.json": "
|
|
308
|
+
"spec/v2/surface-baseline.json": "3cc12012d8f0437b2dd8280e18ac472063afffb30af25d1f1a126e20f9aee674"
|
|
309
309
|
},
|
|
310
|
-
"corpusCommit": "
|
|
310
|
+
"corpusCommit": "458e139f76c2469f90f187ee41ae1d7070748adf"
|
|
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
|
|
20
|
-
* drives `POST /v1/host/sample/trigger-bridge/deliver` and reads
|
|
21
|
-
* event-log seam
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* `
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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<
|
|
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
|
|
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
|
|
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<
|
|
120
|
+
async function deadLetterViaSeam(): Promise<SeamLeg> {
|
|
110
121
|
const exhaust = await driveDelivery({ scenario: 'exhaust', source: 'webhook' });
|
|
111
|
-
if (exhaust === null) return
|
|
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
|
|
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<
|
|
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
|
|
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
|
|
152
|
-
if (
|
|
160
|
+
const paths = await witnessPaths();
|
|
161
|
+
if (!paths.seam && !paths.normative) return seamAbsent(NO_PATH);
|
|
153
162
|
|
|
154
|
-
if (
|
|
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
|
-
|
|
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
|
|
175
|
-
if (
|
|
184
|
+
const paths = await witnessPaths();
|
|
185
|
+
if (!paths.seam && !paths.normative) return seamAbsent(NO_PATH);
|
|
176
186
|
|
|
177
|
-
if (
|
|
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
|
-
|
|
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
|
|
197
|
-
if (
|
|
207
|
+
const paths = await witnessPaths();
|
|
208
|
+
if (!paths.seam && !paths.normative) return seamAbsent(NO_PATH);
|
|
198
209
|
|
|
199
|
-
if (
|
|
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
|
-
|
|
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()))
|
|
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' });
|
|
@@ -166,9 +166,18 @@ describe.skipIf(HTTP_SKIP)('trigger-stream-cdc: behavioral ingestion + dedup (ca
|
|
|
166
166
|
},
|
|
167
167
|
];
|
|
168
168
|
|
|
169
|
+
// Post every source first, so a host that serves neither is recorded before any assertion.
|
|
170
|
+
const served: { body: Record<string, unknown>; res: Awaited<ReturnType<typeof driver.post>> }[] = [];
|
|
169
171
|
for (const body of bodies) {
|
|
170
172
|
const res = await driver.post('/v1/host/sample/trigger-bridge/ingest', body);
|
|
171
|
-
if ((res.status === 404 || res.status === 405 || res.status === 400 || res.status === 422) && !advertisesNew) continue; // pre-0127 host
|
|
173
|
+
if ((res.status === 404 || res.status === 405 || res.status === 400 || res.status === 422) && !advertisesNew) continue; // pre-0127 host: this source is not served
|
|
174
|
+
served.push({ body, res });
|
|
175
|
+
}
|
|
176
|
+
// Until 2.45.9 a host that advertises neither source and refuses both passed this leg with zero
|
|
177
|
+
// assertions and no reason, which RFC 0148 §A records as `blocked` and which denies a major-1 bundle.
|
|
178
|
+
if (served.length === 0) return softSkip('inapplicable', 'triggerBridge.ingestion.externalSources lists neither stream nor change, and the host refused both (a pre-RFC 0127 host)');
|
|
179
|
+
|
|
180
|
+
for (const { body, res } of served) {
|
|
172
181
|
expect(
|
|
173
182
|
res.status < 400,
|
|
174
183
|
req('openwop.it.trigger-stream-cdc-sources.a-stream-and-a-change-event-each-ingest-to-a-run-with-a-schema-valid-envelope-an',
|
|
@@ -220,7 +229,7 @@ describe.skipIf(HTTP_SKIP)('trigger-stream-cdc: behavioral ingestion + dedup (ca
|
|
|
220
229
|
const dedupKey = freshStreamDedupKey();
|
|
221
230
|
const first = await driveDelivery({ scenario: 'dedup', dedupKey, source: 'stream' });
|
|
222
231
|
if (first === null) return softSkip('blocked', 'precondition not met — `first === null` returned early (delivery seam unwired — soft-skip) (seam, prior step, or fixture unavailable)'); // delivery seam unwired — soft-skip
|
|
223
|
-
if (first.outcome === undefined && !external.includes('stream')) return softSkip('
|
|
232
|
+
if (first.outcome === undefined && !external.includes('stream')) return softSkip('inapplicable', 'triggerBridge.ingestion.externalSources does not list stream, and the delivery seam reported no outcome for a stream source (a pre-RFC 0127 host)');
|
|
224
233
|
expect(
|
|
225
234
|
first.deliveredCount === 1 || first.outcome === 'delivered',
|
|
226
235
|
req('openwop.it.trigger-stream-cdc-sources.stream-dedup-the-same-broker-coordinates-delivered-twice-are-effectively-once-c',
|
|
@@ -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
|
+
});
|