@melra/runtime-core 0.3.0-alpha.17 → 0.3.0-alpha.19

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.
@@ -1,8 +1,8 @@
1
1
  // Copyright 2026 XAGI Labs Private Limited
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  import { randomUUID } from "node:crypto";
4
- import { allowsRetry, delegationChain, effectContract, executionGuaranteeFor, isOutcomeUnknown, LOCAL_IDENTITY, TaskRequestSchema, } from "@melra/protocol";
5
- import { approvalTtl, CAPABILITY_DAY_MS, classifyOperation, defaultEvidenceFor, evaluatePolicy, validateApproval, } from "@melra/policy-core";
4
+ import { allowsRetry, delegationChain, effectContract, executionGuaranteeFor, isOutcomeUnknown, LOCAL_IDENTITY, MelraTaskJudgeInputSchema, TaskRequestSchema, } from "@melra/protocol";
5
+ import { approvalTtl, CAPABILITY_DAY_MS, classifyOperation, defaultEvidenceFor, digestOperation, evaluatePolicy, validateApproval, } from "@melra/policy-core";
6
6
  import { createCertificate, createReceiptId, evidenceStrength, redactSecrets, redactStructuredValue, sha256, } from "@melra/receipt-schema";
7
7
  import { CircuitBreaker } from "./circuit-breaker.js";
8
8
  function now() {
@@ -84,12 +84,80 @@ export function capabilityUsageReader(store) {
84
84
  return { operations: drawn.operations, amountToday: drawn.amountInWindow };
85
85
  };
86
86
  }
87
+ /**
88
+ * A stored copy's content digests, masked as the content is. A file read's or
89
+ * write's `sha256` hashes exactly what redaction took out of the record, and
90
+ * a `file_hash` item that did not pass carries the file's real hash (one that
91
+ * passed equals the hash its request declared). Applied after redaction to
92
+ * the task's result and to receipt and certificate evidence; the marker maps
93
+ * to itself, so a certificate written from a stored receipt (`certify`) says
94
+ * what the live one did. The caller still gets the `sha256` in `output`, and
95
+ * recovery reads the sealed raw result. A failed item's digest is returned
96
+ * nowhere, since the caller gets the stored receipt and certificate; a file
97
+ * `hash` operation reads it again.
98
+ */
99
+ const MASKED = "[REDACTED_SENSITIVE_FIELD]";
100
+ function maskResultDigest(result, operation) {
101
+ return operation.kind === "file" && (operation.action === "read" || operation.action === "write") && "sha256" in result
102
+ ? { ...result, sha256: MASKED }
103
+ : result;
104
+ }
105
+ /** The same list when there is nothing to mask, so a caller can tell. */
106
+ function maskEvidenceDigests(evidence) {
107
+ const hides = (item) => item.type === "file_hash" && !item.passed && item.digest !== undefined && item.digest !== MASKED;
108
+ return evidence.some(hides) ? evidence.map((item) => (hides(item) ? { ...item, digest: MASKED } : item)) : evidence;
109
+ }
87
110
  /** Statuses a task leaves only once: by starting, by a deny, or by a refusal. */
88
111
  const UNSTARTED = ["planned", "awaiting_approval"];
89
112
  /** Whether a task waits on a question only someone outside MELRA answers (#54). */
90
113
  function asksSemantic(request) {
91
114
  return request.requiredEvidence.some((predicate) => predicate.type === "semantic");
92
115
  }
116
+ /**
117
+ * A semantic task's evidence with its answers written in (#54). An answered
118
+ * item passes only on a yes, is inconclusive on cannot_tell, and says who
119
+ * judged it and, as `executedBy`, the receipt's declared principal, so the
120
+ * two declared chains can be compared without the receipt; an open one stays
121
+ * failed and inconclusive, summarised `unanswered`. Matched by index: a
122
+ * semantic task's receipt has one item per predicate, in order. Every other
123
+ * item is left as it was.
124
+ */
125
+ function overlay(evidence, judgements, unanswered, executedBy) {
126
+ return evidence.map((item, index) => {
127
+ if (item.type !== "semantic")
128
+ return item;
129
+ const { inconclusive: _inconclusive, ...rest } = item;
130
+ const judgedBy = judgements.find((judgement) => judgement.predicateIndex === index)?.judgedBy;
131
+ if (judgedBy === undefined)
132
+ return { ...rest, passed: false, inconclusive: true, summary: unanswered };
133
+ return {
134
+ ...rest,
135
+ passed: judgedBy.verdict === "yes",
136
+ ...(judgedBy.verdict === "cannot_tell" ? { inconclusive: true } : {}),
137
+ summary: `semantic_judgement_${judgedBy.verdict}`,
138
+ judgedBy,
139
+ ...(executedBy === undefined ? {} : { executedBy }),
140
+ };
141
+ });
142
+ }
143
+ /**
144
+ * Whether a judge is the actor it would judge (#54): its declared principal
145
+ * is the actor's, or a non-human link the actor acts for; or its declared
146
+ * chain names the actor's principal, as a grader the actor spawned would. The
147
+ * person the work was for may judge it, and so may another session under a
148
+ * link the actor's chain shares. `client`, the link a transport authenticated,
149
+ * heads the judge's chain undeclared and is not compared: an OAuth client that
150
+ * planned as itself would otherwise refuse every judge it sends. Claims on
151
+ * both sides, so it stops one loop grading itself, not a harness that
152
+ * declares someone else.
153
+ */
154
+ function judgesItself(judge, actor, client) {
155
+ const same = (one, other) => one.kind === other.kind && one.id === other.id;
156
+ const declared = judge.onBehalfOf.slice(client === undefined ? 0 : 1);
157
+ return (same(actor.principal, judge.principal) ||
158
+ actor.onBehalfOf.some((link) => link.kind !== "human" && same(link, judge.principal)) ||
159
+ declared.some((link) => same(link, actor.principal)));
160
+ }
93
161
  /** The one browser action that reads each list. */
94
162
  const BROWSER_LIST_ACTIONS = [
95
163
  ["values", "select"],
@@ -182,7 +250,7 @@ export class TaskController {
182
250
  ? {}
183
251
  : { idempotencyKey: options.idempotencyKey }),
184
252
  ...(options.attempt === undefined ? {} : { attempt: options.attempt }),
185
- ...(policy.challenge === undefined ? {} : { approval: policy.challenge }),
253
+ ...(policy.challenge === undefined ? {} : { approval: this.keyedChallenge(policy.challenge) }),
186
254
  receiptIds: [],
187
255
  createdAt: timestamp,
188
256
  updatedAt: timestamp,
@@ -296,8 +364,13 @@ export class TaskController {
296
364
  return this.finishWithoutReceipt(task, []);
297
365
  }
298
366
  if (rechecked.decision.outcome === "confirm") {
299
- if (rechecked.challenge === undefined ||
300
- task.approval?.actionDigest !== rechecked.challenge.actionDigest) {
367
+ // Stored keyed (`keyedChallenge`). One a 0.3.0-alpha.17 or alpha.18
368
+ // process planned holds the plain digest, which binds the same exact
369
+ // operation; it stays executable until it expires.
370
+ const fresh = rechecked.challenge?.actionDigest;
371
+ if (fresh === undefined ||
372
+ (task.approval?.actionDigest !== this.payloadCipher.digest(fresh, "action") &&
373
+ task.approval?.actionDigest !== fresh)) {
301
374
  throw new Error("approval_action_digest_mismatch");
302
375
  }
303
376
  // Enforced mode asserts the agent holds no credential, so anything it
@@ -307,7 +380,10 @@ export class TaskController {
307
380
  throw new Error("approval_in_band_refused");
308
381
  }
309
382
  // A decision an operator recorded wins over anything the caller sent.
310
- const render = this.approvalRender(task, request).renderDigest;
383
+ const { contract, renderDigest: render } = this.approvalRender(task, request);
384
+ // Recorded keyed, whatever form `render` takes: an older executor reads
385
+ // the plain form only from a decision row.
386
+ const bound = this.payloadCipher.digest(contract, "render");
311
387
  const decision = this.store.getApprovalDecision(task.approval.approvalId);
312
388
  // Waiting is only worth it while an operator could still say yes:
313
389
  // `decide` refuses an expired challenge, so past it the task is re-planned.
@@ -315,15 +391,20 @@ export class TaskController {
315
391
  throw new Error(Date.parse(task.approval.expiresAt) <= Date.now() ? "approval_expired" : "approval_awaiting_operator");
316
392
  }
317
393
  if (decision !== undefined) {
318
- if (decision.renderDigest !== render)
394
+ // A row a 0.3.0-alpha.17 or alpha.18 `melra approve` or console wrote
395
+ // holds the plain digest of the same contract.
396
+ if (decision.renderDigest !== render && decision.renderDigest !== sha256(contract)) {
319
397
  throw new Error("approval_render_mismatch");
398
+ }
320
399
  if (!this.usable(task, decision))
321
400
  throw new Error("approval_expired");
322
401
  // Out of band is this kernel's claim to make, not the row's: a
323
402
  // developer-mode kernel cannot vouch that nothing the agent touched
324
- // wrote it, whatever mode the process that recorded it was in.
403
+ // wrote it, whatever mode the process that recorded it was in. The
404
+ // digest is keyed, whichever form the row holds.
325
405
  task.approvedBy = {
326
406
  ...decision,
407
+ renderDigest: bound,
327
408
  channel: decision.channel === "out_of_band" && this.policy.mode === "enforced"
328
409
  ? "out_of_band"
329
410
  : "in_band",
@@ -334,12 +415,12 @@ export class TaskController {
334
415
  if (!approvalResult.ok) {
335
416
  throw new Error(approvalResult.reason);
336
417
  }
337
- // The phrase is derivable by anyone who knows the task, so an echo of
338
- // it says only that something echoed it.
418
+ // The phrase is readable by anyone who can read the task, so an echo
419
+ // of it says only that something echoed it.
339
420
  task.approvedBy = {
340
421
  approvalId: task.approval.approvalId,
341
422
  taskId: task.id,
342
- renderDigest: render,
423
+ renderDigest: bound,
343
424
  channel: "in_band",
344
425
  approver: "phrase",
345
426
  decidedAt: now(),
@@ -363,7 +444,10 @@ export class TaskController {
363
444
  let budgetExhausted = false;
364
445
  let shortCircuited = false;
365
446
  let effectReturned = false;
366
- let settled;
447
+ // Set once verification is done, until what it decided is committed: the
448
+ // writes are undone together if any fails, and the catch puts back what
449
+ // was true before them.
450
+ let unwritten;
367
451
  if (rechecked.grantId !== undefined) {
368
452
  const held = this.reserved.get(rechecked.grantId) ?? new Map();
369
453
  held.set(task.id, classified.spend?.amount ?? 0);
@@ -377,13 +461,15 @@ export class TaskController {
377
461
  // Any other throw is an adapter saying nothing went out, and costs
378
462
  // nothing; so does a refusal before it was called. Counting an effect
379
463
  // that did not happen is preferred to a grant funding more than it says.
380
- const charge = (ran) => {
464
+ const draw = () => {
381
465
  if (rechecked.grantId === undefined)
382
466
  return;
383
- if (ran) {
384
- this.store.recordCapabilityUse(rechecked.grantId, task.id, classified.spend?.amount ?? 0, now());
385
- }
386
- this.reserved.get(rechecked.grantId)?.delete(task.id);
467
+ this.store.recordCapabilityUse(rechecked.grantId, task.id, classified.spend?.amount ?? 0, now());
468
+ };
469
+ // Only once the draw is committed, or the effect is known not to have run.
470
+ const release = () => {
471
+ if (rechecked.grantId !== undefined)
472
+ this.reserved.get(rechecked.grantId)?.delete(task.id);
387
473
  };
388
474
  try {
389
475
  const opened = this.breaker.check(classified.target);
@@ -402,8 +488,12 @@ export class TaskController {
402
488
  effectReturned = true;
403
489
  const sanitizedResult = redactStructuredValue(result);
404
490
  task.status = "verifying";
405
- task.result = sanitizedResult.value;
491
+ task.result = maskResultDigest(sanitizedResult.value, request.operation);
406
492
  task.updatedAt = now();
493
+ // Committed on its own, ahead of the rest, on purpose: recovery settles
494
+ // a `verifying` change it can look at again against this result
495
+ // (`recoverTask`), and no transaction is held across the verifier,
496
+ // which may wait on an HTTP probe.
407
497
  this.store.saveTaskExecutionResult(task, this.payloadCipher.seal(result, `task:${task.id}:result`));
408
498
  const verification = await this.verifier.verify(request.requiredEvidence, result);
409
499
  const actionSucceeded = result.success === undefined || result.success === true;
@@ -433,57 +523,73 @@ export class TaskController {
433
523
  // The effect ran and passed every kernel check, so it is committed now:
434
524
  // a key that ran must not run again, even while it waits for its verdict.
435
525
  const checksHeld = verified || awaitingJudgement;
436
- if (checksHeld &&
437
- task.idempotencyKey !== undefined &&
438
- !this.store.commitIdempotency(task.idempotencyKey, task.id, task.attempt ?? 1, now())) {
439
- task.status = "cancelled";
440
- task.error = "duplicate_attempt_prevented";
441
- }
442
- // The adapter returned, so the effect ran: drawn down for a `partial`,
443
- // a `failed` or a duplicate collapsed just above as for a verified task.
444
- charge(true);
526
+ unwritten = { checksHeld, receiptIds: [...task.receiptIds] };
445
527
  const receipt = this.createReceipt(task, request, classified.capability, classified.target, classified.effect, startedAt, actionSucceeded, result, evidence);
446
- this.store.saveReceipt(receipt);
447
- task.receiptIds.push(receipt.receiptId);
448
- if (task.status !== "cancelled") {
449
- task.status = verified
450
- ? "verified_success"
451
- : awaitingJudgement
452
- ? "waiting_user"
453
- : actionSucceeded
454
- ? "partial"
455
- : "failed";
456
- }
457
- // Written in the same save as the status, so a waiting row always says
458
- // what it waits on, and until when.
459
- if (task.status === "waiting_user") {
460
- const expiresAt = new Date(Date.now() + approvalTtl(this.policy)).toISOString();
461
- task.judgements = request.requiredEvidence.flatMap((predicate, predicateIndex) => predicate.type === "semantic" ? [{ judgementId: randomUUID(), predicateIndex, expiresAt }] : []);
462
- }
463
- task.updatedAt = now();
464
- // A wait lands with its certificate in one transaction: written apart, a
465
- // peer's expiry between the two was overwritten by the second. Either
466
- // failing writes neither, so `settled` stays unset for the catch.
467
- if (task.status === "waiting_user") {
468
- const certificate = this.store.transaction(() => {
469
- this.store.saveTask(task);
528
+ // Everything that records the outcome commits at once: the key, the
529
+ // draw, the receipt, the task and its certificate. A crash before the
530
+ // commit leaves the task `verifying`, as a crash during verification
531
+ // does, and never a part of the outcome.
532
+ const certificate = this.store.transaction(() => {
533
+ if (checksHeld &&
534
+ task.idempotencyKey !== undefined &&
535
+ !this.store.commitIdempotency(task.idempotencyKey, task.id, task.attempt ?? 1, now())) {
536
+ task.status = "cancelled";
537
+ task.error = "duplicate_attempt_prevented";
538
+ }
539
+ // The adapter returned, so the effect ran: drawn down for a `partial`,
540
+ // a `failed` or a duplicate collapsed just above as for a verified task.
541
+ draw();
542
+ this.store.saveReceipt(receipt);
543
+ task.receiptIds.push(receipt.receiptId);
544
+ if (task.status !== "cancelled") {
545
+ task.status = verified
546
+ ? "verified_success"
547
+ : awaitingJudgement
548
+ ? "waiting_user"
549
+ : actionSucceeded
550
+ ? "partial"
551
+ : "failed";
552
+ }
553
+ // Written in the same save as the status, so a waiting row always says
554
+ // what it waits on, and until when.
555
+ if (task.status === "waiting_user") {
556
+ const expiresAt = new Date(Date.now() + approvalTtl(this.policy)).toISOString();
557
+ task.judgements = request.requiredEvidence.flatMap((predicate, predicateIndex) => predicate.type === "semantic" ? [{ judgementId: randomUUID(), predicateIndex, expiresAt }] : []);
558
+ }
559
+ task.updatedAt = now();
560
+ this.store.saveTask(task);
561
+ // A wait lands with its certificate or not at all: written apart, a
562
+ // peer's expiry between the two was overwritten by the second.
563
+ if (task.status === "waiting_user")
470
564
  return this.createAndSaveCertificate(task, evidence, true);
471
- });
472
- return { task, output: result, receipt, certificate };
473
- }
474
- this.store.saveTask(task);
475
- settled = { task, output: result, receipt };
476
- const certificate = this.createAndSaveCertificate(task, evidence, true);
477
- return { ...settled, certificate };
565
+ // Any other outcome stands without its certificate. Rewriting it
566
+ // would contradict its receipt and commit, and an error would read
567
+ // as the effect not having happened, so the caller gets what was
568
+ // recorded; a workflow step's certificate is written by the run's
569
+ // next advance (`certify`). A failure SQLite rolled the whole
570
+ // transaction back for fails the commit instead, and the catch has it.
571
+ try {
572
+ return this.createAndSaveCertificate(task, evidence, true);
573
+ }
574
+ catch {
575
+ return undefined;
576
+ }
577
+ });
578
+ unwritten = undefined;
579
+ release();
580
+ return { task, output: result, receipt, ...(certificate === undefined ? {} : { certificate }) };
478
581
  }
479
582
  catch (error) {
480
- // The outcome is recorded, beside its receipt and idempotency commit,
481
- // and only the certificate summarising it failed to write. Rewriting it
482
- // would contradict them, and an error would read as the effect not
483
- // having happened, so the caller gets what was recorded. A workflow
484
- // step's certificate is written by the run's next advance (`certify`).
485
- if (settled !== undefined)
486
- return settled;
583
+ // The writes after verification were undone together. The receipt they
584
+ // stored is gone, and a key whose effect passed every check is committed
585
+ // here on its own, as it was before anything after it could fail: a
586
+ // parked task settles on that commit (`settleCommitted`).
587
+ if (unwritten !== undefined) {
588
+ task.receiptIds = unwritten.receiptIds;
589
+ if (unwritten.checksHeld && task.idempotencyKey !== undefined) {
590
+ this.store.commitIdempotency(task.idempotencyKey, task.id, task.attempt ?? 1, now());
591
+ }
592
+ }
487
593
  const rawMessage = error instanceof Error ? error.message : String(error);
488
594
  // Capped after redaction, which can lengthen it, at the 10,000 characters
489
595
  // a workflow node's `error` holds: a longer one failed the node's write
@@ -501,7 +607,9 @@ export class TaskController {
501
607
  // that fails to write leaves the reservation held, and the task running
502
608
  // for recovery to charge.
503
609
  const ran = effectReturned || isOutcomeUnknown(rawMessage);
504
- charge(ran);
610
+ if (ran)
611
+ draw();
612
+ release();
505
613
  // Ahead of every other outcome, because it is the strongest thing known:
506
614
  // the effect may already have happened, either because the adapter says
507
615
  // so or because it returned and a write after it failed. `failed`,
@@ -750,9 +858,12 @@ export class TaskController {
750
858
  // Settled already, it is left as it stands rather than certified again.
751
859
  // Asked of the stored row: a peer may have settled it since `task` was
752
860
  // read, and its status says `recovery_required` either way.
861
+ // A verdict of cannot_tell settled it as well: settling it again would
862
+ // rewrite that answer, and any yes beside it, as expired.
753
863
  const stored = this.status(task.id);
754
- if (stored.error === "semantic_judgement_expired")
864
+ if (stored.error === "semantic_judgement_expired" || stored.error === "semantic_judgement_cannot_tell") {
755
865
  return { task: stored };
866
+ }
756
867
  // Cut off before it reached `waiting_user`, maybe before its draw too.
757
868
  this.drawGrant(task, request, classified);
758
869
  return this.settleUnanswered(task);
@@ -812,11 +923,13 @@ export class TaskController {
812
923
  /**
813
924
  * A waiting task whose judgements are past their `expiresAt` settles
814
925
  * closed. Re-read and written in one transaction, so a peer settling the
815
- * same task wins once.
926
+ * same task, or a verdict, wins once.
816
927
  *
817
928
  * ponytail: runs on the startup pass and the 30 s recovery sweep, so a wait
818
929
  * can outlive its `expiresAt` by one interval, or until a runtime next opens
819
- * this data directory; a verdict tool must check `expiresAt` itself.
930
+ * this data directory. `judge` refuses a verdict from `expiresAt` on and
931
+ * writes nothing, so nothing is answered in that gap; every runtime runs
932
+ * this pass before it serves, `melra judge` included.
820
933
  */
821
934
  expireJudgements(failures) {
822
935
  for (const listed of this.store.listExpiredJudgements(now())) {
@@ -836,21 +949,101 @@ export class TaskController {
836
949
  * No answer is not a yes (#54). A task whose semantic question went
837
950
  * unanswered, at expiry or cut off after its commit, settles closed: its
838
951
  * effect ran, so a change is `recovery_required` (never `failed`) and a
839
- * read is `partial`. Nothing runs here, so it writes no receipt: the
840
- * certificate, replaced, carries the evidence of the task's last one, the
841
- * live path's or the one recovery wrote.
952
+ * read is `partial`. A yes given before expiry stays on the certificate.
842
953
  */
843
954
  settleUnanswered(task) {
955
+ return this.settleSemantic(task, task.policyDecision.effect === "read" ? "partial" : "recovery_required", "semantic_judgement_expired", "semantic_judgement_expired");
956
+ }
957
+ /**
958
+ * Settles a semantic task. Nothing runs here, so it writes no receipt: the
959
+ * certificate, replaced, carries the evidence of the task's last one, the
960
+ * live path's or the one recovery wrote, with the answers given so far.
961
+ */
962
+ settleSemantic(task, status, error, unanswered) {
844
963
  const last = task.receiptIds[task.receiptIds.length - 1];
845
964
  const receipt = last === undefined ? undefined : this.store.getReceipt(last);
846
- const evidence = (receipt?.evidence ?? []).map((item) => item.type === "semantic"
847
- ? { ...item, passed: false, inconclusive: true, summary: "semantic_judgement_expired" }
848
- : item);
849
- task.status = task.policyDecision.effect === "read" ? "partial" : "recovery_required";
850
- task.error = "semantic_judgement_expired";
965
+ const evidence = overlay(receipt?.evidence ?? [], task.judgements ?? [], unanswered, receipt?.principal);
966
+ task.status = status;
967
+ if (error === undefined)
968
+ delete task.error;
969
+ else
970
+ task.error = error;
851
971
  task.updatedAt = now();
852
972
  return { task, certificate: this.createAndSaveCertificate(task, evidence) };
853
973
  }
974
+ /**
975
+ * Records the answer to one semantic question of a waiting task (#54). The
976
+ * first verdict other than yes settles the task: no ends it `partial`,
977
+ * cannot_tell ends a change `recovery_required` and a read `partial`. The
978
+ * yes that answers the last open question verifies it; an earlier one is
979
+ * recorded, and the task waits on.
980
+ *
981
+ * A verdict writes the task row and nothing else, its certificate on a
982
+ * settle included: no receipt, grant draw, breaker record or idempotency
983
+ * commit, all of which the effect settled when it ran. Every check reads
984
+ * the row inside one transaction, and each refusal writes nothing, so a
985
+ * question is answered once, and a peer's verdict or the expiry sweep wins
986
+ * against this one at most once.
987
+ *
988
+ * `by` is the surface. An agent's declared identity is checked against the
989
+ * task's actor, with `client` the link its transport authenticated, if any;
990
+ * an operator's is not, as `decide` checks no approver. Only
991
+ * an operator in an enforced process, on a task that ran enforced, judges
992
+ * out of band, and an in-band verdict counts for nothing where approvals
993
+ * come only from an operator.
994
+ */
995
+ judge(input, by) {
996
+ const parsed = MelraTaskJudgeInputSchema.omit({ identity: true }).parse(input);
997
+ const operator = "operator" in by && this.enforced;
998
+ return this.store.transaction(() => {
999
+ const task = this.status(parsed.taskId);
1000
+ // The task and its question before the channel: told to change it, a
1001
+ // caller would only meet a settled task or a wrong id there (#384).
1002
+ // Nothing here is news to it: `melra_task_status` and `melra inspect`
1003
+ // show both.
1004
+ if (task.status !== "waiting_user")
1005
+ throw new Error(`task_not_awaiting_judgement:${task.status}`);
1006
+ const judgement = task.judgements?.find((entry) => entry.judgementId === parsed.judgementId);
1007
+ if (judgement === undefined)
1008
+ throw new Error("judgement_not_found");
1009
+ if (judgement.judgedBy !== undefined)
1010
+ throw new Error("judgement_already_answered");
1011
+ // Refused rather than settled here, which the throw would roll back:
1012
+ // the recovery pass settles it closed.
1013
+ if (Date.parse(judgement.expiresAt) <= Date.now())
1014
+ throw new Error("judgement_expired");
1015
+ const runEnforced = this.runMode(task) === "enforced";
1016
+ // Every door an agent has to an enforced kernel is its harness's, and a
1017
+ // developer process sharing the home must not settle an enforced run.
1018
+ if (!operator && (this.enforced || runEnforced))
1019
+ throw new Error("judgement_in_band_refused");
1020
+ // Out of band only on an enforced run, as `execute` downgrades an
1021
+ // approval: on a developer run the agent's own shell could have sent it.
1022
+ const channel = operator && runEnforced ? "out_of_band" : "in_band";
1023
+ // After the channel: where no agent's verdict counts, who it claims to
1024
+ // be is moot.
1025
+ if ("identity" in by && judgesItself(by.identity, this.loadRequest(task.id).identity ?? LOCAL_IDENTITY, by.client)) {
1026
+ throw new Error("semantic_judge_is_actor");
1027
+ }
1028
+ judgement.judgedBy = redactStructuredValue({
1029
+ verdict: parsed.verdict,
1030
+ judge: "identity" in by ? delegationChain(by.identity) : by.operator,
1031
+ channel,
1032
+ judgedAt: now(),
1033
+ ...(parsed.rationale === undefined ? {} : { rationale: parsed.rationale }),
1034
+ }).value;
1035
+ if (parsed.verdict === "yes" && task.judgements.some((entry) => entry.judgedBy === undefined)) {
1036
+ task.updatedAt = now();
1037
+ this.store.saveTask(task);
1038
+ return { task };
1039
+ }
1040
+ return this.settleSemantic(task, parsed.verdict === "yes"
1041
+ ? "verified_success"
1042
+ : parsed.verdict === "cannot_tell" && task.policyDecision.effect !== "read"
1043
+ ? "recovery_required"
1044
+ : "partial", parsed.verdict === "yes" ? undefined : `semantic_judgement_${parsed.verdict}`, "semantic_judgement_unanswered");
1045
+ });
1046
+ }
854
1047
  async recoverTask(task) {
855
1048
  const request = this.loadRequest(task.id);
856
1049
  const classified = classifyOperation(request.operation);
@@ -920,7 +1113,24 @@ export class TaskController {
920
1113
  */
921
1114
  approvalRender(task, request) {
922
1115
  const { authorization: _authorization, policy: _policy, ...contract } = effectContract({ ...task, request }, classifyOperation(request.operation));
923
- return { contract, renderDigest: sha256(contract) };
1116
+ // Keyed, like the challenge. A task a 0.3.0-alpha.17 or alpha.18 process
1117
+ // planned is shown its plain digest, the one that version's executor
1118
+ // recomputes, so a decision recorded here is one it accepts.
1119
+ const legacy = task.approval?.actionDigest === digestOperation(task.id, request.operation);
1120
+ return { contract, renderDigest: legacy ? sha256(contract) : this.payloadCipher.digest(contract, "render") };
1121
+ }
1122
+ /**
1123
+ * The challenge as stored beside the redacted request: its digest keyed by
1124
+ * this home's payload key, and the phrase still `APPROVE` plus that digest's
1125
+ * first twelve hex digits, as clients read it.
1126
+ */
1127
+ keyedChallenge(challenge) {
1128
+ const actionDigest = this.payloadCipher.digest(challenge.actionDigest, "action");
1129
+ return {
1130
+ ...challenge,
1131
+ actionDigest,
1132
+ ...(challenge.phrase === undefined ? {} : { phrase: `APPROVE ${actionDigest.slice(0, 12)}` }),
1133
+ };
924
1134
  }
925
1135
  /** A pending challenge as an operator surface shows it. */
926
1136
  approvalView(taskId) {
@@ -1044,20 +1254,32 @@ export class TaskController {
1044
1254
  const approvalId = task.approvedBy?.approvalId ??
1045
1255
  (task.policyDecision.outcome === "confirm" ? task.approval?.approvalId : undefined);
1046
1256
  const sanitized = redactStructuredValue(result);
1257
+ const observedEffect = maskResultDigest(sanitized.value, request.operation);
1047
1258
  // A verifier's summary and source are stored text like any other.
1048
1259
  const sanitizedEvidence = redactStructuredValue(evidence);
1260
+ const storedEvidence = maskEvidenceDigests(sanitizedEvidence.value);
1261
+ // The declared chain, redacted as the task row, a judge's name and a
1262
+ // certificate's `executedBy` are, so it reads the same as `executedBy`.
1263
+ const principal = redactSecrets(delegationChain(request.identity ?? LOCAL_IDENTITY));
1049
1264
  const adapterRedactions = Array.isArray(result.redactions)
1050
1265
  ? result.redactions.filter((item) => typeof item === "string")
1051
1266
  : [];
1267
+ const masked = observedEffect !== sanitized.value || storedEvidence !== sanitizedEvidence.value ? [MASKED] : [];
1052
1268
  const redactions = [
1053
- ...new Set([...adapterRedactions, ...sanitized.redactions, ...sanitizedEvidence.redactions]),
1269
+ ...new Set([
1270
+ ...adapterRedactions,
1271
+ ...sanitized.redactions,
1272
+ ...sanitizedEvidence.redactions,
1273
+ ...principal.redactions,
1274
+ ...masked,
1275
+ ]),
1054
1276
  ];
1055
1277
  return {
1056
1278
  schemaVersion: "1.0.0",
1057
1279
  receiptId: createReceiptId(),
1058
1280
  taskId: task.id,
1059
1281
  capability,
1060
- principal: delegationChain(request.identity ?? LOCAL_IDENTITY),
1282
+ principal: principal.value,
1061
1283
  effect,
1062
1284
  executionGuarantee: executionGuaranteeFor(effect, request.reconciliation.length > 0),
1063
1285
  mode: this.runMode(task) ?? this.policy.mode,
@@ -1079,12 +1301,14 @@ export class TaskController {
1079
1301
  // The target is built from the request (a URL, a memory key) and stored
1080
1302
  // as it is, so it takes the same secret shapes out as everything else.
1081
1303
  target: redactSecrets(target).value,
1082
- inputDigest: sha256(request.operation),
1304
+ // Keyed and bound to the task: it still pins the exact input for a
1305
+ // holder of the home's key, and two tasks' digests are never comparable.
1306
+ inputDigest: this.payloadCipher.digest({ taskId: task.id, operation: request.operation }, "input"),
1083
1307
  startedAt,
1084
1308
  endedAt: now(),
1085
1309
  success,
1086
- observedEffect: sanitized.value,
1087
- evidence: sanitizedEvidence.value,
1310
+ observedEffect,
1311
+ evidence: storedEvidence,
1088
1312
  redactions,
1089
1313
  ...(error === undefined ? {} : { error }),
1090
1314
  };
@@ -1149,10 +1373,10 @@ export class TaskController {
1149
1373
  result: certificateResult(task),
1150
1374
  policyVersion: task.policyDecision.policyVersion,
1151
1375
  receiptIds: task.receiptIds,
1152
- // Redacted as a receipt's is, before the digest is taken. Redacting
1153
- // again changes nothing, so one written from a stored receipt's items
1154
- // (`certify`) says what one written from the verifier's would.
1155
- evidence: redactStructuredValue(evidence).value,
1376
+ // Redacted and masked as a receipt's is, before the digest is taken.
1377
+ // Doing either again changes nothing, so one written from a stored
1378
+ // receipt's items (`certify`) says what one written from the verifier's would.
1379
+ evidence: maskEvidenceDigests(redactStructuredValue(evidence).value),
1156
1380
  createdAt: now(),
1157
1381
  ...(mode === undefined ? {} : { mode }),
1158
1382
  ...(approvalChannel === undefined ? {} : { approvalChannel }),