@fungi.computer/approvals 0.0.0-stage → 0.0.2
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/LICENSE +21 -0
- package/README.md +179 -2
- package/dist/contract.d.ts +198 -0
- package/dist/contract.js +120 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/ledger.d.ts +203 -0
- package/dist/ledger.js +655 -0
- package/docs/api/README.md +1494 -0
- package/package.json +59 -3
package/dist/ledger.js
ADDED
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
import * as Schema from "effect/Schema";
|
|
2
|
+
import { ApprovalRecordSchema, ApprovalEvidenceSchema, ApprovalRefusalReasonSchema, ApprovalGetInputSchema, ApprovalListInputSchema, ApprovalAnswerInputSchema, AutoApproveInputSchema, } from "./contract.js";
|
|
3
|
+
/** Canonical zero-effect or lifecycle refusal at a tool result boundary. */
|
|
4
|
+
export const ApprovalRefusalSchema = Schema.Struct({
|
|
5
|
+
kind: Schema.Literal("refused"),
|
|
6
|
+
reason: ApprovalRefusalReasonSchema,
|
|
7
|
+
});
|
|
8
|
+
const defaultAutoApprove = {
|
|
9
|
+
team: false,
|
|
10
|
+
oyster: true,
|
|
11
|
+
local: true,
|
|
12
|
+
};
|
|
13
|
+
/** An unguessable recipient idempotency key; never part of any record view. */
|
|
14
|
+
function mintDeliveryId() {
|
|
15
|
+
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
|
16
|
+
return ("approval-result:" +
|
|
17
|
+
[...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""));
|
|
18
|
+
}
|
|
19
|
+
/** The initial owner-controlled wait duration; passing the deadline never approves. */
|
|
20
|
+
export const DEFAULT_APPROVAL_EXPIRY_MS = 24 * 60 * 60 * 1000;
|
|
21
|
+
const decodeRecord = Schema.decodeUnknownSync(Schema.fromJsonString(ApprovalRecordSchema), { onExcessProperty: "error" });
|
|
22
|
+
const encodeRecord = Schema.encodeSync(Schema.fromJsonString(ApprovalRecordSchema));
|
|
23
|
+
const decodeRow = Schema.decodeUnknownSync(Schema.Struct({ record_json: Schema.String }));
|
|
24
|
+
const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Json));
|
|
25
|
+
const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Json));
|
|
26
|
+
const decodePayload = Schema.decodeUnknownSync(Schema.Struct({
|
|
27
|
+
input_json: Schema.String,
|
|
28
|
+
output_json: Schema.NullOr(Schema.String),
|
|
29
|
+
}));
|
|
30
|
+
async function sha256(bytes) {
|
|
31
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
32
|
+
return [...new Uint8Array(digest)]
|
|
33
|
+
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
34
|
+
.join("");
|
|
35
|
+
}
|
|
36
|
+
/** SQLite lifecycle beside Mycelium. Native owner transactions serialize every transition. */
|
|
37
|
+
export class ApprovalLedger {
|
|
38
|
+
options;
|
|
39
|
+
homeKey;
|
|
40
|
+
constructor(options) {
|
|
41
|
+
this.options = options;
|
|
42
|
+
this.homeKey = JSON.stringify([options.home.kind, options.home.id]);
|
|
43
|
+
options.owner.sql.exec("CREATE TABLE IF NOT EXISTS approval_rules (home_key TEXT NOT NULL, rule_key TEXT NOT NULL, agent_id TEXT, module_id TEXT, access_class TEXT NOT NULL, enabled INTEGER NOT NULL, set_by TEXT NOT NULL, set_at INTEGER NOT NULL, PRIMARY KEY (home_key, rule_key))");
|
|
44
|
+
options.owner.sql.exec("CREATE TABLE IF NOT EXISTS approval_records (home_key TEXT NOT NULL, record_id TEXT NOT NULL, record_json TEXT NOT NULL, PRIMARY KEY (home_key, record_id))");
|
|
45
|
+
options.owner.sql.exec("CREATE INDEX IF NOT EXISTS approval_records_by_state ON approval_records (home_key, json_extract(record_json, '$.state'), record_id)");
|
|
46
|
+
options.owner.sql.exec("CREATE TABLE IF NOT EXISTS approval_payloads (home_key TEXT NOT NULL, record_id TEXT NOT NULL, input_json TEXT NOT NULL, output_json TEXT, PRIMARY KEY (home_key, record_id))");
|
|
47
|
+
options.owner.sql.exec("CREATE TABLE IF NOT EXISTS approval_handoffs (home_key TEXT NOT NULL, record_id TEXT NOT NULL, delivery_id TEXT NOT NULL, command_id TEXT, attempts INTEGER NOT NULL DEFAULT 0, next_attempt_at INTEGER, undeliverable_at INTEGER, PRIMARY KEY (home_key, record_id))");
|
|
48
|
+
options.owner.sql.exec("CREATE INDEX IF NOT EXISTS approval_handoffs_by_receipt ON approval_handoffs (home_key, command_id, record_id)");
|
|
49
|
+
}
|
|
50
|
+
/** Bound control surface; null identity means membership is gone, never an impersonated key. */
|
|
51
|
+
capability(host) {
|
|
52
|
+
const requireMember = (actor) => {
|
|
53
|
+
if (actor?.kind !== "human")
|
|
54
|
+
throw new Error("Approval read requires a current member");
|
|
55
|
+
};
|
|
56
|
+
const get = async (raw) => {
|
|
57
|
+
const { recordId } = Schema.decodeUnknownSync(ApprovalGetInputSchema, {
|
|
58
|
+
onExcessProperty: "error",
|
|
59
|
+
})(raw);
|
|
60
|
+
const record = this.get(recordId);
|
|
61
|
+
const actor = await host.actor(record);
|
|
62
|
+
requireMember(actor);
|
|
63
|
+
return record === null ? null : view(record, actor);
|
|
64
|
+
};
|
|
65
|
+
const ruleSetter = async (rule) => {
|
|
66
|
+
if (this.options.home.kind === "team")
|
|
67
|
+
return host.setter === undefined ? null : host.setter(rule);
|
|
68
|
+
return host.actor(null);
|
|
69
|
+
};
|
|
70
|
+
// Rights are recomputed for the holder: the narrow rule this record offers.
|
|
71
|
+
const view = async (record, actor) => {
|
|
72
|
+
const rule = {
|
|
73
|
+
scope: { agentId: record.requester.agentId },
|
|
74
|
+
family: { module: record.module, access: record.access },
|
|
75
|
+
enabled: true,
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
record,
|
|
79
|
+
canAnswer: record.state === "pending" && this.eligible(actor),
|
|
80
|
+
canSetAutoApprove: this.canManagePolicy(rule, await ruleSetter(rule)),
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
return {
|
|
84
|
+
get,
|
|
85
|
+
list: async (raw) => {
|
|
86
|
+
const input = Schema.decodeUnknownSync(ApprovalListInputSchema, {
|
|
87
|
+
onExcessProperty: "error",
|
|
88
|
+
})(raw);
|
|
89
|
+
requireMember(await host.actor(null));
|
|
90
|
+
const limit = input.limit ?? 100;
|
|
91
|
+
const rows = this.options.owner.sql
|
|
92
|
+
.exec("SELECT record_json FROM approval_records WHERE home_key = ? AND record_id > ? AND (? IS NULL OR CASE WHEN json_extract(record_json, '$.state') = 'pending' AND json_extract(record_json, '$.expiresAt') <= ? THEN 'expired' ELSE json_extract(record_json, '$.state') END = ?) ORDER BY record_id LIMIT ?", this.homeKey, input.cursor ?? "", input.state ?? null, this.options.now(), input.state ?? null, limit + 1)
|
|
93
|
+
.toArray()
|
|
94
|
+
.map((raw) => decodeRecord(decodeRow(raw).record_json));
|
|
95
|
+
const page = rows.slice(0, limit);
|
|
96
|
+
const records = page.flatMap((record) => {
|
|
97
|
+
const current = this.get(record.recordId);
|
|
98
|
+
return current === null ||
|
|
99
|
+
(input.state !== undefined && current.state !== input.state)
|
|
100
|
+
? []
|
|
101
|
+
: [current];
|
|
102
|
+
});
|
|
103
|
+
const items = await Promise.all(records.map(async (record) => {
|
|
104
|
+
const actor = await host.actor(record);
|
|
105
|
+
requireMember(actor);
|
|
106
|
+
return view(record, actor);
|
|
107
|
+
}));
|
|
108
|
+
const last = page.at(-1);
|
|
109
|
+
return {
|
|
110
|
+
items,
|
|
111
|
+
nextCursor: rows.length > limit && last !== undefined ? last.recordId : null,
|
|
112
|
+
};
|
|
113
|
+
},
|
|
114
|
+
answer: async (raw) => {
|
|
115
|
+
const input = Schema.decodeUnknownSync(ApprovalAnswerInputSchema, {
|
|
116
|
+
onExcessProperty: "error",
|
|
117
|
+
})(raw);
|
|
118
|
+
const result = this.answer(input, await host.actor(this.get(input.recordId)));
|
|
119
|
+
if (result.kind === "refused")
|
|
120
|
+
return result;
|
|
121
|
+
// The native home must persist/wake its own apply and message work.
|
|
122
|
+
// A missing or failed host callback is never treated as applied work.
|
|
123
|
+
await host.decision(result.record.recordId);
|
|
124
|
+
const view = await get({ recordId: result.record.recordId });
|
|
125
|
+
if (view === null)
|
|
126
|
+
throw new Error("Answered approval disappeared");
|
|
127
|
+
return { kind: "answered", view };
|
|
128
|
+
},
|
|
129
|
+
setAutoApprove: async (raw) => {
|
|
130
|
+
const rule = Schema.decodeUnknownSync(AutoApproveInputSchema, {
|
|
131
|
+
onExcessProperty: "error",
|
|
132
|
+
})(raw);
|
|
133
|
+
return this.setAutoApprove(rule, await ruleSetter(rule));
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
eligible(actor) {
|
|
138
|
+
return (actor?.kind === "human" &&
|
|
139
|
+
!actor.actingForAgent &&
|
|
140
|
+
actor.mayUseOperation &&
|
|
141
|
+
(this.options.home.kind !== "oyster" || actor.role === "owner"));
|
|
142
|
+
}
|
|
143
|
+
/** Normal tool admission returns pending immediately, or applies policy approval inline. */
|
|
144
|
+
async submit(input, host) {
|
|
145
|
+
const staged = await this.request(input, host.evidence);
|
|
146
|
+
if (staged.kind === "refused")
|
|
147
|
+
return staged;
|
|
148
|
+
const { record } = staged;
|
|
149
|
+
// Only this call's own policy approval applies inline. Every other
|
|
150
|
+
// approved record is applied by the home's work loop, never by a retry.
|
|
151
|
+
if (staged.created && record.state === "approved")
|
|
152
|
+
return this.applyInline(record.recordId, host);
|
|
153
|
+
return this.outcome(record);
|
|
154
|
+
}
|
|
155
|
+
/** A retried request's current outcome, without claiming any work. */
|
|
156
|
+
outcome(record) {
|
|
157
|
+
switch (record.state) {
|
|
158
|
+
case "requested":
|
|
159
|
+
case "pending":
|
|
160
|
+
case "approved":
|
|
161
|
+
case "claimed":
|
|
162
|
+
return {
|
|
163
|
+
kind: "pending_approval",
|
|
164
|
+
recordId: record.recordId,
|
|
165
|
+
description: record.description,
|
|
166
|
+
};
|
|
167
|
+
case "applied": {
|
|
168
|
+
// A human-approved result reaches the requester as its own message.
|
|
169
|
+
if (record.by?.kind !== "policy")
|
|
170
|
+
return { kind: "refused", reason: "already-answered" };
|
|
171
|
+
const output = this.payload(record.recordId).output_json;
|
|
172
|
+
if (output === null)
|
|
173
|
+
throw new Error("Applied approval has no recorded output");
|
|
174
|
+
return { kind: "applied", record, output: decodeJson(output) };
|
|
175
|
+
}
|
|
176
|
+
case "denied":
|
|
177
|
+
return { kind: "refused", reason: record.reason ?? "declined" };
|
|
178
|
+
case "expired":
|
|
179
|
+
return { kind: "refused", reason: "expired" };
|
|
180
|
+
case "outcome_unknown":
|
|
181
|
+
return { kind: "refused", reason: "outcome-unknown" };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Stage one stable intended effect. Duplicate identities never create
|
|
186
|
+
* another action; created says whether this call staged the record.
|
|
187
|
+
*/
|
|
188
|
+
async request(input, evidence) {
|
|
189
|
+
// Preserve the reviewed identity, authority and payload across the digest await.
|
|
190
|
+
const request = structuredClone(input);
|
|
191
|
+
const attachments = evidence === undefined
|
|
192
|
+
? {}
|
|
193
|
+
: {
|
|
194
|
+
evidence: Schema.decodeUnknownSync(ApprovalEvidenceSchema, {
|
|
195
|
+
onExcessProperty: "error",
|
|
196
|
+
})(structuredClone(evidence)),
|
|
197
|
+
};
|
|
198
|
+
decodeJson(request.inputJson);
|
|
199
|
+
const bytes = new TextEncoder().encode(request.inputJson);
|
|
200
|
+
const [recordId, inputSha256] = await Promise.all([
|
|
201
|
+
sha256(new TextEncoder().encode(JSON.stringify([
|
|
202
|
+
this.homeKey,
|
|
203
|
+
request.requester.principalId,
|
|
204
|
+
request.requester.sessionId,
|
|
205
|
+
request.module,
|
|
206
|
+
request.operation,
|
|
207
|
+
request.idempotencyKey,
|
|
208
|
+
]))),
|
|
209
|
+
sha256(bytes),
|
|
210
|
+
]);
|
|
211
|
+
return this.options.owner.transactionSync(() => {
|
|
212
|
+
const existing = this.get(recordId);
|
|
213
|
+
if (existing !== null) {
|
|
214
|
+
if (existing.description.inputSha256 !== inputSha256 ||
|
|
215
|
+
existing.authorityFence !== request.authorityFence ||
|
|
216
|
+
JSON.stringify(existing.evidence ?? []) !==
|
|
217
|
+
JSON.stringify(attachments.evidence ?? []))
|
|
218
|
+
return { kind: "refused", reason: "identity-conflict" };
|
|
219
|
+
return { kind: "record", record: existing, created: false };
|
|
220
|
+
}
|
|
221
|
+
const now = this.options.now();
|
|
222
|
+
const autoApproved = this.selectAutoApprove({
|
|
223
|
+
agentId: request.requester.agentId,
|
|
224
|
+
module: request.module,
|
|
225
|
+
access: request.access,
|
|
226
|
+
});
|
|
227
|
+
const record = Schema.decodeUnknownSync(ApprovalRecordSchema, {
|
|
228
|
+
onExcessProperty: "error",
|
|
229
|
+
})({
|
|
230
|
+
recordId,
|
|
231
|
+
requester: request.requester,
|
|
232
|
+
module: request.module,
|
|
233
|
+
operation: request.operation,
|
|
234
|
+
access: request.access,
|
|
235
|
+
description: {
|
|
236
|
+
title: (request.module + "." + request.operation).slice(0, 512),
|
|
237
|
+
inputBytes: bytes.length,
|
|
238
|
+
inputSha256,
|
|
239
|
+
},
|
|
240
|
+
...attachments,
|
|
241
|
+
authorityFence: request.authorityFence,
|
|
242
|
+
reversibility: request.reversibility,
|
|
243
|
+
state: autoApproved ? "approved" : "pending",
|
|
244
|
+
by: autoApproved ? { kind: "policy" } : null,
|
|
245
|
+
reason: null,
|
|
246
|
+
requestedAt: now,
|
|
247
|
+
expiresAt: now + DEFAULT_APPROVAL_EXPIRY_MS,
|
|
248
|
+
updatedAt: now,
|
|
249
|
+
revision: 1,
|
|
250
|
+
});
|
|
251
|
+
this.options.persistIntent?.(record);
|
|
252
|
+
this.options.owner.sql.exec("INSERT INTO approval_records (home_key, record_id, record_json) VALUES (?, ?, ?)", this.homeKey, recordId, encodeRecord(record));
|
|
253
|
+
this.options.owner.sql.exec("INSERT INTO approval_payloads (home_key, record_id, input_json) VALUES (?, ?, ?)", this.homeKey, recordId, request.inputJson);
|
|
254
|
+
// Only a waiting, host-attested Agent Session needs an ordinary later message.
|
|
255
|
+
// Policy-approved inline calls must not manufacture another Agent turn.
|
|
256
|
+
if (!autoApproved &&
|
|
257
|
+
record.requester.agentId !== null &&
|
|
258
|
+
record.requester.sessionId !== null)
|
|
259
|
+
this.options.owner.sql.exec("INSERT INTO approval_handoffs (home_key, record_id, delivery_id) VALUES (?, ?, ?)", this.homeKey, recordId, mintDeliveryId());
|
|
260
|
+
return { kind: "record", record, created: true };
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
/** Answering is revision-pinned and never changes the requester's underlying grants. */
|
|
264
|
+
answer(input, actor) {
|
|
265
|
+
if (!this.eligible(actor))
|
|
266
|
+
return { kind: "refused", reason: "not-eligible" };
|
|
267
|
+
return this.options.owner.transactionSync(() => {
|
|
268
|
+
const record = this.get(input.recordId);
|
|
269
|
+
if (record === null)
|
|
270
|
+
return { kind: "refused", reason: "not-found" };
|
|
271
|
+
if (record.state === "expired")
|
|
272
|
+
return { kind: "refused", reason: "expired" };
|
|
273
|
+
if (record.state !== "pending")
|
|
274
|
+
return { kind: "refused", reason: "already-answered" };
|
|
275
|
+
if (record.revision !== input.expectedRevision)
|
|
276
|
+
return { kind: "refused", reason: "stale-revision" };
|
|
277
|
+
const answered = {
|
|
278
|
+
...record,
|
|
279
|
+
state: input.decision === "approve" ? "approved" : "denied",
|
|
280
|
+
reason: input.decision === "approve" ? null : "declined",
|
|
281
|
+
by: { kind: "human", memberId: actor.memberId },
|
|
282
|
+
revision: record.revision + 1,
|
|
283
|
+
updatedAt: this.options.now(),
|
|
284
|
+
};
|
|
285
|
+
this.save(answered);
|
|
286
|
+
return { kind: "answered", record: answered };
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
/** Policy changes are attributed; an Agent can never enable its own writes. */
|
|
290
|
+
setAutoApprove(input, setter) {
|
|
291
|
+
const rule = Schema.decodeUnknownSync(AutoApproveInputSchema, {
|
|
292
|
+
onExcessProperty: "error",
|
|
293
|
+
})(input);
|
|
294
|
+
if (!this.canManagePolicy(rule, setter) || setter?.kind !== "human")
|
|
295
|
+
return { kind: "refused", reason: "not-eligible" };
|
|
296
|
+
this.options.owner.sql.exec("INSERT INTO approval_rules (home_key, rule_key, agent_id, module_id, access_class, enabled, set_by, set_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (home_key, rule_key) DO UPDATE SET enabled = excluded.enabled, set_by = excluded.set_by, set_at = excluded.set_at", this.homeKey, JSON.stringify([
|
|
297
|
+
rule.scope.agentId,
|
|
298
|
+
rule.family.module,
|
|
299
|
+
rule.family.access,
|
|
300
|
+
]), rule.scope.agentId, rule.family.module, rule.family.access, rule.enabled ? 1 : 0, setter.memberId, this.options.now());
|
|
301
|
+
return { kind: "updated", rule };
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Team: a rule for one Agent and one module needs live use of that family
|
|
305
|
+
* and agent.manage on that Agent; any wider rule is the owner's alone.
|
|
306
|
+
* Oyster and Local rules are owner-only.
|
|
307
|
+
*/
|
|
308
|
+
canManagePolicy(rule, setter) {
|
|
309
|
+
if (setter?.kind !== "human" || setter.actingForAgent)
|
|
310
|
+
return false;
|
|
311
|
+
if (this.options.home.kind !== "team")
|
|
312
|
+
return setter.role === "owner";
|
|
313
|
+
const narrow = rule.scope.agentId !== null && rule.family.module !== null;
|
|
314
|
+
return narrow
|
|
315
|
+
? setter.mayUseOperation &&
|
|
316
|
+
"mayManageAgent" in setter &&
|
|
317
|
+
setter.mayManageAgent
|
|
318
|
+
: setter.role === "owner";
|
|
319
|
+
}
|
|
320
|
+
/** Pure policy selection: no record, claim, dispatch, grant widening or containment choice. */
|
|
321
|
+
selectAutoApprove(query) {
|
|
322
|
+
// Agent-specific policy wins over the home; within a scope, a module narrows all modules.
|
|
323
|
+
const raw = this.options.owner.sql
|
|
324
|
+
.exec("SELECT enabled FROM approval_rules WHERE home_key = ? AND access_class = ? AND (agent_id IS NULL OR agent_id = ?) AND (module_id IS NULL OR module_id = ?) ORDER BY (agent_id IS NOT NULL) DESC, (module_id IS NOT NULL) DESC LIMIT 1", this.homeKey, query.access, query.agentId, query.module)
|
|
325
|
+
.toArray()[0];
|
|
326
|
+
if (raw === undefined)
|
|
327
|
+
return defaultAutoApprove[this.options.home.kind];
|
|
328
|
+
return (Schema.decodeUnknownSync(Schema.Struct({ enabled: Schema.Literals([0, 1]) }))(raw).enabled === 1);
|
|
329
|
+
}
|
|
330
|
+
/** Expire one bounded home-local quantum without a member read or an approval. */
|
|
331
|
+
expirePending() {
|
|
332
|
+
const decode = Schema.decodeUnknownSync(Schema.Struct({
|
|
333
|
+
record_id: ApprovalRecordSchema.fields.recordId,
|
|
334
|
+
}));
|
|
335
|
+
const rows = this.options.owner.sql
|
|
336
|
+
.exec("SELECT record_id FROM approval_records WHERE home_key = ? AND json_extract(record_json, '$.state') = 'pending' AND json_extract(record_json, '$.expiresAt') <= ? ORDER BY record_id LIMIT 100", this.homeKey, this.options.now())
|
|
337
|
+
.toArray();
|
|
338
|
+
for (const row of rows)
|
|
339
|
+
this.get(decode(row).record_id);
|
|
340
|
+
}
|
|
341
|
+
/** Earliest native wake for waiting or unfinished effects; no claim or dispatch. */
|
|
342
|
+
nextWakeAt() {
|
|
343
|
+
const row = this.options.owner.sql
|
|
344
|
+
.exec("SELECT MIN(CASE WHEN json_extract(record_json, '$.state') = 'pending' THEN json_extract(record_json, '$.expiresAt') ELSE ? END) AS wake_at FROM approval_records WHERE home_key = ? AND json_extract(record_json, '$.state') IN ('pending', 'approved', 'claimed')", this.options.now() + 250, this.homeKey)
|
|
345
|
+
.toArray()[0];
|
|
346
|
+
const decodeWake = Schema.decodeUnknownSync(Schema.Struct({
|
|
347
|
+
wake_at: Schema.NullOr(ApprovalRecordSchema.fields.expiresAt),
|
|
348
|
+
}));
|
|
349
|
+
const effects = decodeWake(row).wake_at;
|
|
350
|
+
if (this.options.resultDelivery === undefined)
|
|
351
|
+
return effects;
|
|
352
|
+
const now = this.options.now();
|
|
353
|
+
const results = decodeWake(this.options.owner.sql
|
|
354
|
+
.exec("SELECT MIN(COALESCE(h.next_attempt_at, ?)) AS wake_at FROM approval_handoffs h JOIN approval_records r ON r.home_key = h.home_key AND r.record_id = h.record_id WHERE h.home_key = ? AND h.command_id IS NULL AND h.undeliverable_at IS NULL AND (json_extract(r.record_json, '$.state') IN ('applied', 'denied', 'expired', 'outcome_unknown') OR (json_extract(r.record_json, '$.state') = 'pending' AND json_extract(r.record_json, '$.expiresAt') <= ?))", now, this.homeKey, now)
|
|
355
|
+
.toArray()[0]).wake_at;
|
|
356
|
+
return effects === null
|
|
357
|
+
? results
|
|
358
|
+
: results === null
|
|
359
|
+
? effects
|
|
360
|
+
: Math.min(effects, results);
|
|
361
|
+
}
|
|
362
|
+
/** Bounded approved work for the native owner; selection never claims or dispatches. */
|
|
363
|
+
pendingApplications() {
|
|
364
|
+
const decode = Schema.decodeUnknownSync(Schema.Struct({ record_id: ApprovalRecordSchema.fields.recordId }));
|
|
365
|
+
return this.options.owner.sql
|
|
366
|
+
.exec("SELECT record_id FROM approval_records WHERE home_key = ? AND json_extract(record_json, '$.state') = 'approved' ORDER BY record_id LIMIT 100", this.homeKey)
|
|
367
|
+
.toArray()
|
|
368
|
+
.map((row) => decode(row).record_id);
|
|
369
|
+
}
|
|
370
|
+
/** Bounded, home-local terminal notices for the native owner's work loop. */
|
|
371
|
+
pendingHandoffs() {
|
|
372
|
+
const decode = Schema.decodeUnknownSync(Schema.Struct({
|
|
373
|
+
record_id: ApprovalRecordSchema.fields.recordId,
|
|
374
|
+
}));
|
|
375
|
+
return this.options.owner.sql
|
|
376
|
+
.exec("SELECT h.record_id FROM approval_handoffs h JOIN approval_records r ON r.home_key = h.home_key AND r.record_id = h.record_id WHERE h.home_key = ? AND h.command_id IS NULL AND h.undeliverable_at IS NULL AND (h.next_attempt_at IS NULL OR h.next_attempt_at <= ?) AND (json_extract(r.record_json, '$.state') IN ('applied', 'denied', 'expired', 'outcome_unknown') OR (json_extract(r.record_json, '$.state') = 'pending' AND json_extract(r.record_json, '$.expiresAt') <= ?)) ORDER BY h.record_id LIMIT 100", this.homeKey, this.options.now(), this.options.now())
|
|
377
|
+
.toArray()
|
|
378
|
+
.map((row) => decode(row).record_id);
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Retry only the ordinary message, with the same recipient-side idempotency key.
|
|
382
|
+
* A send rejection leaves the intent pending; it never reopens effect execution.
|
|
383
|
+
* The host must return the real Session acceptance, not a success-shaped placeholder.
|
|
384
|
+
*/
|
|
385
|
+
async handoff(recordId, send) {
|
|
386
|
+
const record = this.get(recordId);
|
|
387
|
+
if (record === null)
|
|
388
|
+
return { kind: "refused", reason: "not-found" };
|
|
389
|
+
const receipt = () => {
|
|
390
|
+
const raw = this.options.owner.sql
|
|
391
|
+
.exec("SELECT command_id, delivery_id, attempts, undeliverable_at FROM approval_handoffs WHERE home_key = ? AND record_id = ?", this.homeKey, recordId)
|
|
392
|
+
.toArray()[0];
|
|
393
|
+
return raw === undefined
|
|
394
|
+
? null
|
|
395
|
+
: Schema.decodeUnknownSync(Schema.Struct({
|
|
396
|
+
command_id: Schema.NullOr(Schema.String),
|
|
397
|
+
delivery_id: Schema.String,
|
|
398
|
+
attempts: Schema.Int,
|
|
399
|
+
undeliverable_at: Schema.NullOr(Schema.Int),
|
|
400
|
+
}))(raw);
|
|
401
|
+
};
|
|
402
|
+
const previous = receipt();
|
|
403
|
+
if (previous === null)
|
|
404
|
+
return { kind: "not-required" };
|
|
405
|
+
if (previous.command_id !== null)
|
|
406
|
+
return { kind: "delivered", commandId: previous.command_id };
|
|
407
|
+
if (previous.undeliverable_at !== null)
|
|
408
|
+
return { kind: "undeliverable" };
|
|
409
|
+
let result;
|
|
410
|
+
switch (record.state) {
|
|
411
|
+
case "applied": {
|
|
412
|
+
const output = this.payload(recordId).output_json;
|
|
413
|
+
if (output === null)
|
|
414
|
+
throw new Error("Applied approval has no recorded output");
|
|
415
|
+
result = { kind: "applied", record, output: decodeJson(output) };
|
|
416
|
+
break;
|
|
417
|
+
}
|
|
418
|
+
case "denied":
|
|
419
|
+
result = { kind: "refused", reason: record.reason ?? "declined" };
|
|
420
|
+
break;
|
|
421
|
+
case "expired":
|
|
422
|
+
result = { kind: "refused", reason: "expired" };
|
|
423
|
+
break;
|
|
424
|
+
case "outcome_unknown":
|
|
425
|
+
result = { kind: "refused", reason: "outcome-unknown" };
|
|
426
|
+
break;
|
|
427
|
+
case "claimed":
|
|
428
|
+
return { kind: "refused", reason: "in-flight" };
|
|
429
|
+
case "requested":
|
|
430
|
+
case "pending":
|
|
431
|
+
case "approved":
|
|
432
|
+
return { kind: "refused", reason: "pending" };
|
|
433
|
+
}
|
|
434
|
+
let accepted;
|
|
435
|
+
try {
|
|
436
|
+
// The minted id is the recipient's idempotency key on every retry.
|
|
437
|
+
accepted = await send({
|
|
438
|
+
deliveryId: previous.delivery_id,
|
|
439
|
+
record,
|
|
440
|
+
result,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
catch (cause) {
|
|
444
|
+
const delivery = this.options.resultDelivery;
|
|
445
|
+
if (delivery !== undefined) {
|
|
446
|
+
const attempt = previous.attempts + 1;
|
|
447
|
+
// An absent or erased requester never accepts: stop, keep the record.
|
|
448
|
+
if (attempt >= delivery.attemptLimit) {
|
|
449
|
+
this.options.owner.sql.exec("UPDATE approval_handoffs SET attempts = ?, next_attempt_at = NULL, undeliverable_at = ? WHERE home_key = ? AND record_id = ? AND command_id IS NULL", attempt, this.options.now(), this.homeKey, recordId);
|
|
450
|
+
return { kind: "undeliverable" };
|
|
451
|
+
}
|
|
452
|
+
this.options.owner.sql.exec("UPDATE approval_handoffs SET attempts = ?, next_attempt_at = ? WHERE home_key = ? AND record_id = ? AND command_id IS NULL", attempt, this.options.now() + delivery.retryDelay(attempt), this.homeKey, recordId);
|
|
453
|
+
}
|
|
454
|
+
throw cause;
|
|
455
|
+
}
|
|
456
|
+
return this.options.owner.transactionSync(() => {
|
|
457
|
+
const current = receipt();
|
|
458
|
+
if (current === null)
|
|
459
|
+
throw new Error("Approval result delivery intent is missing");
|
|
460
|
+
if (current.command_id !== null &&
|
|
461
|
+
current.command_id !== accepted.commandId)
|
|
462
|
+
throw new Error("Approval result delivery identity conflicted");
|
|
463
|
+
this.options.owner.sql.exec("UPDATE approval_handoffs SET command_id = ? WHERE home_key = ? AND record_id = ? AND command_id IS NULL", accepted.commandId, this.homeKey, recordId);
|
|
464
|
+
return { kind: "delivered", commandId: accepted.commandId };
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* The home's work loop: only a durable approved record claims the existing
|
|
469
|
+
* host operation, and its result always reaches a waiting Agent as a message.
|
|
470
|
+
*/
|
|
471
|
+
apply(recordId, host) {
|
|
472
|
+
return this.run(recordId, host, true);
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Apply the caller's own just-staged policy approval inside its live tool
|
|
476
|
+
* call; that call's result is the outcome, so no later message is owed.
|
|
477
|
+
*/
|
|
478
|
+
applyInline(recordId, host) {
|
|
479
|
+
return this.run(recordId, host, false);
|
|
480
|
+
}
|
|
481
|
+
async run(recordId, host, deferred) {
|
|
482
|
+
const claim = this.options.owner.transactionSync(() => {
|
|
483
|
+
const record = this.get(recordId);
|
|
484
|
+
if (record === null)
|
|
485
|
+
return {
|
|
486
|
+
kind: "refused",
|
|
487
|
+
reason: "not-found",
|
|
488
|
+
};
|
|
489
|
+
if (record.state === "applied") {
|
|
490
|
+
const output = this.payload(recordId).output_json;
|
|
491
|
+
if (output === null)
|
|
492
|
+
throw new Error("Applied approval has no recorded output");
|
|
493
|
+
return {
|
|
494
|
+
kind: "applied",
|
|
495
|
+
record,
|
|
496
|
+
output: decodeJson(output),
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
if (record.state === "expired")
|
|
500
|
+
return { kind: "refused", reason: "expired" };
|
|
501
|
+
if (record.state === "denied")
|
|
502
|
+
return {
|
|
503
|
+
kind: "refused",
|
|
504
|
+
reason: record.reason ?? "declined",
|
|
505
|
+
};
|
|
506
|
+
if (record.state === "claimed")
|
|
507
|
+
return {
|
|
508
|
+
kind: "refused",
|
|
509
|
+
reason: "in-flight",
|
|
510
|
+
};
|
|
511
|
+
if (record.state === "outcome_unknown")
|
|
512
|
+
return {
|
|
513
|
+
kind: "refused",
|
|
514
|
+
reason: "outcome-unknown",
|
|
515
|
+
};
|
|
516
|
+
if (record.state !== "approved")
|
|
517
|
+
return { kind: "refused", reason: "pending" };
|
|
518
|
+
const input = decodeJson(this.payload(recordId).input_json);
|
|
519
|
+
const claimed = {
|
|
520
|
+
...record,
|
|
521
|
+
state: "claimed",
|
|
522
|
+
updatedAt: this.options.now(),
|
|
523
|
+
revision: record.revision + 1,
|
|
524
|
+
};
|
|
525
|
+
this.save(claimed);
|
|
526
|
+
// A deferred apply (even one policy approved) answers no live tool call.
|
|
527
|
+
if (deferred &&
|
|
528
|
+
claimed.requester.agentId !== null &&
|
|
529
|
+
claimed.requester.sessionId !== null)
|
|
530
|
+
this.options.owner.sql.exec("INSERT OR IGNORE INTO approval_handoffs (home_key, record_id, delivery_id) VALUES (?, ?, ?)", this.homeKey, recordId, mintDeliveryId());
|
|
531
|
+
return { kind: "claimed", record: claimed, input };
|
|
532
|
+
});
|
|
533
|
+
if (claim.kind !== "claimed")
|
|
534
|
+
return claim;
|
|
535
|
+
try {
|
|
536
|
+
const admission = await host.recheck(claim.record);
|
|
537
|
+
const refusal = admission.kind === "refused"
|
|
538
|
+
? admission.reason
|
|
539
|
+
: admission.authorityFence !== claim.record.authorityFence
|
|
540
|
+
? "fenced"
|
|
541
|
+
: null;
|
|
542
|
+
if (refusal !== null)
|
|
543
|
+
return this.finishDenied(claim.record, refusal);
|
|
544
|
+
const completion = await host.dispatch({
|
|
545
|
+
record: claim.record,
|
|
546
|
+
input: claim.input,
|
|
547
|
+
});
|
|
548
|
+
if (completion.kind === "refused")
|
|
549
|
+
return this.finishDenied(claim.record, completion.reason);
|
|
550
|
+
const output = completion.output;
|
|
551
|
+
const outputJson = encodeJson(output);
|
|
552
|
+
return this.options.owner.transactionSync(() => {
|
|
553
|
+
const current = this.get(recordId);
|
|
554
|
+
if (current?.state !== "claimed" ||
|
|
555
|
+
current.revision !== claim.record.revision)
|
|
556
|
+
return { kind: "refused", reason: "outcome-unknown" };
|
|
557
|
+
this.options.owner.sql.exec("UPDATE approval_payloads SET output_json = ? WHERE home_key = ? AND record_id = ?", outputJson, this.homeKey, recordId);
|
|
558
|
+
const applied = {
|
|
559
|
+
...current,
|
|
560
|
+
state: "applied",
|
|
561
|
+
updatedAt: this.options.now(),
|
|
562
|
+
revision: current.revision + 1,
|
|
563
|
+
};
|
|
564
|
+
this.save(applied);
|
|
565
|
+
return { kind: "applied", record: applied, output };
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
catch {
|
|
569
|
+
// A failed authority read or dispatch cannot authorize a retry of a durable claim.
|
|
570
|
+
return this.options.owner.transactionSync(() => {
|
|
571
|
+
const current = this.get(recordId);
|
|
572
|
+
if (current?.state === "claimed" &&
|
|
573
|
+
current.revision === claim.record.revision) {
|
|
574
|
+
this.save({
|
|
575
|
+
...current,
|
|
576
|
+
state: "outcome_unknown",
|
|
577
|
+
reason: "outcome-unknown",
|
|
578
|
+
updatedAt: this.options.now(),
|
|
579
|
+
revision: current.revision + 1,
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
return { kind: "refused", reason: "outcome-unknown" };
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
finishDenied(claimed, reason) {
|
|
587
|
+
return this.options.owner.transactionSync(() => {
|
|
588
|
+
const current = this.get(claimed.recordId);
|
|
589
|
+
if (current?.state !== "claimed" || current.revision !== claimed.revision)
|
|
590
|
+
return { kind: "refused", reason: "outcome-unknown" };
|
|
591
|
+
this.save({
|
|
592
|
+
...current,
|
|
593
|
+
state: "denied",
|
|
594
|
+
reason,
|
|
595
|
+
updatedAt: this.options.now(),
|
|
596
|
+
revision: current.revision + 1,
|
|
597
|
+
});
|
|
598
|
+
return { kind: "refused", reason };
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
/** Native host boot only: orphaned durable claims must never be redispatched. */
|
|
602
|
+
recoverClaims() {
|
|
603
|
+
return this.options.owner.transactionSync(() => {
|
|
604
|
+
const records = this.options.owner.sql
|
|
605
|
+
.exec("SELECT record_json FROM approval_records WHERE home_key = ? AND json_extract(record_json, '$.state') = 'claimed' ORDER BY record_id LIMIT 100", this.homeKey)
|
|
606
|
+
.toArray();
|
|
607
|
+
return records.map((raw) => {
|
|
608
|
+
const record = decodeRecord(decodeRow(raw).record_json);
|
|
609
|
+
const unknown = {
|
|
610
|
+
...record,
|
|
611
|
+
state: "outcome_unknown",
|
|
612
|
+
reason: "outcome-unknown",
|
|
613
|
+
updatedAt: this.options.now(),
|
|
614
|
+
revision: record.revision + 1,
|
|
615
|
+
};
|
|
616
|
+
this.save(unknown);
|
|
617
|
+
return unknown;
|
|
618
|
+
});
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
payload(recordId) {
|
|
622
|
+
const raw = this.options.owner.sql
|
|
623
|
+
.exec("SELECT input_json, output_json FROM approval_payloads WHERE home_key = ? AND record_id = ?", this.homeKey, recordId)
|
|
624
|
+
.toArray()[0];
|
|
625
|
+
if (raw === undefined)
|
|
626
|
+
throw new Error("Approval has no persisted operation payload");
|
|
627
|
+
return decodePayload(raw);
|
|
628
|
+
}
|
|
629
|
+
save(record) {
|
|
630
|
+
this.options.owner.sql.exec("UPDATE approval_records SET record_json = ? WHERE home_key = ? AND record_id = ?", encodeRecord(record), this.homeKey, record.recordId);
|
|
631
|
+
}
|
|
632
|
+
/** Read a record from this home only; the maintained decoder owns stored JSON. */
|
|
633
|
+
get(recordId) {
|
|
634
|
+
return this.options.owner.transactionSync(() => {
|
|
635
|
+
const raw = this.options.owner.sql
|
|
636
|
+
.exec("SELECT record_json FROM approval_records WHERE home_key = ? AND record_id = ?", this.homeKey, recordId)
|
|
637
|
+
.toArray()[0];
|
|
638
|
+
if (raw === undefined)
|
|
639
|
+
return null;
|
|
640
|
+
const record = decodeRecord(decodeRow(raw).record_json);
|
|
641
|
+
const now = this.options.now();
|
|
642
|
+
if (record.state !== "pending" || now < record.expiresAt)
|
|
643
|
+
return record;
|
|
644
|
+
const expired = {
|
|
645
|
+
...record,
|
|
646
|
+
state: "expired",
|
|
647
|
+
reason: "expired",
|
|
648
|
+
updatedAt: now,
|
|
649
|
+
revision: record.revision + 1,
|
|
650
|
+
};
|
|
651
|
+
this.save(expired);
|
|
652
|
+
return expired;
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
}
|