openclaw-scheduler 0.2.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 (70) hide show
  1. package/AGENTS.md +302 -0
  2. package/BEST-PRACTICES.md +506 -0
  3. package/CHANGELOG.md +82 -0
  4. package/CODE_OF_CONDUCT.md +22 -0
  5. package/CONTEXT.md +26 -0
  6. package/CONTRIBUTING.md +73 -0
  7. package/IMPLEMENTATION_SPEC.md +170 -0
  8. package/INSTALL-ADDITIONAL-HOST.md +333 -0
  9. package/INSTALL-LINUX.md +419 -0
  10. package/INSTALL-WINDOWS.md +305 -0
  11. package/INSTALL.md +364 -0
  12. package/JOB-QUICK-REF.md +222 -0
  13. package/LICENSE +21 -0
  14. package/QUICK-START.md +256 -0
  15. package/README.md +2170 -0
  16. package/SECURITY.md +34 -0
  17. package/UNINSTALL.md +129 -0
  18. package/UPGRADING.md +436 -0
  19. package/agents.js +67 -0
  20. package/approval.js +107 -0
  21. package/backup.js +390 -0
  22. package/bin/openclaw-scheduler.js +138 -0
  23. package/cli.js +1083 -0
  24. package/db.js +122 -0
  25. package/dispatch/529-recovery.mjs +204 -0
  26. package/dispatch/README.md +372 -0
  27. package/dispatch/config.example.json +24 -0
  28. package/dispatch/deliver-watcher.sh +57 -0
  29. package/dispatch/hooks.mjs +171 -0
  30. package/dispatch/index.mjs +1836 -0
  31. package/dispatch/watcher.mjs +1396 -0
  32. package/dispatch-queue.js +112 -0
  33. package/dispatcher-approvals.js +96 -0
  34. package/dispatcher-delivery.js +43 -0
  35. package/dispatcher-maintenance.js +242 -0
  36. package/dispatcher-shell.js +29 -0
  37. package/dispatcher-strategies.js +1280 -0
  38. package/dispatcher-utils.js +81 -0
  39. package/dispatcher.js +855 -0
  40. package/docs/adr-schedule-ownership.md +73 -0
  41. package/docs/gateway-contract.md +904 -0
  42. package/docs/plans/2026-03-09-fix-typescript-types.md +91 -0
  43. package/docs/plans/2026-03-09-test-coverage-gaps.md +83 -0
  44. package/docs/plans/2026-03-10-dispatcher-refactor.md +801 -0
  45. package/docs/trust-architecture.md +266 -0
  46. package/gateway.js +473 -0
  47. package/idempotency.js +119 -0
  48. package/index.d.ts +864 -0
  49. package/index.js +17 -0
  50. package/jobs.js +1224 -0
  51. package/messages.js +357 -0
  52. package/migrate-consolidate.js +694 -0
  53. package/migrate.js +125 -0
  54. package/package.json +130 -0
  55. package/paths.js +79 -0
  56. package/prompt-context.js +94 -0
  57. package/retrieval.js +176 -0
  58. package/runs.js +270 -0
  59. package/scheduler-schema.js +101 -0
  60. package/schema.sql +480 -0
  61. package/scripts/dispatch-cli-utils.mjs +65 -0
  62. package/scripts/inbox-consumer.mjs +288 -0
  63. package/scripts/stuck-detector.sh +18 -0
  64. package/scripts/stuck-run-detector.mjs +333 -0
  65. package/scripts/telegram-webhook-check.mjs +238 -0
  66. package/setup.mjs +724 -0
  67. package/shell-result.js +214 -0
  68. package/task-tracker.js +300 -0
  69. package/team-adapter.js +335 -0
  70. package/v02-runtime.js +599 -0
package/v02-runtime.js ADDED
@@ -0,0 +1,599 @@
1
+ // v0.2 Runtime -- pure evaluation functions for OpenClaw identity, trust,
2
+ // authorization, evidence, and credential handoff.
3
+ //
4
+ // Design constraints:
5
+ // - No side effects, no DB writes, no imports from other scheduler modules.
6
+ // - The caller is responsible for persisting outcomes.
7
+ // - Every function accepts a plain job object (as stored in SQLite, with
8
+ // JSON blob fields as strings), parses JSON internally, and returns a
9
+ // plain object suitable for JSON.stringify.
10
+ // - Functions return null when the relevant feature is not declared.
11
+ // - Functions never throw; parse errors surface as { error: ... } in the result.
12
+
13
+ /** Canonical trust level ordering (lowest to highest). */
14
+ export const TRUST_LEVELS = ['untrusted', 'restricted', 'supervised', 'autonomous'];
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Internal helpers
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /**
21
+ * Safely parse a JSON string. Returns the parsed value on success, or
22
+ * undefined on failure. Sets `err.message` on the provided error holder
23
+ * when parsing fails.
24
+ */
25
+ function safeParse(str, errorHolder) {
26
+ if (str == null || str === '') return undefined;
27
+ try {
28
+ return JSON.parse(str);
29
+ } catch (e) {
30
+ if (errorHolder) errorHolder.message = e.message;
31
+ return undefined;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Return the integer index of a trust level in the canonical ordering,
37
+ * or -1 if the level is not recognized.
38
+ */
39
+ function trustIndex(level) {
40
+ if (level == null) return -1;
41
+ return TRUST_LEVELS.indexOf(level);
42
+ }
43
+
44
+ /**
45
+ * Compare two trust levels. Returns:
46
+ * -1 if a < b (a is lower trust)
47
+ * 0 if a === b
48
+ * 1 if a > b (a is higher trust)
49
+ *
50
+ * Unrecognized levels compare as lower than any known level.
51
+ * Both null/undefined returns 0 (both unknown = equal).
52
+ */
53
+ export function compareTrustLevels(a, b) {
54
+ const ai = trustIndex(a);
55
+ const bi = trustIndex(b);
56
+ if (ai === bi) return 0;
57
+ return ai < bi ? -1 : 1;
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // resolveIdentity
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /**
65
+ * Extract and normalize identity declaration from a job record.
66
+ * When the identity blob references a provider, that provider must be
67
+ * available in ctx. Provider-backed declarations fail closed when the plugin
68
+ * is missing or errors. Structural resolution is the fallback only for jobs
69
+ * that do not reference a provider.
70
+ *
71
+ * @param {object} job - Job record with v0.2 identity fields.
72
+ * @param {object} [ctx={}] - Optional context with provider accessors.
73
+ * @returns {Promise<
74
+ * { subject_kind, principal, trust_level, delegation_mode, raw } |
75
+ * { provider, session, source: 'provider', subject_kind, principal, trust_level, delegation_mode, raw } |
76
+ * { provider, error, transient, source: 'provider-error' } |
77
+ * null
78
+ * >}
79
+ */
80
+ export async function resolveIdentity(job, ctx = {}) {
81
+ if (!job) return null;
82
+
83
+ // Attempt to parse the JSON blob first; scalar fields serve as fallback.
84
+ const parseErr = {};
85
+ const blob = safeParse(job.identity, parseErr);
86
+
87
+ // Try provider-based resolution before structural fallback.
88
+ const providerName = (blob && typeof blob === 'object' && !Array.isArray(blob))
89
+ ? (blob.provider || blob.auth?.provider || null)
90
+ : null;
91
+
92
+ if (providerName) {
93
+ const provider = ctx.getIdentityProvider?.(providerName);
94
+ if (!provider) {
95
+ return {
96
+ provider: providerName,
97
+ error: `identity provider not loaded: ${providerName}`,
98
+ transient: false,
99
+ source: 'provider-error',
100
+ };
101
+ }
102
+ try {
103
+ const scope = blob?.scope || blob?.auth?.scopes?.[0] || null;
104
+ const result = await provider.resolveSession(
105
+ { profile: blob, instanceId: job.id, scope },
106
+ { env: ctx.env || process.env, cwd: ctx.cwd || process.cwd() },
107
+ );
108
+ if (!result.ok) {
109
+ return {
110
+ provider: providerName,
111
+ error: result.error,
112
+ transient: result.transient ?? true,
113
+ source: 'provider-error',
114
+ };
115
+ }
116
+ return {
117
+ provider: providerName,
118
+ session: result.session,
119
+ source: 'provider',
120
+ // Include structural fields for backward compat
121
+ subject_kind: result.session?.subject?.kind || 'unknown',
122
+ principal: result.session?.subject?.principal || null,
123
+ trust_level: result.session?.trust?.effective_level || blob?.trust?.level || null,
124
+ delegation_mode: blob?.subject?.delegation_mode || null,
125
+ raw: blob,
126
+ };
127
+ } catch (err) {
128
+ return {
129
+ provider: providerName,
130
+ error: err.message,
131
+ transient: true,
132
+ source: 'provider-error',
133
+ };
134
+ }
135
+ }
136
+
137
+ // Fallback: structural resolution (original logic).
138
+
139
+ if (parseErr.message && job.identity != null && job.identity !== '') {
140
+ // The blob was present but malformed -- report the error while still
141
+ // falling back to scalar fields so callers get partial data.
142
+ const result = buildIdentityFromScalars(job);
143
+ if (result) {
144
+ result.raw = { error: `identity JSON parse failed: ${parseErr.message}` };
145
+ return result;
146
+ }
147
+ return {
148
+ subject_kind: 'unknown',
149
+ principal: null,
150
+ trust_level: null,
151
+ delegation_mode: null,
152
+ raw: { error: `identity JSON parse failed: ${parseErr.message}` },
153
+ };
154
+ }
155
+
156
+ if (blob && typeof blob === 'object' && !Array.isArray(blob)) {
157
+ return {
158
+ subject_kind: blob.subject_kind || blob.identity_subject_kind || job.identity_subject_kind || 'unknown',
159
+ principal: blob.principal || blob.identity_principal || job.identity_principal || null,
160
+ trust_level: blob.trust_level || blob.identity_trust_level || job.identity_trust_level || null,
161
+ delegation_mode: blob.delegation_mode || blob.identity_delegation_mode || job.identity_delegation_mode || null,
162
+ raw: blob,
163
+ };
164
+ }
165
+
166
+ // No blob (or blob was a primitive) -- use scalar fields.
167
+ return buildIdentityFromScalars(job);
168
+ }
169
+
170
+ function buildIdentityFromScalars(job) {
171
+ const hasAny = job.identity_principal != null
172
+ || job.identity_run_as != null
173
+ || job.identity_attestation != null
174
+ || job.identity_ref != null
175
+ || job.identity_subject_kind != null
176
+ || job.identity_subject_principal != null
177
+ || job.identity_trust_level != null
178
+ || job.identity_delegation_mode != null;
179
+
180
+ if (!hasAny) return null;
181
+
182
+ return {
183
+ subject_kind: job.identity_subject_kind || 'unknown',
184
+ principal: job.identity_principal || job.identity_subject_principal || null,
185
+ trust_level: job.identity_trust_level || null,
186
+ delegation_mode: job.identity_delegation_mode || null,
187
+ raw: null,
188
+ };
189
+ }
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // evaluateTrust
193
+ // ---------------------------------------------------------------------------
194
+
195
+ /**
196
+ * Compare effective trust level against the contract's required trust level.
197
+ *
198
+ * @param {object} job - Job record with v0.2 contract fields.
199
+ * @param {object|null} resolvedIdentity - Output of resolveIdentity().
200
+ * @returns {{ effective_level, required_level, decision: 'permit'|'deny'|'warn', reason }}
201
+ */
202
+ export function evaluateTrust(job, resolvedIdentity) {
203
+ if (!job) {
204
+ return { effective_level: null, required_level: null, decision: 'permit', reason: 'no job provided' };
205
+ }
206
+
207
+ const requiredLevel = job.contract_required_trust_level || null;
208
+ if (!requiredLevel) {
209
+ return { effective_level: resolvedIdentity?.trust_level || null, required_level: null, decision: 'permit', reason: 'no trust requirement declared' };
210
+ }
211
+
212
+ const effectiveLevel = resolvedIdentity?.trust_level || job.identity_trust_level || null;
213
+ const effectiveIdx = trustIndex(effectiveLevel);
214
+ const requiredIdx = trustIndex(requiredLevel);
215
+
216
+ if (requiredIdx < 0) {
217
+ return { effective_level: effectiveLevel, required_level: requiredLevel, decision: 'permit', reason: `unrecognized required trust level: ${requiredLevel}` };
218
+ }
219
+
220
+ // Normalize enforcement: agentcli uses advisory/strict, runtime uses warn/block.
221
+ const rawEnforcement = job.contract_trust_enforcement || 'none';
222
+ const normalizedEnforcement = rawEnforcement === 'advisory' ? 'warn'
223
+ : rawEnforcement === 'strict' ? 'block'
224
+ : rawEnforcement;
225
+
226
+ if (effectiveLevel == null) {
227
+ // No effective level declared -- enforcement determines outcome.
228
+ const enforcement = normalizedEnforcement;
229
+ if (enforcement === 'block') {
230
+ return { effective_level: null, required_level: requiredLevel, decision: 'deny', reason: 'no trust level declared; enforcement is block' };
231
+ }
232
+ if (enforcement === 'warn') {
233
+ return { effective_level: null, required_level: requiredLevel, decision: 'warn', reason: 'no trust level declared; enforcement is warn' };
234
+ }
235
+ return { effective_level: null, required_level: requiredLevel, decision: 'permit', reason: 'no trust level declared; enforcement is none' };
236
+ }
237
+
238
+ if (effectiveIdx < 0) {
239
+ // Effective level not in canonical list.
240
+ const enforcement = normalizedEnforcement;
241
+ if (enforcement === 'block') {
242
+ return { effective_level: effectiveLevel, required_level: requiredLevel, decision: 'deny', reason: `unrecognized effective trust level: ${effectiveLevel}` };
243
+ }
244
+ if (enforcement === 'warn') {
245
+ return { effective_level: effectiveLevel, required_level: requiredLevel, decision: 'warn', reason: `unrecognized effective trust level: ${effectiveLevel}` };
246
+ }
247
+ return { effective_level: effectiveLevel, required_level: requiredLevel, decision: 'permit', reason: `unrecognized effective trust level: ${effectiveLevel}` };
248
+ }
249
+
250
+ if (effectiveIdx >= requiredIdx) {
251
+ return { effective_level: effectiveLevel, required_level: requiredLevel, decision: 'permit', reason: 'trust level meets or exceeds requirement' };
252
+ }
253
+
254
+ // Effective is below required -- check enforcement.
255
+ const enforcement = normalizedEnforcement;
256
+ if (enforcement === 'block') {
257
+ return { effective_level: effectiveLevel, required_level: requiredLevel, decision: 'deny', reason: `trust level ${effectiveLevel} is below required ${requiredLevel}` };
258
+ }
259
+ if (enforcement === 'warn') {
260
+ return { effective_level: effectiveLevel, required_level: requiredLevel, decision: 'warn', reason: `trust level ${effectiveLevel} is below required ${requiredLevel}` };
261
+ }
262
+ return { effective_level: effectiveLevel, required_level: requiredLevel, decision: 'permit', reason: `trust level ${effectiveLevel} is below required ${requiredLevel}; enforcement is none` };
263
+ }
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // verifyAuthorizationProof
267
+ // ---------------------------------------------------------------------------
268
+
269
+ /** Recognized proof method values for structural validation. */
270
+ const KNOWN_PROOF_METHODS = ['signed-jwt', 'hmac', 'api-key', 'bearer', 'mtls', 'oidc', 'saml', 'custom'];
271
+
272
+ /**
273
+ * Validate authorization proof structure.
274
+ * When the proof blob references a provider or verifier, that verifier must be
275
+ * available in ctx. Provider-backed verification fails closed when the plugin
276
+ * is missing or errors. Structural validation is the fallback only for proofs
277
+ * without an explicit verifier.
278
+ *
279
+ * @param {object} job - Job record with v0.2 authorization_proof fields.
280
+ * @param {object} [ctx={}] - Optional context with provider accessors.
281
+ * @returns {Promise<{ verified: boolean, method, ref, error? } | null>}
282
+ */
283
+ export async function verifyAuthorizationProof(job, ctx = {}) {
284
+ if (!job) return null;
285
+
286
+ const proofStr = job.authorization_proof;
287
+ const proofRef = job.authorization_proof_ref || null;
288
+
289
+ if (proofStr == null && proofRef == null) return null;
290
+
291
+ if (proofStr == null || proofStr === '') {
292
+ // Only a ref, no inline proof.
293
+ return { verified: false, method: null, ref: proofRef, error: 'authorization_proof is empty; only ref provided' };
294
+ }
295
+
296
+ const parseErr = {};
297
+ const blob = safeParse(proofStr, parseErr);
298
+
299
+ if (parseErr.message) {
300
+ return { verified: false, method: null, ref: proofRef, error: `authorization_proof JSON parse failed: ${parseErr.message}` };
301
+ }
302
+
303
+ if (!blob || typeof blob !== 'object' || Array.isArray(blob)) {
304
+ return { verified: false, method: null, ref: proofRef, error: 'authorization_proof must be a JSON object' };
305
+ }
306
+
307
+ const method = blob.method || null;
308
+ const blobRef = blob.ref || proofRef;
309
+
310
+ // Try provider-based verification before structural fallback.
311
+ const verifierName = blob.verifier || blob.provider || null;
312
+ if (verifierName) {
313
+ const verifier = ctx.getProofVerifier?.(verifierName);
314
+ if (!verifier) {
315
+ return {
316
+ verified: false,
317
+ method,
318
+ ref: blobRef,
319
+ error: `proof verifier not loaded: ${verifierName}`,
320
+ source: 'provider-error',
321
+ provider: verifierName,
322
+ };
323
+ }
324
+ try {
325
+ const result = await verifier.verifyProof(
326
+ { proof: blob, ref: blobRef, jobId: job.id },
327
+ { env: ctx.env || process.env, cwd: ctx.cwd || process.cwd() },
328
+ );
329
+ return {
330
+ verified: !!result.verified,
331
+ method,
332
+ ref: blobRef,
333
+ source: 'provider',
334
+ provider: verifierName,
335
+ ...(result.error ? { error: result.error } : {}),
336
+ };
337
+ } catch (err) {
338
+ return {
339
+ verified: false,
340
+ method,
341
+ ref: blobRef,
342
+ error: err.message,
343
+ source: 'provider-error',
344
+ provider: verifierName,
345
+ };
346
+ }
347
+ }
348
+
349
+ // Fallback: structural validation (original logic).
350
+
351
+ if (!method) {
352
+ return { verified: false, method: null, ref: blobRef, error: 'authorization_proof missing required "method" field' };
353
+ }
354
+
355
+ if (!KNOWN_PROOF_METHODS.includes(method)) {
356
+ return { verified: false, method, ref: blobRef, error: `unrecognized proof method: ${method}` };
357
+ }
358
+
359
+ // Structural validation passed.
360
+ return { verified: true, method, ref: blobRef };
361
+ }
362
+
363
+ // ---------------------------------------------------------------------------
364
+ // evaluateAuthorization
365
+ // ---------------------------------------------------------------------------
366
+
367
+ /**
368
+ * Evaluate authorization policy.
369
+ * When the authorization blob references a provider, that provider must be
370
+ * available in ctx. Provider-backed authorization fails closed when the
371
+ * plugin is missing or errors. Structural evaluation is the fallback only for
372
+ * policies without an explicit provider.
373
+ *
374
+ * @param {object} job - Job record with v0.2 authorization fields.
375
+ * @param {object|null} identityResult - Output of resolveIdentity().
376
+ * @param {object|null} trustResult - Output of evaluateTrust().
377
+ * @param {object} [ctx={}] - Optional context with provider accessors.
378
+ * @returns {Promise<{ decision: 'permit'|'deny'|'escalate', reason, ref } | null>}
379
+ */
380
+ export async function evaluateAuthorization(job, identityResult, trustResult, ctx = {}) {
381
+ if (!job) return null;
382
+
383
+ const authStr = job.authorization;
384
+ const authRef = job.authorization_ref || null;
385
+
386
+ if (authStr == null && authRef == null) return null;
387
+
388
+ if (authStr == null || authStr === '') {
389
+ // Only a ref, no inline authorization policy. External policy resolution
390
+ // is not yet implemented. Fail closed: a job that declares authorization_ref
391
+ // without an inline authorization blob intends an external policy gate.
392
+ // Permitting would silently bypass that intent.
393
+ return { decision: 'deny', reason: 'authorization_ref present but external policy resolution is not yet implemented; provide an inline authorization blob or remove authorization_ref', ref: authRef };
394
+ }
395
+
396
+ const parseErr = {};
397
+ const blob = safeParse(authStr, parseErr);
398
+
399
+ if (parseErr.message) {
400
+ return { decision: 'deny', reason: `authorization JSON parse failed: ${parseErr.message}`, ref: authRef };
401
+ }
402
+
403
+ if (!blob || typeof blob !== 'object' || Array.isArray(blob)) {
404
+ return { decision: 'deny', reason: 'authorization must be a JSON object', ref: authRef };
405
+ }
406
+
407
+ const blobRef = blob.ref || authRef;
408
+
409
+ // Try provider-based authorization before structural fallback.
410
+ const providerName = blob.provider || blob.authorization_provider || null;
411
+ if (providerName) {
412
+ const provider = ctx.getAuthorizationProvider?.(providerName);
413
+ if (!provider) {
414
+ return {
415
+ decision: 'deny',
416
+ reason: `authorization provider not loaded: ${providerName}`,
417
+ ref: blobRef,
418
+ source: 'provider-error',
419
+ provider: providerName,
420
+ };
421
+ }
422
+ try {
423
+ const result = await provider.authorize(
424
+ { policy: blob, identity: identityResult, trust: trustResult, ref: blobRef, jobId: job.id },
425
+ { env: ctx.env || process.env, cwd: ctx.cwd || process.cwd() },
426
+ );
427
+ const rawDecision = typeof result?.decision === 'string' ? result.decision : null;
428
+ const decision = rawDecision === 'permit' || rawDecision === 'deny' || rawDecision === 'escalate'
429
+ ? rawDecision
430
+ : 'deny';
431
+ const reason = decision === 'deny' && rawDecision !== 'deny' && rawDecision !== null
432
+ ? `authorization provider ${providerName} returned unsupported decision "${rawDecision}"`
433
+ : decision === 'deny' && rawDecision == null
434
+ ? `authorization provider ${providerName} returned no decision`
435
+ : result?.reason || `provider ${providerName} returned ${decision}`;
436
+ return {
437
+ decision,
438
+ reason,
439
+ ref: blobRef,
440
+ source: 'provider',
441
+ provider: providerName,
442
+ };
443
+ } catch (err) {
444
+ return {
445
+ decision: 'deny',
446
+ reason: `authorization provider error: ${err.message}`,
447
+ ref: blobRef,
448
+ source: 'provider-error',
449
+ provider: providerName,
450
+ };
451
+ }
452
+ }
453
+
454
+ // Fallback: structural evaluation (original logic).
455
+
456
+ // If the blob contains an explicit decision, honor it.
457
+ if (blob.decision === 'deny') {
458
+ return { decision: 'deny', reason: blob.reason || 'explicit deny in authorization policy', ref: blobRef };
459
+ }
460
+ if (blob.decision === 'escalate') {
461
+ return { decision: 'escalate', reason: blob.reason || 'explicit escalate in authorization policy', ref: blobRef };
462
+ }
463
+
464
+ // If trust evaluation resulted in deny and the authorization depends on trust,
465
+ // propagate the denial.
466
+ const dependsOnTrust = blob.depends_on_trust !== false; // default true
467
+ if (dependsOnTrust && trustResult && trustResult.decision === 'deny') {
468
+ return { decision: 'deny', reason: `trust evaluation denied: ${trustResult.reason}`, ref: blobRef };
469
+ }
470
+
471
+ // If no identity was resolved and authorization requires identity, deny.
472
+ if (blob.requires_identity && !identityResult) {
473
+ return { decision: 'deny', reason: 'authorization requires identity but none was resolved', ref: blobRef };
474
+ }
475
+
476
+ // Default: permit. Actual OPA/provider calls are future work.
477
+ return { decision: 'permit', reason: blob.reason || 'authorization policy permits (structural check only)', ref: blobRef };
478
+ }
479
+
480
+ // ---------------------------------------------------------------------------
481
+ // generateEvidence
482
+ // ---------------------------------------------------------------------------
483
+
484
+ /**
485
+ * Create evidence record metadata.
486
+ * MVP: builds a metadata envelope. Actual evidence storage and hashing are
487
+ * future work.
488
+ *
489
+ * @param {object} job - Job record with v0.2 evidence fields.
490
+ * @param {object|null} runResult - Run result metadata (e.g. { id, status }).
491
+ * @param {object|null} outcomes - Aggregated outcomes from other v0.2 functions.
492
+ * @returns {{ evidence_ref, created_at, hash, payload_summary }} or null if
493
+ * no evidence declaration.
494
+ */
495
+ export function generateEvidence(job, runResult, outcomes) {
496
+ if (!job) return null;
497
+
498
+ const evidenceStr = job.evidence;
499
+ const evidenceRef = job.evidence_ref || null;
500
+
501
+ if (evidenceStr == null && evidenceRef == null) return null;
502
+
503
+ const parseErr = {};
504
+ const blob = (evidenceStr != null && evidenceStr !== '') ? safeParse(evidenceStr, parseErr) : null;
505
+
506
+ if (parseErr.message) {
507
+ return {
508
+ evidence_ref: evidenceRef,
509
+ created_at: new Date().toISOString(),
510
+ hash: null,
511
+ payload_summary: { error: `evidence JSON parse failed: ${parseErr.message}` },
512
+ };
513
+ }
514
+
515
+ const effectiveRef = (blob && blob.ref) || evidenceRef;
516
+
517
+ // Build a summary of what was recorded.
518
+ const payloadSummary = {};
519
+
520
+ if (blob && typeof blob === 'object' && !Array.isArray(blob)) {
521
+ if (blob.collect) payloadSummary.collect = blob.collect;
522
+ if (blob.retention) payloadSummary.retention = blob.retention;
523
+ if (blob.format) payloadSummary.format = blob.format;
524
+ }
525
+
526
+ if (runResult && typeof runResult === 'object') {
527
+ payloadSummary.run_id = runResult.id || null;
528
+ payloadSummary.run_status = runResult.status || null;
529
+ }
530
+
531
+ if (outcomes && typeof outcomes === 'object') {
532
+ const outcomeKeys = Object.keys(outcomes).filter(k => outcomes[k] != null);
533
+ payloadSummary.outcome_fields_present = outcomeKeys;
534
+ }
535
+
536
+ // Evidence records are currently metadata envelopes only. There is no
537
+ // cryptographic hash binding evidence to run output. The `integrity` field
538
+ // makes this explicit so consumers do not assume tamper-evidence guarantees.
539
+ // Content-addressable hashing is planned but not yet implemented.
540
+ return {
541
+ evidence_ref: effectiveRef,
542
+ created_at: new Date().toISOString(),
543
+ hash: null,
544
+ integrity: 'none',
545
+ payload_summary: payloadSummary,
546
+ };
547
+ }
548
+
549
+ // ---------------------------------------------------------------------------
550
+ // summarizeCredentialHandoff
551
+ // ---------------------------------------------------------------------------
552
+
553
+ /**
554
+ * Summarize the credential handoff plan from the identity declaration.
555
+ *
556
+ * @param {object} job - Job record with v0.2 identity fields.
557
+ * @returns {{ mode, bindings_count, cleanup_required }} or null if no
558
+ * identity or no presentation bindings are declared.
559
+ */
560
+ export function summarizeCredentialHandoff(job) {
561
+ if (!job) return null;
562
+
563
+ const parseErr = {};
564
+ const blob = safeParse(job.identity, parseErr);
565
+
566
+ if (parseErr.message && job.identity != null && job.identity !== '') {
567
+ return {
568
+ mode: null,
569
+ bindings_count: 0,
570
+ cleanup_required: false,
571
+ error: `identity JSON parse failed: ${parseErr.message}`,
572
+ };
573
+ }
574
+
575
+ if (!blob || typeof blob !== 'object' || Array.isArray(blob)) {
576
+ // No blob available -- cannot determine credential handoff.
577
+ return null;
578
+ }
579
+
580
+ // Look for presentation / credential handoff configuration.
581
+ const presentation = blob.presentation || blob.credential_handoff || null;
582
+ if (!presentation || typeof presentation !== 'object' || Array.isArray(presentation)) {
583
+ return null;
584
+ }
585
+
586
+ const mode = presentation.mode || null;
587
+ const bindings = Array.isArray(presentation.bindings) ? presentation.bindings : [];
588
+ const cleanupRequired = presentation.cleanup === true
589
+ || presentation.cleanup_required === true
590
+ || bindings.some(b => b && b.cleanup === true);
591
+
592
+ if (!mode && bindings.length === 0) return null;
593
+
594
+ return {
595
+ mode,
596
+ bindings_count: bindings.length,
597
+ cleanup_required: cleanupRequired,
598
+ };
599
+ }