sentinelayer-cli 0.46.5 → 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 (133) hide show
  1. package/README.md +47 -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 +32 -1
  26. package/src/auth/session-store.js +37 -0
  27. package/src/board/evidence-resolvers.js +3 -4
  28. package/src/cli.js +23 -3
  29. package/src/commands/ai/identity-lifecycle.js +30 -26
  30. package/src/commands/ai/provision-governance.js +32 -24
  31. package/src/commands/ai/shared.js +6 -4
  32. package/src/commands/audit.js +14 -12
  33. package/src/commands/board.js +2 -2
  34. package/src/commands/chat.js +8 -6
  35. package/src/commands/judge.js +125 -0
  36. package/src/commands/legacy-args.js +1 -0
  37. package/src/commands/mcp.js +45 -52
  38. package/src/commands/omargate.js +2 -1
  39. package/src/commands/plugin.js +11 -3
  40. package/src/commands/review.js +9 -4
  41. package/src/commands/scan.js +7 -7
  42. package/src/commands/session.js +516 -30
  43. package/src/commands/skills.js +45 -0
  44. package/src/commands/watch.js +9 -7
  45. package/src/config/output-filesystem.js +156 -0
  46. package/src/config/schema.js +4 -2
  47. package/src/config/service.js +40 -4
  48. package/src/cost/history.js +15 -9
  49. package/src/daemon/artifact-lineage.js +15 -12
  50. package/src/daemon/assignment-ledger.js +31 -25
  51. package/src/daemon/ast-drift.js +7 -3
  52. package/src/daemon/budget-governor.js +26 -20
  53. package/src/daemon/error-worker.js +28 -22
  54. package/src/daemon/fix-cycle.js +5 -4
  55. package/src/daemon/hybrid-mapper.js +19 -16
  56. package/src/daemon/ingest-refresh.js +4 -0
  57. package/src/daemon/jira-lifecycle.js +21 -17
  58. package/src/daemon/operator-control.js +18 -15
  59. package/src/daemon/reliability-lane.js +18 -15
  60. package/src/daemon/scope-engine.js +12 -8
  61. package/src/daemon/watchdog.js +15 -12
  62. package/src/engram/build-state.js +24 -3
  63. package/src/engram/capability-index.js +367 -0
  64. package/src/engram/compile-graph.js +1063 -0
  65. package/src/engram/contracts/action-receipt.js +82 -0
  66. package/src/engram/contracts/capability-index.js +259 -0
  67. package/src/engram/contracts/capability.js +377 -0
  68. package/src/engram/contracts/content-hash.js +8 -0
  69. package/src/engram/contracts/evidence.js +258 -0
  70. package/src/engram/contracts/graph.js +594 -0
  71. package/src/engram/contracts/identity.js +8 -0
  72. package/src/engram/contracts/index.js +149 -0
  73. package/src/engram/contracts/media-asset.js +85 -0
  74. package/src/engram/contracts/money.js +249 -0
  75. package/src/engram/contracts/source-snapshot.js +57 -0
  76. package/src/engram/contracts/task-context-pack.js +114 -0
  77. package/src/engram/contracts/temporal-segment.js +72 -0
  78. package/src/engram/contracts/versions.js +96 -0
  79. package/src/engram/extract-html.js +692 -0
  80. package/src/engram/graph-store.js +877 -0
  81. package/src/engram/index.js +98 -14
  82. package/src/engram/ingest-site.js +132 -0
  83. package/src/engram/live-state.js +1180 -0
  84. package/src/engram/namespace.js +34 -1
  85. package/src/engram/retrieve-site.js +753 -0
  86. package/src/engram/safe-reader.js +597 -0
  87. package/src/engram/source-intake.js +251 -0
  88. package/src/engram/store.js +227 -9
  89. package/src/ingest/engine.js +21 -28
  90. package/src/judge/verdict.js +305 -0
  91. package/src/legacy-cli.js +96 -19
  92. package/src/mcp/bridge-inputs.js +25 -4
  93. package/src/mcp/cli-command-tools.js +70 -0
  94. package/src/mcp/registry.js +17 -13
  95. package/src/mcp/session-stdio-server.js +297 -19
  96. package/src/memory/blackboard.js +5 -4
  97. package/src/net/gh-api-path.js +35 -9
  98. package/src/net/test-egress-guard.js +16 -4
  99. package/src/plugin/manifest.js +12 -8
  100. package/src/review/ai-review.js +110 -10
  101. package/src/review/investor-dd-devtestbot.js +17 -6
  102. package/src/review/investor-dd-orchestrator.js +21 -13
  103. package/src/review/local-review.js +26 -16
  104. package/src/review/omargate-cache.js +5 -5
  105. package/src/review/omargate-orchestrator.js +89 -17
  106. package/src/review/replay.js +9 -4
  107. package/src/review/report.js +22 -13
  108. package/src/review/typesafe-severity.js +201 -0
  109. package/src/session/admission-auth.js +34 -7
  110. package/src/session/admission.js +110 -15
  111. package/src/session/attachment-summary.js +77 -0
  112. package/src/session/edit-summary.js +19 -0
  113. package/src/session/invitations.js +65 -1
  114. package/src/session/mentions.js +17 -9
  115. package/src/session/message-edits.js +5 -4
  116. package/src/session/preview.js +10 -2
  117. package/src/session/recap.js +3 -2
  118. package/src/session/room-files.js +519 -0
  119. package/src/session/route-classes.js +22 -8
  120. package/src/session/setup-guides.js +4 -0
  121. package/src/session/standup.js +192 -0
  122. package/src/session/sync.js +229 -10
  123. package/src/session/tickets.js +41 -0
  124. package/src/session/transcript.js +2 -1
  125. package/src/session/wake/resolve-target.js +7 -3
  126. package/src/skills/listener-runtime.cjs +92 -0
  127. package/src/skills/service.js +207 -0
  128. package/src/swarm/factory.js +5 -5
  129. package/src/swarm/pentest.js +9 -9
  130. package/src/swarm/report.js +4 -4
  131. package/src/swarm/runtime.js +15 -12
  132. package/src/swarm/scenario-dsl.js +7 -5
  133. package/src/telemetry/ledger.js +16 -7
@@ -0,0 +1,377 @@
1
+ /**
2
+ * ENGRAM §EW02 — Capability contract (R04 skills, R14 parallelism).
3
+ *
4
+ * Source/capability versions and tenant scope are required — no inferred
5
+ * backend endpoints from screenshots (ticket 73ead329 mandatory constraints).
6
+ *
7
+ * This module codifies:
8
+ * - Capability versioning: every source and capability declares its version
9
+ * - Tenant scoping: every graph consumer operates within a tenant
10
+ * - Protocol capability negotiation: what two parties agree they can do
11
+ * - Unknown/unsupported/stale errors: typed, with evidence addresses
12
+ * - Pagination: cursor-based pagination for evidence lists
13
+ * - Migration policy: version compatibility declarations
14
+ *
15
+ * All schemas are strict: extra fields are rejected (luna-judge P2 finding).
16
+ * Negotiation validates inputs on every path and returns validated results.
17
+ */
18
+
19
+ import { z } from "zod";
20
+ import { nonblankIdentitySchema } from "./identity.js";
21
+
22
+ /**
23
+ * A capability version identifier.
24
+ * Format: <name>@<semver> (e.g. "engram-graph@1.0.0")
25
+ */
26
+ const CAPABILITY_NAME_RE = /^[a-z][a-z0-9_-]{0,47}$/;
27
+ const CAPABILITY_VERSION_RE = /^([a-z][a-z0-9_-]{0,47})@(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
28
+
29
+ export const capabilityVersionSchema = z.string().regex(
30
+ CAPABILITY_VERSION_RE,
31
+ { message: "capability version must match <name>@<semver>" },
32
+ );
33
+
34
+ /** The canonical tenant key used by sources, entries and negotiation. */
35
+ export const tenantScopeSchema = nonblankIdentitySchema;
36
+
37
+ /** Optional structured tenant metadata; it is not a wire tenant key. */
38
+ export const tenantScopeDescriptorSchema = z.object({
39
+ tenantId: nonblankIdentitySchema.max(128),
40
+ namespace: nonblankIdentitySchema.max(256).optional(),
41
+ environment: z.enum(["production", "staging", "development", "sandbox"]).optional(),
42
+ }).strict();
43
+
44
+ /**
45
+ * A source descriptor: identifies where an assertion or capability originated.
46
+ */
47
+ export const sourceDescriptorSchema = z.object({
48
+ sourceId: nonblankIdentitySchema,
49
+ sourceType: z.enum(["adapter", "ingest", "derived", "external", "manual"]),
50
+ version: capabilityVersionSchema,
51
+ tenantScope: tenantScopeSchema,
52
+ endpoint: z.string().url().optional(),
53
+ }).strict();
54
+
55
+ /**
56
+ * Valid capability status values for server entries.
57
+ */
58
+ const CAPABILITY_STATUS = z.enum(["supported", "deprecated", "experimental", "unknown", "unsupported", "stale"]);
59
+
60
+ /**
61
+ * A single capability entry: what a source can do, at what version.
62
+ */
63
+ export const capabilityEntrySchema = z.object({
64
+ capability: z.string().regex(CAPABILITY_NAME_RE),
65
+ version: capabilityVersionSchema,
66
+ source: sourceDescriptorSchema,
67
+ status: CAPABILITY_STATUS,
68
+ evidenceAddress: z.string().optional(),
69
+ deprecatesAt: z.string().datetime().optional(),
70
+ migratesTo: capabilityVersionSchema.optional(),
71
+ }).strict().superRefine((entry, ctx) => {
72
+ for (const field of ["version", "migratesTo"]) {
73
+ if (entry[field] && !versionNameMatches(entry.capability, entry[field])) {
74
+ ctx.addIssue({ code: "custom", path: [field], message: `${field} name must match capability` });
75
+ }
76
+ }
77
+ });
78
+
79
+ /**
80
+ * Protocol capability negotiation result.
81
+ */
82
+ export const negotiationResultSchema = z.object({
83
+ capability: z.string().regex(CAPABILITY_NAME_RE),
84
+ clientVersion: capabilityVersionSchema,
85
+ serverVersion: capabilityVersionSchema.optional(),
86
+ negotiatedVersion: capabilityVersionSchema.optional(),
87
+ tenantScope: tenantScopeSchema.optional(),
88
+ result: z.enum(["matched", "upgraded", "downgraded", "incompatible", "unknown"]),
89
+ reason: z.string().optional(),
90
+ evidenceAddress: z.string().optional(),
91
+ }).strict().superRefine((result, ctx) => {
92
+ for (const field of ["clientVersion", "serverVersion", "negotiatedVersion"]) {
93
+ if (result[field] && !versionNameMatches(result.capability, result[field])) {
94
+ ctx.addIssue({ code: "custom", path: [field], message: `${field} name must match capability` });
95
+ }
96
+ }
97
+ const usable = ["matched", "upgraded", "downgraded"].includes(result.result);
98
+ if (usable) {
99
+ if (!result.tenantScope) {
100
+ ctx.addIssue({ code: "custom", path: ["tenantScope"], message: "usable negotiation requires requesting tenant scope" });
101
+ }
102
+ if (!result.serverVersion || !result.negotiatedVersion || result.negotiatedVersion !== result.serverVersion) {
103
+ ctx.addIssue({ code: "custom", path: ["negotiatedVersion"], message: "usable negotiation must identify the server version" });
104
+ }
105
+ if (result.serverVersion) {
106
+ const client = parseCapVersion(result.clientVersion);
107
+ const server = parseCapVersion(result.serverVersion);
108
+ if (client && server) {
109
+ if (client[1] !== server[1]) {
110
+ ctx.addIssue({ code: "custom", path: ["result"], message: "usable negotiation requires a matching major version" });
111
+ }
112
+ const order = server[2] === client[2]
113
+ ? (server[3] > client[3] ? 1 : server[3] < client[3] ? -1 : 0)
114
+ : (server[2] > client[2] ? 1 : -1);
115
+ if ((result.result === "matched" && order !== 0) ||
116
+ (result.result === "upgraded" && order <= 0) ||
117
+ (result.result === "downgraded" && order >= 0)) {
118
+ ctx.addIssue({ code: "custom", path: ["result"], message: "negotiation result must match version order" });
119
+ }
120
+ }
121
+ }
122
+ } else if (result.negotiatedVersion) {
123
+ ctx.addIssue({ code: "custom", path: ["negotiatedVersion"], message: "unusable negotiation cannot grant a version" });
124
+ }
125
+ });
126
+
127
+ /**
128
+ * Pagination cursor for evidence lists.
129
+ */
130
+ export const paginationSchema = z.object({
131
+ cursor: z.string().optional(),
132
+ limit: z.number().int().min(1).max(1000).optional(),
133
+ nextCursor: z.string().optional(),
134
+ hasMore: z.boolean().optional(),
135
+ total: z.number().int().nonnegative().optional(),
136
+ }).strict().refine(
137
+ (page) => !page.hasMore || !!page.nextCursor,
138
+ { message: "hasMore requires nextCursor", path: ["nextCursor"] },
139
+ );
140
+
141
+ /**
142
+ * Migration policy: declares compatibility between capability versions.
143
+ */
144
+ export const migrationPolicySchema = z.object({
145
+ capability: z.string().regex(CAPABILITY_NAME_RE),
146
+ fromVersion: capabilityVersionSchema,
147
+ toVersion: capabilityVersionSchema,
148
+ compatibility: z.enum(["compatible", "breaking", "additive"]),
149
+ migrationNotes: z.string().optional(),
150
+ createdAt: z.string().datetime(),
151
+ }).strict().superRefine((policy, ctx) => {
152
+ for (const field of ["fromVersion", "toVersion"]) {
153
+ if (!versionNameMatches(policy.capability, policy[field])) {
154
+ ctx.addIssue({ code: "custom", path: [field], message: `${field} name must match capability` });
155
+ }
156
+ }
157
+ });
158
+
159
+ /**
160
+ * Error types for capability operations.
161
+ */
162
+ export class CapabilityError extends Error {
163
+ constructor(message, code, evidence) {
164
+ super(message);
165
+ this.name = "CapabilityError";
166
+ this.code = code;
167
+ this.evidence = evidence || null;
168
+ }
169
+ }
170
+
171
+ export function unknownCapability(capability, evidence) {
172
+ return new CapabilityError(
173
+ `capability "${capability}" is unknown or not registered`,
174
+ "unknown_capability",
175
+ evidence,
176
+ );
177
+ }
178
+
179
+ export function unsupportedCapability(capability, version, reason, evidence) {
180
+ return new CapabilityError(
181
+ `capability "${capability}@${version}" is unsupported: ${reason || "no reason given"}`,
182
+ "unsupported_capability",
183
+ evidence,
184
+ );
185
+ }
186
+
187
+ export function staleCapability(capability, version, asOf, evidence) {
188
+ return new CapabilityError(
189
+ `capability "${capability}@${version}" is stale (asOf ${asOf}); refresh required`,
190
+ "stale_capability",
191
+ evidence,
192
+ );
193
+ }
194
+
195
+ /**
196
+ * Parse a semver tuple from a capability version string.
197
+ * @param {string} capVersion — e.g. "engram-graph@1.2.3"
198
+ * @returns {[string, bigint, bigint, bigint]|null} — [name, major, minor, patch]
199
+ */
200
+ function parseCapVersion(capVersion) {
201
+ const match = typeof capVersion === "string" && CAPABILITY_VERSION_RE.exec(capVersion);
202
+ if (!match) return null;
203
+ return [match[1], BigInt(match[2]), BigInt(match[3]), BigInt(match[4])];
204
+ }
205
+
206
+ /**
207
+ * Validate that a server entry's version name binds to the stated capability.
208
+ */
209
+ function versionNameMatches(capabilityName, versionString) {
210
+ const parts = parseCapVersion(versionString);
211
+ if (!parts) return false;
212
+ return parts[0] === capabilityName;
213
+ }
214
+
215
+ /**
216
+ * Negotiate a protocol capability between client and server.
217
+ *
218
+ * F7/F8 fixes:
219
+ * - Stale capabilities return "incompatible" with NO negotiatedVersion
220
+ * - Unknown capabilities return "unknown" immediately, no version comparison
221
+ * - Validates server entries and binds version names to the capability
222
+ * - Misspelled statuses are rejected (invalid status → incompatible)
223
+ * - Every path returns a negotiationResultSchema-valid result or a typed error
224
+ *
225
+ * @param {object} params
226
+ * @param {string} params.capability
227
+ * @param {string} params.clientVersion
228
+ * @param {string} params.tenantScope — requesting tenant; caller must authenticate it
229
+ * @param {Array<{capability: string, version: string, status: string}>} params.serverCapabilities
230
+ * @returns {object} negotiationResultSchema-shaped result
231
+ */
232
+ export function negotiateCapability({ capability, clientVersion, tenantScope, serverCapabilities = [] } = {}) {
233
+ // All failure paths retain a schema-valid result; malformed caller values
234
+ // cannot leak into the output or grant a usable negotiated version.
235
+ const safeCapability = typeof capability === "string" && CAPABILITY_NAME_RE.test(capability)
236
+ ? capability : "unknown-cap";
237
+ const clientParts = parseCapVersion(clientVersion);
238
+ const safeClientVersion = clientParts?.[0] === safeCapability
239
+ ? clientVersion : `${safeCapability}@0.0.0`;
240
+ const requestedTenant = tenantScopeSchema.safeParse(tenantScope).success
241
+ ? tenantScope : undefined;
242
+ const makeResult = (overrides) => negotiationResultSchema.parse({
243
+ capability: safeCapability,
244
+ clientVersion: safeClientVersion,
245
+ ...(requestedTenant ? { tenantScope: requestedTenant } : {}),
246
+ result: "incompatible",
247
+ ...overrides,
248
+ });
249
+
250
+ if (safeCapability !== capability) {
251
+ return makeResult({ reason: "capability is required and must be a valid name" });
252
+ }
253
+ if (!clientParts) {
254
+ return makeResult({ reason: "clientVersion is not a valid capability version" });
255
+ }
256
+ if (clientParts[0] !== capability) {
257
+ return makeResult({
258
+ reason: `clientVersion name "${clientParts[0]}" does not match capability "${capability}"`,
259
+ });
260
+ }
261
+ if (!requestedTenant) {
262
+ return makeResult({ reason: "requesting tenantScope is required for capability negotiation" });
263
+ }
264
+ if (!Array.isArray(serverCapabilities)) {
265
+ return makeResult({ result: "unknown", reason: "serverCapabilities must be an array" });
266
+ }
267
+
268
+ const matchingEntries = serverCapabilities.filter((entry) =>
269
+ entry && typeof entry === "object" && entry.capability === capability &&
270
+ entry.source?.tenantScope === requestedTenant);
271
+
272
+ if (matchingEntries.length === 0) {
273
+ return makeResult({ result: "unknown", reason: `capability "${capability}" is not registered for this tenant` });
274
+ }
275
+ // A list with multiple claims for one capability and tenant cannot choose
276
+ // authority by array order. A selection policy for version sets belongs in a
277
+ // later protocol version; this contract fails closed until one is defined.
278
+ if (matchingEntries.length > 1) {
279
+ return makeResult({ reason: `capability "${capability}" has ambiguous server entries for this tenant` });
280
+ }
281
+ const serverCap = matchingEntries[0];
282
+
283
+ // Validate the server entry against the full capabilityEntrySchema.
284
+ // This ensures source, tenantScope, and all required fields are present
285
+ // before negotiating. An entry missing source/tenant is not a valid capability.
286
+ const entryResult = capabilityEntrySchema.safeParse(serverCap);
287
+ if (!entryResult.success) {
288
+ return makeResult({
289
+ result: "unknown",
290
+ reason: `capability "${capability}" server entry is invalid: ${entryResult.error.issues.map((i) => i.message).join("; ")}`,
291
+ });
292
+ }
293
+
294
+ const status = entryResult.data.status;
295
+
296
+ // stale → incompatible, no negotiated version
297
+ if (status === "stale") {
298
+ return makeResult({
299
+ serverVersion: serverCap.version,
300
+ result: "incompatible",
301
+ reason: `capability "${capability}" is stale; cannot negotiate a usable version`,
302
+ });
303
+ }
304
+
305
+ // unknown → unknown immediately, no version comparison
306
+ if (status === "unknown") {
307
+ return makeResult({
308
+ serverVersion: serverCap.version,
309
+ result: "unknown",
310
+ reason: `capability "${capability}" status is unknown; cannot determine compatibility`,
311
+ });
312
+ }
313
+
314
+ if (status === "unsupported") {
315
+ return makeResult({
316
+ serverVersion: serverCap.version,
317
+ reason: `capability "${capability}" is explicitly unsupported`,
318
+ });
319
+ }
320
+
321
+ // Version comparison for supported/deprecated/experimental
322
+ const serverParts = parseCapVersion(serverCap.version);
323
+ if (!serverParts) {
324
+ return makeResult({
325
+ serverVersion: serverCap.version,
326
+ reason: "cannot parse server version numbers",
327
+ });
328
+ }
329
+
330
+ if (clientParts[1] !== serverParts[1]) {
331
+ return makeResult({
332
+ serverVersion: serverCap.version,
333
+ result: "incompatible",
334
+ reason: `major version mismatch: client ${clientParts[1]}, server ${serverParts[1]}`,
335
+ });
336
+ }
337
+
338
+ if (
339
+ serverParts[2] > clientParts[2] ||
340
+ (serverParts[2] === clientParts[2] && serverParts[3] >= clientParts[3])
341
+ ) {
342
+ const rawResult = {
343
+ capability,
344
+ clientVersion,
345
+ serverVersion: serverCap.version,
346
+ negotiatedVersion: serverCap.version,
347
+ tenantScope: requestedTenant,
348
+ result: clientVersion === serverCap.version ? "matched" : "upgraded",
349
+ };
350
+ const parsed = negotiationResultSchema.safeParse(rawResult);
351
+ if (!parsed.success) {
352
+ return makeResult({
353
+ serverVersion: serverCap.version,
354
+ reason: `output validation failed: ${parsed.error.issues.map((i) => i.message).join("; ")}`,
355
+ });
356
+ }
357
+ return parsed.data;
358
+ }
359
+
360
+ const rawResult = {
361
+ capability,
362
+ clientVersion,
363
+ serverVersion: serverCap.version,
364
+ negotiatedVersion: serverCap.version,
365
+ tenantScope: requestedTenant,
366
+ result: "downgraded",
367
+ reason: `server version ${serverCap.version} is behind client ${clientVersion}`,
368
+ };
369
+ const parsed = negotiationResultSchema.safeParse(rawResult);
370
+ if (!parsed.success) {
371
+ return makeResult({
372
+ serverVersion: serverCap.version,
373
+ reason: `output validation failed: ${parsed.error.issues.map((i) => i.message).join("; ")}`,
374
+ });
375
+ }
376
+ return parsed.data;
377
+ }
@@ -0,0 +1,8 @@
1
+ /** Structural SHA-256 address only; callers still verify bytes and trust. */
2
+ import { z } from "zod";
3
+
4
+ // 32 bytes: 64 hexadecimal characters or canonical padded base64.
5
+ export const sha256ContentHashSchema = z.string().regex(
6
+ /^sha256:(?:[0-9a-fA-F]{64}|[A-Za-z0-9+/]{42}[AEIMQUYcgkosw048]=)$/,
7
+ { message: "contentHash must be a complete sha256 digest in hex or canonical base64" },
8
+ );
@@ -0,0 +1,258 @@
1
+ /**
2
+ * ENGRAM §EW02 — Evidence contract (R09 truth).
3
+ *
4
+ * Every assertion's evidence is addressable. This module codifies:
5
+ * - Evidence addresses: stable references to the source of an assertion
6
+ * - Pagination: cursor-based evidence list traversal
7
+ * - Migration policy: evidence format version compatibility
8
+ * - Freshness: every evidence carries an asOf or declares itself stale
9
+ *
10
+ * Mandatory constraint (ticket 73ead329): include unknown/unsupported/stale
11
+ * errors, pagination, evidence addresses, migration policy and protocol
12
+ * capability negotiation.
13
+ *
14
+ * Luna-judge P2: evidencePageSchema requires nextCursor when hasMore is true.
15
+ */
16
+
17
+ import { z } from "zod";
18
+ import { EVIDENCE_SCHEMA_VERSION } from "./versions.js";
19
+ import { nonblankIdentitySchema } from "./identity.js";
20
+
21
+ /**
22
+ * Supported evidence locator schemes. "screenshot" and
23
+ * "image" are explicitly banned (ticket 73ead329: no inferred backend
24
+ * endpoints from screenshots).
25
+ */
26
+ const TRUSTED_SCHEMES = Object.freeze(["file", "http", "https", "engram", "receipt", "inline"]);
27
+
28
+ /**
29
+ * An evidence address: a stable reference to where an assertion's proof lives.
30
+ * Must NOT be inferred from screenshots (ticket 73ead329 constraint).
31
+ *
32
+ * These fields record a provenance claim. Parsing them does not authenticate
33
+ * the claimed verifier; consumers must check it against a trusted source.
34
+ */
35
+ export const evidenceAddressSchema = z.object({
36
+ /** Protocol/scheme: must be an allowed locator scheme */
37
+ scheme: z.enum(["file", "http", "https", "engram", "receipt", "inline"]),
38
+ /** Locator within that scheme */
39
+ locator: z.string().min(1),
40
+ /** Content hash at the time of reference (for integrity) */
41
+ contentHash: z.string().min(1).optional(),
42
+ /** When this evidence was captured */
43
+ capturedAt: z.string().datetime().optional(),
44
+ /** Source version that produced this evidence */
45
+ sourceVersion: nonblankIdentitySchema,
46
+ /** Tenant scope of the evidence */
47
+ tenantScope: nonblankIdentitySchema,
48
+ /** Whether the evidence is still reachable */
49
+ reachable: z.boolean().optional(),
50
+ /** Verifier identity — who attested this address was not screenshot-inferred */
51
+ verifiedBy: z.string().min(1).optional(),
52
+ }).strict().refine(
53
+ (addr) => {
54
+ // Require a verifier identity as metadata, but do not treat this string as
55
+ // proof of independent verification.
56
+ if (!addr.verifiedBy) return false;
57
+ return true;
58
+ },
59
+ {
60
+ message: "evidence address must claim verifiedBy; a trusted source must verify the source version and claim",
61
+ path: ["verifiedBy"],
62
+ },
63
+ );
64
+
65
+ /**
66
+ * An evidence record: the address plus the actual evidence payload.
67
+ */
68
+ export const evidenceRecordSchema = z.object({
69
+ address: evidenceAddressSchema,
70
+ /** The raw evidence payload (type depends on scheme) */
71
+ payload: z.json(),
72
+ /** Schema version of the evidence format */
73
+ schemaVersion: z.literal(EVIDENCE_SCHEMA_VERSION),
74
+ /** When the evidence was collected */
75
+ collectedAt: z.string().datetime(),
76
+ /** When the evidence expires (if applicable) */
77
+ expiresAt: z.string().datetime().optional(),
78
+ /** Model confidence in the evidence interpretation */
79
+ modelConfidence: z.enum(["high", "medium", "low", "unavailable"]).optional(),
80
+ /** Sender's verification claim; parsing this boolean does not establish trust */
81
+ independentlyVerified: z.boolean().optional(),
82
+ /** Claimed verifier identity; authenticate it through a trusted runtime source */
83
+ verifiedBy: z.string().min(1).optional(),
84
+ }).strict().superRefine((record, ctx) => {
85
+ if (record.address.capturedAt && Date.parse(record.address.capturedAt) > Date.parse(record.collectedAt)) {
86
+ ctx.addIssue({ code: "custom", message: "address capturedAt cannot be after collectedAt", path: ["address", "capturedAt"] });
87
+ }
88
+ if (record.expiresAt && Date.parse(record.expiresAt) <= Date.parse(record.collectedAt)) {
89
+ ctx.addIssue({ code: "custom", message: "expiresAt must be after collectedAt", path: ["expiresAt"] });
90
+ }
91
+ });
92
+
93
+ /**
94
+ * Paginated evidence list.
95
+ *
96
+ * When hasMore is true, nextCursor is required so clients can retrieve the
97
+ * next page (luna-judge P2 finding).
98
+ */
99
+ export const evidencePageSchema = z.object({
100
+ items: z.array(evidenceRecordSchema),
101
+ cursor: z.string().optional(),
102
+ nextCursor: z.string().optional(),
103
+ hasMore: z.boolean(),
104
+ total: z.number().int().nonnegative().optional(),
105
+ }).strict().superRefine((data, ctx) => {
106
+ if (data.hasMore && !data.nextCursor) {
107
+ ctx.addIssue({ code: "custom", message: "hasMore requires nextCursor for pagination", path: ["nextCursor"] });
108
+ }
109
+ const scopes = new Set(data.items.map((record) => record.address.tenantScope));
110
+ if (scopes.size > 1) {
111
+ ctx.addIssue({ code: "custom", message: "evidence page cannot mix tenant scopes", path: ["items"] });
112
+ }
113
+ if (data.total != null && data.total < data.items.length) {
114
+ ctx.addIssue({ code: "custom", message: "total cannot be smaller than items count", path: ["total"] });
115
+ }
116
+ });
117
+
118
+ /**
119
+ * Migration policy for evidence format changes.
120
+ */
121
+ export const evidenceMigrationPolicySchema = z.object({
122
+ fromSchemaVersion: z.string().min(1),
123
+ toSchemaVersion: z.string().min(1),
124
+ compatibility: z.enum(["compatible", "breaking", "additive"]),
125
+ migrationNotes: z.string().optional(),
126
+ createdAt: z.string().datetime(),
127
+ appliesToSchemes: z.array(z.string()).optional(),
128
+ }).strict();
129
+
130
+ /**
131
+ * Freshness declaration for an evidence set.
132
+ */
133
+ export const evidenceFreshnessSchema = z.object({
134
+ asOf: z.string().datetime(),
135
+ /** Time at which the stale claim was evaluated; required when isStale is supplied. */
136
+ evaluatedAt: z.string().datetime().optional(),
137
+ coverage: z.object({
138
+ totalAssertions: z.number().int().nonnegative(),
139
+ evidenced: z.number().int().nonnegative(),
140
+ ratio: z.number().min(0).max(1),
141
+ }).strict().superRefine((coverage, ctx) => {
142
+ if (coverage.evidenced > coverage.totalAssertions) {
143
+ ctx.addIssue({ code: "custom", path: ["evidenced"], message: "evidenced cannot exceed totalAssertions" });
144
+ }
145
+ const expected = coverage.totalAssertions === 0 ? 0 : coverage.evidenced / coverage.totalAssertions;
146
+ if (Math.abs(coverage.ratio - expected) > 1e-9) {
147
+ ctx.addIssue({ code: "custom", path: ["ratio"], message: "ratio must match evidenced / totalAssertions" });
148
+ }
149
+ }),
150
+ staleAfterMs: z.number().int().positive().optional(),
151
+ isStale: z.boolean().optional(),
152
+ }).strict().superRefine((freshness, ctx) => {
153
+ if (freshness.evaluatedAt && Date.parse(freshness.evaluatedAt) < Date.parse(freshness.asOf)) {
154
+ ctx.addIssue({ code: "custom", path: ["evaluatedAt"],
155
+ message: "evaluatedAt cannot precede asOf" });
156
+ return;
157
+ }
158
+ if (freshness.isStale === undefined) return;
159
+ if (!freshness.evaluatedAt || freshness.staleAfterMs === undefined) {
160
+ ctx.addIssue({ code: "custom", path: ["isStale"],
161
+ message: "isStale requires evaluatedAt and staleAfterMs" });
162
+ return;
163
+ }
164
+ const ageMs = Date.parse(freshness.evaluatedAt) - Date.parse(freshness.asOf);
165
+ if (ageMs < 0 || freshness.isStale !== (ageMs >= freshness.staleAfterMs)) {
166
+ ctx.addIssue({ code: "custom", path: ["isStale"],
167
+ message: "isStale must agree with asOf, evaluatedAt and staleAfterMs" });
168
+ }
169
+ });
170
+
171
+ /**
172
+ * Error types for evidence operations.
173
+ */
174
+ export class EvidenceError extends Error {
175
+ constructor(message, code, address) {
176
+ super(message);
177
+ this.name = "EvidenceError";
178
+ this.code = code;
179
+ this.address = address || null;
180
+ }
181
+ }
182
+
183
+ export function evidenceNotFound(address) {
184
+ return new EvidenceError(
185
+ `evidence at "${address}" not found or unreachable`,
186
+ "evidence_not_found",
187
+ address,
188
+ );
189
+ }
190
+
191
+ export function evidenceStale(address, asOf) {
192
+ return new EvidenceError(
193
+ `evidence at "${address}" is stale (asOf ${asOf})`,
194
+ "evidence_stale",
195
+ address,
196
+ );
197
+ }
198
+
199
+ export function evidenceUnsupportedScheme(scheme) {
200
+ return new EvidenceError(
201
+ `evidence scheme "${scheme}" is not supported by this runtime`,
202
+ "unsupported_evidence_scheme",
203
+ null,
204
+ );
205
+ }
206
+
207
+ /**
208
+ * Validate the address shape and ask a trusted, out-of-band source verifier
209
+ * whether it was actually derived from the stated source. Caller-supplied
210
+ * sourceVersion, hash, and verifiedBy strings cannot prove this by themselves.
211
+ *
212
+ * @param {object} address — evidenceAddressSchema-shaped
213
+ * @param {{verifySource?: (address: object) => boolean}} options
214
+ * @returns {{ valid: boolean, error?: string }}
215
+ */
216
+ export function validateNotScreenshotInferred(address, { verifySource } = {}) {
217
+ if (!address || typeof address !== "object") {
218
+ return { valid: false, error: "evidence address is required" };
219
+ }
220
+ if (address.scheme === "screenshot" || address.scheme === "image") {
221
+ return {
222
+ valid: false,
223
+ error: `evidence scheme "${address.scheme}" is screenshot-inferred; backend endpoints must not be inferred from screenshots (ticket 73ead329)`,
224
+ };
225
+ }
226
+ if (address.scheme && !TRUSTED_SCHEMES.includes(address.scheme)) {
227
+ return {
228
+ valid: false,
229
+ error: `evidence scheme "${address.scheme}" is not a trusted scheme; must be one of: ${TRUSTED_SCHEMES.join(", ")}`,
230
+ };
231
+ }
232
+ if (!address.sourceVersion && !address.contentHash) {
233
+ return {
234
+ valid: false,
235
+ error: "evidence address lacks both sourceVersion and contentHash; cannot verify it was not screenshot-inferred",
236
+ };
237
+ }
238
+ if (!address.verifiedBy) {
239
+ return {
240
+ valid: false,
241
+ error: "evidence address lacks verifiedBy metadata; trusted source verification is also required",
242
+ };
243
+ }
244
+ if (!evidenceAddressSchema.safeParse(address).success) {
245
+ return { valid: false, error: "evidence address does not match the required source and tenant shape" };
246
+ }
247
+ if (typeof verifySource !== "function") {
248
+ return { valid: false, error: "trusted source verification is required; verifiedBy is only a claim" };
249
+ }
250
+ try {
251
+ if (verifySource(address) !== true) {
252
+ return { valid: false, error: "trusted source did not verify this evidence address" };
253
+ }
254
+ } catch {
255
+ return { valid: false, error: "trusted source verification failed" };
256
+ }
257
+ return { valid: true };
258
+ }