@agentguard-run/spend 0.15.1 → 0.15.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.
- package/CHANGELOG.md +13 -0
- package/dist/dashboard/aggregate.d.ts +90 -0
- package/dist/dashboard/aggregate.d.ts.map +1 -0
- package/dist/dashboard/aggregate.js +147 -0
- package/dist/dashboard/aggregate.js.map +1 -0
- package/dist/frameworks/hermes-kanban.d.ts +10 -2
- package/dist/frameworks/hermes-kanban.d.ts.map +1 -1
- package/dist/frameworks/hermes-kanban.js +55 -9
- package/dist/frameworks/hermes-kanban.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -2
- package/dist/index.js.map +1 -1
- package/dist/receipts/portability.d.ts +76 -0
- package/dist/receipts/portability.d.ts.map +1 -0
- package/dist/receipts/portability.js +88 -0
- package/dist/receipts/portability.js.map +1 -0
- package/dist/telemetry.js +1 -1
- package/package.json +8 -4
- package/src/dashboard/aggregate.test.ts +103 -0
- package/src/dashboard/aggregate.ts +201 -0
- package/src/frameworks/hermes-kanban.ts +67 -8
- package/src/receipts/portability.test.ts +68 -0
- package/src/receipts/portability.ts +156 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'crypto';
|
|
2
|
+
import { resolve as resolvePath } from 'path';
|
|
2
3
|
import { inferProvider } from '../cost-table';
|
|
3
4
|
import { canonicalJson, computeSignerFingerprint, sha256Hex } from '../decision-log';
|
|
4
5
|
import { signDagNode, verifyReceiptDag, type ReceiptCapabilityLevel, type ReceiptDagEnvelope, type ReceiptDagNode } from '../receipts/dag';
|
|
@@ -7,7 +8,8 @@ import type { CallContext, CapabilityTier, Provider, SignedDecisionLogEntry, Spe
|
|
|
7
8
|
import { safeRequestShape } from './common';
|
|
8
9
|
|
|
9
10
|
export type HermesKanbanProfile = 'researcher' | 'writer' | 'reviewer' | 'director' | 'orchestrator' | string;
|
|
10
|
-
export type HermesKanbanTaskState = '
|
|
11
|
+
export type HermesKanbanTaskState = 'triage' | 'blocked' | 'scheduled' | 'running' | 'complete' | 'revoked';
|
|
12
|
+
export type HermesKanbanGovernanceMode = 'lightweight' | 'full';
|
|
11
13
|
|
|
12
14
|
export interface HermesKanbanProfilePolicy {
|
|
13
15
|
capability: ReceiptCapabilityLevel;
|
|
@@ -23,6 +25,8 @@ export interface HermesKanbanAdapterOptions {
|
|
|
23
25
|
defaultModel?: string;
|
|
24
26
|
defaultCapCents?: number;
|
|
25
27
|
runtimeBudgetCentsPerMinute?: number;
|
|
28
|
+
lightweightThresholdCents?: number;
|
|
29
|
+
lightweightCapabilityCeiling?: ReceiptCapabilityLevel;
|
|
26
30
|
profileMap?: Record<string, HermesKanbanProfilePolicy>;
|
|
27
31
|
reviewerCascade?: {
|
|
28
32
|
enabled?: boolean;
|
|
@@ -39,6 +43,7 @@ export interface HermesKanbanTaskInput {
|
|
|
39
43
|
boardSlug: string;
|
|
40
44
|
profile: HermesKanbanProfile;
|
|
41
45
|
parentTaskIds?: string[];
|
|
46
|
+
workspacePath?: string;
|
|
42
47
|
maxRuntimeMinutes?: number;
|
|
43
48
|
maxRuntimeMs?: number;
|
|
44
49
|
metadata?: Record<string, unknown>;
|
|
@@ -56,6 +61,8 @@ export interface HermesKanbanEnvelope {
|
|
|
56
61
|
capCents: number;
|
|
57
62
|
parentTaskIds: string[];
|
|
58
63
|
parentEntryHashes: string[];
|
|
64
|
+
workspacePath?: string;
|
|
65
|
+
governanceMode: HermesKanbanGovernanceMode;
|
|
59
66
|
scope: SpendScope;
|
|
60
67
|
builderCode?: string;
|
|
61
68
|
createdAt: string;
|
|
@@ -101,6 +108,7 @@ export interface HermesKanbanReceiptInput {
|
|
|
101
108
|
tokenCounts?: { inputTokens?: number; outputTokens?: number };
|
|
102
109
|
totalCostCents?: number;
|
|
103
110
|
metadata?: Record<string, unknown>;
|
|
111
|
+
workspacePath?: string;
|
|
104
112
|
highRiskClassifierScore?: number;
|
|
105
113
|
keywordRisk?: boolean;
|
|
106
114
|
reviewerVerdict?: 'drafter_only' | 'review_required' | 'approved' | 'rejected';
|
|
@@ -146,6 +154,8 @@ const SAFE_METADATA_KEYS = new Set([
|
|
|
146
154
|
'ticket_id',
|
|
147
155
|
'token_count',
|
|
148
156
|
'workflow_id',
|
|
157
|
+
'workspace_hash',
|
|
158
|
+
'workspace_path',
|
|
149
159
|
]);
|
|
150
160
|
|
|
151
161
|
export function createHermesKanbanAdapter(opts: HermesKanbanAdapterOptions): HermesKanbanAdapter {
|
|
@@ -177,12 +187,14 @@ export class HermesKanbanAdapter {
|
|
|
177
187
|
taskHash,
|
|
178
188
|
boardHash,
|
|
179
189
|
profile: normalizeProfile(input.profile),
|
|
180
|
-
state: '
|
|
190
|
+
state: 'running',
|
|
181
191
|
capabilityCeiling: profilePolicy.capability,
|
|
182
192
|
toolAllowlist: [...(profilePolicy.toolAllowlist ?? [])],
|
|
183
193
|
capCents,
|
|
184
194
|
parentTaskIds: [...new Set(input.parentTaskIds ?? [])],
|
|
185
195
|
parentEntryHashes: [],
|
|
196
|
+
workspacePath: resolveWorkspacePath(input.workspacePath),
|
|
197
|
+
governanceMode: governanceModeFor(profilePolicy.capability, capCents, this.opts),
|
|
186
198
|
scope,
|
|
187
199
|
builderCode: safeBuilderCode(input.builderCode),
|
|
188
200
|
createdAt: new Date().toISOString(),
|
|
@@ -263,17 +275,21 @@ export class HermesKanbanAdapter {
|
|
|
263
275
|
};
|
|
264
276
|
}
|
|
265
277
|
|
|
266
|
-
async revokeTask(taskId: string): Promise<HermesKanbanReceiptResult> {
|
|
278
|
+
async revokeTask(taskId: string, reason = 'stale_lock_reclaimed'): Promise<HermesKanbanReceiptResult> {
|
|
267
279
|
const envelope = this.requireEnvelope(taskId);
|
|
268
280
|
if (envelope.state === 'revoked') return this.receiptResult(envelope, this.requireNode(taskId));
|
|
269
281
|
envelope.state = 'revoked';
|
|
270
282
|
envelope.revokedAt = new Date().toISOString();
|
|
283
|
+
const finalSpendCents = this.spendCentsSoFar(taskId);
|
|
271
284
|
const node = await this.signTaskNode(envelope, {
|
|
272
285
|
event: 'revoked',
|
|
273
286
|
action: 'block',
|
|
274
287
|
blocked: true,
|
|
275
288
|
capability: envelope.capabilityCeiling,
|
|
276
289
|
trustScore: 0.7,
|
|
290
|
+
revocationReason: safeLabel(reason) ?? 'stale_lock_reclaimed',
|
|
291
|
+
finalSpendCents,
|
|
292
|
+
postRevocationSpendCents: 0,
|
|
277
293
|
});
|
|
278
294
|
this.nodesByTaskId.set(taskId, node);
|
|
279
295
|
this.nodes.push(node);
|
|
@@ -284,7 +300,8 @@ export class HermesKanbanAdapter {
|
|
|
284
300
|
const envelope = this.requireEnvelope(input.taskId);
|
|
285
301
|
assertActive(envelope);
|
|
286
302
|
assertMetadataOnly(input.metadata ?? {});
|
|
287
|
-
envelope.
|
|
303
|
+
if (input.workspacePath) envelope.workspacePath = resolveWorkspacePath(input.workspacePath);
|
|
304
|
+
envelope.state = 'complete';
|
|
288
305
|
envelope.completedAt = new Date().toISOString();
|
|
289
306
|
const parentTaskIds = [...new Set(input.parentTaskIds ?? envelope.parentTaskIds)];
|
|
290
307
|
const parentNodes = parentTaskIds.map((id) => this.nodesByTaskId.get(id)).filter(Boolean) as ReceiptDagNode[];
|
|
@@ -346,6 +363,9 @@ export class HermesKanbanAdapter {
|
|
|
346
363
|
tokenCounts?: { inputTokens?: number; outputTokens?: number };
|
|
347
364
|
totalCostCents?: number;
|
|
348
365
|
reviewerCascade?: Record<string, unknown>;
|
|
366
|
+
revocationReason?: string;
|
|
367
|
+
finalSpendCents?: number;
|
|
368
|
+
postRevocationSpendCents?: number;
|
|
349
369
|
},
|
|
350
370
|
): Promise<ReceiptDagNode> {
|
|
351
371
|
if (!this.config.signingKeys) throw new Error('Hermes Kanban receipts require signingKeys');
|
|
@@ -373,6 +393,15 @@ export class HermesKanbanAdapter {
|
|
|
373
393
|
};
|
|
374
394
|
}
|
|
375
395
|
|
|
396
|
+
private spendCentsSoFar(taskId: string): number {
|
|
397
|
+
let total = 0;
|
|
398
|
+
for (const node of this.nodes) {
|
|
399
|
+
const receipt = node.decision?.outcomeReceipt as Record<string, unknown> | undefined;
|
|
400
|
+
if (receipt?.taskId === taskId) total += safeNonNegativeInteger(receipt.totalCostCents);
|
|
401
|
+
}
|
|
402
|
+
return total;
|
|
403
|
+
}
|
|
404
|
+
|
|
376
405
|
private requireEnvelope(taskId: string): HermesKanbanEnvelope {
|
|
377
406
|
const envelope = this.envelopes.get(taskId);
|
|
378
407
|
if (!envelope) throw new Error(`Unknown Hermes Kanban task ${taskId}`);
|
|
@@ -413,6 +442,9 @@ function taskDecision(
|
|
|
413
442
|
tokenCounts?: { inputTokens?: number; outputTokens?: number };
|
|
414
443
|
totalCostCents?: number;
|
|
415
444
|
reviewerCascade?: Record<string, unknown>;
|
|
445
|
+
revocationReason?: string;
|
|
446
|
+
finalSpendCents?: number;
|
|
447
|
+
postRevocationSpendCents?: number;
|
|
416
448
|
},
|
|
417
449
|
policy: SpendPolicy,
|
|
418
450
|
): SpendDecision {
|
|
@@ -433,6 +465,10 @@ function taskDecision(
|
|
|
433
465
|
parentTaskIds: [...envelope.parentTaskIds],
|
|
434
466
|
parentEntryHashes: parentNodes.map((node) => node.entryHash),
|
|
435
467
|
parentMsgHashes: parentNodes.map((node) => node.msgHash),
|
|
468
|
+
boardScopeHash: envelope.boardHash,
|
|
469
|
+
workspaceProvenance: envelope.workspacePath ? { type: 'directory', location: `dir:${envelope.workspacePath}` } : null,
|
|
470
|
+
governanceMode: envelope.governanceMode,
|
|
471
|
+
lightweight: envelope.governanceMode === 'lightweight',
|
|
436
472
|
modelIds: safeModelIds(args.modelIds ?? []),
|
|
437
473
|
inputTokens,
|
|
438
474
|
outputTokens,
|
|
@@ -440,6 +476,15 @@ function taskDecision(
|
|
|
440
476
|
metadata: filterReceiptMetadata(args.metadata ?? {}),
|
|
441
477
|
};
|
|
442
478
|
if (args.reviewerCascade) receipt.reviewerCascade = args.reviewerCascade;
|
|
479
|
+
if (args.event === 'revoked') {
|
|
480
|
+
receipt.revocation = {
|
|
481
|
+
reason: args.revocationReason ?? 'stale_lock_reclaimed',
|
|
482
|
+
finalSpendCents: safeNonNegativeInteger(args.finalSpendCents),
|
|
483
|
+
postRevocationSpendCents: safeNonNegativeInteger(args.postRevocationSpendCents),
|
|
484
|
+
closedAt: envelope.revokedAt ?? new Date().toISOString(),
|
|
485
|
+
assertion: 'no_spend_after_revoke',
|
|
486
|
+
};
|
|
487
|
+
}
|
|
443
488
|
const decision: SpendDecision = {
|
|
444
489
|
decisionId: randomUUID(),
|
|
445
490
|
timestamp: new Date().toISOString(),
|
|
@@ -481,15 +526,29 @@ function buildReviewerCascadeReceipt(input: HermesKanbanReceiptInput, opts: Herm
|
|
|
481
526
|
}
|
|
482
527
|
|
|
483
528
|
function reviewerCascadeTriggers(envelope: HermesKanbanEnvelope, input: HermesKanbanReceiptInput, opts: HermesKanbanAdapterOptions): string[] {
|
|
484
|
-
if (opts.reviewerCascade?.enabled === false) return [];
|
|
529
|
+
if (opts.reviewerCascade?.enabled === false || envelope.governanceMode === 'lightweight') return [];
|
|
485
530
|
const threshold = opts.reviewerCascade?.highRiskThreshold ?? 0.55;
|
|
486
531
|
const triggers: string[] = [];
|
|
487
|
-
if (envelope.profile === 'reviewer') triggers.push('reviewer_profile');
|
|
488
532
|
if (typeof input.highRiskClassifierScore === 'number' && input.highRiskClassifierScore >= threshold) triggers.push(`risk_score_above:${threshold}`);
|
|
489
533
|
if (input.keywordRisk === true) triggers.push('keyword_prefilter');
|
|
490
534
|
return [...new Set(triggers)];
|
|
491
535
|
}
|
|
492
536
|
|
|
537
|
+
|
|
538
|
+
function governanceModeFor(capability: ReceiptCapabilityLevel, capCents: number, opts: HermesKanbanAdapterOptions): HermesKanbanGovernanceMode {
|
|
539
|
+
const threshold = opts.lightweightThresholdCents ?? 5;
|
|
540
|
+
const ceiling = opts.lightweightCapabilityCeiling ?? 'READ_ONLY';
|
|
541
|
+
return CAPABILITY_RANK[capability] <= CAPABILITY_RANK[ceiling] && capCents <= threshold ? 'lightweight' : 'full';
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function resolveWorkspacePath(value: unknown): string | undefined {
|
|
545
|
+
if (typeof value !== 'string') return undefined;
|
|
546
|
+
const trimmed = value.trim();
|
|
547
|
+
if (!trimmed) return undefined;
|
|
548
|
+
if (trimmed.length > 1024 || /[\u0000-\u001f\u007f]/.test(trimmed)) throw new Error('Hermes Kanban workspacePath must be a metadata path');
|
|
549
|
+
return resolvePath(trimmed);
|
|
550
|
+
}
|
|
551
|
+
|
|
493
552
|
function capForTask(input: HermesKanbanTaskInput, profilePolicy: HermesKanbanProfilePolicy, opts: HermesKanbanAdapterOptions): number {
|
|
494
553
|
if (Number.isSafeInteger(profilePolicy.capCents) && profilePolicy.capCents! >= 0) return profilePolicy.capCents!;
|
|
495
554
|
const runtimeMinutes = runtimeMinutesFor(input);
|
|
@@ -513,7 +572,7 @@ function spendCapabilityForReceiptCapability(capability: ReceiptCapabilityLevel)
|
|
|
513
572
|
|
|
514
573
|
function assertTaskInput(input: HermesKanbanTaskInput): void {
|
|
515
574
|
assertPlainRecord(input, 'task');
|
|
516
|
-
const allowed = new Set(['taskId', 'boardSlug', 'profile', 'parentTaskIds', 'maxRuntimeMinutes', 'maxRuntimeMs', 'metadata', 'builderCode']);
|
|
575
|
+
const allowed = new Set(['taskId', 'boardSlug', 'profile', 'parentTaskIds', 'workspacePath', 'maxRuntimeMinutes', 'maxRuntimeMs', 'metadata', 'builderCode']);
|
|
517
576
|
for (const key of Object.keys(input as unknown as Record<string, unknown>)) {
|
|
518
577
|
if (!allowed.has(key)) throw new Error(`Hermes Kanban task input cannot include data-plane or unknown field: ${key}`);
|
|
519
578
|
}
|
|
@@ -545,7 +604,7 @@ function assertPlainRecord(value: unknown, name: string): void {
|
|
|
545
604
|
|
|
546
605
|
function assertActive(envelope: HermesKanbanEnvelope): void {
|
|
547
606
|
if (envelope.state === 'revoked') throw new AgentGuardBlockedError(blockedDecision(envelope, 'revoked'), envelope.scope);
|
|
548
|
-
if (envelope.state === '
|
|
607
|
+
if (envelope.state === 'complete') throw new AgentGuardBlockedError(blockedDecision(envelope, 'complete'), envelope.scope);
|
|
549
608
|
}
|
|
550
609
|
|
|
551
610
|
function blockedDecision(envelope: HermesKanbanEnvelope, state: string): SpendDecision {
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { workloadProvenance, formatLineage } from './portability';
|
|
4
|
+
import type { SignedDecisionLogEntry, SpendDecision, Provider } from '../types';
|
|
5
|
+
import type { ProvenanceProvider } from './schema';
|
|
6
|
+
|
|
7
|
+
let seq = 0;
|
|
8
|
+
function entry(model: string, fam: ProvenanceProvider, route: string, extra: Partial<SpendDecision> = {}): SignedDecisionLogEntry {
|
|
9
|
+
const decision: SpendDecision = {
|
|
10
|
+
decisionId: `d${seq}`, timestamp: `2026-07-10T14:${String(seq).padStart(2, '0')}:00.000Z`,
|
|
11
|
+
action: 'allow', triggeredCap: null, triggeredScopeKey: 'tenant:acme|agent:planner',
|
|
12
|
+
projectedCents: 10, actualCents: 10, windowSpendBefore: 0, windowSpendAfter: 0,
|
|
13
|
+
provider: (fam === 'self_hosted' ? 'unknown' : fam) as Provider,
|
|
14
|
+
modelRequested: model, modelResolved: model, policyId: 'p', policyVersion: 1,
|
|
15
|
+
enforcementMode: 'enforce', reasons: [],
|
|
16
|
+
provenance: {
|
|
17
|
+
model_identity: { provider: fam, model_id: model, model_version: '1', model_family: fam, weights_origin_country: 'US' },
|
|
18
|
+
hosting: { provider_route: route, jurisdiction_country: 'US', jurisdiction_region: 'us' },
|
|
19
|
+
compliance: { baa_in_force: false, baa_vendor: null, hipaa_eligible: false, data_retention_days: null, data_residency_attested: false, foreign_origin_weight_flag: false, foreign_origin_consent_receipt_id: null, inference_billing: 'customer_managed' },
|
|
20
|
+
captured_at: '2026-07-10T14:00:00.000Z',
|
|
21
|
+
},
|
|
22
|
+
...extra,
|
|
23
|
+
};
|
|
24
|
+
return { sequence: seq++, decision, previousHash: '0'.repeat(64), entryHash: 'x', signature: 'y', signerFingerprint: 'f' };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test('lineage, swaps, and boundary crossing across a hot-swap sequence', () => {
|
|
28
|
+
seq = 0;
|
|
29
|
+
const entries = [
|
|
30
|
+
entry('claude-5', 'anthropic', 'anthropic-api'),
|
|
31
|
+
entry('claude-5', 'anthropic', 'anthropic-api'), // repeat: no swap
|
|
32
|
+
entry('gpt-5-mini', 'openai', 'openai-api'), // frontier→frontier swap
|
|
33
|
+
entry('glm-5.2', 'self_hosted', 'self-hosted-gpu'), // frontier→dark swap (boundary)
|
|
34
|
+
];
|
|
35
|
+
const report = workloadProvenance(entries, { verified: true, entries: entries.length });
|
|
36
|
+
assert.equal(report.workloads.length, 1);
|
|
37
|
+
const w = report.workloads[0];
|
|
38
|
+
assert.deepEqual(w.lineage, ['claude-5', 'gpt-5-mini', 'glm-5.2']);
|
|
39
|
+
assert.equal(formatLineage(w), 'claude-5 → gpt-5-mini → glm-5.2');
|
|
40
|
+
assert.equal(w.swaps.length, 2);
|
|
41
|
+
assert.equal(w.swaps[0].crossesBoundary, false); // frontier→frontier
|
|
42
|
+
assert.equal(w.swaps[1].crossesBoundary, true); // frontier→dark
|
|
43
|
+
assert.equal(w.swaps[1].fromModel, 'gpt-5-mini');
|
|
44
|
+
assert.equal(w.swaps[1].toModel, 'glm-5.2');
|
|
45
|
+
assert.equal(w.darkCents, 10);
|
|
46
|
+
assert.equal(w.frontierCents, 30);
|
|
47
|
+
assert.equal(report.continuity.verified, true); // one chain spans all swaps
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('groups by keyOf; separate workloads do not merge', () => {
|
|
51
|
+
seq = 0;
|
|
52
|
+
const a = entry('claude-5', 'anthropic', 'api', { triggeredScopeKey: 'wf:A' });
|
|
53
|
+
const b = entry('glm-5.2', 'self_hosted', 'self', { triggeredScopeKey: 'wf:B' });
|
|
54
|
+
const report = workloadProvenance([a, b], { verified: true, entries: 2 });
|
|
55
|
+
assert.equal(report.workloads.length, 2);
|
|
56
|
+
assert.ok(report.workloads.every(w => w.swaps.length === 0));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('settlement/outcome entries add no model steps', () => {
|
|
60
|
+
seq = 0;
|
|
61
|
+
const entries = [
|
|
62
|
+
entry('claude-5', 'anthropic', 'api'),
|
|
63
|
+
entry('claude-5', 'anthropic', 'api', { entryType: 'settlement', actualCents: 9 }),
|
|
64
|
+
];
|
|
65
|
+
const w = workloadProvenance(entries, { verified: true, entries: 2 }).workloads[0];
|
|
66
|
+
assert.equal(w.calls, 1);
|
|
67
|
+
assert.equal(w.swaps.length, 0);
|
|
68
|
+
});
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentGuard® — model-agnostic provenance that survives hot-swap.
|
|
3
|
+
*
|
|
4
|
+
* The "headless" / model-fungibility problem (raised on All-In E280): teams want
|
|
5
|
+
* to hot-swap models — frontier → open-weight → self-hosted — per task to cut
|
|
6
|
+
* cost, but "no one has figured out how to abstract memory/context/AUDIT away
|
|
7
|
+
* from the model." A provider console can't help: it only sees its own tokens,
|
|
8
|
+
* and the trail breaks the moment you route to a different provider.
|
|
9
|
+
*
|
|
10
|
+
* AgentGuard's signed decision log already records, per call, which model served
|
|
11
|
+
* it (provenance) inside one continuous hash chain. This module reads that log
|
|
12
|
+
* and produces a PORTABLE, model-agnostic provenance manifest per workload: the
|
|
13
|
+
* ordered model lineage, every swap, the frontier/dark origin mix, and a single
|
|
14
|
+
* tamper-evidence verdict that spans the whole swap sequence. The audit is keyed
|
|
15
|
+
* to the workload, not the model — so it survives the swap.
|
|
16
|
+
*
|
|
17
|
+
* Honest scope note (same as the dashboard): the signed SpendDecision does not
|
|
18
|
+
* carry a first-class workload/agent id (only `triggeredScopeKey`). Callers pass
|
|
19
|
+
* a `keyOf` selector to group; the default uses `triggeredScopeKey`. We never
|
|
20
|
+
* invent a grouping the ledger does not support.
|
|
21
|
+
*
|
|
22
|
+
* Patent notice: signed hash-chained decision log (U.S. application filed May
|
|
23
|
+
* 2026); composes with DAG Trust Attestation, Patent D §7.3 (App. No.
|
|
24
|
+
* 63/984,626). AgentGuard® is a U.S. registered trademark (Reg. No. 8281464) of
|
|
25
|
+
* Dunecrest Ventures Inc.
|
|
26
|
+
*/
|
|
27
|
+
import type { SignedDecisionLogEntry, SpendDecision } from '../types';
|
|
28
|
+
import { classifyOrigin, decisionCents, type TokenOrigin } from '../dashboard/aggregate';
|
|
29
|
+
|
|
30
|
+
export interface ModelUse {
|
|
31
|
+
model: string;
|
|
32
|
+
origin: TokenOrigin;
|
|
33
|
+
provider: string;
|
|
34
|
+
calls: number;
|
|
35
|
+
cents: number;
|
|
36
|
+
}
|
|
37
|
+
export interface ModelSwap {
|
|
38
|
+
atSequence: number;
|
|
39
|
+
fromModel: string;
|
|
40
|
+
toModel: string;
|
|
41
|
+
fromOrigin: TokenOrigin;
|
|
42
|
+
toOrigin: TokenOrigin;
|
|
43
|
+
/** True when the swap crossed the frontier↔dark boundary (the audit-risky kind). */
|
|
44
|
+
crossesBoundary: boolean;
|
|
45
|
+
}
|
|
46
|
+
export interface WorkloadProvenance {
|
|
47
|
+
key: string;
|
|
48
|
+
calls: number;
|
|
49
|
+
spentCents: number;
|
|
50
|
+
frontierCents: number;
|
|
51
|
+
darkCents: number;
|
|
52
|
+
firstAt: string | null;
|
|
53
|
+
lastAt: string | null;
|
|
54
|
+
models: ModelUse[];
|
|
55
|
+
/** Ordered model lineage, e.g. ['claude-5','gpt-5-mini','glm-5.2']. */
|
|
56
|
+
lineage: string[];
|
|
57
|
+
swaps: ModelSwap[];
|
|
58
|
+
}
|
|
59
|
+
export interface WorkloadProvenanceReport {
|
|
60
|
+
workloads: WorkloadProvenance[];
|
|
61
|
+
/** Chain-level tamper evidence spans ALL swaps — the point of the manifest. */
|
|
62
|
+
continuity: { verified: boolean; entries: number; reason?: string };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const defaultKeyOf = (d: SpendDecision): string => d.triggeredScopeKey ?? 'default';
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Build the portable per-workload provenance manifest from signed ledger
|
|
69
|
+
* entries. Pass the async `verifyChain` result as `continuity` so this stays a
|
|
70
|
+
* pure, synchronous function (never null-continuity in production — a manifest
|
|
71
|
+
* without tamper evidence is not the product).
|
|
72
|
+
*/
|
|
73
|
+
export function workloadProvenance(
|
|
74
|
+
entries: SignedDecisionLogEntry[],
|
|
75
|
+
continuity: WorkloadProvenanceReport['continuity'] = { verified: false, entries: 0, reason: 'NOT_CHECKED' },
|
|
76
|
+
keyOf: (d: SpendDecision) => string = defaultKeyOf,
|
|
77
|
+
): WorkloadProvenanceReport {
|
|
78
|
+
interface Group {
|
|
79
|
+
calls: number; spent: number; frontier: number; dark: number;
|
|
80
|
+
first: string | null; last: string | null;
|
|
81
|
+
models: Map<string, ModelUse>; ordered: Array<{ seq: number; model: string; origin: TokenOrigin }>;
|
|
82
|
+
}
|
|
83
|
+
const groups = new Map<string, Group>();
|
|
84
|
+
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
const d = entry.decision;
|
|
87
|
+
const kind = d.entryType ?? 'decision';
|
|
88
|
+
if (kind !== 'decision') continue; // settlements/outcomes don't add model steps
|
|
89
|
+
|
|
90
|
+
const key = keyOf(d);
|
|
91
|
+
const grp: Group = groups.get(key) ?? {
|
|
92
|
+
calls: 0, spent: 0, frontier: 0, dark: 0, first: null, last: null,
|
|
93
|
+
models: new Map(), ordered: [],
|
|
94
|
+
};
|
|
95
|
+
const model = d.modelResolved || d.modelRequested || 'unknown';
|
|
96
|
+
const { origin } = classifyOrigin(d);
|
|
97
|
+
const cents = decisionCents(d);
|
|
98
|
+
|
|
99
|
+
grp.calls += 1;
|
|
100
|
+
grp.spent += cents;
|
|
101
|
+
if (origin === 'frontier') grp.frontier += cents;
|
|
102
|
+
else if (origin === 'dark') grp.dark += cents;
|
|
103
|
+
if (d.timestamp) {
|
|
104
|
+
if (!grp.first || d.timestamp < grp.first) grp.first = d.timestamp;
|
|
105
|
+
if (!grp.last || d.timestamp > grp.last) grp.last = d.timestamp;
|
|
106
|
+
}
|
|
107
|
+
const mu = grp.models.get(model) ?? { model, origin, provider: d.provider ?? 'unknown', calls: 0, cents: 0 };
|
|
108
|
+
mu.calls += 1;
|
|
109
|
+
mu.cents += cents;
|
|
110
|
+
grp.models.set(model, mu);
|
|
111
|
+
grp.ordered.push({ seq: entry.sequence, model, origin });
|
|
112
|
+
|
|
113
|
+
groups.set(key, grp);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const workloads: WorkloadProvenance[] = [];
|
|
117
|
+
for (const [key, grp] of groups) {
|
|
118
|
+
grp.ordered.sort((a, b) => a.seq - b.seq);
|
|
119
|
+
const swaps: ModelSwap[] = [];
|
|
120
|
+
for (let i = 1; i < grp.ordered.length; i++) {
|
|
121
|
+
const prev = grp.ordered[i - 1];
|
|
122
|
+
const cur = grp.ordered[i];
|
|
123
|
+
if (cur.model !== prev.model) {
|
|
124
|
+
swaps.push({
|
|
125
|
+
atSequence: cur.seq,
|
|
126
|
+
fromModel: prev.model, toModel: cur.model,
|
|
127
|
+
fromOrigin: prev.origin, toOrigin: cur.origin,
|
|
128
|
+
crossesBoundary: prev.origin !== cur.origin
|
|
129
|
+
&& prev.origin !== 'unknown' && cur.origin !== 'unknown',
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// lineage = models in first-appearance order (collapse consecutive repeats)
|
|
134
|
+
const lineage: string[] = [];
|
|
135
|
+
for (const step of grp.ordered) if (lineage[lineage.length - 1] !== step.model) lineage.push(step.model);
|
|
136
|
+
|
|
137
|
+
workloads.push({
|
|
138
|
+
key,
|
|
139
|
+
calls: grp.calls,
|
|
140
|
+
spentCents: grp.spent,
|
|
141
|
+
frontierCents: grp.frontier,
|
|
142
|
+
darkCents: grp.dark,
|
|
143
|
+
firstAt: grp.first,
|
|
144
|
+
lastAt: grp.last,
|
|
145
|
+
models: [...grp.models.values()].sort((a, b) => b.cents - a.cents),
|
|
146
|
+
lineage,
|
|
147
|
+
swaps,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
workloads.sort((a, b) => b.spentCents - a.spentCents);
|
|
152
|
+
return { workloads, continuity };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Human-readable lineage, e.g. "claude-5 → gpt-5-mini → glm-5.2". */
|
|
156
|
+
export const formatLineage = (w: WorkloadProvenance): string => w.lineage.join(' → ');
|