@audiofool/moondog 0.1.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.
Files changed (109) hide show
  1. package/README.md +125 -0
  2. package/assets/brand/moondog-lunar-record/moondog-logo-1x1.png +0 -0
  3. package/contracts/v1/capability-policy.schema.json +572 -0
  4. package/contracts/v1/definitions.schema.json +365 -0
  5. package/contracts/v1/library-track-observation.schema.json +138 -0
  6. package/contracts/v1/listening-event.schema.json +111 -0
  7. package/contracts/v1/profile-evidence.schema.json +237 -0
  8. package/contracts/v1/taste-event.schema.json +258 -0
  9. package/contracts/v1/tool-invocation.schema.json +318 -0
  10. package/contracts/v1/tool-receipt.schema.json +1066 -0
  11. package/contracts/v1/track-ref.schema.json +275 -0
  12. package/contracts/v2/profile-evidence.schema.json +284 -0
  13. package/package.json +105 -0
  14. package/scripts/contract-lib.mjs +361 -0
  15. package/scripts/contract-semantics.mjs +2630 -0
  16. package/scripts/demo-studio.mjs +58 -0
  17. package/scripts/moondog +52 -0
  18. package/scripts/moondog.mjs +954 -0
  19. package/src/core/apple-data-migration.mjs +50 -0
  20. package/src/core/apple-library-source-status.mjs +220 -0
  21. package/src/core/apple-projection-domain-services.mjs +2356 -0
  22. package/src/core/capability-catalog.mjs +577 -0
  23. package/src/core/listening-profile-domain-services.mjs +1010 -0
  24. package/src/core/lyric-library.mjs +121 -0
  25. package/src/core/lyric-profile.mjs +50 -0
  26. package/src/core/lyric-service.mjs +41 -0
  27. package/src/core/moondog-application.mjs +1827 -0
  28. package/src/core/state-directory.mjs +19 -0
  29. package/src/core/synthetic-domain-services.mjs +1485 -0
  30. package/src/core/uuid-v5.mjs +42 -0
  31. package/src/demo/fictional-spotify-history.mjs +400 -0
  32. package/src/demo/moondog-demo.mjs +355 -0
  33. package/src/demo/moondog-interactive-tasteprint-demo.mjs +293 -0
  34. package/src/demo/moondog-tasteprint-demo.mjs +631 -0
  35. package/src/importers/apple-music-library/import-batch.mjs +290 -0
  36. package/src/importers/apple-music-library/index.mjs +76 -0
  37. package/src/importers/apple-music-library/normalize.mjs +1126 -0
  38. package/src/importers/apple-music-library/parse-plist.mjs +357 -0
  39. package/src/importers/apple-music-library/projection-records.mjs +557 -0
  40. package/src/importers/apple-music-library/read-batch.mjs +276 -0
  41. package/src/importers/apple-music-library/sqlite-projection.mjs +1307 -0
  42. package/src/importers/apple-music-library/stable-ids.mjs +19 -0
  43. package/src/importers/apple-music-library/track-snapshot.schema.json +136 -0
  44. package/src/integrations/apple-music/catalog.mjs +770 -0
  45. package/src/integrations/cross-catalog-artist-identity.mjs +222 -0
  46. package/src/integrations/listenbrainz/history-file.mjs +560 -0
  47. package/src/integrations/lyrics/lrclib.mjs +55 -0
  48. package/src/integrations/open-music-similarity/artist-radio.mjs +806 -0
  49. package/src/integrations/public-playlists.mjs +104 -0
  50. package/src/integrations/spotify/account-data-history.mjs +545 -0
  51. package/src/integrations/spotify/account-data-profile.mjs +918 -0
  52. package/src/integrations/spotify/authentication.mjs +791 -0
  53. package/src/integrations/spotify/catalog-resolver.mjs +657 -0
  54. package/src/integrations/spotify/connection.mjs +125 -0
  55. package/src/integrations/spotify/credential-store.mjs +26 -0
  56. package/src/integrations/spotify/extended-streaming-history.mjs +525 -0
  57. package/src/integrations/spotify/history-archive.mjs +59 -0
  58. package/src/integrations/spotify/index.mjs +80 -0
  59. package/src/integrations/spotify/recent-activity.mjs +198 -0
  60. package/src/integrations/spotify/resolution-cache.mjs +260 -0
  61. package/src/integrations/spotify/service.mjs +713 -0
  62. package/src/integrations/spotify/settings.mjs +89 -0
  63. package/src/integrations/spotify/web-api-client.mjs +761 -0
  64. package/src/integrations/web/codex-web.mjs +263 -0
  65. package/src/integrations/youtube-music/takeout.mjs +143 -0
  66. package/src/memory/local-memory-store.mjs +1705 -0
  67. package/src/memory/reflection-worker.mjs +87 -0
  68. package/src/profile/apple-library-import.mjs +28 -0
  69. package/src/profile/history-import.mjs +163 -0
  70. package/src/profile/listener-corrections.mjs +217 -0
  71. package/src/profile/listening-history-store.mjs +2055 -0
  72. package/src/profile/listening-profile-projection.mjs +2520 -0
  73. package/src/profile/local-music-data.mjs +812 -0
  74. package/src/profile/music-import-bundle.mjs +91 -0
  75. package/src/profile/music-providers.mjs +25 -0
  76. package/src/profile/spotify-archive-catalog-hints.mjs +210 -0
  77. package/src/profile/spotify-archive-taste.mjs +482 -0
  78. package/src/runtime/pi/agent-runtime.mjs +6444 -0
  79. package/src/runtime/pi/authentication.mjs +104 -0
  80. package/src/runtime/pi/configured-runtime.mjs +98 -0
  81. package/src/runtime/pi/memory-agent-runtime.mjs +337 -0
  82. package/src/runtime/pi/model-catalog.mjs +62 -0
  83. package/src/runtime/pi/model-transport.mjs +117 -0
  84. package/src/runtime/pi/persistent-credential-store.mjs +670 -0
  85. package/src/runtime/pi/provider-registry.mjs +74 -0
  86. package/src/runtime/pi/runtime-settings.mjs +81 -0
  87. package/src/surfaces/cli/auth-command.mjs +355 -0
  88. package/src/surfaces/cli/brand-components.mjs +390 -0
  89. package/src/surfaces/cli/brand-theme.mjs +231 -0
  90. package/src/surfaces/cli/catalog-command.mjs +509 -0
  91. package/src/surfaces/cli/command-completions.mjs +113 -0
  92. package/src/surfaces/cli/data-command.mjs +214 -0
  93. package/src/surfaces/cli/format-output.mjs +750 -0
  94. package/src/surfaces/cli/history-import-view.mjs +445 -0
  95. package/src/surfaces/cli/import-guides.mjs +9 -0
  96. package/src/surfaces/cli/listenbrainz-command.mjs +129 -0
  97. package/src/surfaces/cli/listening-menu.mjs +171 -0
  98. package/src/surfaces/cli/profile-command.mjs +290 -0
  99. package/src/surfaces/cli/spotify-command.mjs +849 -0
  100. package/src/surfaces/cli/taste-profile-model.mjs +306 -0
  101. package/src/surfaces/cli/taste-profile-view.mjs +272 -0
  102. package/src/surfaces/cli/terminal-art.mjs +295 -0
  103. package/src/surfaces/cli/tui.mjs +1714 -0
  104. package/src/surfaces/cli/web-command.mjs +52 -0
  105. package/src/surfaces/html/tasteprint-artifact.mjs +96 -0
  106. package/src/surfaces/html/tasteprint.mjs +3575 -0
  107. package/src/surfaces/web/showcase.mjs +272 -0
  108. package/src/surfaces/web/studio-page.mjs +4148 -0
  109. package/src/surfaces/web/studio.mjs +2288 -0
@@ -0,0 +1,2630 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ const riskDomains = ["privacy", "account", "copyright", "spend"];
4
+ const riskRank = new Map([
5
+ ["none", 0],
6
+ ["low", 1],
7
+ ["medium", 2],
8
+ ["high", 3],
9
+ ]);
10
+ const effectKinds = new Set([
11
+ "read_external",
12
+ "write_local",
13
+ "write_external",
14
+ "send_message",
15
+ "spend",
16
+ "publish",
17
+ "delete",
18
+ "use_credentials",
19
+ ]);
20
+ const executionModes = new Set(["plan", "dry_run", "execute"]);
21
+ const namespacedKeyPattern =
22
+ /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$/;
23
+ const uuidPattern =
24
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
25
+ const mutatingEffects = new Set([
26
+ "write_local",
27
+ "write_external",
28
+ "send_message",
29
+ "spend",
30
+ "publish",
31
+ "delete",
32
+ ]);
33
+ const accountEffects = new Set([
34
+ "write_external",
35
+ "send_message",
36
+ "spend",
37
+ "publish",
38
+ "delete",
39
+ "use_credentials",
40
+ ]);
41
+ const allowReceiptStatuses = new Set([
42
+ "simulated",
43
+ "accepted",
44
+ "succeeded",
45
+ "failed",
46
+ "timed_out",
47
+ "partial",
48
+ "unknown",
49
+ "cancelled",
50
+ ]);
51
+ const executedReceiptStatuses = new Set([
52
+ "accepted",
53
+ "succeeded",
54
+ "failed",
55
+ "timed_out",
56
+ "partial",
57
+ "unknown",
58
+ ]);
59
+ const sensitiveKeyNames = new Set([
60
+ "accesstoken",
61
+ "apikey",
62
+ "authorization",
63
+ "clientassertion",
64
+ "clientsecret",
65
+ "cookie",
66
+ "cookies",
67
+ "credential",
68
+ "credentials",
69
+ "idtoken",
70
+ "password",
71
+ "passwd",
72
+ "privatekey",
73
+ "providertoken",
74
+ "rawproviderpayload",
75
+ "refreshtoken",
76
+ "sessiontoken",
77
+ "setcookie",
78
+ ]);
79
+ const sensitiveUrlParameterNames = new Set([
80
+ ...sensitiveKeyNames,
81
+ "code",
82
+ "key",
83
+ "secret",
84
+ "signature",
85
+ "token",
86
+ ]);
87
+
88
+ function issue(code, path, message) {
89
+ return { code, path, message };
90
+ }
91
+
92
+ function normalizeKey(value) {
93
+ return value.toLowerCase().replaceAll(/[^a-z0-9]/g, "");
94
+ }
95
+
96
+ function isSensitiveKeyName(value, sensitiveNames = sensitiveKeyNames) {
97
+ const candidates = [
98
+ normalizeKey(value),
99
+ ...value.split(/[.:/\\-]+/).map((segment) => normalizeKey(segment)),
100
+ ];
101
+ return candidates.some((candidate) =>
102
+ [...sensitiveNames].some(
103
+ (sensitiveName) =>
104
+ candidate === sensitiveName || candidate.endsWith(sensitiveName),
105
+ ),
106
+ );
107
+ }
108
+
109
+ function isPlainObject(value) {
110
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
111
+ const prototype = Object.getPrototypeOf(value);
112
+ return prototype === Object.prototype || prototype === null;
113
+ }
114
+
115
+ export function canonicalizeJson(value) {
116
+ if (value === null) return "null";
117
+
118
+ if (typeof value === "string" || typeof value === "boolean") {
119
+ return JSON.stringify(value);
120
+ }
121
+
122
+ if (typeof value === "number") {
123
+ if (!Number.isFinite(value)) {
124
+ throw new TypeError("Canonical JSON cannot contain a non-finite number");
125
+ }
126
+ return JSON.stringify(value);
127
+ }
128
+
129
+ if (Array.isArray(value)) {
130
+ return `[${value.map((item) => canonicalizeJson(item)).join(",")}]`;
131
+ }
132
+
133
+ if (!isPlainObject(value)) {
134
+ throw new TypeError("Canonical JSON accepts only JSON-compatible values");
135
+ }
136
+
137
+ return `{${Object.keys(value)
138
+ .sort()
139
+ .map(
140
+ (key) => `${JSON.stringify(key)}:${canonicalizeJson(value[key])}`,
141
+ )
142
+ .join(",")}}`;
143
+ }
144
+
145
+ export function sha256Hex(value) {
146
+ const source = typeof value === "string" ? value : canonicalizeJson(value);
147
+ return createHash("sha256").update(source, "utf8").digest("hex");
148
+ }
149
+
150
+ export function invocationSemanticPayload(invocation) {
151
+ if (!isPlainObject(invocation)) {
152
+ throw new TypeError("ToolInvocation must be an object");
153
+ }
154
+
155
+ const payload = structuredClone(invocation);
156
+ delete payload.semantic_digest;
157
+ delete payload.confirmation_grant_ref;
158
+
159
+ for (const field of [
160
+ "declared_effects",
161
+ "declared_risk_flags",
162
+ "rights_basis_refs",
163
+ ]) {
164
+ payload[field]?.sort(compareCanonicalValues);
165
+ }
166
+ for (const field of [
167
+ "source_refs",
168
+ "data_classes",
169
+ "planned_destinations",
170
+ ]) {
171
+ payload.data_manifest?.[field]?.sort(compareCanonicalValues);
172
+ }
173
+ payload.budget_request?.sort(compareCanonicalValues);
174
+ return payload;
175
+ }
176
+
177
+ function compareCanonicalValues(left, right) {
178
+ const leftValue = canonicalizeJson(left);
179
+ const rightValue = canonicalizeJson(right);
180
+ if (leftValue < rightValue) return -1;
181
+ if (leftValue > rightValue) return 1;
182
+ return 0;
183
+ }
184
+
185
+ export function computeInvocationSemanticDigest(invocation) {
186
+ return sha256Hex(invocationSemanticPayload(invocation));
187
+ }
188
+
189
+ export function computeConfirmationScopeDigest({
190
+ invocationSemanticDigest,
191
+ capabilityRegistryVersion,
192
+ }) {
193
+ return sha256Hex({
194
+ kind: "moondog.confirmation_scope",
195
+ version: 1,
196
+ invocation_semantic_digest: invocationSemanticDigest,
197
+ capability_registry_version: capabilityRegistryVersion,
198
+ });
199
+ }
200
+
201
+ function externalIdentity(externalRef) {
202
+ if (!externalRef) return null;
203
+ return {
204
+ system: externalRef.system,
205
+ entity_type: externalRef.entity_type,
206
+ external_id: externalRef.external_id,
207
+ };
208
+ }
209
+
210
+ export function computeIdempotencyKeyDigest(invocation) {
211
+ if (typeof invocation?.idempotency_key !== "string") {
212
+ throw new TypeError("Invocation idempotency key must be a string");
213
+ }
214
+ return sha256Hex({
215
+ kind: "moondog.idempotency_scope",
216
+ version: 1,
217
+ subject_id: invocation.subject_id,
218
+ capability_id: invocation.capability_id,
219
+ capability_version: invocation.capability_version,
220
+ account_identity: externalIdentity(invocation.account_ref),
221
+ idempotency_key: invocation.idempotency_key,
222
+ });
223
+ }
224
+
225
+ function inspectUrl(value, path, issues) {
226
+ if (!/^[a-z][a-z0-9+.-]*:/i.test(value)) return;
227
+
228
+ let parsed;
229
+ try {
230
+ parsed = new URL(value);
231
+ } catch {
232
+ return;
233
+ }
234
+
235
+ if (new Set(["data:", "javascript:", "file:"]).has(parsed.protocol)) {
236
+ issues.push(
237
+ issue(
238
+ "secret.unsafe_uri_scheme",
239
+ path,
240
+ "Persisted contract records must not contain unsafe URI schemes.",
241
+ ),
242
+ );
243
+ }
244
+
245
+ if (parsed.username || parsed.password) {
246
+ issues.push(
247
+ issue(
248
+ "secret.url_userinfo",
249
+ path,
250
+ "Persisted URLs must not contain credentials in userinfo.",
251
+ ),
252
+ );
253
+ }
254
+
255
+ for (const key of parsed.searchParams.keys()) {
256
+ if (isSensitiveKeyName(key, sensitiveUrlParameterNames)) {
257
+ issues.push(
258
+ issue(
259
+ "secret.url_query",
260
+ path,
261
+ "Persisted URLs must not contain credential query parameters.",
262
+ ),
263
+ );
264
+ break;
265
+ }
266
+ }
267
+
268
+ for (const key of parsed.hash.replace(/^#/, "").split(/[&;]/)) {
269
+ const [name] = key.split("=", 1);
270
+ if (isSensitiveKeyName(name, sensitiveUrlParameterNames)) {
271
+ issues.push(
272
+ issue(
273
+ "secret.url_fragment",
274
+ path,
275
+ "Persisted URLs must not contain credential fragments.",
276
+ ),
277
+ );
278
+ break;
279
+ }
280
+ }
281
+ }
282
+
283
+ export function findUnsafePersistedData(
284
+ value,
285
+ path = "$",
286
+ { maxDepth = 64, maxNodes = 10_000, maxBytes = 1_000_000 } = {},
287
+ ) {
288
+ const issues = [];
289
+ let nodeCount = 0;
290
+ let limitReported = false;
291
+
292
+ try {
293
+ if (Buffer.byteLength(JSON.stringify(value), "utf8") > maxBytes) {
294
+ issues.push(
295
+ issue(
296
+ "record.byte_limit",
297
+ path,
298
+ "The persisted record exceeds the semantic inspection byte limit.",
299
+ ),
300
+ );
301
+ }
302
+ } catch {
303
+ issues.push(
304
+ issue(
305
+ "record.not_json",
306
+ path,
307
+ "The persisted record must be finite JSON data.",
308
+ ),
309
+ );
310
+ }
311
+
312
+ function visit(current, currentPath, depth) {
313
+ nodeCount += 1;
314
+ if (depth > maxDepth || nodeCount > maxNodes) {
315
+ if (!limitReported) {
316
+ issues.push(
317
+ issue(
318
+ "record.inspection_limit",
319
+ currentPath,
320
+ "The persisted record exceeds semantic inspection limits.",
321
+ ),
322
+ );
323
+ limitReported = true;
324
+ }
325
+ return;
326
+ }
327
+ if (typeof current === "string") {
328
+ if (/^(?:basic|bearer)\s+[a-z0-9._~+\/-]+=*$/i.test(current)) {
329
+ issues.push(
330
+ issue(
331
+ "secret.authorization_value",
332
+ currentPath,
333
+ "Persisted values must not contain authorization material.",
334
+ ),
335
+ );
336
+ }
337
+ if (/-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----/.test(current)) {
338
+ issues.push(
339
+ issue(
340
+ "secret.private_key_value",
341
+ currentPath,
342
+ "Persisted values must not contain private keys.",
343
+ ),
344
+ );
345
+ }
346
+ inspectUrl(current, currentPath, issues);
347
+ return;
348
+ }
349
+
350
+ if (Array.isArray(current)) {
351
+ current.forEach((item, index) =>
352
+ visit(item, `${currentPath}/${index}`, depth + 1),
353
+ );
354
+ return;
355
+ }
356
+
357
+ if (!isPlainObject(current)) return;
358
+
359
+ for (const [key, child] of Object.entries(current)) {
360
+ const childPath = `${currentPath}/${key}`;
361
+ if (isSensitiveKeyName(key)) {
362
+ issues.push(
363
+ issue(
364
+ "secret.forbidden_field",
365
+ childPath,
366
+ "Persisted contract records must not contain credential fields or raw provider payloads.",
367
+ ),
368
+ );
369
+ }
370
+ visit(child, childPath, depth + 1);
371
+ }
372
+ }
373
+
374
+ visit(value, path, 0);
375
+ return issues;
376
+ }
377
+
378
+ function canonicalEqual(left, right) {
379
+ return canonicalizeJson(left) === canonicalizeJson(right);
380
+ }
381
+
382
+ function exactSetEqual(left, right) {
383
+ if (!Array.isArray(left) || !Array.isArray(right)) return false;
384
+ const leftSet = new Set(left);
385
+ const rightSet = new Set(right);
386
+ if (leftSet.size !== left.length || rightSet.size !== right.length) return false;
387
+ if (leftSet.size !== rightSet.size) return false;
388
+ return [...leftSet].every((value) => rightSet.has(value));
389
+ }
390
+
391
+ function includesCanonical(items, expected) {
392
+ return Array.isArray(items) && items.some((item) => canonicalEqual(item, expected));
393
+ }
394
+
395
+ function includesExternalIdentity(items, expected) {
396
+ return (
397
+ Array.isArray(items) &&
398
+ items.some((item) =>
399
+ canonicalEqual(externalIdentity(item), externalIdentity(expected)),
400
+ )
401
+ );
402
+ }
403
+
404
+ function parseTimestamp(value) {
405
+ if (typeof value !== "string") return null;
406
+ const timestamp = Date.parse(value);
407
+ return Number.isFinite(timestamp) ? timestamp : null;
408
+ }
409
+
410
+ function appendUniqueKeyIssues(items, keyFor, path, code, issues) {
411
+ const seen = new Set();
412
+ for (const item of items ?? []) {
413
+ const key = keyFor(item);
414
+ if (seen.has(key)) {
415
+ issues.push(issue(code, path, "Semantic keys must be unique."));
416
+ return;
417
+ }
418
+ seen.add(key);
419
+ }
420
+ }
421
+
422
+ export function validateCapabilityDescriptor(capability) {
423
+ const issues = [];
424
+ if (!isPlainObject(capability)) {
425
+ issues.push(
426
+ issue(
427
+ "registry.descriptor_missing",
428
+ "/capability",
429
+ "A trusted capability descriptor is required.",
430
+ ),
431
+ );
432
+ return { ok: false, issues };
433
+ }
434
+
435
+ if (
436
+ typeof capability.registry_version !== "string" ||
437
+ capability.registry_version.trim() === ""
438
+ ) {
439
+ issues.push(
440
+ issue(
441
+ "registry.version_invalid",
442
+ "/capability/registry_version",
443
+ "The trusted registry version must be a non-empty string.",
444
+ ),
445
+ );
446
+ }
447
+ if (
448
+ typeof capability.capability_id !== "string" ||
449
+ !namespacedKeyPattern.test(capability.capability_id)
450
+ ) {
451
+ issues.push(
452
+ issue(
453
+ "registry.capability_id_invalid",
454
+ "/capability/capability_id",
455
+ "The trusted capability identifier is invalid.",
456
+ ),
457
+ );
458
+ }
459
+ if (
460
+ !Number.isInteger(capability.capability_version) ||
461
+ capability.capability_version < 1
462
+ ) {
463
+ issues.push(
464
+ issue(
465
+ "registry.capability_version_invalid",
466
+ "/capability/capability_version",
467
+ "The trusted capability version must be a positive integer.",
468
+ ),
469
+ );
470
+ }
471
+ if (
472
+ !isPlainObject(capability.tool_ref) ||
473
+ typeof capability.tool_ref.tool_id !== "string" ||
474
+ !namespacedKeyPattern.test(capability.tool_ref.tool_id) ||
475
+ !Number.isInteger(capability.tool_ref.contract_version) ||
476
+ capability.tool_ref.contract_version < 1
477
+ ) {
478
+ issues.push(
479
+ issue(
480
+ "registry.tool_ref_invalid",
481
+ "/capability/tool_ref",
482
+ "The trusted tool reference is invalid.",
483
+ ),
484
+ );
485
+ }
486
+
487
+ if (
488
+ !Array.isArray(capability.effects) ||
489
+ capability.effects.length === 0 ||
490
+ capability.effects.some((effect) => !effectKinds.has(effect))
491
+ ) {
492
+ issues.push(
493
+ issue(
494
+ "registry.effects_invalid",
495
+ "/capability/effects",
496
+ "Trusted effects must be a non-empty array of known effect kinds.",
497
+ ),
498
+ );
499
+ } else {
500
+ appendUniqueKeyIssues(
501
+ capability.effects,
502
+ (value) => value,
503
+ "/capability/effects",
504
+ "registry.duplicate_effect",
505
+ issues,
506
+ );
507
+ }
508
+
509
+ if (
510
+ !Array.isArray(capability.allowed_execution_modes) ||
511
+ capability.allowed_execution_modes.length === 0 ||
512
+ capability.allowed_execution_modes.some((mode) => !executionModes.has(mode))
513
+ ) {
514
+ issues.push(
515
+ issue(
516
+ "registry.execution_modes_invalid",
517
+ "/capability/allowed_execution_modes",
518
+ "Trusted execution modes must be a non-empty array of known modes.",
519
+ ),
520
+ );
521
+ } else {
522
+ appendUniqueKeyIssues(
523
+ capability.allowed_execution_modes,
524
+ (value) => value,
525
+ "/capability/allowed_execution_modes",
526
+ "registry.duplicate_execution_mode",
527
+ issues,
528
+ );
529
+ }
530
+
531
+ if (isPlainObject(capability.risk_levels)) {
532
+ const keys = Object.keys(capability.risk_levels);
533
+ if (!exactSetEqual(keys, riskDomains)) {
534
+ issues.push(
535
+ issue(
536
+ "registry.risk_domains_incomplete",
537
+ "/capability/risk_levels",
538
+ "The trusted descriptor must assign a level to every v1 risk domain.",
539
+ ),
540
+ );
541
+ }
542
+ for (const level of Object.values(capability.risk_levels)) {
543
+ if (!riskRank.has(level)) {
544
+ issues.push(
545
+ issue(
546
+ "registry.risk_level_unknown",
547
+ "/capability/risk_levels",
548
+ "The trusted descriptor contains an unknown risk level.",
549
+ ),
550
+ );
551
+ break;
552
+ }
553
+ }
554
+ } else {
555
+ issues.push(
556
+ issue(
557
+ "registry.risk_levels_invalid",
558
+ "/capability/risk_levels",
559
+ "The trusted descriptor must provide a risk-level object.",
560
+ ),
561
+ );
562
+ }
563
+
564
+ return { ok: issues.length === 0, issues };
565
+ }
566
+
567
+ function validateChronology(invocation, policy, now, issues) {
568
+ const requestedAt = parseTimestamp(invocation.requested_at);
569
+ const expiresAt = parseTimestamp(invocation.expires_at);
570
+ const validFrom = parseTimestamp(policy.valid_from);
571
+ const validUntil = policy.valid_until
572
+ ? parseTimestamp(policy.valid_until)
573
+ : Number.POSITIVE_INFINITY;
574
+ const nowValue = now instanceof Date ? now.getTime() : parseTimestamp(now);
575
+
576
+ if (
577
+ requestedAt === null ||
578
+ expiresAt === null ||
579
+ validFrom === null ||
580
+ validUntil === null ||
581
+ nowValue === null ||
582
+ !Number.isFinite(nowValue)
583
+ ) {
584
+ issues.push(
585
+ issue(
586
+ "time.invalid",
587
+ "/requested_at",
588
+ "Invocation, policy, and evaluation timestamps must be valid.",
589
+ ),
590
+ );
591
+ return;
592
+ }
593
+
594
+ if (requestedAt >= expiresAt) {
595
+ issues.push(
596
+ issue(
597
+ "time.invocation_order",
598
+ "/expires_at",
599
+ "Invocation expiry must follow its request time.",
600
+ ),
601
+ );
602
+ }
603
+ if (validFrom >= validUntil) {
604
+ issues.push(
605
+ issue(
606
+ "time.policy_order",
607
+ "/policy/valid_until",
608
+ "Policy expiry must follow its start time.",
609
+ ),
610
+ );
611
+ }
612
+ if (nowValue < requestedAt || nowValue >= expiresAt) {
613
+ issues.push(
614
+ issue(
615
+ "time.invocation_inactive",
616
+ "/expires_at",
617
+ "The invocation is not active at evaluation time.",
618
+ ),
619
+ );
620
+ }
621
+ if (nowValue < validFrom || nowValue >= validUntil) {
622
+ issues.push(
623
+ issue(
624
+ "time.policy_inactive",
625
+ "/policy/valid_until",
626
+ "The policy is not active at evaluation time.",
627
+ ),
628
+ );
629
+ }
630
+ }
631
+
632
+ function validatePrivacy(invocation, policy, issues) {
633
+ if (invocation.purpose_id !== policy.privacy?.purpose_id) {
634
+ issues.push(
635
+ issue(
636
+ "privacy.purpose_denied",
637
+ "/purpose_id",
638
+ "The invocation purpose is outside the selected policy.",
639
+ ),
640
+ );
641
+ }
642
+ const allowedDataClasses = new Set(policy.privacy?.allowed_data_classes ?? []);
643
+ for (const dataClass of invocation.data_manifest?.data_classes ?? []) {
644
+ if (!allowedDataClasses.has(dataClass)) {
645
+ issues.push(
646
+ issue(
647
+ "privacy.data_class_denied",
648
+ "/data_manifest/data_classes",
649
+ "The invocation contains a data class outside the selected policy.",
650
+ ),
651
+ );
652
+ }
653
+ }
654
+
655
+ for (const destination of invocation.data_manifest?.planned_destinations ?? []) {
656
+ const allowed = (policy.privacy?.allowed_destinations ?? []).some(
657
+ (candidate) =>
658
+ candidate.destination_type === destination.destination_type &&
659
+ candidate.system === destination.system &&
660
+ candidate.purpose_id === destination.purpose_id,
661
+ );
662
+ if (!allowed) {
663
+ issues.push(
664
+ issue(
665
+ "privacy.destination_denied",
666
+ "/data_manifest/planned_destinations",
667
+ "The invocation contains a destination outside the selected policy.",
668
+ ),
669
+ );
670
+ }
671
+ }
672
+
673
+ if (
674
+ invocation.data_manifest?.raw_data_included === true &&
675
+ policy.privacy?.raw_data_egress_allowed !== true
676
+ ) {
677
+ issues.push(
678
+ issue(
679
+ "privacy.raw_egress_denied",
680
+ "/data_manifest/raw_data_included",
681
+ "The selected policy does not permit raw data egress.",
682
+ ),
683
+ );
684
+ }
685
+
686
+ if (
687
+ policy.privacy?.rights_basis_required === true &&
688
+ !(invocation.rights_basis_refs?.length > 0)
689
+ ) {
690
+ issues.push(
691
+ issue(
692
+ "privacy.rights_basis_missing",
693
+ "/rights_basis_refs",
694
+ "The selected policy requires a rights basis.",
695
+ ),
696
+ );
697
+ }
698
+ }
699
+
700
+ function validateBudgets(invocation, policy, capability, issues) {
701
+ appendUniqueKeyIssues(
702
+ policy.budgets,
703
+ (item) => item.resource,
704
+ "/policy/budgets",
705
+ "budget.policy_resource_duplicate",
706
+ issues,
707
+ );
708
+ appendUniqueKeyIssues(
709
+ invocation.budget_request,
710
+ (item) => item.resource,
711
+ "/budget_request",
712
+ "budget.request_resource_duplicate",
713
+ issues,
714
+ );
715
+
716
+ if (capability.effects?.includes("spend") && !invocation.budget_request?.length) {
717
+ issues.push(
718
+ issue(
719
+ "budget.request_missing",
720
+ "/budget_request",
721
+ "A spending capability requires a non-empty budget request.",
722
+ ),
723
+ );
724
+ }
725
+
726
+ for (const requested of invocation.budget_request ?? []) {
727
+ const limit = (policy.budgets ?? []).find(
728
+ (candidate) => candidate.resource === requested.resource,
729
+ );
730
+ if (!limit || limit.unit !== requested.unit) {
731
+ issues.push(
732
+ issue(
733
+ "budget.resource_denied",
734
+ "/budget_request",
735
+ "The requested budget resource is not allowed by the selected policy.",
736
+ ),
737
+ );
738
+ continue;
739
+ }
740
+ if (
741
+ limit.max_per_invocation !== undefined &&
742
+ requested.quantity > limit.max_per_invocation
743
+ ) {
744
+ issues.push(
745
+ issue(
746
+ "budget.invocation_limit_exceeded",
747
+ "/budget_request",
748
+ "The requested quantity exceeds the per-invocation budget.",
749
+ ),
750
+ );
751
+ }
752
+ }
753
+ }
754
+
755
+ function trustedConfirmationContext(invocation, capability, computedDigest) {
756
+ const scopeDigest = computeConfirmationScopeDigest({
757
+ invocationSemanticDigest: computedDigest,
758
+ capabilityRegistryVersion: capability.registry_version,
759
+ });
760
+ return {
761
+ grant_ref: invocation.confirmation_grant_ref,
762
+ subject_id: invocation.subject_id,
763
+ actor: invocation.actor,
764
+ capability_id: invocation.capability_id,
765
+ capability_version: invocation.capability_version,
766
+ policy_ref: invocation.policy_ref,
767
+ account_ref: invocation.account_ref,
768
+ target: invocation.target,
769
+ semantic_digest: computedDigest,
770
+ confirmation_scope_digest: scopeDigest,
771
+ capability_registry_version: capability.registry_version,
772
+ budget_request_digest: sha256Hex(invocation.budget_request ?? []),
773
+ data_manifest_digest: sha256Hex(invocation.data_manifest ?? {}),
774
+ invocation_expires_at: invocation.expires_at,
775
+ };
776
+ }
777
+
778
+ function operationSucceeded(result) {
779
+ return result === true || (result?.ok === true && result?.valid !== false);
780
+ }
781
+
782
+ function verificationValid(result) {
783
+ return result === true || (result?.valid === true && result?.ok !== false);
784
+ }
785
+
786
+ function isolatedInvocationHookContext(invocation, capability) {
787
+ const invocationSnapshot = structuredClone(invocation);
788
+ return {
789
+ arguments: invocationSnapshot.arguments,
790
+ capability: structuredClone(capability),
791
+ invocation: invocationSnapshot,
792
+ };
793
+ }
794
+
795
+ async function validateToolInvocationSemanticsUnchecked({
796
+ invocation,
797
+ policy,
798
+ capability,
799
+ now = new Date(),
800
+ validateArguments,
801
+ inspectIntent,
802
+ verifyRightsBasis,
803
+ verifyConfirmation,
804
+ reserveBudget,
805
+ }) {
806
+ const issues = [...findUnsafePersistedData(invocation, "$")];
807
+ let computedDigest = null;
808
+ let confirmationVerification = null;
809
+ let budgetReservation = null;
810
+ let intentInspection = null;
811
+
812
+ if (!isPlainObject(invocation)) {
813
+ issues.push(
814
+ issue(
815
+ "record.invocation_invalid",
816
+ "/",
817
+ "Tool invocation semantics require a schema-valid object.",
818
+ ),
819
+ );
820
+ return {
821
+ ok: false,
822
+ issues,
823
+ computed_digest: null,
824
+ confirmation_verification: null,
825
+ intent_inspection: null,
826
+ budget_reservation: null,
827
+ };
828
+ }
829
+
830
+ const descriptorResult = validateCapabilityDescriptor(capability);
831
+ issues.push(...descriptorResult.issues);
832
+ if (!descriptorResult.ok) {
833
+ return {
834
+ ok: false,
835
+ issues,
836
+ computed_digest: null,
837
+ confirmation_verification: null,
838
+ intent_inspection: null,
839
+ budget_reservation: null,
840
+ };
841
+ }
842
+
843
+ try {
844
+ computedDigest = computeInvocationSemanticDigest(invocation);
845
+ if (invocation.semantic_digest !== computedDigest) {
846
+ issues.push(
847
+ issue(
848
+ "digest.mismatch",
849
+ "/semantic_digest",
850
+ "The invocation semantic digest does not match its canonical payload.",
851
+ ),
852
+ );
853
+ }
854
+ } catch {
855
+ issues.push(
856
+ issue(
857
+ "digest.uncomputable",
858
+ "/semantic_digest",
859
+ "The invocation cannot be represented as canonical JSON.",
860
+ ),
861
+ );
862
+ }
863
+
864
+ if (!isPlainObject(policy)) {
865
+ issues.push(
866
+ issue(
867
+ "policy.missing",
868
+ "/policy_ref",
869
+ "The exact trusted policy revision is required.",
870
+ ),
871
+ );
872
+ } else {
873
+ if (
874
+ invocation.policy_ref?.policy_id !== policy.policy_id ||
875
+ invocation.policy_ref?.policy_version !== policy.policy_version
876
+ ) {
877
+ issues.push(
878
+ issue(
879
+ "policy.revision_mismatch",
880
+ "/policy_ref",
881
+ "The invocation does not bind the supplied policy revision.",
882
+ ),
883
+ );
884
+ }
885
+ if (policy.subject_id !== invocation.subject_id) {
886
+ issues.push(
887
+ issue(
888
+ "policy.subject_mismatch",
889
+ "/subject_id",
890
+ "The selected policy belongs to another subject.",
891
+ ),
892
+ );
893
+ }
894
+ if (!policy.enabled || policy.effect !== "allow") {
895
+ issues.push(
896
+ issue(
897
+ "policy.not_allowing",
898
+ "/policy_ref",
899
+ "The selected policy is disabled or does not allow execution.",
900
+ ),
901
+ );
902
+ }
903
+ if (!policy.capability_ids?.includes(invocation.capability_id)) {
904
+ issues.push(
905
+ issue(
906
+ "policy.capability_denied",
907
+ "/capability_id",
908
+ "The selected policy does not include this exact capability.",
909
+ ),
910
+ );
911
+ }
912
+ if (!policy.actor_types?.includes(invocation.actor?.actor_type)) {
913
+ issues.push(
914
+ issue(
915
+ "policy.actor_type_denied",
916
+ "/actor",
917
+ "The actor type is outside the selected policy.",
918
+ ),
919
+ );
920
+ }
921
+ if (!includesCanonical(policy.actor_refs, invocation.actor)) {
922
+ issues.push(
923
+ issue(
924
+ "policy.actor_denied",
925
+ "/actor",
926
+ "The exact actor is outside the selected policy.",
927
+ ),
928
+ );
929
+ }
930
+ if (!policy.surfaces?.includes(invocation.surface)) {
931
+ issues.push(
932
+ issue(
933
+ "policy.surface_denied",
934
+ "/surface",
935
+ "The invocation surface is outside the selected policy.",
936
+ ),
937
+ );
938
+ }
939
+ if (!policy.allowed_execution_modes?.includes(invocation.execution_mode)) {
940
+ issues.push(
941
+ issue(
942
+ "policy.execution_mode_denied",
943
+ "/execution_mode",
944
+ "The execution mode is outside the selected policy.",
945
+ ),
946
+ );
947
+ }
948
+ }
949
+
950
+ if (isPlainObject(capability)) {
951
+ if (
952
+ invocation.capability_id !== capability.capability_id ||
953
+ invocation.capability_version !== capability.capability_version
954
+ ) {
955
+ issues.push(
956
+ issue(
957
+ "registry.capability_mismatch",
958
+ "/capability_id",
959
+ "The invocation does not bind the trusted capability revision.",
960
+ ),
961
+ );
962
+ }
963
+ if (!canonicalEqual(invocation.tool_ref, capability.tool_ref)) {
964
+ issues.push(
965
+ issue(
966
+ "registry.tool_mismatch",
967
+ "/tool_ref",
968
+ "The invocation tool does not match the trusted capability descriptor.",
969
+ ),
970
+ );
971
+ }
972
+ if (!exactSetEqual(invocation.declared_effects, capability.effects)) {
973
+ issues.push(
974
+ issue(
975
+ "registry.effects_mismatch",
976
+ "/declared_effects",
977
+ "Declared effects must exactly match the trusted capability descriptor.",
978
+ ),
979
+ );
980
+ }
981
+ const trustedRiskFlags = riskDomains.filter(
982
+ (domain) => capability.risk_levels?.[domain] !== "none",
983
+ );
984
+ if (!exactSetEqual(invocation.declared_risk_flags, trustedRiskFlags)) {
985
+ issues.push(
986
+ issue(
987
+ "registry.risks_mismatch",
988
+ "/declared_risk_flags",
989
+ "Declared risk flags must exactly match the trusted capability descriptor.",
990
+ ),
991
+ );
992
+ }
993
+ if (!capability.allowed_execution_modes?.includes(invocation.execution_mode)) {
994
+ issues.push(
995
+ issue(
996
+ "registry.execution_mode_denied",
997
+ "/execution_mode",
998
+ "The trusted capability does not support this execution mode.",
999
+ ),
1000
+ );
1001
+ }
1002
+ }
1003
+
1004
+ if (isPlainObject(policy) && isPlainObject(capability)) {
1005
+ for (const effect of capability.effects ?? []) {
1006
+ if (!policy.allowed_effects?.includes(effect)) {
1007
+ issues.push(
1008
+ issue(
1009
+ "policy.effect_denied",
1010
+ "/declared_effects",
1011
+ "The trusted capability has an effect outside the selected policy.",
1012
+ ),
1013
+ );
1014
+ }
1015
+ }
1016
+
1017
+ appendUniqueKeyIssues(
1018
+ policy.risk_limits,
1019
+ (item) => item.domain,
1020
+ "/policy/risk_limits",
1021
+ "policy.risk_domain_duplicate",
1022
+ issues,
1023
+ );
1024
+ const riskLimits = new Map(
1025
+ (policy.risk_limits ?? []).map((item) => [item.domain, item.max_level]),
1026
+ );
1027
+ if (!exactSetEqual([...riskLimits.keys()], riskDomains)) {
1028
+ issues.push(
1029
+ issue(
1030
+ "policy.risk_domains_incomplete",
1031
+ "/policy/risk_limits",
1032
+ "The selected policy must bound every v1 risk domain.",
1033
+ ),
1034
+ );
1035
+ }
1036
+ for (const domain of riskDomains) {
1037
+ const trustedLevel = capability.risk_levels?.[domain];
1038
+ const allowedLevel = riskLimits.get(domain);
1039
+ if (
1040
+ riskRank.has(trustedLevel) &&
1041
+ riskRank.has(allowedLevel) &&
1042
+ riskRank.get(trustedLevel) > riskRank.get(allowedLevel)
1043
+ ) {
1044
+ issues.push(
1045
+ issue(
1046
+ "policy.risk_limit_exceeded",
1047
+ `/policy/risk_limits/${domain}`,
1048
+ "The trusted capability risk exceeds the selected policy limit.",
1049
+ ),
1050
+ );
1051
+ }
1052
+ }
1053
+ }
1054
+
1055
+ const needsAccount = (capability?.effects ?? []).some((effect) =>
1056
+ accountEffects.has(effect),
1057
+ );
1058
+ if (needsAccount) {
1059
+ if (!invocation.account_ref) {
1060
+ issues.push(
1061
+ issue(
1062
+ "account.missing",
1063
+ "/account_ref",
1064
+ "This capability requires an explicit account reference.",
1065
+ ),
1066
+ );
1067
+ }
1068
+ }
1069
+
1070
+ if (
1071
+ invocation.account_ref &&
1072
+ !includesExternalIdentity(policy?.account_refs, invocation.account_ref)
1073
+ ) {
1074
+ issues.push(
1075
+ issue(
1076
+ "account.denied",
1077
+ "/account_ref",
1078
+ "The account is outside the selected policy.",
1079
+ ),
1080
+ );
1081
+ }
1082
+
1083
+ const needsIdempotency = (capability?.effects ?? []).some((effect) =>
1084
+ mutatingEffects.has(effect),
1085
+ );
1086
+ if (needsIdempotency && !invocation.idempotency_key) {
1087
+ issues.push(
1088
+ issue(
1089
+ "idempotency.key_missing",
1090
+ "/idempotency_key",
1091
+ "Mutating capabilities require an idempotency key.",
1092
+ ),
1093
+ );
1094
+ }
1095
+ for (const effect of capability?.effects ?? []) {
1096
+ if (
1097
+ mutatingEffects.has(effect) &&
1098
+ !policy?.idempotency?.required_for?.includes(effect)
1099
+ ) {
1100
+ issues.push(
1101
+ issue(
1102
+ "policy.idempotency_effect_missing",
1103
+ "/policy/idempotency/required_for",
1104
+ "The selected policy does not require idempotency for every trusted mutating effect.",
1105
+ ),
1106
+ );
1107
+ }
1108
+ }
1109
+
1110
+ if (isPlainObject(policy)) {
1111
+ validateChronology(invocation, policy, now, issues);
1112
+ validatePrivacy(invocation, policy, issues);
1113
+ }
1114
+ if (isPlainObject(policy) && isPlainObject(capability)) {
1115
+ validateBudgets(invocation, policy, capability, issues);
1116
+ }
1117
+
1118
+ if (typeof validateArguments !== "function") {
1119
+ issues.push(
1120
+ issue(
1121
+ "arguments.validator_missing",
1122
+ "/arguments",
1123
+ "A trusted capability-specific argument validator is required.",
1124
+ ),
1125
+ );
1126
+ } else if (issues.length === 0) {
1127
+ try {
1128
+ const result = await validateArguments(
1129
+ isolatedInvocationHookContext(invocation, capability),
1130
+ );
1131
+ if (!operationSucceeded(result)) {
1132
+ issues.push(
1133
+ issue(
1134
+ "arguments.invalid",
1135
+ "/arguments",
1136
+ "Capability-specific argument validation failed.",
1137
+ ),
1138
+ );
1139
+ }
1140
+ } catch {
1141
+ issues.push(
1142
+ issue(
1143
+ "arguments.validator_error",
1144
+ "/arguments",
1145
+ "Capability-specific argument validation did not complete safely.",
1146
+ ),
1147
+ );
1148
+ }
1149
+ }
1150
+
1151
+ if (typeof inspectIntent !== "function") {
1152
+ issues.push(
1153
+ issue(
1154
+ "intent.inspector_missing",
1155
+ "/arguments",
1156
+ "A trusted capability-specific intent inspector is required.",
1157
+ ),
1158
+ );
1159
+ } else if (issues.length === 0) {
1160
+ try {
1161
+ intentInspection = await inspectIntent(
1162
+ isolatedInvocationHookContext(invocation, capability),
1163
+ );
1164
+ const inspected = intentInspection?.intent ?? intentInspection;
1165
+ const declaredSecurityIntent = {
1166
+ account_identity: externalIdentity(invocation.account_ref),
1167
+ target: invocation.target ?? null,
1168
+ data_manifest: invocation.data_manifest,
1169
+ budget_request: invocation.budget_request,
1170
+ };
1171
+ const inspectedSecurityIntent = {
1172
+ account_identity: externalIdentity(inspected?.account_ref),
1173
+ target: inspected?.target ?? null,
1174
+ data_manifest: inspected?.data_manifest,
1175
+ budget_request: inspected?.budget_request,
1176
+ };
1177
+ if (
1178
+ !operationSucceeded(intentInspection) ||
1179
+ !canonicalEqual(declaredSecurityIntent, inspectedSecurityIntent)
1180
+ ) {
1181
+ issues.push(
1182
+ issue(
1183
+ "intent.mismatch",
1184
+ "/arguments",
1185
+ "Declared account, target, data movement, or budget does not match trusted intent inspection.",
1186
+ ),
1187
+ );
1188
+ }
1189
+ } catch {
1190
+ issues.push(
1191
+ issue(
1192
+ "intent.inspector_error",
1193
+ "/arguments",
1194
+ "Capability-specific intent inspection did not complete safely.",
1195
+ ),
1196
+ );
1197
+ }
1198
+ }
1199
+
1200
+ const rightsBasisRequired = policy?.privacy?.rights_basis_required === true;
1201
+ if (rightsBasisRequired && typeof verifyRightsBasis !== "function") {
1202
+ issues.push(
1203
+ issue(
1204
+ "rights.verifier_missing",
1205
+ "/rights_basis_refs",
1206
+ "A trusted rights-basis verifier is required.",
1207
+ ),
1208
+ );
1209
+ } else if (rightsBasisRequired && issues.length === 0) {
1210
+ try {
1211
+ const verification = await verifyRightsBasis({
1212
+ subject_id: invocation.subject_id,
1213
+ rights_basis_refs: structuredClone(invocation.rights_basis_refs),
1214
+ capability_id: invocation.capability_id,
1215
+ data_manifest: structuredClone(invocation.data_manifest),
1216
+ });
1217
+ if (!verificationValid(verification)) {
1218
+ issues.push(
1219
+ issue(
1220
+ "rights.invalid",
1221
+ "/rights_basis_refs",
1222
+ "The rights basis could not be independently verified.",
1223
+ ),
1224
+ );
1225
+ }
1226
+ } catch {
1227
+ issues.push(
1228
+ issue(
1229
+ "rights.verifier_error",
1230
+ "/rights_basis_refs",
1231
+ "Rights-basis verification did not complete safely.",
1232
+ ),
1233
+ );
1234
+ }
1235
+ }
1236
+
1237
+ const confirmationRequired = policy?.confirmation?.mode === "always";
1238
+ if (
1239
+ confirmationRequired &&
1240
+ !uuidPattern.test(invocation.confirmation_grant_ref ?? "")
1241
+ ) {
1242
+ issues.push(
1243
+ issue(
1244
+ invocation.confirmation_grant_ref
1245
+ ? "confirmation.reference_invalid"
1246
+ : "confirmation.reference_missing",
1247
+ "/confirmation_grant_ref",
1248
+ "An always-confirm policy requires a UUID confirmation grant reference.",
1249
+ ),
1250
+ );
1251
+ }
1252
+ if (
1253
+ policy?.confirmation?.mode === "never" &&
1254
+ invocation.confirmation_grant_ref
1255
+ ) {
1256
+ issues.push(
1257
+ issue(
1258
+ "confirmation.reference_unexpected",
1259
+ "/confirmation_grant_ref",
1260
+ "A never-confirm policy cannot accept a confirmation grant reference.",
1261
+ ),
1262
+ );
1263
+ }
1264
+ if (confirmationRequired && typeof verifyConfirmation !== "function") {
1265
+ issues.push(
1266
+ issue(
1267
+ "confirmation.verifier_missing",
1268
+ "/confirmation_grant_ref",
1269
+ "An independent confirmation verifier is required.",
1270
+ ),
1271
+ );
1272
+ } else if (confirmationRequired && issues.length === 0) {
1273
+ try {
1274
+ confirmationVerification = await verifyConfirmation(
1275
+ structuredClone(
1276
+ trustedConfirmationContext(invocation, capability, computedDigest),
1277
+ ),
1278
+ );
1279
+ if (!verificationValid(confirmationVerification)) {
1280
+ issues.push(
1281
+ issue(
1282
+ "confirmation.invalid",
1283
+ "/confirmation_grant_ref",
1284
+ "The confirmation grant did not match this invocation.",
1285
+ ),
1286
+ );
1287
+ }
1288
+ } catch {
1289
+ issues.push(
1290
+ issue(
1291
+ "confirmation.verifier_error",
1292
+ "/confirmation_grant_ref",
1293
+ "Confirmation verification did not complete safely.",
1294
+ ),
1295
+ );
1296
+ }
1297
+ }
1298
+
1299
+ const needsBudgetReservation =
1300
+ invocation.execution_mode === "execute" &&
1301
+ (invocation.budget_request?.length ?? 0) > 0;
1302
+ if (needsBudgetReservation && typeof reserveBudget !== "function") {
1303
+ issues.push(
1304
+ issue(
1305
+ "budget.reserver_missing",
1306
+ "/budget_request",
1307
+ "An atomic budget reservation callback is required before execution.",
1308
+ ),
1309
+ );
1310
+ } else if (needsBudgetReservation && issues.length === 0) {
1311
+ try {
1312
+ budgetReservation = await reserveBudget({
1313
+ subject_id: invocation.subject_id,
1314
+ policy_ref: invocation.policy_ref,
1315
+ invocation_id: invocation.invocation_id,
1316
+ semantic_digest: computedDigest,
1317
+ requested: structuredClone(invocation.budget_request),
1318
+ });
1319
+ if (
1320
+ !operationSucceeded(budgetReservation) ||
1321
+ !budgetReservation.reservation_id
1322
+ ) {
1323
+ issues.push(
1324
+ issue(
1325
+ "budget.reservation_failed",
1326
+ "/budget_request",
1327
+ "The requested budget was not atomically reserved.",
1328
+ ),
1329
+ );
1330
+ }
1331
+ } catch {
1332
+ issues.push(
1333
+ issue(
1334
+ "budget.reserver_error",
1335
+ "/budget_request",
1336
+ "Budget reservation did not complete safely.",
1337
+ ),
1338
+ );
1339
+ }
1340
+ }
1341
+
1342
+ return {
1343
+ ok: issues.length === 0,
1344
+ issues,
1345
+ computed_digest: computedDigest,
1346
+ confirmation_verification: confirmationVerification,
1347
+ intent_inspection: intentInspection,
1348
+ budget_reservation: budgetReservation,
1349
+ };
1350
+ }
1351
+
1352
+ export async function validateToolInvocationSemantics(context) {
1353
+ try {
1354
+ const sources = {
1355
+ invocation: context?.invocation,
1356
+ policy: context?.policy,
1357
+ capability: context?.capability,
1358
+ };
1359
+ const fingerprints = {
1360
+ invocation: canonicalizeJson(sources.invocation),
1361
+ policy: canonicalizeJson(sources.policy),
1362
+ capability: canonicalizeJson(sources.capability),
1363
+ };
1364
+ const snapshotContext = {
1365
+ ...(context ?? {}),
1366
+ invocation: structuredClone(sources.invocation),
1367
+ policy: structuredClone(sources.policy),
1368
+ capability: structuredClone(sources.capability),
1369
+ };
1370
+ const result =
1371
+ await validateToolInvocationSemanticsUnchecked(snapshotContext);
1372
+ const contextMutated =
1373
+ canonicalizeJson(sources.invocation) !== fingerprints.invocation ||
1374
+ canonicalizeJson(sources.policy) !== fingerprints.policy ||
1375
+ canonicalizeJson(sources.capability) !== fingerprints.capability;
1376
+
1377
+ if (contextMutated) {
1378
+ return {
1379
+ ...result,
1380
+ ok: false,
1381
+ issues: [
1382
+ ...result.issues,
1383
+ issue(
1384
+ "record.context_mutated",
1385
+ "/",
1386
+ "Invocation, policy, and capability inputs must remain unchanged during validation.",
1387
+ ),
1388
+ ],
1389
+ };
1390
+ }
1391
+ return result;
1392
+ } catch {
1393
+ return {
1394
+ ok: false,
1395
+ issues: [
1396
+ issue(
1397
+ "record.invocation_invalid",
1398
+ "/",
1399
+ "Tool invocation semantics require a complete schema-valid context.",
1400
+ ),
1401
+ ],
1402
+ computed_digest: null,
1403
+ confirmation_verification: null,
1404
+ intent_inspection: null,
1405
+ budget_reservation: null,
1406
+ };
1407
+ }
1408
+ }
1409
+
1410
+ function expectedPolicyDecision(status) {
1411
+ if (status === "denied") return "deny";
1412
+ if (status === "awaiting_confirmation") return "require_confirmation";
1413
+ if (status === "requires_human") return "escalate";
1414
+ if (allowReceiptStatuses.has(status)) return "allow";
1415
+ return null;
1416
+ }
1417
+
1418
+ function budgetMap(items) {
1419
+ return new Map(
1420
+ (items ?? []).map((item) => [
1421
+ item.resource,
1422
+ { quantity: item.quantity, unit: item.unit },
1423
+ ]),
1424
+ );
1425
+ }
1426
+
1427
+ function budgetArraysEqual(left, right) {
1428
+ if (!Array.isArray(left) || !Array.isArray(right)) return false;
1429
+ const leftMap = budgetMap(left);
1430
+ const rightMap = budgetMap(right);
1431
+ if (leftMap.size !== left.length || rightMap.size !== right.length) return false;
1432
+ if (leftMap.size !== rightMap.size) return false;
1433
+ for (const [resource, amount] of leftMap) {
1434
+ const other = rightMap.get(resource);
1435
+ if (!other || !canonicalEqual(amount, other)) return false;
1436
+ }
1437
+ return true;
1438
+ }
1439
+
1440
+ function validateToolReceiptSemanticsUnchecked({
1441
+ receipt,
1442
+ invocation,
1443
+ capability,
1444
+ priorReceiptById,
1445
+ }) {
1446
+ const issues = [...findUnsafePersistedData(receipt, "$")];
1447
+ let computedDigest = null;
1448
+
1449
+ if (!isPlainObject(receipt) || !isPlainObject(invocation)) {
1450
+ issues.push(
1451
+ issue(
1452
+ "record.receipt_context_invalid",
1453
+ "/",
1454
+ "Receipt semantics require schema-valid receipt and invocation objects.",
1455
+ ),
1456
+ );
1457
+ return { ok: false, issues, computed_digest: null };
1458
+ }
1459
+
1460
+ const descriptorResult = validateCapabilityDescriptor(capability);
1461
+ issues.push(...descriptorResult.issues);
1462
+ if (!descriptorResult.ok) {
1463
+ return { ok: false, issues, computed_digest: null };
1464
+ }
1465
+ for (const [path, items] of [
1466
+ ["/usage", receipt.usage],
1467
+ ["/budget_reservation/reserved", receipt.budget_reservation?.reserved],
1468
+ ["/budget_reservation/actual", receipt.budget_reservation?.actual],
1469
+ ]) {
1470
+ appendUniqueKeyIssues(
1471
+ items,
1472
+ (item) => item.resource,
1473
+ path,
1474
+ "receipt.budget_resource_duplicate",
1475
+ issues,
1476
+ );
1477
+ }
1478
+
1479
+ try {
1480
+ computedDigest = computeInvocationSemanticDigest(invocation);
1481
+ } catch {
1482
+ issues.push(
1483
+ issue(
1484
+ "digest.uncomputable",
1485
+ "/invocation_semantic_digest",
1486
+ "The bound invocation cannot be represented as canonical JSON.",
1487
+ ),
1488
+ );
1489
+ }
1490
+
1491
+ const exactBindings = [
1492
+ ["invocation_id", invocation.invocation_id],
1493
+ ["subject_id", invocation.subject_id],
1494
+ ["capability_id", invocation.capability_id],
1495
+ ["capability_version", invocation.capability_version],
1496
+ ];
1497
+ for (const [field, expected] of exactBindings) {
1498
+ if (receipt[field] !== expected) {
1499
+ issues.push(
1500
+ issue(
1501
+ "receipt.binding_mismatch",
1502
+ `/${field}`,
1503
+ "The receipt does not belong to the supplied invocation.",
1504
+ ),
1505
+ );
1506
+ }
1507
+ }
1508
+
1509
+ if (
1510
+ receipt.invocation_semantic_digest !== computedDigest ||
1511
+ invocation.semantic_digest !== computedDigest
1512
+ ) {
1513
+ issues.push(
1514
+ issue(
1515
+ "receipt.digest_mismatch",
1516
+ "/invocation_semantic_digest",
1517
+ "The receipt and invocation do not bind the same canonical intent.",
1518
+ ),
1519
+ );
1520
+ }
1521
+
1522
+ if (
1523
+ receipt.policy_decision?.policy_ref &&
1524
+ !canonicalEqual(receipt.policy_decision.policy_ref, invocation.policy_ref)
1525
+ ) {
1526
+ issues.push(
1527
+ issue(
1528
+ "receipt.policy_revision_mismatch",
1529
+ "/policy_decision/policy_ref",
1530
+ "The receipt cites a different policy revision.",
1531
+ ),
1532
+ );
1533
+ }
1534
+ if (
1535
+ receipt.policy_decision?.capability_registry_version !==
1536
+ capability.registry_version
1537
+ ) {
1538
+ issues.push(
1539
+ issue(
1540
+ "receipt.registry_version_mismatch",
1541
+ "/policy_decision/capability_registry_version",
1542
+ "The receipt cites a different capability registry snapshot.",
1543
+ ),
1544
+ );
1545
+ }
1546
+
1547
+ const expectedDecision = expectedPolicyDecision(receipt.status);
1548
+ if (
1549
+ expectedDecision === null ||
1550
+ receipt.policy_decision?.decision !== expectedDecision
1551
+ ) {
1552
+ issues.push(
1553
+ issue(
1554
+ "receipt.status_decision_mismatch",
1555
+ "/policy_decision/decision",
1556
+ "Receipt status and policy decision are inconsistent.",
1557
+ ),
1558
+ );
1559
+ }
1560
+
1561
+ for (const effect of receipt.effects ?? []) {
1562
+ if (!capability.effects?.includes(effect.effect_type)) {
1563
+ issues.push(
1564
+ issue(
1565
+ "receipt.effect_untrusted",
1566
+ "/effects",
1567
+ "The receipt reports an effect outside the trusted capability descriptor.",
1568
+ ),
1569
+ );
1570
+ }
1571
+ if (
1572
+ invocation.execution_mode !== "execute" &&
1573
+ effect.status !== "planned"
1574
+ ) {
1575
+ issues.push(
1576
+ issue(
1577
+ "receipt.non_execute_effect",
1578
+ "/effects",
1579
+ "Plan and dry-run invocations cannot report applied or unknown effects.",
1580
+ ),
1581
+ );
1582
+ }
1583
+ }
1584
+
1585
+ if (
1586
+ invocation.execution_mode !== "execute" &&
1587
+ ((receipt.usage?.length ?? 0) > 0 ||
1588
+ (receipt.privacy_egress?.length ?? 0) > 0)
1589
+ ) {
1590
+ issues.push(
1591
+ issue(
1592
+ "receipt.non_execute_activity",
1593
+ "/status",
1594
+ "Plan and dry-run invocations cannot report usage or external data egress.",
1595
+ ),
1596
+ );
1597
+ }
1598
+ const nonExecuteStatuses = new Set([
1599
+ "simulated",
1600
+ "denied",
1601
+ "awaiting_confirmation",
1602
+ "failed",
1603
+ "timed_out",
1604
+ "cancelled",
1605
+ "requires_human",
1606
+ ]);
1607
+ if (
1608
+ invocation.execution_mode !== "execute" &&
1609
+ !nonExecuteStatuses.has(receipt.status)
1610
+ ) {
1611
+ issues.push(
1612
+ issue(
1613
+ "receipt.non_execute_status",
1614
+ "/status",
1615
+ "Plan and dry-run invocations must use a non-execution receipt status.",
1616
+ ),
1617
+ );
1618
+ }
1619
+ if (
1620
+ invocation.execution_mode !== "execute" &&
1621
+ (receipt.executor_ref ||
1622
+ receipt.idempotency ||
1623
+ receipt.confirmation?.consumed_at)
1624
+ ) {
1625
+ issues.push(
1626
+ issue(
1627
+ "receipt.non_execute_proof",
1628
+ "/status",
1629
+ "Plan and dry-run receipts cannot contain executor, idempotency, or consumed-confirmation evidence.",
1630
+ ),
1631
+ );
1632
+ }
1633
+
1634
+ if (receipt.status === "simulated" && invocation.execution_mode === "execute") {
1635
+ issues.push(
1636
+ issue(
1637
+ "receipt.simulation_mode_mismatch",
1638
+ "/status",
1639
+ "A simulated receipt must belong to a plan or dry-run invocation.",
1640
+ ),
1641
+ );
1642
+ }
1643
+
1644
+ if (
1645
+ new Set(["denied", "awaiting_confirmation", "requires_human"]).has(
1646
+ receipt.status,
1647
+ ) &&
1648
+ ((receipt.effects?.length ?? 0) > 0 ||
1649
+ (receipt.usage?.length ?? 0) > 0 ||
1650
+ (receipt.privacy_egress?.length ?? 0) > 0 ||
1651
+ receipt.budget_reservation?.state !== "none")
1652
+ ) {
1653
+ issues.push(
1654
+ issue(
1655
+ "receipt.pre_execution_activity",
1656
+ "/status",
1657
+ "A pre-execution receipt cannot report effects, usage, egress, or a reservation.",
1658
+ ),
1659
+ );
1660
+ }
1661
+ if (
1662
+ new Set(["denied", "awaiting_confirmation", "requires_human"]).has(
1663
+ receipt.status,
1664
+ ) &&
1665
+ (receipt.started_at ||
1666
+ receipt.executor_ref ||
1667
+ receipt.confirmation?.consumed_at ||
1668
+ (receipt.idempotency &&
1669
+ !(
1670
+ receipt.status === "denied" &&
1671
+ receipt.idempotency.disposition === "conflict"
1672
+ )))
1673
+ ) {
1674
+ issues.push(
1675
+ issue(
1676
+ "receipt.pre_execution_proof",
1677
+ "/status",
1678
+ "A pre-execution receipt cannot claim execution, consumed confirmation, or non-conflict idempotency state.",
1679
+ ),
1680
+ );
1681
+ }
1682
+
1683
+ const mutating = capability.effects?.some((effect) =>
1684
+ mutatingEffects.has(effect),
1685
+ );
1686
+ if (receipt.idempotency) {
1687
+ if (!invocation.idempotency_key) {
1688
+ issues.push(
1689
+ issue(
1690
+ "receipt.unexpected_idempotency",
1691
+ "/idempotency",
1692
+ "A receipt cannot claim idempotency state when the invocation has no key.",
1693
+ ),
1694
+ );
1695
+ } else {
1696
+ const keyDigest = computeIdempotencyKeyDigest(invocation);
1697
+ if (
1698
+ receipt.idempotency.key_digest !== keyDigest ||
1699
+ receipt.idempotency.semantic_digest !== computedDigest
1700
+ ) {
1701
+ issues.push(
1702
+ issue(
1703
+ "receipt.idempotency_mismatch",
1704
+ "/idempotency",
1705
+ "The receipt idempotency record does not bind this invocation.",
1706
+ ),
1707
+ );
1708
+ }
1709
+ }
1710
+ if (
1711
+ receipt.idempotency.disposition === "conflict" &&
1712
+ receipt.status !== "denied"
1713
+ ) {
1714
+ issues.push(
1715
+ issue(
1716
+ "receipt.idempotency_conflict_executed",
1717
+ "/idempotency/disposition",
1718
+ "An idempotency conflict must be denied before execution.",
1719
+ ),
1720
+ );
1721
+ }
1722
+ if (receipt.idempotency.disposition === "replayed") {
1723
+ const prior =
1724
+ priorReceiptById instanceof Map
1725
+ ? priorReceiptById.get(receipt.deduplicated_from_receipt_id)
1726
+ : priorReceiptById?.[receipt.deduplicated_from_receipt_id];
1727
+ const priorRecordedAt = parseTimestamp(prior?.recorded_at);
1728
+ const replayRecordedAt = parseTimestamp(receipt.recorded_at);
1729
+ if (
1730
+ !isPlainObject(prior) ||
1731
+ prior.receipt_id !== receipt.deduplicated_from_receipt_id ||
1732
+ prior.receipt_id === receipt.receipt_id ||
1733
+ prior.subject_id !== receipt.subject_id ||
1734
+ prior.capability_id !== receipt.capability_id ||
1735
+ prior.capability_version !== receipt.capability_version ||
1736
+ prior.invocation_semantic_digest !== computedDigest ||
1737
+ prior.idempotency?.key_digest !== receipt.idempotency.key_digest ||
1738
+ prior.idempotency?.semantic_digest !== computedDigest ||
1739
+ !Number.isInteger(prior.attempt) ||
1740
+ prior.attempt >= receipt.attempt ||
1741
+ priorRecordedAt === null ||
1742
+ replayRecordedAt === null ||
1743
+ priorRecordedAt >= replayRecordedAt
1744
+ ) {
1745
+ issues.push(
1746
+ issue(
1747
+ "receipt.replay_source_invalid",
1748
+ "/deduplicated_from_receipt_id",
1749
+ "A replay must resolve to a prior receipt for the same subject and intent.",
1750
+ ),
1751
+ );
1752
+ }
1753
+ if (
1754
+ (receipt.usage?.length ?? 0) > 0 ||
1755
+ (receipt.privacy_egress?.length ?? 0) > 0
1756
+ ) {
1757
+ issues.push(
1758
+ issue(
1759
+ "receipt.replay_new_activity",
1760
+ "/idempotency/disposition",
1761
+ "A replay cannot report new usage or data egress.",
1762
+ ),
1763
+ );
1764
+ }
1765
+ }
1766
+ }
1767
+ if (
1768
+ mutating &&
1769
+ invocation.execution_mode === "execute" &&
1770
+ executedReceiptStatuses.has(receipt.status)
1771
+ ) {
1772
+ if (!receipt.idempotency) {
1773
+ issues.push(
1774
+ issue(
1775
+ "receipt.idempotency_missing",
1776
+ "/idempotency",
1777
+ "An executed mutating attempt requires an idempotency record.",
1778
+ ),
1779
+ );
1780
+ }
1781
+ }
1782
+
1783
+ const confirmationRequired =
1784
+ Boolean(invocation.confirmation_grant_ref) ||
1785
+ capability.effects?.some((effect) => accountEffects.has(effect));
1786
+ if (
1787
+ confirmationRequired &&
1788
+ invocation.execution_mode === "execute" &&
1789
+ executedReceiptStatuses.has(receipt.status)
1790
+ ) {
1791
+ const scopeDigest = computeConfirmationScopeDigest({
1792
+ invocationSemanticDigest: computedDigest,
1793
+ capabilityRegistryVersion: capability.registry_version,
1794
+ });
1795
+ if (
1796
+ receipt.confirmation?.grant_id !== invocation.confirmation_grant_ref ||
1797
+ receipt.confirmation?.scope_digest !== scopeDigest
1798
+ ) {
1799
+ issues.push(
1800
+ issue(
1801
+ "receipt.confirmation_mismatch",
1802
+ "/confirmation",
1803
+ "The receipt confirmation does not bind this invocation.",
1804
+ ),
1805
+ );
1806
+ }
1807
+ const verifiedAt = parseTimestamp(receipt.confirmation?.verified_at);
1808
+ const consumedAt = parseTimestamp(receipt.confirmation?.consumed_at);
1809
+ const executionStartedAt = parseTimestamp(receipt.started_at);
1810
+ const invocationRequestedAt = parseTimestamp(invocation.requested_at);
1811
+ const invocationExpiresAt = parseTimestamp(invocation.expires_at);
1812
+ if (
1813
+ verifiedAt === null ||
1814
+ consumedAt === null ||
1815
+ verifiedAt > consumedAt ||
1816
+ (executionStartedAt !== null && consumedAt > executionStartedAt) ||
1817
+ (invocationRequestedAt !== null && verifiedAt < invocationRequestedAt) ||
1818
+ (invocationExpiresAt !== null && consumedAt >= invocationExpiresAt)
1819
+ ) {
1820
+ issues.push(
1821
+ issue(
1822
+ "receipt.confirmation_time_order",
1823
+ "/confirmation/consumed_at",
1824
+ "Executed account effects require confirmation consumption before execution starts.",
1825
+ ),
1826
+ );
1827
+ }
1828
+ }
1829
+
1830
+ const budgetRequested = (invocation.budget_request?.length ?? 0) > 0;
1831
+ if (!budgetRequested) {
1832
+ if (
1833
+ receipt.budget_reservation?.state !== "none" ||
1834
+ (receipt.usage?.length ?? 0) > 0
1835
+ ) {
1836
+ issues.push(
1837
+ issue(
1838
+ "receipt.unrequested_budget",
1839
+ "/budget_reservation",
1840
+ "The receipt reports usage or a reservation without an approved request.",
1841
+ ),
1842
+ );
1843
+ }
1844
+ } else if (
1845
+ invocation.execution_mode === "execute" &&
1846
+ executedReceiptStatuses.has(receipt.status)
1847
+ ) {
1848
+ if (receipt.budget_reservation?.state === "none") {
1849
+ issues.push(
1850
+ issue(
1851
+ "receipt.budget_reservation_missing",
1852
+ "/budget_reservation",
1853
+ "An executed budgeted attempt must retain its reservation record.",
1854
+ ),
1855
+ );
1856
+ }
1857
+ if (
1858
+ !budgetArraysEqual(
1859
+ receipt.budget_reservation?.reserved,
1860
+ invocation.budget_request,
1861
+ )
1862
+ ) {
1863
+ issues.push(
1864
+ issue(
1865
+ "receipt.budget_reservation_mismatch",
1866
+ "/budget_reservation/reserved",
1867
+ "The receipt reservation does not match the invocation request.",
1868
+ ),
1869
+ );
1870
+ }
1871
+ const reservationRecordedAt = parseTimestamp(
1872
+ receipt.budget_reservation?.recorded_at,
1873
+ );
1874
+ const executionStartedAt = parseTimestamp(receipt.started_at);
1875
+ if (
1876
+ reservationRecordedAt === null ||
1877
+ executionStartedAt === null ||
1878
+ reservationRecordedAt > executionStartedAt
1879
+ ) {
1880
+ issues.push(
1881
+ issue(
1882
+ "receipt.budget_reserved_after_start",
1883
+ "/budget_reservation/recorded_at",
1884
+ "A budget reservation must be recorded before execution starts.",
1885
+ ),
1886
+ );
1887
+ }
1888
+ if (
1889
+ new Set(["succeeded", "failed", "timed_out", "partial"]).has(
1890
+ receipt.status,
1891
+ ) &&
1892
+ !new Set(["reconciled", "released"]).has(
1893
+ receipt.budget_reservation?.state,
1894
+ )
1895
+ ) {
1896
+ issues.push(
1897
+ issue(
1898
+ "receipt.budget_terminal_state",
1899
+ "/budget_reservation/state",
1900
+ "A known terminal budgeted attempt must reconcile or release its reservation.",
1901
+ ),
1902
+ );
1903
+ }
1904
+ }
1905
+
1906
+ if (
1907
+ ["reconciled", "released"].includes(receipt.budget_reservation?.state) &&
1908
+ !budgetArraysEqual(receipt.budget_reservation.actual, receipt.usage)
1909
+ ) {
1910
+ issues.push(
1911
+ issue(
1912
+ "receipt.budget_reconciliation_mismatch",
1913
+ "/budget_reservation/actual",
1914
+ "Reconciled budget actuals must match recorded usage.",
1915
+ ),
1916
+ );
1917
+ }
1918
+ if (["reconciled", "released"].includes(receipt.budget_reservation?.state)) {
1919
+ const reconciledAt = parseTimestamp(
1920
+ receipt.budget_reservation?.reconciled_at,
1921
+ );
1922
+ const executionStartedAt = parseTimestamp(receipt.started_at);
1923
+ const receiptRecordedAt = parseTimestamp(receipt.recorded_at);
1924
+ if (
1925
+ reconciledAt === null ||
1926
+ (executionStartedAt !== null && reconciledAt < executionStartedAt) ||
1927
+ (receiptRecordedAt !== null && reconciledAt > receiptRecordedAt)
1928
+ ) {
1929
+ issues.push(
1930
+ issue(
1931
+ "receipt.budget_reconciliation_time",
1932
+ "/budget_reservation/reconciled_at",
1933
+ "Budget reconciliation must occur after execution starts and before the receipt is recorded.",
1934
+ ),
1935
+ );
1936
+ }
1937
+ }
1938
+
1939
+ if (receipt.status === "unknown") {
1940
+ if (receipt.error?.retryable !== false) {
1941
+ issues.push(
1942
+ issue(
1943
+ "receipt.unknown_retryable",
1944
+ "/error/retryable",
1945
+ "An unknown external outcome cannot be automatically retried.",
1946
+ ),
1947
+ );
1948
+ }
1949
+ if (!new Set(["reconcile", "human_handoff"]).has(receipt.recovery?.action)) {
1950
+ issues.push(
1951
+ issue(
1952
+ "receipt.unknown_recovery",
1953
+ "/recovery/action",
1954
+ "An unknown outcome requires reconciliation or human handoff.",
1955
+ ),
1956
+ );
1957
+ }
1958
+ if (budgetRequested && receipt.budget_reservation?.state !== "held") {
1959
+ issues.push(
1960
+ issue(
1961
+ "receipt.unknown_budget_released",
1962
+ "/budget_reservation/state",
1963
+ "Budget must remain held until an unknown outcome is reconciled.",
1964
+ ),
1965
+ );
1966
+ }
1967
+ }
1968
+
1969
+ if (
1970
+ receipt.status === "succeeded" &&
1971
+ (receipt.error ||
1972
+ (receipt.effects ?? []).some((effect) => effect.status === "unknown"))
1973
+ ) {
1974
+ issues.push(
1975
+ issue(
1976
+ "receipt.success_contradiction",
1977
+ "/status",
1978
+ "A successful receipt cannot contain an error or unknown effect.",
1979
+ ),
1980
+ );
1981
+ }
1982
+ if (
1983
+ receipt.status === "succeeded" &&
1984
+ (receipt.effects ?? []).some((effect) => effect.status === "planned")
1985
+ ) {
1986
+ issues.push(
1987
+ issue(
1988
+ "receipt.success_effect_incomplete",
1989
+ "/effects",
1990
+ "A successful receipt cannot leave an effect in planned state.",
1991
+ ),
1992
+ );
1993
+ }
1994
+
1995
+ if (
1996
+ receipt.status === "accepted" &&
1997
+ (receipt.effects ?? []).some((effect) => effect.status !== "planned")
1998
+ ) {
1999
+ issues.push(
2000
+ issue(
2001
+ "receipt.accepted_effect_completed",
2002
+ "/effects",
2003
+ "An accepted request cannot claim that an effect already completed.",
2004
+ ),
2005
+ );
2006
+ }
2007
+
2008
+ if (
2009
+ receipt.status === "timed_out" &&
2010
+ (receipt.effects ?? []).some((effect) => effect.status === "unknown")
2011
+ ) {
2012
+ issues.push(
2013
+ issue(
2014
+ "receipt.timeout_unknown_effect",
2015
+ "/status",
2016
+ "A timeout with an uncertain external effect must use unknown status.",
2017
+ ),
2018
+ );
2019
+ }
2020
+
2021
+ if (
2022
+ receipt.status !== "unknown" &&
2023
+ (receipt.effects ?? []).some((effect) => effect.status === "unknown")
2024
+ ) {
2025
+ issues.push(
2026
+ issue(
2027
+ "receipt.unknown_effect_status",
2028
+ "/status",
2029
+ "Any uncertain external effect requires unknown receipt status.",
2030
+ ),
2031
+ );
2032
+ }
2033
+
2034
+ if (
2035
+ receipt.status === "cancelled" &&
2036
+ (receipt.effects ?? []).some((effect) =>
2037
+ new Set(["applied", "unknown"]).has(effect.status),
2038
+ )
2039
+ ) {
2040
+ issues.push(
2041
+ issue(
2042
+ "receipt.cancelled_effect",
2043
+ "/effects",
2044
+ "A cancelled receipt cannot contain applied or unknown effects.",
2045
+ ),
2046
+ );
2047
+ }
2048
+ if (
2049
+ receipt.status === "cancelled" &&
2050
+ budgetRequested &&
2051
+ receipt.budget_reservation?.state !== "released"
2052
+ ) {
2053
+ issues.push(
2054
+ issue(
2055
+ "receipt.cancelled_budget_not_released",
2056
+ "/budget_reservation/state",
2057
+ "A cancelled budgeted attempt must release its reservation.",
2058
+ ),
2059
+ );
2060
+ }
2061
+
2062
+ if (
2063
+ receipt.status === "requires_human" &&
2064
+ receipt.recovery?.action !== "human_handoff"
2065
+ ) {
2066
+ issues.push(
2067
+ issue(
2068
+ "receipt.human_recovery_missing",
2069
+ "/recovery/action",
2070
+ "A human escalation receipt requires human_handoff recovery.",
2071
+ ),
2072
+ );
2073
+ }
2074
+
2075
+ const startedAt = parseTimestamp(receipt.started_at);
2076
+ const finishedAt = parseTimestamp(receipt.finished_at);
2077
+ const recordedAt = parseTimestamp(receipt.recorded_at);
2078
+ const invocationRequestedAt = parseTimestamp(invocation.requested_at);
2079
+ const invocationExpiresAt = parseTimestamp(invocation.expires_at);
2080
+ if (
2081
+ (startedAt !== null && finishedAt !== null && startedAt > finishedAt) ||
2082
+ (finishedAt !== null && recordedAt !== null && finishedAt > recordedAt)
2083
+ ) {
2084
+ issues.push(
2085
+ issue(
2086
+ "receipt.time_order",
2087
+ "/finished_at",
2088
+ "Receipt timestamps are not chronological.",
2089
+ ),
2090
+ );
2091
+ }
2092
+ if (
2093
+ executedReceiptStatuses.has(receipt.status) &&
2094
+ (startedAt === null ||
2095
+ invocationRequestedAt === null ||
2096
+ invocationExpiresAt === null ||
2097
+ startedAt < invocationRequestedAt ||
2098
+ startedAt >= invocationExpiresAt)
2099
+ ) {
2100
+ issues.push(
2101
+ issue(
2102
+ "receipt.execution_outside_invocation_window",
2103
+ "/started_at",
2104
+ "Execution must start within the bound invocation validity window.",
2105
+ ),
2106
+ );
2107
+ }
2108
+
2109
+ const plannedDestinations = invocation.data_manifest?.planned_destinations ?? [];
2110
+ const plannedDataClasses = new Set(
2111
+ invocation.data_manifest?.data_classes ?? [],
2112
+ );
2113
+ for (const egress of receipt.privacy_egress ?? []) {
2114
+ if (!plannedDataClasses.has(egress.data_class)) {
2115
+ issues.push(
2116
+ issue(
2117
+ "receipt.egress_data_unplanned",
2118
+ "/privacy_egress",
2119
+ "The receipt reports an unplanned data class.",
2120
+ ),
2121
+ );
2122
+ }
2123
+ const planned = plannedDestinations.some(
2124
+ (destination) =>
2125
+ destination.destination_type === egress.destination?.destination_type &&
2126
+ destination.system === egress.destination?.system &&
2127
+ destination.purpose_id === egress.destination?.purpose_id,
2128
+ );
2129
+ if (!planned) {
2130
+ issues.push(
2131
+ issue(
2132
+ "receipt.egress_destination_unplanned",
2133
+ "/privacy_egress",
2134
+ "The receipt reports an unplanned destination.",
2135
+ ),
2136
+ );
2137
+ }
2138
+ }
2139
+
2140
+ return { ok: issues.length === 0, issues, computed_digest: computedDigest };
2141
+ }
2142
+
2143
+ export function validateToolReceiptSemantics(context) {
2144
+ try {
2145
+ return validateToolReceiptSemanticsUnchecked(context ?? {});
2146
+ } catch {
2147
+ return {
2148
+ ok: false,
2149
+ issues: [
2150
+ issue(
2151
+ "record.receipt_context_invalid",
2152
+ "/",
2153
+ "Receipt semantics require a complete schema-valid receipt and invocation context.",
2154
+ ),
2155
+ ],
2156
+ computed_digest: null,
2157
+ };
2158
+ }
2159
+ }
2160
+
2161
+ export function validateTrackRefSemantics(trackRef) {
2162
+ const issues = [];
2163
+ const createdAt = parseTimestamp(trackRef.created_at);
2164
+ const resolvedAt = parseTimestamp(trackRef.resolved_at);
2165
+
2166
+ if (resolvedAt !== null && createdAt !== null && resolvedAt < createdAt) {
2167
+ issues.push(
2168
+ issue(
2169
+ "track.resolution_time_order",
2170
+ "/resolved_at",
2171
+ "Track resolution cannot predate record creation.",
2172
+ ),
2173
+ );
2174
+ }
2175
+ if (trackRef.revision === 1 && trackRef.supersedes) {
2176
+ issues.push(
2177
+ issue(
2178
+ "track.initial_supersedes",
2179
+ "/supersedes",
2180
+ "An initial TrackRef revision cannot supersede another revision.",
2181
+ ),
2182
+ );
2183
+ }
2184
+ if (trackRef.revision > 1) {
2185
+ if (
2186
+ trackRef.supersedes?.track_ref_id !== trackRef.track_ref_id ||
2187
+ trackRef.supersedes?.revision !== trackRef.revision - 1
2188
+ ) {
2189
+ issues.push(
2190
+ issue(
2191
+ "track.supersedes_mismatch",
2192
+ "/supersedes",
2193
+ "A TrackRef revision must supersede the immediately preceding revision of the same identity.",
2194
+ ),
2195
+ );
2196
+ }
2197
+ }
2198
+ if (trackRef.merged_into?.track_ref_id === trackRef.track_ref_id) {
2199
+ issues.push(
2200
+ issue(
2201
+ "track.merge_cycle",
2202
+ "/merged_into",
2203
+ "A TrackRef cannot merge into itself.",
2204
+ ),
2205
+ );
2206
+ }
2207
+ if (trackRef.candidate_track_ref_ids?.includes(trackRef.track_ref_id)) {
2208
+ issues.push(
2209
+ issue(
2210
+ "track.ambiguous_self_candidate",
2211
+ "/candidate_track_ref_ids",
2212
+ "An ambiguous TrackRef cannot list itself as a candidate.",
2213
+ ),
2214
+ );
2215
+ }
2216
+
2217
+ return { ok: issues.length === 0, issues };
2218
+ }
2219
+
2220
+ export function validateListeningEventSemantics(listeningEvent) {
2221
+ const issues = [];
2222
+ const occurredAt = parseTimestamp(listeningEvent.occurred_at);
2223
+ const ingestedAt = parseTimestamp(listeningEvent.ingested_at);
2224
+ if (occurredAt !== null && ingestedAt !== null && occurredAt > ingestedAt) {
2225
+ issues.push(
2226
+ issue(
2227
+ "listening.time_order",
2228
+ "/ingested_at",
2229
+ "A listening event cannot be ingested before it occurred.",
2230
+ ),
2231
+ );
2232
+ }
2233
+ return { ok: issues.length === 0, issues };
2234
+ }
2235
+
2236
+ export function validateTasteEventSemantics(tasteEvent) {
2237
+ const issues = [];
2238
+ const occurredAt = parseTimestamp(tasteEvent.occurred_at);
2239
+ const recordedAt = parseTimestamp(tasteEvent.recorded_at);
2240
+ if (occurredAt !== null && recordedAt !== null && occurredAt > recordedAt) {
2241
+ issues.push(
2242
+ issue(
2243
+ "taste.time_order",
2244
+ "/recorded_at",
2245
+ "A taste event cannot be recorded before it occurred.",
2246
+ ),
2247
+ );
2248
+ }
2249
+
2250
+ if (tasteEvent.signal_type === "rating") {
2251
+ const { value, scale_min: scaleMin, scale_max: scaleMax } =
2252
+ tasteEvent.rating ?? {};
2253
+ if (
2254
+ typeof value !== "number" ||
2255
+ typeof scaleMin !== "number" ||
2256
+ typeof scaleMax !== "number" ||
2257
+ scaleMin >= scaleMax ||
2258
+ value < scaleMin ||
2259
+ value > scaleMax
2260
+ ) {
2261
+ issues.push(
2262
+ issue(
2263
+ "taste.rating_range",
2264
+ "/rating",
2265
+ "A rating value must fall within a strictly increasing scale.",
2266
+ ),
2267
+ );
2268
+ }
2269
+ }
2270
+ if (
2271
+ tasteEvent.operation === "retract" &&
2272
+ tasteEvent.retracts_taste_event_id === tasteEvent.taste_event_id
2273
+ ) {
2274
+ issues.push(
2275
+ issue(
2276
+ "taste.self_retraction",
2277
+ "/retracts_taste_event_id",
2278
+ "A taste event cannot retract itself.",
2279
+ ),
2280
+ );
2281
+ }
2282
+
2283
+ return { ok: issues.length === 0, issues };
2284
+ }
2285
+
2286
+ export function contractRecordKey(recordType, recordId) {
2287
+ return `${recordType}:${recordId}`;
2288
+ }
2289
+
2290
+ function recordFromCollection(records, recordType, recordId) {
2291
+ const key = contractRecordKey(recordType, recordId);
2292
+ if (records instanceof Map) return records.get(key);
2293
+ return records?.[key];
2294
+ }
2295
+
2296
+ function recordsFromCollection(records) {
2297
+ if (records instanceof Map) return [...records.values()];
2298
+ return Object.values(records ?? {});
2299
+ }
2300
+
2301
+ function recordIdForType(record, recordType) {
2302
+ if (recordType === "library_track_observation") {
2303
+ return record.library_track_observation_id;
2304
+ }
2305
+ if (recordType === "listening_event") return record.listening_event_id;
2306
+ if (recordType === "taste_event") return record.taste_event_id;
2307
+ return undefined;
2308
+ }
2309
+
2310
+ function basisTimestamp(record, recordType) {
2311
+ if (recordType === "library_track_observation") {
2312
+ return parseTimestamp(record.observed_at);
2313
+ }
2314
+ return parseTimestamp(record.occurred_at);
2315
+ }
2316
+
2317
+ export function validateLibraryTrackObservationSemantics(
2318
+ observation,
2319
+ { trackRef } = {},
2320
+ ) {
2321
+ const issues = [];
2322
+ if (!isPlainObject(observation)) {
2323
+ return {
2324
+ ok: false,
2325
+ issues: [
2326
+ issue(
2327
+ "observation.record_invalid",
2328
+ "/",
2329
+ "LibraryTrackObservation semantics require an object.",
2330
+ ),
2331
+ ],
2332
+ };
2333
+ }
2334
+
2335
+ if (
2336
+ observation.provenance?.source_kind !== "import" ||
2337
+ observation.source_batch_ref !== observation.provenance?.import_batch_id
2338
+ ) {
2339
+ issues.push(
2340
+ issue(
2341
+ "observation.provenance_mismatch",
2342
+ "/provenance",
2343
+ "A library observation must bind its exact import batch.",
2344
+ ),
2345
+ );
2346
+ }
2347
+ if (observation.observed_at !== observation.provenance?.captured_at) {
2348
+ issues.push(
2349
+ issue(
2350
+ "observation.capture_time_mismatch",
2351
+ "/observed_at",
2352
+ "Observation and provenance capture timestamps must match.",
2353
+ ),
2354
+ );
2355
+ }
2356
+
2357
+ const observedAt = parseTimestamp(observation.observed_at);
2358
+ for (const [field, value] of [
2359
+ ["last_played_at", observation.aggregate_state?.last_played_at],
2360
+ ["last_skipped_at", observation.aggregate_state?.last_skipped_at],
2361
+ ["date_added", observation.library_state?.date_added],
2362
+ ["date_modified", observation.library_state?.date_modified],
2363
+ ]) {
2364
+ const stateTimestamp = parseTimestamp(value);
2365
+ if (
2366
+ observedAt !== null &&
2367
+ stateTimestamp !== null &&
2368
+ stateTimestamp > observedAt
2369
+ ) {
2370
+ issues.push(
2371
+ issue(
2372
+ "observation.state_after_capture",
2373
+ field.startsWith("date_")
2374
+ ? `/library_state/${field}`
2375
+ : `/aggregate_state/${field}`,
2376
+ "Observed provider state cannot be later than its snapshot capture.",
2377
+ ),
2378
+ );
2379
+ }
2380
+ }
2381
+
2382
+ if (
2383
+ trackRef &&
2384
+ (trackRef.track_ref_id !== observation.track_ref_id ||
2385
+ trackRef.revision !== observation.track_ref_revision)
2386
+ ) {
2387
+ issues.push(
2388
+ issue(
2389
+ "observation.track_ref_mismatch",
2390
+ "/track_ref_id",
2391
+ "A library observation must bind the exact TrackRef revision supplied by the registry.",
2392
+ ),
2393
+ );
2394
+ }
2395
+
2396
+ return { ok: issues.length === 0, issues };
2397
+ }
2398
+
2399
+ function entityIdentity(entityRef) {
2400
+ const identity = {};
2401
+ for (const field of [
2402
+ "entity_type",
2403
+ "entity_id",
2404
+ "entity_revision",
2405
+ "entity_key",
2406
+ ]) {
2407
+ if (entityRef?.[field] !== undefined) identity[field] = entityRef[field];
2408
+ }
2409
+ return identity;
2410
+ }
2411
+
2412
+ export function validateProfileEvidenceSemantics({ evidence, records }) {
2413
+ const issues = [];
2414
+ const resolvedBasis = [];
2415
+
2416
+ for (const basisRef of evidence.basis_refs ?? []) {
2417
+ const record = recordFromCollection(
2418
+ records,
2419
+ basisRef.record_type,
2420
+ basisRef.record_id,
2421
+ );
2422
+ if (!record) {
2423
+ issues.push(
2424
+ issue(
2425
+ "evidence.basis_missing",
2426
+ "/basis_refs",
2427
+ "A referenced evidence record does not exist.",
2428
+ ),
2429
+ );
2430
+ continue;
2431
+ }
2432
+ if (recordIdForType(record, basisRef.record_type) !== basisRef.record_id) {
2433
+ issues.push(
2434
+ issue(
2435
+ "evidence.basis_identity_mismatch",
2436
+ "/basis_refs",
2437
+ "A referenced evidence record has a different identity.",
2438
+ ),
2439
+ );
2440
+ }
2441
+ if (record.subject_id !== evidence.subject_id) {
2442
+ issues.push(
2443
+ issue(
2444
+ "evidence.subject_mismatch",
2445
+ "/basis_refs",
2446
+ "Evidence cannot cross subject boundaries.",
2447
+ ),
2448
+ );
2449
+ }
2450
+ resolvedBasis.push({ basisRef, record });
2451
+ }
2452
+
2453
+ const windowStart = parseTimestamp(evidence.evidence_window?.start_at);
2454
+ const windowEnd = parseTimestamp(evidence.evidence_window?.end_at);
2455
+ const createdAt = parseTimestamp(evidence.created_at);
2456
+ if (evidence.evidence_window && windowStart > windowEnd) {
2457
+ issues.push(
2458
+ issue(
2459
+ "evidence.window_order",
2460
+ "/evidence_window/end_at",
2461
+ "The evidence window end must follow its start.",
2462
+ ),
2463
+ );
2464
+ }
2465
+ if (
2466
+ evidence.evidence_window &&
2467
+ createdAt !== null &&
2468
+ windowEnd !== null &&
2469
+ windowEnd > createdAt
2470
+ ) {
2471
+ issues.push(
2472
+ issue(
2473
+ "evidence.window_after_creation",
2474
+ "/evidence_window/end_at",
2475
+ "The evidence window cannot extend past record creation.",
2476
+ ),
2477
+ );
2478
+ }
2479
+ for (const { basisRef, record } of resolvedBasis) {
2480
+ const occurredAt = basisTimestamp(record, basisRef.record_type);
2481
+ if (evidence.evidence_window) {
2482
+ if (
2483
+ occurredAt !== null &&
2484
+ (occurredAt < windowStart || occurredAt > windowEnd)
2485
+ ) {
2486
+ issues.push(
2487
+ issue(
2488
+ "evidence.basis_outside_window",
2489
+ "/basis_refs",
2490
+ "A referenced record falls outside the declared evidence window.",
2491
+ ),
2492
+ );
2493
+ }
2494
+ }
2495
+ if (occurredAt !== null && createdAt !== null && occurredAt > createdAt) {
2496
+ issues.push(
2497
+ issue(
2498
+ "evidence.basis_after_creation",
2499
+ "/basis_refs",
2500
+ "A profile claim cannot depend on a future record.",
2501
+ ),
2502
+ );
2503
+ }
2504
+ }
2505
+
2506
+ const retractedTasteIds = new Set(
2507
+ recordsFromCollection(records)
2508
+ .filter(
2509
+ (record) =>
2510
+ record?.operation === "retract" &&
2511
+ record.subject_id === evidence.subject_id &&
2512
+ (createdAt === null ||
2513
+ parseTimestamp(record.recorded_at ?? record.occurred_at) <= createdAt),
2514
+ )
2515
+ .map((record) => record.retracts_taste_event_id)
2516
+ .filter(Boolean),
2517
+ );
2518
+ for (const { basisRef, record } of resolvedBasis) {
2519
+ if (
2520
+ basisRef.record_type === "taste_event" &&
2521
+ (record.operation !== "assert" ||
2522
+ retractedTasteIds.has(record.taste_event_id))
2523
+ ) {
2524
+ issues.push(
2525
+ issue(
2526
+ "evidence.taste_retracted",
2527
+ "/basis_refs",
2528
+ "A retracted taste assertion cannot support profile evidence.",
2529
+ ),
2530
+ );
2531
+ }
2532
+ }
2533
+
2534
+ if (evidence.derivation?.kind === "direct") {
2535
+ if (resolvedBasis.length !== 1) {
2536
+ issues.push(
2537
+ issue(
2538
+ "evidence.direct_basis_count",
2539
+ "/basis_refs",
2540
+ "Direct evidence must bind exactly one record.",
2541
+ ),
2542
+ );
2543
+ } else {
2544
+ const [{ basisRef, record }] = resolvedBasis;
2545
+ if (basisRef.record_type !== "taste_event" || record.operation !== "assert") {
2546
+ issues.push(
2547
+ issue(
2548
+ "evidence.direct_basis_type",
2549
+ "/basis_refs",
2550
+ "Direct profile evidence must bind one explicit taste assertion.",
2551
+ ),
2552
+ );
2553
+ }
2554
+ if (
2555
+ evidence.claim?.value?.value_type !== "entity" ||
2556
+ !canonicalEqual(
2557
+ entityIdentity(evidence.claim?.value?.entity_ref),
2558
+ entityIdentity(record.target),
2559
+ )
2560
+ ) {
2561
+ issues.push(
2562
+ issue(
2563
+ "evidence.direct_target_mismatch",
2564
+ "/claim/value/entity_ref",
2565
+ "A direct entity claim must match the exact asserted target revision.",
2566
+ ),
2567
+ );
2568
+ }
2569
+ }
2570
+ }
2571
+
2572
+ if (
2573
+ resolvedBasis.some(
2574
+ ({ basisRef }) =>
2575
+ basisRef.record_type === "library_track_observation",
2576
+ ) &&
2577
+ !new Set(["rule", "aggregate"]).has(evidence.derivation?.kind)
2578
+ ) {
2579
+ issues.push(
2580
+ issue(
2581
+ "evidence.observation_derivation_invalid",
2582
+ "/derivation/kind",
2583
+ "Observation-derived evidence must use a rule or aggregate derivation.",
2584
+ ),
2585
+ );
2586
+ }
2587
+
2588
+ if (
2589
+ new Set(["rule", "aggregate", "model"]).has(evidence.derivation?.kind) &&
2590
+ !evidence.derivation?.parameters_digest
2591
+ ) {
2592
+ issues.push(
2593
+ issue(
2594
+ "evidence.parameters_digest_missing",
2595
+ "/derivation/parameters_digest",
2596
+ "Non-direct derivations must bind their parameters.",
2597
+ ),
2598
+ );
2599
+ }
2600
+
2601
+ const expiresAt = parseTimestamp(evidence.expires_at);
2602
+ if (expiresAt !== null && createdAt !== null && expiresAt <= createdAt) {
2603
+ issues.push(
2604
+ issue(
2605
+ "evidence.expiry_order",
2606
+ "/expires_at",
2607
+ "Profile evidence expiry must follow its creation time.",
2608
+ ),
2609
+ );
2610
+ }
2611
+
2612
+ if (evidence.supersedes_profile_evidence_id) {
2613
+ const superseded = recordFromCollection(
2614
+ records,
2615
+ "profile_evidence",
2616
+ evidence.supersedes_profile_evidence_id,
2617
+ );
2618
+ if (!superseded || superseded.subject_id !== evidence.subject_id) {
2619
+ issues.push(
2620
+ issue(
2621
+ "evidence.superseded_record_invalid",
2622
+ "/supersedes_profile_evidence_id",
2623
+ "Superseded profile evidence must exist for the same subject.",
2624
+ ),
2625
+ );
2626
+ }
2627
+ }
2628
+
2629
+ return { ok: issues.length === 0, issues };
2630
+ }