@@ -235,6 +235,17 @@ pub enum AuditKind {
235235 action : String ,
236236 reason : String ,
237237 } ,
238+ /// Logged when a peer held at least one action-matched capability but
239+ /// every candidate's scope refused the concrete request, so the dispatch
240+ /// was denied. `reason` is the same short scope diagnostic the caller
241+ /// saw. Deliberately carries **no candidate-cap attribution**
242+ /// (provenance-capability-rule-evaluation-trace decision): on a scope
243+ /// reject there is no single "matched rule" — the peer may have held
244+ /// several action-matched grants, all scope-insufficient — so one
245+ /// `signature_b58` would mislead, a `Vec` would balloon audit volume,
246+ /// and either would unfreeze the golden wire schema
247+ /// (`audit-kinds.v1.json` / `provenance-records.v1.json`). Revisit only
248+ /// via an explicit schema-version bump.
238249 CapabilityScopeRejected {
239250 agent_id : String ,
240251 action : String ,
@@ -1439,19 +1450,20 @@ pub struct PrivilegedAction {
14391450 /// `Some` on rows that name an authorizing or affected capability.
14401451 pub rule : Option < String > ,
14411452 /// How the action resolved: `authorized`, `denied`, `granted`,
1442- /// `grant_rejected`, `revoked`, `revoke_noop`, `scope_rejected`, or
1443- /// `revoke_rejected`.
1453+ /// `grant_rejected`, `revoked`, `revoke_noop`, `scope_rejected`,
1454+ /// `revoke_rejected`, or `budget_exhausted` .
14441455 pub outcome : String ,
14451456}
14461457
14471458/// Project one audit event into its privileged-action rows. A
14481459/// [`AuditKind::CapabilityCheck`] fans out to one `authorized` row per
14491460/// granted required action (each naming its approver and rule) plus one
1450- /// `denied` row per missing action; the single-row grant, revoke, and
1451- /// rejection kinds produce exactly one row. Every other kind — intent
1452- /// dispatch, Hermes tool calls, budget enforcement, peer/operator
1453- /// administration — has no authorizing rule and projects to an empty
1454- /// vec, so the provenance view stays scoped to capability provenance.
1461+ /// `denied` row per missing action; the single-row grant, revoke,
1462+ /// rejection, and use-budget-exhaustion kinds produce exactly one row.
1463+ /// Every other kind — intent dispatch, Hermes tool calls, token-budget
1464+ /// enforcement, peer/operator administration — has no authorizing rule
1465+ /// and projects to an empty vec, so the provenance view stays scoped to
1466+ /// capability provenance.
14551467pub fn project_privileged_actions ( event : & AuditEvent ) -> Vec < PrivilegedAction > {
14561468 let row = |actor : String ,
14571469 action : String ,
@@ -1554,6 +1566,24 @@ pub fn project_privileged_actions(event: &AuditEvent) -> Vec<PrivilegedAction> {
15541566 "revoke_rejected" ,
15551567 "capability_revoke_rejected" ,
15561568 ) ] ,
1569+ // A budget-exhausted refusal is a capability deny with a *known*
1570+ // matched rule: the grant verified but its signed max_uses budget was
1571+ // spent. Project it so the provenance view answers "which rule ran
1572+ // out" rather than dropping the deny. The variant carries no subject
1573+ // display, so like `CapabilityRevokeRejected` the row is keyed by the
1574+ // authorizing signature with an empty actor.
1575+ AuditKind :: CapabilityBudgetExhausted {
1576+ signature_b58,
1577+ action,
1578+ ..
1579+ } => vec ! [ row(
1580+ String :: new( ) ,
1581+ action. clone( ) ,
1582+ None ,
1583+ Some ( signature_b58. clone( ) ) ,
1584+ "budget_exhausted" ,
1585+ "capability_budget_exhausted" ,
1586+ ) ] ,
15571587 _ => Vec :: new ( ) ,
15581588 }
15591589}
@@ -1921,17 +1951,17 @@ mod tests {
19211951
19221952 #[ tokio:: test]
19231953 async fn jsonl_integrity_report_pins_root_hash_as_genesis_seeded_chain_fold ( ) {
1924- // verify_integrity returns root_hash_hex (lib.rs:1308 ) as the final
1954+ // verify_integrity returns root_hash_hex (lib.rs:1319 ) as the final
19251955 // accumulator of the audit hash chain: it seeds previous_hash_hex from
1926- // ZERO_CHAIN_HASH (lib.rs:1263 ), then folds each event line forward as
1927- // previous = chain_hash(previous, sha256_hex(line)) (lib.rs:1264-1296 ).
1956+ // ZERO_CHAIN_HASH (lib.rs:1274 ), then folds each event line forward as
1957+ // previous = chain_hash(previous, sha256_hex(line)) (lib.rs:1275-1307 ).
19281958 // That root is the audit-root subject release signing binds
1929- // (lib.rs:2669 ), so its exact byte construction is load-bearing for any
1959+ // (lib.rs:2699 ), so its exact byte construction is load-bearing for any
19301960 // independent verifier of the anchor.
19311961 //
19321962 // jsonl_integrity_report_accepts_untampered_chain pins root_hash_hex
19331963 // only by length (== 64) plus report.valid, and valid is RELATIONAL: it
1934- // holds whenever the write-path build_chain_entries (lib.rs:1014 ) agrees
1964+ // holds whenever the write-path build_chain_entries (lib.rs:1025 ) agrees
19351965 // with the verify-path inline fold, so a mutation applied consistently
19361966 // to BOTH paths survives it. chain_hash_pins_separator_and_sha256_-
19371967 // composition pins the single LINK, but nothing recomputes the
@@ -1953,7 +1983,7 @@ mod tests {
19531983 assert ! ( report. valid, "{report:?}" ) ;
19541984
19551985 // Independent fold of the exact lines verify_integrity reads
1956- // (read_event_lines filters empty lines identically, lib.rs:1039-1045 ).
1986+ // (read_event_lines filters empty lines identically, lib.rs:1050-1056 ).
19571987 let raw = std:: fs:: read_to_string ( & path) . unwrap ( ) ;
19581988 let mut expected = ZERO_CHAIN_HASH . to_string ( ) ;
19591989 for line in raw. lines ( ) . filter ( |l| !l. is_empty ( ) ) {
@@ -1977,11 +2007,11 @@ mod tests {
19772007 #[ tokio:: test]
19782008 async fn jsonl_integrity_report_detects_dangling_chain_anchor ( ) {
19792009 // verify_integrity flags a hash-chain sidecar that outruns the
1980- // event log: `if anchors.len() > event_lines.len()` (lib.rs:1298 )
2010+ // event log: `if anchors.len() > event_lines.len()` (lib.rs:1309 )
19812011 // reports the surplus as "<n> dangling chain anchor(s)" — the
19822012 // specific diagnostic for an event log that lost trailing lines
19832013 // (truncated or rolled back) while the append-only chain sidecar
1984- // kept its anchors. The earlier `!=` parity check (lib.rs:1256 )
2014+ // kept its anchors. The earlier `!=` parity check (lib.rs:1267 )
19852015 // also marks any count mismatch invalid, so line 827 is the
19862016 // dangling-count diagnostic on top of that verdict, not the sole
19872017 // gate. Every other integrity test runs equal event/anchor counts
@@ -2036,7 +2066,7 @@ mod tests {
20362066 async fn jsonl_integrity_report_detects_missing_anchor_for_unanchored_event ( ) {
20372067 // verify_integrity flags a well-formed event line that has no backing
20382068 // chain anchor: the `Ok(event)` branch's `None => "chain entry {index}
2039- // missing"` arm (lib.rs:1280 ), reached only when the event log outruns
2069+ // missing"` arm (lib.rs:1291 ), reached only when the event log outruns
20402070 // the append-only hash-chain sidecar (event_lines.len() >
20412071 // anchors.len()). That skew is the signature of a forged event appended
20422072 // without extending the chain, and the diagnostic must name the
@@ -2047,7 +2077,7 @@ mod tests {
20472077 //
20482078 // Mutation: narrowing line 977 to `None => {}` drops only this
20492079 // per-index diagnostic. The `anchors.len() != event_lines.len()` parity
2050- // check at lib.rs:1256 already flips `valid`, so asserting only
2080+ // check at lib.rs:1267 already flips `valid`, so asserting only
20512081 // `!report.valid` would NOT catch the regression — the load-bearing
20522082 // assertion is the specific "chain entry 1 missing" string.
20532083 let dir = tempfile:: tempdir ( ) . unwrap ( ) ;
@@ -3673,6 +3703,47 @@ mod tests {
36733703 }
36743704 }
36753705
3706+ #[ test]
3707+ fn audit_kind_capability_budget_exhausted_serde_pins_four_field_variant ( ) {
3708+ // AuditKind::CapabilityBudgetExhausted records a deny whose matched
3709+ // grant verified but had already spent its signed max_uses budget —
3710+ // signature_b58 is the join key back to the spent grant and the one
3711+ // field QueryProvenance projects as the rule.
3712+ let kind = AuditKind :: CapabilityBudgetExhausted {
3713+ signature_b58 : "SpentSig" . into ( ) ,
3714+ action : "tool.call.echo" . into ( ) ,
3715+ max_uses : 5 ,
3716+ used : 5 ,
3717+ } ;
3718+
3719+ let wire = serde_json:: to_value ( & kind) . unwrap ( ) ;
3720+ let obj = wire
3721+ . as_object ( )
3722+ . expect ( "AuditKind serializes as a JSON object" ) ;
3723+ let mut keys: Vec < & str > = obj. keys ( ) . map ( String :: as_str) . collect ( ) ;
3724+ keys. sort ( ) ;
3725+ assert_eq ! (
3726+ keys,
3727+ vec![ "action" , "max_uses" , "signature_b58" , "type" , "used" ]
3728+ ) ;
3729+ assert_eq ! (
3730+ obj. get( "type" ) ,
3731+ Some ( & serde_json:: json!( "capability_budget_exhausted" ) ) ,
3732+ ) ;
3733+
3734+ let back: AuditKind = serde_json:: from_value ( wire. clone ( ) ) . unwrap ( ) ;
3735+ assert_eq ! ( back, kind) ;
3736+
3737+ for required in [ "signature_b58" , "action" , "max_uses" , "used" ] {
3738+ let mut missing = obj. clone ( ) ;
3739+ missing. remove ( required) ;
3740+ assert ! (
3741+ serde_json:: from_value:: <AuditKind >( serde_json:: Value :: Object ( missing) ) . is_err( ) ,
3742+ "AuditKind::CapabilityBudgetExhausted wire form must reject a payload missing {required:?}" ,
3743+ ) ;
3744+ }
3745+ }
3746+
36763747 #[ test]
36773748 fn audit_kind_authentication_failed_serde_pins_two_field_variant ( ) {
36783749 // AuditKind::AuthenticationFailed records every rejected auth
@@ -5418,13 +5489,56 @@ mod tests {
54185489 ) ;
54195490 }
54205491
5492+ #[ test]
5493+ fn capability_budget_exhausted_projects_the_spent_rule ( ) {
5494+ let rows = project_privileged_actions ( & dummy ( AuditKind :: CapabilityBudgetExhausted {
5495+ signature_b58 : "SpentSig" . into ( ) ,
5496+ action : "tool.call.echo" . into ( ) ,
5497+ max_uses : 5 ,
5498+ used : 5 ,
5499+ } ) ) ;
5500+ assert_eq ! ( rows. len( ) , 1 ) ;
5501+ assert_eq ! ( rows[ 0 ] . kind, "capability_budget_exhausted" ) ;
5502+ assert_eq ! ( rows[ 0 ] . outcome, "budget_exhausted" ) ;
5503+ assert_eq ! (
5504+ rows[ 0 ] . rule. as_deref( ) ,
5505+ Some ( "SpentSig" ) ,
5506+ "a budget-exhausted deny is the one deny path with exactly one \
5507+ matched rule — the provenance view must name the spent grant",
5508+ ) ;
5509+ assert_eq ! ( rows[ 0 ] . action, "tool.call.echo" ) ;
5510+ assert ! (
5511+ rows[ 0 ] . approver. is_none( ) ,
5512+ "exhaustion is refused by budget arithmetic, not by an approver" ,
5513+ ) ;
5514+ assert ! (
5515+ rows[ 0 ] . actor. is_empty( ) ,
5516+ "the variant carries no subject display; like a cross-peer revoke \
5517+ rejection the row is keyed by the authorizing signature",
5518+ ) ;
5519+ }
5520+
54215521 #[ test]
54225522 fn non_capability_kinds_project_no_privileged_actions ( ) {
54235523 let rows = project_privileged_actions ( & dummy ( AuditKind :: AuthenticationFailed {
54245524 transport : "ipc" . into ( ) ,
54255525 reason : "bad token" . into ( ) ,
54265526 } ) ) ;
54275527 assert ! ( rows. is_empty( ) , "auth failures carry no authorizing rule" ) ;
5528+
5529+ let rows = project_privileged_actions ( & dummy ( AuditKind :: BudgetExhausted {
5530+ agent_display : "research@agent" . into ( ) ,
5531+ intent_id : Uuid :: nil ( ) ,
5532+ intent_text : "summarize the logs" . into ( ) ,
5533+ requested : 10 ,
5534+ tokens_remaining : 0 ,
5535+ refill_eta_ms : 60_000 ,
5536+ } ) ) ;
5537+ assert ! (
5538+ rows. is_empty( ) ,
5539+ "token-budget exhaustion names no signed rule — only the \
5540+ capability use-budget variant projects into provenance",
5541+ ) ;
54285542 }
54295543
54305544 #[ test]
0 commit comments