Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .pa/ACTIVITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,8 @@ Append-only: neue Einträge am Ende. Regeln: AGENTS.md.
- Zusammenfassung: SETUP-09: scripts/review/run-local.sh lehnt jetzt zwei Modelle ab, die in dieselbe Protokolldatei schreiben wuerden (vorher: zweimal ok, Exit 0, ein Urteil weg). Fail-closed bei Zeichensatz und Gross-/Kleinschreibung. Commits ae5a66b (rot) 1cbe02b 0fe87ff c4f7d3b; Review kimi-k3 + glm-5.2 (6x low, alle angenommen); Draft-PR #273. Neue Dateien: .pa/review_setup-09-protocol-collision_{kimi-k3,glm-5.2,disposition}.md
- Commits: keine
- Uncommitted: keine

## 2026-10-04 16:36 — codex (codex/hq2-05b-p2-claude-probe, srv-hq2-05b-p2-claude-probe)
- Zusammenfassung: HQ2-05b package 2: real Claude 2.1.287 JSON probe, scrubbed fixture and pure fail-closed UsageReceipt parser; red-first commits ee4a010/5a3a94c; prepush and push hook green; draft PR #299; Tier B review and later route/store wiring pending.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Classify the store change as Tier A

This package changes files under src-tauri/src/store/, which the repository rules explicitly include in a serial seam, but this journal entry schedules only a Tier B review. That classification obtains one reviewer instead of the two cross-vendor reviewers required for seam changes, so reclassify the package as Tier A and record both review dispositions before accepting it.

AGENTS.md reference: AGENTS.md:L26-L32

Useful? React with 👍 / 👎.

- Commits: keine
- Uncommitted: keine
2 changes: 2 additions & 0 deletions src-tauri/src/store/development_budget.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ use crate::development_policy::{DevelopmentPolicy, TokenPolicy};
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, Sqlite, Transaction};

#[path = "development_claude_usage.rs"]
pub(super) mod claude_usage;
#[path = "development_codex_usage.rs"]
pub(super) mod codex_usage;
#[path = "development_usage_receipt.rs"]
Expand Down
133 changes: 133 additions & 0 deletions src-tauri/src/store/development_claude_usage.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
//! Pure parser for one completed `claude -p --output-format json` capture.
//! Runtime and ledger wiring belong to a later serial package.
use super::usage_receipt::UsageReceipt;
use serde_json::Value;
use sha2::{Digest, Sha256};

pub(in crate::store) const CLAUDE_COLLECTOR: &str = "claude-print-json-v1";
pub(in crate::store) const CLAUDE_TRANSPORT: &str = "native_claude_print_json";

/// Classify one complete process-owned capture without touching the store.
pub(in crate::store) fn complete_usage(stdout: &[u8], exit_code: Option<i32>) -> UsageReceipt {
let source_sha256 = format!("{:x}", Sha256::digest(stdout));
let rejected = |reason| UsageReceipt::Rejected {
reason,
source_sha256: source_sha256.clone(),
collector: CLAUDE_COLLECTOR,
};
if exit_code != Some(0) {
return rejected("process exit code is not zero");
}
if stdout.is_empty() {
return rejected("capture is empty");
}
if stdout.len() > 1_048_576 {
return rejected("capture exceeds 1 MiB");
}
let result: Value = match serde_json::from_slice(stdout) {
Ok(value) => value,
Err(_) => return rejected("capture is not one JSON value"),
};
if result["type"] != "result" || result["subtype"] != "success" || result["is_error"] != false {
return rejected("provider reported an error result");
}
let Some(usage) = result.get("usage").filter(|value| value.is_object()) else {
return UsageReceipt::NotReported {
Comment on lines +34 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject malformed usage payloads

When a successful Claude response contains usage: null, an array, or a string because of schema drift or capture corruption, this filter follows the same branch as an absent field and returns NotReported. That state omits the capture digest and claims the result simply lacked usage, making malformed provider data indistinguishable from a legitimate missing report; distinguish an absent key from a present non-object value and reject the latter, with regression cases for these sibling forms.

AGENTS.md reference: AGENTS.md:L87-L89

Useful? React with 👍 / 👎.

provider: "claude".into(),
transport: CLAUDE_TRANSPORT.into(),
};
};
// Claude reports these as separate categories. `modelUsage` and the cost
// fields have no counterpart in UsageReceipt, so this parser does not
// guess a model or turn a list-cost field into subscription billing.
let mut tokens = 0_i64;
for field in [
"input_tokens",
"cache_creation_input_tokens",
"cache_read_input_tokens",
"output_tokens",
Comment on lines +44 to +48

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Account for non-executor usage iterations

When a Claude run invokes the Advisor tool, the top-level usage counters cover executor iterations only, while the advisor's tokens are reported separately in usage.iterations[] entries of type advisor_message, as documented in the Anthropic Advisor usage contract. This loop always sums only the four top-level counters and marks the receipt measured, so such a run silently settles fewer tokens than it consumed and can incorrectly leave budget available; either account for the complete iteration breakdown without double-counting or reject unsupported iteration types until they can be measured.

Useful? React with 👍 / 👎.

] {
let Some(count) = usage.get(field).and_then(Value::as_i64).filter(|n| *n >= 0) else {
return rejected("usage count is missing, negative or not an integer");
};
let Some(total) = tokens
.checked_add(count)
.filter(|total| *total <= 1_000_000_000)
else {
return rejected("usage total exceeds the ledger limit");
};
tokens = total;
}
UsageReceipt::Measured {
tokens,
source_sha256,
collector: CLAUDE_COLLECTOR,
}
}

#[cfg(test)]
mod tests {
use super::*;

const REAL_CAPTURE: &[u8] = include_bytes!("fixtures/claude-print-json-2.1.287.json");

#[test]
fn real_claude_2_1_287_json_maps_observed_token_counters() {
let receipt = complete_usage(REAL_CAPTURE, Some(0));
assert_eq!(receipt.state(), "measured");
// 2 uncached + 1,857 cache creation + 535 cache read + 4 output.
assert_eq!(receipt.tokens(), Some(2_398));
assert!(receipt
.ledger_source()
.unwrap()
.starts_with("claude-print-json-v1:sha256:"));
assert_eq!(
receipt.to_json(Some(1))["provenance"]["collector"],
CLAUDE_COLLECTOR
);
}

#[test]
fn claude_json_without_usage_is_not_reported_instead_of_zero() {
let receipt = complete_usage(
br#"{"type":"result","subtype":"success","is_error":false}"#,
Some(0),
);
assert_eq!(receipt.state(), "not_reported");
assert_eq!(receipt.tokens(), None);
assert!(receipt.to_json(Some(1))["reason"]
.as_str()
.unwrap()
.contains("JSON result did not include a usage object"));
}

#[test]
fn negative_or_missing_claude_counters_are_rejected() {
for usage in [
serde_json::json!({"input_tokens":-1,"cache_creation_input_tokens":2,
"cache_read_input_tokens":3,"output_tokens":4}),
serde_json::json!({"input_tokens":1,"cache_creation_input_tokens":2,
"output_tokens":4}),
serde_json::json!({"input_tokens":1,"cache_creation_input_tokens":2,
"cache_read_input_tokens":3,"output_tokens":null}),
] {
let capture = serde_json::json!({"type":"result","subtype":"success",
"is_error":false,"usage":usage});
let receipt = complete_usage(capture.to_string().as_bytes(), Some(0));
assert_eq!(receipt.state(), "rejected", "{capture}");
assert_eq!(receipt.tokens(), None);
}
}

#[test]
fn claude_error_run_is_rejected_instead_of_zero() {
let capture = serde_json::json!({"type":"result","subtype":"error",
"is_error":true,"usage":{"input_tokens":1,"cache_creation_input_tokens":2,
"cache_read_input_tokens":3,"output_tokens":4}});
for exit_code in [Some(0), Some(1), None] {
let receipt = complete_usage(capture.to_string().as_bytes(), exit_code);
assert_eq!(receipt.state(), "rejected", "{exit_code:?}");
assert_eq!(receipt.tokens(), None);
}
}
}
26 changes: 20 additions & 6 deletions src-tauri/src/store/development_usage_receipt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,16 @@ pub(in crate::store) const CODEX_TRANSPORT: &str = "native_codex_exec_json";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(in crate::store) enum UsageReceipt {
/// Final provider-reported usage, read live from process-owned stdout.
Measured { tokens: i64, source_sha256: String },
Measured {
tokens: i64,
source_sha256: String,
collector: &'static str,
},
/// The collector refused the capture; the reservation is retained.
Rejected {
reason: &'static str,
source_sha256: String,
collector: &'static str,
},
/// No trusted collector exists for this adapter and transport.
NotReported { provider: String, transport: String },
Expand All @@ -45,10 +50,12 @@ impl UsageReceipt {
Ok(tokens) => Self::Measured {
tokens,
source_sha256,
collector: CODEX_COLLECTOR,
},
Err(reason) => Self::Rejected {
reason,
source_sha256,
collector: CODEX_COLLECTOR,
},
}
}
Expand All @@ -71,9 +78,11 @@ impl UsageReceipt {
/// Ledger `source` for a measured receipt: collector plus capture digest.
pub(in crate::store) fn ledger_source(&self) -> Option<String> {
match self {
Self::Measured { source_sha256, .. } => {
Some(format!("{CODEX_COLLECTOR}:sha256:{source_sha256}"))
}
Self::Measured {
source_sha256,
collector,
..
} => Some(format!("{collector}:sha256:{source_sha256}")),
_ => None,
}
}
Expand All @@ -83,15 +92,17 @@ impl UsageReceipt {
Self::Measured {
tokens,
source_sha256,
collector,
} => json!({"state":"measured","tokens":tokens,"reservation":"settled",
"provenance":{"collector":CODEX_COLLECTOR,"measurement":"live",
"provenance":{"collector":collector,"measurement":"live",
"source":"process-owned stdout","sourceSha256":source_sha256,
"observedAt":observed_at}}),
Self::Rejected {
reason,
source_sha256,
collector,
} => json!({"state":"rejected","reason":reason,"reservation":"retained",
"provenance":{"collector":CODEX_COLLECTOR,"measurement":"none",
"provenance":{"collector":collector,"measurement":"none",
"source":"process-owned stdout","sourceSha256":source_sha256,
"observedAt":observed_at}}),
Self::NotReported {
Expand Down Expand Up @@ -138,6 +149,9 @@ fn not_reported_reason(provider: &str, transport: &str) -> String {
"opencode" => "no recorded OpenCode status-line bytes exist to parse; \
a PTY trace probe is missing"
.into(),
"claude" if transport == super::claude_usage::CLAUDE_TRANSPORT => {
"Claude JSON result did not include a usage object".into()
}
"claude" => "Claude's statusLine hook reports account-wide rate windows \
(live quota), not per-run tokens"
.into(),
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/store/development_usage_receipt_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ fn every_run_cost_receipt_names_its_state_and_provenance() {
let rejected = UsageReceipt::Rejected {
reason: "capture is empty",
source_sha256: "ff".into(),
collector: CODEX_COLLECTOR,
}
.to_json(Some(40));
let stored = run_receipt(
Expand Down
64 changes: 64 additions & 0 deletions src-tauri/src/store/fixtures/claude-print-json-2.1.287.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
{
"duration_api_ms": 1123,
"stop_reason": "end_turn",
"session_id": "<session-id>",
"total_cost_usd": "<redacted>",
"usage": {
"input_tokens": 2,
"cache_creation_input_tokens": 1857,
"cache_read_input_tokens": 535,
"output_tokens": 4,
"output_tokens_details": { "thinking_tokens": 0 },
"server_tool_use": { "web_search_requests": 0, "web_fetch_requests": 0 },
"service_tier": "standard",
"cache_creation": {
"ephemeral_1h_input_tokens": 1857,
"ephemeral_5m_input_tokens": 0
},
"inference_geo": "not_available",
"iterations": [
{
"input_tokens": 2,
"output_tokens": 4,
"cache_read_input_tokens": 535,
"cache_creation_input_tokens": 1857,
"cache_creation": {
"ephemeral_5m_input_tokens": 0,
"ephemeral_1h_input_tokens": 1857
},
"type": "message"
}
],
"speed": "standard",
"fallback_credit": null
},
"modelUsage": {
"claude-sonnet-5-5": {
"inputTokens": 2,
"outputTokens": 4,
"cacheReadInputTokens": 535,
"cacheCreationInputTokens": 1857,
"webSearchRequests": 0,
"costUSD": "<redacted>",
"contextWindow": 1000000,
"maxOutputTokens": 128000,
"thinkingTokens": 0,
"canonicalModel": "claude-sonnet-5-5",
"provider": "firstParty",
"costBasis": "list"
}
},
"permission_denials": [],
"terminal_reason": "completed",
"fast_mode_state": "off",
"fast_mode_disabled_reason": "sdk_opt_in_required",
"is_error": false,
"num_turns": 1,
"subtype": "success",
"api_error_status": null,
"result": "OK",
"type": "result",
"duration_ms": 1311,
"uuid": "<result-id>",
"result_index": 0
}
Loading