@openwop/openwop-conformance 2.46.44 → 2.48.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 +11 -0
- package/README.md +2 -2
- package/dist/spec-artifacts.lock.json +2 -2
- package/fixtures/conformance-compensate-unwind.json +70 -0
- package/fixtures/conformance-llm-cache-key.json +23 -0
- package/fixtures.md +20 -0
- package/package.json +2 -2
- package/requirements.json +538 -6
- package/scenario-majors.json +11 -2
- package/schemas/CORPUS-STAMP.json +22 -19
- package/src/lib/compensation-unwind.ts +173 -0
- package/src/lib/llm-cache-key-2.ts +114 -0
- package/src/scenarios/v2-compensation-unwind.test.ts +244 -0
- package/src/scenarios/v2-replay-llm-cache-key.test.ts +160 -0
- package/src/scenarios/v2-telemetry-export.test.ts +181 -0
|
@@ -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.
|
|
5
|
-
"corpusTag": "v2.
|
|
4
|
+
"version": "2.48.0",
|
|
5
|
+
"corpusTag": "v2.48.0",
|
|
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": "
|
|
13
|
-
"api/v2/asyncapi.yaml": "
|
|
14
|
-
"api/v2/openapi.yaml": "
|
|
12
|
+
"api/seams-v2.yaml": "61037c7c1d7f9859c9f39c34d6c8e2c65865036dab2d8bd4c5d8e5ff45bbb260",
|
|
13
|
+
"api/v2/asyncapi.yaml": "0d893e632e7dd72c11e17b38ec19907652265b88bc023a79ac4cbc3992ae8aea",
|
|
14
|
+
"api/v2/openapi.yaml": "fb704e969745a8886682c532f7de52f6abce67fa4bf1e58ac56a36c5e1bcd935",
|
|
15
15
|
"api/v2/redocly.yaml": "1e66b60e6118ad11a823bb620678be464d99dfe50a40e3e6f93ec9429b88b34c",
|
|
16
16
|
"schemas/README.md": "276fc1d51eb08e14e9127b9fd27761d7046ee33c12000f3a64a801322db8bf58",
|
|
17
17
|
"schemas/a2a-task-state.schema.json": "75d5049dea8bd873ff0e7546f1c60c8d36c219bec7084264360be54a8be30ae1",
|
|
@@ -115,14 +115,14 @@
|
|
|
115
115
|
"schemas/v2/artifact.schema.json": "85b9dc284d39b99d2a66fa8cbf47f2dd3d8526d3bd6cc411ad315b372de38975",
|
|
116
116
|
"schemas/v2/audit-verify-result.schema.json": "14af8e9283510f0a40e48a2059c929ee44e0fc9fa3cc66a95bfbe90b17c48297",
|
|
117
117
|
"schemas/v2/budget-policy.schema.json": "1de68095db0beb5c7a4be629de26e79f62ebbe748a88d46f36e768edfd1f0d68",
|
|
118
|
-
"schemas/v2/capabilities.schema.json": "
|
|
119
|
-
"schemas/v2/certification-bundle.schema.json": "
|
|
118
|
+
"schemas/v2/capabilities.schema.json": "afae0c548cf91a915c2badca77b7c89cd0c657f100c2cd1db64de896c8da2bf0",
|
|
119
|
+
"schemas/v2/certification-bundle.schema.json": "f9e233dc3584c3cef78809d518749ce37cd1ee79ebbfe58163dc67eeac7802b1",
|
|
120
120
|
"schemas/v2/channel-presence-payload.schema.json": "fc03df0c20cc12a25f7ee6a47503a4c5931d79fe3d5ff24815e8fd7c1bcb9a87",
|
|
121
121
|
"schemas/v2/channel-written-payload.schema.json": "ccecff3c71a3275ad8db035ac0e04db25e6f8d23cf091ddbcd5a60d1be1cbb6b",
|
|
122
122
|
"schemas/v2/chat-card-pack-manifest.schema.json": "9cf235f5d0fe662f34f7e72bfbf2a82e0743046059a9d5af7aec948e228fdb0e",
|
|
123
123
|
"schemas/v2/compact-tool-descriptor.schema.json": "b5c418d72082d5b713bf318212a83397e5be98e56d25ca056302b41b8dd8273b",
|
|
124
124
|
"schemas/v2/compensation-policy.schema.json": "b3c1142637f5bf980f2b0bb67adb47e8cb7abb99a0405a4b6816ccf5d9c089d6",
|
|
125
|
-
"schemas/v2/compensation-projection.schema.json": "
|
|
125
|
+
"schemas/v2/compensation-projection.schema.json": "6acb2385fab26f172eb69cdca4e0ae6a618c003cfabd89783d2d5cf028a9270e",
|
|
126
126
|
"schemas/v2/configurable.schema.json": "33a6884047e2490f723733bf8b74d3b9164e00374cfea6dc3eac5a70dc368861",
|
|
127
127
|
"schemas/v2/connection-pack-manifest.schema.json": "8d019de6bd0c5b14ea3b227580734ec792521493b396a850f7f9217aa7a6a547",
|
|
128
128
|
"schemas/v2/connection-provider-registry.schema.json": "5b404da328a95c45808929e233ee766ebd635cf0df6f5d15d4c19354dfd7e75d",
|
|
@@ -132,7 +132,7 @@
|
|
|
132
132
|
"schemas/v2/credential-reference.schema.json": "ea699ce30f58fb2c32d4279158b3f730f70be7b2dc8c3037693a76e66eb3e001",
|
|
133
133
|
"schemas/v2/debug-bundle.schema.json": "0ec1e0bca9c0a359cc6cf3448e5200a1c3d3d5b1e5efaa6605231477daaf3fee",
|
|
134
134
|
"schemas/v2/dispatch-config.schema.json": "27cb203d2a5cec90d8eb398569acbd8314ad9b8e231d1acfb8db4aa76f8d8f8b",
|
|
135
|
-
"schemas/v2/effect-ledger-projection.schema.json": "
|
|
135
|
+
"schemas/v2/effect-ledger-projection.schema.json": "ac1f577ec420ece9a5fbe74a33a54aa5bbe4d8259f3c5e9c2bd9b8901ed1c289",
|
|
136
136
|
"schemas/v2/effect-seam-manifest.schema.json": "197ef9944ab027a50d1b9352f317cbbc12c8ad955fdb14650fb90477052ffef0",
|
|
137
137
|
"schemas/v2/error-envelope.schema.json": "5d566a910b715452e99b32876426cd490ef803f2177e3ecbbd1051170d4d4462",
|
|
138
138
|
"schemas/v2/eval-summary.schema.json": "d07f4d116c5410997a81d0bc5387545fb61933a6333c360a26ab8a71c749336b",
|
|
@@ -203,14 +203,14 @@
|
|
|
203
203
|
"schemas/workspace-file.schema.json": "464de85c2a068243084ee9c1d969bc7cd5d8f7948574e58450d6493c38a0e1e4",
|
|
204
204
|
"spec/v1/alias-detectors.json": "202a443678e592a515e33aad7f6ab679a07d8408809f2d4b0f59d156c84ffc65",
|
|
205
205
|
"spec/v1/capability-declaration-classes.json": "e7729aed5c4b4e1dd02abab0530f14cc95f5d4070fe51fb139e7f5cccefa00c6",
|
|
206
|
-
"spec/v1/core-standard-manifest.json": "
|
|
206
|
+
"spec/v1/core-standard-manifest.json": "b7c589705ad3361c40620c3b796d8a4fe4e45a25776e736b046c33029a9dd55b",
|
|
207
207
|
"spec/v1/deprecations.json": "901fdab0a7212627f1838cbb06ae7b7204f0dff528299e56d3d938deecea27c1",
|
|
208
208
|
"spec/v1/deprecations.schema.json": "3e393c405d2a41b467d8c5e3c468549078df1ce6a6d2588fc488097b95d9b55b",
|
|
209
209
|
"spec/v1/end-of-support.json": "35f0bf3b70fe78b16e2a2fd81a153fc27e703f6c16376c6fe7cdf1f4f2c4e6d4",
|
|
210
210
|
"spec/v1/event-codemap.json": "3da60d884157793a360da532a9fcbbfb5285636db325a74cec94b34622186d97",
|
|
211
211
|
"spec/v1/event-codemap.schema.json": "d05933b2e88103aff51a2f774df97b9bda065e0fc88dbd9b114cc47a32189174",
|
|
212
212
|
"spec/v1/extensions.json": "79a60754aa16cbdbdb604f8e5c00038af535a1690a4d07bd5ff7c38f0256a7bc",
|
|
213
|
-
"spec/v1/gaps.json": "
|
|
213
|
+
"spec/v1/gaps.json": "e2b4a5721aefd6502137b16b55cd1a4edadd37e51029f25e455a44b7e29b3e06",
|
|
214
214
|
"spec/v1/gaps.schema.json": "8fd83259f556553c9df0f53e7a82ca8c2a4771d471197f69b4e2ae0ceeacfd66",
|
|
215
215
|
"spec/v1/migrations.json": "4c50760be70cb644d02427bcaf5749606700edf4513f423903cf3b1d12f5c300",
|
|
216
216
|
"spec/v1/migrations.schema.json": "886779aa6c22e646db097f5df210adb018a4dd14a7b815465a18c8a7056c8f72",
|
|
@@ -218,7 +218,7 @@
|
|
|
218
218
|
"spec/v1/spec-gaps.json": "ea9612af55bea9dae3e82223d4f714243f8f590b0193f1188257b13491a38afc",
|
|
219
219
|
"spec/v2/README.md": "c58acd84bb67c69043d9f777cbaa16d03e4f0f0a8158d2984526b69981067a14",
|
|
220
220
|
"spec/v2/core/artifact-type-packs.md": "ba5a258ac93b1aa4d74a4464c63a8f72dbbc5d24fd56f34a2c2ce9f464892799",
|
|
221
|
-
"spec/v2/core/capabilities.md": "
|
|
221
|
+
"spec/v2/core/capabilities.md": "2d94e45537693802032bd0750d05682f5a3f1e189fee2064f8fa969907d74271",
|
|
222
222
|
"spec/v2/core/conformance.md": "2e12b5fceb9cbd1bbf4a536310fe92a4cd44947e48d0466013327c5de202a2d9",
|
|
223
223
|
"spec/v2/core/connection-packs.md": "c80f95bd6a03698f35c72cdb2b46bd973f49203bbd786c26760e35fa86e6f23d",
|
|
224
224
|
"spec/v2/core/conversation.md": "8d6abedf6af408a74577753bfca24719d318e2632e10b45ff5d63bdcf31a8aaa",
|
|
@@ -239,17 +239,18 @@
|
|
|
239
239
|
"spec/v2/core/packs.md": "c46a3bbd8a3a8cfbb6476b88f90d234a45f724aa1f640c9ce6430ba05bacb069",
|
|
240
240
|
"spec/v2/core/persistence.md": "d1767bde0db8038bf6c4f8dc31c4900c67dbd77b4ccc382e99b9f29501b472a3",
|
|
241
241
|
"spec/v2/core/portability.md": "93b9cb8bb85448669df716effb7627236b2bb069f8c5ca7306e8971b9355abfd",
|
|
242
|
-
"spec/v2/core/replay.md": "
|
|
242
|
+
"spec/v2/core/replay.md": "de705ad72e4878def9a0e1d215df9020c129eabae5f224b8c9b3330bd3b48c47",
|
|
243
243
|
"spec/v2/core/runs.md": "f9c26490e52fa69a10c8e98c8a489e3bc8dfbe3effe334bb1793bc9b575f5efe",
|
|
244
|
-
"spec/v2/core/security-defaults.md": "
|
|
244
|
+
"spec/v2/core/security-defaults.md": "3ca0c9f973232521536024165de8815f80ad9774df5e5210c8e5bfdd4373b670",
|
|
245
245
|
"spec/v2/core/storage.md": "bfcc74526d0f24539188b1c5d1567d8510e80d0b6c39fdc4d19204fcd6fd1f85",
|
|
246
|
+
"spec/v2/core/telemetry.md": "5e2f30919c51195b57566a2a277663e72dffd1a56a68c19fb8b0d3260629ff7f",
|
|
246
247
|
"spec/v2/core/tool-catalog.md": "d16a9004418719c48301804aefa4d7ab6f41112a4ed83727f552aa1b92b418b0",
|
|
247
248
|
"spec/v2/core/versioning.md": "f25c64849e9fa7e3ddc976f33c502bbf0e3fdd158c5c2d5730d524b1f8a19bfe",
|
|
248
249
|
"spec/v2/core/webhooks.md": "861284396eb4f445f4b7bec210f07dd2c96d18084f8511371235b98617bf2b90",
|
|
249
250
|
"spec/v2/core/workflow-chain-packs.md": "fdce7a8477e269950a2bac05ee6eddbd66eb0b670b464d00054f7196ec05d29c",
|
|
250
251
|
"spec/v2/corrections.json": "5ee76d1ee87677a86d5d9ee2f477b88fc29f9f9d37611040b9ccb08f8f30fb2d",
|
|
251
252
|
"spec/v2/corrections.schema.json": "02f6a0a9531e657693be311af0f59d6432f3efcf645f56ccbf39e5b141e6b641",
|
|
252
|
-
"spec/v2/declaration.json": "
|
|
253
|
+
"spec/v2/declaration.json": "2d6415ee3b9a801d6a7c4de5ec1ab4d62441dded7b5a44ceb42be80f0a258ce9",
|
|
253
254
|
"spec/v2/declaration.schema.json": "ef951633e4070899f64828f5cf1b5f4870270a0360f81dbb49b5b4d6cd407550",
|
|
254
255
|
"spec/v2/eos-override.json": "4c72d2f6a658943b01c1de1643cf294642091933af7832f38ee5495df684cb2d",
|
|
255
256
|
"spec/v2/errors.json": "8b4d2ebab569b26f608323ca4c9adaf805ab515286d26532db23fcb97541787f",
|
|
@@ -282,6 +283,7 @@
|
|
|
282
283
|
"spec/v2/facets/connections.schema.json": "f998bb206a5814183bb4720cc34fa43b7ab64d61e39a79437b009c589ce8b78e",
|
|
283
284
|
"spec/v2/facets/heartbeat.schema.json": "81d7fa1895a8b0241bd07cf00a7e7905bef0fd14eb3427325261c62eee5746a6",
|
|
284
285
|
"spec/v2/facets/hostEvents.schema.json": "8834c2fde7bc2ba9eb433f22c9d6ec3f9739f44049eecdbf0a05baf2bfa0bb7d",
|
|
286
|
+
"spec/v2/facets/idempotency.schema.json": "f20c1e0b08749385b443a7e65293197cfe68cb51486b29659c6af6b96179fa98",
|
|
285
287
|
"spec/v2/facets/interrupt.schema.json": "e89e008a06b0a53cebfeac500c1622ca871c920fc8856b31c019689c5df41b66",
|
|
286
288
|
"spec/v2/facets/limits.schema.json": "e68327665def83007f2573b413757caa602e82ad347d1b85e9853fea153ffb48",
|
|
287
289
|
"spec/v2/facets/loopback.schema.json": "577a34333d1633f8ab00f11e1aeaee2708022faa69a26cf316fc5bea00ee7d50",
|
|
@@ -289,9 +291,10 @@
|
|
|
289
291
|
"spec/v2/facets/nondeterminismPolicy.schema.json": "e9dfaa32e0778a1a837fdad3b8a0673fccda9579ab08c8bfe9777362dd15e989",
|
|
290
292
|
"spec/v2/facets/oauth.schema.json": "3a0cbe249d3bd5a5982349ae7ba2534776cc958e4a57e0fbeabf2bdab6e99e69",
|
|
291
293
|
"spec/v2/facets/packs.schema.json": "d544a280419c0052ce0f3c3b378f7aa04085cadc31df52a3c8f43da600b131b1",
|
|
292
|
-
"spec/v2/facets/replay.schema.json": "
|
|
294
|
+
"spec/v2/facets/replay.schema.json": "ebb1ce04ed1e4fca237f448e565fe75c833e630320930accecba2ce4e660c8d9",
|
|
293
295
|
"spec/v2/facets/runList.schema.json": "6223e1baaebbb9f4faa71bfe968f8d22b4aa88046d0e43e8d5f56409e7f86e90",
|
|
294
296
|
"spec/v2/facets/sandbox.schema.json": "e75f54486d387b2d089791aa93b5093cd1450f1920753a0f97fc23acdfe978ed",
|
|
297
|
+
"spec/v2/facets/telemetry.schema.json": "56e96a26910ff1d891eabfc520c2d152fd9881449fdcea7e03df34793763849e",
|
|
295
298
|
"spec/v2/facets/uiPlugins.schema.json": "1fd3f8440cfb6e4422553a7c4b5f091eb3e10a40da0d43e8b977a18929116758",
|
|
296
299
|
"spec/v2/facets/webhooks.schema.json": "54b576f1d150e28e1642693d846ce471c118e9c521bdf3924185305fbd1d0de4",
|
|
297
300
|
"spec/v2/facets/workflowChainPacks.schema.json": "ea625ce818fbeb746478f00f31f96ee9613d538e4409c258c1bfb517acdeaba9",
|
|
@@ -306,9 +309,9 @@
|
|
|
306
309
|
"spec/v2/path-manifest.json": "3720e502614f301efc7556e0bfcbfa7b7ad8a37c579256ba6bca82417a2728f1",
|
|
307
310
|
"spec/v2/peer-dependency-aliases.json": "cbf62f551f9295b0ac7ba6cc0466abb153563354a57b0622f3ec6e6689cba47e",
|
|
308
311
|
"spec/v2/profiles.json": "b382b261c7844798575f7c1b37c934f0da19846016a2337d883d4f5ef6e05479",
|
|
309
|
-
"spec/v2/release.json": "
|
|
312
|
+
"spec/v2/release.json": "8dfa992e0c12d7efda3d0e1c6a63f66790c7c1b29fbe6bb5ae81ba7321f98c39",
|
|
310
313
|
"spec/v2/retention-floors.json": "eaf3722d95c79947af1d4269ef85117e126518c588cfcf1a2b21b97269f51624",
|
|
311
|
-
"spec/v2/surface-baseline.json": "
|
|
314
|
+
"spec/v2/surface-baseline.json": "f1638b1acc7f7408f208c3ada6bd982ed61f9825a6e0e515fba9873808b48192"
|
|
312
315
|
},
|
|
313
|
-
"corpusCommit": "
|
|
316
|
+
"corpusCommit": "37c05c6cdf6b15dded360ad23455e066e2fc2b99"
|
|
314
317
|
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0265 — pure judges for `v2-compensation-unwind.test.ts`. The scenario runs
|
|
3
|
+
* the `conformance-compensate-unwind` fixture (three compensable effects, then a
|
|
4
|
+
* node that fails) and records the run's event log, its compensation projection
|
|
5
|
+
* and its effect ledger; these functions decide each leg from those reads, so
|
|
6
|
+
* the self-tests can fail every wrong host answer without a host.
|
|
7
|
+
*
|
|
8
|
+
* Attribution of effect-ledger rows. The ledger projection carries no
|
|
9
|
+
* forward/inverse marker (`schemas/v2/effect-ledger-projection.schema.json`:
|
|
10
|
+
* `effectId, nodeId, attempt, keying, state, at`), and a host may record an
|
|
11
|
+
* inverse under the forward node's id or under one of its own. So a row is
|
|
12
|
+
* attributed by elimination, using only what the fixture fixes:
|
|
13
|
+
* - the FORWARD record of each effect node is that node's earliest row by `at`
|
|
14
|
+
* (the forward runs once, deterministically, before the failure);
|
|
15
|
+
* - rows for the failing node are neither (it performs no effect);
|
|
16
|
+
* - every other row is an INVERSE record — the fixture has nothing else that
|
|
17
|
+
* could put a row on the ledger.
|
|
18
|
+
* A ledger with no row for some effect node never recorded its forward effect,
|
|
19
|
+
* so the inverse count is not measurable there (RFC 0265 risk R1: the host did
|
|
20
|
+
* not map the reserved node to a ledgered effect). That is `blocked`, never a
|
|
21
|
+
* failure.
|
|
22
|
+
*
|
|
23
|
+
* @see RFCS/0265-compensation-unwind-witness.md
|
|
24
|
+
* @see spec/v2/core/security-defaults.md §Compensation
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { refiredAttempts, type EffectRow } from './effect-refire.js';
|
|
28
|
+
|
|
29
|
+
export interface Finding { readonly ok: boolean; readonly message: string }
|
|
30
|
+
export interface Ev { readonly type?: unknown; readonly nodeId?: unknown; readonly sequence?: unknown; readonly payload?: unknown }
|
|
31
|
+
export interface LedgerRow extends EffectRow { readonly effectId?: unknown; readonly state?: unknown }
|
|
32
|
+
export interface Attempt { readonly nodeId?: unknown; readonly attempt?: unknown; readonly outcome?: unknown; readonly at?: unknown }
|
|
33
|
+
export interface Projection { readonly status?: unknown; readonly plan?: unknown; readonly attempts?: unknown }
|
|
34
|
+
|
|
35
|
+
/** The fixture's three compensable effect nodes, in forward order, and its failing node. */
|
|
36
|
+
export const EFFECT_NODES: readonly string[] = ['e1', 'e2', 'e3'];
|
|
37
|
+
export const FAIL_NODE = 'fail';
|
|
38
|
+
|
|
39
|
+
type Rec = Record<string, unknown>;
|
|
40
|
+
const isRec = (v: unknown): v is Rec => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
41
|
+
const f = (ok: boolean, message: string): Finding => ({ ok, message });
|
|
42
|
+
const seqOf = (e: Ev): number => (typeof e.sequence === 'number' ? e.sequence : Number.NaN);
|
|
43
|
+
const nodeOf = (e: Ev): unknown => e.nodeId ?? (isRec(e.payload) ? e.payload['nodeId'] : undefined);
|
|
44
|
+
const atOf = (r: LedgerRow): number => (typeof r.at === 'string' ? Date.parse(r.at) : Number.NaN);
|
|
45
|
+
const bySeq = (log: readonly Ev[]): Ev[] => log.filter((e) => Number.isFinite(seqOf(e))).sort((a, b) => seqOf(a) - seqOf(b));
|
|
46
|
+
|
|
47
|
+
/** Compensation events that end an unwind (asyncapi `compensation.*`). */
|
|
48
|
+
export const COMPENSATION_TERMINAL = new Set(['compensation.completed', 'compensation.failed', 'compensation.manual-intervention-required']);
|
|
49
|
+
/** Projection statuses an unwind can rest in (compensation-projection.schema.json). */
|
|
50
|
+
export const PROJECTION_SETTLED = new Set(['completed', 'partial', 'failed', 'manual-intervention']);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The order the effect nodes FORWARD-completed: the first `node.completed` per
|
|
54
|
+
* node, by log sequence. An inverse executed under the same node id may emit a
|
|
55
|
+
* later `node.completed`; the first one is the forward.
|
|
56
|
+
*/
|
|
57
|
+
export function forwardCompletionOrder(log: readonly Ev[], nodes: readonly string[] = EFFECT_NODES): string[] {
|
|
58
|
+
const seen = new Set<string>();
|
|
59
|
+
const out: string[] = [];
|
|
60
|
+
for (const e of bySeq(log)) {
|
|
61
|
+
const n = nodeOf(e);
|
|
62
|
+
if (e.type === 'node.completed' && typeof n === 'string' && nodes.includes(n) && !seen.has(n)) { seen.add(n); out.push(n); }
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Leg 1 — `compensation.requested` precedes the first `compensation.started`, and both are present. */
|
|
68
|
+
export function judgePlanBeforeInverse(log: readonly Ev[]): Finding[] {
|
|
69
|
+
const ordered = bySeq(log);
|
|
70
|
+
const requested = ordered.find((e) => e.type === 'compensation.requested');
|
|
71
|
+
const started = ordered.find((e) => e.type === 'compensation.started');
|
|
72
|
+
return [
|
|
73
|
+
f(started !== undefined, 'the fixture commits three effects and then fails on a `node-failure` trigger, so an unwind is owed: the log MUST hold `compensation.started` — it holds none, so no inverse action ever began'),
|
|
74
|
+
f(requested !== undefined, 'the log MUST hold `compensation.requested` (the plan is durable) before any inverse action executes — it holds none'),
|
|
75
|
+
f(requested !== undefined && started !== undefined && seqOf(requested) < seqOf(started),
|
|
76
|
+
`\`compensation.started\` MUST follow \`compensation.requested\` (asyncapi): requested at sequence ${requested === undefined ? 'none' : seqOf(requested)}, first started at ${started === undefined ? 'none' : seqOf(started)}`),
|
|
77
|
+
];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Leg 2 — the projection's `attempts[]` names the effect nodes in the reverse of
|
|
82
|
+
* their forward completion order. Judged on the array order of each node's
|
|
83
|
+
* first attempt; a retried inverse (a second attempt for the same node) does
|
|
84
|
+
* not reorder the plan.
|
|
85
|
+
*/
|
|
86
|
+
export function judgeReverseCompletion(forwardOrder: readonly string[], attempts: readonly Attempt[], nodes: readonly string[] = EFFECT_NODES): Finding[] {
|
|
87
|
+
const expected = [...forwardOrder].reverse();
|
|
88
|
+
const actual: string[] = [];
|
|
89
|
+
for (const a of attempts) {
|
|
90
|
+
if (typeof a.nodeId === 'string' && nodes.includes(a.nodeId) && !actual.includes(a.nodeId)) actual.push(a.nodeId);
|
|
91
|
+
}
|
|
92
|
+
return [
|
|
93
|
+
f(actual.length === nodes.length, `attempts[] MUST name every compensable node (${nodes.join(', ')}) — it names ${actual.length === 0 ? 'none' : actual.join(', ')}`),
|
|
94
|
+
f(JSON.stringify(actual) === JSON.stringify(expected), `orderingModel reverse-completion: attempts[] MUST run in the reverse of forward completion — forward completed ${forwardOrder.join(' → ')}, so attempts MUST read ${expected.join(', ')}; they read ${actual.join(', ') || '(none)'}`),
|
|
95
|
+
];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface LedgerSplit {
|
|
99
|
+
/** Effect nodes with no ledger row at all (forward effect not recorded). */
|
|
100
|
+
readonly missingForward: readonly string[];
|
|
101
|
+
readonly forward: ReadonlyMap<string, LedgerRow>;
|
|
102
|
+
readonly inverse: readonly LedgerRow[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Attribute ledger rows to forward and inverse records (see the module comment). */
|
|
106
|
+
export function splitLedger(rows: readonly LedgerRow[], nodes: readonly string[] = EFFECT_NODES, failNode: string = FAIL_NODE): LedgerSplit {
|
|
107
|
+
const forward = new Map<string, LedgerRow>();
|
|
108
|
+
for (const n of nodes) {
|
|
109
|
+
const mine = rows.filter((r) => r.nodeId === n).sort((a, b) => atOf(a) - atOf(b));
|
|
110
|
+
const first = mine[0];
|
|
111
|
+
if (first !== undefined) forward.set(n, first);
|
|
112
|
+
}
|
|
113
|
+
const forwardRows = new Set<LedgerRow>(forward.values());
|
|
114
|
+
return {
|
|
115
|
+
missingForward: nodes.filter((n) => !forward.has(n)),
|
|
116
|
+
forward,
|
|
117
|
+
inverse: rows.filter((r) => !forwardRows.has(r) && r.nodeId !== failNode),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Leg 3 — exactly one inverse record per forward effect, each performed once.
|
|
123
|
+
* Call only when `split.missingForward` is empty (otherwise the leg is blocked).
|
|
124
|
+
* A second record for one inverse (an unstable identity, or a re-execution)
|
|
125
|
+
* shows as a fourth row or a repeated effect; both fail.
|
|
126
|
+
*/
|
|
127
|
+
export function judgeInverseOnce(split: LedgerSplit, nodes: readonly string[] = EFFECT_NODES): Finding[] {
|
|
128
|
+
const inv = split.inverse;
|
|
129
|
+
const ids = new Set(inv.map((r) => String(r.effectId)));
|
|
130
|
+
const out: Finding[] = [
|
|
131
|
+
f(inv.length === nodes.length, `the effect ledger MUST hold exactly ${nodes.length} inverse records, one per forward effect — it holds ${inv.length} beyond the forward records: ${JSON.stringify(inv).slice(0, 300)}`),
|
|
132
|
+
f(ids.size === inv.length, `each inverse effect MUST be performed once — ${inv.length} inverse record(s) carry ${ids.size} distinct effectId(s), so an inverse was recorded more than once`),
|
|
133
|
+
];
|
|
134
|
+
// Where the host records inverses under the forward node's id, the per-node
|
|
135
|
+
// distribution is observable too; under its own ids it is not, and the count
|
|
136
|
+
// above is the whole witness.
|
|
137
|
+
const attributable = inv.filter((r) => typeof r.nodeId === 'string' && nodes.includes(r.nodeId));
|
|
138
|
+
if (attributable.length === inv.length && inv.length > 0) {
|
|
139
|
+
for (const n of nodes) {
|
|
140
|
+
const c = attributable.filter((r) => r.nodeId === n).length;
|
|
141
|
+
out.push(f(c === 1, `node ${n}'s forward effect MUST have exactly one inverse record — it has ${c}`));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Leg 4 — the projection agrees with the log: `completed` iff `compensation.completed`; `plan[]` names the effect nodes. */
|
|
148
|
+
export function judgeProjectionAgrees(projection: Projection, log: readonly Ev[], nodes: readonly string[] = EFFECT_NODES): Finding[] {
|
|
149
|
+
const logged = log.some((e) => e.type === 'compensation.completed');
|
|
150
|
+
const status = projection.status;
|
|
151
|
+
const plan = Array.isArray(projection.plan) ? (projection.plan as unknown[]) : [];
|
|
152
|
+
const planned = [...new Set(plan.map((p) => (isRec(p) ? p['nodeId'] : undefined)).filter((n): n is string => typeof n === 'string'))].sort();
|
|
153
|
+
const want = [...nodes].sort();
|
|
154
|
+
return [
|
|
155
|
+
f((status === 'completed') === logged, `the projection's status MUST be "completed" exactly when the log holds compensation.completed — status is ${JSON.stringify(status)}, the log ${logged ? 'holds' : 'does not hold'} compensation.completed`),
|
|
156
|
+
f(JSON.stringify(planned) === JSON.stringify(want), `plan[] MUST name the three compensable nodes (${want.join(', ')}) — it names ${planned.length === 0 ? 'none' : planned.join(', ')}`),
|
|
157
|
+
];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Leg 5 — the records a replay fork's ledger holds that the parent's never did.
|
|
162
|
+
* Every such row is newly performed work, and on a replay fork of this fixture
|
|
163
|
+
* that can only be a re-fired forward or a re-fired inverse. The two are not
|
|
164
|
+
* separable on the wire: a fork ledger keyed per run, re-firing only the
|
|
165
|
+
* inverses under the forward nodes' ids, looks exactly like one that re-fired
|
|
166
|
+
* the forwards. So the judge counts every fresh row; a re-fired forward breaks
|
|
167
|
+
* the same suppression rule (replay.md §Suppression rule 1) and is not a pass
|
|
168
|
+
* either. `refiredAttempts` treats a fork row that repeats a parent attempt's
|
|
169
|
+
* `(nodeId, attempt, at)` as inherited history.
|
|
170
|
+
*/
|
|
171
|
+
export function refiredOnFork(parent: readonly LedgerRow[], fork: readonly LedgerRow[]): LedgerRow[] {
|
|
172
|
+
return refiredAttempts(parent, fork);
|
|
173
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 0260 — LLM cache key recipe 2 (`openwop-llm-key-2`). A pure function: the
|
|
3
|
+
* suite recomputes the key a host recorded and compares (§B, `0260.key-portable`).
|
|
4
|
+
*
|
|
5
|
+
* key = hex(SHA-256(JCS({ recipe, model, provider, messages, tools, toolChoice,
|
|
6
|
+
* responseSchema, params })))
|
|
7
|
+
*
|
|
8
|
+
* `params` names every sampling or output-shaping parameter; one the host did not
|
|
9
|
+
* send is `null`, never omitted. `stop` is sorted (UTF-16 code-unit order, as JCS
|
|
10
|
+
* orders keys) and de-duplicated. Any other parameter sits under `params.extra`.
|
|
11
|
+
* A string message content is the one-part array `[{type: "text", text}]`. No
|
|
12
|
+
* Unicode normalisation: the bytes are as sent.
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { canonicalJSON, codeUnitCompare } from './jcs.js';
|
|
16
|
+
|
|
17
|
+
export const RECIPE_2 = 'openwop-llm-key-2';
|
|
18
|
+
|
|
19
|
+
/** The parameters recipe 2 names. A request that omits one puts `null` in the key. */
|
|
20
|
+
export const NAMED_PARAMS = ['temperature', 'maxTokens', 'stop', 'seed', 'topP', 'topK', 'frequencyPenalty', 'presencePenalty'] as const;
|
|
21
|
+
export type NamedParam = (typeof NAMED_PARAMS)[number];
|
|
22
|
+
|
|
23
|
+
export interface KeyInputs {
|
|
24
|
+
readonly model: string;
|
|
25
|
+
readonly provider: string;
|
|
26
|
+
readonly messages: readonly { readonly role: string; readonly content: string | readonly Record<string, unknown>[] }[];
|
|
27
|
+
readonly tools?: readonly unknown[];
|
|
28
|
+
readonly toolChoice?: unknown;
|
|
29
|
+
readonly responseSchema?: unknown;
|
|
30
|
+
readonly params?: Partial<Record<NamedParam, unknown>> & { readonly extra?: Record<string, unknown> };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const nullIfAbsent = (v: unknown): unknown => (v === undefined ? null : v);
|
|
34
|
+
|
|
35
|
+
/** The object recipe 2 hashes, before canonicalization. Exported so a failure can show what was hashed. */
|
|
36
|
+
export function keyPreimage(i: KeyInputs): Record<string, unknown> {
|
|
37
|
+
const p = i.params ?? {};
|
|
38
|
+
const params: Record<string, unknown> = {};
|
|
39
|
+
for (const name of NAMED_PARAMS) params[name] = nullIfAbsent(p[name]);
|
|
40
|
+
if (Array.isArray(params['stop'])) params['stop'] = [...new Set((params['stop'] as unknown[]).map(String))].sort(codeUnitCompare);
|
|
41
|
+
if (p.extra !== undefined && Object.keys(p.extra).length > 0) params['extra'] = p.extra;
|
|
42
|
+
return {
|
|
43
|
+
recipe: RECIPE_2,
|
|
44
|
+
model: i.model,
|
|
45
|
+
provider: i.provider,
|
|
46
|
+
messages: i.messages.map((m) => ({ role: m.role, content: typeof m.content === 'string' ? [{ type: 'text', text: m.content }] : m.content })),
|
|
47
|
+
tools: i.tools ?? [],
|
|
48
|
+
toolChoice: nullIfAbsent(i.toolChoice),
|
|
49
|
+
responseSchema: nullIfAbsent(i.responseSchema),
|
|
50
|
+
params,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function cacheKey2(i: KeyInputs): string {
|
|
55
|
+
return createHash('sha256').update(canonicalJSON(keyPreimage(i)), 'utf8').digest('hex');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The inputs the `conformance-llm-cache-key` fixture pins (conformance/fixtures.md
|
|
60
|
+
* §The LLM cache-key fixture): the host's conformance mock is provider
|
|
61
|
+
* `conformance-mock`, model `conformance-mock-1`; the node sends one user message,
|
|
62
|
+
* no tools, no toolChoice, no responseSchema, and exactly the parameters in the
|
|
63
|
+
* run's `inputs.request.params`.
|
|
64
|
+
*/
|
|
65
|
+
export const FIXTURE = 'conformance-llm-cache-key';
|
|
66
|
+
export const NODE_ID = 'llm-call';
|
|
67
|
+
export const FIXTURE_PROVIDER = 'conformance-mock';
|
|
68
|
+
export const FIXTURE_MODEL = 'conformance-mock-1';
|
|
69
|
+
export function fixtureInputs(prompt: string, params: NonNullable<KeyInputs['params']>): KeyInputs {
|
|
70
|
+
return { model: FIXTURE_MODEL, provider: FIXTURE_PROVIDER, messages: [{ role: 'user', content: prompt }], params };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Judges. Each returns findings and asserts nothing; the scenario frames them with req(). ──
|
|
74
|
+
|
|
75
|
+
export interface Finding { readonly ok: boolean; readonly message: string }
|
|
76
|
+
const f = (ok: boolean, message: string): Finding => ({ ok, message });
|
|
77
|
+
|
|
78
|
+
/** The `cacheKey` member of the fixture node's provider-call record on GET /runs/{runId}/effects, or undefined. */
|
|
79
|
+
export interface LedgerKey { readonly recipe?: unknown; readonly digest?: unknown }
|
|
80
|
+
|
|
81
|
+
export function readLedgerKey(projection: unknown, nodeId: string): LedgerKey | undefined | 'no-record' {
|
|
82
|
+
const effects = (projection as { effects?: unknown } | null)?.effects;
|
|
83
|
+
if (!Array.isArray(effects)) return 'no-record';
|
|
84
|
+
const mine = effects.filter((e) => (e as { nodeId?: unknown } | null)?.nodeId === nodeId);
|
|
85
|
+
if (mine.length === 0) return 'no-record';
|
|
86
|
+
const withKey = mine.find((e) => (e as { cacheKey?: unknown }).cacheKey !== undefined) as { cacheKey?: LedgerKey } | undefined;
|
|
87
|
+
return withKey?.cacheKey;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
91
|
+
|
|
92
|
+
export function judgeRecorded(key: LedgerKey | undefined): Finding[] {
|
|
93
|
+
return [
|
|
94
|
+
f(key !== undefined, 'the provider-call record MUST carry cacheKey when replay.llmCacheKeyRecipe is advertised'),
|
|
95
|
+
f(key?.recipe === RECIPE_2, `cacheKey.recipe MUST be "${RECIPE_2}" (got ${JSON.stringify(key?.recipe)})`),
|
|
96
|
+
f(typeof key?.digest === 'string' && HEX64.test(key.digest), `cacheKey.digest MUST be 64 lowercase hex characters (got ${JSON.stringify(key?.digest)})`),
|
|
97
|
+
];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Two invocations that differ only in one named parameter MUST NOT share a key. */
|
|
101
|
+
export function judgeParamsInKey(a: LedgerKey | undefined, b: LedgerKey | undefined): Finding[] {
|
|
102
|
+
return [f(typeof a?.digest === 'string' && typeof b?.digest === 'string' && a.digest !== b.digest, `invocations differing only in maxTokens MUST have different keys (got ${String(a?.digest)} twice)`)];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The same inputs MUST give the same key. */
|
|
106
|
+
export function judgeStable(a: LedgerKey | undefined, b: LedgerKey | undefined): Finding[] {
|
|
107
|
+
return [f(typeof a?.digest === 'string' && a.digest === b?.digest, `identical invocations MUST have the same key (got ${String(a?.digest)} and ${String(b?.digest)})`)];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The host's key MUST equal the recipe computed from the inputs the fixture pins. */
|
|
111
|
+
export function judgePortable(key: LedgerKey | undefined, expected: KeyInputs): Finding[] {
|
|
112
|
+
const want = cacheKey2(expected);
|
|
113
|
+
return [f(key?.digest === want, `cacheKey.digest MUST equal recipe 2 over the fixture's inputs: expected ${want}, got ${String(key?.digest)} (preimage ${canonicalJSON(keyPreimage(expected))})`)];
|
|
114
|
+
}
|