@dsh-enhanced/assistant-policy 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +136 -0
  3. package/cordis.patch.yml +13 -0
  4. package/lib/approval-reviewer.d.ts +39 -0
  5. package/lib/approval-reviewer.d.ts.map +1 -0
  6. package/lib/approval-reviewer.js +119 -0
  7. package/lib/approval-reviewer.js.map +1 -0
  8. package/lib/auto-review.d.ts +22 -0
  9. package/lib/auto-review.d.ts.map +1 -0
  10. package/lib/auto-review.js +442 -0
  11. package/lib/auto-review.js.map +1 -0
  12. package/lib/evaluator.d.ts +4 -0
  13. package/lib/evaluator.d.ts.map +1 -0
  14. package/lib/evaluator.js +127 -0
  15. package/lib/evaluator.js.map +1 -0
  16. package/lib/index.d.ts +20 -0
  17. package/lib/index.d.ts.map +1 -0
  18. package/lib/index.js +16 -0
  19. package/lib/index.js.map +1 -0
  20. package/lib/ledger.d.ts +253 -0
  21. package/lib/ledger.d.ts.map +1 -0
  22. package/lib/ledger.js +891 -0
  23. package/lib/ledger.js.map +1 -0
  24. package/lib/redaction.d.ts +2 -0
  25. package/lib/redaction.d.ts.map +1 -0
  26. package/lib/redaction.js +21 -0
  27. package/lib/redaction.js.map +1 -0
  28. package/lib/service.d.ts +124 -0
  29. package/lib/service.d.ts.map +1 -0
  30. package/lib/service.js +773 -0
  31. package/lib/service.js.map +1 -0
  32. package/lib/session-event-registration.d.ts +23 -0
  33. package/lib/session-event-registration.d.ts.map +1 -0
  34. package/lib/session-event-registration.js +384 -0
  35. package/lib/session-event-registration.js.map +1 -0
  36. package/lib/settlement.d.ts +30 -0
  37. package/lib/settlement.d.ts.map +1 -0
  38. package/lib/settlement.js +54 -0
  39. package/lib/settlement.js.map +1 -0
  40. package/lib/sqlite.d.ts +9 -0
  41. package/lib/sqlite.d.ts.map +1 -0
  42. package/lib/sqlite.js +276 -0
  43. package/lib/sqlite.js.map +1 -0
  44. package/lib/tool-risk.d.ts +10 -0
  45. package/lib/tool-risk.d.ts.map +1 -0
  46. package/lib/tool-risk.js +321 -0
  47. package/lib/tool-risk.js.map +1 -0
  48. package/lib/types.d.ts +73 -0
  49. package/lib/types.d.ts.map +1 -0
  50. package/lib/types.js +2 -0
  51. package/lib/types.js.map +1 -0
  52. package/lib/version.d.ts +2 -0
  53. package/lib/version.d.ts.map +1 -0
  54. package/lib/version.js +2 -0
  55. package/lib/version.js.map +1 -0
  56. package/package.json +98 -0
package/lib/ledger.js ADDED
@@ -0,0 +1,891 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { isAbsolute } from 'node:path';
3
+ import { redactAuditValue } from './redaction.js';
4
+ import { legacyApprovalIntentHash, openPolicyDatabase, PolicyDatabaseError } from './sqlite.js';
5
+ export class PolicyLedgerError extends Error {
6
+ code;
7
+ constructor(code, message) {
8
+ super(message);
9
+ this.code = code;
10
+ this.name = 'PolicyLedgerError';
11
+ }
12
+ }
13
+ /** Shared byte contract for Policy, Delivery, and channel approval rendering. */
14
+ export const APPROVAL_DISPLAY_BUDGET = Object.freeze({
15
+ maxTextBytes: 64 * 1_024,
16
+ maxSummaryBytes: 120,
17
+ renderingReserveBytes: 4 * 1_024,
18
+ maxDiffBytes: 60 * 1_024,
19
+ });
20
+ const textLimits = Object.freeze({
21
+ id: 512,
22
+ action: 256,
23
+ resourceId: 4_096,
24
+ workspace: 4_096,
25
+ summary: APPROVAL_DISPLAY_BUDGET.maxSummaryBytes,
26
+ diff: APPROVAL_DISPLAY_BUDGET.maxDiffBytes,
27
+ reason: 2_048,
28
+ });
29
+ function requireText(value, field) {
30
+ if (value.trim() === '')
31
+ throw new PolicyLedgerError('invalid-input', `${field} must not be empty`);
32
+ }
33
+ function requireBoundedText(value, field, maxBytes, options = {}) {
34
+ if (!options.allowEmpty)
35
+ requireText(value, field);
36
+ if (Buffer.byteLength(value, 'utf8') > maxBytes) {
37
+ throw new PolicyLedgerError('invalid-input', `${field} must not exceed ${maxBytes} UTF-8 bytes`);
38
+ }
39
+ }
40
+ function dispatchPayloadHash(input) {
41
+ const canonicalPayload = JSON.stringify([
42
+ input.proposalId,
43
+ input.route.sourceId,
44
+ input.route.bindingId,
45
+ input.route.workspace,
46
+ input.route.principal,
47
+ input.requester,
48
+ input.action,
49
+ input.resourceKind,
50
+ input.resourceId,
51
+ input.summary,
52
+ input.diff,
53
+ input.diffHash,
54
+ input.expiresAt,
55
+ input.proposalVersion,
56
+ ]);
57
+ return createHash('sha256').update(canonicalPayload).digest('hex');
58
+ }
59
+ /**
60
+ * Canonical identity of an abandoned recovery attempt. The raw diff participates
61
+ * in the digest but is never persisted in a tombstone.
62
+ */
63
+ function approvalRecoveryIntentHash(input) {
64
+ const dispatch = input.dispatch;
65
+ const canonicalIntent = JSON.stringify([
66
+ 'approval-recovery-intent-v1',
67
+ input.idempotencyKey,
68
+ input.notAfter,
69
+ input.requester,
70
+ input.principal,
71
+ input.action,
72
+ input.resource.kind,
73
+ input.resource.id,
74
+ input.diff,
75
+ input.summary,
76
+ dispatch === undefined ? 0 : 1,
77
+ dispatch?.sourceId ?? null,
78
+ dispatch?.bindingId ?? null,
79
+ dispatch?.workspace ?? null,
80
+ dispatch?.principal ?? null,
81
+ ]);
82
+ return createHash('sha256').update(canonicalIntent).digest('hex');
83
+ }
84
+ function requirePositive(value, field) {
85
+ if (!Number.isFinite(value) || value <= 0) {
86
+ throw new PolicyLedgerError('invalid-input', `${field} must be a positive finite number`);
87
+ }
88
+ }
89
+ function requirePeriod(value) {
90
+ if (!Number.isSafeInteger(value) || value <= 0) {
91
+ throw new PolicyLedgerError('invalid-input', 'periodMs must be a positive safe integer');
92
+ }
93
+ }
94
+ export class PolicyLedger {
95
+ #database;
96
+ #now;
97
+ #closed = false;
98
+ constructor(options) {
99
+ this.#now = options.now ?? Date.now;
100
+ try {
101
+ this.#database = openPolicyDatabase(options.path);
102
+ }
103
+ catch (error) {
104
+ if (error instanceof PolicyDatabaseError) {
105
+ throw new PolicyLedgerError(error.code, error.message);
106
+ }
107
+ throw error;
108
+ }
109
+ }
110
+ close() {
111
+ if (this.#closed)
112
+ return;
113
+ this.#closed = true;
114
+ this.#database.close();
115
+ }
116
+ #transaction(operation) {
117
+ this.#database.exec('BEGIN IMMEDIATE');
118
+ try {
119
+ const value = operation();
120
+ this.#database.exec('COMMIT');
121
+ return value;
122
+ }
123
+ catch (error) {
124
+ this.#database.exec('ROLLBACK');
125
+ throw error;
126
+ }
127
+ }
128
+ #period(row) {
129
+ const period = this.#database.prepare(`
130
+ SELECT limit_amount, reserved_amount, spent_amount
131
+ FROM budget_periods
132
+ WHERE scope = ? AND metric = ? AND period_start = ? AND period_ms = ?
133
+ `).get(row.scope, row.metric, row.period_start, row.period_ms);
134
+ if (period === undefined)
135
+ throw new PolicyLedgerError('invalid-state', 'reservation budget period is missing');
136
+ return period;
137
+ }
138
+ #result(row, replayed) {
139
+ const period = this.#period(row);
140
+ return {
141
+ reservationId: row.id,
142
+ status: row.status,
143
+ periodStart: row.period_start,
144
+ remaining: period.limit_amount - period.reserved_amount - period.spent_amount,
145
+ replayed,
146
+ };
147
+ }
148
+ reserve(input) {
149
+ requireText(input.scope, 'scope');
150
+ requireText(input.metric, 'metric');
151
+ requireText(input.idempotencyKey, 'idempotencyKey');
152
+ requirePositive(input.limit, 'limit');
153
+ requirePositive(input.amount, 'amount');
154
+ requirePeriod(input.periodMs);
155
+ const now = this.#now();
156
+ const periodStart = Math.floor(now / input.periodMs) * input.periodMs;
157
+ return this.#transaction(() => {
158
+ const existing = this.#database.prepare(`
159
+ SELECT id, idempotency_key, scope, metric, period_start, period_ms,
160
+ amount, actual_amount, status
161
+ FROM budget_reservations WHERE idempotency_key = ?
162
+ `).get(input.idempotencyKey);
163
+ if (existing !== undefined) {
164
+ const sameInput = existing.scope === input.scope
165
+ && existing.metric === input.metric
166
+ && existing.period_ms === input.periodMs
167
+ && existing.amount === input.amount;
168
+ if (!sameInput) {
169
+ throw new PolicyLedgerError('idempotency-conflict', 'idempotency key was used for another reservation');
170
+ }
171
+ return this.#result(existing, true);
172
+ }
173
+ this.#database.prepare(`
174
+ INSERT INTO budget_periods(
175
+ scope, metric, period_start, period_ms, limit_amount, reserved_amount, spent_amount
176
+ ) VALUES (?, ?, ?, ?, ?, 0, 0)
177
+ ON CONFLICT(scope, metric, period_start, period_ms) DO NOTHING
178
+ `).run(input.scope, input.metric, periodStart, input.periodMs, input.limit);
179
+ const period = this.#database.prepare(`
180
+ SELECT limit_amount, reserved_amount, spent_amount
181
+ FROM budget_periods
182
+ WHERE scope = ? AND metric = ? AND period_start = ? AND period_ms = ?
183
+ `).get(input.scope, input.metric, periodStart, input.periodMs);
184
+ if (period.limit_amount !== input.limit) {
185
+ throw new PolicyLedgerError('idempotency-conflict', 'budget limit changed within an active period');
186
+ }
187
+ if (period.reserved_amount + period.spent_amount + input.amount > period.limit_amount) {
188
+ throw new PolicyLedgerError('budget-exhausted', 'hard budget is exhausted');
189
+ }
190
+ const id = randomUUID();
191
+ this.#database.prepare(`
192
+ INSERT INTO budget_reservations(
193
+ id, idempotency_key, scope, metric, period_start, period_ms,
194
+ amount, actual_amount, status, created_at, updated_at
195
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 'reserved', ?, ?)
196
+ `).run(id, input.idempotencyKey, input.scope, input.metric, periodStart, input.periodMs, input.amount, now, now);
197
+ this.#database.prepare(`
198
+ UPDATE budget_periods
199
+ SET reserved_amount = reserved_amount + ?, version = version + 1
200
+ WHERE scope = ? AND metric = ? AND period_start = ? AND period_ms = ?
201
+ `).run(input.amount, input.scope, input.metric, periodStart, input.periodMs);
202
+ return this.#result({
203
+ id,
204
+ idempotency_key: input.idempotencyKey,
205
+ scope: input.scope,
206
+ metric: input.metric,
207
+ period_start: periodStart,
208
+ period_ms: input.periodMs,
209
+ amount: input.amount,
210
+ actual_amount: null,
211
+ status: 'reserved',
212
+ }, false);
213
+ });
214
+ }
215
+ finalize(reservationId, actualAmount) {
216
+ requireText(reservationId, 'reservationId');
217
+ if (!Number.isFinite(actualAmount) || actualAmount < 0) {
218
+ throw new PolicyLedgerError('invalid-input', 'actualAmount must be a non-negative finite number');
219
+ }
220
+ return this.#transaction(() => {
221
+ const row = this.#reservation(reservationId);
222
+ if (row.status === 'finalized') {
223
+ if (row.actual_amount !== actualAmount) {
224
+ throw new PolicyLedgerError('idempotency-conflict', 'reservation was finalized with another amount');
225
+ }
226
+ return this.#result(row, true);
227
+ }
228
+ if (row.status !== 'reserved') {
229
+ throw new PolicyLedgerError('invalid-state', `cannot finalize a ${row.status} reservation`);
230
+ }
231
+ if (actualAmount > row.amount) {
232
+ throw new PolicyLedgerError('invalid-input', 'actualAmount cannot exceed the reserved amount');
233
+ }
234
+ this.#database.prepare(`
235
+ UPDATE budget_periods
236
+ SET reserved_amount = reserved_amount - ?, spent_amount = spent_amount + ?, version = version + 1
237
+ WHERE scope = ? AND metric = ? AND period_start = ? AND period_ms = ?
238
+ `).run(row.amount, actualAmount, row.scope, row.metric, row.period_start, row.period_ms);
239
+ this.#database.prepare(`
240
+ UPDATE budget_reservations
241
+ SET status = 'finalized', actual_amount = ?, updated_at = ?, version = version + 1
242
+ WHERE id = ?
243
+ `).run(actualAmount, this.#now(), row.id);
244
+ return this.#result({ ...row, status: 'finalized', actual_amount: actualAmount }, false);
245
+ });
246
+ }
247
+ release(reservationId) {
248
+ requireText(reservationId, 'reservationId');
249
+ return this.#transaction(() => {
250
+ const row = this.#reservation(reservationId);
251
+ if (row.status === 'released')
252
+ return this.#result(row, true);
253
+ if (row.status !== 'reserved') {
254
+ throw new PolicyLedgerError('invalid-state', `cannot release a ${row.status} reservation`);
255
+ }
256
+ this.#database.prepare(`
257
+ UPDATE budget_periods
258
+ SET reserved_amount = reserved_amount - ?, version = version + 1
259
+ WHERE scope = ? AND metric = ? AND period_start = ? AND period_ms = ?
260
+ `).run(row.amount, row.scope, row.metric, row.period_start, row.period_ms);
261
+ this.#database.prepare(`
262
+ UPDATE budget_reservations
263
+ SET status = 'released', updated_at = ?, version = version + 1
264
+ WHERE id = ?
265
+ `).run(this.#now(), row.id);
266
+ return this.#result({ ...row, status: 'released' }, false);
267
+ });
268
+ }
269
+ #reservation(id) {
270
+ const row = this.#database.prepare(`
271
+ SELECT id, idempotency_key, scope, metric, period_start, period_ms,
272
+ amount, actual_amount, status
273
+ FROM budget_reservations WHERE id = ?
274
+ `).get(id);
275
+ if (row === undefined)
276
+ throw new PolicyLedgerError('not-found', 'budget reservation was not found');
277
+ return row;
278
+ }
279
+ getEmergencyStop() {
280
+ const row = this.#database.prepare(`
281
+ SELECT enabled, reason, actor, updated_at, version FROM emergency_state WHERE singleton = 1
282
+ `).get();
283
+ if (row === undefined) {
284
+ return { enabled: false, reason: undefined, actor: undefined, updatedAt: undefined, version: 0 };
285
+ }
286
+ return {
287
+ enabled: row.enabled === 1,
288
+ reason: row.reason,
289
+ actor: row.actor,
290
+ updatedAt: row.updated_at,
291
+ version: row.version,
292
+ };
293
+ }
294
+ setEmergencyStop(input) {
295
+ requireText(input.actor, 'actor');
296
+ requireText(input.reason, 'reason');
297
+ const now = this.#now();
298
+ this.#transaction(() => {
299
+ this.#database.prepare(`
300
+ INSERT INTO emergency_state(singleton, enabled, reason, actor, updated_at, version)
301
+ VALUES (1, ?, ?, ?, ?, 1)
302
+ ON CONFLICT(singleton) DO UPDATE SET
303
+ enabled = excluded.enabled,
304
+ reason = excluded.reason,
305
+ actor = excluded.actor,
306
+ updated_at = excluded.updated_at,
307
+ version = emergency_state.version + 1
308
+ `).run(input.enabled ? 1 : 0, input.reason, input.actor, now);
309
+ });
310
+ return this.getEmergencyStop();
311
+ }
312
+ #validateProposalContent(input) {
313
+ requireBoundedText(input.idempotencyKey, 'idempotencyKey', textLimits.id);
314
+ requireBoundedText(input.requester, 'requester', textLimits.id);
315
+ requireBoundedText(input.principal, 'principal', textLimits.id);
316
+ requireBoundedText(input.action, 'action', textLimits.action);
317
+ requireBoundedText(input.resource.kind, 'resource.kind', textLimits.action);
318
+ requireBoundedText(input.resource.id, 'resource.id', textLimits.resourceId);
319
+ requireBoundedText(input.summary, 'summary', textLimits.summary);
320
+ requireBoundedText(input.diff, 'diff', textLimits.diff, { allowEmpty: true });
321
+ if (input.dispatch !== undefined) {
322
+ requireBoundedText(input.dispatch.sourceId, 'dispatch.sourceId', textLimits.id);
323
+ requireBoundedText(input.dispatch.bindingId, 'dispatch.bindingId', textLimits.id);
324
+ requireBoundedText(input.dispatch.workspace, 'dispatch.workspace', textLimits.workspace);
325
+ requireBoundedText(input.dispatch.principal, 'dispatch.principal', textLimits.id);
326
+ if (!isAbsolute(input.dispatch.workspace)) {
327
+ throw new PolicyLedgerError('invalid-path', 'dispatch.workspace must be absolute');
328
+ }
329
+ if (input.dispatch.principal !== input.principal) {
330
+ throw new PolicyLedgerError('unauthorized', 'dispatch route is bound to another principal');
331
+ }
332
+ }
333
+ return createHash('sha256').update(input.diff).digest('hex');
334
+ }
335
+ #proposalByIdempotencyKey(idempotencyKey) {
336
+ return this.#database.prepare(`
337
+ SELECT id, idempotency_key, requester, principal, action, resource_kind,
338
+ resource_id, diff_hash, diff_text, summary, status, created_at, expires_at, decided_by,
339
+ decision_reason, version
340
+ FROM approval_proposals WHERE idempotency_key = ?
341
+ `).get(idempotencyKey);
342
+ }
343
+ #assertExistingProposalMatches(existing, input, diffHash, expiryMatches) {
344
+ const existingDispatch = this.#database.prepare(`
345
+ SELECT source_id, binding_id, workspace, principal, state, payload_hash, proposal_version
346
+ FROM approval_dispatches WHERE proposal_id = ?
347
+ `).get(existing.id);
348
+ const sameDispatch = input.dispatch === undefined
349
+ ? existingDispatch === undefined
350
+ : existingDispatch !== undefined
351
+ && existingDispatch.source_id === input.dispatch.sourceId
352
+ && existingDispatch.binding_id === input.dispatch.bindingId
353
+ && existingDispatch.workspace === input.dispatch.workspace
354
+ && existingDispatch.principal === input.dispatch.principal;
355
+ const sameDiff = input.dispatch === undefined
356
+ ? existingDispatch === undefined && existing.diff_hash === diffHash
357
+ : existingDispatch !== undefined
358
+ && existing.diff_hash === diffHash
359
+ && (existing.diff_text === null || existing.diff_text === input.diff)
360
+ && existingDispatch.payload_hash === dispatchPayloadHash({
361
+ proposalId: existing.id,
362
+ route: input.dispatch,
363
+ requester: input.requester,
364
+ action: input.action,
365
+ resourceKind: input.resource.kind,
366
+ resourceId: input.resource.id,
367
+ summary: input.summary,
368
+ diff: input.diff,
369
+ diffHash,
370
+ expiresAt: existing.expires_at,
371
+ proposalVersion: existingDispatch.proposal_version,
372
+ });
373
+ const sameInput = existing.requester === input.requester
374
+ && existing.principal === input.principal
375
+ && existing.action === input.action
376
+ && existing.resource_kind === input.resource.kind
377
+ && existing.resource_id === input.resource.id
378
+ && sameDiff
379
+ && existing.summary === input.summary
380
+ && expiryMatches
381
+ && sameDispatch;
382
+ if (!sameInput) {
383
+ throw new PolicyLedgerError('idempotency-conflict', 'idempotency key was used for another proposal');
384
+ }
385
+ }
386
+ #insertProposal(input, diffHash, createdAt, expiresAt) {
387
+ const id = randomUUID();
388
+ this.#database.prepare(`
389
+ INSERT INTO approval_proposals(
390
+ id, idempotency_key, requester, principal, action, resource_kind,
391
+ resource_id, diff_hash, diff_text, summary, status, created_at, expires_at
392
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)
393
+ `).run(id, input.idempotencyKey, input.requester, input.principal, input.action, input.resource.kind, input.resource.id, diffHash, input.dispatch === undefined ? null : input.diff, input.summary, createdAt, expiresAt);
394
+ if (input.dispatch !== undefined) {
395
+ const payloadHash = dispatchPayloadHash({
396
+ proposalId: id,
397
+ route: input.dispatch,
398
+ requester: input.requester,
399
+ action: input.action,
400
+ resourceKind: input.resource.kind,
401
+ resourceId: input.resource.id,
402
+ summary: input.summary,
403
+ diff: input.diff,
404
+ diffHash,
405
+ expiresAt,
406
+ proposalVersion: 1,
407
+ });
408
+ this.#database.prepare(`
409
+ INSERT INTO approval_dispatches(
410
+ proposal_id, source_id, binding_id, workspace, principal,
411
+ state, payload_hash, created_at, proposal_version
412
+ ) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, 1)
413
+ `).run(id, input.dispatch.sourceId, input.dispatch.bindingId, input.dispatch.workspace, input.dispatch.principal, payloadHash, createdAt);
414
+ }
415
+ return {
416
+ proposalId: id,
417
+ status: 'pending',
418
+ diffHash,
419
+ expiresAt,
420
+ version: 1,
421
+ replayed: false,
422
+ };
423
+ }
424
+ propose(input) {
425
+ const diffHash = this.#validateProposalContent(input);
426
+ requirePeriod(input.ttlMs);
427
+ const now = this.#now();
428
+ const expiresAt = now + input.ttlMs;
429
+ if (!Number.isSafeInteger(expiresAt)) {
430
+ throw new PolicyLedgerError('invalid-input', 'proposal expiry exceeds the safe timestamp range');
431
+ }
432
+ return this.#transaction(() => {
433
+ const tombstone = this.#database.prepare(`
434
+ SELECT idempotency_key FROM approval_idempotency_tombstones WHERE idempotency_key = ?
435
+ `).get(input.idempotencyKey);
436
+ if (tombstone !== undefined) {
437
+ throw new PolicyLedgerError('idempotency-conflict', 'proposal idempotency key was permanently abandoned');
438
+ }
439
+ const existing = this.#proposalByIdempotencyKey(input.idempotencyKey);
440
+ if (existing !== undefined) {
441
+ this.#assertExistingProposalMatches(existing, input, diffHash, existing.expires_at - existing.created_at === input.ttlMs);
442
+ return this.#proposalResult(existing, true);
443
+ }
444
+ return this.#insertProposal(input, diffHash, now, expiresAt);
445
+ });
446
+ }
447
+ /**
448
+ * Atomically recover an existing exact proposal, create it under an immutable
449
+ * absolute deadline, or permanently abandon the idempotency key after that
450
+ * deadline. `BEGIN IMMEDIATE` serializes this decision across Policy processes,
451
+ * closing the lookup-then-create orphan-card race.
452
+ */
453
+ recoverOrCreateProposal(input) {
454
+ const diffHash = this.#validateProposalContent(input);
455
+ if (!Number.isSafeInteger(input.notAfter) || input.notAfter < 0) {
456
+ throw new PolicyLedgerError('invalid-input', 'proposal notAfter must be a non-negative safe integer');
457
+ }
458
+ const intentHash = approvalRecoveryIntentHash(input);
459
+ return this.#transaction(() => {
460
+ const existing = this.#proposalByIdempotencyKey(input.idempotencyKey);
461
+ if (existing !== undefined) {
462
+ this.#assertExistingProposalMatches(existing, input, diffHash, existing.expires_at === input.notAfter);
463
+ return Object.freeze({
464
+ kind: 'proposal',
465
+ proposal: this.#proposalResult(existing, true),
466
+ });
467
+ }
468
+ const tombstone = this.#database.prepare(`
469
+ SELECT idempotency_key, not_after, abandoned_at, intent_hash
470
+ FROM approval_idempotency_tombstones WHERE idempotency_key = ?
471
+ `).get(input.idempotencyKey);
472
+ if (tombstone !== undefined) {
473
+ if (tombstone.intent_hash === legacyApprovalIntentHash || tombstone.intent_hash !== intentHash) {
474
+ throw new PolicyLedgerError('idempotency-conflict', 'idempotency key was abandoned for another or unverifiable proposal intent');
475
+ }
476
+ return Object.freeze({
477
+ kind: 'abandoned',
478
+ idempotencyKey: tombstone.idempotency_key,
479
+ notAfter: tombstone.not_after,
480
+ abandonedAt: tombstone.abandoned_at,
481
+ replayed: true,
482
+ });
483
+ }
484
+ // Sample only after BEGIN IMMEDIATE has acquired the write lock. A value
485
+ // read before lock acquisition could become stale while another Policy
486
+ // process holds the transaction across the absolute deadline.
487
+ const now = this.#now();
488
+ if (now >= input.notAfter) {
489
+ this.#database.prepare(`
490
+ INSERT INTO approval_idempotency_tombstones(
491
+ idempotency_key, not_after, abandoned_at, intent_hash
492
+ ) VALUES (?, ?, ?, ?)
493
+ `).run(input.idempotencyKey, input.notAfter, now, intentHash);
494
+ return Object.freeze({
495
+ kind: 'abandoned',
496
+ idempotencyKey: input.idempotencyKey,
497
+ notAfter: input.notAfter,
498
+ abandonedAt: now,
499
+ replayed: false,
500
+ });
501
+ }
502
+ return Object.freeze({
503
+ kind: 'proposal',
504
+ proposal: this.#insertProposal(input, diffHash, now, input.notAfter),
505
+ });
506
+ });
507
+ }
508
+ decideProposal(input) {
509
+ requireBoundedText(input.proposalId, 'proposalId', textLimits.id);
510
+ requireBoundedText(input.principal, 'principal', textLimits.id);
511
+ requireBoundedText(input.reason, 'reason', textLimits.reason);
512
+ if (!Number.isSafeInteger(input.expectedVersion) || input.expectedVersion <= 0) {
513
+ throw new PolicyLedgerError('invalid-input', 'expectedVersion must be a positive safe integer');
514
+ }
515
+ return this.#transaction(() => {
516
+ const proposal = this.#proposal(input.proposalId);
517
+ if (proposal.principal !== input.principal) {
518
+ throw new PolicyLedgerError('unauthorized', 'proposal is bound to another principal');
519
+ }
520
+ if (proposal.status === input.decision) {
521
+ if (proposal.decided_by !== input.principal || proposal.decision_reason !== input.reason) {
522
+ throw new PolicyLedgerError('idempotency-conflict', 'proposal already has a different decision');
523
+ }
524
+ return this.#proposalResult(proposal, true);
525
+ }
526
+ if (proposal.version !== input.expectedVersion) {
527
+ throw new PolicyLedgerError('version-conflict', 'proposal version changed');
528
+ }
529
+ if (proposal.status !== 'pending') {
530
+ throw new PolicyLedgerError('invalid-state', `proposal is already ${proposal.status}`);
531
+ }
532
+ const now = this.#now();
533
+ const status = now >= proposal.expires_at ? 'expired' : input.decision;
534
+ const decidedBy = status === 'expired' ? 'system:expiry' : input.principal;
535
+ const decisionReason = status === 'expired' ? 'expired' : input.reason;
536
+ this.#database.prepare(`
537
+ UPDATE approval_proposals
538
+ SET status = ?, decided_at = ?, decided_by = ?, decision_reason = ?,
539
+ diff_text = NULL, version = version + 1
540
+ WHERE id = ? AND version = ?
541
+ `).run(status, now, decidedBy, decisionReason, proposal.id, proposal.version);
542
+ return this.#proposalResult({
543
+ ...proposal,
544
+ status,
545
+ decided_by: decidedBy,
546
+ decision_reason: decisionReason,
547
+ version: proposal.version + 1,
548
+ }, false);
549
+ });
550
+ }
551
+ /**
552
+ * Read one proposal's current status without deciding it. A domain reconciler
553
+ * uses this to learn that its own pending operation was already approved or
554
+ * rejected elsewhere, then commits that outcome itself.
555
+ */
556
+ getProposal(proposalId) {
557
+ requireBoundedText(proposalId, 'proposalId', textLimits.id);
558
+ const row = this.#database.prepare(`
559
+ SELECT id, idempotency_key, requester, principal, action, resource_kind,
560
+ resource_id, diff_hash, diff_text, summary, status, created_at, expires_at, decided_by,
561
+ decision_reason, version
562
+ FROM approval_proposals WHERE id = ?
563
+ `).get(proposalId);
564
+ if (row === undefined)
565
+ return undefined;
566
+ return Object.freeze({
567
+ proposalId: row.id,
568
+ requester: row.requester,
569
+ principal: row.principal,
570
+ action: row.action,
571
+ resource: Object.freeze({ kind: row.resource_kind, id: row.resource_id }),
572
+ summary: row.summary,
573
+ status: row.status,
574
+ diffHash: row.diff_hash,
575
+ expiresAt: row.expires_at,
576
+ version: row.version,
577
+ decidedBy: row.decided_by ?? undefined,
578
+ decisionReason: row.decision_reason ?? undefined,
579
+ });
580
+ }
581
+ /**
582
+ * Recover an existing proposal by its immutable scoped idempotency identity.
583
+ * A mismatch is indistinguishable from absence; callers may subsequently try
584
+ * `propose`, whose normal idempotency conflict check remains fail-closed.
585
+ */
586
+ getProposalByIdempotencyKey(input) {
587
+ requireBoundedText(input.idempotencyKey, 'idempotencyKey', textLimits.id);
588
+ requireBoundedText(input.requester, 'requester', textLimits.id);
589
+ requireBoundedText(input.principal, 'principal', textLimits.id);
590
+ requireBoundedText(input.action, 'action', textLimits.action);
591
+ requireBoundedText(input.resource.kind, 'resource.kind', textLimits.action);
592
+ requireBoundedText(input.resource.id, 'resource.id', textLimits.resourceId);
593
+ const row = this.#database.prepare(`
594
+ SELECT id, idempotency_key, requester, principal, action, resource_kind,
595
+ resource_id, diff_hash, diff_text, summary, status, created_at, expires_at, decided_by,
596
+ decision_reason, version
597
+ FROM approval_proposals
598
+ WHERE idempotency_key = ? AND requester = ? AND principal = ? AND action = ?
599
+ AND resource_kind = ? AND resource_id = ?
600
+ `).get(input.idempotencyKey, input.requester, input.principal, input.action, input.resource.kind, input.resource.id);
601
+ if (row === undefined)
602
+ return undefined;
603
+ return Object.freeze({
604
+ proposalId: row.id,
605
+ requester: row.requester,
606
+ principal: row.principal,
607
+ action: row.action,
608
+ resource: Object.freeze({ kind: row.resource_kind, id: row.resource_id }),
609
+ summary: row.summary,
610
+ status: row.status,
611
+ diffHash: row.diff_hash,
612
+ expiresAt: row.expires_at,
613
+ version: row.version,
614
+ decidedBy: row.decided_by ?? undefined,
615
+ decisionReason: row.decision_reason ?? undefined,
616
+ });
617
+ }
618
+ listPendingApprovalDispatches(limit = 100, after) {
619
+ if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 100) {
620
+ throw new PolicyLedgerError('invalid-input', 'approval dispatch limit must be between 1 and 100');
621
+ }
622
+ if (after !== undefined) {
623
+ if (after === null || typeof after !== 'object' || Array.isArray(after)
624
+ || Object.keys(after).length !== 2
625
+ || !Object.hasOwn(after, 'createdAt') || !Object.hasOwn(after, 'proposalId')
626
+ || !Number.isSafeInteger(after.createdAt) || after.createdAt < 0
627
+ || typeof after.proposalId !== 'string') {
628
+ throw new PolicyLedgerError('invalid-input', 'approval dispatch cursor is invalid');
629
+ }
630
+ requireBoundedText(after.proposalId, 'approval dispatch cursor proposalId', textLimits.id);
631
+ }
632
+ const cursorCreatedAt = after?.createdAt ?? null;
633
+ const cursorProposalId = after?.proposalId ?? '';
634
+ const rows = this.#database.prepare(`
635
+ SELECT d.proposal_id, d.source_id, d.binding_id, d.workspace,
636
+ d.principal AS dispatch_principal, d.state, d.payload_hash,
637
+ d.created_at AS dispatch_created_at, d.enqueued_at,
638
+ d.version AS dispatch_version, d.proposal_version, p.requester,
639
+ p.principal AS proposal_principal, p.action, p.resource_kind,
640
+ p.resource_id, p.diff_hash, p.diff_text, p.summary, p.status,
641
+ p.expires_at, p.version AS current_proposal_version
642
+ FROM approval_dispatches d
643
+ JOIN approval_proposals p ON p.id = d.proposal_id
644
+ WHERE d.state = 'pending' AND p.status = 'pending' AND p.expires_at > ?
645
+ AND (? IS NULL OR d.created_at > ? OR (d.created_at = ? AND d.proposal_id > ?))
646
+ ORDER BY d.created_at ASC, d.proposal_id ASC
647
+ LIMIT ?
648
+ `).all(this.#now(), cursorCreatedAt, cursorCreatedAt, cursorCreatedAt, cursorProposalId, limit);
649
+ return rows.map(row => this.#dispatchSnapshot(row));
650
+ }
651
+ markApprovalDispatchEnqueued(proposalId, expectedVersion) {
652
+ requireBoundedText(proposalId, 'proposalId', textLimits.id);
653
+ if (!Number.isSafeInteger(expectedVersion) || expectedVersion <= 0) {
654
+ throw new PolicyLedgerError('invalid-input', 'expectedVersion must be a positive safe integer');
655
+ }
656
+ return this.#transaction(() => {
657
+ const dispatch = this.#dispatch(proposalId);
658
+ if (dispatch.state === 'enqueued') {
659
+ return this.#dispatchResult(dispatch, true);
660
+ }
661
+ if (dispatch.dispatch_version !== expectedVersion) {
662
+ throw new PolicyLedgerError('version-conflict', 'approval dispatch version changed');
663
+ }
664
+ const now = this.#now();
665
+ if (dispatch.status !== 'pending' || dispatch.expires_at <= now) {
666
+ throw new PolicyLedgerError('invalid-state', 'approval proposal is no longer pending');
667
+ }
668
+ this.#dispatchSnapshot(dispatch);
669
+ const result = this.#database.prepare(`
670
+ UPDATE approval_dispatches
671
+ SET state = 'enqueued', enqueued_at = ?, version = version + 1
672
+ WHERE proposal_id = ? AND state = 'pending' AND version = ?
673
+ `).run(now, proposalId, expectedVersion);
674
+ if (Number(result.changes) !== 1) {
675
+ throw new PolicyLedgerError('version-conflict', 'approval dispatch version changed');
676
+ }
677
+ const cleared = this.#database.prepare(`
678
+ UPDATE approval_proposals SET diff_text = NULL
679
+ WHERE id = ? AND status = 'pending' AND version = ? AND diff_text IS NOT NULL
680
+ `).run(proposalId, dispatch.proposal_version);
681
+ if (Number(cleared.changes) !== 1) {
682
+ throw new PolicyLedgerError('invalid-state', 'approval dispatch diff could not be cleared');
683
+ }
684
+ return this.#dispatchResult({
685
+ ...dispatch,
686
+ state: 'enqueued',
687
+ enqueued_at: now,
688
+ dispatch_version: dispatch.dispatch_version + 1,
689
+ diff_text: null,
690
+ }, false);
691
+ });
692
+ }
693
+ expireProposals(limit = 100) {
694
+ if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 1_000) {
695
+ throw new PolicyLedgerError('invalid-input', 'proposal expiry limit must be between 1 and 1000');
696
+ }
697
+ const now = this.#now();
698
+ return this.#transaction(() => {
699
+ const rows = this.#database.prepare(`
700
+ SELECT id FROM approval_proposals
701
+ WHERE status = 'pending' AND expires_at <= ?
702
+ ORDER BY expires_at ASC, id ASC
703
+ LIMIT ?
704
+ `).all(now, limit);
705
+ const expire = this.#database.prepare(`
706
+ UPDATE approval_proposals
707
+ SET status = 'expired', decided_at = ?, decided_by = 'system:expiry',
708
+ decision_reason = 'expired', diff_text = NULL, version = version + 1
709
+ WHERE id = ? AND status = 'pending'
710
+ `);
711
+ let changed = 0;
712
+ for (const row of rows)
713
+ changed += Number(expire.run(now, row.id).changes);
714
+ return changed;
715
+ });
716
+ }
717
+ #proposal(id) {
718
+ const proposal = this.#database.prepare(`
719
+ SELECT id, idempotency_key, requester, principal, action, resource_kind,
720
+ resource_id, diff_hash, diff_text, summary, status, created_at, expires_at, decided_by,
721
+ decision_reason, version
722
+ FROM approval_proposals WHERE id = ?
723
+ `).get(id);
724
+ if (proposal === undefined)
725
+ throw new PolicyLedgerError('not-found', 'approval proposal was not found');
726
+ return proposal;
727
+ }
728
+ #dispatch(proposalId) {
729
+ const dispatch = this.#database.prepare(`
730
+ SELECT d.proposal_id, d.source_id, d.binding_id, d.workspace,
731
+ d.principal AS dispatch_principal, d.state, d.payload_hash,
732
+ d.created_at AS dispatch_created_at, d.enqueued_at,
733
+ d.version AS dispatch_version, d.proposal_version, p.requester,
734
+ p.principal AS proposal_principal, p.action, p.resource_kind,
735
+ p.resource_id, p.diff_hash, p.diff_text, p.summary, p.status,
736
+ p.expires_at, p.version AS current_proposal_version
737
+ FROM approval_dispatches d
738
+ JOIN approval_proposals p ON p.id = d.proposal_id
739
+ WHERE d.proposal_id = ?
740
+ `).get(proposalId);
741
+ if (dispatch === undefined) {
742
+ throw new PolicyLedgerError('not-found', 'approval dispatch was not found');
743
+ }
744
+ return dispatch;
745
+ }
746
+ #dispatchSnapshot(row) {
747
+ if (row.diff_text === null) {
748
+ throw new PolicyLedgerError('invalid-state', 'approval dispatch is missing its immutable diff');
749
+ }
750
+ if (row.dispatch_principal !== row.proposal_principal) {
751
+ throw new PolicyLedgerError('invalid-state', 'approval dispatch principal does not match proposal');
752
+ }
753
+ if (row.current_proposal_version !== row.proposal_version) {
754
+ throw new PolicyLedgerError('invalid-state', 'approval dispatch proposal version changed');
755
+ }
756
+ const expectedPayloadHash = dispatchPayloadHash({
757
+ proposalId: row.proposal_id,
758
+ route: {
759
+ sourceId: row.source_id,
760
+ bindingId: row.binding_id,
761
+ workspace: row.workspace,
762
+ principal: row.dispatch_principal,
763
+ },
764
+ requester: row.requester,
765
+ action: row.action,
766
+ resourceKind: row.resource_kind,
767
+ resourceId: row.resource_id,
768
+ summary: row.summary,
769
+ diff: row.diff_text,
770
+ diffHash: row.diff_hash,
771
+ expiresAt: row.expires_at,
772
+ proposalVersion: row.proposal_version,
773
+ });
774
+ if (row.payload_hash !== expectedPayloadHash) {
775
+ throw new PolicyLedgerError('invalid-state', 'approval dispatch payload hash does not match proposal');
776
+ }
777
+ return Object.freeze({
778
+ proposalId: row.proposal_id,
779
+ sourceId: row.source_id,
780
+ bindingId: row.binding_id,
781
+ workspace: row.workspace,
782
+ principal: row.proposal_principal,
783
+ requester: row.requester,
784
+ action: row.action,
785
+ resource: Object.freeze({ kind: row.resource_kind, id: row.resource_id }),
786
+ summary: row.summary,
787
+ diff: row.diff_text,
788
+ diffHash: row.diff_hash,
789
+ payloadHash: row.payload_hash,
790
+ expiresAt: row.expires_at,
791
+ proposalVersion: row.proposal_version,
792
+ state: row.state,
793
+ createdAt: row.dispatch_created_at,
794
+ enqueuedAt: row.enqueued_at ?? undefined,
795
+ version: row.dispatch_version,
796
+ });
797
+ }
798
+ #dispatchResult(row, replayed) {
799
+ if (row.state !== 'enqueued' || row.enqueued_at === null) {
800
+ throw new PolicyLedgerError('invalid-state', 'approval dispatch is not enqueued');
801
+ }
802
+ return Object.freeze({
803
+ proposalId: row.proposal_id,
804
+ state: 'enqueued',
805
+ payloadHash: row.payload_hash,
806
+ enqueuedAt: row.enqueued_at,
807
+ version: row.dispatch_version,
808
+ replayed,
809
+ });
810
+ }
811
+ #proposalResult(proposal, replayed) {
812
+ return {
813
+ proposalId: proposal.id,
814
+ status: proposal.status,
815
+ diffHash: proposal.diff_hash,
816
+ expiresAt: proposal.expires_at,
817
+ version: proposal.version,
818
+ replayed,
819
+ };
820
+ }
821
+ appendAudit(input) {
822
+ requireText(input.actor, 'actor');
823
+ requireText(input.action, 'action');
824
+ requireText(input.resource.kind, 'resource.kind');
825
+ requireText(input.resource.id, 'resource.id');
826
+ requireText(input.outcome, 'outcome');
827
+ requireText(input.reasonCode, 'reasonCode');
828
+ const resourceHash = createHash('sha256').update(input.resource.id).digest('hex');
829
+ const details = redactAuditValue(input.details);
830
+ let detailsJson;
831
+ try {
832
+ detailsJson = JSON.stringify(details);
833
+ }
834
+ catch {
835
+ throw new PolicyLedgerError('invalid-input', 'audit details must be JSON serializable');
836
+ }
837
+ const eventId = randomUUID();
838
+ const occurredAt = this.#now();
839
+ const result = this.#database.prepare(`
840
+ INSERT INTO audit_events(
841
+ event_id, occurred_at, actor, action, resource_kind, resource_hash,
842
+ outcome, reason_code, details_json
843
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
844
+ `).run(eventId, occurredAt, input.actor, input.action, input.resource.kind, resourceHash, input.outcome, input.reasonCode, detailsJson);
845
+ return {
846
+ sequence: Number(result.lastInsertRowid),
847
+ eventId,
848
+ occurredAt,
849
+ actor: input.actor,
850
+ action: input.action,
851
+ resourceKind: input.resource.kind,
852
+ resourceHash,
853
+ outcome: input.outcome,
854
+ reasonCode: input.reasonCode,
855
+ details,
856
+ };
857
+ }
858
+ queryAudit(options = {}) {
859
+ const afterSequence = options.afterSequence ?? 0;
860
+ const limit = options.limit ?? 50;
861
+ if (!Number.isSafeInteger(afterSequence) || afterSequence < 0) {
862
+ throw new PolicyLedgerError('invalid-input', 'afterSequence must be a non-negative safe integer');
863
+ }
864
+ if (!Number.isSafeInteger(limit) || limit <= 0 || limit > 100) {
865
+ throw new PolicyLedgerError('invalid-input', 'audit limit must be between 1 and 100');
866
+ }
867
+ const rows = this.#database.prepare(`
868
+ SELECT sequence, event_id, occurred_at, actor, action, resource_kind,
869
+ resource_hash, outcome, reason_code, details_json
870
+ FROM audit_events WHERE sequence > ? ORDER BY sequence ASC LIMIT ?
871
+ `).all(afterSequence, limit);
872
+ return rows.map(row => ({
873
+ sequence: row.sequence,
874
+ eventId: row.event_id,
875
+ occurredAt: row.occurred_at,
876
+ actor: row.actor,
877
+ action: row.action,
878
+ resourceKind: row.resource_kind,
879
+ resourceHash: row.resource_hash,
880
+ outcome: row.outcome,
881
+ reasonCode: row.reason_code,
882
+ details: JSON.parse(row.details_json),
883
+ }));
884
+ }
885
+ health() {
886
+ const row = this.#database.prepare('SELECT COALESCE(MAX(sequence), 0) AS sequence FROM audit_events')
887
+ .get();
888
+ return { lastAuditSequence: row.sequence };
889
+ }
890
+ }
891
+ //# sourceMappingURL=ledger.js.map