@openwop/openwop-conformance 1.154.0 → 1.155.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # `@openwop/openwop-conformance` Changelog
2
2
 
3
+ ## [1.155.0] — 2026-09-02 — RFC 0165 `Draft → Active`: `protocolVersions[]`, the Subject record, header dual emission
4
+
5
+ Three new scenario files (468 → 471) and four added legs, all additive:
6
+
7
+ - `protocol-versions-array.test.ts` — server-free: the schema declares root `protocolVersions` with the scalar's strict grammar; presence-gated: items match, are unique, contain `protocolVersion`, and profile derivation is unchanged by the array.
8
+ - `owner-subject-shape.test.ts` — server-free: `subject.schema.json` compiles; legacy and workload forms accepted; email/whitespace `subjectId` rejected (invariant `subject-record-opaque`); `keyClass` iff saml/scim; both `owner` sub-objects declare `subject` and the `run.started` echo declares `principalKind`.
9
+ - `owner-subject-echo.test.ts` — gated on a snapshot carrying `owner.subject`: consistency with the owner triple, actor depth ≤ 4, `run.started` echo, fork copies tenant + subject verbatim.
10
+ - Legs: `discovery.test.ts` (standard `ETag` + `If-None-Match` → 304, presence-gated); `webhook-signed-delivery.test.ts` (the `OpenWOP-*` family equals `X-openwop-*` and verifies, presence-gated); `auth-subject-link.test.ts` (legacy subjects never linked — seam-gated, records `blocked`); `identity-owner-shape.test.ts` registers `subject.schema.json`.
11
+
3
12
  ## [1.154.0] — 2026-09-02 — the tarball carries the suite and nothing else; the vendored contract is digest-checked (v2 charter Phase 1)
4
13
 
5
14
  No new scenario file. Three packed changes:
package/README.md CHANGED
@@ -103,7 +103,7 @@ Exit code is non-zero on any failed assertion. `--certify` distinguishes: `0`
103
103
 
104
104
  ## What's Covered
105
105
 
106
- The current suite has 468 scenario files under `src/scenarios/`.
106
+ The current suite has 471 scenario files under `src/scenarios/`.
107
107
  - 2026-08-19 (suite `1.137.0 → 1.138.0`): NEW `durability-poison-exhaustion.test.ts` — RFC 0158 §C.8, the FIRST row of that RFC's conformance table to land. Asserts what `failure-path.test.ts` cannot: not just that deterministically failing work reaches terminal, but that attempts STOP — counted on the log, re-counted after a scaled quiet window, asserted unchanged. A host still redelivering records more. Seam-gated on the existing event-log seam (`blocked` = unobservable, not unmet) and outside every profile floor.
108
108
  - 2026-08-19 (suite `1.136.15 → 1.137.0`): NEW `replay-fanout-suppression.test.ts` — capability-gated on `webhooks.supported`, **outside every profile floor**; witnesses `replay.md` §"Host-initiated fan-out is an external effect", which was the largest normative MUST NOT on the replay surface with no scenario and no SECURITY invariant. Three legs in ONE `it` against ONE receiver and ONE subscription — a positive control, the MUST NOT, and a `branch` boundary leg — because "no delivery arrived" passes identically when delivery never worked, so absence is asserted only after presence is proven on that exact wiring. A host with an SSRF guard correctly refuses the loopback receiver and records `blocked`: **unobservable, not unmet.**
109
109
  - 2026-08-18 (suite `1.136.7 → 1.136.8`, SP-04): NEW `spec-section-citations.test.ts` — server-free; a `<doc>.md §"<Section>"` citation of a checked doc MUST resolve to a heading that exists. `storage-adapters.md §"Claim acquisition"` was cited by four artifacts, including a normative MUST in `production-profile.md` §Durability, and the section did not exist for the life of RFC 0009. Scoped to the docs whose section citations carry the durability contract; a corpus-wide sweep finds ~260 unresolved citations that need triage before they can gate.
@@ -447,7 +447,7 @@ Server-required (added in 1.7.0):
447
447
  | ------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
448
448
  | **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. |
449
449
 
450
- Current source tree: 468 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
450
+ Current source tree: 471 scenario files. Use [`coverage.md`](./coverage.md) for current grade/gap tracking.
451
451
 
452
452
  ## Remaining Gaps
453
453
 
package/api/openapi.yaml CHANGED
@@ -109,6 +109,14 @@ paths:
109
109
  summary: Capability declaration handshake.
110
110
  operationId: getCapabilities
111
111
  security: []
112
+ parameters:
113
+ - name: If-None-Match
114
+ in: header
115
+ required: false
116
+ schema: { type: string }
117
+ description: >-
118
+ RFC 0165 §C.2. Standard conditional request against the discovery document's `ETag`;
119
+ a matching value SHOULD yield `304 Not Modified`.
112
120
  responses:
113
121
  '200':
114
122
  description: Capabilities object — see `capabilities.md`.
@@ -116,13 +124,21 @@ paths:
116
124
  Cache-Control:
117
125
  schema: { type: string }
118
126
  example: 'public, max-age=300'
127
+ ETag:
128
+ schema: { type: string }
129
+ description: >-
130
+ RFC 0165 §C.2. Standard strong validator for the response bytes (SHOULD be sent).
131
+ Distinct from `Capabilities-Etag`, which validates negotiation safety
132
+ (`capabilities-change-detection.md` §"Cache validators").
119
133
  Capabilities-Etag:
120
134
  schema: { type: string }
121
- description: Optional probe handle for mid-session capability change detection.
135
+ description: Optional probe handle for mid-session capability change detection. Deprecated toward v2 (RFC 0165 §C.2).
122
136
  content:
123
137
  application/json:
124
138
  schema:
125
139
  $ref: '#/components/schemas/Capabilities'
140
+ '304':
141
+ description: RFC 0165 §C.2 — `If-None-Match` matched the current `ETag`; the body is unchanged.
126
142
  '503':
127
143
  description: Server unable to compute capabilities (transient).
128
144
  content:
package/coverage.md CHANGED
@@ -112,6 +112,9 @@ The scenario groups in the table below (one row per group; count the rows — th
112
112
  | `oauth-authorization-code-roundtrip.test.ts` | `capabilities.oauth` (RFC 0047 §C) + `SECURITY/invariants.yaml` `credential-payload-redaction` | A (capability-gated on `oauth.supported` + `grants` ∋ `authorization_code`; behavioral roundtrip via optional `POST /v1/host/sample/oauth/authorize-code-roundtrip` seam against the one canonical synthetic provider in `fixtures/oauth-providers/synthetic.json` — returns a credential REFERENCE; the authorization code / state / PKCE verifier / acquired access+refresh tokens are absent from every observable surface; `connector.authorized` carries the ref not the token) | `host-pending` | Capability+grant-gated; behavioral probe soft-skips on 404. Closes the RFC 0047 Tier-2 gap — exercises the actual authorization-code dance (shape + redaction scenarios existed; the grant itself was unexercised). |
113
113
  | `connector-manifest-validity.test.ts` | `node-pack-manifest.schema.json` §Connector (RFC 0045, `node-packs.md` §Connectors) | Server-free (schema validity of the `connector` block incl. both ConnectorAuth variants + positive/negative round-trip; §B action/trigger typeId-resolution semantics — `connector_action_unresolved` on an unknown typeId) | host-pass (server-free) | Always runs; no host needed. Behavioral idempotency-hint + rate-limit-honored scenarios deferred until a host advertises a connector. |
114
114
  | `identity-owner-shape.test.ts` | `run-snapshot.schema.json` properties.owner (RFC 0048 §C, `auth.md` §Identity claims) | Server-free (owner triple schema validity: positive `{tenant}` + full triple; negative missing-tenant + unknown-prop) | host-pass (server-free) | Always runs; no host needed. |
115
+ | `protocol-versions-array.test.ts` | RFC 0165 §A (`version-negotiation.md` §"`protocolVersions[]`") | A (server-free: schema declares the array with the scalar's grammar) + B (presence-gated: items match, unique, contain `protocolVersion`; profile derivation unchanged) | host-pending (presence-gated) | A host that omits the array records `inapplicable`. |
116
+ | `owner-subject-shape.test.ts` | RFC 0165 §B.1–§B.2 (`schemas/subject.schema.json`; both `owner` sub-objects) | Server-free: subject compiles; legacy + workload forms accepted; email/whitespace `subjectId` rejected (invariant `subject-record-opaque`); keyClass iff saml/scim; both owner sub-objects declare `subject` and the echo declares `principalKind` | host-pass (server-free) | Always runs; no host needed. |
117
+ | `owner-subject-echo.test.ts` | RFC 0165 §B.2, §B.4, §B.5 (`auth.md` §"The Subject record", `replay.md` §"Fork ownership") | B (gated on a snapshot carrying `owner.subject`): tenant/subjectId/kind/keyClass consistency, actor depth ≤ 4, `run.started` echo equals the snapshot, fork copies tenant + subject verbatim | host-pending (gated) | A host that does not emit `owner.subject` records `inapplicable`. |
115
118
  | `cross-workspace-isolation.test.ts` | RFC 0048 §C/§D (`auth.md` §Identity claims, `rest-endpoints.md` `run_forbidden`) | A (owner-echo shape if a sample run is readable; §D isolation MUST-NOT via optional `POST /v1/host/sample/identity/cross-workspace-read` seam — cross-workspace read fails closed with `run_forbidden`/`not_found`) | `host-pending` | Behavioral probe soft-skips on 404; no host advertises run ownership yet (RFC 0048 `Draft`). |
116
119
  | `authorization-roles-shape.test.ts` | `capabilities.authorization` (RFC 0049 §A, `auth.md` §"Role-based authorization") | A (advertisement shape always — `supported` boolean; `failClosed` const true; every `roles[].role` non-empty + `scopes` array) | `host-pending` | Always runs; asserts the block is absent or well-formed. |
117
120
  | `authorization-fail-closed.test.ts` | `capabilities.authorization` (RFC 0049 §C) + `SECURITY/invariants.yaml` `authorization-fail-closed` | A (advertisement `failClosed===true` always; fail-closed MUST-NOT via optional `POST /v1/host/sample/authorization/decide` seam — an unseeded-role principal resolves `allowed:false`) | `host-pending` | Capability-gated on `authorization.supported`; behavioral probe soft-skips on 404. Scope-match + denial-audited scenarios deferred to a host. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "1.154.0",
3
+ "version": "1.155.0",
4
4
  "description": "Production-ready black-box conformance suite for OpenWOP v1.0 compliant servers.",
5
5
  "repository": {
6
6
  "type": "git",
package/requirements.json CHANGED
@@ -2,9 +2,9 @@
2
2
  "$comment": "GENERATED by conformance/scripts/generate-requirement-registry.mjs — do not edit. One record per it()/test() in src/scenarios. Ids: openwop.it.<file-stem>.<title-slug>[~n] (src/lib/requirement-ids.ts). A record with id null has an interpolated title; its run-time row is keyed by the rendered title and maps here by file+line only. Renamed ids need a row in requirement-aliases.json.",
3
3
  "generatedFrom": "src/scenarios/*.test.ts",
4
4
  "counts": {
5
- "files": 468,
6
- "tests": 1950,
7
- "withStableId": 1927,
5
+ "files": 471,
6
+ "tests": 1968,
7
+ "withStableId": 1945,
8
8
  "interpolatedTitles": 23,
9
9
  "explicitIds": 0
10
10
  },
@@ -3479,7 +3479,7 @@
3479
3479
  {
3480
3480
  "id": "openwop.it.anonymous-actor-shape.a-content-free-anon-grant-deny-record-validates-against-the-existing-def",
3481
3481
  "file": "anonymous-actor-shape.test.ts",
3482
- "line": 142,
3482
+ "line": 147,
3483
3483
  "title": "a content-free anon grant + deny record validates against the existing $def",
3484
3484
  "explicitId": null,
3485
3485
  "citations": []
@@ -3487,7 +3487,7 @@
3487
3487
  {
3488
3488
  "id": "openwop.it.anonymous-actor-shape.authorization-decided-is-in-the-runeventtype-enum-the-reused-audit-event",
3489
3489
  "file": "anonymous-actor-shape.test.ts",
3490
- "line": 168,
3490
+ "line": 173,
3491
3491
  "title": "authorization.decided is in the RunEventType enum (the reused audit event)",
3492
3492
  "explicitId": null,
3493
3493
  "citations": []
@@ -4531,6 +4531,14 @@
4531
4531
  }
4532
4532
  ]
4533
4533
  },
4534
+ {
4535
+ "id": "openwop.it.auth-subject-link.a-scim-deactivation-does-not-deny-a-saml-login-whose-run-subject-is-legacy-issue",
4536
+ "file": "auth-subject-link.test.ts",
4537
+ "line": 201,
4538
+ "title": "a SCIM deactivation does not deny a SAML login whose run subject is legacy-issued",
4539
+ "explicitId": null,
4540
+ "citations": []
4541
+ },
4534
4542
  {
4535
4543
  "id": "openwop.it.auth.returns-401-with-canonical-error-envelope-per-auth-md-3",
4536
4544
  "file": "auth.test.ts",
@@ -7998,7 +8006,7 @@
7998
8006
  {
7999
8007
  "id": "openwop.it.discovery.returns-200-with-required-capabilities-fields-per-capabilities-md-2",
8000
8008
  "file": "discovery.test.ts",
8001
- "line": 15,
8009
+ "line": 16,
8002
8010
  "title": "returns 200 with required Capabilities fields per capabilities.md §2",
8003
8011
  "explicitId": null,
8004
8012
  "citations": [
@@ -8020,7 +8028,7 @@
8020
8028
  {
8021
8029
  "id": "openwop.it.discovery.serves-cache-control-per-capabilities-md-4-caching-guidance",
8022
8030
  "file": "discovery.test.ts",
8023
- "line": 35,
8031
+ "line": 36,
8024
8032
  "title": "serves Cache-Control per capabilities.md §4 (caching guidance)",
8025
8033
  "explicitId": null,
8026
8034
  "citations": [
@@ -8030,10 +8038,27 @@
8030
8038
  }
8031
8039
  ]
8032
8040
  },
8041
+ {
8042
+ "id": "openwop.it.discovery.if-a-standard-etag-is-present-a-matching-if-none-match-yields-304-rfc-0165-c-2-p",
8043
+ "file": "discovery.test.ts",
8044
+ "line": 46,
8045
+ "title": "IF a standard ETag is present, a matching If-None-Match yields 304 (RFC 0165 §C.2 — presence-gated)",
8046
+ "explicitId": null,
8047
+ "citations": [
8048
+ {
8049
+ "section": "RFC 0165 §C.2",
8050
+ "requirement": "ETag, when present, MUST be a non-empty validator"
8051
+ },
8052
+ {
8053
+ "section": "capabilities-change-detection.md §\"Cache validators\"",
8054
+ "requirement": "a matching If-None-Match SHOULD yield 304 Not Modified"
8055
+ }
8056
+ ]
8057
+ },
8033
8058
  {
8034
8059
  "id": "openwop.it.discovery.if-capabilities-etag-is-present-it-is-non-empty-and-stable-within-the-cache-wind",
8035
8060
  "file": "discovery.test.ts",
8036
- "line": 45,
8061
+ "line": 57,
8037
8062
  "title": "IF Capabilities-Etag is present, it is non-empty and stable within the cache window",
8038
8063
  "explicitId": null,
8039
8064
  "citations": [
@@ -8054,7 +8079,7 @@
8054
8079
  {
8055
8080
  "id": "openwop.it.discovery.declares-non-zero-limits-per-capabilities-md-3-capabilitylimiter-shape",
8056
8081
  "file": "discovery.test.ts",
8057
- "line": 71,
8082
+ "line": 83,
8058
8083
  "title": "declares non-zero limits per capabilities.md §3 (CapabilityLimiter shape)",
8059
8084
  "explicitId": null,
8060
8085
  "citations": [
@@ -8077,7 +8102,7 @@
8077
8102
  {
8078
8103
  "id": "openwop.it.discovery.if-fixtures-is-present-it-must-be-a-string-of-unique-non-empty-entries",
8079
8104
  "file": "discovery.test.ts",
8080
- "line": 95,
8105
+ "line": 107,
8081
8106
  "title": "IF fixtures is present, it MUST be a string[] of unique non-empty entries",
8082
8107
  "explicitId": null,
8083
8108
  "citations": [
@@ -8102,7 +8127,7 @@
8102
8127
  {
8103
8128
  "id": "openwop.it.discovery.returns-200-with-a-parseable-openapi-3-1-document",
8104
8129
  "file": "discovery.test.ts",
8105
- "line": 135,
8130
+ "line": 147,
8106
8131
  "title": "returns 200 with a parseable OpenAPI 3.1 document",
8107
8132
  "explicitId": null,
8108
8133
  "citations": [
@@ -8119,7 +8144,7 @@
8119
8144
  {
8120
8145
  "id": "openwop.it.discovery.host-claiming-auth-scoped-discovery-advertises-required-fields",
8121
8146
  "file": "discovery.test.ts",
8122
- "line": 194,
8147
+ "line": 206,
8123
8148
  "title": "host claiming auth-scoped discovery advertises required fields",
8124
8149
  "explicitId": null,
8125
8150
  "citations": [
@@ -8140,7 +8165,7 @@
8140
8165
  {
8141
8166
  "id": "openwop.it.discovery.authenticated-discovery-preserves-required-capabilities-fields",
8142
8167
  "file": "discovery.test.ts",
8143
- "line": 230,
8168
+ "line": 242,
8144
8169
  "title": "authenticated discovery preserves required Capabilities fields",
8145
8170
  "explicitId": null,
8146
8171
  "citations": [
@@ -8158,7 +8183,7 @@
8158
8183
  {
8159
8184
  "id": "openwop.it.discovery.unauthorized-key-must-not-reveal-capabilities-outside-its-authorization",
8160
8185
  "file": "discovery.test.ts",
8161
- "line": 271,
8186
+ "line": 283,
8162
8187
  "title": "unauthorized key MUST NOT reveal capabilities outside its authorization",
8163
8188
  "explicitId": null,
8164
8189
  "citations": [
@@ -11581,7 +11606,7 @@
11581
11606
  {
11582
11607
  "id": "openwop.it.identity-owner-shape.run-snapshot-schema-json-defines-an-optional-owner-triple",
11583
11608
  "file": "identity-owner-shape.test.ts",
11584
- "line": 35,
11609
+ "line": 37,
11585
11610
  "title": "run-snapshot.schema.json defines an optional owner triple",
11586
11611
  "explicitId": null,
11587
11612
  "citations": []
@@ -11589,7 +11614,7 @@
11589
11614
  {
11590
11615
  "id": "openwop.it.identity-owner-shape.positive-tenant-only-owner-validates",
11591
11616
  "file": "identity-owner-shape.test.ts",
11592
- "line": 46,
11617
+ "line": 50,
11593
11618
  "title": "positive: tenant-only owner validates",
11594
11619
  "explicitId": null,
11595
11620
  "citations": []
@@ -11597,7 +11622,7 @@
11597
11622
  {
11598
11623
  "id": "openwop.it.identity-owner-shape.positive-full-triple-validates",
11599
11624
  "file": "identity-owner-shape.test.ts",
11600
- "line": 50,
11625
+ "line": 54,
11601
11626
  "title": "positive: full triple validates",
11602
11627
  "explicitId": null,
11603
11628
  "citations": []
@@ -11605,7 +11630,7 @@
11605
11630
  {
11606
11631
  "id": "openwop.it.identity-owner-shape.negative-owner-missing-tenant-is-rejected-tenant-is-required",
11607
11632
  "file": "identity-owner-shape.test.ts",
11608
- "line": 57,
11633
+ "line": 61,
11609
11634
  "title": "negative: owner missing tenant is rejected (tenant is required)",
11610
11635
  "explicitId": null,
11611
11636
  "citations": []
@@ -11613,7 +11638,7 @@
11613
11638
  {
11614
11639
  "id": "openwop.it.identity-owner-shape.negative-unknown-owner-property-is-rejected-additionalproperties-false",
11615
11640
  "file": "identity-owner-shape.test.ts",
11616
- "line": 61,
11641
+ "line": 65,
11617
11642
  "title": "negative: unknown owner property is rejected (additionalProperties:false)",
11618
11643
  "explicitId": null,
11619
11644
  "citations": []
@@ -14556,6 +14581,141 @@
14556
14581
  }
14557
14582
  ]
14558
14583
  },
14584
+ {
14585
+ "id": "openwop.it.owner-subject-echo.subject-is-consistent-with-the-owner-triple-b-2-and-the-actor-chain-is-bounded-b",
14586
+ "file": "owner-subject-echo.test.ts",
14587
+ "line": 77,
14588
+ "title": "subject is consistent with the owner triple (§B.2) and the actor chain is bounded (§B.5)",
14589
+ "explicitId": null,
14590
+ "citations": [
14591
+ {
14592
+ "section": "RFC 0165 §B.2",
14593
+ "requirement": "subject.tenant MUST equal owner.tenant"
14594
+ },
14595
+ {
14596
+ "section": "RFC 0165 §B.2",
14597
+ "requirement": "subject.subjectId MUST equal owner.principal when both are present"
14598
+ },
14599
+ {
14600
+ "section": "RFC 0165 §B.2",
14601
+ "requirement": "subject.kind MUST equal owner.principalKind when both are present"
14602
+ },
14603
+ {
14604
+ "section": "RFC 0165 §B.2",
14605
+ "requirement": "keyClass MUST be present on a linkable lane"
14606
+ },
14607
+ {
14608
+ "section": "RFC 0165 §B.2",
14609
+ "requirement": "keyClass MUST be absent off the linkable lanes"
14610
+ },
14611
+ {
14612
+ "section": "RFC 0165 §B.5",
14613
+ "requirement": "actor chain depth MUST NOT exceed 4"
14614
+ }
14615
+ ]
14616
+ },
14617
+ {
14618
+ "id": "openwop.it.owner-subject-echo.run-started-echoes-the-snapshot-subject-verbatim-b-2-rfc-0048-c",
14619
+ "file": "owner-subject-echo.test.ts",
14620
+ "line": 99,
14621
+ "title": "run.started echoes the snapshot subject verbatim (§B.2, RFC 0048 §C)",
14622
+ "explicitId": null,
14623
+ "citations": [
14624
+ {
14625
+ "section": "RFC 0048 §C",
14626
+ "requirement": "run.started MUST be present in the event log"
14627
+ },
14628
+ {
14629
+ "section": "RFC 0165 §B.2",
14630
+ "requirement": "run.started owner.subject MUST equal RunSnapshot.owner.subject"
14631
+ }
14632
+ ]
14633
+ },
14634
+ {
14635
+ "id": "openwop.it.owner-subject-echo.a-fork-copies-owner-tenant-and-owner-subject-verbatim-onto-the-child-b-4",
14636
+ "file": "owner-subject-echo.test.ts",
14637
+ "line": 110,
14638
+ "title": "a fork copies owner.tenant and owner.subject verbatim onto the child (§B.4)",
14639
+ "explicitId": null,
14640
+ "citations": [
14641
+ {
14642
+ "section": "replay.md §\"Fork ownership\"",
14643
+ "requirement": ":fork MUST return 201 for a completed noop run"
14644
+ },
14645
+ {
14646
+ "section": "RFC 0165 §B.4",
14647
+ "requirement": "the child owner.tenant MUST equal the source"
14648
+ },
14649
+ {
14650
+ "section": "RFC 0165 §B.4",
14651
+ "requirement": "the child owner.subject MUST be copied verbatim from the source"
14652
+ }
14653
+ ]
14654
+ },
14655
+ {
14656
+ "id": "openwop.it.owner-subject-shape.compiles-and-accepts-a-full-saml-subject",
14657
+ "file": "owner-subject-shape.test.ts",
14658
+ "line": 43,
14659
+ "title": "compiles and accepts a full SAML subject",
14660
+ "explicitId": null,
14661
+ "citations": []
14662
+ },
14663
+ {
14664
+ "id": "openwop.it.owner-subject-shape.accepts-the-legacy-issuer-form-rfc-0165-b-3-and-a-workload-subject",
14665
+ "file": "owner-subject-shape.test.ts",
14666
+ "line": 47,
14667
+ "title": "accepts the legacy issuer form (RFC 0165 §B.3) and a workload subject",
14668
+ "explicitId": null,
14669
+ "citations": []
14670
+ },
14671
+ {
14672
+ "id": "openwop.it.owner-subject-shape.rejects-an-email-shaped-or-whitespace-subjectid-invariant-subject-record-opaque",
14673
+ "file": "owner-subject-shape.test.ts",
14674
+ "line": 52,
14675
+ "title": "rejects an email-shaped or whitespace subjectId (invariant subject-record-opaque)",
14676
+ "explicitId": null,
14677
+ "citations": []
14678
+ },
14679
+ {
14680
+ "id": "openwop.it.owner-subject-shape.requires-keyclass-on-the-saml-scim-lanes-and-forbids-it-elsewhere",
14681
+ "file": "owner-subject-shape.test.ts",
14682
+ "line": 57,
14683
+ "title": "requires keyClass on the saml/scim lanes and forbids it elsewhere",
14684
+ "explicitId": null,
14685
+ "citations": []
14686
+ },
14687
+ {
14688
+ "id": "openwop.it.owner-subject-shape.rejects-an-unknown-member-and-a-missing-required-member",
14689
+ "file": "owner-subject-shape.test.ts",
14690
+ "line": 64,
14691
+ "title": "rejects an unknown member and a missing required member",
14692
+ "explicitId": null,
14693
+ "citations": []
14694
+ },
14695
+ {
14696
+ "id": "openwop.it.owner-subject-shape.accepts-a-nested-actor-chain-depth-is-a-host-obligation-rfc-0165-b-5",
14697
+ "file": "owner-subject-shape.test.ts",
14698
+ "line": 70,
14699
+ "title": "accepts a nested actor chain (depth is a host obligation, RFC 0165 §B.5)",
14700
+ "explicitId": null,
14701
+ "citations": []
14702
+ },
14703
+ {
14704
+ "id": "openwop.it.owner-subject-shape.runsnapshot-owner-accepts-a-subject-and-still-rejects-an-undeclared-member",
14705
+ "file": "owner-subject-shape.test.ts",
14706
+ "line": 82,
14707
+ "title": "RunSnapshot.owner accepts a subject and still rejects an undeclared member",
14708
+ "explicitId": null,
14709
+ "citations": []
14710
+ },
14711
+ {
14712
+ "id": "openwop.it.owner-subject-shape.run-started-owner-echo-accepts-the-same-subject-and-principalkind-the-echo-match",
14713
+ "file": "owner-subject-shape.test.ts",
14714
+ "line": 88,
14715
+ "title": "run.started owner echo accepts the same subject AND principalKind (the echo matches the snapshot)",
14716
+ "explicitId": null,
14717
+ "citations": []
14718
+ },
14559
14719
  {
14560
14720
  "id": "openwop.it.pack-manifest-extension-opacity.leg-1-an-extension-bearing-manifest-installs",
14561
14721
  "file": "pack-manifest-extension-opacity.test.ts",
@@ -16816,6 +16976,73 @@
16816
16976
  "explicitId": null,
16817
16977
  "citations": []
16818
16978
  },
16979
+ {
16980
+ "id": "openwop.it.protocol-versions-array.declares-root-protocolversions-as-a-unique-non-empty-string-array",
16981
+ "file": "protocol-versions-array.test.ts",
16982
+ "line": 44,
16983
+ "title": "declares root `protocolVersions` as a unique, non-empty string array",
16984
+ "explicitId": null,
16985
+ "citations": []
16986
+ },
16987
+ {
16988
+ "id": "openwop.it.protocol-versions-array.item-grammar-equals-the-scalar-grammar-one-axis-one-grammar",
16989
+ "file": "protocol-versions-array.test.ts",
16990
+ "line": 52,
16991
+ "title": "item grammar equals the scalar grammar (one axis, one grammar)",
16992
+ "explicitId": null,
16993
+ "citations": []
16994
+ },
16995
+ {
16996
+ "id": "openwop.it.protocol-versions-array.accepts-1-11-2-0-rejects-01-0-1-0-0-duplicates-and-empty",
16997
+ "file": "protocol-versions-array.test.ts",
16998
+ "line": 59,
16999
+ "title": "accepts [\"1.11\", \"2.0\"], rejects \"01.0\", \"1.0.0\", duplicates, and empty",
17000
+ "explicitId": null,
17001
+ "citations": []
17002
+ },
17003
+ {
17004
+ "id": "openwop.it.protocol-versions-array.when-advertised-every-item-matches-the-grammar-items-are-unique-and-the-array-co",
17005
+ "file": "protocol-versions-array.test.ts",
17006
+ "line": 73,
17007
+ "title": "when advertised, every item matches the grammar, items are unique, and the array contains protocolVersion",
17008
+ "explicitId": null,
17009
+ "citations": [
17010
+ {
17011
+ "section": "RFC 0165 §A.1",
17012
+ "requirement": "protocolVersions MUST be an array"
17013
+ },
17014
+ {
17015
+ "section": "RFC 0165 §A.1",
17016
+ "requirement": "protocolVersions MUST be non-empty"
17017
+ },
17018
+ {
17019
+ "section": "RFC 0165 §A.1",
17020
+ "requirement": null,
17021
+ "interpolated": true
17022
+ },
17023
+ {
17024
+ "section": "RFC 0165 §A.1",
17025
+ "requirement": "protocolVersions items MUST be unique"
17026
+ },
17027
+ {
17028
+ "section": "RFC 0165 §A.2",
17029
+ "requirement": "protocolVersions MUST contain the value of protocolVersion"
17030
+ }
17031
+ ]
17032
+ },
17033
+ {
17034
+ "id": "openwop.it.protocol-versions-array.profile-derivation-is-unchanged-by-the-array-iscore-reads-the-scalar-only",
17035
+ "file": "protocol-versions-array.test.ts",
17036
+ "line": 90,
17037
+ "title": "profile derivation is unchanged by the array (isCore reads the scalar only)",
17038
+ "explicitId": null,
17039
+ "citations": [
17040
+ {
17041
+ "section": "RFC 0165 §A.2",
17042
+ "requirement": "openwop-discovery-core derivation MUST NOT depend on protocolVersions in v1.x"
17043
+ }
17044
+ ]
17045
+ },
16819
17046
  {
16820
17047
  "id": "openwop.it.provider-usage.capabilities-providerusage-is-either-absent-or-a-well-formed-object",
16821
17048
  "file": "provider-usage.test.ts",
@@ -22365,13 +22592,22 @@
22365
22592
  {
22366
22593
  "section": "webhooks.md §\"Delivery headers\"",
22367
22594
  "requirement": "X-openwop-Signature MUST be sha256=HMAC-SHA256(secret, `${X-openwop-Timestamp}.${rawBody}`)"
22595
+ },
22596
+ {
22597
+ "section": "RFC 0165 §C.1",
22598
+ "requirement": null,
22599
+ "interpolated": true
22600
+ },
22601
+ {
22602
+ "section": "RFC 0165 §C.1",
22603
+ "requirement": "OpenWOP-Signature MUST verify over the same bytes as X-openwop-Signature"
22368
22604
  }
22369
22605
  ]
22370
22606
  },
22371
22607
  {
22372
22608
  "id": "openwop.it.webhook-signed-delivery.control-with-a-tunnel-wired-the-host-still-refuses-the-loopback-receiver",
22373
22609
  "file": "webhook-signed-delivery.test.ts",
22374
- "line": 356,
22610
+ "line": 378,
22375
22611
  "title": "control: with a tunnel wired, the host still refuses the loopback receiver",
22376
22612
  "explicitId": null,
22377
22613
  "citations": [
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "_comment": "Provenance of this vendored schemas/ copy. See conformance/README.md §\"Resolving the contract\". Compare against the stamp in your installed @openwop/openwop-conformance to detect a stale hand-copied contract.",
3
- "suiteVersion": "1.154.0",
4
- "corpusCommit": "07f4573cca4a742445d1078773f422d28f986ee5",
3
+ "suiteVersion": "1.155.0",
4
+ "corpusCommit": "69a420b081aac0fc7f3a6b7576855d6d1ba33f48",
5
5
  "files": {
6
6
  "api/.redocly.lint-ignore.yaml": "bf5a8350b88a72fa43f59605ed8d903ed24b6cfccda5e45509c9f6ed9ee4e712",
7
7
  "api/asyncapi.yaml": "d5ecb9ee6114582be3b1f662c84bfac9ae96dae7bacb853e461168f70a8e1c7d",
8
8
  "api/grpc/openwop.proto": "c3e72bb17cba514ee98feb6434e6c9b6ea6795bfd086489ec69fd882dd1ad977",
9
- "api/openapi.yaml": "105e7bc27d216eb29fbb526b3548b5545711ec2a18c004bce20b08f5af38b750",
9
+ "api/openapi.yaml": "39081c59fb696159806b0f2f9a42e7e9ff830d622fcf2ad4159b21357580a955",
10
10
  "api/redocly.yaml": "9f2374405b9ca58994ae076ada9fbe4dfb8265b77e4bf65331d1493192c0bd3a",
11
- "schemas/README.md": "d9624e98ba3534b27fe4823227e8c2ac8df3ffd4bc4f57b4a023c91f5addefd6",
11
+ "schemas/README.md": "3769a5bcdc2d8f02c819d20eecf34e0c50666b47ac922380a9b0e0ec13cfc9e2",
12
12
  "schemas/a2a-task-state.schema.json": "c9365918f993f943b4b619d42551eb066a1ed33a08d895d51b395432a5b1f1bc",
13
13
  "schemas/a2ui-surface-delta-frame.schema.json": "970d434e4fdcb925de64aaace91eb83bfbcfd8f76757a9ce9f339eda5fb3f4e8",
14
14
  "schemas/agent-deployment-transition.schema.json": "1ac987fed0f380fa5b4e9a40e8e13316e5ae42d03a0ff42db4be6273e1bfe9f4",
@@ -26,7 +26,7 @@
26
26
  "schemas/artifact-type-pack-manifest.schema.json": "13932ea58e819e01b3a0cc7a65590b70683dd5fe9208fc06c64c339943d3c4ce",
27
27
  "schemas/audit-verify-result.schema.json": "e8fd013fb610ba5db27ab0113ec2eb18e7da5f9310dd3de2dd3dc87a259c21fe",
28
28
  "schemas/budget-policy.schema.json": "3d4fe30ae129fe136ab9ece395f2a2a42a76360786b1b9a1d5f1cbc753eb5b39",
29
- "schemas/capabilities.schema.json": "594d92806866886f3aefa7855e1ef5f33fec6459b06ef1ce6c07220c396b5661",
29
+ "schemas/capabilities.schema.json": "72811aa52360e9b84988e7989e4cad519acf0db096f4e3894574f7d022e37e7a",
30
30
  "schemas/certification-bundle-v2.schema.json": "3540232676086869ae78d24f3b9c2154f9c4a8a5eeb976512d6a7409f8cf19bb",
31
31
  "schemas/channel-presence-payload.schema.json": "f9d0ef4219814c2b765baf3e68cbe5eea2cb42379f516a93a0459dcd5f7db47e",
32
32
  "schemas/channel-written-payload.schema.json": "54723a56bc5a35232f64544ca686ac1ac959a5fb05b32b2401facca6b3ef51a3",
@@ -77,15 +77,16 @@
77
77
  "schemas/residency.schema.json": "b45fdc91ae88091251914dbd6787a1e2ce687580b91d31a759217b7a1f0010c0",
78
78
  "schemas/run-ancestry-response.schema.json": "963a5763b03216aa72fc2854560c778e53994f8cb06ea9a4053e541273c87f5c",
79
79
  "schemas/run-diff-response.schema.json": "d9d196730b5d5d8a04a591d21a8fc6b44be7fa134f11827ff7e4f1a86d47e83d",
80
- "schemas/run-event-payloads.schema.json": "6907c2a388d27169756781e1a5771e411238b465d9f0917698cde25dde6bccae",
80
+ "schemas/run-event-payloads.schema.json": "37cdbdb5038e148056c104a49b8d28a9668479e06d401309d354a8b0a70921a3",
81
81
  "schemas/run-event.schema.json": "c70c06932b9831295042836213e617c8e946f20ddd49cf4e889b74d89d2175a8",
82
82
  "schemas/run-options.schema.json": "f8241ece6ddc6527de9160d811d42c231b4f77b935f2e107b3c05608545c59ab",
83
83
  "schemas/run-orchestrator-decided-event.schema.json": "17a43a3c44680a24d411141357a867b3caed6107d56de88fe2e062eaf93fa2c1",
84
- "schemas/run-snapshot.schema.json": "05c406120e1958984ac7e06deff369f2df9f4d7b72ced9b624c327c1c86999e8",
84
+ "schemas/run-snapshot.schema.json": "8a45f997beec0a2fa596f4631129a869cfbc37c9ad38583530ed11655301d2ab",
85
85
  "schemas/security-advisory.schema.json": "9c550f0bf10a3193dcca4a127081fb66fe5dc1b6f729c4c17bfaea3ba3ce05fc",
86
86
  "schemas/self-hosted-runner-dispatch-frame.schema.json": "3f1d7edd572c6747657299525d8e85329b93b6fadf670e3a19c74d53e725adec",
87
87
  "schemas/self-hosted-runner-registration.schema.json": "6c781adf1bb915a9e47796494f4a527353cbbf72b480e5a05be4aac65a6d0c6e",
88
88
  "schemas/self-hosted-runner-result-frame.schema.json": "16f2f711b6071c797d8d4065f6f5ff39102ff03890fefce7a88a669831566296",
89
+ "schemas/subject.schema.json": "89679e35c7ec586adf6dd4adaff0bfa5453648d067fb2a43d7a2f7b2d016e10c",
89
90
  "schemas/suspend-request.schema.json": "1aa39f2a947934bedc9f9696bf45fcb6119395c580d5ca282efb9443bd94a7bb",
90
91
  "schemas/tool-descriptor.schema.json": "7f6adbae63003c1f1311f6a436f7f286542be62aa19a27b46730f2c2810857f7",
91
92
  "schemas/trigger-event.schema.json": "59d63b79dca3593f45d281776cbfe43ae4d85ee25519b82ad59f162a9efb592c",
package/schemas/README.md CHANGED
@@ -71,6 +71,7 @@
71
71
  | `run-options.schema.json` | `run-options.md` | Per-run input overlay (configurable + tags + metadata) on `POST /v1/runs` |
72
72
  | `run-orchestrator-decided-event.schema.json` | orchestrator RFC + `observability.md` | Event payload for orchestrator decisions |
73
73
  | `run-snapshot.schema.json` | `rest-endpoints.md` §RunSnapshot | Projected run state from `GET /v1/runs/{runId}` |
74
+ | `subject.schema.json` | `auth.md` §"The Subject record" + RFC 0165 | DRAFT — issuer-scoped, lane-typed identity record carried as `owner.subject` on `RunSnapshot` and the `run.started` echo; opaque `subjectId` (no `@`/whitespace), `keyClass` iff saml/scim, optional bounded `actor` chain. Optional in v1.x; the v2 major requires it. |
74
75
  | `residency.schema.json` | RFC 0129 + `capabilities.md` §dataResidency | OPTIONAL data-residency `{region}` constraint on `POST /v1/runs` (honor-or-reject admission) |
75
76
  | `credential-provenance.schema.json` | `host-capabilities.md` §"Credential provenance + egress policy" (RFC 0079) | Metadata about a host-issued credential at the tool/egress boundary — `credentialId`/`issuer`/`audiences`(+scopes/expiry/redaction/audit-correlation). Secret-free (SR-1); the §C audience-binding MUST is evaluated against `audiences`. |
76
77
  | `security-advisory.schema.json` | `registry-operations.md` + INCIDENT-RESPONSE runbook | Registry-owned CVE advisory record at `registry/security/advisories.json`. One entry per disclosed vulnerability — id, severity, affected pack-name + SemVer range, optional fixedIn/advisoryUrl/credits. Enforced by `check-advisories.mjs` in `.github/workflows/registry-publish.yml`. |
@@ -16,6 +16,13 @@
16
16
  "description": "RFC 0149 \u00a7C \u2014 openwop protocol version the server speaks, as ASCII `<major>.<minor>` with no leading zero except zero itself. `1.0` and `1.12` are valid; `1`, `1.0.0`, `v1.0`, and `01.0` are not. The integer major is the hard compatibility boundary and the integer minor is the additive contract level; PATCH belongs to suite and SDK versions, not the spec version. Previously constrained only by `minLength: 1`, which admitted `v1.0`, `1.0.0`, and `banana` \u2014 a negotiation the wire could not decide, since neither integer can be extracted from an unconstrained string. Independent of `engineVersion` (which gates persisted-doc compatibility per version-negotiation.md).",
17
17
  "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$"
18
18
  },
19
+ "protocolVersions": {
20
+ "type": "array",
21
+ "minItems": 1,
22
+ "uniqueItems": true,
23
+ "items": { "type": "string", "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$" },
24
+ "description": "RFC 0165 \u00a7A. Every protocol `<major>.<minor>` this host speaks, newest first by convention. OPTIONAL in v1.x; when present it MUST contain the value of `protocolVersion` and MUST NOT name a major the host does not serve. Same grammar as `protocolVersion` (RFC 0149 \u00a7C), not the looser A2A item pattern. Consumers that do not understand it ignore it; profile derivation reads `protocolVersion` only. Reserved for v2 major negotiation."
25
+ },
19
26
  "contractProvenance": {
20
27
  "type": "object",
21
28
  "description": "RFC 0146 (`Active`). Which corpus revision this host's contract handling corresponds to \u2014 the copy it validates and serves against. OPTIONAL; absent \u21d2 UNSPECIFIED provenance, neither current nor stale, and a consumer MUST NOT infer a version from its absence. ADVISORY: a consumer MUST NOT reject a request, refuse interop, or fail a run solely because this differs from its own \u2014 v1.x corpus revisions are additive, so a host on an older revision is conformant and this field detects drift rather than making drift an error. Nothing else on the wire answers this: `protocolVersion` is `1.0` across the entire v1 line (the drift happens inside that value) and `schemaVersions` is per-envelope-type. NOT an integrity check \u2014 `corpusCommit` says WHICH contract, never whether the copy was modified.",
@@ -1019,6 +1019,15 @@
1019
1019
  "principal": {
1020
1020
  "type": "string",
1021
1021
  "minLength": 1
1022
+ },
1023
+ "principalKind": {
1024
+ "type": "string",
1025
+ "enum": ["user", "agent", "anonymous"],
1026
+ "description": "RFC 0132 (declared here by RFC 0165): the echo carries the same optional kind as `RunSnapshot.owner.principalKind`, so a host that echoes its own snapshot owner emits a valid payload."
1027
+ },
1028
+ "subject": {
1029
+ "$ref": "subject.schema.json",
1030
+ "description": "RFC 0165 \u00a7B. The run's Subject record, echoed verbatim from `RunSnapshot.owner.subject` when present."
1022
1031
  }
1023
1032
  },
1024
1033
  "additionalProperties": false
@@ -46,6 +46,10 @@
46
46
  "tenant": { "type": "string", "minLength": 1, "description": "Top-level isolation boundary." },
47
47
  "workspace": { "type": "string", "minLength": 1, "description": "Optional sub-tenant within the tenant (RFC 0048 workspace)." },
48
48
  "principal": { "type": "string", "minLength": 1, "description": "Acting identity (user or agent) — opaque id, never PII." },
49
+ "subject": {
50
+ "$ref": "subject.schema.json",
51
+ "description": "RFC 0165 \u00a7B. OPTIONAL issuer-scoped Subject record for the acting identity. When present: `subject.tenant` MUST equal `tenant`, `subject.subjectId` MUST equal `principal` when both are present, `subject.kind` MUST equal `principalKind` when both are present. A host that emits it on new runs synthesizes `issuer: \"urn:openwop:legacy\"` for runs that predate emission (\u00a7B.3) and copies it verbatim onto forks (\u00a7B.4)."
52
+ },
49
53
  "principalKind": {
50
54
  "type": "string",
51
55
  "enum": ["user", "agent", "anonymous"],
@@ -0,0 +1,56 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://openwop.dev/spec/v1/subject.schema.json",
4
+ "title": "OpenWOP Subject",
5
+ "description": "RFC 0165 §B. An issuer-scoped, lane-typed identity record carried as `owner.subject` on RunSnapshot and on the run.started owner echo. With `issuer` in the key, two trust roots issuing the same identifier are distinct by construction. Optional in v1.x; the v2 major requires it and removes the bare `owner.principal` string.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["issuer", "subjectId", "tenant", "lane", "kind"],
9
+ "properties": {
10
+ "issuer": {
11
+ "type": "string",
12
+ "minLength": 1,
13
+ "maxLength": 1024,
14
+ "pattern": "^\\S+$",
15
+ "description": "Trust root that asserted this subject: the SAML IdP entityID, the OIDC `iss`, the mTLS CA subject, the API-key realm, the SCIM connection id, or `urn:openwop:legacy` for a run that predates subject emission (RFC 0165 §B.3)."
16
+ },
17
+ "subjectId": {
18
+ "type": "string",
19
+ "minLength": 1,
20
+ "maxLength": 512,
21
+ "pattern": "^[^@\\s]+$",
22
+ "description": "Opaque, stable, issuer-scoped identifier. MUST NOT be an email address, a display name, or credential material (SECURITY invariant `subject-record-opaque`). Equals `owner.principal` when both are present (RFC 0165 §B.2)."
23
+ },
24
+ "tenant": {
25
+ "type": "string",
26
+ "minLength": 1,
27
+ "description": "MUST equal the enclosing `owner.tenant` (RFC 0165 §B.2)."
28
+ },
29
+ "lane": {
30
+ "type": "string",
31
+ "enum": ["api-key", "oauth2", "oidc", "mtls", "saml", "scim", "ldap", "workload"],
32
+ "description": "The authentication lane that minted the subject (auth-profiles.md). `workload` corresponds to RFC 0154's workload identity; the RFC 0154 `scheme` is carried in `issuer`."
33
+ },
34
+ "kind": {
35
+ "type": "string",
36
+ "enum": ["user", "agent", "anonymous", "workload"],
37
+ "description": "Superset of `owner.principalKind` (RFC 0132) plus `workload` (RFC 0154). Equals `principalKind` when both are present."
38
+ },
39
+ "keyClass": {
40
+ "type": "string",
41
+ "enum": ["opaque-idp", "configured-immutable"],
42
+ "description": "How `subjectId` was derived when the lane is linkable (RFC 0163 §A). Present iff `lane` is `saml` or `scim`; MUST equal `capabilities.auth.subjectLinkKey` when both identity profiles are advertised (RFC 0164 §A.3)."
43
+ },
44
+ "actor": {
45
+ "$ref": "#",
46
+ "description": "Who acted on this subject's behalf (delegation; RFC 0154 §B inverted to the run's point of view). Provenance, not authorization. Chain depth MUST NOT exceed 4; a host refuses rather than truncates (RFC 0165 §B.5)."
47
+ }
48
+ },
49
+ "allOf": [
50
+ {
51
+ "if": { "properties": { "lane": { "enum": ["saml", "scim"] } } },
52
+ "then": { "required": ["keyClass"] },
53
+ "else": { "not": { "required": ["keyClass"] } }
54
+ }
55
+ ]
56
+ }
@@ -112,9 +112,13 @@ describe('anonymous-actor-shape: capability advertisement (RFC 0132 §B, server-
112
112
  describe('anonymous-actor-shape: owner.principalKind (RFC 0132 §A, server-free)', () => {
113
113
  it('run-snapshot owner accepts principalKind "anonymous" and rejects "guest"', () => {
114
114
  const snap = loadSchema('run-snapshot.schema.json');
115
- const owner = (snap.properties as Record<string, unknown>).owner as Record<string, unknown>;
115
+ // RFC 0165 added an optional `subject` member ($ref subject.schema.json);
116
+ // register it and give the extracted sub-schema its parent's `$id` as the
117
+ // base the relative $ref resolves against.
118
+ const owner = { $id: snap.$id as string, ...((snap.properties as Record<string, unknown>).owner as Record<string, unknown>) };
116
119
  const ajv = new Ajv2020({ strict: false, allErrors: true });
117
120
  addFormats(ajv);
121
+ ajv.addSchema(loadSchema('subject.schema.json'));
118
122
  const validate = ajv.compile(owner);
119
123
  expect(
120
124
  validate({ tenant: 'acme', principal: 'anon:sess-3f9c', principalKind: 'anonymous' }),
@@ -136,6 +140,7 @@ describe('anonymous-actor-shape: audit reuses authorization.decided (RFC 0132 §
136
140
  const payloads = loadSchema('run-event-payloads.schema.json');
137
141
  const ajv = new Ajv2020({ strict: false, allErrors: true });
138
142
  addFormats(ajv);
143
+ ajv.addSchema(loadSchema('subject.schema.json'));
139
144
  ajv.addSchema(payloads, 'payloads');
140
145
  const decided = ajv.getSchema('payloads#/$defs/authorizationDecided');
141
146
 
@@ -24,7 +24,7 @@
24
24
  */
25
25
 
26
26
  import { describe, it, expect } from 'vitest';
27
- import { softSkip } from '../lib/soft-skip.js';
27
+ import { softSkip, seamAbsent } from '../lib/soft-skip.js';
28
28
  import { driver } from '../lib/driver.js';
29
29
  import { capabilityFamily } from '../lib/discovery-capabilities.js';
30
30
 
@@ -190,3 +190,22 @@ describe('auth-subject-link: link-key hygiene (RFC 0159 §A.2 — opt-in)', () =
190
190
  ).toBe(false);
191
191
  });
192
192
  });
193
+
194
+ describe('auth-subject-link: legacy subjects are never linked (RFC 0165 §B.3 — seam-gated)', () => {
195
+ // Invariant `subject-legacy-not-linkable` (witness class: negative-existence,
196
+ // seam-gated). A run that predates `owner.subject` emission carries a synthesized
197
+ // `issuer: "urn:openwop:legacy"`; a SCIM deactivation MUST NOT deny it. The only
198
+ // causable observation is a host seam that mints a legacy-issuer subject and a
199
+ // linked SCIM record; no such seam is specified yet, so this leg records
200
+ // `blocked` honestly rather than a pass (RFC 0148 §A).
201
+ it('a SCIM deactivation does not deny a SAML login whose run subject is legacy-issued', async () => {
202
+ const res = await driver.get('/.well-known/openwop', { authenticated: false });
203
+ const auth = ((res.json as Record<string, unknown>)['auth'] ?? (res.json as { capabilities?: { auth?: unknown } }).capabilities?.auth) as { profiles?: string[] } | undefined;
204
+ const both = Array.isArray(auth?.profiles) && auth.profiles.includes('openwop-auth-saml') && auth.profiles.includes('openwop-auth-scim');
205
+ if (!both) {
206
+ return softSkip('inapplicable', 'host does not advertise both identity profiles (RFC 0164) — the legacy-link rule has no linked pair to protect');
207
+ }
208
+ return seamAbsent('no seam mints a legacy-issuer subject bound to a SCIM record (RFC 0165 §B.3); the negative cannot be caused from outside the host');
209
+ });
210
+ });
211
+
@@ -10,6 +10,7 @@ import { describe, it, expect } from 'vitest';
10
10
  import { driver } from '../lib/driver.js';
11
11
  import { discoveryFamilies } from '../lib/discovery-capabilities.js';
12
12
  import { behaviorGate } from '../lib/behavior-gate.js';
13
+ import { softSkip } from '../lib/soft-skip.js';
13
14
 
14
15
  describe('discovery: /.well-known/openwop', () => {
15
16
  it('returns 200 with required Capabilities fields per capabilities.md §2', async () => {
@@ -42,6 +43,17 @@ describe('discovery: /.well-known/openwop', () => {
42
43
  )).toBeTruthy();
43
44
  });
44
45
 
46
+ it('IF a standard ETag is present, a matching If-None-Match yields 304 (RFC 0165 §C.2 — presence-gated)', async () => {
47
+ const first = await driver.get('/.well-known/openwop', { authenticated: false });
48
+ const etag = first.headers.get('etag');
49
+ if (etag === null) {
50
+ return softSkip('inapplicable', 'host does not send a standard ETag on the discovery document (RFC 0165 §C.2 — SHOULD, presence-gated)');
51
+ }
52
+ expect(etag.trim().length, driver.describe('RFC 0165 §C.2', 'ETag, when present, MUST be a non-empty validator')).toBeGreaterThan(0);
53
+ const second = await driver.get('/.well-known/openwop', { authenticated: false, headers: { 'If-None-Match': etag } });
54
+ expect(second.status, driver.describe('capabilities-change-detection.md §"Cache validators"', 'a matching If-None-Match SHOULD yield 304 Not Modified')).toBe(304);
55
+ });
56
+
45
57
  it('IF Capabilities-Etag is present, it is non-empty and stable within the cache window', async () => {
46
58
  const first = await driver.get('/.well-known/openwop', { authenticated: false });
47
59
  const firstEtag = first.headers.get('capabilities-etag');
@@ -9,8 +9,9 @@
9
9
  * - Positive: `{ tenant }` and `{ tenant, workspace, principal }` validate.
10
10
  * - Negative: missing `tenant` (required), or an unknown property, is rejected.
11
11
  *
12
- * The owner subschema is self-contained (no external $ref), so it compiles
13
- * standalone via ajv.
12
+ * The owner subschema referenced only itself until RFC 0165 added an optional
13
+ * `subject` member ($ref subject.schema.json); that schema is registered on the
14
+ * ajv instance so the owner subschema still compiles standalone.
14
15
  *
15
16
  * @see RFCS/0048-tenant-workspace-principal-identity-model.md
16
17
  * @see schemas/run-snapshot.schema.json properties.owner
@@ -24,6 +25,7 @@ import { SCHEMAS_DIR } from '../lib/paths.js';
24
25
 
25
26
  interface SnapshotSchema {
26
27
  $schema: string;
28
+ $id: string;
27
29
  properties: { owner?: Record<string, unknown> };
28
30
  }
29
31
 
@@ -40,7 +42,9 @@ describe('category: identity owner-triple shape (RFC 0048 §C)', () => {
40
42
  });
41
43
 
42
44
  const ajv = new Ajv2020({ allErrors: true, strict: false });
43
- const ownerSchema = { $schema: snapshot.$schema, ...(snapshot.properties.owner as Record<string, unknown>) };
45
+ ajv.addSchema(JSON.parse(readFileSync(join(SCHEMAS_DIR, 'subject.schema.json'), 'utf8')) as Record<string, unknown>);
46
+ // `$id` is the base the relative `subject.schema.json` $ref (RFC 0165) resolves against.
47
+ const ownerSchema = { $schema: snapshot.$schema, $id: snapshot.$id, ...(snapshot.properties.owner as Record<string, unknown>) };
44
48
  const validate = ajv.compile(ownerSchema);
45
49
 
46
50
  it('positive: tenant-only owner validates', () => {
@@ -0,0 +1,127 @@
1
+ /**
2
+ * owner-subject-echo — RFC 0165 §B.2, §B.4 verification (host-facing, gated).
3
+ *
4
+ * A host that emits `owner.subject` on a new run MUST: keep it consistent with
5
+ * the owner triple (§B.2), echo it verbatim on `run.started` (§B.2; RFC 0048
6
+ * §C), and copy it verbatim onto a fork (§B.4 — `replay.md` §"Fork
7
+ * ownership"). This file drives the `conformance-noop` fixture, reads the
8
+ * snapshot, and gates every leg on the snapshot actually carrying a subject:
9
+ * a host that does not emit one is conformant in v1.x (`inapplicable`), and a
10
+ * host that emits one is held to the rules.
11
+ *
12
+ * @see RFCS/0165-v2-preparation-wire-shapes.md §B
13
+ * @see spec/v1/auth.md §"The Subject record"
14
+ * @see spec/v1/replay.md §"Fork ownership"
15
+ */
16
+
17
+ import { describe, it, expect } from 'vitest';
18
+ import { driver } from '../lib/driver.js';
19
+ import { isFixtureAdvertised } from '../lib/fixtures.js';
20
+ import { softSkip } from '../lib/soft-skip.js';
21
+
22
+ const NOOP_WORKFLOW_ID = 'conformance-noop';
23
+ const SKIP_NO_NOOP = !isFixtureAdvertised(NOOP_WORKFLOW_ID);
24
+
25
+ interface Subject {
26
+ issuer: string;
27
+ subjectId: string;
28
+ tenant: string;
29
+ lane: string;
30
+ kind: string;
31
+ keyClass?: string;
32
+ actor?: Subject;
33
+ }
34
+ interface Owner {
35
+ tenant?: string;
36
+ workspace?: string;
37
+ principal?: string;
38
+ principalKind?: string;
39
+ subject?: Subject;
40
+ }
41
+ interface Snapshot {
42
+ runId?: string;
43
+ status?: string;
44
+ owner?: Owner;
45
+ }
46
+ interface RawEvent {
47
+ type?: string;
48
+ sequence?: number;
49
+ payload?: { owner?: Owner };
50
+ }
51
+
52
+ async function createRun(): Promise<string> {
53
+ const create = await driver.post('/v1/runs', { workflowId: NOOP_WORKFLOW_ID });
54
+ if (create.status !== 201) throw new Error(`Failed to start ${NOOP_WORKFLOW_ID}: ${create.status}`);
55
+ return (create.json as { runId: string }).runId;
56
+ }
57
+
58
+ async function snapshot(runId: string): Promise<Snapshot> {
59
+ const res = await driver.get(`/v1/runs/${encodeURIComponent(runId)}`);
60
+ if (res.status !== 200) throw new Error(`GET /v1/runs/${runId}: ${res.status}`);
61
+ return res.json as Snapshot;
62
+ }
63
+
64
+ async function events(runId: string): Promise<readonly RawEvent[]> {
65
+ const res = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events/poll?lastSequence=0&timeout=1`);
66
+ if (res.status !== 200) throw new Error(`events for ${runId}: ${res.status}`);
67
+ return (res.json as { events?: RawEvent[] })?.events ?? [];
68
+ }
69
+
70
+ function actorDepth(s: Subject | undefined): number {
71
+ let d = 0;
72
+ for (let cur = s?.actor; cur !== undefined; cur = cur.actor) d++;
73
+ return d;
74
+ }
75
+
76
+ describe.skipIf(SKIP_NO_NOOP)('owner-subject-echo: consistency, echo, fork (RFC 0165 §B — gated on a host that emits owner.subject)', () => {
77
+ it('subject is consistent with the owner triple (§B.2) and the actor chain is bounded (§B.5)', async () => {
78
+ const runId = await createRun();
79
+ const snap = await snapshot(runId);
80
+ const subject = snap.owner?.subject;
81
+ if (subject === undefined) {
82
+ return softSkip('inapplicable', 'host does not emit owner.subject on new runs (RFC 0165 §B — optional in v1.x)');
83
+ }
84
+ expect(subject.tenant, driver.describe('RFC 0165 §B.2', 'subject.tenant MUST equal owner.tenant')).toBe(snap.owner?.tenant);
85
+ if (snap.owner?.principal !== undefined) {
86
+ expect(subject.subjectId, driver.describe('RFC 0165 §B.2', 'subject.subjectId MUST equal owner.principal when both are present')).toBe(snap.owner.principal);
87
+ }
88
+ if (snap.owner?.principalKind !== undefined) {
89
+ expect(subject.kind, driver.describe('RFC 0165 §B.2', 'subject.kind MUST equal owner.principalKind when both are present')).toBe(snap.owner.principalKind);
90
+ }
91
+ if (subject.lane === 'saml' || subject.lane === 'scim') {
92
+ expect(subject.keyClass, driver.describe('RFC 0165 §B.2', 'keyClass MUST be present on a linkable lane')).toBeDefined();
93
+ } else {
94
+ expect(subject.keyClass, driver.describe('RFC 0165 §B.2', 'keyClass MUST be absent off the linkable lanes')).toBeUndefined();
95
+ }
96
+ expect(actorDepth(subject), driver.describe('RFC 0165 §B.5', 'actor chain depth MUST NOT exceed 4')).toBeLessThanOrEqual(4);
97
+ });
98
+
99
+ it('run.started echoes the snapshot subject verbatim (§B.2, RFC 0048 §C)', async () => {
100
+ const runId = await createRun();
101
+ const snap = await snapshot(runId);
102
+ if (snap.owner?.subject === undefined) {
103
+ return softSkip('inapplicable', 'host does not emit owner.subject on new runs (RFC 0165 §B — optional in v1.x)');
104
+ }
105
+ const started = (await events(runId)).find((e) => e.type === 'run.started');
106
+ expect(started, driver.describe('RFC 0048 §C', 'run.started MUST be present in the event log')).toBeDefined();
107
+ expect(started?.payload?.owner?.subject, driver.describe('RFC 0165 §B.2', 'run.started owner.subject MUST equal RunSnapshot.owner.subject')).toEqual(snap.owner.subject);
108
+ });
109
+
110
+ it('a fork copies owner.tenant and owner.subject verbatim onto the child (§B.4)', async () => {
111
+ const sourceRunId = await createRun();
112
+ const source = await snapshot(sourceRunId);
113
+ if (source.owner?.subject === undefined) {
114
+ return softSkip('inapplicable', 'host does not emit owner.subject on new runs (RFC 0165 §B — optional in v1.x)');
115
+ }
116
+ const fork = await driver.post(`/v1/runs/${encodeURIComponent(sourceRunId)}:fork`, { fromSeq: 0, mode: 'replay' });
117
+ if (fork.status === 404 || fork.status === 501 || fork.status === 403) {
118
+ return softSkip('inapplicable', `host does not offer :fork on this run (${fork.status}); the fork-copy rule has no fork to check`);
119
+ }
120
+ expect(fork.status, driver.describe('replay.md §"Fork ownership"', ':fork MUST return 201 for a completed noop run')).toBe(201);
121
+ const childId = (fork.json as { runId?: string }).runId;
122
+ expect(typeof childId, 'fork response MUST include the child runId').toBe('string');
123
+ const child = await snapshot(childId as string);
124
+ expect(child.owner?.tenant, driver.describe('RFC 0165 §B.4', 'the child owner.tenant MUST equal the source')).toBe(source.owner.tenant);
125
+ expect(child.owner?.subject, driver.describe('RFC 0165 §B.4', 'the child owner.subject MUST be copied verbatim from the source')).toEqual(source.owner.subject);
126
+ });
127
+ });
@@ -0,0 +1,96 @@
1
+ /**
2
+ * owner-subject-shape — RFC 0165 §B.1–§B.2 verification (server-free).
3
+ *
4
+ * `schemas/subject.schema.json` is the OPTIONAL issuer-scoped identity record
5
+ * carried as `owner.subject` on `RunSnapshot` and on the `run.started` owner
6
+ * echo. This file proves the SCHEMA half of §B: the record compiles standalone,
7
+ * both owner sub-objects reference it as a declared optional member (they are
8
+ * `additionalProperties: false`, so an undeclared `subject` would be rejected —
9
+ * the live `principalKind` divergence RFC 0165 §Alternatives 3 names), and the
10
+ * opacity + key-class conditionals hold. Behaviour (echo, fork copy, legacy
11
+ * synthesis) is `owner-subject-echo.test.ts`, gated on a host that emits it.
12
+ *
13
+ * SECURITY invariant `subject-record-opaque` (witness class: claims-check —
14
+ * the schema pattern is what forbids an email-shaped or whitespace subjectId;
15
+ * no probe can prove a host never mints PII, only that the wire refuses it).
16
+ *
17
+ * @see RFCS/0165-v2-preparation-wire-shapes.md §B
18
+ * @see schemas/subject.schema.json
19
+ */
20
+
21
+ import { describe, it, expect } from 'vitest';
22
+ import { readFileSync } from 'node:fs';
23
+ import { join } from 'node:path';
24
+ import Ajv2020 from 'ajv/dist/2020.js';
25
+ import { SCHEMAS_DIR } from '../lib/paths.js';
26
+
27
+ const read = (f: string): Record<string, unknown> => JSON.parse(readFileSync(join(SCHEMAS_DIR, f), 'utf8')) as Record<string, unknown>;
28
+ const subject = read('subject.schema.json');
29
+ const snapshot = read('run-snapshot.schema.json') as { $schema: string; $id: string; properties: { owner: Record<string, unknown> } };
30
+ const payloads = read('run-event-payloads.schema.json') as { $schema: string; $id: string; $defs: { runStarted: { properties: { owner: Record<string, unknown> } } } };
31
+
32
+ function ajvWithSubject(): Ajv2020 {
33
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
34
+ ajv.addSchema(subject);
35
+ return ajv;
36
+ }
37
+
38
+ const VALID = { issuer: 'https://idp.example.com/entity', subjectId: 'idp-op-8f3a', tenant: 'acme', lane: 'saml', kind: 'user', keyClass: 'opaque-idp' };
39
+
40
+ describe('owner-subject-shape: subject.schema.json (RFC 0165 §B.1)', () => {
41
+ const validate = ajvWithSubject().compile(subject);
42
+
43
+ it('compiles and accepts a full SAML subject', () => {
44
+ expect(validate(VALID), JSON.stringify(validate.errors)).toBe(true);
45
+ });
46
+
47
+ it('accepts the legacy issuer form (RFC 0165 §B.3) and a workload subject', () => {
48
+ expect(validate({ issuer: 'urn:openwop:legacy', subjectId: 'user_42', tenant: 'acme', lane: 'api-key', kind: 'user' }), JSON.stringify(validate.errors)).toBe(true);
49
+ expect(validate({ issuer: 'spiffe://trust.example', subjectId: 'spiffe://trust.example/ns/a/sa/b', tenant: 'acme', lane: 'workload', kind: 'workload' }), JSON.stringify(validate.errors)).toBe(true);
50
+ });
51
+
52
+ it('rejects an email-shaped or whitespace subjectId (invariant subject-record-opaque)', () => {
53
+ expect(validate({ ...VALID, subjectId: 'alice@example.com' }), 'RFC 0165 §B.2: subjectId MUST NOT be an email address').toBe(false);
54
+ expect(validate({ ...VALID, subjectId: 'Alice Example' }), 'RFC 0165 §B.2: subjectId MUST NOT contain whitespace (a display name)').toBe(false);
55
+ });
56
+
57
+ it('requires keyClass on the saml/scim lanes and forbids it elsewhere', () => {
58
+ const { keyClass: _k, ...noKey } = VALID;
59
+ expect(validate(noKey), 'RFC 0165 §B.2: keyClass MUST be present when lane is saml or scim').toBe(false);
60
+ expect(validate({ ...VALID, lane: 'oidc' }), 'RFC 0165 §B.2: keyClass MUST be absent when the lane is not linkable').toBe(false);
61
+ expect(validate({ ...noKey, lane: 'oidc' }), JSON.stringify(validate.errors)).toBe(true);
62
+ });
63
+
64
+ it('rejects an unknown member and a missing required member', () => {
65
+ expect(validate({ ...VALID, email: 'x' })).toBe(false);
66
+ const { tenant: _t, ...noTenant } = VALID;
67
+ expect(validate(noTenant)).toBe(false);
68
+ });
69
+
70
+ it('accepts a nested actor chain (depth is a host obligation, RFC 0165 §B.5)', () => {
71
+ const peer = { issuer: 'https://peer.example', subjectId: 'peer-1', tenant: 'acme', lane: 'workload', kind: 'workload' };
72
+ const { keyClass: _k, ...anon } = { ...VALID, kind: 'anonymous', lane: 'api-key', actor: peer };
73
+ expect(validate(anon), JSON.stringify(validate.errors)).toBe(true);
74
+ });
75
+ });
76
+
77
+ describe('owner-subject-shape: both owner sub-objects declare `subject` (RFC 0165 §B.1)', () => {
78
+ // The extracted sub-schema needs its parent's `$id` as the base for the relative `subject.schema.json` $ref.
79
+ const ownerSnap = { $schema: snapshot.$schema, $id: snapshot.$id, ...snapshot.properties.owner };
80
+ const ownerEcho = { $schema: payloads.$schema, $id: payloads.$id, ...payloads.$defs.runStarted.properties.owner };
81
+
82
+ it('RunSnapshot.owner accepts a subject and still rejects an undeclared member', () => {
83
+ const v = ajvWithSubject().compile(ownerSnap);
84
+ expect(v({ tenant: 'acme', principal: 'idp-op-8f3a', principalKind: 'user', subject: VALID }), JSON.stringify(v.errors)).toBe(true);
85
+ expect(v({ tenant: 'acme', role: 'admin' })).toBe(false);
86
+ });
87
+
88
+ it('run.started owner echo accepts the same subject AND principalKind (the echo matches the snapshot)', () => {
89
+ const v = ajvWithSubject().compile(ownerEcho);
90
+ expect(
91
+ v({ tenant: 'acme', principal: 'idp-op-8f3a', principalKind: 'user', subject: VALID }),
92
+ 'RFC 0165 §B.1: run.started owner MUST declare subject and principalKind so a host can echo its snapshot owner verbatim',
93
+ ).toBe(true);
94
+ expect(v({ tenant: 'acme', subject: { ...VALID, subjectId: 'a@b' } })).toBe(false);
95
+ });
96
+ });
@@ -0,0 +1,103 @@
1
+ /**
2
+ * protocol-versions-array — RFC 0165 §A verification.
3
+ *
4
+ * `protocolVersions: string[]` is the OPTIONAL root advertisement of every
5
+ * `<major>.<minor>` a host serves, reserved for v2 major negotiation
6
+ * (`version-negotiation.md` §"`protocolVersions[]`"). Three things are
7
+ * checkable today:
8
+ *
9
+ * 1. Server-free: the schema declares the array with the SAME strict grammar
10
+ * as the scalar (RFC 0149 §C), not the looser A2A item pattern — one axis,
11
+ * one grammar; and the Levenshtein-1 neighbour of `protocolVersion` is a
12
+ * real property, so the root-layout typo lint cannot mistake it.
13
+ * 2. Host, presence-gated: when advertised, every item matches, items are
14
+ * unique, and the array contains `protocolVersion`'s value.
15
+ * 3. Host, presence-gated: profile derivation is unchanged by the array —
16
+ * `isCore` reads the scalar only (RFC 0165 §A.2, §Alternatives 6).
17
+ *
18
+ * A host that omits the array is conformant (`inapplicable`, not a pass).
19
+ *
20
+ * @see RFCS/0165-v2-preparation-wire-shapes.md §A
21
+ * @see spec/v1/version-negotiation.md §"Protocol version grammar"
22
+ */
23
+
24
+ import { describe, it, expect } from 'vitest';
25
+ import { readFileSync } from 'node:fs';
26
+ import { join } from 'node:path';
27
+ import Ajv2020 from 'ajv/dist/2020.js';
28
+ import { driver } from '../lib/driver.js';
29
+ import { SCHEMAS_DIR } from '../lib/paths.js';
30
+ import { isCore, type DiscoveryPayload } from '../lib/profiles.js';
31
+ import { softSkip } from '../lib/soft-skip.js';
32
+
33
+ interface CapsSchema {
34
+ $schema: string;
35
+ properties: {
36
+ protocolVersion?: { pattern?: string };
37
+ protocolVersions?: { type?: string; minItems?: number; uniqueItems?: boolean; items?: { pattern?: string } };
38
+ };
39
+ }
40
+
41
+ const caps = JSON.parse(readFileSync(join(SCHEMAS_DIR, 'capabilities.schema.json'), 'utf8')) as CapsSchema;
42
+
43
+ describe('protocol-versions-array: schema shape (RFC 0165 §A.1, server-free)', () => {
44
+ it('declares root `protocolVersions` as a unique, non-empty string array', () => {
45
+ const p = caps.properties.protocolVersions;
46
+ expect(p, 'RFC 0165 §A.1: capabilities.schema.json MUST declare root `protocolVersions`').toBeDefined();
47
+ expect(p?.type).toBe('array');
48
+ expect(p?.minItems).toBe(1);
49
+ expect(p?.uniqueItems).toBe(true);
50
+ });
51
+
52
+ it('item grammar equals the scalar grammar (one axis, one grammar)', () => {
53
+ expect(
54
+ caps.properties.protocolVersions?.items?.pattern,
55
+ 'RFC 0165 §A.1: protocolVersions items MUST use the RFC 0149 §C grammar, not the A2A item pattern',
56
+ ).toBe(caps.properties.protocolVersion?.pattern);
57
+ });
58
+
59
+ it('accepts ["1.11", "2.0"], rejects "01.0", "1.0.0", duplicates, and empty', () => {
60
+ const ajv = new Ajv2020({ allErrors: true, strict: false });
61
+ const validate = ajv.compile({ $schema: caps.$schema, ...(caps.properties.protocolVersions as Record<string, unknown>) });
62
+ expect(validate(['1.11', '2.0'])).toBe(true);
63
+ expect(validate(['01.0'])).toBe(false);
64
+ expect(validate(['1.0.0'])).toBe(false);
65
+ expect(validate(['1.0', '1.0'])).toBe(false);
66
+ expect(validate([])).toBe(false);
67
+ });
68
+ });
69
+
70
+ describe('protocol-versions-array: advertisement (RFC 0165 §A.2 — presence-gated)', () => {
71
+ const GRAMMAR = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
72
+
73
+ it('when advertised, every item matches the grammar, items are unique, and the array contains protocolVersion', async () => {
74
+ const res = await driver.get('/.well-known/openwop', { authenticated: false });
75
+ const doc = res.json as Record<string, unknown>;
76
+ const arr = doc['protocolVersions'];
77
+ if (arr === undefined) {
78
+ return softSkip('inapplicable', 'host does not advertise protocolVersions (RFC 0165 §A — optional in v1.x)');
79
+ }
80
+ expect(Array.isArray(arr), driver.describe('RFC 0165 §A.1', 'protocolVersions MUST be an array')).toBe(true);
81
+ const items = arr as unknown[];
82
+ expect(items.length, driver.describe('RFC 0165 §A.1', 'protocolVersions MUST be non-empty')).toBeGreaterThan(0);
83
+ for (const v of items) {
84
+ expect(typeof v === 'string' && GRAMMAR.test(v), driver.describe('RFC 0165 §A.1', `protocolVersions item ${JSON.stringify(v)} MUST match the RFC 0149 §C grammar`)).toBe(true);
85
+ }
86
+ expect(new Set(items).size, driver.describe('RFC 0165 §A.1', 'protocolVersions items MUST be unique')).toBe(items.length);
87
+ expect(items, driver.describe('RFC 0165 §A.2', 'protocolVersions MUST contain the value of protocolVersion')).toContain(doc['protocolVersion']);
88
+ });
89
+
90
+ it('profile derivation is unchanged by the array (isCore reads the scalar only)', async () => {
91
+ const res = await driver.get('/.well-known/openwop', { authenticated: false });
92
+ const doc = res.json as DiscoveryPayload & Record<string, unknown>;
93
+ if (doc['protocolVersions'] === undefined) {
94
+ return softSkip('inapplicable', 'host does not advertise protocolVersions (RFC 0165 §A — optional in v1.x)');
95
+ }
96
+ const without = { ...doc } as Record<string, unknown>;
97
+ delete without['protocolVersions'];
98
+ expect(
99
+ isCore(doc),
100
+ driver.describe('RFC 0165 §A.2', 'openwop-discovery-core derivation MUST NOT depend on protocolVersions in v1.x'),
101
+ ).toBe(isCore(without as DiscoveryPayload));
102
+ });
103
+ });
@@ -323,6 +323,28 @@ describe('webhook-signed-delivery: end-to-end HMAC v1', () => {
323
323
  driver.describe('webhooks.md §"Delivery headers"', 'X-openwop-Signature MUST be sha256=HMAC-SHA256(secret, `${X-openwop-Timestamp}.${rawBody}`)'),
324
324
  ).toBe(expected);
325
325
 
326
+ // RFC 0165 §C.1 — dual emission of the `OpenWOP-*` family. SHOULD-level and
327
+ // presence-gated: when the new family is present, every value MUST equal its
328
+ // `X-openwop-*` counterpart and the signature verifies reading either.
329
+ const dual = first.headers['openwop-signature'];
330
+ if (dual === undefined) {
331
+ softSkip('inapplicable', 'host does not yet dual-emit the OpenWOP-* webhook header family (RFC 0165 §C.1 — SHOULD)');
332
+ } else {
333
+ for (const [neu, old] of [
334
+ ['openwop-webhook-id', 'x-openwop-webhook-id'],
335
+ ['openwop-event-type', 'x-openwop-event-type'],
336
+ ['openwop-timestamp', 'x-openwop-timestamp'],
337
+ ['openwop-signature', 'x-openwop-signature'],
338
+ ['openwop-signature-algorithm', 'x-openwop-signature-algorithm'],
339
+ ] as const) {
340
+ expect(first.headers[neu], driver.describe('RFC 0165 §C.1', `${neu} MUST equal ${old} when the OpenWOP-* family is emitted`)).toBe(first.headers[old]);
341
+ }
342
+ expect(
343
+ String(dual).replace('sha256=', ''),
344
+ driver.describe('RFC 0165 §C.1', 'OpenWOP-Signature MUST verify over the same bytes as X-openwop-Signature'),
345
+ ).toBe(expected);
346
+ }
347
+
326
348
  // Body should parse as JSON with a run event shape.
327
349
  const event = JSON.parse(first.body) as { type?: unknown; runId?: unknown };
328
350
  expect(typeof event.type).toBe('string');