sentinelayer-cli 0.46.4 → 0.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.
Files changed (137) hide show
  1. package/README.md +63 -3
  2. package/package.json +12 -7
  3. package/skills/omar-gate-setup/SKILL.md +103 -0
  4. package/skills/senti-agent-identity/SKILL.md +80 -0
  5. package/skills/senti-judge/SKILL.md +139 -0
  6. package/skills/senti-room-notifications/SKILL.md +73 -0
  7. package/skills/senti-room-notifications/listen-wrapper.cjs +150 -0
  8. package/skills/senti-room-notifications/package.json +1 -0
  9. package/skills/senti-room-notifications/test-classifier.js +109 -0
  10. package/skills/senti-room-notifications/test-receipt.js +54 -0
  11. package/skills/sentinelayer-cli/SKILL.md +222 -0
  12. package/src/agents/devtestbot/runner.js +22 -11
  13. package/src/agents/devtestbot/tool.js +24 -11
  14. package/src/agents/jules/fix-cycle.js +7 -1
  15. package/src/agents/jules/tools/runtime-audit.js +17 -12
  16. package/src/agents/shared-tools/dispatch-core.js +10 -5
  17. package/src/ai/aidenid.js +4 -2
  18. package/src/ai/domain-target-store.js +11 -5
  19. package/src/ai/identity-store.js +11 -5
  20. package/src/ai/site-store.js +8 -4
  21. package/src/audit/orchestrator.js +21 -14
  22. package/src/audit/package.js +6 -4
  23. package/src/audit/persona-loop.js +7 -0
  24. package/src/audit/replay.js +4 -2
  25. package/src/auth/gate.js +46 -8
  26. package/src/auth/service.js +34 -15
  27. package/src/auth/session-store.js +105 -47
  28. package/src/board/evidence-resolvers.js +3 -4
  29. package/src/cli.js +23 -3
  30. package/src/commands/ai/identity-lifecycle.js +30 -26
  31. package/src/commands/ai/provision-governance.js +32 -24
  32. package/src/commands/ai/shared.js +6 -4
  33. package/src/commands/audit.js +14 -12
  34. package/src/commands/auth.js +24 -4
  35. package/src/commands/board.js +2 -2
  36. package/src/commands/chat.js +8 -6
  37. package/src/commands/judge.js +125 -0
  38. package/src/commands/legacy-args.js +1 -0
  39. package/src/commands/mcp.js +45 -52
  40. package/src/commands/omargate.js +2 -1
  41. package/src/commands/plugin.js +11 -3
  42. package/src/commands/review.js +9 -4
  43. package/src/commands/scan.js +7 -7
  44. package/src/commands/session.js +607 -57
  45. package/src/commands/skills.js +45 -0
  46. package/src/commands/watch.js +9 -7
  47. package/src/config/output-filesystem.js +156 -0
  48. package/src/config/schema.js +4 -2
  49. package/src/config/service.js +40 -4
  50. package/src/cost/history.js +15 -9
  51. package/src/daemon/artifact-lineage.js +15 -12
  52. package/src/daemon/assignment-ledger.js +31 -25
  53. package/src/daemon/ast-drift.js +7 -3
  54. package/src/daemon/budget-governor.js +26 -20
  55. package/src/daemon/error-worker.js +28 -22
  56. package/src/daemon/fix-cycle.js +5 -4
  57. package/src/daemon/hybrid-mapper.js +19 -16
  58. package/src/daemon/ingest-refresh.js +4 -0
  59. package/src/daemon/jira-lifecycle.js +21 -17
  60. package/src/daemon/operator-control.js +18 -15
  61. package/src/daemon/reliability-lane.js +18 -15
  62. package/src/daemon/scope-engine.js +12 -8
  63. package/src/daemon/watchdog.js +15 -12
  64. package/src/engram/build-state.js +24 -3
  65. package/src/engram/capability-index.js +367 -0
  66. package/src/engram/compile-graph.js +1063 -0
  67. package/src/engram/contracts/action-receipt.js +82 -0
  68. package/src/engram/contracts/capability-index.js +259 -0
  69. package/src/engram/contracts/capability.js +377 -0
  70. package/src/engram/contracts/content-hash.js +8 -0
  71. package/src/engram/contracts/evidence.js +258 -0
  72. package/src/engram/contracts/graph.js +594 -0
  73. package/src/engram/contracts/identity.js +8 -0
  74. package/src/engram/contracts/index.js +149 -0
  75. package/src/engram/contracts/media-asset.js +85 -0
  76. package/src/engram/contracts/money.js +249 -0
  77. package/src/engram/contracts/source-snapshot.js +57 -0
  78. package/src/engram/contracts/task-context-pack.js +114 -0
  79. package/src/engram/contracts/temporal-segment.js +72 -0
  80. package/src/engram/contracts/versions.js +96 -0
  81. package/src/engram/extract-html.js +692 -0
  82. package/src/engram/graph-store.js +877 -0
  83. package/src/engram/index.js +98 -14
  84. package/src/engram/ingest-site.js +132 -0
  85. package/src/engram/live-state.js +1180 -0
  86. package/src/engram/namespace.js +34 -1
  87. package/src/engram/retrieve-site.js +753 -0
  88. package/src/engram/safe-reader.js +597 -0
  89. package/src/engram/source-intake.js +251 -0
  90. package/src/engram/store.js +227 -9
  91. package/src/ingest/engine.js +21 -28
  92. package/src/judge/verdict.js +305 -0
  93. package/src/legacy-cli.js +96 -19
  94. package/src/mcp/bridge-inputs.js +28 -4
  95. package/src/mcp/cli-command-tools.js +70 -0
  96. package/src/mcp/registry.js +17 -13
  97. package/src/mcp/session-stdio-server.js +297 -19
  98. package/src/memory/blackboard.js +5 -4
  99. package/src/net/gh-api-path.js +35 -9
  100. package/src/net/test-egress-guard.js +16 -4
  101. package/src/plugin/manifest.js +12 -8
  102. package/src/review/ai-review.js +110 -10
  103. package/src/review/investor-dd-devtestbot.js +17 -6
  104. package/src/review/investor-dd-orchestrator.js +21 -13
  105. package/src/review/local-review.js +26 -16
  106. package/src/review/omargate-cache.js +5 -5
  107. package/src/review/omargate-orchestrator.js +89 -17
  108. package/src/review/replay.js +9 -4
  109. package/src/review/report.js +22 -13
  110. package/src/review/typesafe-severity.js +201 -0
  111. package/src/session/admission-auth.js +34 -7
  112. package/src/session/admission.js +322 -71
  113. package/src/session/agent-access.js +137 -0
  114. package/src/session/agent-registry.js +2 -1
  115. package/src/session/attachment-summary.js +77 -0
  116. package/src/session/edit-summary.js +19 -0
  117. package/src/session/invitations.js +65 -1
  118. package/src/session/mentions.js +17 -9
  119. package/src/session/message-edits.js +5 -4
  120. package/src/session/preview.js +10 -2
  121. package/src/session/recap.js +3 -2
  122. package/src/session/room-files.js +519 -0
  123. package/src/session/route-classes.js +22 -8
  124. package/src/session/setup-guides.js +4 -0
  125. package/src/session/standup.js +192 -0
  126. package/src/session/sync.js +229 -10
  127. package/src/session/tickets.js +41 -0
  128. package/src/session/transcript.js +2 -1
  129. package/src/session/wake/resolve-target.js +7 -3
  130. package/src/skills/listener-runtime.cjs +92 -0
  131. package/src/skills/service.js +207 -0
  132. package/src/swarm/factory.js +5 -5
  133. package/src/swarm/pentest.js +9 -9
  134. package/src/swarm/report.js +4 -4
  135. package/src/swarm/runtime.js +15 -12
  136. package/src/swarm/scenario-dsl.js +7 -5
  137. package/src/telemetry/ledger.js +16 -7
@@ -0,0 +1,1180 @@
1
+ /**
2
+ * ENGRAM §EW14 — Live-state hydration: freshness classes, source versioning,
3
+ * revalidation gates, TOCTOU guards, and commerce context.
4
+ *
5
+ * ## Freshness Data Classes
6
+ *
7
+ * Every fact in the ENGRAM graph carries an explicit freshness class:
8
+ * static — immutable facts (e.g. a SHA hash, a contract version)
9
+ * stale_readable — cacheable with a known staleness deadline; reads are
10
+ * marked with an asOf timestamp, never claimed fresh
11
+ * live_required — MUST be fetched from the authoritative source at
12
+ * action time; cached values are insufficient
13
+ * private_session — scoped to a caller session; never shared or cached
14
+ *
15
+ * ## Source Versioning
16
+ *
17
+ * Source adapters carry ETags, content versions, and staleness deadlines.
18
+ * Every fetch records the version it observed. A mismatch at action time
19
+ * triggers re-fetch, never silent substitution.
20
+ *
21
+ * ## TOCTOU Guard
22
+ *
23
+ * Cached graph facts cannot authorize a transaction. Before any
24
+ * authorization-significant action, the caller must revalidate against the
25
+ * live source. The revalidation gate (`mustRevalidate`) enforces this:
26
+ * a graph-level read returns facts with an explicit staleness marker;
27
+ * an authorization path requires a fresh source fetch.
28
+ *
29
+ * ## Source Outage
30
+ *
31
+ * When a source is unavailable, dependent actions are disabled or escalated.
32
+ * No invented substitute facts. The `SourceStatus` enumeration tracks
33
+ * available/degraded/unavailable, and `disabledByOutage` checks whether a
34
+ * requested action is blocked.
35
+ *
36
+ * ## Commerce Context
37
+ *
38
+ * Currency, market, variant, customer eligibility, and quantity are part of
39
+ * the relevant commerce context — never one global price. Every
40
+ * commerce-sensitive operation carries a `CommerceContext` bundle.
41
+ */
42
+ import { moneySchema } from "./contracts/money.js";
43
+ import { EW05_PREDICATES } from "./compile-graph.js";
44
+
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Freshness data classes
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /**
51
+ * Exactly four freshness classes. Every graph fact must be classifiable into
52
+ * one of these, and the class determines what a consumer may do with it.
53
+ */
54
+ export const FRESHNESS_CLASSES = Object.freeze([
55
+ "static",
56
+ "stale_readable",
57
+ "live_required",
58
+ "private_session",
59
+ ]);
60
+
61
+ const FRESHNESS_CLASS_SET = new Set(FRESHNESS_CLASSES);
62
+
63
+ /**
64
+ * Classification rules (deterministic, not inferred):
65
+ *
66
+ * static immutable-by-nature facts: contract versions, content
67
+ * hashes, schema identifiers, cryptographic fingerprints.
68
+ * These may be cached indefinitely.
69
+ *
70
+ * stale_readable facts that can be read from cache with a known asOf
71
+ * timestamp and a staleness deadline, but must disclose
72
+ * their age to the consumer. Examples: indexed prices,
73
+ * inventory snapshots, policy documents.
74
+ *
75
+ * live_required facts that MUST be fetched from the authoritative source
76
+ * at action time. Cached values are informative only and
77
+ * cannot authorize. Examples: current inventory count at
78
+ * checkout, payment authorization, delivery estimate for
79
+ * a specific cart.
80
+ *
81
+ * private_session facts scoped to the caller's session: authentication
82
+ * tokens, session-scoped preferences, transient state.
83
+ * Never cached or shared across sessions.
84
+ *
85
+ * @param {{kind: string, sourceKind?: string, isAuth?: boolean, isSessionScoped?: boolean}} params
86
+ * @returns {string} one of FRESHNESS_CLASSES
87
+ */
88
+ export function classifyFreshness({ kind, sourceKind, isAuth, isSessionScoped } = {}) {
89
+ if (isSessionScoped) return "private_session";
90
+ if (isAuth) return "live_required";
91
+ if (kind === "hash" || kind === "version" || kind === "schema" || kind === "fingerprint") return "static";
92
+ if (sourceKind === "live" || sourceKind === "realtime") return "live_required";
93
+ return "stale_readable";
94
+ }
95
+
96
+ /**
97
+ * Validate that a freshness class string is one of the four canonical values.
98
+ */
99
+ export function isValidFreshnessClass(value) {
100
+ return FRESHNESS_CLASS_SET.has(value);
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Freshness marker
105
+ // ---------------------------------------------------------------------------
106
+
107
+ /**
108
+ * A FreshnessMarker records what is known about a fact's age.
109
+ *
110
+ * Rules:
111
+ * - `asOf` is mandatory when `freshnessClass` is `stale_readable` or `live_required`.
112
+ * - Never claim `staleAfterMs = 0` or `isStale = false` on a stale_readable fact
113
+ * without a live revalidation.
114
+ * - `sourceVersion` records the ETag or content version observed at fetch time.
115
+ * - `sourceStatus` records whether the source was available at fetch time.
116
+ *
117
+ * @typedef {{asOf: string, freshnessClass: string, staleAfterMs?: number,
118
+ * sourceVersion?: string, sourceStatus?: string,
119
+ * isStale?: boolean, revalidatedAt?: string}} FreshnessMarker
120
+ */
121
+
122
+ /**
123
+ * Create a freshness marker with mandatory timestamp and disclosure.
124
+ *
125
+ * @param {{freshnessClass: string, asOf?: string, staleAfterMs?: number,
126
+ * sourceVersion?: string, sourceStatus?: string,
127
+ * isStale?: boolean, revalidatedAt?: string}} params
128
+ * @returns {FreshnessMarker}
129
+ */
130
+ export function createFreshnessMarker({
131
+ freshnessClass,
132
+ asOf,
133
+ staleAfterMs,
134
+ sourceVersion,
135
+ sourceStatus,
136
+ isStale,
137
+ revalidatedAt,
138
+ } = {}) {
139
+ if (!isValidFreshnessClass(freshnessClass)) {
140
+ throw new FreshnessClassError(
141
+ `invalid freshness class "${freshnessClass}"; must be one of ${FRESHNESS_CLASSES.join(", ")}`,
142
+ );
143
+ }
144
+
145
+ const now = asOf || new Date().toISOString();
146
+
147
+ // live_required and stale_readable MUST carry an asOf timestamp
148
+ if ((freshnessClass === "live_required" || freshnessClass === "stale_readable") && !asOf) {
149
+ throw new FreshnessClassError(
150
+ `freshness class "${freshnessClass}" requires an explicit asOf timestamp`,
151
+ );
152
+ }
153
+
154
+ // Never claim zero staleness on stale_readable without revalidation
155
+ if (freshnessClass === "stale_readable" && staleAfterMs !== undefined && staleAfterMs <= 0) {
156
+ throw new FreshnessClassError(
157
+ "stale_readable facts cannot claim zero or negative staleness",
158
+ );
159
+ }
160
+
161
+ if (sourceStatus !== undefined && !SOURCE_STATUS_SET.has(sourceStatus)) {
162
+ throw new FreshnessClassError(
163
+ `sourceStatus must be one of ${SOURCE_STATUSES.join(", ")}; got "${sourceStatus}"`,
164
+ );
165
+ }
166
+
167
+ const marker = {
168
+ asOf: now,
169
+ freshnessClass,
170
+ };
171
+
172
+ if (staleAfterMs !== undefined) marker.staleAfterMs = staleAfterMs;
173
+ if (sourceVersion !== undefined) marker.sourceVersion = sourceVersion;
174
+ if (sourceStatus !== undefined) marker.sourceStatus = sourceStatus;
175
+ if (isStale !== undefined) marker.isStale = isStale;
176
+ if (revalidatedAt !== undefined) marker.revalidatedAt = revalidatedAt;
177
+
178
+ return Object.freeze(marker);
179
+ }
180
+
181
+ /**
182
+ * Determine whether a freshness marker is currently stale.
183
+ * Returns undefined when staleness cannot be determined (never lie).
184
+ */
185
+ export function isFreshnessStale(marker, now = new Date()) {
186
+ if (!marker || marker.freshnessClass === "static") return false;
187
+ if (marker.freshnessClass === "live_required" && !marker.revalidatedAt) return true;
188
+ if (marker.isStale === true) return true;
189
+ if (marker.staleAfterMs !== undefined && marker.asOf) {
190
+ const age = now.getTime() - new Date(marker.asOf).getTime();
191
+ if (age > marker.staleAfterMs) return true;
192
+ }
193
+ return undefined; // cannot determine — disclose, don't invent
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Source versioning (ETags, content versions)
198
+ // ---------------------------------------------------------------------------
199
+
200
+ /**
201
+ * A source version descriptor captured at fetch time.
202
+ *
203
+ * @typedef {{sourceId: string, version: string, etag?: string,
204
+ * fetchedAt: string, tenantScope: string,
205
+ * sourceStatus: string}} SourceVersion
206
+ */
207
+
208
+ export const SOURCE_STATUSES = Object.freeze([
209
+ "available",
210
+ "degraded",
211
+ "unavailable",
212
+ ]);
213
+
214
+ const SOURCE_STATUS_SET = new Set(SOURCE_STATUSES);
215
+
216
+ /**
217
+ * Create a source version record from a fetch result.
218
+ */
219
+ export function createSourceVersion({
220
+ sourceId,
221
+ version,
222
+ etag,
223
+ fetchedAt,
224
+ tenantScope,
225
+ sourceStatus,
226
+ } = {}) {
227
+ if (!sourceId || typeof sourceId !== "string" || sourceId.length === 0) {
228
+ throw new SourceVersionError("sourceId is required and must be non-empty");
229
+ }
230
+ if (!version || typeof version !== "string" || version.length === 0) {
231
+ throw new SourceVersionError("version is required and must be non-empty");
232
+ }
233
+ if (!fetchedAt) {
234
+ throw new SourceVersionError("fetchedAt is required");
235
+ }
236
+ if (!SOURCE_STATUS_SET.has(sourceStatus)) {
237
+ throw new SourceVersionError(
238
+ `sourceStatus must be one of ${SOURCE_STATUSES.join(", ")}; got "${sourceStatus}"`,
239
+ );
240
+ }
241
+
242
+ const record = {
243
+ sourceId,
244
+ version,
245
+ fetchedAt: typeof fetchedAt === "string" ? fetchedAt : fetchedAt.toISOString(),
246
+ tenantScope: tenantScope || "default",
247
+ sourceStatus,
248
+ };
249
+
250
+ if (etag !== undefined) record.etag = etag;
251
+
252
+ return Object.freeze(record);
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // Authoritative priority resolution
257
+ // ---------------------------------------------------------------------------
258
+
259
+ /**
260
+ * Resolve which of two source versions is more authoritative.
261
+ *
262
+ * Priority (highest to lowest):
263
+ * 1. available > degraded > unavailable
264
+ * 2. fresher fetchedAt
265
+ * 3. higher version (string comparison)
266
+ *
267
+ * Returns the more authoritative version, or null if neither is usable.
268
+ */
269
+ export function resolveAuthoritativeSource(a, b) {
270
+ if (!a || !b) return a || b || null;
271
+
272
+ const authorityRank = { available: 3, degraded: 2, unavailable: 1 };
273
+ const rankA = authorityRank[a.sourceStatus] || 0;
274
+ const rankB = authorityRank[b.sourceStatus] || 0;
275
+
276
+ if (rankA !== rankB) return rankA > rankB ? a : b;
277
+
278
+ const timeA = new Date(a.fetchedAt).getTime();
279
+ const timeB = new Date(b.fetchedAt).getTime();
280
+ if (timeA !== timeB) return timeA > timeB ? a : b;
281
+
282
+ return a.version > b.version ? a : b;
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Revalidation gate (TOCTOU guard)
287
+ // ---------------------------------------------------------------------------
288
+
289
+ /**
290
+ * Determine whether a freshness marker requires revalidation before the
291
+ * fact it describes can authorize an action.
292
+ *
293
+ * Rules:
294
+ * - live_required ALWAYS requires revalidation (cached is never enough)
295
+ * - stale_readable requires revalidation when isStale or past deadline
296
+ * - unavailable source → revalidation is impossible → escalate
297
+ * - static never requires revalidation
298
+ *
299
+ * @returns {{mustRevalidate: boolean, reason?: string, escalate?: boolean}}
300
+ */
301
+ export function mustRevalidate(marker, now = new Date()) {
302
+ if (!marker) {
303
+ return { mustRevalidate: true, reason: "no freshness marker — cannot authorize without provenance" };
304
+ }
305
+
306
+ // Fail-safe: unknown freshness class must revalidate
307
+ if (!isValidFreshnessClass(marker.freshnessClass)) {
308
+ return { mustRevalidate: true, reason: `unknown freshness class "${marker.freshnessClass}" — refusing to authorize` };
309
+ }
310
+
311
+ if (marker.freshnessClass === "static") {
312
+ return { mustRevalidate: false };
313
+ }
314
+
315
+ if (marker.freshnessClass === "live_required") {
316
+ return { mustRevalidate: true, reason: "live_required facts must be fetched at action time" };
317
+ }
318
+
319
+ if (marker.freshnessClass === "private_session") {
320
+ return { mustRevalidate: false }; // session-scoped; already bound to caller
321
+ }
322
+
323
+ // stale_readable
324
+ if (marker.isStale === true) {
325
+ return { mustRevalidate: true, reason: "fact is marked stale" };
326
+ }
327
+
328
+ if (marker.staleAfterMs !== undefined && marker.asOf) {
329
+ const age = now.getTime() - new Date(marker.asOf).getTime();
330
+ if (age > marker.staleAfterMs) {
331
+ return { mustRevalidate: true, reason: `staleness deadline exceeded (age ${age}ms > ${marker.staleAfterMs}ms)` };
332
+ }
333
+ }
334
+
335
+ // Degraded source: allow but disclose reduced confidence
336
+ if (marker.sourceStatus === "degraded") {
337
+ return {
338
+ mustRevalidate: false,
339
+ degraded: true,
340
+ reason: "source is degraded — proceed with reduced confidence",
341
+ };
342
+ }
343
+
344
+ // Source outage: cannot revalidate → escalate
345
+ if (marker.sourceStatus === "unavailable") {
346
+ return {
347
+ mustRevalidate: true,
348
+ reason: "source is unavailable — cannot revalidate; escalate to operator",
349
+ escalate: true,
350
+ };
351
+ }
352
+
353
+ // No staleness signal → must disclose but not block
354
+ return { mustRevalidate: false };
355
+ }
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // Source outage handling
359
+ // ---------------------------------------------------------------------------
360
+
361
+ /** How bad a source status is; a status outside SOURCE_STATUSES is the worst. */
362
+ const SOURCE_STATUS_RANK = new Map([["available", 0], ["degraded", 1], ["unavailable", 2]]);
363
+ const statusRank = (status) => SOURCE_STATUS_RANK.get(status) ?? 3;
364
+
365
+ /**
366
+ * Check whether a source outage disables a dependent action.
367
+ *
368
+ * When a source is unavailable, dependent actions are disabled.
369
+ * Degraded sources allow actions but with reduced confidence.
370
+ * No invented substitute facts.
371
+ *
372
+ * Every dependency is checked before a degraded one is reported, so one
373
+ * unavailable source disables the action wherever it sits in the list. A
374
+ * status outside SOURCE_STATUSES cannot be verified and disables it too.
375
+ * When several records name one source, the worst status counts (unknown,
376
+ * then unavailable, then degraded, then available), so the result never
377
+ * depends on their order.
378
+ */
379
+ export function disabledByOutage(sourceVersions, actionDependencies) {
380
+ if (!actionDependencies || actionDependencies.length === 0) return { disabled: false };
381
+
382
+ const sources = new Map();
383
+ for (const sv of sourceVersions || []) {
384
+ if (!sv || !sv.sourceId) continue;
385
+ const recorded = sources.get(sv.sourceId);
386
+ if (!recorded || statusRank(sv.sourceStatus) > statusRank(recorded.sourceStatus)) {
387
+ sources.set(sv.sourceId, sv);
388
+ }
389
+ }
390
+
391
+ let degraded = null;
392
+ for (const dep of actionDependencies) {
393
+ const sv = sources.get(dep);
394
+ if (!sv) {
395
+ return { disabled: true, reason: `source "${dep}" has no recorded version — cannot verify availability` };
396
+ }
397
+ if (sv.sourceStatus === "unavailable") {
398
+ return { disabled: true, reason: `source "${dep}" is unavailable` };
399
+ }
400
+ if (sv.sourceStatus === "degraded") {
401
+ degraded ??= { disabled: false, degraded: true, reason: `source "${dep}" is degraded — proceed with caution` };
402
+ } else if (sv.sourceStatus !== "available") {
403
+ return {
404
+ disabled: true,
405
+ reason: `source "${dep}" has unknown status "${sv.sourceStatus}" — cannot verify availability`,
406
+ };
407
+ }
408
+ }
409
+
410
+ return degraded ?? { disabled: false };
411
+ }
412
+
413
+ // ---------------------------------------------------------------------------
414
+ // Commerce context (currency / market / variant / customer eligibility / quantity)
415
+ // ---------------------------------------------------------------------------
416
+
417
+ /**
418
+ * A CommerceContext bundles the commerce-relevant dimensions for a single
419
+ * operation. Every operation that touches price, inventory, or eligibility
420
+ * carries its own context — none of these fields is global.
421
+ *
422
+ * Rules:
423
+ * - No fields are global — every commerce operation carries its own bundle.
424
+ * - An absent field means "not applicable to this operation" or "not
425
+ * provided"; it is never silently defaulted to a global value.
426
+ */
427
+ export function createCommerceContext({
428
+ currency,
429
+ market,
430
+ variant,
431
+ customerEligibility,
432
+ quantity,
433
+ priceContext,
434
+ } = {}) {
435
+ const ctx = {};
436
+
437
+ if (currency !== undefined) {
438
+ if (typeof currency !== "string" || !/^[A-Z]{3}$/.test(currency)) {
439
+ throw new CommerceContextError(`invalid currency code "${currency}"`);
440
+ }
441
+ ctx.currency = currency;
442
+ }
443
+
444
+ if (market !== undefined) {
445
+ if (typeof market !== "string" || market.length === 0) {
446
+ throw new CommerceContextError("market must be a non-empty string");
447
+ }
448
+ ctx.market = market;
449
+ }
450
+
451
+ if (variant !== undefined) {
452
+ if (typeof variant !== "string" || variant.length === 0) {
453
+ throw new CommerceContextError("variant must be a non-empty string");
454
+ }
455
+ ctx.variant = variant;
456
+ }
457
+
458
+ if (customerEligibility !== undefined) {
459
+ if (typeof customerEligibility !== "string") {
460
+ throw new CommerceContextError("customerEligibility must be a string");
461
+ }
462
+ ctx.customerEligibility = customerEligibility;
463
+ }
464
+
465
+ if (quantity !== undefined) {
466
+ if (typeof quantity !== "number" || !Number.isSafeInteger(quantity) || quantity < 0) {
467
+ throw new CommerceContextError("quantity must be a non-negative safe integer");
468
+ }
469
+ ctx.quantity = quantity;
470
+ }
471
+
472
+ if (priceContext !== undefined) {
473
+ if (typeof priceContext !== "object" || priceContext === null) {
474
+ throw new CommerceContextError("priceContext must be an object");
475
+ }
476
+ ctx.priceContext = priceContext;
477
+ }
478
+
479
+ return Object.freeze(ctx);
480
+ }
481
+
482
+ // ---------------------------------------------------------------------------
483
+ // Error types
484
+ // ---------------------------------------------------------------------------
485
+
486
+ export class FreshnessClassError extends Error {
487
+ constructor(message) {
488
+ super(message);
489
+ this.name = "FreshnessClassError";
490
+ }
491
+ }
492
+
493
+ export class SourceVersionError extends Error {
494
+ constructor(message) {
495
+ super(message);
496
+ this.name = "SourceVersionError";
497
+ }
498
+ }
499
+
500
+ export class RevalidationRequiredError extends Error {
501
+ constructor(message) {
502
+ super(message);
503
+ this.name = "RevalidationRequiredError";
504
+ }
505
+ }
506
+
507
+ export class SourceOutageError extends Error {
508
+ constructor(message) {
509
+ super(message);
510
+ this.name = "SourceOutageError";
511
+ }
512
+ }
513
+
514
+ export class CommerceContextError extends Error {
515
+ constructor(message) {
516
+ super(message);
517
+ this.name = "CommerceContextError";
518
+ }
519
+ }
520
+
521
+ // ---------------------------------------------------------------------------
522
+ // Live-source adapter interface (contract only — adapter is injected)
523
+ // ---------------------------------------------------------------------------
524
+
525
+ /**
526
+ * A live-source adapter MUST fulfil this interface. No concrete implementation
527
+ * is provided here; callers inject an adapter per authoritative source.
528
+ *
529
+ * fetchLive(commerceContext, entityIds, fieldNames) → Promise<Map<string, Map<string, any>>>
530
+ *
531
+ * - commerceContext: a CommerceContext (created by createCommerceContext)
532
+ * - entityIds: string[] of entity identifiers (e.g. ["product:1", "product:2"])
533
+ * - fieldNames: string[] of field names to fetch (e.g. ["price", "inventory"])
534
+ * - Returns: Map keyed by entityId → Map keyed by field name → live value
535
+ * Entities or fields that are unknown or unavailable are simply absent from the Maps.
536
+ *
537
+ * sourceId: string — identifies the live source (e.g. "catalog:us-east-1")
538
+ *
539
+ * sourceStatus(): "available" | "degraded" | "unavailable"
540
+ *
541
+ * Adapters may throw on transport failure; callers MUST handle that.
542
+ */
543
+
544
+ /**
545
+ * Validate that an object satisfies the minimal live-source adapter shape.
546
+ * Returns an array of missing/invalid members (empty = valid).
547
+ */
548
+ export function validateAdapterShape(adapter) {
549
+ const issues = [];
550
+ if (!adapter || typeof adapter !== "object") {
551
+ return ["adapter must be an object"];
552
+ }
553
+ if (typeof adapter.fetchLive !== "function") {
554
+ issues.push("adapter.fetchLive must be a function");
555
+ }
556
+ if (typeof adapter.sourceId !== "string" || adapter.sourceId.length === 0) {
557
+ issues.push("adapter.sourceId must be a non-empty string");
558
+ }
559
+ if (typeof adapter.sourceStatus !== "function") {
560
+ issues.push("adapter.sourceStatus must be a function");
561
+ }
562
+ return issues;
563
+ }
564
+
565
+ // ---------------------------------------------------------------------------
566
+ // Volatile values: one rule for what hydrate fetches, merges and clears
567
+ // ---------------------------------------------------------------------------
568
+
569
+ /**
570
+ * Check whether a value is a canonical money object per moneySchema.
571
+ */
572
+ function isMoneyValue(value) {
573
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
574
+ // moneySchema requires amountUnit: an object without it is not parsed, which
575
+ // keeps the walk over a large graph cheap.
576
+ if (!("amountUnit" in value)) return false;
577
+ return moneySchema.safeParse(value).success;
578
+ }
579
+
580
+ /** True for a list that holds a money value, directly or in nested lists. */
581
+ function holdsMoneyInList(value) {
582
+ return Array.isArray(value) && value.some((item) => isMoneyValue(item) || holdsMoneyInList(item));
583
+ }
584
+
585
+ /**
586
+ * True when a value found under `key` is one volatile node: the key names a
587
+ * volatile field (isVolatileField), the value is canonical money
588
+ * (moneySchema), or the value is a list holding money. Such a list is one
589
+ * node, fetched, merged and cleared whole: clearing its money items one by one
590
+ * would shift the items after them, and a live value written by path would
591
+ * land at another item's index.
592
+ */
593
+ function isVolatileNode(key, value) {
594
+ return isVolatileField(key) || isMoneyValue(value) || holdsMoneyInList(value);
595
+ }
596
+
597
+ /** Returned by a mapVolatileNodes callback to drop the node it was given. */
598
+ const OMIT = Symbol("omit");
599
+
600
+ /**
601
+ * Rebuild a JSON value with every volatile node replaced by
602
+ * replace(path, node); a node replaced by OMIT is dropped from its object.
603
+ *
604
+ * The rule (isVolatileNode) holds at ANY depth, so it covers a price inside
605
+ * offers or a variant offer, a priceSpecification on an offer or on the
606
+ * product itself, an AggregateOffer's lowPrice and highPrice, the prices of a
607
+ * top-level Offer, AggregateOffer or UnitPriceSpecification entity, and the
608
+ * per-source copies the compiler keeps when two pages describe one product.
609
+ * The root (path null) is never a volatile node itself. A volatile node is
610
+ * handed over whole and not walked into, so no volatile path is a prefix of
611
+ * another.
612
+ *
613
+ * Paths are the keys adapters use for live values: "price", "offers.price",
614
+ * "offers[0].priceSpecification.price". Below the root, a list item is never
615
+ * a volatile node (a list holding money is one node), so lists keep their
616
+ * length and every item its index.
617
+ */
618
+ function mapVolatileNodes(value, path, key, replace) {
619
+ if (path !== null && isVolatileNode(key, value)) return replace(path, value);
620
+ if (Array.isArray(value)) {
621
+ return value.map((item, i) => {
622
+ const next = mapVolatileNodes(item, `${path ?? ""}[${i}]`, undefined, replace);
623
+ return next === OMIT ? null : next; // only an item of a root list can be dropped
624
+ });
625
+ }
626
+ if (value !== null && typeof value === "object") {
627
+ const fields = [];
628
+ for (const [childKey, child] of Object.entries(value)) {
629
+ const childPath = path === null ? childKey : `${path}.${childKey}`;
630
+ const next = mapVolatileNodes(child, childPath, childKey, replace);
631
+ if (next !== OMIT) fields.push([childKey, next]);
632
+ }
633
+ return Object.fromEntries(fields);
634
+ }
635
+ return value;
636
+ }
637
+
638
+ /** The volatile paths of an entity: the fields hydrate asks its source for. */
639
+ function volatileFieldsForEntity(entity) {
640
+ const paths = new Set();
641
+ mapVolatileNodes(entity?.attributes, null, undefined, (path, node) => {
642
+ paths.add(path);
643
+ return node;
644
+ });
645
+ return paths;
646
+ }
647
+
648
+ /** True when a JSON value is, or holds at any depth, a volatile value. */
649
+ function holdsVolatileValue(value) {
650
+ let found = isVolatileNode(undefined, value);
651
+ mapVolatileNodes(value, null, undefined, (_path, node) => {
652
+ found = true;
653
+ return node;
654
+ });
655
+ return found;
656
+ }
657
+
658
+ /**
659
+ * An entity with each volatile path holding the value the source returned for
660
+ * that exact path, and every volatile path the source did not return removed.
661
+ * Only the entity's own volatile paths are ever written: an adapter cannot add
662
+ * a field, grow an offers array or overwrite a stable fact. Each path written
663
+ * is added to liveFields.
664
+ */
665
+ function overlayLiveValues(entity, live, liveFields) {
666
+ if (!entity?.attributes) return entity;
667
+ // Attributes that are themselves a volatile node (a money value, or a list
668
+ // holding money) have no path to ask the source for, so they are never served.
669
+ if (isVolatileNode(undefined, entity.attributes)) return { ...entity, attributes: {} };
670
+ const attributes = mapVolatileNodes(entity.attributes, null, undefined, (path) => {
671
+ const value = live.get(path);
672
+ if (value === undefined) return OMIT;
673
+ liveFields.add(path);
674
+ return value;
675
+ });
676
+ return { ...entity, attributes };
677
+ }
678
+
679
+ /**
680
+ * Merge live values (per-entity Map<entityId, Map<path, value>>) into entity
681
+ * attributes. Each entity gets the values for its own volatile paths only; a
682
+ * volatile path with no live value is removed, and on an outage every one is.
683
+ *
684
+ * Returns the hydrated entities and liveFields: the paths that hold a live
685
+ * value in them.
686
+ */
687
+ function mergeLiveValues(entities, liveValues, sourceStatus) {
688
+ const liveFields = new Set();
689
+ const hydratedEntities = entities.map((entity) => {
690
+ // On outage nothing is live: never serve cached values
691
+ const live = sourceStatus === "unavailable" ? undefined : liveValues.get(entity?.id);
692
+ return overlayLiveValues(entity, live ?? new Map(), liveFields);
693
+ });
694
+ return { hydratedEntities, liveFields };
695
+ }
696
+
697
+ // ---------------------------------------------------------------------------
698
+ // Cached volatile values outside entity attributes
699
+ // ---------------------------------------------------------------------------
700
+
701
+ /** The compiler states attribute <field> of an entity as describes_<field>. */
702
+ const ATTRIBUTE_PREDICATE_PREFIX = `${EW05_PREDICATES.DESCRIBES}_`;
703
+
704
+ /**
705
+ * True when an assertion carries a cached volatile value: its object is or
706
+ * holds one, or its predicate names a volatile field, by name
707
+ * (describes_price, describes_availability) or because that field of its
708
+ * subject is volatile (describes_lowPrice where lowPrice is money).
709
+ */
710
+ function isVolatileAssertion(assertion, volatileFieldsBySubject) {
711
+ const predicate = typeof assertion?.predicate === "string" ? assertion.predicate : "";
712
+ const field = predicate.startsWith(ATTRIBUTE_PREDICATE_PREFIX)
713
+ ? predicate.slice(ATTRIBUTE_PREDICATE_PREFIX.length)
714
+ : predicate;
715
+ return isVolatileField(field) ||
716
+ volatileFieldsBySubject.get(assertion?.subject)?.has(field) === true ||
717
+ holdsVolatileValue(assertion?.object);
718
+ }
719
+
720
+ /** Coverage counts for the assertions hydrate returns. */
721
+ function recountCoverage(coverage, assertions) {
722
+ const countBy = (field) => {
723
+ const counts = {};
724
+ for (const assertion of assertions) {
725
+ const value = assertion?.[field];
726
+ if (value !== undefined) counts[value] = (counts[value] ?? 0) + 1;
727
+ }
728
+ return counts;
729
+ };
730
+ const recounted = { ...coverage, totalAssertions: assertions.length };
731
+ if (coverage.byProvenance) recounted.byProvenance = countBy("provenance");
732
+ if (coverage.bySchemaValidity) recounted.bySchemaValidity = countBy("schemaValidity");
733
+ return recounted;
734
+ }
735
+
736
+ /**
737
+ * The graph hydrate returns: the hydrated entities, and no cached volatile
738
+ * value anywhere else in the published graph.
739
+ *
740
+ * Assertions. The compiler copies each attribute of an entity into a
741
+ * describes_<field> assertion, so a published graph also holds cached prices
742
+ * there (describes_price, describes_lowPrice, ...). Such an assertion is
743
+ * removed, not rewritten with the live value. An assertion states what one
744
+ * snapshot said: source.version and source.capturedAt name that snapshot, and
745
+ * the graph contract rejects a fact captured after graph.generatedAt. A live
746
+ * value written into it would either keep the snapshot's provenance, which is
747
+ * false, or carry its own fetch time, which the contract rejects; and a live
748
+ * value that is not canonical money would fail the contract's check on
749
+ * monetary predicates. The live value is on the entity, at its own path.
750
+ * coverage is recounted, so the graph stays valid under its contract.
751
+ *
752
+ * Links. A link is kept only when both its ends are in the returned graph: a
753
+ * returned entity or a kept assertion. Otherwise a removed assertion, or an
754
+ * entity left out by entityIds, would leave it dangling.
755
+ *
756
+ * Unresolved facts. A rawValue is a value the compiler could not type: a
757
+ * price with no currency, an amount it could not read, or an array recorded
758
+ * whole, which can hold prices under any field name. hydrate cannot tell which
759
+ * raw values are prices and never asks the source for them, so it returns
760
+ * each record without its rawValue; field, path and reason still disclose the
761
+ * gap.
762
+ */
763
+ function withoutCachedVolatiles(graph, entities) {
764
+ const hydrated = { ...graph, entities };
765
+
766
+ const volatileFieldsBySubject = new Map();
767
+ for (const entity of graph.entities ?? []) {
768
+ if (entity) volatileFieldsBySubject.set(entity.id, volatileFieldsForEntity(entity));
769
+ }
770
+ const assertions = Array.isArray(graph.assertions) ? graph.assertions : [];
771
+ const kept = assertions.filter((assertion) => !isVolatileAssertion(assertion, volatileFieldsBySubject));
772
+ if (kept.length < assertions.length) {
773
+ hydrated.assertions = kept;
774
+ if (graph.coverage) hydrated.coverage = recountCoverage(graph.coverage, kept);
775
+ }
776
+
777
+ if (Array.isArray(graph.links)) {
778
+ const nodes = new Set([...entities, ...kept].map((node) => node?.id));
779
+ hydrated.links = graph.links.filter((link) => nodes.has(link?.source) && nodes.has(link?.target));
780
+ }
781
+
782
+ if (Array.isArray(graph.unresolvedFacts)) {
783
+ hydrated.unresolvedFacts = graph.unresolvedFacts.map((fact) => {
784
+ if (!fact || typeof fact !== "object") return fact;
785
+ const { rawValue: _cached, ...record } = fact;
786
+ return record;
787
+ });
788
+ }
789
+ return hydrated;
790
+ }
791
+
792
+ // ---------------------------------------------------------------------------
793
+ // Adapter results, checked before they are trusted
794
+ // ---------------------------------------------------------------------------
795
+
796
+ /**
797
+ * A live source's status. A missing adapter, a status check that throws and
798
+ * a status outside SOURCE_STATUSES all read as "unavailable": hydrate and
799
+ * revalidateForAction fail closed rather than trust them.
800
+ */
801
+ function liveSourceStatus(adapter) {
802
+ if (!adapter || typeof adapter.sourceStatus !== "function") return "unavailable";
803
+ try {
804
+ const status = adapter.sourceStatus();
805
+ return SOURCE_STATUS_SET.has(status) ? status : "unavailable";
806
+ } catch {
807
+ return "unavailable";
808
+ }
809
+ }
810
+
811
+ /** True for a Map whose values are all Maps: Map<entityId, Map<path, value>>. */
812
+ function isMapOfMaps(value) {
813
+ if (!(value instanceof Map)) return false;
814
+ for (const inner of value.values()) {
815
+ if (!(inner instanceof Map)) return false;
816
+ }
817
+ return true;
818
+ }
819
+
820
+ /** True for a record shaped like createSourceVersion's output. */
821
+ function isSourceVersionRecord(record) {
822
+ if (record === null || typeof record !== "object") return false;
823
+ const named = [record.sourceId, record.version, record.fetchedAt];
824
+ return named.every((field) => typeof field === "string" && field.length > 0) &&
825
+ SOURCE_STATUS_SET.has(record.sourceStatus);
826
+ }
827
+
828
+ // ---------------------------------------------------------------------------
829
+ // hydrate — read graph through graph-store, overlay live values per entity
830
+ // ---------------------------------------------------------------------------
831
+
832
+ /**
833
+ * Hydrate the caller's view of the published graph.
834
+ *
835
+ * Reads the graph through graph-store and resolves every entity whose
836
+ * freshness class is `live_required` against the live-source adapter.
837
+ * Each entity gets its own live values — never a single shared value.
838
+ *
839
+ * A volatile value (a field named in VOLATILE_FIELD_NAMES in any spelling,
840
+ * canonical money, or a list holding money, at any depth of an entity's
841
+ * attributes) comes only from the live source. A volatile path the source
842
+ * does not return is CLEARED; on an outage, a transport failure, a malformed
843
+ * response or with no adapter every one is (never served from cache), and
844
+ * the caller sees sourceStatus="unavailable". Assertions that carry a
845
+ * volatile value and the raw values of unresolved facts are never returned
846
+ * (see withoutCachedVolatiles).
847
+ *
848
+ * Optional `entityIds` and `fieldNames` (top-level attribute names) filter
849
+ * the returned graph to the requested entities and fields, with or without
850
+ * an adapter; the source is asked only for the volatile paths inside them.
851
+ *
852
+ * Returns:
853
+ * { graph, version, publishedAt, hydratedAt, liveFields: string[],
854
+ * sourceStatus: "available"|"degraded"|"unavailable" }
855
+ *
856
+ * liveFields lists the volatile paths that hold a live value in the
857
+ * returned graph, so it is empty whenever nothing came back from the source.
858
+ */
859
+ export async function hydrate(
860
+ caller,
861
+ namespace,
862
+ graphStore,
863
+ liveSourceAdapter,
864
+ commerceContext,
865
+ { entityIds, fieldNames } = {},
866
+ ) {
867
+ // Read the currently published graph
868
+ const result = await graphStore.readCurrentGraph(caller, namespace);
869
+
870
+ if (!result || result.graph === null) {
871
+ return {
872
+ graph: null,
873
+ version: result?.version ?? 0,
874
+ reason: result?.reason ?? "no graph published",
875
+ hydratedAt: new Date().toISOString(),
876
+ liveFields: [],
877
+ sourceStatus: "unavailable",
878
+ };
879
+ }
880
+
881
+ const { graph, version, publishedAt } = result;
882
+
883
+ // The caller's view: the requested entities and top-level fields
884
+ let entities = graph.entities ?? [];
885
+ if (entityIds && entityIds.length > 0) {
886
+ const idSet = new Set(entityIds);
887
+ entities = entities.filter((entity) => idSet.has(entity?.id));
888
+ }
889
+ if (fieldNames && fieldNames.length > 0) {
890
+ const fieldSet = new Set(fieldNames);
891
+ entities = entities.map((entity) => {
892
+ if (!entity?.attributes) return entity;
893
+ // Attributes that are themselves a volatile node are one value, never
894
+ // served: a slice of them (a money object's amount alone) would no
895
+ // longer look volatile.
896
+ if (isVolatileNode(undefined, entity.attributes)) return { ...entity, attributes: {} };
897
+ const attributes = Object.fromEntries(Object.entries(entity.attributes).filter(([key]) => fieldSet.has(key)));
898
+ return { ...entity, attributes };
899
+ });
900
+ }
901
+
902
+ // Ask the source for the volatile paths in that view only. Assertions are
903
+ // not hydrated (withoutCachedVolatiles removes the ones that carry a
904
+ // volatile value), so it is asked for entity fields only.
905
+ const ids = entities.map((entity) => entity?.id).filter(Boolean);
906
+ const requested = new Set();
907
+ for (const entity of entities) {
908
+ for (const path of volatileFieldsForEntity(entity)) requested.add(path);
909
+ }
910
+
911
+ let sourceStatus = liveSourceStatus(liveSourceAdapter);
912
+ let liveValues = new Map();
913
+ if (sourceStatus !== "unavailable" && requested.size > 0 && ids.length > 0) {
914
+ try {
915
+ const response = await liveSourceAdapter.fetchLive(commerceContext ?? {}, ids, [...requested]);
916
+ // A response outside the adapter contract is a failed fetch
917
+ if (isMapOfMaps(response)) liveValues = response;
918
+ else sourceStatus = "unavailable";
919
+ } catch {
920
+ // Transport failure → treat as unavailable for this hydration
921
+ sourceStatus = "unavailable";
922
+ }
923
+ }
924
+
925
+ // Merge per-entity live values (or clear volatiles on outage)
926
+ const { hydratedEntities, liveFields } = mergeLiveValues(entities, liveValues, sourceStatus);
927
+
928
+ return {
929
+ graph: withoutCachedVolatiles(graph, hydratedEntities),
930
+ version,
931
+ publishedAt,
932
+ hydratedAt: new Date().toISOString(),
933
+ liveFields: [...liveFields],
934
+ sourceStatus,
935
+ };
936
+ }
937
+
938
+ // ---------------------------------------------------------------------------
939
+ // revalidateForAction — TOCTOU re-check before authorisation
940
+ // ---------------------------------------------------------------------------
941
+
942
+ /**
943
+ * Revalidate live_required fields against the live source before an
944
+ * authorization-significant action.
945
+ *
946
+ * Accepts `expectedVersions`: a Map<entityId, Map<fieldName, version>> that
947
+ * records what the caller saw. Compares live values against THESE versions,
948
+ * not against cached graph values. If the caller's version differs from live,
949
+ * the action MUST be re-attempted with the new values. Every expected field
950
+ * must be a volatile field of its entity in the published graph, checked
951
+ * before the source is called: the source is asked for the graph's own
952
+ * volatile fields only, so a caller cannot widen the live query.
953
+ *
954
+ * `actionDependencies` (optional): SourceVersion records (createSourceVersion)
955
+ * for the other sources the action depends on, as the caller last observed
956
+ * them. One that is unavailable blocks the action (disabledByOutage), and so
957
+ * does anything that is not such a record: a non-empty sourceId, version and
958
+ * fetchedAt, and a status in SOURCE_STATUSES. With two records for one
959
+ * source the worse status counts.
960
+ *
961
+ * Reduced confidence: when the live source or a dependency is degraded the
962
+ * action may go ahead, and the result says so with degraded: true and a
963
+ * reason naming each degraded source (sourceStatus alone reports only the
964
+ * live source).
965
+ *
966
+ * Empty or missing expectedVersions: nothing is compared, so the result is
967
+ * ok:true with an empty changedFields, meaning the action relied on no
968
+ * volatile value. The source is still asked, so an outage, a failed fetch or
969
+ * an unavailable dependency still gives ok:false. A caller that showed a
970
+ * volatile value must pass it here.
971
+ *
972
+ * Returns:
973
+ * { ok: boolean, mustRetry: boolean, changedFields: Map<string, {callerVersion, live}>,
974
+ * sourceStatus: string, revalidatedAt: string, degraded?: true, reason?: string }
975
+ *
976
+ * When ok=false the action is blocked (no graph, the source unavailable or
977
+ * misbehaving, a missing live value, an unavailable dependency, ...); a
978
+ * missing adapter or a malformed argument or response blocks it too, nothing
979
+ * is thrown. When mustRetry=true the action must be re-attempted with the
980
+ * live values.
981
+ */
982
+ export async function revalidateForAction(
983
+ caller,
984
+ namespace,
985
+ graphStore,
986
+ liveSourceAdapter,
987
+ commerceContext,
988
+ actionDependencies,
989
+ expectedVersions,
990
+ ) {
991
+ const revalidatedAt = new Date().toISOString();
992
+ const blocked = (reason, sourceStatus) => ({
993
+ ok: false,
994
+ mustRetry: false,
995
+ reason,
996
+ ...(sourceStatus ? { sourceStatus } : {}),
997
+ revalidatedAt,
998
+ });
999
+
1000
+ // Read current graph
1001
+ const result = await graphStore.readCurrentGraph(caller, namespace);
1002
+ if (!result || result.graph === null) return blocked(result?.reason ?? "no graph published");
1003
+
1004
+ const { graph } = result;
1005
+ const entities = graph.entities ?? [];
1006
+
1007
+ // Collect live_required fields per entity (including offers)
1008
+ const allLiveFieldNames = new Set();
1009
+ const ids = [];
1010
+ for (const entity of entities) {
1011
+ if (!entity) continue;
1012
+ if (entity.id) ids.push(entity.id);
1013
+ for (const fn of volatileFieldsForEntity(entity)) {
1014
+ allLiveFieldNames.add(fn);
1015
+ }
1016
+ }
1017
+
1018
+ // Check source outage; a missing or misbehaving adapter is one
1019
+ const sourceStatus = liveSourceStatus(liveSourceAdapter);
1020
+ if (sourceStatus === "unavailable") return blocked("live source unavailable — cannot revalidate", sourceStatus);
1021
+
1022
+ // Check the other sources the action depends on
1023
+ let degradedDependency = null;
1024
+ if (actionDependencies !== undefined && actionDependencies !== null) {
1025
+ if (!Array.isArray(actionDependencies) || !actionDependencies.every(isSourceVersionRecord)) {
1026
+ return blocked("actionDependencies must be SourceVersion records (createSourceVersion)", sourceStatus);
1027
+ }
1028
+ const outage = disabledByOutage(actionDependencies, actionDependencies.map((record) => record.sourceId));
1029
+ if (outage.disabled) return blocked(outage.reason, sourceStatus);
1030
+ if (outage.degraded) degradedDependency = outage.reason;
1031
+ }
1032
+
1033
+ // Walk expectedVersions before the source is called: an entity no longer in
1034
+ // the graph, or a field outside its volatile set, is refused here.
1035
+ if (expectedVersions !== undefined && expectedVersions !== null && !isMapOfMaps(expectedVersions)) {
1036
+ return blocked("expectedVersions must be a Map<entityId, Map<field, version>>", sourceStatus);
1037
+ }
1038
+ const ev = expectedVersions ?? new Map();
1039
+ const entityMap = new Map(entities.filter((e) => e?.id).map((e) => [e.id, e]));
1040
+ for (const [entityId, expectedFields] of ev) {
1041
+ if (!entityMap.has(entityId)) return blocked(`entity ${entityId} is no longer in the graph`, sourceStatus);
1042
+ const entityVolatiles = volatileFieldsForEntity(entityMap.get(entityId));
1043
+ for (const field of expectedFields.keys()) {
1044
+ if (!entityVolatiles.has(field)) {
1045
+ return blocked(`entity ${entityId}: field ${field} is not a volatile field of this entity`, sourceStatus);
1046
+ }
1047
+ }
1048
+ }
1049
+
1050
+ // Fetch the graph's own volatile fields
1051
+ let liveValues;
1052
+ try {
1053
+ liveValues = await liveSourceAdapter.fetchLive(
1054
+ commerceContext ?? {},
1055
+ ids,
1056
+ [...allLiveFieldNames],
1057
+ );
1058
+ } catch {
1059
+ return blocked("live source fetch failed — cannot revalidate", "unavailable");
1060
+ }
1061
+ if (!isMapOfMaps(liveValues)) {
1062
+ return blocked("live source returned a malformed response — cannot revalidate", "unavailable");
1063
+ }
1064
+
1065
+ // Fail closed: every expected field needs a live value
1066
+ const changedFields = new Map();
1067
+ for (const [entityId, perEntityExpected] of ev) {
1068
+ const perEntityLive = liveValues.get(entityId);
1069
+
1070
+ for (const [field, expectedVersion] of perEntityExpected) {
1071
+ if (!perEntityLive || perEntityLive.size === 0) {
1072
+ return blocked(`entity ${entityId}: no live values returned for expected field ${field}`, sourceStatus);
1073
+ }
1074
+ if (!perEntityLive.has(field)) {
1075
+ return blocked(`entity ${entityId}: expected field ${field} has no live value`, sourceStatus);
1076
+ }
1077
+
1078
+ const liveVal = perEntityLive.get(field);
1079
+
1080
+ // Compare against caller's expected version
1081
+ const liveVersion = typeof liveVal === "object" && liveVal !== null
1082
+ ? JSON.stringify(liveVal)
1083
+ : String(liveVal);
1084
+ const expectedStr = typeof expectedVersion === "object" && expectedVersion !== null
1085
+ ? JSON.stringify(expectedVersion)
1086
+ : String(expectedVersion);
1087
+
1088
+ if (liveVersion !== expectedStr) {
1089
+ changedFields.set(`${entityId}:${field}`, {
1090
+ callerVersion: expectedVersion,
1091
+ live: liveVal,
1092
+ });
1093
+ }
1094
+ }
1095
+ }
1096
+
1097
+ // Disclose reduced confidence, from the live source and from dependencies
1098
+ const degraded = [
1099
+ sourceStatus === "degraded" ? "live source is degraded — proceed with reduced confidence" : null,
1100
+ degradedDependency,
1101
+ ].filter(Boolean);
1102
+
1103
+ return {
1104
+ ok: true,
1105
+ mustRetry: changedFields.size > 0,
1106
+ changedFields,
1107
+ sourceStatus,
1108
+ revalidatedAt,
1109
+ ...(degraded.length > 0 ? { degraded: true, reason: degraded.join("; ") } : {}),
1110
+ };
1111
+ }
1112
+
1113
+ // ---------------------------------------------------------------------------
1114
+ // LiveStateError
1115
+ // ---------------------------------------------------------------------------
1116
+
1117
+ export class LiveStateError extends Error {
1118
+ constructor(message) {
1119
+ super(message);
1120
+ this.name = "LiveStateError";
1121
+ }
1122
+ }
1123
+
1124
+ // ---------------------------------------------------------------------------
1125
+ // Known volatile commerce field names (always live_required)
1126
+ // ---------------------------------------------------------------------------
1127
+
1128
+ /**
1129
+ * Field names that are always served from the live source, never from cache.
1130
+ * These correspond to the volatile commerce fields from the ticket: price,
1131
+ * availability, inventory (schema.org inventoryLevel) and delivery estimate
1132
+ * (schema.org deliveryTime on shipping details, deliveryLeadTime on an
1133
+ * offer).
1134
+ *
1135
+ * Note: schema.org Products typically carry price and availability inside
1136
+ * `offers` (an object, array of variants, or AggregateOffer), and prices also
1137
+ * sit in a priceSpecification or on top-level Offer, AggregateOffer and
1138
+ * UnitPriceSpecification entities. hydrate and revalidateForAction therefore
1139
+ * treat a field with one of these names, and any canonical money value, as
1140
+ * volatile at any depth of an entity's attributes (mapVolatileNodes).
1141
+ *
1142
+ * A name is volatile wherever it appears, on any entity: a store-wide shipping
1143
+ * policy under deliveryTime (an Organization's, say) is cleared like an
1144
+ * offer's estimate unless the adapter serves it. Adapters must serve these
1145
+ * fields to keep them; limiting the delivery names to offer paths would let a
1146
+ * cached estimate through on a top-level Offer entity or under makesOffer.
1147
+ */
1148
+ export const VOLATILE_FIELD_NAMES = Object.freeze([
1149
+ "price",
1150
+ "availability",
1151
+ "inventory",
1152
+ "inventoryLevel",
1153
+ "delivery_estimate",
1154
+ "deliveryEstimate",
1155
+ "deliveryTime",
1156
+ "deliveryLeadTime",
1157
+ ]);
1158
+
1159
+ /**
1160
+ * The form a field name is compared in: the local name after any namespace
1161
+ * prefix (schema:availability, https://schema.org/availability), lowercased,
1162
+ * with every character other than a letter or digit removed. The compiler
1163
+ * keeps markup keys as written, so Availability, AVAILABILITY and
1164
+ * delivery-estimate name the same fields as availability and
1165
+ * deliveryEstimate.
1166
+ */
1167
+ function normalizeFieldName(fieldName) {
1168
+ if (typeof fieldName !== "string") return "";
1169
+ return fieldName.split(/[:/#]/).pop().toLowerCase().replace(/[^a-z0-9]/g, "");
1170
+ }
1171
+
1172
+ const VOLATILE_FIELD_SET = new Set(VOLATILE_FIELD_NAMES.map(normalizeFieldName));
1173
+
1174
+ /**
1175
+ * Check whether a field name is volatile (must be fetched live), in any
1176
+ * spelling normalizeFieldName folds together.
1177
+ */
1178
+ export function isVolatileField(fieldName) {
1179
+ return VOLATILE_FIELD_SET.has(normalizeFieldName(fieldName));
1180
+ }