@decentrys/agent 0.1.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/src/ledger.ts ADDED
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Cumulative spend and rate accounting.
3
+ *
4
+ * ## What this is, honestly
5
+ *
6
+ * **This ledger is in-process and in-memory.** It counts what one instance of
7
+ * one process admitted. It is not shared, not durable, and not correct across
8
+ * a restart or a horizontal scale-out: two replicas each enforcing a $10,000
9
+ * daily cap enforce $20,000 between them, and a restart resets the day to
10
+ * zero.
11
+ *
12
+ * That is stated rather than papered over, and the shape of the API is chosen
13
+ * so it can be fixed without a rewrite: `evaluatePolicy` depends only on the
14
+ * `AgentUsage` interface below, so a Redis- or Postgres-backed implementation
15
+ * of two methods replaces this class entirely. A distributed limit needs
16
+ * shared storage with atomic reservation, which is a later concern and a real
17
+ * one — do not deploy a fleet of agents behind this class and describe the cap
18
+ * as enforced.
19
+ *
20
+ * ## Why reservations, rather than counting on the way out
21
+ *
22
+ * An agent asks "may I", gets an allow, and then signs. If budget were only
23
+ * counted when the transaction is confirmed, an agent could ask a hundred
24
+ * times before the first answer was recorded and get a hundred allows against
25
+ * a cap of one. So an admitted decision holds its value immediately, and the
26
+ * hold is released when the caller says the action did not happen or when it
27
+ * expires unclaimed. The failure direction is a briefly over-tight budget,
28
+ * which is the right way for this to be wrong.
29
+ */
30
+
31
+ import type { AgentOutcomeRecord } from './model';
32
+
33
+ /**
34
+ * What `evaluatePolicy` needs to know about the past.
35
+ *
36
+ * Deliberately two read methods and nothing else. Anything satisfying this can
37
+ * be handed to the evaluator — including a shared store, which is how this
38
+ * becomes correct across processes.
39
+ */
40
+ export interface AgentUsage {
41
+ /** Value held or committed by this agent at or after `sinceMs`. */
42
+ spentUsdSince(agentId: string, sinceMs: number): number;
43
+ /** Decisions this guard admitted for the agent at or after `sinceMs`. */
44
+ actionsSince(agentId: string, sinceMs: number): number;
45
+ }
46
+
47
+ /**
48
+ * A usage source that can also hold budget.
49
+ *
50
+ * Kept separate from `AgentUsage` because the two are genuinely different
51
+ * capabilities: a read-only source (a warehouse query, a metrics store) can
52
+ * answer "how much has this agent spent today" without being able to take a
53
+ * hold. `AgentGuard` accepts either, and says which it got — a guard running
54
+ * on a read-only source enforces cumulative caps against *settled* history
55
+ * only, which cannot stop two concurrent actions that each fit alone.
56
+ */
57
+ export interface ReservationLedger extends AgentUsage {
58
+ reserve(input: {
59
+ decisionId: string;
60
+ agentId: string;
61
+ valueUsd: number;
62
+ idempotencyKey?: string;
63
+ now?: Date;
64
+ }): Reservation;
65
+ confirm(decisionId: string, options?: { txHash?: string; now?: Date }): AgentOutcomeRecord | null;
66
+ release(decisionId: string, options?: { note?: string; now?: Date }): AgentOutcomeRecord | null;
67
+ }
68
+
69
+ export function isReservationLedger(usage: AgentUsage): usage is ReservationLedger {
70
+ const candidate = usage as Partial<ReservationLedger>;
71
+ return typeof candidate.reserve === 'function'
72
+ && typeof candidate.confirm === 'function'
73
+ && typeof candidate.release === 'function';
74
+ }
75
+
76
+ export type ReservationState = 'held' | 'confirmed' | 'released' | 'expired';
77
+
78
+ export interface Reservation {
79
+ decisionId: string;
80
+ agentId: string;
81
+ valueUsd: number;
82
+ idempotencyKey?: string;
83
+ state: ReservationState;
84
+ createdAtMs: number;
85
+ expiresAtMs: number;
86
+ settledAtMs?: number;
87
+ txHash?: string;
88
+ note?: string;
89
+ }
90
+
91
+ export interface SpendLedgerOptions {
92
+ /**
93
+ * How long an unsettled hold survives. Default 5 minutes — long enough for a
94
+ * signature and a broadcast, short enough that an agent that crashed
95
+ * mid-action does not hold its budget until the window rolls over.
96
+ */
97
+ reservationTtlMs?: number;
98
+ /**
99
+ * How long settled entries are kept. Must exceed the longest policy window,
100
+ * or a cumulative cap silently stops being cumulative. Default 8 days.
101
+ */
102
+ retentionMs?: number;
103
+ /** A hard ceiling on entries, so a runaway agent cannot exhaust memory. */
104
+ maxEntries?: number;
105
+ /**
106
+ * The clock, injected for the same reason Protect injects `fetch`: expiry
107
+ * and retention are time-dependent behaviour, and behaviour that can only be
108
+ * exercised by waiting is behaviour that does not get tested.
109
+ */
110
+ clock?: () => number;
111
+ }
112
+
113
+ const DEFAULT_RESERVATION_TTL_MS = 5 * 60_000;
114
+ const DEFAULT_RETENTION_MS = 8 * 24 * 60 * 60_000;
115
+ const DEFAULT_MAX_ENTRIES = 50_000;
116
+
117
+ export class SpendLedger implements ReservationLedger {
118
+ private readonly reservationTtlMs: number;
119
+ private readonly retentionMs: number;
120
+ private readonly maxEntries: number;
121
+ private readonly clock: () => number;
122
+ private readonly entries: Reservation[] = [];
123
+ private readonly byDecision = new Map<string, Reservation>();
124
+ private readonly byIdempotencyKey = new Map<string, Reservation>();
125
+
126
+ constructor(options: SpendLedgerOptions = {}) {
127
+ this.reservationTtlMs = options.reservationTtlMs ?? DEFAULT_RESERVATION_TTL_MS;
128
+ this.retentionMs = options.retentionMs ?? DEFAULT_RETENTION_MS;
129
+ this.maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES;
130
+ this.clock = options.clock ?? (() => Date.now());
131
+ }
132
+
133
+ /**
134
+ * Hold budget for an admitted decision.
135
+ *
136
+ * A repeated `idempotencyKey` returns the existing hold instead of taking a
137
+ * second one. An agent retrying a call after a timeout is one action, and
138
+ * counting it twice would tighten its own cap against it for no reason.
139
+ */
140
+ reserve(input: {
141
+ decisionId: string;
142
+ agentId: string;
143
+ valueUsd: number;
144
+ idempotencyKey?: string;
145
+ now?: Date;
146
+ }): Reservation {
147
+ const nowMs = input.now ? input.now.getTime() : this.clock();
148
+ this.prune(nowMs);
149
+
150
+ if (input.idempotencyKey) {
151
+ const existing = this.byIdempotencyKey.get(this.keyFor(input.agentId, input.idempotencyKey));
152
+ if (existing && existing.state !== 'released' && existing.state !== 'expired') return existing;
153
+ }
154
+
155
+ const reservation: Reservation = {
156
+ decisionId: input.decisionId,
157
+ agentId: input.agentId,
158
+ // A negative value would be a credit against the agent's own cap, which
159
+ // is a way to spend more than the cap allows. Clamped, not trusted.
160
+ valueUsd: Number.isFinite(input.valueUsd) ? Math.max(0, input.valueUsd) : 0,
161
+ ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
162
+ state: 'held',
163
+ createdAtMs: nowMs,
164
+ expiresAtMs: nowMs + this.reservationTtlMs,
165
+ };
166
+
167
+ this.entries.push(reservation);
168
+ this.byDecision.set(reservation.decisionId, reservation);
169
+ if (input.idempotencyKey) {
170
+ this.byIdempotencyKey.set(this.keyFor(input.agentId, input.idempotencyKey), reservation);
171
+ }
172
+ this.enforceCeiling();
173
+ return reservation;
174
+ }
175
+
176
+ /** The action happened. The hold becomes a permanent commitment. */
177
+ confirm(decisionId: string, options: { txHash?: string; now?: Date } = {}): AgentOutcomeRecord | null {
178
+ return this.settle(decisionId, 'confirmed', {
179
+ ...(options.txHash === undefined ? {} : { txHash: options.txHash }),
180
+ ...(options.now === undefined ? {} : { now: options.now }),
181
+ });
182
+ }
183
+
184
+ /** The action did not happen. The hold is returned to the budget. */
185
+ release(decisionId: string, options: { note?: string; now?: Date } = {}): AgentOutcomeRecord | null {
186
+ return this.settle(decisionId, 'released', {
187
+ ...(options.note === undefined ? {} : { note: options.note }),
188
+ ...(options.now === undefined ? {} : { now: options.now }),
189
+ });
190
+ }
191
+
192
+ /**
193
+ * Value held or committed in the window.
194
+ *
195
+ * Released and expired holds do not count. They are actions that did not
196
+ * happen, and charging an agent for them would shrink a cap the operator
197
+ * set for real spending.
198
+ */
199
+ spentUsdSince(agentId: string, sinceMs: number): number {
200
+ this.prune(this.clock());
201
+ let total = 0;
202
+ for (const entry of this.entries) {
203
+ if (entry.agentId !== agentId) continue;
204
+ if (entry.state === 'released' || entry.state === 'expired') continue;
205
+ if (entry.createdAtMs < sinceMs) continue;
206
+ total += entry.valueUsd;
207
+ }
208
+ return total;
209
+ }
210
+
211
+ /**
212
+ * Decisions admitted in the window — regardless of how they settled.
213
+ *
214
+ * A rate ceiling counts what the guard admitted, not what landed on chain.
215
+ * An agent that spins through assess-and-abandon at machine speed is exactly
216
+ * the loop the ceiling exists to catch, and it would be invisible to a
217
+ * counter that only saw confirmed transactions.
218
+ */
219
+ actionsSince(agentId: string, sinceMs: number): number {
220
+ this.prune(this.clock());
221
+ let count = 0;
222
+ for (const entry of this.entries) {
223
+ if (entry.agentId !== agentId) continue;
224
+ if (entry.createdAtMs < sinceMs) continue;
225
+ count += 1;
226
+ }
227
+ return count;
228
+ }
229
+
230
+ reservation(decisionId: string): Reservation | undefined {
231
+ return this.byDecision.get(decisionId);
232
+ }
233
+
234
+ /** Snapshot for inspection. Copies, so a caller cannot edit the ledger. */
235
+ list(agentId?: string): Reservation[] {
236
+ return this.entries
237
+ .filter((entry) => !agentId || entry.agentId === agentId)
238
+ .map((entry) => ({ ...entry }));
239
+ }
240
+
241
+ clear(): void {
242
+ this.entries.length = 0;
243
+ this.byDecision.clear();
244
+ this.byIdempotencyKey.clear();
245
+ }
246
+
247
+ // -------------------------------------------------------------------------
248
+
249
+ private settle(
250
+ decisionId: string, state: 'confirmed' | 'released',
251
+ options: { txHash?: string; note?: string; now?: Date },
252
+ ): AgentOutcomeRecord | null {
253
+ const now = options.now ?? new Date(this.clock());
254
+ const entry = this.byDecision.get(decisionId);
255
+ if (!entry) return null;
256
+ // An expired hold that later confirms is a real and dangerous case: the
257
+ // budget was already returned, so the spend happened outside the cap. It
258
+ // is recorded as confirmed so the total is right, and the note says the
259
+ // hold had lapsed, because that is a tuning signal for the TTL.
260
+ const lapsed = entry.state === 'expired';
261
+ entry.state = state;
262
+ entry.settledAtMs = now.getTime();
263
+ if (options.txHash !== undefined) entry.txHash = options.txHash;
264
+ if (options.note !== undefined) entry.note = options.note;
265
+
266
+ return {
267
+ decisionId,
268
+ state,
269
+ ...(entry.txHash === undefined ? {} : { txHash: entry.txHash }),
270
+ ...(lapsed
271
+ ? { note: 'The hold had already expired when this was settled; the reservation TTL may be too short.' }
272
+ : entry.note === undefined ? {} : { note: entry.note }),
273
+ at: now.toISOString(),
274
+ };
275
+ }
276
+
277
+ private prune(nowMs: number): void {
278
+ for (const entry of this.entries) {
279
+ if (entry.state === 'held' && entry.expiresAtMs <= nowMs) entry.state = 'expired';
280
+ }
281
+ for (let i = this.entries.length - 1; i >= 0; i -= 1) {
282
+ const entry = this.entries[i]!;
283
+ if (nowMs - entry.createdAtMs <= this.retentionMs) continue;
284
+ this.entries.splice(i, 1);
285
+ this.byDecision.delete(entry.decisionId);
286
+ if (entry.idempotencyKey) this.byIdempotencyKey.delete(this.keyFor(entry.agentId, entry.idempotencyKey));
287
+ }
288
+ }
289
+
290
+ private enforceCeiling(): void {
291
+ while (this.entries.length > this.maxEntries) {
292
+ const entry = this.entries.shift();
293
+ if (!entry) break;
294
+ this.byDecision.delete(entry.decisionId);
295
+ if (entry.idempotencyKey) this.byIdempotencyKey.delete(this.keyFor(entry.agentId, entry.idempotencyKey));
296
+ }
297
+ }
298
+
299
+ /** Idempotency keys are the caller's, so they are namespaced per agent. */
300
+ private keyFor(agentId: string, idempotencyKey: string): string {
301
+ return `${agentId}${idempotencyKey}`;
302
+ }
303
+ }
package/src/model.ts ADDED
@@ -0,0 +1,324 @@
1
+ /**
2
+ * AgentGuard — the types.
3
+ *
4
+ * Protect informs a person, who then decides. AgentGuard sits in front of
5
+ * something with no judgement that will do exactly what it was told, at
6
+ * machine speed, as many times as its loop runs. Three consequences shape
7
+ * everything in this file:
8
+ *
9
+ * 1. **A warning is not a control.** Protect deliberately warns and never
10
+ * blocks, because the person holding the keys should decide. An agent
11
+ * cannot heed a warning — a warning it ignores is an allow with extra
12
+ * words. So AgentGuard's outcomes are `allow`, `require_human_approval`
13
+ * and `deny`, and the middle one means *stop and hand this to a person*.
14
+ *
15
+ * 2. **The operator is not the agent.** Limits are set by a human ahead of
16
+ * time. The agent must not be able to widen them — not by argument, not by
17
+ * mutating the object it was handed, not by passing an override. That is
18
+ * enforced structurally in `policy.ts` (`sealPolicy`, `narrowPolicy`),
19
+ * not by convention.
20
+ *
21
+ * 3. **Every decision must survive being questioned later.** "The agent spent
22
+ * the treasury" is answered by an audit record that names each rule, the
23
+ * limit it enforced and the value it observed. An allow that cannot be
24
+ * explained is as bad as a wrong one, so a passing rule is recorded just
25
+ * as loudly as a failing one.
26
+ *
27
+ * The risk model is *not* reimplemented here. Classification, the seven risk
28
+ * levels and `applyPolicy` come from `@decentrys/protect` unchanged; this
29
+ * package adds the limits an autonomous actor needs and a human does not.
30
+ */
31
+
32
+ import type { Assessment, Policy, RiskLevel } from '@decentrys/protect';
33
+
34
+ export const AGENT_MODEL_VERSION = 'agent-1.0.0';
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // What an agent proposes to do
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /**
41
+ * The kinds of action a policy can talk about.
42
+ *
43
+ * These are coarse on purpose. "May swap, may not approve" is a sentence an
44
+ * operator can write and check; "may call selector 0x095ea7b3" is not. An
45
+ * action whose type the integrator cannot determine is `unknown`, which is a
46
+ * first-class value rather than a default — see `deniedActions` below.
47
+ */
48
+ export const AGENT_ACTION_TYPES = [
49
+ 'transfer',
50
+ 'swap',
51
+ 'approve',
52
+ 'contract_call',
53
+ 'bridge',
54
+ 'stake',
55
+ 'unstake',
56
+ 'deploy',
57
+ 'sign_message',
58
+ 'unknown',
59
+ ] as const;
60
+ export type AgentActionType = (typeof AGENT_ACTION_TYPES)[number];
61
+
62
+ export interface AgentAction {
63
+ /** Which agent proposes it. Limits are held per agent, not per process. */
64
+ agentId: string;
65
+ chain: string;
66
+ type: AgentActionType;
67
+ /** The wallet the agent signs with. */
68
+ from: string;
69
+ /** Counterparty or contract. Absent for a deploy or a bare message signature. */
70
+ to?: string;
71
+ /** Token contract, for a transfer, swap or approval. */
72
+ token?: string;
73
+ /** Base units, as a string. Numbers lose precision at these magnitudes. */
74
+ value?: string;
75
+ /**
76
+ * The action's value in USD, as the integrator prices it.
77
+ *
78
+ * **`undefined` is not zero.** An action with no value at all (a message
79
+ * signature, a zero-value call) should say `0`; leaving this out means "I do
80
+ * not know what this is worth", and a value cap cannot be enforced against
81
+ * an unknown. See `VALUE_UNKNOWN` in `policy.ts` for what happens then, and
82
+ * why it is the only defensible answer for an autonomous signer.
83
+ *
84
+ * AgentGuard never prices anything itself. Pricing belongs to whoever runs
85
+ * the agent, with the feed and the staleness rules they are willing to be
86
+ * accountable for.
87
+ */
88
+ valueUsd?: number;
89
+ /** EVM calldata. */
90
+ data?: string;
91
+ /** Non-EVM families carry their serialized payload here. */
92
+ raw?: string;
93
+ /** The site or service that asked for this, when there is one. */
94
+ origin?: string;
95
+ /** Base units, or `unlimited`. Only meaningful for `type: 'approve'`. */
96
+ approvalAmount?: string;
97
+ /** What the agent believes it is doing, in its own words. Recorded, never trusted. */
98
+ intent?: string;
99
+ /**
100
+ * Set by the caller so a retried action is not counted twice against a
101
+ * cumulative cap. Two calls carrying the same key reuse one reservation.
102
+ */
103
+ idempotencyKey?: string;
104
+ proposedAt?: string;
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Policy
109
+ // ---------------------------------------------------------------------------
110
+
111
+ /** A cumulative limit: how much may be committed inside a rolling window. */
112
+ export interface SpendWindow {
113
+ /** A name an operator recognises in an audit record — 'daily', 'per-hour'. */
114
+ label: string;
115
+ windowMs: number;
116
+ maxValueUsd: number;
117
+ }
118
+
119
+ /** A rate ceiling: how many actions may be admitted inside a rolling window. */
120
+ export interface RateWindow {
121
+ label: string;
122
+ windowMs: number;
123
+ maxActions: number;
124
+ }
125
+
126
+ /**
127
+ * What an operator can express.
128
+ *
129
+ * Every field is optional and every omitted field means *unconstrained on that
130
+ * axis* — with one deliberate exception, `allowUnlimitedApprovals`, which
131
+ * defaults to refusing. An unlimited approval is the single action that turns
132
+ * a bounded mistake into an unbounded one, and defaulting it open would make
133
+ * the safe configuration the one you have to remember to write.
134
+ */
135
+ export interface AgentPolicy {
136
+ /** Free text for the operator's own change control. Recorded in every decision. */
137
+ version?: string;
138
+ /**
139
+ * Bind this policy to one agent. When set, an action from a different
140
+ * `agentId` is denied rather than evaluated — a policy leaking across agents
141
+ * is how one agent's generous limits become another's.
142
+ */
143
+ agentId?: string;
144
+
145
+ // --- risk, from @decentrys/protect ---------------------------------------
146
+
147
+ /**
148
+ * Risk level → action, exactly the `Policy` type Protect uses. Merged over
149
+ * `DEFAULT_AGENT_RISK_POLICY`; see that constant for why the agent default
150
+ * differs from the consumer one.
151
+ */
152
+ risk?: Policy;
153
+ /** Shorthand: deny anything strictly above this level. Applied on top of `risk`. */
154
+ maxRiskLevel?: RiskLevel;
155
+
156
+ // --- value ---------------------------------------------------------------
157
+
158
+ maxValuePerActionUsd?: number;
159
+ /** Rolling cumulative caps. Several may run at once — per-hour and per-day. */
160
+ spendWindows?: SpendWindow[];
161
+
162
+ // --- counterparties and contracts ----------------------------------------
163
+
164
+ /**
165
+ * When present, an allowlist: any counterparty not in it is denied. Absent
166
+ * means unconstrained, which is a real choice an operator may make and not
167
+ * one AgentGuard makes for them.
168
+ */
169
+ allowedCounterparties?: string[];
170
+ /** Always denied, even if also allowlisted. Denial wins. */
171
+ blockedCounterparties?: string[];
172
+ /** When present, an allowlist over the contract the action calls. */
173
+ allowedContracts?: string[];
174
+ /** When present, an allowlist over `token`. */
175
+ allowedTokens?: string[];
176
+
177
+ // --- what the agent may do -----------------------------------------------
178
+
179
+ allowedActions?: AgentActionType[];
180
+ deniedActions?: AgentActionType[];
181
+ allowedChains?: string[];
182
+ /** Default `false`. Set true only if an unbounded allowance is genuinely intended. */
183
+ allowUnlimitedApprovals?: boolean;
184
+
185
+ // --- rate ----------------------------------------------------------------
186
+
187
+ /** An agent stuck in a loop is a real failure mode, not a hypothetical one. */
188
+ rateWindows?: RateWindow[];
189
+
190
+ // --- escalation and availability -----------------------------------------
191
+
192
+ /** Above this value, a human decides even when every other rule passes. */
193
+ humanApprovalAboveUsd?: number;
194
+ /** At or above this level, a human decides even when the risk policy allows. */
195
+ humanApprovalAtRiskLevel?: RiskLevel;
196
+ /** What to do when Decentrys could not be reached. Default `escalate`. */
197
+ failMode?: AgentFailMode;
198
+ }
199
+
200
+ /**
201
+ * What AgentGuard does when the risk check could not run.
202
+ *
203
+ * Protect offers `open | warn | closed` and defaults to `warn`, which is right
204
+ * for a wallet: tell the person, let them decide. **`warn` is not offered
205
+ * here, because there is nobody to warn.** A warning delivered to an
206
+ * autonomous process is an allow that logs.
207
+ *
208
+ * - `open` — proceed on operator-set limits alone.
209
+ * - `escalate` — the local policy still applies and still binds; only the
210
+ * external risk check is missing, so a human decides. **Default.**
211
+ * - `closed` — stop.
212
+ *
213
+ * `escalate` is the default because it is the only one of the three that
214
+ * loses nothing. `open` signs unscreened transactions during exactly the
215
+ * window an attacker would choose to create. `closed` halts the agent
216
+ * completely, which for a liquidation or a market-making agent is its own
217
+ * loss and pushes operators to configure `open` to avoid it. `escalate`
218
+ * spends a human's attention instead of the treasury, and where an integrator
219
+ * has no approval path it degrades to a stop — the safe direction.
220
+ *
221
+ * Note that a local rule that *denies* still denies during an outage. An
222
+ * unreachable Decentrys removes one input; it does not suspend the operator's
223
+ * limits.
224
+ */
225
+ export type AgentFailMode = 'open' | 'escalate' | 'closed';
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // Decisions
229
+ // ---------------------------------------------------------------------------
230
+
231
+ /**
232
+ * Three outcomes, never two.
233
+ *
234
+ * Collapsing `require_human_approval` into `deny` loses the difference between
235
+ * "this must not happen" and "a person needs to look at this", and an operator
236
+ * who cannot see that difference in the log cannot tune the policy.
237
+ */
238
+ export type AgentVerdict = 'allow' | 'require_human_approval' | 'deny';
239
+
240
+ /** The rules AgentGuard can apply. Stable identifiers — they end up in logs. */
241
+ export const AGENT_RULES = [
242
+ 'AGENT_BINDING',
243
+ 'ACTION_TYPE',
244
+ 'CHAIN',
245
+ 'COUNTERPARTY',
246
+ 'CONTRACT',
247
+ 'TOKEN',
248
+ 'APPROVAL_ALLOWANCE',
249
+ 'VALUE_PER_ACTION',
250
+ 'CUMULATIVE_SPEND',
251
+ 'RATE_LIMIT',
252
+ 'RISK_LEVEL',
253
+ 'ASSESSMENT_AVAILABILITY',
254
+ 'HUMAN_APPROVAL_THRESHOLD',
255
+ ] as const;
256
+ export type AgentRuleId = (typeof AGENT_RULES)[number];
257
+
258
+ /**
259
+ * `not_configured` and `not_applicable` are distinct and both are recorded.
260
+ *
261
+ * "There was no counterparty allowlist" and "there was one and this
262
+ * counterparty was on it" produce the same allow and mean completely
263
+ * different things to whoever reads the record afterwards.
264
+ */
265
+ export type RuleOutcome = 'pass' | 'fail' | 'escalate' | 'not_applicable' | 'not_configured';
266
+
267
+ export interface RuleEvaluation {
268
+ rule: AgentRuleId;
269
+ outcome: RuleOutcome;
270
+ /** One sentence naming the limit and what was seen against it. */
271
+ statement: string;
272
+ /** The configured bound, when there was one. */
273
+ limit?: string | number;
274
+ /** What this action presented against that bound. */
275
+ observed?: string | number;
276
+ }
277
+
278
+ export interface AgentDecision {
279
+ /** Stable id for this decision, carried into the reservation and the log. */
280
+ decisionId: string;
281
+ agentId: string;
282
+ verdict: AgentVerdict;
283
+ /** The failing or escalating rules, in evaluation order. Empty on a clean allow. */
284
+ reasons: string[];
285
+ /**
286
+ * Every rule that was considered, passing ones included.
287
+ *
288
+ * This is what makes an *allow* explainable. A record that lists only what
289
+ * went wrong cannot answer "why was this permitted", which is the question
290
+ * asked after the money is gone.
291
+ */
292
+ evaluations: RuleEvaluation[];
293
+ /** The Protect assessment this decision used, when one was obtained. */
294
+ assessment?: Assessment;
295
+ /** Why no assessment was available, when none was. */
296
+ assessmentUnavailable?: string;
297
+ /** Fingerprint of the sealed policy in force, so the record is reproducible. */
298
+ policyFingerprint: string;
299
+ policyVersion?: string;
300
+ /**
301
+ * Present on `allow` and `require_human_approval`. Value and rate budget is
302
+ * held against this id until it is confirmed or released; see `ledger.ts`.
303
+ */
304
+ reservationId?: string;
305
+ /** A digest of the action, so a log entry can be tied to what was proposed. */
306
+ actionDigest: string;
307
+ modelVersion: string;
308
+ decidedAt: string;
309
+ }
310
+
311
+ /**
312
+ * What actually happened to a reserved action.
313
+ *
314
+ * Recorded separately from the decision, because "AgentGuard allowed it" and
315
+ * "the agent then executed it" are different facts and an audit trail that
316
+ * conflates them cannot show an allow that was never used.
317
+ */
318
+ export interface AgentOutcomeRecord {
319
+ decisionId: string;
320
+ state: 'confirmed' | 'released' | 'expired';
321
+ txHash?: string;
322
+ note?: string;
323
+ at: string;
324
+ }