@kyaki/agents 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.
Files changed (46) hide show
  1. package/dist/approvals.d.ts +35 -0
  2. package/dist/approvals.d.ts.map +1 -0
  3. package/dist/approvals.js +93 -0
  4. package/dist/approvals.js.map +1 -0
  5. package/dist/auditor.d.ts +104 -0
  6. package/dist/auditor.d.ts.map +1 -0
  7. package/dist/auditor.js +250 -0
  8. package/dist/auditor.js.map +1 -0
  9. package/dist/continuous-auditor.d.ts +38 -0
  10. package/dist/continuous-auditor.d.ts.map +1 -0
  11. package/dist/continuous-auditor.js +52 -0
  12. package/dist/continuous-auditor.js.map +1 -0
  13. package/dist/index.d.ts +20 -0
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +8 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/market.d.ts +10 -0
  18. package/dist/market.d.ts.map +1 -0
  19. package/dist/market.js +10 -0
  20. package/dist/market.js.map +1 -0
  21. package/dist/procurer.d.ts +54 -0
  22. package/dist/procurer.d.ts.map +1 -0
  23. package/dist/procurer.js +142 -0
  24. package/dist/procurer.js.map +1 -0
  25. package/dist/steward.d.ts +89 -0
  26. package/dist/steward.d.ts.map +1 -0
  27. package/dist/steward.js +121 -0
  28. package/dist/steward.js.map +1 -0
  29. package/dist/treasury.d.ts +134 -0
  30. package/dist/treasury.d.ts.map +1 -0
  31. package/dist/treasury.js +346 -0
  32. package/dist/treasury.js.map +1 -0
  33. package/dist/types.d.ts +47 -0
  34. package/dist/types.d.ts.map +1 -0
  35. package/dist/types.js +2 -0
  36. package/dist/types.js.map +1 -0
  37. package/package.json +34 -0
  38. package/src/approvals.ts +111 -0
  39. package/src/auditor.ts +342 -0
  40. package/src/continuous-auditor.ts +68 -0
  41. package/src/index.ts +31 -0
  42. package/src/market.ts +11 -0
  43. package/src/procurer.ts +183 -0
  44. package/src/steward.ts +168 -0
  45. package/src/treasury.ts +458 -0
  46. package/src/types.ts +57 -0
package/src/steward.ts ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * steward.ts — The Steward: KYA's fourth agent, and the only one that acts on
3
+ * the PRINCIPAL's key.
4
+ *
5
+ * Where the Procurer pushes money outward and the Treasurer moves the balance
6
+ * sheet (both under DELEGATED authority), and the Auditor merely observes, the
7
+ * Steward GRANTS and REVOKES authority itself. It operationalizes KYA's third
8
+ * pillar — "authorization, *revocation*, and audit" — the one with no agent
9
+ * until now. It spends nothing.
10
+ *
11
+ * Two motions:
12
+ * - issueFor(target) — compile a scoped mandate from org policy (L1
13
+ * `compileScope`) and sign it with the principal's key (L0 `issueMandate`),
14
+ * so delegations are born from policy-as-code, not by hand.
15
+ * - govern(report) — read the Auditor's verdict (`report.demoted`, the
16
+ * provably-misbehaving agents) and REVOKE their mandates in the kernel's
17
+ * live revocation registry. The next spend on a revoked mandate is denied
18
+ * by the kernel (`MANDATE_REVOKED`). The Auditor adjusts the autonomy TIER;
19
+ * the Steward terminates the AUTHORITY. Detect → respond, closed.
20
+ *
21
+ * Every issuance and revocation appends to the Steward's own hash-chained
22
+ * governance log, and `attest()` signs its head — so authority changes are
23
+ * themselves tamper-evident, exactly like a mandate, an intent, or an audit.
24
+ */
25
+ import {
26
+ canonicalBytes, fromBase58, issueMandate, publicKeyFromDid, sign, toBase58, verifySignature,
27
+ type AuditLog, type Identity, type RevocationStore, type SpendMandate,
28
+ } from '@kyaki/core';
29
+ import { compileScope, type SpendPolicy } from '@kyaki/policy';
30
+ import type { AuditReport } from './auditor.js';
31
+
32
+ const GENESIS = '0'.repeat(64);
33
+
34
+ export type AuthorityActionKind = 'issued' | 'revoked' | 'noop';
35
+
36
+ export interface AuthorityAction {
37
+ kind: AuthorityActionKind;
38
+ agentDid: string;
39
+ mandateId?: string;
40
+ reasons: string[];
41
+ /** Present on `issued` — the freshly signed delegation. */
42
+ mandate?: SpendMandate;
43
+ }
44
+
45
+ export interface GovernanceAttestation {
46
+ type: 'KyaGovernanceAttestation';
47
+ steward: string;
48
+ chainHead: string;
49
+ at: string;
50
+ signature: string;
51
+ }
52
+
53
+ /** Re-check a governance attestation offline — tampering with the chain head or
54
+ * the timestamp invalidates it, exactly like a mandate or an audit. */
55
+ export function verifyGovernanceAttestation(att: GovernanceAttestation): boolean {
56
+ try {
57
+ const { signature, ...doc } = att;
58
+ return verifySignature(fromBase58(signature), canonicalBytes(doc), publicKeyFromDid(att.steward));
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ export interface StewardConfig {
65
+ /** The principal's key — issues and revokes delegated authority. */
66
+ principal: Identity;
67
+ /** The Steward's own key — signs governance attestations. */
68
+ stewardKeys: Identity;
69
+ policy: SpendPolicy;
70
+ /** The kernel's LIVE revocation registry — must be the same instance the
71
+ * agents' VerificationContext consults, or revocation won't bite. */
72
+ revocations: RevocationStore;
73
+ /** The Steward's own lifecycle chain; every issue/revoke is anchored here. */
74
+ governance?: AuditLog;
75
+ now?: Date;
76
+ }
77
+
78
+ export class Steward {
79
+ private readonly byAgent = new Map<string, Set<string>>(); // agentDid -> mandateIds
80
+ private readonly owner = new Map<string, string>(); // mandateId -> agentDid
81
+
82
+ constructor(private readonly cfg: StewardConfig) {}
83
+
84
+ private now(): Date { return this.cfg.now ?? new Date(); }
85
+
86
+ /** Issue a policy-derived, principal-signed mandate for an agent/role. */
87
+ issueFor(target: { agentDid: string; role?: string }, opts: { validForSeconds?: number } = {}): AuthorityAction {
88
+ const compiled = compileScope(this.cfg.policy, {
89
+ agentDid: target.agentDid, ...(target.role ? { role: target.role } : {}),
90
+ });
91
+ const validForSeconds = opts.validForSeconds ?? compiled.validForSeconds;
92
+ const mandate = issueMandate({
93
+ principal: this.cfg.principal, agentDid: target.agentDid, scope: compiled.scope,
94
+ ...(this.cfg.now ? { validFrom: this.cfg.now } : {}),
95
+ ...(validForSeconds !== undefined ? { validForSeconds } : {}),
96
+ });
97
+ this.track(mandate);
98
+ this.cfg.governance?.append(
99
+ { type: 'authority.issued', mandateId: mandate.id, agentDid: target.agentDid, ruleId: compiled.ruleId },
100
+ this.now(),
101
+ );
102
+ return { kind: 'issued', agentDid: target.agentDid, mandateId: mandate.id, reasons: [compiled.ruleId], mandate };
103
+ }
104
+
105
+ /** Register an externally-issued mandate so the Steward can later revoke it. */
106
+ track(mandate: SpendMandate): void {
107
+ const agentDid = mandate.credentialSubject.id;
108
+ this.owner.set(mandate.id, agentDid);
109
+ const set = this.byAgent.get(agentDid) ?? new Set<string>();
110
+ set.add(mandate.id);
111
+ this.byAgent.set(agentDid, set);
112
+ }
113
+
114
+ mandatesOf(agentDid: string): string[] {
115
+ return [...(this.byAgent.get(agentDid) ?? [])];
116
+ }
117
+
118
+ isRevoked(mandateId: string): Promise<boolean> {
119
+ return this.cfg.revocations.isRevoked(mandateId);
120
+ }
121
+
122
+ /** Revoke a single mandate in the kernel's live registry, audited. */
123
+ async revoke(mandateId: string, reasons: string[]): Promise<AuthorityAction> {
124
+ const agentDid = this.owner.get(mandateId) ?? '—';
125
+ await this.cfg.revocations.revoke(mandateId);
126
+ this.cfg.governance?.append({ type: 'authority.revoked', mandateId, agentDid, reasons }, this.now());
127
+ return { kind: 'revoked', agentDid, mandateId, reasons };
128
+ }
129
+
130
+ /**
131
+ * Respond to an audit: revoke the mandates of every agent the Auditor
132
+ * provably caught (`report.demoted`). The Steward enacts the consequence the
133
+ * Auditor's demotion only signals. Idempotent — an already-revoked mandate is
134
+ * a noop, so re-running the same report never double-revokes.
135
+ */
136
+ async govern(report: Pick<AuditReport, 'demoted' | 'findings'>): Promise<AuthorityAction[]> {
137
+ const actions: AuthorityAction[] = [];
138
+ for (const agentDid of report.demoted) {
139
+ const reasons = report.findings.filter((f) => f.agentDid === agentDid).map((f) => f.code);
140
+ const mandateIds = this.mandatesOf(agentDid);
141
+ if (mandateIds.length === 0) {
142
+ actions.push({ kind: 'noop', agentDid, reasons: ['NO_TRACKED_MANDATE'] });
143
+ continue;
144
+ }
145
+ for (const mandateId of mandateIds) {
146
+ if (await this.cfg.revocations.isRevoked(mandateId)) {
147
+ actions.push({ kind: 'noop', agentDid, mandateId, reasons: ['ALREADY_REVOKED'] });
148
+ continue;
149
+ }
150
+ actions.push(await this.revoke(mandateId, reasons.length > 0 ? reasons : ['AGENT_DEMOTED']));
151
+ }
152
+ }
153
+ return actions;
154
+ }
155
+
156
+ /** Sign the governance chain head — the authority-change history, verifiable. */
157
+ attest(): GovernanceAttestation {
158
+ const entries = this.cfg.governance?.entries() ?? [];
159
+ const chainHead = entries.length > 0 ? entries[entries.length - 1]!.hash : GENESIS;
160
+ const doc = {
161
+ type: 'KyaGovernanceAttestation' as const,
162
+ steward: this.cfg.stewardKeys.did,
163
+ chainHead,
164
+ at: this.now().toISOString(),
165
+ };
166
+ return { ...doc, signature: toBase58(sign(canonicalBytes(doc), this.cfg.stewardKeys.privateKey)) };
167
+ }
168
+ }
@@ -0,0 +1,458 @@
1
+ /**
2
+ * treasury.ts — The Treasurer, KYA's second autonomous agent.
3
+ *
4
+ * Where the Procurer spends OUTWARD, the Treasurer manages the BALANCE SHEET
5
+ * and proves, movement by movement, that it stayed inside its mandate. A full
6
+ * cycle (runCycle) pays due obligations FIRST (due-date order), then sweeps
7
+ * idle cash above a configured buffer into reserve/yield.
8
+ *
9
+ * Reconstructed from the canonical Notion source and rebased onto the on-disk
10
+ * hardened seam: every movement runs through evaluateWithPolicy(), which
11
+ * atomically does kernel-verify -> escalation gate -> org-exposure check ->
12
+ * kernel COMMIT. Account balances mutate ONLY after a committed allow, so a
13
+ * denied or escalated movement can never leave a negative balance or a phantom
14
+ * debit.
15
+ *
16
+ * - Escalated movements park in a TreasuryApprovalInbox that uses the same
17
+ * crash-safe two-phase exactly-once protocol as the Procurer:
18
+ * approved -> claimExecution() -> 'executing' -> confirm / revert / fail.
19
+ * A signed CFO Approval is required to finalize; the agent cannot approve
20
+ * its own movement.
21
+ * - The FX-funding leg is denominated in a currency the org actually HOLDS,
22
+ * so L0/L1 always evaluate a real in-mandate-currency amount rather than
23
+ * tripping a single-currency mandate.
24
+ */
25
+ import { randomUUID } from 'node:crypto';
26
+ import {
27
+ createTransactionIntent,
28
+ type AgentKeys,
29
+ type AuditLog,
30
+ type Identity,
31
+ type SpendMandate,
32
+ type TransactionIntent,
33
+ type VerificationContext,
34
+ } from '@kyaki/core';
35
+ import {
36
+ evaluateWithPolicy,
37
+ isSecurityAnomaly,
38
+ type AutonomyLadder,
39
+ type OrgSpendLedger,
40
+ type SpendPolicy,
41
+ } from '@kyaki/policy';
42
+ import { signApproval, verifyApproval, type SignedApproval } from './approvals.js';
43
+
44
+ // ── Domain model ──────────────────────────────────────────────────────────
45
+
46
+ export type AccountType = 'operating' | 'reserve' | 'yield';
47
+
48
+ export interface Account {
49
+ id: string;
50
+ currency: string;
51
+ balance: number;
52
+ type: AccountType;
53
+ /** Operating accounts keep at least this much; the excess is sweepable. */
54
+ minBuffer?: number;
55
+ }
56
+
57
+ export interface Obligation {
58
+ id: string;
59
+ payee: string;
60
+ category: string;
61
+ amount: number;
62
+ currency: string;
63
+ /** Epoch ms. Funded only once `dueAt <= now`. */
64
+ dueAt: number;
65
+ }
66
+
67
+ export interface FXProvider {
68
+ /** Units of `to` per one unit of `from`. */
69
+ rate(from: string, to: string): number;
70
+ }
71
+
72
+ /** A deterministic FX desk for demos and tests. */
73
+ export class StaticFX implements FXProvider {
74
+ constructor(private readonly table: Record<string, number>) {}
75
+ rate(from: string, to: string): number {
76
+ if (from === to) return 1;
77
+ const direct = this.table[`${from}/${to}`];
78
+ if (direct !== undefined) return direct;
79
+ const inverse = this.table[`${to}/${from}`];
80
+ if (inverse !== undefined && inverse !== 0) return 1 / inverse;
81
+ throw new Error(`NO_FX_RATE: ${from}->${to}`);
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Amounts are integer minor units; FX products carry IEEE-754 noise
87
+ * (100000 * 1.1 === 110000.00000000001). Snap to whole minor units before
88
+ * rounding so the kernel always evaluates a clean integer.
89
+ */
90
+ function roundMinorUnits(x: number): number {
91
+ return Math.round(x);
92
+ }
93
+ function ceilMinorUnits(x: number): number {
94
+ // Strip float noise to 6 decimals, then round up to the next whole unit.
95
+ return Math.ceil(Math.round(x * 1e6) / 1e6);
96
+ }
97
+
98
+ export type ActionKind = 'obligation_payment' | 'fx_conversion' | 'liquidity_sweep';
99
+ export type ActionStatus = 'committed' | 'pending_approval' | 'denied' | 'skipped';
100
+
101
+ export interface TreasuryAction {
102
+ kind: ActionKind;
103
+ status: ActionStatus;
104
+ accountId: string;
105
+ payee: string;
106
+ category: string;
107
+ /** Amount actually moved, in the SOURCE account's (held) currency. */
108
+ amount: number;
109
+ currency: string;
110
+ obligationId?: string;
111
+ intentId?: string;
112
+ approvalId?: string;
113
+ reasons?: string[];
114
+ }
115
+
116
+ export interface CashPosition {
117
+ baseCurrency: string;
118
+ byCurrency: Record<string, number>;
119
+ totalInBase: number;
120
+ nearTermObligationsInBase: number;
121
+ netLiquidityInBase: number;
122
+ }
123
+
124
+ // ── Two-phase escalation inbox (mirrors the Procurer's exactly-once protocol) ─
125
+
126
+ export type MovementStatus =
127
+ | 'pending' | 'approved' | 'executing' | 'executed' | 'rejected' | 'failed';
128
+
129
+ export interface PendingMovement {
130
+ id: string;
131
+ intent: TransactionIntent;
132
+ mandate: SpendMandate;
133
+ kind: ActionKind;
134
+ accountId: string;
135
+ payee: string;
136
+ category: string;
137
+ amount: number;
138
+ currency: string;
139
+ obligationId?: string;
140
+ reason: string;
141
+ submittedAt: string;
142
+ status: MovementStatus;
143
+ decidedBy?: string;
144
+ decidedAt?: string;
145
+ failureReasons?: string[];
146
+ }
147
+
148
+ export class TreasuryApprovalInbox {
149
+ private movements = new Map<string, PendingMovement>();
150
+
151
+ submit(input: Omit<PendingMovement, 'id' | 'submittedAt' | 'status'>): PendingMovement {
152
+ const movement: PendingMovement = {
153
+ ...input,
154
+ id: `mov_${randomUUID().slice(0, 8)}`,
155
+ submittedAt: new Date().toISOString(),
156
+ status: 'pending',
157
+ };
158
+ this.movements.set(movement.id, movement);
159
+ return movement;
160
+ }
161
+
162
+ get(id: string): PendingMovement | undefined { return this.movements.get(id); }
163
+ pending(): PendingMovement[] { return [...this.movements.values()].filter((m) => m.status === 'pending'); }
164
+ all(): PendingMovement[] { return [...this.movements.values()]; }
165
+
166
+ /** The CFO countersigns a parked movement with their own key. */
167
+ approve(id: string, approver: Identity): SignedApproval {
168
+ const m = this.movements.get(id);
169
+ if (!m) throw new Error(`No movement ${id}`);
170
+ if (m.status !== 'pending') throw new Error(`Movement ${id} is ${m.status}, not pending`);
171
+ const approval = signApproval(m.intent.id, approver);
172
+ m.status = 'approved';
173
+ m.decidedBy = approval.approvedBy;
174
+ m.decidedAt = approval.at;
175
+ return approval;
176
+ }
177
+
178
+ reject(id: string, approver: Identity): PendingMovement {
179
+ const m = this.movements.get(id);
180
+ if (!m) throw new Error(`No movement ${id}`);
181
+ if (m.status !== 'pending') throw new Error(`Movement ${id} is ${m.status}, not pending`);
182
+ m.status = 'rejected';
183
+ m.decidedBy = approver.did;
184
+ m.decidedAt = new Date().toISOString();
185
+ return m;
186
+ }
187
+
188
+ /** Atomic exactly-once gate: 'approved' -> 'executing'. True iff WE won. */
189
+ claimExecution(id: string): boolean {
190
+ const m = this.movements.get(id);
191
+ if (!m || m.status !== 'approved') return false;
192
+ m.status = 'executing';
193
+ return true;
194
+ }
195
+ confirmExecuted(id: string): void {
196
+ const m = this.movements.get(id);
197
+ if (m && m.status === 'executing') m.status = 'executed';
198
+ }
199
+ /** Transient deny: stays retryable. */
200
+ revertToApproved(id: string, reasons: string[]): void {
201
+ const m = this.movements.get(id);
202
+ if (m && m.status === 'executing') { m.status = 'approved'; m.failureReasons = reasons; }
203
+ }
204
+ /** Security failure: terminal AND visible. */
205
+ markFailed(id: string, reasons: string[]): void {
206
+ const m = this.movements.get(id);
207
+ if (m && m.status === 'executing') { m.status = 'failed'; m.failureReasons = reasons; }
208
+ }
209
+ }
210
+
211
+ // ── The Treasurer ───────────────────────────────────────────────────────────
212
+
213
+ export interface TreasurerConfig {
214
+ agentKeys: AgentKeys;
215
+ mandate: SpendMandate;
216
+ policy: SpendPolicy;
217
+ accounts: Account[];
218
+ fx: FXProvider;
219
+ baseCurrency: string;
220
+ approvalAbove?: number;
221
+ ladder?: AutonomyLadder;
222
+ orgLedger?: OrgSpendLedger;
223
+ kernelCtx: VerificationContext;
224
+ approvals: TreasuryApprovalInbox;
225
+ audit?: AuditLog;
226
+ }
227
+
228
+ export class Treasurer {
229
+ private readonly accounts: Map<string, Account>;
230
+
231
+ constructor(private readonly cfg: TreasurerConfig) {
232
+ this.accounts = new Map(cfg.accounts.map((a) => [a.id, { ...a }]));
233
+ }
234
+
235
+ account(id: string): Account | undefined {
236
+ const a = this.accounts.get(id);
237
+ return a ? { ...a } : undefined;
238
+ }
239
+
240
+ /** The signed {intent, mandate} pairs this agent committed — Auditor input. */
241
+ committedSpends(): { intent: TransactionIntent; mandate: SpendMandate }[] {
242
+ return [...this.committed];
243
+ }
244
+ private committed: { intent: TransactionIntent; mandate: SpendMandate }[] = [];
245
+
246
+ private operatingFor(currency: string): Account | undefined {
247
+ return [...this.accounts.values()].find((a) => a.type === 'operating' && a.currency === currency);
248
+ }
249
+
250
+ private intentFor(source: Account, payee: string, category: string, amount: number, currency: string, now?: Date): TransactionIntent {
251
+ return createTransactionIntent({
252
+ agent: this.cfg.agentKeys,
253
+ mandateId: this.cfg.mandate.id,
254
+ merchant: payee,
255
+ amount,
256
+ currency,
257
+ category,
258
+ description: `treasury:${source.id}`,
259
+ ...(now ? { now } : {}),
260
+ });
261
+ }
262
+
263
+ /**
264
+ * The shared movement pipeline. Balance is debited ONLY on a committed
265
+ * allow. On escalate the movement parks (nothing debited); on deny it
266
+ * aborts (nothing debited). evaluateWithPolicy handles the org-exposure
267
+ * reserve/release and the atomic kernel commit internally.
268
+ */
269
+ private async execute(
270
+ kind: ActionKind, source: Account, payee: string, category: string,
271
+ amount: number, currency: string, obligationId: string | undefined, now: Date | undefined,
272
+ ): Promise<TreasuryAction> {
273
+ const live = this.accounts.get(source.id)!;
274
+ // Balance check up front: never sign a movement we cannot fund.
275
+ if (live.balance < amount) {
276
+ return {
277
+ kind, status: 'skipped', accountId: source.id, payee, category, amount, currency,
278
+ ...(obligationId ? { obligationId } : {}), reasons: ['INSUFFICIENT_FUNDS'],
279
+ };
280
+ }
281
+
282
+ const intent = this.intentFor(live, payee, category, amount, currency, now);
283
+ const result = await evaluateWithPolicy({
284
+ policy: this.cfg.policy, mandate: this.cfg.mandate, intent,
285
+ ...(this.cfg.approvalAbove !== undefined ? { approvalAbove: this.cfg.approvalAbove } : {}),
286
+ ...(this.cfg.ladder ? { ladder: this.cfg.ladder } : {}),
287
+ ...(this.cfg.orgLedger ? { orgLedger: this.cfg.orgLedger } : {}),
288
+ kernelCtx: this.cfg.kernelCtx,
289
+ });
290
+
291
+ if (result.decision === 'allow') {
292
+ live.balance -= amount;
293
+ this.committed.push({ intent, mandate: this.cfg.mandate });
294
+ this.cfg.audit?.append({
295
+ type: 'treasury.committed', kind, accountId: source.id, payee, category,
296
+ amount, currency, intentId: intent.id, ...(obligationId ? { obligationId } : {}),
297
+ });
298
+ return {
299
+ kind, status: 'committed', accountId: source.id, payee, category, amount, currency,
300
+ intentId: intent.id, ...(obligationId ? { obligationId } : {}),
301
+ };
302
+ }
303
+
304
+ if (result.decision === 'escalate') {
305
+ const movement = this.cfg.approvals.submit({
306
+ intent, mandate: this.cfg.mandate, kind, accountId: source.id, payee, category,
307
+ amount, currency, ...(obligationId ? { obligationId } : {}), reason: result.policy.reasons.join(','),
308
+ });
309
+ this.cfg.audit?.append({
310
+ type: 'treasury.escalated', kind, accountId: source.id, payee, category,
311
+ amount, currency, approvalId: movement.id, intentId: intent.id, ...(obligationId ? { obligationId } : {}),
312
+ });
313
+ return {
314
+ kind, status: 'pending_approval', accountId: source.id, payee, category, amount,
315
+ currency, approvalId: movement.id, intentId: intent.id, ...(obligationId ? { obligationId } : {}),
316
+ };
317
+ }
318
+
319
+ const reasons = [...result.kernel.reasons, ...result.policy.reasons];
320
+ this.cfg.audit?.append({
321
+ type: 'treasury.denied', kind, accountId: source.id, payee, category,
322
+ amount, currency, reasons, ...(obligationId ? { obligationId } : {}),
323
+ });
324
+ return {
325
+ kind, status: 'denied', accountId: source.id, payee, category, amount, currency,
326
+ reasons, ...(obligationId ? { obligationId } : {}),
327
+ };
328
+ }
329
+
330
+ /** Fund a single obligation, routing through FX if we don't hold its currency. */
331
+ private async fundObligation(ob: Obligation, now?: Date): Promise<TreasuryAction> {
332
+ // 1) We hold the obligation's currency directly. Pay if it can cover it;
333
+ // if underfunded, SKIP — converting into a currency we already hold is
334
+ // nonsense, so this is a genuine funding gap.
335
+ const native = this.operatingFor(ob.currency);
336
+ if (native) {
337
+ if (native.balance >= ob.amount) {
338
+ return this.execute('obligation_payment', native, ob.payee, ob.category, ob.amount, ob.currency, ob.id, now);
339
+ }
340
+ return {
341
+ kind: 'obligation_payment', status: 'skipped', accountId: native.id, payee: ob.payee,
342
+ category: ob.category, amount: ob.amount, currency: ob.currency, obligationId: ob.id,
343
+ reasons: ['INSUFFICIENT_FUNDS'],
344
+ };
345
+ }
346
+
347
+ // 2) We do NOT hold the obligation's currency: route ONE mandate-governed
348
+ // movement in a currency we hold (prefer base), FX desk settles abroad.
349
+ const candidates = [...this.accounts.values()].filter((a) => a.type === 'operating' && a.currency !== ob.currency);
350
+ const source = candidates.find((a) => a.currency === this.cfg.baseCurrency) ?? candidates[0];
351
+ if (!source) {
352
+ return {
353
+ kind: 'obligation_payment', status: 'skipped', accountId: '—', payee: ob.payee,
354
+ category: ob.category, amount: ob.amount, currency: ob.currency, obligationId: ob.id,
355
+ reasons: ['NO_FUNDING_ACCOUNT'],
356
+ };
357
+ }
358
+ const sourceAmount = ceilMinorUnits(ob.amount * this.cfg.fx.rate(ob.currency, source.currency));
359
+ return this.execute('fx_conversion', source, ob.payee, ob.category, sourceAmount, source.currency, ob.id, now);
360
+ }
361
+
362
+ /** A full treasury cycle: pay due obligations FIRST, then sweep idle cash. */
363
+ async runCycle(obligations: Obligation[], now?: Date): Promise<TreasuryAction[]> {
364
+ const at = (now ?? new Date()).getTime();
365
+ const actions: TreasuryAction[] = [];
366
+
367
+ const due = obligations.filter((o) => o.dueAt <= at).sort((a, b) => a.dueAt - b.dueAt);
368
+ for (const ob of due) actions.push(await this.fundObligation(ob, now));
369
+
370
+ // Liquidity sweep AFTER obligations, so we never sweep money we owe.
371
+ for (const acct of this.accounts.values()) {
372
+ if (acct.type !== 'operating' || acct.minBuffer === undefined) continue;
373
+ const excess = acct.balance - acct.minBuffer;
374
+ if (excess <= 0) continue;
375
+ const reserve = [...this.accounts.values()].find((a) => (a.type === 'reserve' || a.type === 'yield') && a.currency === acct.currency);
376
+ if (!reserve) continue;
377
+ const action = await this.execute('liquidity_sweep', acct, reserve.id, 'treasury_sweep', excess, acct.currency, undefined, now);
378
+ if (action.status === 'committed') this.accounts.get(reserve.id)!.balance += excess;
379
+ actions.push(action);
380
+ }
381
+ return actions;
382
+ }
383
+
384
+ /** Finalize an escalated movement with a signed CFO approval (two-phase). */
385
+ async applyApproval(approvalId: string, approval: SignedApproval): Promise<TreasuryAction> {
386
+ const movement = this.cfg.approvals.get(approvalId);
387
+ if (!movement) throw new Error(`No movement ${approvalId}`);
388
+
389
+ const base = {
390
+ kind: movement.kind, accountId: movement.accountId, payee: movement.payee,
391
+ category: movement.category, amount: movement.amount, currency: movement.currency,
392
+ ...(movement.obligationId ? { obligationId: movement.obligationId } : {}),
393
+ };
394
+
395
+ if (!verifyApproval(approval) || approval.intentId !== movement.intent.id) {
396
+ return { ...base, status: 'denied', reasons: ['APPROVAL_SIGNATURE_INVALID'] };
397
+ }
398
+ // Separation of duties: the agent that initiated the movement can never be
399
+ // the human that countersigns it. Approval is authority from ANOTHER key.
400
+ if (approval.approvedBy === this.cfg.agentKeys.did) {
401
+ return { ...base, status: 'denied', reasons: ['SELF_APPROVAL_FORBIDDEN'] };
402
+ }
403
+ // Exactly-once gate: a double-submit dies here, never reaching the kernel.
404
+ if (!this.cfg.approvals.claimExecution(approvalId)) {
405
+ return { ...base, status: 'denied', reasons: ['ALREADY_EXECUTED'] };
406
+ }
407
+
408
+ const result = await evaluateWithPolicy({
409
+ policy: this.cfg.policy, mandate: this.cfg.mandate, intent: movement.intent,
410
+ ...(this.cfg.approvalAbove !== undefined ? { approvalAbove: this.cfg.approvalAbove } : {}),
411
+ humanApproval: approval,
412
+ ...(this.cfg.ladder ? { ladder: this.cfg.ladder } : {}),
413
+ ...(this.cfg.orgLedger ? { orgLedger: this.cfg.orgLedger } : {}),
414
+ kernelCtx: this.cfg.kernelCtx,
415
+ });
416
+
417
+ if (result.decision !== 'allow') {
418
+ const reasons = [...result.kernel.reasons, ...result.policy.reasons];
419
+ if (isSecurityAnomaly(reasons)) this.cfg.approvals.markFailed(approvalId, reasons);
420
+ else this.cfg.approvals.revertToApproved(approvalId, reasons);
421
+ this.cfg.audit?.append({ type: 'treasury.approval_denied', approvalId, intentId: movement.intent.id, reasons });
422
+ return { ...base, status: 'denied', reasons };
423
+ }
424
+
425
+ this.cfg.approvals.confirmExecuted(approvalId);
426
+ const src = this.accounts.get(movement.accountId);
427
+ if (src) src.balance -= movement.amount;
428
+ if (movement.kind === 'liquidity_sweep') {
429
+ const reserve = [...this.accounts.values()].find((a) => (a.type === 'reserve' || a.type === 'yield') && a.currency === movement.currency);
430
+ if (reserve) reserve.balance += movement.amount;
431
+ }
432
+ this.committed.push({ intent: movement.intent, mandate: movement.mandate });
433
+ this.cfg.audit?.append({
434
+ type: 'treasury.approval_executed', approvalId, intentId: movement.intent.id,
435
+ approvedBy: approval.approvedBy, approvalSignature: approval.signature,
436
+ amount: movement.amount, currency: movement.currency, payee: movement.payee,
437
+ });
438
+ return { ...base, status: 'committed', intentId: movement.intent.id, approvalId };
439
+ }
440
+
441
+ /** Multi-currency cash position, converted to base, net of near-term obligations. */
442
+ cashPosition(obligations: Obligation[], horizonMs = 30 * 24 * 3600 * 1000, now?: Date): CashPosition {
443
+ const at = (now ?? new Date()).getTime();
444
+ const byCurrency: Record<string, number> = {};
445
+ let totalInBase = 0;
446
+ for (const a of this.accounts.values()) {
447
+ byCurrency[a.currency] = (byCurrency[a.currency] ?? 0) + a.balance;
448
+ totalInBase += roundMinorUnits(a.balance * this.cfg.fx.rate(a.currency, this.cfg.baseCurrency));
449
+ }
450
+ const nearTermObligationsInBase = obligations
451
+ .filter((o) => o.dueAt <= at + horizonMs)
452
+ .reduce((s, o) => s + roundMinorUnits(o.amount * this.cfg.fx.rate(o.currency, this.cfg.baseCurrency)), 0);
453
+ return {
454
+ baseCurrency: this.cfg.baseCurrency, byCurrency, totalInBase,
455
+ nearTermObligationsInBase, netLiquidityInBase: totalInBase - nearTermObligationsInBase,
456
+ };
457
+ }
458
+ }
package/src/types.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * types.ts — L2 agent runtime data model (procurement domain).
3
+ */
4
+ import type { SpendMandate, TransactionIntent } from '@kyaki/core';
5
+
6
+ export interface ProcurementNeed {
7
+ id: string;
8
+ description: string;
9
+ category?: string;
10
+ maxBudget?: number;
11
+ }
12
+
13
+ export interface VendorOffer {
14
+ needId: string;
15
+ vendor: string;
16
+ price: number;
17
+ currency: string;
18
+ }
19
+
20
+ export interface Market {
21
+ offersFor(needId: string): Promise<VendorOffer[]> | VendorOffer[];
22
+ }
23
+
24
+ export type OutcomeStatus =
25
+ | 'purchased'
26
+ | 'pending_approval'
27
+ | 'no_compliant_vendor'
28
+ | 'deferred';
29
+
30
+ export interface SkippedOffer {
31
+ vendor: string;
32
+ price: number;
33
+ reason: string;
34
+ }
35
+
36
+ export interface ProcurementOutcome {
37
+ need: ProcurementNeed;
38
+ status: OutcomeStatus;
39
+ offer?: VendorOffer;
40
+ txnId?: string;
41
+ approvalId?: string;
42
+ skippedNonCompliant?: SkippedOffer[];
43
+ reasons?: string[];
44
+ }
45
+
46
+ export interface ApprovalRequest {
47
+ id: string;
48
+ intent: TransactionIntent;
49
+ mandate: SpendMandate;
50
+ need: ProcurementNeed;
51
+ offer: VendorOffer;
52
+ reason: string;
53
+ submittedAt: string;
54
+ status: 'pending' | 'approved' | 'rejected' | 'executed';
55
+ decidedBy?: string;
56
+ decidedAt?: string;
57
+ }