@openwop/openwop-conformance 2.46.20 → 2.46.21

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,3 +1,13 @@
1
+ ## [2.46.21] — 2026-10-24 — RFC 0249 Active: v2 proposal operations
2
+
3
+ - **New scenario `v2-proposal-lifecycle`.** One client-submitted `prompt-template` proposal is driven through submit, revise and apply. The legs are `0249.shape` (server-free), `submit-kind-refused`, `draft-inert`, `apply-stale-refused`, `apply-unscoped-refused`, `apply-installs`, `readback-byte-match`, `terminal-refused` and `cross-tenant-not-found`. Judges are in `lib/proposal-lifecycle-witness.ts`, whose self-tests fail each wrong host answer, including a hash passed off as an install.
4
+ - **Fix before release (found by openwop-app's pre-release run):** the judges read error codes from a nested `{error: {code}}`. The v2 envelope is flat (`{error: "<code>", message, details?}`), so three legs failed on a correct host. They now use `readErrorCode` and read `details` at the root; self-tests cover the flat form.
5
+ - **`0249.unadvertised-not-found`** in `v2-unadvertised-operation-not-found`: `GET /proposals` on a host without `agents.proposals` answers `404 not_found`.
6
+ - **Gating.** The host legs need `agents.proposals` with `clientDrafts: true` and `readback` ∋ `prompt-template`, plus `OPENWOP_TEST_LOW_SCOPE_KEY` (read and write, no `proposals:apply`) and `OPENWOP_TEST_TENANT_B_API_KEY`. An unset key records `blocked`.
7
+ - **Invariants.** `proposal-inert-until-applied` and `proposal-no-resynthesis` move `seam-gated` → `witnessable-gated`.
8
+ - **New top-level name `proposals`** is pinned in `check-manifest-top-level-segments`. No host in INTEROP-MATRIX serves `/proposals` unversioned: openwop-app's proposals are under `/host/openwop-app/`, and MyndHyve's v1 surface was `/v1/host/sample/proposals`.
9
+ - **Host impact:** none today. No host advertises `clientDrafts`. MyndHyve advertises `agents.proposals` without it, so its legs are `inapplicable`.
10
+
1
11
  ## [2.46.20] — 2026-10-24 — RFC 0081 eval envelope legs; RFC 0249 Draft
2
12
 
3
13
  - **Two new legs in `v2-eval-trajectory-trials` (RFC 0081 §C)**, on the existing `conformance-eval-trajectory` run, so no extra run: `0081.eval-envelope` (one `eval.started` and one `eval.completed` bracket every `eval.scored`, `eval.completed` precedes `run.completed`, and its counts equal the summary's) and `0081.summary-served` (the served `EvalSummary` validates and its counts match its rows). Judges in `lib/eval-envelope-witness.ts`, whose self-tests fail each wrong host answer. Before this, no v2 leg read `eval.completed`.
package/README.md CHANGED
@@ -137,7 +137,7 @@ Each scenario file records its disposition and assertion count in `<report-dir>/
137
137
 
138
138
  ## What's Covered
139
139
 
140
- The current suite has 659 scenario files under `src/scenarios/`. By target major ([`scenario-majors.json`](./scenario-majors.json)): 219 files run against a v2 host and 455 against a v1 host; 15 of them run at both.
140
+ The current suite has 660 scenario files under `src/scenarios/`. By target major ([`scenario-majors.json`](./scenario-majors.json)): 220 files run against a v2 host and 455 against a v1 host; 15 of them run at both.
141
141
 
142
142
  The v2 scenarios (`src/scenarios/v2-*.test.ts`) cover, by area:
143
143
 
@@ -166,7 +166,7 @@ Three kinds of scenario share the tree:
166
166
  - **Host** checks drive a live host over HTTP.
167
167
  - **Gated** checks run only when the host advertises the family, profile or fixture they need, and otherwise record `inapplicable` or `blocked` with a reason. `--require-behavior` turns an advertised-but-unwitnessable behaviour into a failure.
168
168
 
169
- Current source tree: 659 scenario files. [`coverage.md`](./coverage.md) maps each spec document and every OpenAPI operation to its scenarios. [`CHANGELOG.md`](./CHANGELOG.md) records when each scenario landed.
169
+ Current source tree: 660 scenario files. [`coverage.md`](./coverage.md) maps each spec document and every OpenAPI operation to its scenarios. [`CHANGELOG.md`](./CHANGELOG.md) records when each scenario landed.
170
170
 
171
171
  ## How the 2.x suite is built
172
172
 
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "package": "@openwop/spec-artifacts",
3
- "version": "2.46.20",
4
- "stampSha256": "426586e18ceca126f915b8517b442ecc6178e3f2cecde3325bf00dcfd76fb37d"
3
+ "version": "2.46.21",
4
+ "stampSha256": "bf5d66dc96dadf165262981562db0cb1a45b8fca7d4eb0910b6c60e6e0c5b7ad"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openwop/openwop-conformance",
3
- "version": "2.46.20",
3
+ "version": "2.46.21",
4
4
  "description": "Black-box conformance suite for OpenWOP hosts (v2 current; v1 scenarios retained for the frozen v1 tree).",
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.46.20"
59
+ "@openwop/spec-artifacts": "2.46.21"
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": 747,
6
- "tests": 2824,
7
- "withStableId": 2824,
5
+ "files": 748,
6
+ "tests": 2834,
7
+ "withStableId": 2834,
8
8
  "interpolatedTitles": 0,
9
- "explicitIds": 2697
9
+ "explicitIds": 2707
10
10
  },
11
11
  "records": [
12
12
  {
@@ -35846,6 +35846,111 @@
35846
35846
  }
35847
35847
  ]
35848
35848
  },
35849
+ {
35850
+ "id": "openwop.it.v2-proposal-lifecycle.the-submit-revise-and-apply-bodies-and-installedartifact-validate-and-the-negati",
35851
+ "file": "v2-proposal-lifecycle.test.ts",
35852
+ "line": 186,
35853
+ "title": "the submit, revise and apply bodies and installedArtifact validate, and the negatives are refused",
35854
+ "explicitId": "openwop.requirement.0249.shape",
35855
+ "citations": [
35856
+ {
35857
+ "section": "proposal-submit / proposal-revise / proposal-apply / proposal.schema.json (RFC 0249)",
35858
+ "requirement": "a minimal submit MUST validate"
35859
+ },
35860
+ {
35861
+ "section": "proposal-submit / proposal-revise / proposal-apply / proposal.schema.json (RFC 0249)",
35862
+ "requirement": "an unknown kind MUST be refused"
35863
+ },
35864
+ {
35865
+ "section": "proposal-submit / proposal-revise / proposal-apply / proposal.schema.json (RFC 0249)",
35866
+ "requirement": "a submit carrying state MUST be refused (closed body)"
35867
+ },
35868
+ {
35869
+ "section": "proposal-submit / proposal-revise / proposal-apply / proposal.schema.json (RFC 0249)",
35870
+ "requirement": "an empty revise MUST be refused"
35871
+ },
35872
+ {
35873
+ "section": "proposal-submit / proposal-revise / proposal-apply / proposal.schema.json (RFC 0249)",
35874
+ "requirement": "a sha256 digest MUST validate"
35875
+ },
35876
+ {
35877
+ "section": "proposal-submit / proposal-revise / proposal-apply / proposal.schema.json (RFC 0249)",
35878
+ "requirement": "a digest without the sha256: prefix and 64 hex MUST be refused"
35879
+ },
35880
+ {
35881
+ "section": "proposal-submit / proposal-revise / proposal-apply / proposal.schema.json (RFC 0249)",
35882
+ "requirement": "a minimal installedArtifact MUST validate"
35883
+ },
35884
+ {
35885
+ "section": "proposal-submit / proposal-revise / proposal-apply / proposal.schema.json (RFC 0249)",
35886
+ "requirement": "installedArtifact is closed"
35887
+ }
35888
+ ]
35889
+ },
35890
+ {
35891
+ "id": "openwop.it.v2-proposal-lifecycle.a-kind-outside-artifactkinds-is-refused-at-submit",
35892
+ "file": "v2-proposal-lifecycle.test.ts",
35893
+ "line": 204,
35894
+ "title": "a kind outside artifactKinds is refused at submit",
35895
+ "explicitId": "openwop.requirement.0249.submit-kind-refused",
35896
+ "citations": []
35897
+ },
35898
+ {
35899
+ "id": "openwop.it.v2-proposal-lifecycle.a-draft-prompt-template-is-not-served-listed-or-renderable",
35900
+ "file": "v2-proposal-lifecycle.test.ts",
35901
+ "line": 213,
35902
+ "title": "a draft prompt-template is not served, listed or renderable",
35903
+ "explicitId": "openwop.requirement.0249.draft-inert",
35904
+ "citations": []
35905
+ },
35906
+ {
35907
+ "id": "openwop.it.v2-proposal-lifecycle.a-revision-re-digests-and-an-apply-bound-to-the-old-bytes-is-stale",
35908
+ "file": "v2-proposal-lifecycle.test.ts",
35909
+ "line": 221,
35910
+ "title": "a revision re-digests, and an apply bound to the old bytes is stale",
35911
+ "explicitId": "openwop.requirement.0249.apply-stale-refused",
35912
+ "citations": []
35913
+ },
35914
+ {
35915
+ "id": "openwop.it.v2-proposal-lifecycle.an-apply-without-proposals-apply-is-refused-and-installs-nothing",
35916
+ "file": "v2-proposal-lifecycle.test.ts",
35917
+ "line": 229,
35918
+ "title": "an apply without proposals:apply is refused and installs nothing",
35919
+ "explicitId": "openwop.requirement.0249.apply-unscoped-refused",
35920
+ "citations": []
35921
+ },
35922
+ {
35923
+ "id": "openwop.it.v2-proposal-lifecycle.an-authorized-current-apply-names-the-read-that-serves-the-installed-template",
35924
+ "file": "v2-proposal-lifecycle.test.ts",
35925
+ "line": 239,
35926
+ "title": "an authorized, current apply names the read that serves the installed template",
35927
+ "explicitId": "openwop.requirement.0249.apply-installs",
35928
+ "citations": []
35929
+ },
35930
+ {
35931
+ "id": "openwop.it.v2-proposal-lifecycle.the-installed-template-is-the-reviewed-revision-byte-for-byte",
35932
+ "file": "v2-proposal-lifecycle.test.ts",
35933
+ "line": 248,
35934
+ "title": "the installed template is the reviewed revision, byte for byte",
35935
+ "explicitId": "openwop.requirement.0249.readback-byte-match",
35936
+ "citations": []
35937
+ },
35938
+ {
35939
+ "id": "openwop.it.v2-proposal-lifecycle.an-applied-proposal-is-terminal",
35940
+ "file": "v2-proposal-lifecycle.test.ts",
35941
+ "line": 257,
35942
+ "title": "an applied proposal is terminal",
35943
+ "explicitId": "openwop.requirement.0249.terminal-refused",
35944
+ "citations": []
35945
+ },
35946
+ {
35947
+ "id": "openwop.it.v2-proposal-lifecycle.another-tenant-sees-the-proposal-and-the-installed-template-as-unknown-ids",
35948
+ "file": "v2-proposal-lifecycle.test.ts",
35949
+ "line": 266,
35950
+ "title": "another tenant sees the proposal and the installed template as unknown ids",
35951
+ "explicitId": "openwop.requirement.0249.cross-tenant-not-found",
35952
+ "citations": []
35953
+ },
35849
35954
  {
35850
35955
  "id": "openwop.it.v2-protected-resource-metadata.serves-rfc-9728-metadata-unauthenticated-at-the-uri-derived-from-its-resource-id",
35851
35956
  "file": "v2-protected-resource-metadata.test.ts",
@@ -38317,7 +38422,7 @@
38317
38422
  {
38318
38423
  "id": "openwop.it.v2-unadvertised-operation-not-found.get-prompts-answers-404-not-found-when-prompts-endpointssupported-is-not-adverti",
38319
38424
  "file": "v2-unadvertised-operation-not-found.test.ts",
38320
- "line": 43,
38425
+ "line": 45,
38321
38426
  "title": "GET /prompts answers 404 not_found when prompts.endpointsSupported is not advertised",
38322
38427
  "explicitId": "openwop.requirement.0228.unadvertised-prompts-not-found",
38323
38428
  "citations": [
@@ -38335,7 +38440,7 @@
38335
38440
  {
38336
38441
  "id": "openwop.it.v2-unadvertised-operation-not-found.get-content-settings-answers-404-not-found-when-content-is-not-advertised",
38337
38442
  "file": "v2-unadvertised-operation-not-found.test.ts",
38338
- "line": 54,
38443
+ "line": 56,
38339
38444
  "title": "GET /content/settings answers 404 not_found when content is not advertised",
38340
38445
  "explicitId": "openwop.requirement.0228.unadvertised-content-not-found",
38341
38446
  "citations": [
@@ -38350,6 +38455,24 @@
38350
38455
  }
38351
38456
  ]
38352
38457
  },
38458
+ {
38459
+ "id": "openwop.it.v2-unadvertised-operation-not-found.get-proposals-answers-404-not-found-when-agents-proposals-is-not-advertised-rfc",
38460
+ "file": "v2-unadvertised-operation-not-found.test.ts",
38461
+ "line": 66,
38462
+ "title": "GET /proposals answers 404 not_found when agents.proposals is not advertised (RFC 0249)",
38463
+ "explicitId": "openwop.requirement.0249.unadvertised-not-found",
38464
+ "citations": [
38465
+ {
38466
+ "section": "spec/v2/core/errors.md §Unadvertised operations",
38467
+ "requirement": null,
38468
+ "interpolated": true
38469
+ },
38470
+ {
38471
+ "section": "spec/v2/core/errors.md §Unadvertised operations",
38472
+ "requirement": "the 404 for an unadvertised gated operation MUST carry the registered code not_found"
38473
+ }
38474
+ ]
38475
+ },
38353
38476
  {
38354
38477
  "id": "openwop.it.v2-unmapped-type-refused.poll-over-a-log-with-an-unmapped-unprefixed-type-fails-with-500-event-type-unmap",
38355
38478
  "file": "v2-unmapped-type-refused.test.ts",
@@ -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": 659,
4
+ "files": 660,
5
5
  "v1": 455,
6
- "v2": 219
6
+ "v2": 220
7
7
  },
8
8
  "majors": {
9
9
  "a2a-1-0-agent-card.test.ts": [
@@ -1686,6 +1686,9 @@
1686
1686
  "v2-prompt-template-shape.test.ts": [
1687
1687
  2
1688
1688
  ],
1689
+ "v2-proposal-lifecycle.test.ts": [
1690
+ 2
1691
+ ],
1689
1692
  "v2-protected-resource-metadata.test.ts": [
1690
1693
  2
1691
1694
  ],
@@ -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.46.20",
5
- "corpusTag": "v2.46.20",
4
+ "version": "2.46.21",
5
+ "corpusTag": "v2.46.21",
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": "ac6de5303872664b78ff419c035915593e01599e02835e777a0123de1baeb398",
11
11
  "api/redocly.yaml": "b0604c89b2ca6d5076ec25725c539dad44a741a811fe524439ee6daef8baa09f",
12
- "api/seams-v2.yaml": "14325981296aaf0ab2dcce8aa96527bd68935c1130bd9bf6103ee6b6c4df4f8e",
13
- "api/v2/asyncapi.yaml": "b5f5369bbb35a2c3c75ff6c4f6c9281e77b141dd1d21e9495fef670d72b74a5b",
14
- "api/v2/openapi.yaml": "3b17112c01e94987b0cef6a328cc9a6d3a0c0521bbafb14b1fc8abb4e10d9091",
12
+ "api/seams-v2.yaml": "804179658b0f947c68a735eb33b652dd045b93980ab58a6f266a8de17c22daad",
13
+ "api/v2/asyncapi.yaml": "b881e2ca9b6d4b1de799d1be704118cb10249dbf3b1ecdf0f25a585658cfa690",
14
+ "api/v2/openapi.yaml": "9bd1bd491b690b3b5d0a358b0ef8235d8ded11b040e1e12ce1d4cfb41c24374d",
15
15
  "api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
16
16
  "schemas/README.md": "276fc1d51eb08e14e9127b9fd27761d7046ee33c12000f3a64a801322db8bf58",
17
17
  "schemas/a2a-task-state.schema.json": "75d5049dea8bd873ff0e7546f1c60c8d36c219bec7084264360be54a8be30ae1",
@@ -117,7 +117,7 @@
117
117
  "schemas/v2/artifact.schema.json": "85b9dc284d39b99d2a66fa8cbf47f2dd3d8526d3bd6cc411ad315b372de38975",
118
118
  "schemas/v2/audit-verify-result.schema.json": "14af8e9283510f0a40e48a2059c929ee44e0fc9fa3cc66a95bfbe90b17c48297",
119
119
  "schemas/v2/budget-policy.schema.json": "1de68095db0beb5c7a4be629de26e79f62ebbe748a88d46f36e768edfd1f0d68",
120
- "schemas/v2/capabilities.schema.json": "fe28a95bc36767b99cdf529fe7db47dce49c49c5635a724e552c728871c48b06",
120
+ "schemas/v2/capabilities.schema.json": "9d5b222dcd2003141b7f09e9b836744a1b63defc01bffea5d10c1da7747bc2d8",
121
121
  "schemas/v2/certification-bundle.schema.json": "5cfa7fb5247160333e33a58b60d42bcd4e5269d8391d6a112d301e14828892ed",
122
122
  "schemas/v2/channel-presence-payload.schema.json": "fc03df0c20cc12a25f7ee6a47503a4c5931d79fe3d5ff24815e8fd7c1bcb9a87",
123
123
  "schemas/v2/channel-written-payload.schema.json": "ccecff3c71a3275ad8db035ac0e04db25e6f8d23cf091ddbcd5a60d1be1cbb6b",
@@ -144,7 +144,7 @@
144
144
  "schemas/v2/envelopes/schema.request.schema.json": "be2c5f7aa881cdd252ccff47ac02193659e1393633a1541f4297383205d384a2",
145
145
  "schemas/v2/envelopes/schema.response.schema.json": "d049a081267c06c28879c331b147f3ed821b14ccfd93005c67aff64db0a8c68e",
146
146
  "schemas/v2/envelopes/ui.a2ui-surface.schema.json": "e92a2548ba65454c9e0a34f72ea6768d18f59e61fa5da7668d0b2d5baddaddc8",
147
- "schemas/v2/error-envelope.schema.json": "8ddb1d80330dfef17572a74dfe3a53e1a06411e4b0af6a6f1dd6e7e7461da987",
147
+ "schemas/v2/error-envelope.schema.json": "0b15d6a3a2baad6bf314a66a4a8da4756460574887b929b3b06cb23a6c02f0bd",
148
148
  "schemas/v2/eval-summary.schema.json": "d07f4d116c5410997a81d0bc5387545fb61933a6333c360a26ab8a71c749336b",
149
149
  "schemas/v2/export-bundle.schema.json": "bd28905a7729437ab7f2ad8bb33e13a371b2bf5605a7eb2962a0d246de312d69",
150
150
  "schemas/v2/form-content-pack-manifest.schema.json": "a1942a124e250a6e9fbdb6b811c5a125e78fbcbcaa46dfc71d0636f24710a339",
@@ -169,7 +169,11 @@
169
169
  "schemas/v2/prompt-pack-manifest.schema.json": "752407469475396ad250085437a6cc6bd56a017ac6d3e52196aecc65dda4e51a",
170
170
  "schemas/v2/prompt-ref.schema.json": "8288495bf353fbc158cef6525d16dd1dcd9373941d15ec072044f05d143355d0",
171
171
  "schemas/v2/prompt-template.schema.json": "63825b39807e4ee85d41b9ad296ff2a0dd7bd2076d91e76a6c15a337e3e3bb84",
172
- "schemas/v2/proposal.schema.json": "a5c582ce1e854e5fe10412ebed00e537961ee827bbaca506ca374b200a685992",
172
+ "schemas/v2/proposal-apply.schema.json": "eb187cfec8cf9a799d94463c34550fa84879e29bbc201a03b5d6ab58efb398d9",
173
+ "schemas/v2/proposal-list-response.schema.json": "1b5e78fc1e4256dbe6bc866487879deb8f7b9cc18f5bc875db3fde46d79bea02",
174
+ "schemas/v2/proposal-revise.schema.json": "065e75cccd01fcc619e63c25f2a1775adcc24b0467689dca42462ace6bfa1146",
175
+ "schemas/v2/proposal-submit.schema.json": "00fb91a4a76ebcee530295f5262b8b369ed024055cd29a47aa520db03a7fc6c9",
176
+ "schemas/v2/proposal.schema.json": "52a1e54cfce966a07e2588d5e323a4fa896470ccd3a3a21d2ed19d977d2922b7",
173
177
  "schemas/v2/registry-version-manifest.schema.json": "940cfd82511690cd6f2539e5676b185e7812628d0707c283fd7ffb33b6e54afd",
174
178
  "schemas/v2/residency.schema.json": "3fd23c9732beb140504e81560fc426eb6764d547e56da5fb92c5a5c7bf04db3a",
175
179
  "schemas/v2/run-ancestry-response.schema.json": "334c17f2062dea62726ca1d07b8be3f169cdc422af843c1def5fb317039814c9",
@@ -208,14 +212,14 @@
208
212
  "schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
209
213
  "spec/v1/alias-detectors.json": "22444c22bb88c568339ef512553f9fc38146e22b566d11ab8608f655d688d19c",
210
214
  "spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
211
- "spec/v1/core-standard-manifest.json": "d70cc792bd36812bfcec84c6a8b0d2cd096a3fbed19967afbc8f1dd1a7d26d87",
215
+ "spec/v1/core-standard-manifest.json": "6e33742deb9559c5d995750c0a13c7f83446ab8e74c6f2f2d728b107f929e053",
212
216
  "spec/v1/deprecations.json": "e723bc11d7272c71789ad1303bb7f21809a8a024abf57594f9d5c35dc199ece9",
213
217
  "spec/v1/deprecations.schema.json": "3e393c405d2a41b467d8c5e3c468549078df1ce6a6d2588fc488097b95d9b55b",
214
218
  "spec/v1/end-of-support.json": "35f0bf3b70fe78b16e2a2fd81a153fc27e703f6c16376c6fe7cdf1f4f2c4e6d4",
215
219
  "spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
216
220
  "spec/v1/event-codemap.schema.json": "d05933b2e88103aff51a2f774df97b9bda065e0fc88dbd9b114cc47a32189174",
217
221
  "spec/v1/extensions.json": "79a60754aa16cbdbdb604f8e5c00038af535a1690a4d07bd5ff7c38f0256a7bc",
218
- "spec/v1/gaps.json": "4655f3087d9917f3b6b7612af23c86a1b338c732a9c1a72ee4a705a1b4f4f7a9",
222
+ "spec/v1/gaps.json": "9db4f2d06006cbfa55773e3a1dce2b0418a8da94ede4bf6e2992e9f18eaac4d1",
219
223
  "spec/v1/gaps.schema.json": "8fd83259f556553c9df0f53e7a82ca8c2a4771d471197f69b4e2ae0ceeacfd66",
220
224
  "spec/v1/migrations.json": "4c50760be70cb644d02427bcaf5749606700edf4513f423903cf3b1d12f5c300",
221
225
  "spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
@@ -227,11 +231,11 @@
227
231
  "spec/v2/core/conformance.md": "3ca291e63bc70a6ef829ae8759ad0ded3741ea7aa507bbbdffc57de443ab2ae2",
228
232
  "spec/v2/core/connection-packs.md": "c80f95bd6a03698f35c72cdb2b46bd973f49203bbd786c26760e35fa86e6f23d",
229
233
  "spec/v2/core/conversation.md": "8d6abedf6af408a74577753bfca24719d318e2632e10b45ff5d63bdcf31a8aaa",
230
- "spec/v2/core/errors.md": "e48838701b86bbfe0ffee77b18a8ffc4e5f04724b4318507d9771da653f8da0f",
234
+ "spec/v2/core/errors.md": "e932dd663ec7434480038ca9cdbe8fe37f6e422350ce95574522af4006b8a356",
231
235
  "spec/v2/core/events.md": "c3dcece6f1b1aa43e1e01c5ece23b2df075a505d5f6c2f37de0801aeaa486b62",
232
236
  "spec/v2/core/execution.md": "4218dbb397534940f83ca5d46561f02bf8f3fdf68d2ae7fefe7f2eb2ed771103",
233
237
  "spec/v2/core/form-content-packs.md": "02931227899676fd4581a27017be2a6c4953904717be9b81c29d12f87ab3917d",
234
- "spec/v2/core/headers.md": "f69fe81e4a3ad21879bb85948660c16860eb05bf152928c31aa4898f6d7d1887",
238
+ "spec/v2/core/headers.md": "dae37c2b3980099272e5b9581107c0410d6b23aac95ea6c5a0890c34f0e51c8f",
235
239
  "spec/v2/core/host-services.md": "a6b5af882cb1e5c992dddc53c4de06feb36eee01bd566614330041213b3ce657",
236
240
  "spec/v2/core/i18n.md": "882904b7d59f992ab1e5f7346167161a54cf665b8fee47e64a3862cf9133a580",
237
241
  "spec/v2/core/idempotency.md": "adbd317a6ebb57c2759c076297b31f67def9e391576bccd0bbea787b13e85872",
@@ -246,7 +250,7 @@
246
250
  "spec/v2/core/portability.md": "ab13a6ca43544b9a6a91edfb377ec680c768d14be841c84fb79125b231426891",
247
251
  "spec/v2/core/replay.md": "9e66e2029b65866187c0920428a8c5bf8ad1c2b39feefbd5c02f0e59909afc7d",
248
252
  "spec/v2/core/runs.md": "1c292c0ab4a3119aeff3a6e9b6e99cff69782df78c3872179f9f0661b6204f23",
249
- "spec/v2/core/security-defaults.md": "887d8c4bc592094e3b931c23f1c2175c18ebbd47528b074016f9d2091bc513b2",
253
+ "spec/v2/core/security-defaults.md": "d201fbcd964a624ab45a8bdb7ceca113c561df4c33e9017c2952dd8ef62919a4",
250
254
  "spec/v2/core/storage.md": "dc94b985847c1341c7a064f04a6010f0fdbe6c3ab3e555d4362878c81d764c18",
251
255
  "spec/v2/core/tool-catalog.md": "d16a9004418719c48301804aefa4d7ab6f41112a4ed83727f552aa1b92b418b0",
252
256
  "spec/v2/core/versioning.md": "348a6dc5bd3425efc0016bb6772d5d04ebf578b73eebcdfec6c2d82346a8577e",
@@ -257,7 +261,7 @@
257
261
  "spec/v2/declaration.json": "5ccdcf9865ca02022c01a551583e1f343a4b7c73fd228049f1be620858a60ad5",
258
262
  "spec/v2/declaration.schema.json": "ef951633e4070899f64828f5cf1b5f4870270a0360f81dbb49b5b4d6cd407550",
259
263
  "spec/v2/eos-override.json": "4c72d2f6a658943b01c1de1643cf294642091933af7832f38ee5495df684cb2d",
260
- "spec/v2/errors.json": "1f0e29455edbd22e28f67f221766209c3c55ac4987beb77993b5702dd7cae131",
264
+ "spec/v2/errors.json": "33f9b4e450c51dfb1046bb00b327ee88163fb53360b0b43bcd204061ec966a13",
261
265
  "spec/v2/errors.schema.json": "47404ad53e6147cf9017b813483712466573e419fb33e6c4b6c12d3e7deba1bc",
262
266
  "spec/v2/event-codemap.json": "37869a03cdfd8758b57e6f350641a6ebbd383231cebda51f5473ad85f94ab74c",
263
267
  "spec/v2/event-codemap.schema.json": "b173f2a9bcc0bf9b62a474ce3b91e1431d597e64fd1560fc58662cd8606eaf9c",
@@ -281,7 +285,7 @@
281
285
  "spec/v2/ext/sandbox-runtime-notes/README.md": "0d1290fb1e20b2e35c0b15051541ab6546e4e2a6f693b4f912cb3f7d6a65e92c",
282
286
  "spec/v2/ext/webResearch/README.md": "b675e6f6646128d781b661f754c2393eed86e5935d16c3a22df582a5ce025b9b",
283
287
  "spec/v2/facets/a2a.schema.json": "b68bf5e29e455e46c2ad7cfcccf5963730ec2310ff9db81884550d8df30fd6ef",
284
- "spec/v2/facets/agents.schema.json": "88fe144572c25e1b09b99fd83e2ad12b2611d4d7366b5d53c3a50d0310faad93",
288
+ "spec/v2/facets/agents.schema.json": "0eb1172ea140607cb46d164af8bf405b32f5b47aa54baac7844e2c4161290025",
285
289
  "spec/v2/facets/aiProviders.schema.json": "6e4327bcd48110686cc1adfd26ddd6fbbf4ff529c476dcc74eeff8c5ae9965ce",
286
290
  "spec/v2/facets/auditLogIntegrity.schema.json": "9a7f81320c8f940c6f4129bc7dd895a4063231c5458a717a08c2cbf9bb886c46",
287
291
  "spec/v2/facets/auth.schema.json": "7d98bc81a0f7d307460d039eebfebd21388091bfe1d538762583940f94c0bd2a",
@@ -303,20 +307,20 @@
303
307
  "spec/v2/facets/uiPlugins.schema.json": "1fd3f8440cfb6e4422553a7c4b5f091eb3e10a40da0d43e8b977a18929116758",
304
308
  "spec/v2/facets/webhooks.schema.json": "54b576f1d150e28e1642693d846ce471c118e9c521bdf3924185305fbd1d0de4",
305
309
  "spec/v2/facets/workflowChainPacks.schema.json": "ea625ce818fbeb746478f00f31f96ee9613d538e4409c258c1bfb517acdeaba9",
306
- "spec/v2/generated/error-codes.md": "eafb66f1632da783c9a0347a416ae32bcf322f41b9f8783375f579791e9cdcf0",
310
+ "spec/v2/generated/error-codes.md": "e8441e944d2a8856a9112c1de8175e5bfae8bb36b8f389dce9dfa838702c21b6",
307
311
  "spec/v2/harness-trust-anchors.json": "6007b92eae16a070fbc3777424efb0abfe865fda89d096963109b96282b91205",
308
312
  "spec/v2/harness-trust-anchors.schema.json": "372b5d92337c893d3449eff3f6bfd009ac09fdac285296d8bdbe0a08fff9e34c",
309
- "spec/v2/id-field-bindings.json": "aa3e7c3590a490a77286e3ae60c1ab7407e7d627e05f981c3e08f221af4bd5dc",
313
+ "spec/v2/id-field-bindings.json": "97e96b6c35c405d0408b5a877c5859714dbbf4507853de9aab381163371ac098",
310
314
  "spec/v2/interop-map.json": "02255dafe7eb55f61ca7f9ae7c21f079d1848a6756991343088975ac9d184b8d",
311
315
  "spec/v2/interop-map.schema.json": "0017c882df34ee215e2e0729de051490e09d43aac3d2c949285698ed51832f12",
312
316
  "spec/v2/migrations.json": "651954938a1e154c0b6f1e3dd6e105889d30de3bb65789f21de7f77a12869e9e",
313
317
  "spec/v2/migrations.schema.json": "b91ade6320ad1b1185c9495b9424f3aa63438ed8e412c45171c227d82c0afd86",
314
- "spec/v2/path-manifest.json": "7b5679a4dad98c80d855361d88a98c8a4e045af15e310862a05b285b815dc9fc",
318
+ "spec/v2/path-manifest.json": "20f598f56df460ad3ed50aba3d6bc2933ea16a3c96ce125da1c5dd6989d4155c",
315
319
  "spec/v2/peer-dependency-aliases.json": "d10299280abee08258502925bc327293ee413e0108cd6e6ec75ff6110653308d",
316
320
  "spec/v2/profiles.json": "b382b261c7844798575f7c1b37c934f0da19846016a2337d883d4f5ef6e05479",
317
- "spec/v2/release.json": "a8b6d20a4ed90b21b4f9feb57a9c2ec5fdb13c74a74f857e5e3aec5ee8e75e40",
321
+ "spec/v2/release.json": "a777b332ad7a5ed3c721a42be6fd72b8cc30eaba7e1673d86f1249aca26772e0",
318
322
  "spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
319
- "spec/v2/surface-baseline.json": "c5aee6bbf8011e11351a4f9e0aafaf444d60af55116c543f04b55b88878cdf83"
323
+ "spec/v2/surface-baseline.json": "ed538138c27c16b2d16618fe3cf96737e285f00f5b19710c61a89e33364d050d"
320
324
  },
321
- "corpusCommit": "3ed6fc93890b4d00c44aafc0fe3ce75a24b8dcc7"
325
+ "corpusCommit": "d2f718e938cc2f7e68ce050376e563749a5af995"
322
326
  }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * RFC 0249 — pure judges for `v2-proposal-lifecycle.test.ts`. The scenario
3
+ * drives one client-submitted `prompt-template` proposal through submit →
4
+ * revise → apply and records every response; these functions decide each leg
5
+ * from those records, so the self-tests can fail every wrong host answer
6
+ * without a host.
7
+ *
8
+ * The rules are the `agents.proposals` facet description
9
+ * (`spec/v2/facets/agents.schema.json`), `proposal.schema.json`, the proposal
10
+ * operations in `api/v2/openapi.yaml` and `spec/v2/errors.json`. They make
11
+ * RFC 0096's `proposal-inert-until-applied` and `proposal-no-resynthesis`
12
+ * observable for the one kind with a v2 read-back.
13
+ *
14
+ * @see RFCS/0249-proposal-operations-and-install-readback.md
15
+ */
16
+
17
+ import { createHash } from 'node:crypto';
18
+ import { canonicalJSON } from './jcs.js';
19
+ import { readErrorCode } from './error-envelope.js';
20
+
21
+ export interface ProposalFinding { readonly ok: boolean; readonly message: string }
22
+
23
+ type Rec = Record<string, unknown>;
24
+ /** One recorded HTTP exchange. `null` means the request never got an answer. */
25
+ export type Exchange = { readonly status: number; readonly json: unknown } | null;
26
+
27
+ const isRec = (v: unknown): v is Rec => typeof v === 'object' && v !== null && !Array.isArray(v);
28
+ const f = (ok: boolean, message: string): ProposalFinding => ({ ok, message });
29
+ // The v2 envelope is flat: `{ error: "<code>", message, details? }`
30
+ // (`error-envelope.schema.json`). `readErrorCode` reads it (and tolerates the
31
+ // legacy nested form); `details` sits at the root.
32
+ const code = (x: Exchange): string | undefined => readErrorCode(x?.json);
33
+ const details = (x: Exchange): Rec => {
34
+ const body = isRec(x?.json) ? x.json : {};
35
+ if (isRec(body['details'])) return body['details'];
36
+ const nested = body['error'];
37
+ return isRec(nested) && isRec(nested['details']) ? nested['details'] : {};
38
+ };
39
+ const shown = (x: Exchange): string => (x === null ? 'no answer' : `${x.status}${code(x) ? ` ${code(x)}` : ''}`);
40
+
41
+ /** `sha256:` + lowercase hex SHA-256 of the RFC 8785 (JCS) serialization. */
42
+ export function artifactDigest(artifact: unknown): string {
43
+ return `sha256:${createHash('sha256').update(canonicalJSON(artifact), 'utf8').digest('hex')}`;
44
+ }
45
+
46
+ /** The kind's host-owned fields removed (for `prompt-template`, `meta`). */
47
+ export function withoutHostOwned(body: unknown): unknown {
48
+ if (!isRec(body)) return body;
49
+ const { meta: _meta, ...rest } = body;
50
+ return rest;
51
+ }
52
+
53
+ /** JCS equality after removing host-owned fields from both sides. */
54
+ export function byteMatches(served: unknown, artifact: unknown): boolean {
55
+ return canonicalJSON(withoutHostOwned(served)) === canonicalJSON(withoutHostOwned(artifact));
56
+ }
57
+
58
+ /** A fresh, author-minted template id the host has never seen. */
59
+ export function freshTemplate(nonce: string, text: string): Rec {
60
+ return { templateId: `conformance.proposal.${nonce}`, version: '1.0.0', kind: 'system', text };
61
+ }
62
+
63
+ /** A submitted kind outside `artifactKinds` is `400 validation_error` naming `kind`. */
64
+ export function judgeSubmitKindRefused(x: Exchange): ProposalFinding[] {
65
+ return [
66
+ f(x?.status === 400 && code(x) === 'validation_error', `a kind outside artifactKinds MUST be refused 400 validation_error (got ${shown(x)})`),
67
+ f(details(x)['field'] === 'kind', `the refusal MUST name details.field "kind" (got ${JSON.stringify(details(x)['field'])})`),
68
+ ];
69
+ }
70
+
71
+ /** The draft's template is not served, listed or renderable while unapplied. */
72
+ export function judgeInert(get: Exchange, list: Exchange, render: Exchange, templateId: string, when: string): ProposalFinding[] {
73
+ const items = isRec(list?.json) && Array.isArray(list.json['items']) ? (list.json['items'] as unknown[]).filter(isRec) : null;
74
+ return [
75
+ f(get?.status === 404, `${when}: getPromptTemplate MUST NOT serve the unapplied draft's template (got ${shown(get)})`),
76
+ f(list?.status === 200 && items !== null, `${when}: listPromptTemplates MUST answer 200 with items (got ${shown(list)})`),
77
+ f(items !== null && !items.some((t) => t['templateId'] === templateId), `${when}: listPromptTemplates MUST NOT list the unapplied draft's template`),
78
+ f(render !== null && render.status >= 400, `${when}: :render naming the unapplied draft's template MUST fail as for an unknown template (got ${shown(render)})`),
79
+ ];
80
+ }
81
+
82
+ /** An apply the caller is not scoped for installs nothing and changes no state. */
83
+ export function judgeApplyUnscoped(apply: Exchange, after: Exchange, prompt: Exchange, wwwAuthenticate: string | null): ProposalFinding[] {
84
+ const state = isRec(after?.json) ? after.json['state'] : undefined;
85
+ return [
86
+ f(apply?.status === 403, `an apply without proposals:apply MUST be refused 403 (got ${shown(apply)})`),
87
+ f(typeof wwwAuthenticate === 'string' && /error="insufficient_scope"/.test(wwwAuthenticate), `the 403 MUST carry error="insufficient_scope" (got ${wwwAuthenticate ?? 'no WWW-Authenticate'})`),
88
+ f(state === 'revised', `a refused apply MUST leave state unchanged (got ${JSON.stringify(state)})`),
89
+ f(prompt?.status === 404, `a refused apply MUST install nothing (getPromptTemplate ${shown(prompt)})`),
90
+ ];
91
+ }
92
+
93
+ /** An apply bound to bytes the reviewer no longer holds is `409 proposal_stale`. */
94
+ export function judgeApplyStale(apply: Exchange, after: Exchange, prompt: Exchange): ProposalFinding[] {
95
+ const state = isRec(after?.json) ? after.json['state'] : undefined;
96
+ return [
97
+ f(apply?.status === 409 && code(apply) === 'proposal_stale', `an apply whose artifactDigest is not the persisted artifact's MUST be refused 409 proposal_stale (got ${shown(apply)})`),
98
+ f(state === 'revised', `a stale apply MUST leave state unchanged (got ${JSON.stringify(state)})`),
99
+ f(prompt?.status === 404, `a stale apply MUST install nothing (getPromptTemplate ${shown(prompt)})`),
100
+ ];
101
+ }
102
+
103
+ /** The revision is persisted, and the served digest is the revised artifact's. */
104
+ export function judgeRevised(revise: Exchange, revised: Rec): ProposalFinding[] {
105
+ const p = isRec(revise?.json) ? revise.json : {};
106
+ return [
107
+ f(revise?.status === 200 && p['state'] === 'revised', `a revise MUST answer 200 with state revised (got ${shown(revise)}, state ${JSON.stringify(p['state'])})`),
108
+ f(p['artifactDigest'] === artifactDigest(revised), `artifactDigest MUST be sha256 over the JCS of the revised artifact (got ${JSON.stringify(p['artifactDigest'])})`),
109
+ ];
110
+ }
111
+
112
+ /** A successful apply names the read that serves the installed artifact. */
113
+ export function judgeApplied(apply: Exchange, templateId: string): ProposalFinding[] {
114
+ const p = isRec(apply?.json) ? apply.json : {};
115
+ const act = isRec(p['activation']) ? p['activation'] : {};
116
+ const ia = isRec(act['installedArtifact']) ? act['installedArtifact'] : {};
117
+ return [
118
+ f(apply?.status === 200 && p['state'] === 'applied', `an authorized, current apply MUST answer 200 with state applied (got ${shown(apply)}, state ${JSON.stringify(p['state'])})`),
119
+ f(ia['kind'] === 'prompt-template' && ia['operationId'] === 'getPromptTemplate', `activation.installedArtifact MUST name kind prompt-template and operationId getPromptTemplate (got ${JSON.stringify(ia)})`),
120
+ f(ia['id'] === templateId, `installedArtifact.id MUST be the artifact's templateId, never a minted one (got ${JSON.stringify(ia['id'])})`),
121
+ ];
122
+ }
123
+
124
+ /** The installed bytes are the revised artifact's, not the original's. */
125
+ export function judgeReadback(served: Exchange, revised: Rec, original: Rec): ProposalFinding[] {
126
+ const body = served?.json;
127
+ return [
128
+ f(served?.status === 200, `getPromptTemplate MUST serve the installed template after apply (got ${shown(served)})`),
129
+ f(served?.status === 200 && byteMatches(body, revised), 'the read-back minus meta MUST be JCS-equal to the last-persisted (revised) artifact minus meta'),
130
+ f(served?.status === 200 && !byteMatches(body, original), 'the read-back MUST NOT be the pre-revision artifact: that is a silent re-synthesis'),
131
+ ];
132
+ }
133
+
134
+ /** Revise and apply on an applied (terminal) proposal are `409 proposal_state_conflict`. */
135
+ export function judgeTerminal(revise: Exchange, apply: Exchange): ProposalFinding[] {
136
+ return [revise, apply].flatMap((x, i) => {
137
+ const action = i === 0 ? 'revise' : 'apply';
138
+ return [
139
+ f(x?.status === 409 && code(x) === 'proposal_state_conflict', `${action} on an applied proposal MUST be refused 409 proposal_state_conflict (got ${shown(x)})`),
140
+ f(details(x)['state'] === 'applied', `the ${action} refusal MUST name details.state "applied" (got ${JSON.stringify(details(x)['state'])})`),
141
+ ];
142
+ });
143
+ }
144
+
145
+ /** Another tenant sees the proposal and the installed template as ids that do not exist. */
146
+ export function judgeForeign(pairs: ReadonlyArray<{ what: string; foreign: Exchange; unknown: Exchange }>): ProposalFinding[] {
147
+ return pairs.flatMap(({ what, foreign, unknown }) => [
148
+ f(foreign?.status === 404, `${what} as another tenant MUST answer 404 (got ${shown(foreign)})`),
149
+ f(foreign?.status === unknown?.status && code(foreign) === code(unknown), `${what} as another tenant MUST answer exactly as an unknown id (foreign ${shown(foreign)}, unknown ${shown(unknown)}) — otherwise it is an existence oracle`),
150
+ ]);
151
+ }
@@ -0,0 +1,275 @@
1
+ /**
2
+ * v2 — reviewable-learning proposals are inert until applied, and what is
3
+ * installed is what was reviewed (RFC 0249, making RFC 0096 observable; the
4
+ * rules are the `agents.proposals` facet description in
5
+ * `spec/v2/facets/agents.schema.json`, `proposal.schema.json`, the proposal
6
+ * operations in `api/v2/openapi.yaml` and `spec/v2/errors.json`).
7
+ *
8
+ * Server-free (always on):
9
+ * 1. `0249.shape` — the submit / revise / apply bodies and the closed
10
+ * `installedArtifact` validate, and their negatives are refused.
11
+ *
12
+ * Host legs — gated on `agents.proposals` with `clientDrafts: true` and
13
+ * `readback` ∋ `prompt-template`. One client-submitted `prompt-template`
14
+ * proposal with a fresh `templateId` is driven once per worker:
15
+ * submit T → revise to T' → stale apply → unscoped apply → foreign reads →
16
+ * apply → read back → terminal refusals. Judges in
17
+ * `lib/proposal-lifecycle-witness.ts`.
18
+ * 2. `0249.submit-kind-refused` — a kind outside `artifactKinds` is `400`.
19
+ * 3. `0249.draft-inert` — the draft's template is not served, listed or
20
+ * renderable (`proposal-inert-until-applied`).
21
+ * 4. `0249.apply-stale-refused` — the revision's digest is served, and an
22
+ * apply bound to the pre-revision digest is `409 proposal_stale`.
23
+ * 5. `0249.apply-unscoped-refused` — `OPENWOP_TEST_LOW_SCOPE_KEY` (read +
24
+ * write, no `proposals:apply`) is refused `403 insufficient_scope`.
25
+ * 6. `0249.apply-installs` — the apply names `getPromptTemplate` and the
26
+ * artifact's own `templateId`.
27
+ * 7. `0249.readback-byte-match` — the installed template minus `meta` is
28
+ * JCS-equal to T' and not to T (`proposal-no-resynthesis`).
29
+ * 8. `0249.terminal-refused` — revise and apply on the applied proposal are
30
+ * `409 proposal_state_conflict`.
31
+ * 9. `0249.cross-tenant-not-found` — `OPENWOP_TEST_TENANT_B_API_KEY` sees the
32
+ * proposal and the installed template exactly as unknown ids.
33
+ *
34
+ * A harness key that is unset records `blocked` (the `v2-auth-challenge`
35
+ * precedent): the suite will not infer a credential. The unadvertised case is
36
+ * in `v2-unadvertised-operation-not-found`.
37
+ */
38
+
39
+ import { describe, it, expect } from 'vitest';
40
+ import { randomBytes } from 'node:crypto';
41
+ import { driver, type OpenWOPRequestInit } from '../lib/driver.js';
42
+ import { v2Discovery, v2Validator, v2RefValidator } from '../lib/v2.js';
43
+ import { softSkip } from '../lib/soft-skip.js';
44
+ import { req } from '../lib/requirement-ids.js';
45
+ import {
46
+ artifactDigest, freshTemplate, judgeApplied, judgeApplyStale, judgeApplyUnscoped, judgeForeign, judgeInert,
47
+ judgeReadback, judgeRevised, judgeSubmitKindRefused, judgeTerminal, type Exchange, type ProposalFinding,
48
+ } from '../lib/proposal-lifecycle-witness.js';
49
+
50
+ const DOC = 'agents.schema.json §proposals, proposal.schema.json, api/v2 proposals operations (RFC 0249)';
51
+ const UNREADABLE = 'v2 discovery unreachable — /.well-known/openwop did not answer 200 with a JSON body under OpenWOP-Version: 2.0';
52
+ const KINDS = ['agent-pack', 'workflow-chain-pack', 'prompt-template', 'automation'] as const;
53
+ const ID_SHAPE = 'openwop.requirement.0249.shape';
54
+ const ID_SUBMIT_KIND_REFUSED = 'openwop.requirement.0249.submit-kind-refused';
55
+ const ID_DRAFT_INERT = 'openwop.requirement.0249.draft-inert';
56
+ const ID_APPLY_STALE_REFUSED = 'openwop.requirement.0249.apply-stale-refused';
57
+ const ID_APPLY_UNSCOPED_REFUSED = 'openwop.requirement.0249.apply-unscoped-refused';
58
+ const ID_APPLY_INSTALLS = 'openwop.requirement.0249.apply-installs';
59
+ const ID_READBACK_BYTE_MATCH = 'openwop.requirement.0249.readback-byte-match';
60
+ const ID_TERMINAL_REFUSED = 'openwop.requirement.0249.terminal-refused';
61
+ const ID_CROSS_TENANT_NOT_FOUND = 'openwop.requirement.0249.cross-tenant-not-found';
62
+
63
+ type Rec = Record<string, unknown>;
64
+ const isRec = (v: unknown): v is Rec => typeof v === 'object' && v !== null && !Array.isArray(v);
65
+
66
+ interface Answer { readonly x: Exchange; readonly wwwAuthenticate: string | null }
67
+ async function ask(method: string, path: string, body?: unknown, bearer?: string): Promise<Answer> {
68
+ const init: OpenWOPRequestInit = {
69
+ ...(body === undefined ? {} : { body }),
70
+ ...(bearer === undefined ? {} : { authenticated: false, headers: { Authorization: `Bearer ${bearer}` } }),
71
+ };
72
+ try {
73
+ const r = await driver.request(method, path, init);
74
+ return { x: { status: r.status, json: r.json }, wwwAuthenticate: r.headers.get('www-authenticate') };
75
+ } catch {
76
+ return { x: null, wwwAuthenticate: null };
77
+ }
78
+ }
79
+
80
+ interface Facet { readonly doc: Rec | null; readonly facet: Rec | null; readonly renderPath: string }
81
+ async function proposalsFacet(): Promise<Facet> {
82
+ let doc: Rec | null;
83
+ try { doc = await v2Discovery(); } catch { doc = null; }
84
+ const agents = doc?.['agents'];
85
+ const facet = isRec(agents) && isRec(agents['proposals']) ? agents['proposals'] : null;
86
+ const prompts = doc?.['prompts'];
87
+ const renderPath = isRec(prompts) && typeof prompts['renderEndpoint'] === 'string' ? prompts['renderEndpoint'] : '/prompts:render';
88
+ return { doc, facet, renderPath };
89
+ }
90
+
91
+ type Skip = { skip: [Parameters<typeof softSkip>[0], string] };
92
+ async function gate(): Promise<Skip | { facet: Rec; renderPath: string }> {
93
+ const { doc, facet, renderPath } = await proposalsFacet();
94
+ if (!doc) return { skip: ['blocked', UNREADABLE] };
95
+ if (facet === null) return { skip: ['inapplicable', 'the host does not advertise agents.proposals'] };
96
+ if (facet['clientDrafts'] !== true) return { skip: ['inapplicable', 'the host does not advertise agents.proposals.clientDrafts'] };
97
+ const rb = Array.isArray(facet['readback']) ? facet['readback'] : [];
98
+ const kinds = Array.isArray(facet['artifactKinds']) ? facet['artifactKinds'] : [];
99
+ if (!rb.includes('prompt-template') || !kinds.includes('prompt-template')) return { skip: ['inapplicable', 'the host does not advertise prompt-template in both agents.proposals.artifactKinds and readback'] };
100
+ return { facet, renderPath };
101
+ }
102
+
103
+ interface Lifecycle {
104
+ readonly blockedAt?: string;
105
+ readonly t: Rec; readonly t2: Rec; readonly templateId: string;
106
+ readonly inert?: { get: Exchange; list: Exchange; render: Exchange };
107
+ readonly revise?: Exchange;
108
+ readonly stale?: { apply: Exchange; after: Exchange; prompt: Exchange };
109
+ readonly unscoped?: { apply: Exchange; after: Exchange; prompt: Exchange; www: string | null } | string;
110
+ readonly foreignProposal?: Array<{ what: string; foreign: Exchange; unknown: Exchange }> | string;
111
+ readonly apply?: Exchange;
112
+ readonly readback?: Exchange;
113
+ readonly foreignTemplate?: { what: string; foreign: Exchange; unknown: Exchange } | string;
114
+ readonly terminal?: { revise: Exchange; apply: Exchange };
115
+ }
116
+
117
+ let run: Promise<Lifecycle> | undefined;
118
+ /** One lifecycle per worker: every host leg judges a slice of the same records. */
119
+ function lifecycle(renderPath: string): Promise<Lifecycle> {
120
+ run ??= drive(renderPath);
121
+ return run;
122
+ }
123
+
124
+ async function drive(renderPath: string): Promise<Lifecycle> {
125
+ const nonce = randomBytes(6).toString('hex');
126
+ const t = freshTemplate(nonce, 'You summarize the input in one sentence.');
127
+ const t2 = { ...t, text: 'You summarize the input in exactly one short sentence.' };
128
+ const templateId = t['templateId'] as string;
129
+ const prompt = `/prompts/${encodeURIComponent(templateId)}`;
130
+ const base = { t, t2, templateId };
131
+
132
+ const submit = (await ask('POST', '/proposals', { kind: 'prompt-template', artifact: t, title: 'conformance proposal' })).x;
133
+ const pid = isRec(submit?.json) && typeof submit.json['id'] === 'string' ? submit.json['id'] : undefined;
134
+ if (submit?.status !== 201 || pid === undefined) return { ...base, blockedAt: `submitProposal answered ${submit?.status ?? 'nothing'}; no proposal to drive` };
135
+ const path = `/proposals/${encodeURIComponent(pid)}`;
136
+ const getP = async (bearer?: string): Promise<Exchange> => (await ask('GET', path, undefined, bearer)).x;
137
+
138
+ const inert = {
139
+ get: (await ask('GET', prompt)).x,
140
+ list: (await ask('GET', '/prompts')).x,
141
+ render: (await ask('POST', renderPath, { ref: `prompt:${templateId}@1.0.0`, variables: {} })).x,
142
+ };
143
+ const revise = (await ask('PATCH', path, { artifact: t2 })).x;
144
+ if (revise?.status !== 200) return { ...base, inert, revise, blockedAt: `reviseProposal answered ${revise?.status ?? 'nothing'}` };
145
+
146
+ const staleApply = (await ask('POST', `${path}:apply`, { artifactDigest: artifactDigest(t) })).x;
147
+ const stale = { apply: staleApply, after: await getP(), prompt: (await ask('GET', prompt)).x };
148
+
149
+ const low = process.env['OPENWOP_TEST_LOW_SCOPE_KEY']?.trim();
150
+ let unscoped: Lifecycle['unscoped'];
151
+ if (!low) unscoped = 'OPENWOP_TEST_LOW_SCOPE_KEY (a key with proposals:read and proposals:write but not proposals:apply) is not set';
152
+ else {
153
+ const a = await ask('POST', `${path}:apply`, { artifactDigest: artifactDigest(t2) }, low);
154
+ unscoped = { apply: a.x, www: a.wwwAuthenticate, after: await getP(), prompt: (await ask('GET', prompt)).x };
155
+ }
156
+
157
+ const tenantB = process.env['OPENWOP_TEST_TENANT_B_API_KEY']?.trim();
158
+ const unknownPid = `/proposals/conformance-unknown-${nonce}`;
159
+ let foreignProposal: Lifecycle['foreignProposal'];
160
+ if (!tenantB) foreignProposal = 'OPENWOP_TEST_TENANT_B_API_KEY (a credential bound to a second tenant) is not set';
161
+ else if (tenantB === process.env['OPENWOP_API_KEY']) foreignProposal = 'OPENWOP_TEST_TENANT_B_API_KEY equals OPENWOP_API_KEY — the leg needs a second tenant';
162
+ else {
163
+ foreignProposal = [
164
+ { what: 'getProposal', foreign: await getP(tenantB), unknown: (await ask('GET', unknownPid, undefined, tenantB)).x },
165
+ { what: 'applyProposal', foreign: (await ask('POST', `${path}:apply`, { artifactDigest: artifactDigest(t2) }, tenantB)).x,
166
+ unknown: (await ask('POST', `${unknownPid}:apply`, { artifactDigest: artifactDigest(t2) }, tenantB)).x },
167
+ ];
168
+ }
169
+
170
+ const apply = (await ask('POST', `${path}:apply`, { artifactDigest: artifactDigest(t2) })).x;
171
+ const readback = (await ask('GET', prompt)).x;
172
+ const foreignTemplate = typeof foreignProposal === 'string' ? foreignProposal : {
173
+ what: 'getPromptTemplate for the installed template',
174
+ foreign: (await ask('GET', prompt, undefined, tenantB)).x,
175
+ unknown: (await ask('GET', `/prompts/conformance.proposal.unknown-${nonce}`, undefined, tenantB)).x,
176
+ };
177
+ const terminal = { revise: (await ask('PATCH', path, { title: 'after apply' })).x, apply: (await ask('POST', `${path}:apply`, { artifactDigest: artifactDigest(t2) })).x };
178
+ return { ...base, inert, revise, stale, unscoped, foreignProposal, apply, readback, foreignTemplate, terminal };
179
+ }
180
+
181
+ function expectAll(id: string, findings: readonly ProposalFinding[]): void {
182
+ for (const x of findings) expect(x.ok, req(id, DOC, x.message)).toBe(true);
183
+ }
184
+
185
+ describe('v2 RFC 0249 — proposal wire shape (server-free)', () => {
186
+ it('the submit, revise and apply bodies and installedArtifact validate, and the negatives are refused', () => {
187
+ const submit = v2Validator('proposal-submit');
188
+ const revise = v2Validator('proposal-revise');
189
+ const apply = v2Validator('proposal-apply');
190
+ const installed = v2RefValidator('proposal.schema.json#/properties/activation/properties/installedArtifact');
191
+ const S = 'proposal-submit / proposal-revise / proposal-apply / proposal.schema.json (RFC 0249)';
192
+ expect(submit({ kind: 'prompt-template', artifact: { templateId: 'x' } }).ok, req(ID_SHAPE, S, 'a minimal submit MUST validate')).toBe(true);
193
+ expect(submit({ kind: 'skill', artifact: {} }).ok, req(ID_SHAPE, S, 'an unknown kind MUST be refused')).toBe(false);
194
+ expect(submit({ kind: 'prompt-template', artifact: {}, state: 'applied' }).ok, req(ID_SHAPE, S, 'a submit carrying state MUST be refused (closed body)')).toBe(false);
195
+ expect(revise({}).ok, req(ID_SHAPE, S, 'an empty revise MUST be refused')).toBe(false);
196
+ expect(apply({ artifactDigest: `sha256:${'a'.repeat(64)}` }).ok, req(ID_SHAPE, S, 'a sha256 digest MUST validate')).toBe(true);
197
+ expect(apply({ artifactDigest: 'abc123' }).ok, req(ID_SHAPE, S, 'a digest without the sha256: prefix and 64 hex MUST be refused')).toBe(false);
198
+ expect(installed({ kind: 'prompt-template', operationId: 'getPromptTemplate', id: 't' }).ok, req(ID_SHAPE, S, 'a minimal installedArtifact MUST validate')).toBe(true);
199
+ expect(installed({ kind: 'prompt-template', operationId: 'getPromptTemplate', id: 't', url: 'https://x' }).ok, req(ID_SHAPE, S, 'installedArtifact is closed')).toBe(false);
200
+ });
201
+ });
202
+
203
+ describe('v2 RFC 0249 — proposal lifecycle (host)', () => {
204
+ it('a kind outside artifactKinds is refused at submit', async () => {
205
+ const g = await gate();
206
+ if ('skip' in g) return softSkip(...g.skip);
207
+ const kinds = Array.isArray(g.facet['artifactKinds']) ? g.facet['artifactKinds'] : [];
208
+ const missing = KINDS.find((k) => !kinds.includes(k));
209
+ if (missing === undefined) return softSkip('inapplicable', 'the host advertises every proposal kind, so no kind is outside artifactKinds');
210
+ expectAll(ID_SUBMIT_KIND_REFUSED, judgeSubmitKindRefused((await ask('POST', '/proposals', { kind: missing, artifact: {} })).x));
211
+ });
212
+
213
+ it('a draft prompt-template is not served, listed or renderable', async () => {
214
+ const g = await gate();
215
+ if ('skip' in g) return softSkip(...g.skip);
216
+ const l = await lifecycle(g.renderPath);
217
+ if (l.inert === undefined) return softSkip('blocked', l.blockedAt ?? 'the lifecycle did not reach the draft');
218
+ expectAll(ID_DRAFT_INERT, judgeInert(l.inert.get, l.inert.list, l.inert.render, l.templateId, 'draft'));
219
+ });
220
+
221
+ it('a revision re-digests, and an apply bound to the old bytes is stale', async () => {
222
+ const g = await gate();
223
+ if ('skip' in g) return softSkip(...g.skip);
224
+ const l = await lifecycle(g.renderPath);
225
+ if (l.stale === undefined || l.revise === undefined) return softSkip('blocked', l.blockedAt ?? 'the lifecycle did not reach the revision');
226
+ expectAll(ID_APPLY_STALE_REFUSED, [...judgeRevised(l.revise, l.t2), ...judgeApplyStale(l.stale.apply, l.stale.after, l.stale.prompt)]);
227
+ });
228
+
229
+ it('an apply without proposals:apply is refused and installs nothing', async () => {
230
+ const g = await gate();
231
+ if ('skip' in g) return softSkip(...g.skip);
232
+ if (g.facet['activation'] !== 'direct-rbac') return softSkip('inapplicable', 'agents.proposals.activation is not direct-rbac — the apply is gated by an approval run');
233
+ const l = await lifecycle(g.renderPath);
234
+ if (l.unscoped === undefined) return softSkip('blocked', l.blockedAt ?? 'the lifecycle did not reach the apply');
235
+ if (typeof l.unscoped === 'string') return softSkip('blocked', l.unscoped);
236
+ expectAll(ID_APPLY_UNSCOPED_REFUSED, judgeApplyUnscoped(l.unscoped.apply, l.unscoped.after, l.unscoped.prompt, l.unscoped.www));
237
+ });
238
+
239
+ it('an authorized, current apply names the read that serves the installed template', async () => {
240
+ const g = await gate();
241
+ if ('skip' in g) return softSkip(...g.skip);
242
+ if (g.facet['activation'] !== 'direct-rbac') return softSkip('inapplicable', 'agents.proposals.activation is not direct-rbac — the apply is gated by an approval run');
243
+ const l = await lifecycle(g.renderPath);
244
+ if (l.apply === undefined) return softSkip('blocked', l.blockedAt ?? 'the lifecycle did not reach the apply');
245
+ expectAll(ID_APPLY_INSTALLS, judgeApplied(l.apply, l.templateId));
246
+ });
247
+
248
+ it('the installed template is the reviewed revision, byte for byte', async () => {
249
+ const g = await gate();
250
+ if ('skip' in g) return softSkip(...g.skip);
251
+ if (g.facet['activation'] !== 'direct-rbac') return softSkip('inapplicable', 'agents.proposals.activation is not direct-rbac — the apply is gated by an approval run');
252
+ const l = await lifecycle(g.renderPath);
253
+ if (l.readback === undefined) return softSkip('blocked', l.blockedAt ?? 'the lifecycle did not reach the read-back');
254
+ expectAll(ID_READBACK_BYTE_MATCH, judgeReadback(l.readback, l.t2, l.t));
255
+ });
256
+
257
+ it('an applied proposal is terminal', async () => {
258
+ const g = await gate();
259
+ if ('skip' in g) return softSkip(...g.skip);
260
+ if (g.facet['activation'] !== 'direct-rbac') return softSkip('inapplicable', 'agents.proposals.activation is not direct-rbac — the apply is gated by an approval run');
261
+ const l = await lifecycle(g.renderPath);
262
+ if (l.terminal === undefined) return softSkip('blocked', l.blockedAt ?? 'the lifecycle did not reach the applied state');
263
+ expectAll(ID_TERMINAL_REFUSED, judgeTerminal(l.terminal.revise, l.terminal.apply));
264
+ });
265
+
266
+ it('another tenant sees the proposal and the installed template as unknown ids', async () => {
267
+ const g = await gate();
268
+ if ('skip' in g) return softSkip(...g.skip);
269
+ const l = await lifecycle(g.renderPath);
270
+ if (l.foreignProposal === undefined) return softSkip('blocked', l.blockedAt ?? 'the lifecycle did not reach the foreign reads');
271
+ if (typeof l.foreignProposal === 'string') return softSkip('blocked', l.foreignProposal);
272
+ const pairs = [...l.foreignProposal, ...(l.foreignTemplate !== undefined && typeof l.foreignTemplate !== 'string' ? [l.foreignTemplate] : [])];
273
+ expectAll(ID_CROSS_TENANT_NOT_FOUND, judgeForeign(pairs));
274
+ });
275
+ });
@@ -14,6 +14,8 @@
14
14
  * `404 not_found`.
15
15
  * content (hosts that do not advertise `content`) `GET /content/settings`
16
16
  * answers `404 not_found`.
17
+ * proposals (hosts that do not advertise `agents.proposals`) `GET /proposals`
18
+ * answers `404 not_found` (RFC 0249).
17
19
  *
18
20
  * A host that advertises the facet records the leg `inapplicable`.
19
21
  */
@@ -61,4 +63,15 @@ describe('v2 unadvertised operations answer 404 not_found (RFC 0228 §H)', () =>
61
63
  expect(res.status, req(ID, DOC, `an operation gated on content, which the host does not advertise, MUST answer 404 (got ${res.status}; v1's 501 is not a v2 answer)`)).toBe(404);
62
64
  expect(readErrorCode(res.json), req(ID, DOC, 'the 404 for an unadvertised gated operation MUST carry the registered code not_found')).toBe('not_found');
63
65
  });
66
+ it('GET /proposals answers 404 not_found when agents.proposals is not advertised (RFC 0249)', async () => {
67
+ const doc = await discovery();
68
+ if (!doc) return softSkip('blocked', UNREACHABLE);
69
+ const agents = record(doc, 'agents');
70
+ if (agents !== null && record(agents, 'proposals') !== null) return softSkip('inapplicable', 'the host advertises agents.proposals — the /proposals surface is served');
71
+ const res = await get('/proposals');
72
+ if (res === null) return softSkip('blocked', 'GET /proposals unreachable (fetch failed)');
73
+ const ID = 'openwop.requirement.0249.unadvertised-not-found';
74
+ expect(res.status, req(ID, DOC, `an operation gated on agents.proposals, which the host does not advertise, MUST answer 404 (got ${res.status})`)).toBe(404);
75
+ expect(readErrorCode(res.json), req(ID, DOC, 'the 404 for an unadvertised gated operation MUST carry the registered code not_found')).toBe('not_found');
76
+ });
64
77
  });