@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.
- package/README.md +1 -1
- package/dist/payload-cipher.d.ts +12 -0
- package/dist/payload-cipher.d.ts.map +1 -1
- package/dist/payload-cipher.js +15 -1
- package/dist/payload-cipher.js.map +1 -1
- package/dist/task-controller.d.ts +46 -7
- package/dist/task-controller.d.ts.map +1 -1
- package/dist/task-controller.js +311 -87
- package/dist/task-controller.js.map +1 -1
- package/dist/workflow-controller.d.ts +6 -0
- package/dist/workflow-controller.d.ts.map +1 -1
- package/dist/workflow-controller.js +31 -8
- package/dist/workflow-controller.js.map +1 -1
- package/package.json +6 -6
package/dist/task-controller.js
CHANGED
|
@@ -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
|
-
|
|
300
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
464
|
+
const draw = () => {
|
|
381
465
|
if (rechecked.grantId === undefined)
|
|
382
466
|
return;
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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
|
-
|
|
447
|
-
task.
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
task.
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
|
481
|
-
// and
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
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
|
|
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`.
|
|
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 ?? []
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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
|
-
|
|
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([
|
|
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:
|
|
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
|
-
|
|
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
|
|
1087
|
-
evidence:
|
|
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.
|
|
1153
|
-
// again changes nothing, so one written from a stored
|
|
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 }),
|