@decionis/agent-safe-pipeline 0.3.7 → 0.4.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.
- package/README.md +31 -0
- package/dist/Index.d.ts +12 -0
- package/dist/Index.d.ts.map +1 -1
- package/dist/Index.js +12 -0
- package/dist/Index.js.map +1 -1
- package/dist/decision/DecionisGate.d.ts +6 -1
- package/dist/decision/DecionisGate.d.ts.map +1 -1
- package/dist/decision/DecionisGate.js +61 -11
- package/dist/decision/DecionisGate.js.map +1 -1
- package/dist/decision/DecisionAuthority.d.ts +16 -0
- package/dist/decision/DecisionAuthority.d.ts.map +1 -1
- package/dist/decision/DecisionAuthority.js.map +1 -1
- package/dist/execution/AuthorizationVerifier.d.ts +33 -4
- package/dist/execution/AuthorizationVerifier.d.ts.map +1 -1
- package/dist/execution/AuthorizationVerifier.js +74 -10
- package/dist/execution/AuthorizationVerifier.js.map +1 -1
- package/dist/execution/EffectAdapter.d.ts +117 -0
- package/dist/execution/EffectAdapter.d.ts.map +1 -0
- package/dist/execution/EffectAdapter.js +22 -0
- package/dist/execution/EffectAdapter.js.map +1 -0
- package/dist/execution/ExecutionDeadline.d.ts +35 -0
- package/dist/execution/ExecutionDeadline.d.ts.map +1 -0
- package/dist/execution/ExecutionDeadline.js +52 -0
- package/dist/execution/ExecutionDeadline.js.map +1 -0
- package/dist/execution/ExecutionJournal.d.ts +148 -0
- package/dist/execution/ExecutionJournal.d.ts.map +1 -0
- package/dist/execution/ExecutionJournal.js +157 -0
- package/dist/execution/ExecutionJournal.js.map +1 -0
- package/dist/execution/ExecutionOutcome.d.ts +186 -0
- package/dist/execution/ExecutionOutcome.d.ts.map +1 -0
- package/dist/execution/ExecutionOutcome.js +2 -0
- package/dist/execution/ExecutionOutcome.js.map +1 -0
- package/dist/execution/ExecutionPosture.d.ts +50 -0
- package/dist/execution/ExecutionPosture.d.ts.map +1 -0
- package/dist/execution/ExecutionPosture.js +20 -0
- package/dist/execution/ExecutionPosture.js.map +1 -0
- package/dist/execution/ExecutorEvidence.d.ts +64 -0
- package/dist/execution/ExecutorEvidence.d.ts.map +1 -0
- package/dist/execution/ExecutorEvidence.js +14 -0
- package/dist/execution/ExecutorEvidence.js.map +1 -0
- package/dist/execution/GovernedExecutionRunner.d.ts +309 -0
- package/dist/execution/GovernedExecutionRunner.d.ts.map +1 -0
- package/dist/execution/GovernedExecutionRunner.js +1341 -0
- package/dist/execution/GovernedExecutionRunner.js.map +1 -0
- package/dist/execution/NativeExecutor.d.ts +220 -0
- package/dist/execution/NativeExecutor.d.ts.map +1 -0
- package/dist/execution/NativeExecutor.js +22 -0
- package/dist/execution/NativeExecutor.js.map +1 -0
- package/dist/execution/NativeExecutorSchema.d.ts +387 -0
- package/dist/execution/NativeExecutorSchema.d.ts.map +1 -0
- package/dist/execution/NativeExecutorSchema.js +261 -0
- package/dist/execution/NativeExecutorSchema.js.map +1 -0
- package/dist/execution/PostgresExecutionJournal.d.ts +54 -0
- package/dist/execution/PostgresExecutionJournal.d.ts.map +1 -0
- package/dist/execution/PostgresExecutionJournal.js +131 -0
- package/dist/execution/PostgresExecutionJournal.js.map +1 -0
- package/dist/execution/RegistryExecutor.d.ts +35 -0
- package/dist/execution/RegistryExecutor.d.ts.map +1 -0
- package/dist/execution/RegistryExecutor.js +84 -0
- package/dist/execution/RegistryExecutor.js.map +1 -0
- package/dist/execution/SafeExecutor.d.ts +22 -193
- package/dist/execution/SafeExecutor.d.ts.map +1 -1
- package/dist/execution/SafeExecutor.js +49 -521
- package/dist/execution/SafeExecutor.js.map +1 -1
- package/dist/http/AuthorityPaths.d.ts +51 -0
- package/dist/http/AuthorityPaths.d.ts.map +1 -0
- package/dist/http/AuthorityPaths.js +84 -0
- package/dist/http/AuthorityPaths.js.map +1 -0
- package/dist/shadow/ShadowPipeline.d.ts +3 -1
- package/dist/shadow/ShadowPipeline.d.ts.map +1 -1
- package/dist/shadow/ShadowPipeline.js +3 -3
- package/dist/shadow/ShadowPipeline.js.map +1 -1
- package/dist/testing/Index.d.ts +2 -1
- package/dist/testing/Index.d.ts.map +1 -1
- package/dist/testing/Index.js +2 -1
- package/dist/testing/Index.js.map +1 -1
- package/dist/testing/LocalAuthority.d.ts +35 -1
- package/dist/testing/LocalAuthority.d.ts.map +1 -1
- package/dist/testing/LocalAuthority.js +122 -2
- package/dist/testing/LocalAuthority.js.map +1 -1
- package/dist/testing/SignedSignals.d.ts +150 -0
- package/dist/testing/SignedSignals.d.ts.map +1 -0
- package/dist/testing/SignedSignals.js +291 -0
- package/dist/testing/SignedSignals.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1341 @@
|
|
|
1
|
+
import { FailClosedDecision, } from "../decision/DecisionAuthority.js";
|
|
2
|
+
import { AuthorityPaths } from "../http/AuthorityPaths.js";
|
|
3
|
+
import { CanonicalIntentHasher } from "../intent/CanonicalIntentHasher.js";
|
|
4
|
+
import { boundaryOf, workloadOf } from "../intent/ExecutionSignals.js";
|
|
5
|
+
import { IntentCapture } from "../intent/IntentCapture.js";
|
|
6
|
+
import { PreDispatchRefusal, ProviderRefusal } from "./ActionRegistry.js";
|
|
7
|
+
import { dispatchBudgetMs, MonotonicDeadline } from "./ExecutionDeadline.js";
|
|
8
|
+
import { JournalError } from "./ExecutionJournal.js";
|
|
9
|
+
import { STRICT_POSTURE, unavailableDisposition, } from "./ExecutionPosture.js";
|
|
10
|
+
import { EXECUTOR_EVIDENCE_VERSION, evidenceScope, } from "./ExecutorEvidence.js";
|
|
11
|
+
import { NATIVE_EXECUTOR_CONTRACT, } from "./NativeExecutor.js";
|
|
12
|
+
/** A write that never left the process, and why, when the executor said. */
|
|
13
|
+
class NotDispatched {
|
|
14
|
+
code;
|
|
15
|
+
constructor(code) {
|
|
16
|
+
this.code = code;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** The verdicts that admit an execution: ALLOW, and Commerce's PROCEED, which means the same. */
|
|
20
|
+
const ADMITTED_VERDICTS = new Set(["ALLOW", "PROCEED"]);
|
|
21
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
22
|
+
/**
|
|
23
|
+
* The one implementation of everything around a native write. Every executor,
|
|
24
|
+
* on every platform, runs through it, and none of them does any of this
|
|
25
|
+
* itself:
|
|
26
|
+
*
|
|
27
|
+
* - **admission**: an authoritative ALLOW (or PROCEED) only, never after a
|
|
28
|
+
* fail-closed gate, only with a dossier, only for the intent that was
|
|
29
|
+
* decided, at the boundary and by the workload that captured it, and only
|
|
30
|
+
* through a capability the connection has;
|
|
31
|
+
* - **authority calls**, through the `DecisionAuthority` and
|
|
32
|
+
* `AuthorizationVerifier` seams: the decision, one single-use claim, the
|
|
33
|
+
* receipt (finalization), and the reconciliation;
|
|
34
|
+
* - **execution control**: the dispatch deadline, the journal's durable claim
|
|
35
|
+
* and single-use idempotency key, and reconciliation after an outcome
|
|
36
|
+
* nobody knows;
|
|
37
|
+
* - **failure posture** when the authority cannot be reached;
|
|
38
|
+
* - **evidence**: the audit trail and the `agent-safe.executor-evidence/1`
|
|
39
|
+
* line, and the six outcomes every run ends in.
|
|
40
|
+
*
|
|
41
|
+
* Dispatch-style executors write inside the dispatch the runner opens. A
|
|
42
|
+
* permit-style executor's grant is held at admission; the native boundary
|
|
43
|
+
* presents the permit, which claims the grant once, and a later receipt
|
|
44
|
+
* settles it.
|
|
45
|
+
*/
|
|
46
|
+
export class GovernedExecutionRunner {
|
|
47
|
+
hasher = new CanonicalIntentHasher();
|
|
48
|
+
verifier;
|
|
49
|
+
audit;
|
|
50
|
+
authority;
|
|
51
|
+
capture;
|
|
52
|
+
journal;
|
|
53
|
+
evidence;
|
|
54
|
+
posture;
|
|
55
|
+
boundaryId;
|
|
56
|
+
workloadDigest;
|
|
57
|
+
attempts;
|
|
58
|
+
timeoutMs;
|
|
59
|
+
enforceDispatchWindow;
|
|
60
|
+
reportReconciliations;
|
|
61
|
+
clock;
|
|
62
|
+
/** The holds this runner issued and nobody has claimed, keyed by the frozen handle. */
|
|
63
|
+
holds = new WeakMap();
|
|
64
|
+
/** The holds claimed and not yet settled, with the authorization each one consumed. */
|
|
65
|
+
claims = new WeakMap();
|
|
66
|
+
/** The holds already settled, so a second receipt is told so rather than "never presented". */
|
|
67
|
+
settled = new WeakSet();
|
|
68
|
+
constructor(options) {
|
|
69
|
+
this.verifier = options.verifier;
|
|
70
|
+
this.audit = options.audit;
|
|
71
|
+
this.authority = options.authority;
|
|
72
|
+
this.capture = options.capture ?? new IntentCapture();
|
|
73
|
+
this.journal = options.journal ?? null;
|
|
74
|
+
this.evidence = options.evidence ?? null;
|
|
75
|
+
this.posture = options.posture ?? STRICT_POSTURE;
|
|
76
|
+
this.boundaryId = options.boundaryId ?? null;
|
|
77
|
+
this.workloadDigest = options.workloadDigest ?? null;
|
|
78
|
+
this.attempts = options.attempts ?? GovernedExecutionRunner.journalAttempts(this.journal);
|
|
79
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
80
|
+
this.enforceDispatchWindow = options.enforceDispatchWindow ?? true;
|
|
81
|
+
this.reportReconciliations = options.reportReconciliations ?? true;
|
|
82
|
+
this.clock = options.clock ?? Date.now;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The whole path from an action: the executor prepares it, the runner
|
|
86
|
+
* captures the intent and asks the authority, then runs it (dispatch) or
|
|
87
|
+
* holds it for the boundary (permit). An authority that throws is a
|
|
88
|
+
* fail-closed decision, never an authorization.
|
|
89
|
+
*/
|
|
90
|
+
async submit(executor, request) {
|
|
91
|
+
if (this.authority === undefined)
|
|
92
|
+
throw new Error("RUNNER_AUTHORITY_MISSING");
|
|
93
|
+
const prepared = await executor.prepare(request.action, request.scope);
|
|
94
|
+
const captured = this.capture.capture(prepared.proposal, {
|
|
95
|
+
tenantId: request.scope.orgId,
|
|
96
|
+
actor: request.actor,
|
|
97
|
+
downstreamTarget: prepared.downstreamTarget,
|
|
98
|
+
context: prepared.facts,
|
|
99
|
+
idempotencyKey: prepared.idempotencyKey,
|
|
100
|
+
...(prepared.expectedEffectDigest === null
|
|
101
|
+
? {}
|
|
102
|
+
: { expectedEffectDigest: prepared.expectedEffectDigest }),
|
|
103
|
+
// Stryker disable next-line ConditionalExpression: the capture drops an undefined correlation itself; the condition only satisfies the strict optional type.
|
|
104
|
+
...(request.correlationId === undefined ? {} : { correlationId: request.correlationId }),
|
|
105
|
+
});
|
|
106
|
+
let decision;
|
|
107
|
+
try {
|
|
108
|
+
decision = await this.authority.evaluate(captured, request.evidence);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
decision = FailClosedDecision.create(captured.intentHash, "AUTHORITY_UNAVAILABLE");
|
|
112
|
+
}
|
|
113
|
+
const result = await this.run(executor, {
|
|
114
|
+
captured,
|
|
115
|
+
decision,
|
|
116
|
+
action: request.action,
|
|
117
|
+
scope: request.scope,
|
|
118
|
+
});
|
|
119
|
+
return { captured, decision, result };
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Runs a decided intent. A dispatch-style executor writes once, inside the
|
|
123
|
+
* dispatch; a permit-style executor's grant is held instead, as `hold`
|
|
124
|
+
* would, because the boundary performs its write.
|
|
125
|
+
*/
|
|
126
|
+
async run(executor, request) {
|
|
127
|
+
if (executor.style === "permit")
|
|
128
|
+
return await this.hold(executor, request);
|
|
129
|
+
return await this.dispatch(executor, request);
|
|
130
|
+
}
|
|
131
|
+
/** Runs a decided intent through a dispatch-style write, whatever the executor's style says. */
|
|
132
|
+
async dispatch(executor, request) {
|
|
133
|
+
const startedAt = this.clock();
|
|
134
|
+
const { captured, decision } = request;
|
|
135
|
+
const scope = request.scope ?? null;
|
|
136
|
+
const admitted = await this.admit(executor, request, startedAt, "RUN");
|
|
137
|
+
if ("outcome" in admitted)
|
|
138
|
+
return admitted;
|
|
139
|
+
const context = {
|
|
140
|
+
executor,
|
|
141
|
+
action: request.action,
|
|
142
|
+
scope,
|
|
143
|
+
prepared: admitted.prepared,
|
|
144
|
+
};
|
|
145
|
+
const opened = await this.openAttempt(executor, request, admitted, startedAt);
|
|
146
|
+
if (opened !== null)
|
|
147
|
+
return opened;
|
|
148
|
+
const claim = await this.consume(context, captured, decision, startedAt);
|
|
149
|
+
if (claim.outcome !== "CLAIMED") {
|
|
150
|
+
await this.close(captured, claim);
|
|
151
|
+
await this.emit(executor, context, "RUN", captured, decision, claim, startedAt);
|
|
152
|
+
return claim;
|
|
153
|
+
}
|
|
154
|
+
const authorization = claim.authorization;
|
|
155
|
+
const deadline = MonotonicDeadline.after(dispatchBudgetMs(this.timeoutMs, authorization));
|
|
156
|
+
if (this.enforceDispatchWindow && deadline.budgetMs === 0) {
|
|
157
|
+
const failed = await this.failedBeforeDispatch(captured, decision, authorization, "HANDLER_FAILED_BEFORE_DISPATCH", startedAt, "DISPATCH_WINDOW_CLOSED");
|
|
158
|
+
await this.close(captured, failed);
|
|
159
|
+
await this.emit(executor, context, "RUN", captured, decision, failed, startedAt);
|
|
160
|
+
return failed;
|
|
161
|
+
}
|
|
162
|
+
const observed = await this.write(executor, context, captured, authorization, deadline);
|
|
163
|
+
const result = observed instanceof NotDispatched
|
|
164
|
+
? await this.failedBeforeDispatch(captured, decision, authorization, "HANDLER_FAILED_BEFORE_DISPATCH", startedAt, observed.code)
|
|
165
|
+
: await this.concluded(captured, decision, authorization, observed, startedAt);
|
|
166
|
+
await this.close(captured, result);
|
|
167
|
+
await this.emit(executor, context, "RUN", captured, decision, result, startedAt);
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Admits a decision exactly as a run would and holds its grant instead of
|
|
172
|
+
* consuming it. The intent, the decision and the hold are recorded; the
|
|
173
|
+
* grant stays unclaimed until the boundary presents it, and an unclaimed
|
|
174
|
+
* hold simply expires with its grant. A permit-style executor's hold
|
|
175
|
+
* carries the permit the boundary presents.
|
|
176
|
+
*/
|
|
177
|
+
async hold(executor, request) {
|
|
178
|
+
return await this.holdWith(executor, request, []);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Admits a hold this runner did not make: one another replica made, or
|
|
182
|
+
* this process made before a restart, read back from a store the replicas
|
|
183
|
+
* share. Every check a hold makes is made again on the way in, and the
|
|
184
|
+
* record says the hold was adopted. Adoption adds no authority: the grant
|
|
185
|
+
* is still the authority's to hand out once, so a hold claimed elsewhere is
|
|
186
|
+
* refused at the claim by the authority, never admitted twice here.
|
|
187
|
+
*/
|
|
188
|
+
async adoptHold(executor, request) {
|
|
189
|
+
return await this.holdWith(executor, request, ["HOLD_ADOPTED"]);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Registers a claim this runner did not make, so that `settle` can finalize
|
|
193
|
+
* it here: the hold's intent and decision, and the authorization the claim
|
|
194
|
+
* returned, as the store that shared them kept them. Nothing is consumed
|
|
195
|
+
* and nothing is checked against the authority.
|
|
196
|
+
*/
|
|
197
|
+
adoptClaim(executor, request, authorization, material = null) {
|
|
198
|
+
const { captured, decision } = request;
|
|
199
|
+
if (!GovernedExecutionRunner.isAuthoritativeDecision(decision) ||
|
|
200
|
+
decision.intentHash !== captured.intentHash ||
|
|
201
|
+
authorization.intentHash !== captured.intentHash) {
|
|
202
|
+
throw new Error("CLAIM_ADOPTION_MISMATCH");
|
|
203
|
+
}
|
|
204
|
+
if (material !== null) {
|
|
205
|
+
const adopt = this.verifier.adoptClaim;
|
|
206
|
+
if (adopt === undefined || !adopt.call(this.verifier, authorization, material)) {
|
|
207
|
+
throw new Error("CLAIM_MATERIAL_REFUSED");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const held = Object.freeze({
|
|
211
|
+
captured,
|
|
212
|
+
decision,
|
|
213
|
+
expiresAt: authorization.expiresAt,
|
|
214
|
+
});
|
|
215
|
+
this.claims.set(held, {
|
|
216
|
+
executor: executor,
|
|
217
|
+
action: request.action,
|
|
218
|
+
scope: request.scope ?? null,
|
|
219
|
+
prepared: null,
|
|
220
|
+
authorization,
|
|
221
|
+
});
|
|
222
|
+
return held;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* What another process needs to finalize a claim this runner's verifier
|
|
226
|
+
* made, for a store the replicas share to keep sealed; null when the
|
|
227
|
+
* verifier keeps its claims to itself or did not make this one.
|
|
228
|
+
*/
|
|
229
|
+
claimMaterial(authorization) {
|
|
230
|
+
const material = this.verifier.claimMaterial;
|
|
231
|
+
return material === undefined ? null : material.call(this.verifier, authorization);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* The boundary presents a held permit: the grant is claimed, once. The
|
|
235
|
+
* handle is forgotten before the verifier is asked, so a second
|
|
236
|
+
* presentation of the same hold, concurrent or later, is refused here
|
|
237
|
+
* without reaching the authority; a handle this runner did not issue is
|
|
238
|
+
* refused the same way. A permit-style executor then issues the claimed
|
|
239
|
+
* permit the boundary writes with.
|
|
240
|
+
*/
|
|
241
|
+
async present(held) {
|
|
242
|
+
const startedAt = this.clock();
|
|
243
|
+
const state = this.holds.get(held);
|
|
244
|
+
if (state === undefined) {
|
|
245
|
+
return await this.block(held.captured, held.decision, "AUTHORIZATION_INVALID", startedAt);
|
|
246
|
+
}
|
|
247
|
+
this.holds.delete(held);
|
|
248
|
+
const claim = await this.consume(state, held.captured, state.decision, startedAt);
|
|
249
|
+
if (claim.outcome !== "CLAIMED") {
|
|
250
|
+
await this.close(held.captured, claim);
|
|
251
|
+
await this.emit(state.executor, state, "PRESENTATION", held.captured, held.decision, claim, startedAt);
|
|
252
|
+
return claim;
|
|
253
|
+
}
|
|
254
|
+
if (state.executor.style !== "permit") {
|
|
255
|
+
this.claims.set(held, { ...state, authorization: claim.authorization });
|
|
256
|
+
await this.emit(state.executor, state, "PRESENTATION", held.captured, held.decision, claim, startedAt);
|
|
257
|
+
return claim;
|
|
258
|
+
}
|
|
259
|
+
const permit = await this.issuePermit(state, held, claim.authorization);
|
|
260
|
+
if (permit === null) {
|
|
261
|
+
const failed = await this.failedBeforeDispatch(held.captured, held.decision, claim.authorization, "HANDLER_FAILED_BEFORE_DISPATCH", startedAt, "PERMIT_NOT_ISSUED");
|
|
262
|
+
await this.close(held.captured, failed);
|
|
263
|
+
await this.emit(state.executor, state, "PRESENTATION", held.captured, held.decision, failed, startedAt);
|
|
264
|
+
return failed;
|
|
265
|
+
}
|
|
266
|
+
this.claims.set(held, { ...state, authorization: claim.authorization });
|
|
267
|
+
const presented = { outcome: "CLAIMED", authorization: claim.authorization, permit };
|
|
268
|
+
await this.emit(state.executor, state, "PRESENTATION", held.captured, held.decision, presented, startedAt);
|
|
269
|
+
return presented;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Settles a presented execution, once, from what the boundary reported:
|
|
273
|
+
* the outcome it observed, or its native receipt for the executor to read.
|
|
274
|
+
* `COMMITTED` when the effect happened, `FAILED` when it definitively did
|
|
275
|
+
* not, `INDETERMINATE` when nobody can say. A second receipt is refused and
|
|
276
|
+
* changes nothing that was recorded.
|
|
277
|
+
*/
|
|
278
|
+
async settle(held, input) {
|
|
279
|
+
const startedAt = this.clock();
|
|
280
|
+
const state = this.claims.get(held);
|
|
281
|
+
if (state === undefined) {
|
|
282
|
+
return {
|
|
283
|
+
status: "REFUSED",
|
|
284
|
+
reason: this.settled.has(held) ? "RECEIPT_DUPLICATE" : "PERMIT_NOT_PRESENTED",
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
this.claims.delete(held);
|
|
288
|
+
this.settled.add(held);
|
|
289
|
+
const observation = "outcome" in input
|
|
290
|
+
? GovernedExecutionRunner.observationOf(input.outcome, input.signedReceipt ?? null)
|
|
291
|
+
: await this.observeReceipt(state, input.receipt, input.signedReceipt ?? null);
|
|
292
|
+
const finalization = await this.finalize(held.captured, held.decision, state.authorization, observation.status, observation.signedReceipt ?? null, observation.evidence);
|
|
293
|
+
const eventType = GovernedExecutionRunner.settledEvent(observation.status);
|
|
294
|
+
// Stryker disable all: Audit payload mapping is covered by lifecycle event assertions.
|
|
295
|
+
await this.record({
|
|
296
|
+
eventType,
|
|
297
|
+
captured: held.captured,
|
|
298
|
+
decision: held.decision,
|
|
299
|
+
authorization: state.authorization,
|
|
300
|
+
reasonCodes: [GovernedExecutionRunner.finalizationCode(finalization)],
|
|
301
|
+
durationMs: this.clock() - startedAt,
|
|
302
|
+
});
|
|
303
|
+
// Stryker restore all
|
|
304
|
+
const outcome = GovernedExecutionRunner.outcomeOf(observation.status);
|
|
305
|
+
const settlement = {
|
|
306
|
+
status: "SETTLED",
|
|
307
|
+
outcome,
|
|
308
|
+
executed: observation.status === "COMMITTED" ? true : observation.status === "FAILED" ? false : null,
|
|
309
|
+
result: observation.status === "COMMITTED" ? observation.result : null,
|
|
310
|
+
reason: observation.status === "FAILED"
|
|
311
|
+
? (observation.reason ?? "PROVIDER_REFUSED")
|
|
312
|
+
: observation.status === "INDETERMINATE"
|
|
313
|
+
? "PROVIDER_OUTCOME_UNKNOWN"
|
|
314
|
+
: null,
|
|
315
|
+
authorization: state.authorization,
|
|
316
|
+
finalization,
|
|
317
|
+
...(observation.status === "INDETERMINATE"
|
|
318
|
+
? {
|
|
319
|
+
recovery: GovernedExecutionRunner.recoveryReference(held.captured, state.authorization),
|
|
320
|
+
}
|
|
321
|
+
: {}),
|
|
322
|
+
};
|
|
323
|
+
await this.appendQuietly({
|
|
324
|
+
record: "ATTEMPT_CLOSED",
|
|
325
|
+
at: new Date(this.clock()).toISOString(),
|
|
326
|
+
intent_id: held.captured.intent.intentId,
|
|
327
|
+
intent_hash: held.captured.intentHash,
|
|
328
|
+
outcome,
|
|
329
|
+
executed: settlement.executed,
|
|
330
|
+
finalization,
|
|
331
|
+
});
|
|
332
|
+
await this.emitLine(state.executor, state, {
|
|
333
|
+
stage: "SETTLEMENT",
|
|
334
|
+
captured: held.captured,
|
|
335
|
+
decision: held.decision,
|
|
336
|
+
outcome,
|
|
337
|
+
executed: settlement.executed,
|
|
338
|
+
reason: settlement.reason,
|
|
339
|
+
code: null,
|
|
340
|
+
grantId: state.authorization.grantId,
|
|
341
|
+
governed: true,
|
|
342
|
+
disposition: null,
|
|
343
|
+
finalization,
|
|
344
|
+
startedAt,
|
|
345
|
+
});
|
|
346
|
+
return settlement;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Resolves an attempt whose outcome was lost. The recovery reference is
|
|
350
|
+
* the caller's claim until this runner's own record confirms it; the
|
|
351
|
+
* executor is asked read-only, and nothing is ever dispatched again. A
|
|
352
|
+
* proven answer is reported to the authority as evidence.
|
|
353
|
+
*/
|
|
354
|
+
async reconcile(executor, request) {
|
|
355
|
+
const startedAt = this.clock();
|
|
356
|
+
const { captured, recovery } = request;
|
|
357
|
+
const scope = request.scope ?? null;
|
|
358
|
+
if (recovery.version !== "agent-safe.recovery/1" ||
|
|
359
|
+
recovery.intentHash !== captured.intentHash ||
|
|
360
|
+
recovery.idempotencyKey !== captured.intent.idempotencyKey ||
|
|
361
|
+
!this.intentConforms(captured)) {
|
|
362
|
+
return await this.block(captured, undefined, "RECOVERY_BINDING_MISMATCH", startedAt);
|
|
363
|
+
}
|
|
364
|
+
// The decision and grant are the caller's claim until the runner's own
|
|
365
|
+
// record confirms them; the platform is not asked about a binding that
|
|
366
|
+
// was never dispatched here.
|
|
367
|
+
let authority = "NON_AUTHORITATIVE";
|
|
368
|
+
if (this.attempts !== null) {
|
|
369
|
+
const recorded = await this.attempts.authorizationsOf(captured);
|
|
370
|
+
if (recorded.length === 0) {
|
|
371
|
+
return await this.block(captured, undefined, "RECOVERY_ATTEMPT_UNKNOWN", startedAt);
|
|
372
|
+
}
|
|
373
|
+
if (!recorded.some((candidate) => GovernedExecutionRunner.sameBinding(candidate, recovery))) {
|
|
374
|
+
return await this.block(captured, undefined, "RECOVERY_BINDING_MISMATCH", startedAt);
|
|
375
|
+
}
|
|
376
|
+
authority = "AUTHORITATIVE";
|
|
377
|
+
}
|
|
378
|
+
const authorization = GovernedExecutionRunner.authorizationFrom(recovery);
|
|
379
|
+
const prepared = await executor.prepare(request.action, scope);
|
|
380
|
+
const reconciliation = await executor.reconcile({
|
|
381
|
+
intent: captured,
|
|
382
|
+
prepared,
|
|
383
|
+
idempotencyKey: recovery.idempotencyKey,
|
|
384
|
+
scope,
|
|
385
|
+
providerReference: null,
|
|
386
|
+
});
|
|
387
|
+
const context = { executor, action: request.action, scope, prepared };
|
|
388
|
+
if (reconciliation.status === "COMPLETED") {
|
|
389
|
+
const result = {
|
|
390
|
+
outcome: "COMPLETED",
|
|
391
|
+
executed: true,
|
|
392
|
+
recovered: true,
|
|
393
|
+
result: reconciliation.result,
|
|
394
|
+
authorization,
|
|
395
|
+
};
|
|
396
|
+
// Stryker disable all: Audit payload mapping is covered by lifecycle event assertions.
|
|
397
|
+
await this.record({
|
|
398
|
+
eventType: "RECONCILIATION_COMPLETED",
|
|
399
|
+
captured,
|
|
400
|
+
authorization,
|
|
401
|
+
authority,
|
|
402
|
+
durationMs: this.clock() - startedAt,
|
|
403
|
+
});
|
|
404
|
+
// Stryker restore all
|
|
405
|
+
await this.reconciled(context, captured, authorization, result, "COMMITTED", reconciliation.evidence, startedAt);
|
|
406
|
+
return result;
|
|
407
|
+
}
|
|
408
|
+
if (reconciliation.status === "NOT_EXECUTED") {
|
|
409
|
+
const result = {
|
|
410
|
+
outcome: "DEFINITELY_NOT_EXECUTED",
|
|
411
|
+
executed: false,
|
|
412
|
+
recovered: true,
|
|
413
|
+
reason: "PROVIDER_CONFIRMED_NOT_EXECUTED",
|
|
414
|
+
result: null,
|
|
415
|
+
authorization,
|
|
416
|
+
};
|
|
417
|
+
// Stryker disable all: Audit payload mapping is covered by lifecycle event assertions.
|
|
418
|
+
await this.record({
|
|
419
|
+
eventType: "RECONCILIATION_NOT_EXECUTED",
|
|
420
|
+
captured,
|
|
421
|
+
authorization,
|
|
422
|
+
authority,
|
|
423
|
+
reasonCodes: [result.reason],
|
|
424
|
+
durationMs: this.clock() - startedAt,
|
|
425
|
+
});
|
|
426
|
+
// Stryker restore all
|
|
427
|
+
await this.reconciled(context, captured, authorization, result, "FAILED", reconciliation.evidence, startedAt);
|
|
428
|
+
return result;
|
|
429
|
+
}
|
|
430
|
+
const result = {
|
|
431
|
+
outcome: "UNKNOWN_AFTER_DISPATCH",
|
|
432
|
+
executed: null,
|
|
433
|
+
reason: "PROVIDER_OUTCOME_UNKNOWN",
|
|
434
|
+
result: null,
|
|
435
|
+
authorization,
|
|
436
|
+
recovery,
|
|
437
|
+
};
|
|
438
|
+
// Stryker disable all: Audit payload mapping is covered by lifecycle event assertions.
|
|
439
|
+
await this.record({
|
|
440
|
+
eventType: "RECONCILIATION_UNKNOWN",
|
|
441
|
+
captured,
|
|
442
|
+
authorization,
|
|
443
|
+
authority,
|
|
444
|
+
reasonCodes: [result.reason],
|
|
445
|
+
durationMs: this.clock() - startedAt,
|
|
446
|
+
});
|
|
447
|
+
// Stryker restore all
|
|
448
|
+
await this.reconciled(context, captured, authorization, result, null, undefined, startedAt);
|
|
449
|
+
return result;
|
|
450
|
+
}
|
|
451
|
+
async holdWith(executor, request, reasonCodes) {
|
|
452
|
+
const startedAt = this.clock();
|
|
453
|
+
const { captured, decision } = request;
|
|
454
|
+
const admittedRun = await this.admit(executor, request, startedAt, "RUN");
|
|
455
|
+
if ("outcome" in admittedRun)
|
|
456
|
+
return admittedRun;
|
|
457
|
+
const admitted = decision;
|
|
458
|
+
const context = {
|
|
459
|
+
executor,
|
|
460
|
+
action: request.action,
|
|
461
|
+
scope: request.scope ?? null,
|
|
462
|
+
prepared: admittedRun.prepared,
|
|
463
|
+
};
|
|
464
|
+
// The attempt is open before the hold is recorded, so a journal that
|
|
465
|
+
// refuses it leaves no hold behind in the record either.
|
|
466
|
+
const opened = await this.openAttempt(executor, request, admittedRun, startedAt);
|
|
467
|
+
if (opened !== null)
|
|
468
|
+
return opened;
|
|
469
|
+
// Stryker disable all: Audit payload mapping is covered by lifecycle event assertions.
|
|
470
|
+
const heldRecorded = await this.record({
|
|
471
|
+
eventType: "GRANT_HELD",
|
|
472
|
+
captured,
|
|
473
|
+
decision,
|
|
474
|
+
...(reasonCodes.length === 0 ? {} : { reasonCodes: [...reasonCodes] }),
|
|
475
|
+
durationMs: this.clock() - startedAt,
|
|
476
|
+
});
|
|
477
|
+
// Stryker restore all
|
|
478
|
+
if (this.audit?.requiresDeliveryBeforeExecution === true && !heldRecorded) {
|
|
479
|
+
const blocked = await this.block(captured, decision, "AUDIT_UNAVAILABLE", startedAt);
|
|
480
|
+
await this.emit(executor, context, "RUN", captured, decision, blocked, startedAt);
|
|
481
|
+
return blocked;
|
|
482
|
+
}
|
|
483
|
+
const held = Object.freeze({
|
|
484
|
+
captured,
|
|
485
|
+
decision,
|
|
486
|
+
expiresAt: admitted.authorization.expiresAt,
|
|
487
|
+
});
|
|
488
|
+
this.holds.set(held, { ...context, decision: admitted });
|
|
489
|
+
const result = {
|
|
490
|
+
outcome: "HELD",
|
|
491
|
+
executed: false,
|
|
492
|
+
result: null,
|
|
493
|
+
authorization: null,
|
|
494
|
+
held,
|
|
495
|
+
};
|
|
496
|
+
await this.emit(executor, context, "RUN", captured, decision, result, startedAt);
|
|
497
|
+
if (executor.style !== "permit")
|
|
498
|
+
return result;
|
|
499
|
+
return {
|
|
500
|
+
...result,
|
|
501
|
+
permit: Object.freeze({
|
|
502
|
+
permitId: AuthorityPaths.grantIdOf(admitted.authorization.token),
|
|
503
|
+
intentHash: captured.intentHash,
|
|
504
|
+
expiresAt: admitted.authorization.expiresAt,
|
|
505
|
+
}),
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Every check made before a grant may be consumed, with the evidence that
|
|
510
|
+
* the intent was captured and decided. A refusal is recorded and returned.
|
|
511
|
+
* The executor prepares the action last, so a malformed action is refused
|
|
512
|
+
* the way it always was, after the decision's own checks.
|
|
513
|
+
*/
|
|
514
|
+
async admit(executor, request, startedAt, stage) {
|
|
515
|
+
const { captured, decision } = request;
|
|
516
|
+
const scope = request.scope ?? null;
|
|
517
|
+
// Stryker disable next-line all: Audit payload mapping is covered by lifecycle event assertions.
|
|
518
|
+
const intentRecorded = await this.record({ eventType: "INTENT_CAPTURED", captured });
|
|
519
|
+
// An observational artifact (shadow observation, serialized audit event) is
|
|
520
|
+
// rejected before it is recorded or inspected as if it were a decision.
|
|
521
|
+
if (!GovernedExecutionRunner.isAuthoritativeDecision(decision)) {
|
|
522
|
+
return await this.block(captured, undefined, "DECISION_NOT_AUTHORITATIVE", startedAt);
|
|
523
|
+
}
|
|
524
|
+
// Stryker disable all: Audit payload mapping is covered by lifecycle event assertions.
|
|
525
|
+
const decisionRecorded = await this.record({
|
|
526
|
+
eventType: decision.failClosed ? "AUTHORITY_FAILED_CLOSED" : "AUTHORITY_DECISION",
|
|
527
|
+
captured,
|
|
528
|
+
decision,
|
|
529
|
+
});
|
|
530
|
+
// Stryker restore all
|
|
531
|
+
const refuse = async (reason, code) => {
|
|
532
|
+
const blocked = await this.block(captured, decision, reason, startedAt, code);
|
|
533
|
+
await this.emitAdmission(executor, request, stage, blocked, startedAt);
|
|
534
|
+
return blocked;
|
|
535
|
+
};
|
|
536
|
+
if (this.audit?.requiresDeliveryBeforeExecution === true &&
|
|
537
|
+
(!intentRecorded || !decisionRecorded)) {
|
|
538
|
+
return await refuse("AUDIT_UNAVAILABLE");
|
|
539
|
+
}
|
|
540
|
+
if (!ADMITTED_VERDICTS.has(decision.verdict) || decision.failClosed) {
|
|
541
|
+
return await this.notAllowed(executor, request, stage, startedAt);
|
|
542
|
+
}
|
|
543
|
+
const dossierId = decision.dossierId;
|
|
544
|
+
if (dossierId === null)
|
|
545
|
+
return await refuse("DOSSIER_MISSING");
|
|
546
|
+
if (decision.intentHash !== captured.intentHash)
|
|
547
|
+
return await refuse("INTENT_BINDING_MISMATCH");
|
|
548
|
+
if (decision.authorization === null || typeof decision.authorization !== "object") {
|
|
549
|
+
return await refuse("AUTHORIZATION_MISSING");
|
|
550
|
+
}
|
|
551
|
+
if (!this.intentConforms(captured))
|
|
552
|
+
return await refuse("INTENT_CONFORMANCE_FAILED");
|
|
553
|
+
// Before the grant is consumed, not after: a boundary that refuses an
|
|
554
|
+
// intent must leave the authority intact for the boundary that owns it.
|
|
555
|
+
if (!this.boundaryMatches(captured))
|
|
556
|
+
return await refuse("BOUNDARY_MISMATCH");
|
|
557
|
+
if (!this.workloadMatches(captured))
|
|
558
|
+
return await refuse("WORKLOAD_MISMATCH");
|
|
559
|
+
const prepared = await executor.prepare(request.action, scope);
|
|
560
|
+
// What the executor would write must be exactly what was decided: an
|
|
561
|
+
// executor whose own description of the action differs is refused before
|
|
562
|
+
// a grant is spent on it.
|
|
563
|
+
if (!this.preparedMatches(prepared, captured))
|
|
564
|
+
return await refuse("INTENT_BINDING_MISMATCH");
|
|
565
|
+
if (scope !== null) {
|
|
566
|
+
const gap = await GovernedExecutionRunner.capabilityGap(executor, scope, prepared);
|
|
567
|
+
if (gap !== null)
|
|
568
|
+
return await refuse("CAPABILITY_UNAVAILABLE", gap);
|
|
569
|
+
}
|
|
570
|
+
return { prepared, dossierId };
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* A decision that is not an authoritative ALLOW. When it failed closed the
|
|
574
|
+
* posture says what the surface may do; the runner refuses either way, and
|
|
575
|
+
* never writes without a grant.
|
|
576
|
+
*/
|
|
577
|
+
async notAllowed(executor, request, stage, startedAt) {
|
|
578
|
+
const { captured, decision } = request;
|
|
579
|
+
const blocked = await this.block(captured, decision, "DECISION_NOT_ALLOW", startedAt);
|
|
580
|
+
if (!decision.failClosed || this.posture.posture === "STRICT") {
|
|
581
|
+
await this.emitAdmission(executor, request, stage, blocked, startedAt);
|
|
582
|
+
return blocked;
|
|
583
|
+
}
|
|
584
|
+
const disposition = unavailableDisposition(this.posture, { captured, decision });
|
|
585
|
+
const result = {
|
|
586
|
+
...blocked,
|
|
587
|
+
unavailable: { posture: this.posture.posture, disposition },
|
|
588
|
+
};
|
|
589
|
+
await this.emitAdmission(executor, request, stage, result, startedAt, disposition);
|
|
590
|
+
return result;
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Opens the attempt in the journal, after admission and before anything is
|
|
594
|
+
* consumed: a key already dispatched under, or a journal that cannot take
|
|
595
|
+
* the record, is refused while the grant is still intact.
|
|
596
|
+
*/
|
|
597
|
+
async openAttempt(executor, request, admission, startedAt) {
|
|
598
|
+
const journal = this.journal;
|
|
599
|
+
if (journal === null)
|
|
600
|
+
return null;
|
|
601
|
+
const { captured, decision } = request;
|
|
602
|
+
const refuse = async (reason, code) => {
|
|
603
|
+
const blocked = await this.block(captured, decision, reason, startedAt, code);
|
|
604
|
+
await this.emitAdmission(executor, request, "RUN", blocked, startedAt);
|
|
605
|
+
return blocked;
|
|
606
|
+
};
|
|
607
|
+
try {
|
|
608
|
+
if (journal.dispatchedUnder !== undefined) {
|
|
609
|
+
const earlier = await journal.dispatchedUnder(admission.prepared.idempotencyKey);
|
|
610
|
+
if (earlier.length > 0)
|
|
611
|
+
return await refuse("IDEMPOTENCY_KEY_DISPATCHED");
|
|
612
|
+
}
|
|
613
|
+
await journal.append({
|
|
614
|
+
record: "ATTEMPT_OPENED",
|
|
615
|
+
at: new Date(this.clock()).toISOString(),
|
|
616
|
+
intent_id: captured.intent.intentId,
|
|
617
|
+
intent_hash: captured.intentHash,
|
|
618
|
+
idempotency_key: captured.intent.idempotencyKey,
|
|
619
|
+
decision_id: decision.decisionId,
|
|
620
|
+
dossier_id: admission.dossierId,
|
|
621
|
+
caller_principal: null,
|
|
622
|
+
intent: { ...captured.intent },
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
catch (error) {
|
|
626
|
+
return await refuse("JOURNAL_UNAVAILABLE", GovernedExecutionRunner.journalCode(error));
|
|
627
|
+
}
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Consumes the grant through the verifier, checks what came back against
|
|
632
|
+
* the decision and the intent, makes the claim durable, and records the
|
|
633
|
+
* consumption and the start of execution. Nothing here dispatches.
|
|
634
|
+
*/
|
|
635
|
+
async consume(context, captured, decision, startedAt) {
|
|
636
|
+
let authorization;
|
|
637
|
+
try {
|
|
638
|
+
authorization = await this.verifier.verifyAndConsume(captured, decision);
|
|
639
|
+
}
|
|
640
|
+
catch {
|
|
641
|
+
return await this.block(captured, decision, "AUTHORIZATION_INVALID", startedAt);
|
|
642
|
+
}
|
|
643
|
+
if (authorization === null) {
|
|
644
|
+
return await this.block(captured, decision, "AUTHORIZATION_INVALID", startedAt);
|
|
645
|
+
}
|
|
646
|
+
const authorizationExpiry = Date.parse(authorization.expiresAt);
|
|
647
|
+
if (authorization.decisionId !== decision.decisionId ||
|
|
648
|
+
authorization.dossierId !== decision.dossierId ||
|
|
649
|
+
authorization.grantId.length === 0 ||
|
|
650
|
+
authorization.intentHash !== captured.intentHash ||
|
|
651
|
+
Math.floor(authorizationExpiry / 1_000) !==
|
|
652
|
+
Math.floor(Date.parse(decision.authorization.expiresAt) / 1_000) ||
|
|
653
|
+
authorizationExpiry <= Date.now() ||
|
|
654
|
+
authorizationExpiry > Date.parse(captured.intent.expiresAt)) {
|
|
655
|
+
return await this.block(captured, decision, "AUTHORIZATION_INVALID", startedAt);
|
|
656
|
+
}
|
|
657
|
+
if (!this.intentConforms(captured)) {
|
|
658
|
+
return await this.block(captured, decision, "INTENT_CONFORMANCE_FAILED", startedAt);
|
|
659
|
+
}
|
|
660
|
+
// Stryker disable all: Audit payload mapping is covered by lifecycle event assertions.
|
|
661
|
+
const grantRecorded = await this.record({
|
|
662
|
+
eventType: "GRANT_CONSUMED",
|
|
663
|
+
captured,
|
|
664
|
+
decision,
|
|
665
|
+
authorization,
|
|
666
|
+
durationMs: this.clock() - startedAt,
|
|
667
|
+
});
|
|
668
|
+
// Stryker restore all
|
|
669
|
+
if (this.audit?.requiresDeliveryBeforeExecution === true && !grantRecorded) {
|
|
670
|
+
return await this.failedBeforeDispatch(captured, decision, authorization, "AUDIT_UNAVAILABLE", startedAt);
|
|
671
|
+
}
|
|
672
|
+
// The claim is durable before anything can be dispatched: a journal that
|
|
673
|
+
// cannot take it, or that refuses a key already claimed, fails the
|
|
674
|
+
// attempt before dispatch, and the authority is told it failed.
|
|
675
|
+
if (this.journal !== null) {
|
|
676
|
+
try {
|
|
677
|
+
await this.journal.append({
|
|
678
|
+
record: "GRANT_CLAIMED",
|
|
679
|
+
at: new Date(this.clock()).toISOString(),
|
|
680
|
+
intent_id: captured.intent.intentId,
|
|
681
|
+
intent_hash: captured.intentHash,
|
|
682
|
+
idempotency_key: captured.intent.idempotencyKey,
|
|
683
|
+
grant_id: authorization.grantId,
|
|
684
|
+
expires_at: authorization.expiresAt,
|
|
685
|
+
request_digest: context.prepared.requestDigest,
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
catch (error) {
|
|
689
|
+
return await this.failedBeforeDispatch(captured, decision, authorization, "JOURNAL_UNAVAILABLE", startedAt, GovernedExecutionRunner.journalCode(error));
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
// Stryker disable all: Audit payload mapping is covered by lifecycle event assertions.
|
|
693
|
+
const executionRecorded = await this.record({
|
|
694
|
+
eventType: "EXECUTION_STARTED",
|
|
695
|
+
captured,
|
|
696
|
+
decision,
|
|
697
|
+
authorization,
|
|
698
|
+
durationMs: this.clock() - startedAt,
|
|
699
|
+
});
|
|
700
|
+
// Stryker restore all
|
|
701
|
+
if (this.audit?.requiresDeliveryBeforeExecution === true && !executionRecorded) {
|
|
702
|
+
return await this.failedBeforeDispatch(captured, decision, authorization, "AUDIT_UNAVAILABLE", startedAt);
|
|
703
|
+
}
|
|
704
|
+
return { outcome: "CLAIMED", authorization };
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* The write, inside the dispatch boundary the runner opens, and its
|
|
708
|
+
* observation. Whatever the executor says, the boundary decides which side
|
|
709
|
+
* of it an error fell: before it nothing was sent, after it a refusal is a
|
|
710
|
+
* refusal only if the platform said so, and anything else is unknown. The
|
|
711
|
+
* runner never moves "nobody knows" to "definitely not".
|
|
712
|
+
*/
|
|
713
|
+
async write(executor, context, captured, authorization, deadline) {
|
|
714
|
+
const idempotencyKey = captured.intent.idempotencyKey;
|
|
715
|
+
let dispatched = false;
|
|
716
|
+
let signedReceipt = null;
|
|
717
|
+
const dispatch = Object.freeze({
|
|
718
|
+
idempotencyKey,
|
|
719
|
+
run: async (operation) => {
|
|
720
|
+
// Stryker disable next-line StringLiteral: caught below as an error after the dispatch began; its text is never read.
|
|
721
|
+
if (dispatched)
|
|
722
|
+
throw new Error("PROVIDER_DISPATCH_ALREADY_STARTED");
|
|
723
|
+
dispatched = true;
|
|
724
|
+
return await operation(idempotencyKey);
|
|
725
|
+
},
|
|
726
|
+
receipt: (token) => {
|
|
727
|
+
// Stryker disable next-line StringLiteral: caught below as an error before the dispatch; its text is never read.
|
|
728
|
+
if (!dispatched)
|
|
729
|
+
throw new Error("PROVIDER_RECEIPT_BEFORE_DISPATCH");
|
|
730
|
+
signedReceipt = token;
|
|
731
|
+
},
|
|
732
|
+
});
|
|
733
|
+
const unknown = () => ({
|
|
734
|
+
status: "INDETERMINATE",
|
|
735
|
+
reason: null,
|
|
736
|
+
result: null,
|
|
737
|
+
signedReceipt,
|
|
738
|
+
});
|
|
739
|
+
let effect;
|
|
740
|
+
try {
|
|
741
|
+
effect = await executor.execute({
|
|
742
|
+
action: context.action,
|
|
743
|
+
prepared: context.prepared,
|
|
744
|
+
intent: captured,
|
|
745
|
+
scope: context.scope,
|
|
746
|
+
authorization,
|
|
747
|
+
idempotencyKey,
|
|
748
|
+
deadline,
|
|
749
|
+
dispatch,
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
catch (error) {
|
|
753
|
+
if (dispatched && error instanceof ProviderRefusal) {
|
|
754
|
+
return { status: "FAILED", reason: error.reason, result: null, signedReceipt };
|
|
755
|
+
}
|
|
756
|
+
if (dispatched)
|
|
757
|
+
return unknown();
|
|
758
|
+
return new NotDispatched(error instanceof PreDispatchRefusal ? error.code : undefined);
|
|
759
|
+
}
|
|
760
|
+
if (effect.kind === "NOT_DISPATCHED") {
|
|
761
|
+
if (dispatched)
|
|
762
|
+
return unknown();
|
|
763
|
+
return new NotDispatched(effect.code === undefined ? undefined : new PreDispatchRefusal(effect.code).code);
|
|
764
|
+
}
|
|
765
|
+
if (effect.kind === "PERMIT") {
|
|
766
|
+
// A dispatch never yields a permit; one that does broke the contract,
|
|
767
|
+
// and what it did is unknown once its dispatch began.
|
|
768
|
+
if (dispatched)
|
|
769
|
+
return unknown();
|
|
770
|
+
return new NotDispatched("EXECUTOR_CONTRACT_VIOLATION");
|
|
771
|
+
}
|
|
772
|
+
try {
|
|
773
|
+
const observation = await executor.observe(effect, context.prepared);
|
|
774
|
+
return {
|
|
775
|
+
...observation,
|
|
776
|
+
signedReceipt: observation.signedReceipt ?? effect.signedReceipt ?? signedReceipt,
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
catch {
|
|
780
|
+
return unknown();
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
/** The permit a presented, claimed hold writes with; null when the executor issued none. */
|
|
784
|
+
async issuePermit(state, held, authorization) {
|
|
785
|
+
const deadline = MonotonicDeadline.after(dispatchBudgetMs(this.timeoutMs, authorization));
|
|
786
|
+
// A permit-style executor never writes: the boundary does. Either half of
|
|
787
|
+
// the dispatch refuses, so an executor that tries is not issuing a permit.
|
|
788
|
+
const refuse = () => {
|
|
789
|
+
// Stryker disable next-line StringLiteral: caught below as no permit issued; its text is never read.
|
|
790
|
+
throw new Error("PERMIT_EXECUTOR_CANNOT_DISPATCH");
|
|
791
|
+
};
|
|
792
|
+
try {
|
|
793
|
+
const effect = await state.executor.execute({
|
|
794
|
+
action: state.action,
|
|
795
|
+
prepared: state.prepared,
|
|
796
|
+
intent: held.captured,
|
|
797
|
+
scope: state.scope,
|
|
798
|
+
authorization,
|
|
799
|
+
idempotencyKey: held.captured.intent.idempotencyKey,
|
|
800
|
+
deadline,
|
|
801
|
+
dispatch: Object.freeze({
|
|
802
|
+
idempotencyKey: held.captured.intent.idempotencyKey,
|
|
803
|
+
run: refuse,
|
|
804
|
+
receipt: refuse,
|
|
805
|
+
}),
|
|
806
|
+
});
|
|
807
|
+
if (effect.kind !== "PERMIT")
|
|
808
|
+
return null;
|
|
809
|
+
// The permit names this grant, this intent and the authority's proof of
|
|
810
|
+
// the claim, whatever the executor's own description of it says.
|
|
811
|
+
return Object.freeze({
|
|
812
|
+
permitId: authorization.grantId,
|
|
813
|
+
intentHash: held.captured.intentHash,
|
|
814
|
+
expiresAt: authorization.expiresAt,
|
|
815
|
+
...(authorization.claimAttestation === undefined
|
|
816
|
+
? {}
|
|
817
|
+
: { claimAttestation: authorization.claimAttestation }),
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
catch {
|
|
821
|
+
return null;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
/** What the executor reads a permit-style receipt as; a receipt it cannot read is unknown. */
|
|
825
|
+
async observeReceipt(state, receipt, signedReceipt) {
|
|
826
|
+
try {
|
|
827
|
+
const prepared = state.prepared ?? (await state.executor.prepare(state.action, state.scope));
|
|
828
|
+
const observation = (await state.executor.observe({ kind: "RECEIPT", receipt, signedReceipt }, prepared));
|
|
829
|
+
return { ...observation, signedReceipt: observation.signedReceipt ?? signedReceipt };
|
|
830
|
+
}
|
|
831
|
+
catch {
|
|
832
|
+
return { status: "INDETERMINATE", reason: null, result: null, signedReceipt };
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
/** The terminal result of a dispatched write, finalized and recorded. */
|
|
836
|
+
async concluded(captured, decision, authorization, observation, startedAt) {
|
|
837
|
+
const receipt = observation.signedReceipt ?? null;
|
|
838
|
+
if (observation.status === "FAILED") {
|
|
839
|
+
// The provider said no. That is an outcome, so it is finalized `FAILED`
|
|
840
|
+
// rather than left indeterminate, and `executed` is false rather than
|
|
841
|
+
// null: nothing about it is unknown.
|
|
842
|
+
const finalization = await this.finalize(captured, decision, authorization, "FAILED", receipt, observation.evidence);
|
|
843
|
+
const reason = observation.reason ?? "PROVIDER_REFUSED";
|
|
844
|
+
const result = {
|
|
845
|
+
outcome: "DEFINITELY_NOT_EXECUTED",
|
|
846
|
+
executed: false,
|
|
847
|
+
recovered: false,
|
|
848
|
+
reason,
|
|
849
|
+
result: null,
|
|
850
|
+
authorization,
|
|
851
|
+
finalization,
|
|
852
|
+
};
|
|
853
|
+
// Stryker disable all: Audit payload mapping is covered by lifecycle event assertions.
|
|
854
|
+
await this.record({
|
|
855
|
+
eventType: "EXECUTION_REFUSED_AFTER_DISPATCH",
|
|
856
|
+
captured,
|
|
857
|
+
decision,
|
|
858
|
+
authorization,
|
|
859
|
+
reasonCodes: [reason, GovernedExecutionRunner.finalizationCode(finalization)],
|
|
860
|
+
durationMs: this.clock() - startedAt,
|
|
861
|
+
});
|
|
862
|
+
// Stryker restore all
|
|
863
|
+
return result;
|
|
864
|
+
}
|
|
865
|
+
if (observation.status === "INDETERMINATE") {
|
|
866
|
+
const finalization = await this.finalize(captured, decision, authorization, "INDETERMINATE", receipt, observation.evidence);
|
|
867
|
+
const result = {
|
|
868
|
+
outcome: "UNKNOWN_AFTER_DISPATCH",
|
|
869
|
+
executed: null,
|
|
870
|
+
reason: "PROVIDER_OUTCOME_UNKNOWN",
|
|
871
|
+
result: null,
|
|
872
|
+
authorization,
|
|
873
|
+
recovery: GovernedExecutionRunner.recoveryReference(captured, authorization),
|
|
874
|
+
finalization,
|
|
875
|
+
};
|
|
876
|
+
// Stryker disable all: Audit payload mapping is covered by lifecycle event assertions.
|
|
877
|
+
await this.record({
|
|
878
|
+
eventType: "EXECUTION_OUTCOME_UNKNOWN",
|
|
879
|
+
captured,
|
|
880
|
+
decision,
|
|
881
|
+
authorization,
|
|
882
|
+
reasonCodes: [result.reason, GovernedExecutionRunner.finalizationCode(finalization)],
|
|
883
|
+
durationMs: this.clock() - startedAt,
|
|
884
|
+
});
|
|
885
|
+
// Stryker restore all
|
|
886
|
+
return result;
|
|
887
|
+
}
|
|
888
|
+
const finalization = await this.finalize(captured, decision, authorization, "COMMITTED", receipt, observation.evidence);
|
|
889
|
+
const result = {
|
|
890
|
+
outcome: "COMPLETED",
|
|
891
|
+
executed: true,
|
|
892
|
+
recovered: false,
|
|
893
|
+
result: observation.result,
|
|
894
|
+
authorization,
|
|
895
|
+
finalization,
|
|
896
|
+
};
|
|
897
|
+
// Stryker disable all: Audit payload mapping is covered by lifecycle event assertions.
|
|
898
|
+
await this.record({
|
|
899
|
+
eventType: "EXECUTION_COMPLETED",
|
|
900
|
+
captured,
|
|
901
|
+
decision,
|
|
902
|
+
authorization,
|
|
903
|
+
reasonCodes: [GovernedExecutionRunner.finalizationCode(finalization)],
|
|
904
|
+
durationMs: this.clock() - startedAt,
|
|
905
|
+
});
|
|
906
|
+
// Stryker restore all
|
|
907
|
+
return result;
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* Reports the attempt outcome to the verifier's authority, with the
|
|
911
|
+
* provider's effect receipt and the executor's observation when the attempt
|
|
912
|
+
* brought them back. A missing, failing, or malformed finalization can
|
|
913
|
+
* never alter the execution result.
|
|
914
|
+
*/
|
|
915
|
+
async finalize(captured, decision, authorization, outcome, receipt = null, evidence) {
|
|
916
|
+
const finalize = this.verifier.finalize;
|
|
917
|
+
if (finalize === undefined)
|
|
918
|
+
return "UNSUPPORTED";
|
|
919
|
+
try {
|
|
920
|
+
const status = await finalize.call(this.verifier, {
|
|
921
|
+
captured,
|
|
922
|
+
decision,
|
|
923
|
+
authorization,
|
|
924
|
+
outcome,
|
|
925
|
+
...(receipt === null ? {} : { effectReceipt: receipt }),
|
|
926
|
+
...(evidence === undefined ? {} : { effectEvidence: evidence }),
|
|
927
|
+
});
|
|
928
|
+
return status === "RECORDED" ? "RECORDED" : "PENDING";
|
|
929
|
+
}
|
|
930
|
+
catch {
|
|
931
|
+
return "PENDING";
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
/** The journal and the authority hear of a proven reconciliation; neither can change its result. */
|
|
935
|
+
async reconciled(context, captured, authorization, result, proven, evidence, startedAt) {
|
|
936
|
+
await this.appendQuietly({
|
|
937
|
+
record: "RECONCILED",
|
|
938
|
+
at: new Date(this.clock()).toISOString(),
|
|
939
|
+
intent_id: captured.intent.intentId,
|
|
940
|
+
intent_hash: captured.intentHash,
|
|
941
|
+
status: result.outcome,
|
|
942
|
+
source: "CALLER",
|
|
943
|
+
});
|
|
944
|
+
let finalization = null;
|
|
945
|
+
const report = this.verifier.reconcile;
|
|
946
|
+
if (proven !== null && this.reportReconciliations) {
|
|
947
|
+
if (report === undefined) {
|
|
948
|
+
finalization = "UNSUPPORTED";
|
|
949
|
+
}
|
|
950
|
+
else {
|
|
951
|
+
try {
|
|
952
|
+
finalization =
|
|
953
|
+
(await report.call(this.verifier, {
|
|
954
|
+
captured,
|
|
955
|
+
authorization,
|
|
956
|
+
outcome: proven,
|
|
957
|
+
...(evidence === undefined ? {} : { effectEvidence: evidence }),
|
|
958
|
+
})) === "RECORDED"
|
|
959
|
+
? "RECORDED"
|
|
960
|
+
: "PENDING";
|
|
961
|
+
}
|
|
962
|
+
catch {
|
|
963
|
+
finalization = "PENDING";
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
await this.emitLine(context.executor, context, {
|
|
968
|
+
stage: "RECONCILIATION",
|
|
969
|
+
captured,
|
|
970
|
+
decision: null,
|
|
971
|
+
...GovernedExecutionRunner.summaryOf(result),
|
|
972
|
+
disposition: null,
|
|
973
|
+
finalization,
|
|
974
|
+
startedAt,
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
static finalizationCode(finalization) {
|
|
978
|
+
return `COMMIT_FINALIZATION_${finalization}`;
|
|
979
|
+
}
|
|
980
|
+
static settledEvent(outcome) {
|
|
981
|
+
if (outcome === "COMMITTED")
|
|
982
|
+
return "EXECUTION_COMPLETED";
|
|
983
|
+
if (outcome === "FAILED")
|
|
984
|
+
return "EXECUTION_REFUSED_AFTER_DISPATCH";
|
|
985
|
+
return "EXECUTION_OUTCOME_UNKNOWN";
|
|
986
|
+
}
|
|
987
|
+
static outcomeOf(status) {
|
|
988
|
+
if (status === "COMMITTED")
|
|
989
|
+
return "COMPLETED";
|
|
990
|
+
if (status === "FAILED")
|
|
991
|
+
return "DEFINITELY_NOT_EXECUTED";
|
|
992
|
+
return "UNKNOWN_AFTER_DISPATCH";
|
|
993
|
+
}
|
|
994
|
+
static observationOf(status, signedReceipt) {
|
|
995
|
+
return { status, reason: null, result: null, signedReceipt };
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* Observational artifacts carry an explicit non-authoritative marker. They
|
|
999
|
+
* must never be interpreted as a `DecisionGate`, even after an unsafe cast
|
|
1000
|
+
* or a JSON round trip.
|
|
1001
|
+
*/
|
|
1002
|
+
static isAuthoritativeDecision(decision) {
|
|
1003
|
+
const candidate = decision;
|
|
1004
|
+
return candidate.authority !== "OBSERVATIONAL" && candidate.mode !== "SHADOW";
|
|
1005
|
+
}
|
|
1006
|
+
/**
|
|
1007
|
+
* Whether this boundary is the one the intent was captured through. The
|
|
1008
|
+
* boundary is read back out of the hashed context, so an intent whose
|
|
1009
|
+
* boundary was edited after capture fails conformance before it reaches
|
|
1010
|
+
* here, and one that never named a boundary cannot acquire one now.
|
|
1011
|
+
*/
|
|
1012
|
+
boundaryMatches(captured) {
|
|
1013
|
+
if (this.boundaryId === null)
|
|
1014
|
+
return true;
|
|
1015
|
+
return boundaryOf(captured.intent.context)?.boundary_id === this.boundaryId;
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Whether the workload that proposed the intent is the one running it. Like
|
|
1019
|
+
* the boundary, the digest is read back out of the hashed context.
|
|
1020
|
+
*/
|
|
1021
|
+
workloadMatches(captured) {
|
|
1022
|
+
if (this.workloadDigest === null)
|
|
1023
|
+
return true;
|
|
1024
|
+
return workloadOf(captured.intent.context)?.digest === this.workloadDigest;
|
|
1025
|
+
}
|
|
1026
|
+
intentConforms(captured) {
|
|
1027
|
+
let recomputed = null;
|
|
1028
|
+
try {
|
|
1029
|
+
recomputed = this.hasher.capture(captured.intent);
|
|
1030
|
+
}
|
|
1031
|
+
catch {
|
|
1032
|
+
// Malformed runtime objects are non-conformant.
|
|
1033
|
+
}
|
|
1034
|
+
return (recomputed !== null &&
|
|
1035
|
+
recomputed.intentHash === captured.intentHash &&
|
|
1036
|
+
recomputed.canonicalIntent === captured.canonicalIntent &&
|
|
1037
|
+
recomputed.byteLength === captured.byteLength);
|
|
1038
|
+
}
|
|
1039
|
+
/** The executor's description of the action, held to the decided intent field by field. */
|
|
1040
|
+
preparedMatches(prepared, captured) {
|
|
1041
|
+
const intent = captured.intent;
|
|
1042
|
+
let parameters;
|
|
1043
|
+
// Stryker disable BlockStatement: parameters that cannot be canonicalized never match the intent; the explicit false only names that.
|
|
1044
|
+
try {
|
|
1045
|
+
parameters = CanonicalIntentHasher.stringify(prepared.proposal.parameters);
|
|
1046
|
+
}
|
|
1047
|
+
catch {
|
|
1048
|
+
return false;
|
|
1049
|
+
}
|
|
1050
|
+
// Stryker restore BlockStatement
|
|
1051
|
+
return (prepared.actionType === intent.action &&
|
|
1052
|
+
prepared.proposal.action === intent.action &&
|
|
1053
|
+
prepared.proposal.target === intent.target &&
|
|
1054
|
+
parameters === CanonicalIntentHasher.stringify(intent.parameters) &&
|
|
1055
|
+
prepared.downstreamTarget.system === intent.downstreamTarget.system &&
|
|
1056
|
+
prepared.downstreamTarget.operation === intent.downstreamTarget.operation &&
|
|
1057
|
+
prepared.idempotencyKey === intent.idempotencyKey &&
|
|
1058
|
+
(prepared.expectedEffectDigest ?? undefined) === intent.expectedEffectDigest);
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* The connection must be able to take the write: connected, scope granted,
|
|
1062
|
+
* switched on, for this action type on this platform. The refusal names
|
|
1063
|
+
* what is missing in the Commerce executor's own words.
|
|
1064
|
+
*/
|
|
1065
|
+
static async capabilityGap(executor, scope, prepared) {
|
|
1066
|
+
let capabilities;
|
|
1067
|
+
try {
|
|
1068
|
+
capabilities = await executor.capabilities(scope);
|
|
1069
|
+
}
|
|
1070
|
+
catch {
|
|
1071
|
+
return "CAPABILITY_UNREADABLE";
|
|
1072
|
+
}
|
|
1073
|
+
const capability = capabilities.find((candidate) => candidate.actionType === prepared.actionType && candidate.platform === executor.platform);
|
|
1074
|
+
if (capability === undefined)
|
|
1075
|
+
return "UNSUPPORTED_ACTION";
|
|
1076
|
+
if (!capability.connected)
|
|
1077
|
+
return "NOT_CONNECTED";
|
|
1078
|
+
if (!capability.scopeGranted)
|
|
1079
|
+
return "SCOPE_NOT_GRANTED";
|
|
1080
|
+
if (!capability.enabled)
|
|
1081
|
+
return "CAPABILITY_DISABLED";
|
|
1082
|
+
return null;
|
|
1083
|
+
}
|
|
1084
|
+
async block(captured, decision, reason, startedAt, code) {
|
|
1085
|
+
// Stryker disable all: Audit payload mapping is covered by lifecycle event assertions.
|
|
1086
|
+
await this.record({
|
|
1087
|
+
eventType: "EXECUTION_BLOCKED",
|
|
1088
|
+
captured,
|
|
1089
|
+
...(decision === undefined ? {} : { decision }),
|
|
1090
|
+
reasonCodes: code === undefined ? [reason] : [reason, code],
|
|
1091
|
+
durationMs: this.clock() - startedAt,
|
|
1092
|
+
});
|
|
1093
|
+
// Stryker restore all
|
|
1094
|
+
return {
|
|
1095
|
+
outcome: "BLOCKED",
|
|
1096
|
+
executed: false,
|
|
1097
|
+
reason,
|
|
1098
|
+
...(code === undefined ? {} : { code }),
|
|
1099
|
+
result: null,
|
|
1100
|
+
authorization: null,
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
async failedBeforeDispatch(captured, decision, authorization, reason, startedAt, code) {
|
|
1104
|
+
// The grant was consumed but no side effect was attempted.
|
|
1105
|
+
const finalization = await this.finalize(captured, decision, authorization, "FAILED");
|
|
1106
|
+
// Stryker disable all: Audit payload mapping is covered by lifecycle event assertions.
|
|
1107
|
+
await this.record({
|
|
1108
|
+
eventType: "EXECUTION_FAILED_BEFORE_DISPATCH",
|
|
1109
|
+
captured,
|
|
1110
|
+
decision,
|
|
1111
|
+
authorization,
|
|
1112
|
+
reasonCodes: [
|
|
1113
|
+
reason,
|
|
1114
|
+
...(code === undefined ? [] : [code]),
|
|
1115
|
+
GovernedExecutionRunner.finalizationCode(finalization),
|
|
1116
|
+
],
|
|
1117
|
+
durationMs: this.clock() - startedAt,
|
|
1118
|
+
});
|
|
1119
|
+
// Stryker restore all
|
|
1120
|
+
return {
|
|
1121
|
+
outcome: "FAILED_BEFORE_DISPATCH",
|
|
1122
|
+
executed: false,
|
|
1123
|
+
reason,
|
|
1124
|
+
...(code === undefined ? {} : { code }),
|
|
1125
|
+
result: null,
|
|
1126
|
+
authorization,
|
|
1127
|
+
finalization,
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
async record(input) {
|
|
1131
|
+
// Stryker disable next-line BooleanLiteral: The value is irrelevant when no audit policy exists.
|
|
1132
|
+
return this.audit === undefined ? true : await this.audit.record(input);
|
|
1133
|
+
}
|
|
1134
|
+
/** Closes the attempt in the journal; the outcome is already final, so a failure is left to the reconciler. */
|
|
1135
|
+
async close(captured, result) {
|
|
1136
|
+
const summary = GovernedExecutionRunner.summaryOf(result);
|
|
1137
|
+
await this.appendQuietly({
|
|
1138
|
+
record: "ATTEMPT_CLOSED",
|
|
1139
|
+
at: new Date(this.clock()).toISOString(),
|
|
1140
|
+
intent_id: captured.intent.intentId,
|
|
1141
|
+
intent_hash: captured.intentHash,
|
|
1142
|
+
outcome: result.outcome,
|
|
1143
|
+
executed: summary.executed,
|
|
1144
|
+
finalization: summary.finalization,
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
async appendQuietly(record) {
|
|
1148
|
+
// Stryker disable next-line ConditionalExpression: without a journal the append below throws inside the try and is ignored; the guard only saves the attempt.
|
|
1149
|
+
if (this.journal === null)
|
|
1150
|
+
return;
|
|
1151
|
+
try {
|
|
1152
|
+
await this.journal.append(record);
|
|
1153
|
+
}
|
|
1154
|
+
catch {
|
|
1155
|
+
// An attempt that cannot be closed stays open, which the startup
|
|
1156
|
+
// reconciler resolves; it never changes the result already returned.
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
async emitAdmission(executor, request, stage, blocked, startedAt, disposition = null) {
|
|
1160
|
+
await this.emitLine(executor, null, {
|
|
1161
|
+
stage,
|
|
1162
|
+
captured: request.captured,
|
|
1163
|
+
decision: request.decision,
|
|
1164
|
+
...GovernedExecutionRunner.summaryOf(blocked),
|
|
1165
|
+
disposition: disposition ?? (request.decision.failClosed ? "NOT_FORWARDED" : null),
|
|
1166
|
+
startedAt,
|
|
1167
|
+
scope: request.scope ?? null,
|
|
1168
|
+
});
|
|
1169
|
+
}
|
|
1170
|
+
async emit(executor, context, stage, captured, decision, result, startedAt) {
|
|
1171
|
+
await this.emitLine(executor, context, {
|
|
1172
|
+
stage,
|
|
1173
|
+
captured,
|
|
1174
|
+
decision,
|
|
1175
|
+
...GovernedExecutionRunner.summaryOf(result),
|
|
1176
|
+
disposition: null,
|
|
1177
|
+
startedAt,
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
/**
|
|
1181
|
+
* What a step's result says, in the fields an evidence line and a closing
|
|
1182
|
+
* journal record carry: one case per outcome, so no outcome is described by
|
|
1183
|
+
* another's fields.
|
|
1184
|
+
*/
|
|
1185
|
+
static summaryOf(result) {
|
|
1186
|
+
switch (result.outcome) {
|
|
1187
|
+
case "CLAIMED":
|
|
1188
|
+
return {
|
|
1189
|
+
outcome: "CLAIMED",
|
|
1190
|
+
executed: null,
|
|
1191
|
+
reason: null,
|
|
1192
|
+
code: null,
|
|
1193
|
+
grantId: result.authorization.grantId,
|
|
1194
|
+
governed: true,
|
|
1195
|
+
finalization: null,
|
|
1196
|
+
};
|
|
1197
|
+
case "HELD":
|
|
1198
|
+
return {
|
|
1199
|
+
outcome: "HELD",
|
|
1200
|
+
executed: false,
|
|
1201
|
+
reason: null,
|
|
1202
|
+
code: null,
|
|
1203
|
+
grantId: null,
|
|
1204
|
+
governed: true,
|
|
1205
|
+
finalization: null,
|
|
1206
|
+
};
|
|
1207
|
+
case "BLOCKED":
|
|
1208
|
+
return {
|
|
1209
|
+
outcome: "BLOCKED",
|
|
1210
|
+
executed: false,
|
|
1211
|
+
reason: result.reason,
|
|
1212
|
+
code: result.code ?? null,
|
|
1213
|
+
grantId: null,
|
|
1214
|
+
governed: false,
|
|
1215
|
+
finalization: null,
|
|
1216
|
+
};
|
|
1217
|
+
case "FAILED_BEFORE_DISPATCH":
|
|
1218
|
+
return {
|
|
1219
|
+
outcome: "FAILED_BEFORE_DISPATCH",
|
|
1220
|
+
executed: false,
|
|
1221
|
+
reason: result.reason,
|
|
1222
|
+
code: result.code ?? null,
|
|
1223
|
+
grantId: result.authorization.grantId,
|
|
1224
|
+
governed: true,
|
|
1225
|
+
finalization: result.finalization,
|
|
1226
|
+
};
|
|
1227
|
+
case "COMPLETED":
|
|
1228
|
+
return {
|
|
1229
|
+
outcome: "COMPLETED",
|
|
1230
|
+
executed: true,
|
|
1231
|
+
reason: null,
|
|
1232
|
+
code: null,
|
|
1233
|
+
grantId: result.authorization.grantId,
|
|
1234
|
+
governed: true,
|
|
1235
|
+
finalization: "finalization" in result ? result.finalization : null,
|
|
1236
|
+
};
|
|
1237
|
+
case "DEFINITELY_NOT_EXECUTED":
|
|
1238
|
+
return {
|
|
1239
|
+
outcome: "DEFINITELY_NOT_EXECUTED",
|
|
1240
|
+
executed: false,
|
|
1241
|
+
reason: result.reason,
|
|
1242
|
+
code: null,
|
|
1243
|
+
grantId: result.authorization.grantId,
|
|
1244
|
+
governed: true,
|
|
1245
|
+
finalization: "finalization" in result ? result.finalization : null,
|
|
1246
|
+
};
|
|
1247
|
+
case "UNKNOWN_AFTER_DISPATCH":
|
|
1248
|
+
return {
|
|
1249
|
+
outcome: "UNKNOWN_AFTER_DISPATCH",
|
|
1250
|
+
executed: null,
|
|
1251
|
+
reason: result.reason,
|
|
1252
|
+
code: null,
|
|
1253
|
+
grantId: result.authorization.grantId,
|
|
1254
|
+
governed: true,
|
|
1255
|
+
finalization: "finalization" in result ? result.finalization : null,
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
async emitLine(executor, context, line) {
|
|
1260
|
+
const sink = this.evidence;
|
|
1261
|
+
// Stryker disable next-line ConditionalExpression: without a sink the line is built and the emit below throws inside the try; the guard only saves the work.
|
|
1262
|
+
if (sink === null)
|
|
1263
|
+
return;
|
|
1264
|
+
const decision = line.decision;
|
|
1265
|
+
const evidence = {
|
|
1266
|
+
version: EXECUTOR_EVIDENCE_VERSION,
|
|
1267
|
+
contract: NATIVE_EXECUTOR_CONTRACT,
|
|
1268
|
+
at: new Date(this.clock()).toISOString(),
|
|
1269
|
+
stage: line.stage,
|
|
1270
|
+
executor: { platform: executor.platform, version: executor.version, style: executor.style },
|
|
1271
|
+
action_type: line.captured.intent.action,
|
|
1272
|
+
intent_id: line.captured.intent.intentId,
|
|
1273
|
+
intent_hash: line.captured.intentHash,
|
|
1274
|
+
decision_id: decision?.decisionId ?? null,
|
|
1275
|
+
dossier_id: decision?.dossierId ?? null,
|
|
1276
|
+
verdict: decision?.verdict ?? null,
|
|
1277
|
+
grant_id: line.grantId,
|
|
1278
|
+
outcome: line.outcome,
|
|
1279
|
+
executed: line.executed,
|
|
1280
|
+
reason: line.reason,
|
|
1281
|
+
code: line.code,
|
|
1282
|
+
governed: line.governed,
|
|
1283
|
+
authority_state: decision?.failClosed === true ? "AUTHORITY_UNAVAILABLE" : "DECIDED",
|
|
1284
|
+
posture: this.posture.posture,
|
|
1285
|
+
disposition: line.disposition,
|
|
1286
|
+
finalization: line.finalization,
|
|
1287
|
+
duration_ms: Math.max(this.clock() - line.startedAt, 0),
|
|
1288
|
+
scope: evidenceScope(line.scope ?? context?.scope ?? null),
|
|
1289
|
+
};
|
|
1290
|
+
try {
|
|
1291
|
+
await sink.emit(Object.freeze(evidence));
|
|
1292
|
+
}
|
|
1293
|
+
catch {
|
|
1294
|
+
// Evidence is not authority: a sink that fails changes no outcome.
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
static recoveryReference(captured, authorization) {
|
|
1298
|
+
// Named field by field rather than spread: `agent-safe.recovery/1` is a
|
|
1299
|
+
// wire object a caller presents back, so a field added to the
|
|
1300
|
+
// authorization must not appear here by accident. The claim lease in
|
|
1301
|
+
// particular has no business being here — it is the window this attempt
|
|
1302
|
+
// had, and it has certainly closed by the time anyone reconciles.
|
|
1303
|
+
return Object.freeze({
|
|
1304
|
+
version: "agent-safe.recovery/1",
|
|
1305
|
+
decisionId: authorization.decisionId,
|
|
1306
|
+
dossierId: authorization.dossierId,
|
|
1307
|
+
grantId: authorization.grantId,
|
|
1308
|
+
intentHash: authorization.intentHash,
|
|
1309
|
+
expiresAt: authorization.expiresAt,
|
|
1310
|
+
idempotencyKey: captured.intent.idempotencyKey,
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
static sameBinding(recorded, recovery) {
|
|
1314
|
+
return (recorded.decisionId === recovery.decisionId &&
|
|
1315
|
+
recorded.dossierId === recovery.dossierId &&
|
|
1316
|
+
recorded.grantId === recovery.grantId &&
|
|
1317
|
+
recorded.intentHash === recovery.intentHash &&
|
|
1318
|
+
recorded.expiresAt === recovery.expiresAt);
|
|
1319
|
+
}
|
|
1320
|
+
static authorizationFrom(recovery) {
|
|
1321
|
+
return Object.freeze({
|
|
1322
|
+
decisionId: recovery.decisionId,
|
|
1323
|
+
dossierId: recovery.dossierId,
|
|
1324
|
+
grantId: recovery.grantId,
|
|
1325
|
+
intentHash: recovery.intentHash,
|
|
1326
|
+
expiresAt: recovery.expiresAt,
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
/** The journal, read as the attempts a recovery reference is held against. */
|
|
1330
|
+
static journalAttempts(journal) {
|
|
1331
|
+
if (journal === null)
|
|
1332
|
+
return null;
|
|
1333
|
+
return {
|
|
1334
|
+
authorizationsOf: async (captured) => await journal.authorizationsOf(captured.intent.intentId),
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1337
|
+
static journalCode(error) {
|
|
1338
|
+
return error instanceof JournalError ? error.code : "JOURNAL_WRITE_FAILED";
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
//# sourceMappingURL=GovernedExecutionRunner.js.map
|