Skip to content

feat(usage): add the model usage and cost ledger - #344

Merged
mashkovd merged 5 commits into
mainfrom
feat/api-266-model-usage-ledger
Sep 22, 2026
Merged

mashkovd merged 5 commits into
mainfrom
feat/api-266-model-usage-ledger

Conversation

@mashkovd

Copy link
Copy Markdown
Member

Implements #266 against the ADR-012 contract (mctlhq/mctl-agents, docs/adr/012-model-usage-cost-attribution-contract.md).

A financial read model at the grain (run, model), so "what did this DevLoop cost" stays answerable after trace data ages out. Store + versioned pricing catalog + idempotent ingestion + query surface.

Three decisions worth reviewing

Idempotency is the primary key. id = sha256(session_id | result_uuid | model_key), ON CONFLICT DO NOTHING. A Temporal retry, a workflow replay, a restart mid-write and an export reprocessing all land on the same row.

retry_attempt is deliberately not in the key. A retry that re-runs the model is a different invocation with a new session_id and a real new charge — deduping it would lose money that was actually spent. Covered by TestRetryThatRerunsTheModelIsNotDeduped.

Absent is not zero. Every token column is nullable and every Go counter is *int64. A provider that does not report cache tokens and a run that read no cache are different facts; SUM() ignores the first and counts the second. NOT NULL DEFAULT 0 would have silently turned "not measured" into "nothing spent". The summary buckets stay nullable for the same reason.

Rates live in a file, not the binary. A published price is a fact about the world with an effective date; a wrong constant produces plausible-looking money. Cost is derived once at ingest and stored with the rate-card version, so a later card cannot reach back and rewrite history. With no catalog configured the ledger still records every token count and derives no cost — the honest behaviour when rates are unknown.

Privacy

The record type has no prompt, completion or message field. Enforced by construction, not by a redaction pass — TestRecordHasNoFreeTextContentField asserts the absence of the field rather than testing a scrubber, so a future producer that never read the ADR still cannot leak conversation text.

A nil store is 503, never an empty 200

An operator reading zero spend from an unconfigured ledger would conclude the opposite of the truth. All three endpoints are admin-only: aggregate spend per repo and per agent is commercially sensitive in a way a workflow status is not.

Testing

ADR-012's nine invariants, each at the layer it actually lives in. Idempotency, the absent-vs-zero round trip, price-change immutability and the CHECK constraint run against real Postgres — a mocked pool can only show the code handles a conflict, never that the database produces one. (These skip locally without TEST_DATABASE_URL; they ran green here against postgres:16.)

TestUsageRoutesAreRegistered exists because a handler with no route stayed invisible once already in the lifecycle work.

Scope note on acceptance criterion 1

"Ingested idempotently from the DevLoop pilot" cannot be satisfied inside this issue: the pilot instrumentation is mctlhq/.github#50, and the canonical roadmap has #50 depending on this item, not the reverse (the issue prose states it backwards — noted on #266). Read here as the ingestion path is proven idempotent against pilot-shaped records; the live producer arrives with #50.

Closes #266

🤖 Generated with Claude Code

Implements #266 against the ADR-012 contract
(mctlhq/mctl-agents, docs/adr/012-model-usage-cost-attribution-contract.md).

The ledger is a financial read model at the grain (run, model), so that "what
did this DevLoop cost" stays answerable after trace data has aged out. Store,
pricing catalog, ingestion and the query surface, plus a nil-store 503 that
must never degrade into an empty 200 — an operator reading zero spend from an
unconfigured ledger would conclude the opposite of the truth.

Three decisions worth stating, because each is easy to undo later:

Idempotency is the primary key. The row id is sha256(session_id | result_uuid
| model_key), so a Temporal retry, a workflow replay, a restart mid-write and
an export reprocessing all land on the same row and `ON CONFLICT DO NOTHING`
makes the repeat a no-op. retry_attempt is deliberately NOT in the key: a
retry that re-runs the model is a genuinely different invocation carrying a
new session_id and a real new charge, and deduping it would lose money that
was actually spent.

Absent is not zero. Every token column is nullable. A provider that does not
report cache tokens and a run that read no cache are different facts, and
SUM() ignores the first while counting the second. NOT NULL DEFAULT 0 would
have silently converted "not measured" into "nothing spent", so the Go types
are *int64 and the summary buckets stay nullable too.

Rates live in a file, not in the binary. A published price is a fact about the
world with an effective date; a wrong constant produces plausible-looking
money and a price change would need a release. Cost is derived once at ingest
and stored with the rate-card version that produced it, which is what makes a
later card unable to reach back and rewrite history.

The record type has no prompt, completion or message field. That is enforced
by construction rather than by a redaction pass, so a future producer written
by someone who never read the ADR still cannot leak conversation text into the
ledger; a reflection test asserts the absence rather than testing a scrubber.

Tests cover the ADR's nine invariants at the layer each actually lives in.
The idempotency, absent-versus-zero round trip, price-change immutability and
the CHECK constraint are exercised against real Postgres, because a mocked
pool can only show that the code handles a conflict, never that the database
produces one. The route-registration test is there because a handler with no
route stayed invisible once already in the lifecycle work.

Note on the issue's first acceptance criterion: "ingested idempotently from
the DevLoop pilot" cannot be satisfied inside this issue, because the pilot
instrumentation is .github#50 and the canonical graph has #50 depending on
this item, not the reverse. Read here as "the ingestion path is proven
idempotent against pilot-shaped records"; the live producer arrives with #50.

Claude-Session: https://claude.ai/code/session_01TpRNAyt63CpPb9xpQPBy2G
Copilot AI lite review requested due to automatic review settings September 22, 2026 19:43

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@claude

claude Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 4m 22s —— View job


Code review complete

  • Read the pinned diff (945fa83...478bb6a)
  • Review store + SQL (idempotency, injection, nullability)
  • Review pricing catalog + cost derivation
  • Review handlers + routes (authz, input validation)
  • Review wiring in cmd/api/main.go
  • Post inline findings and submit verdict

Verdict submitted against 478bb6a: changes requested — 0 P1, 4 P2, 3 P3. Seven inline comments posted.

Blocking (P2)

# Where Finding
1 internal/usage/pricing.go:86 Pricing.Provider is parsed, documented in the rate-card example, and never consulted — byModel keys on canonical model alone, so a Bedrock card prices a firstParty invocation. Equal effective_from entries are also ordered by a non-stable sort.Slice, so the same catalog file can price identically across restarts differently.
2 internal/usage/types.go:181 EnsureID derives the sha256 only when id is empty, and Record.ID is decoded straight off the wire — the "idempotency is the primary key" guarantee is opt-out. A producer sending its own id double-counts on retry; one that collides with a future real id causes that charge to be silently dropped by ON CONFLICT DO NOTHING. No test sends an id.
3 internal/usage/store.go:429 Summary has no LIMIT and ignores Filter.Limit; a zero Filter matches everything. ?group_by=temporal_workflow_id with no window returns one bucket per workflow in the whole ledger. List is capped; the aggregate path, whose cardinality actually grows, is not.
4 internal/usage/store.go:315 ?limit=5000 falls into the > 1000 branch and resets to 200 — asking for more returns fewer, with no truncation signal on a financial read model.

Non-blocking (P3)

  • types.go:162 — unescaped \| join; distinct tuples can hash identically, and the no-uuid fallback shares the uuid path's 3-part shape.
  • store.go:75 — DOUBLE PRECISION for money; SUM() is order-dependent under parallel plans, so totals are not reproducible. NUMERIC is cheap now, a rewrite later.
  • handlers_usage.go:95 — a body over 1 MiB reports 400 "invalid JSON body", indistinguishable from malformed JSON, while the record-count cap correctly answers 413.
  • store.go:195 — one invalid record rejects all 500 in the batch, with no indication of which.

What holds up well

The (run, model) grain, the absent-vs-zero treatment carried consistently from *int64 through nullable columns to nullable summary buckets, the calculated_cost_requires_version CHECK as defence in depth, the closed GroupBy set, nil store → 503, and TestRecordHasNoFreeTextContentField asserting the absence of a field rather than testing a scrubber. The Postgres-backed tests exercise the right invariants at the right layer. No SQL injection: every predicate is parameterised and the group-by column comes from a fixed map.

Caveat

go build, go vet and go test could not be executed in this environment (sandbox denied the commands), so this is a static review — the Postgres-backed tests were not re-run here. Points 1–4 are read off the code paths directly, not inferred from a failing run.
· branch feat/api-266-model-usage-ledger

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.8-flash-medium · commit 478bb6a.

Finding 1: Nil pointer dereference when ingesting batch containing null record

  • Severity: P2
  • File: internal/usage/store.go:183
  • Anchor: + for _, r := range records {
  • Concrete failure scenario: A client sends a batch containing a null item in the JSON array (e.g. {"records": [null]}). In IngestUsageRecords, req.Records unmarshals into a slice containing a nil *usage.Record. IngestUsageRecords does not validate whether elements are non-nil before invoking s.opts.Usage.Ingest(ctx, req.Records). In Store.Ingest, the loop calls r.EnsureID(), which immediately dereferences r.SchemaVersion on a nil pointer, triggering a runtime panic and causing an unhandled HTTP 500 error or crashing the server.
  • Proposed fix: In Store.Ingest (or inside Record.EnsureID), check for nil records and return a validation error:
    for _, r := range records {
        if r == nil {
            return nil, fmt.Errorf("%w: record must not be nil", ErrInvalidRecord)
        }
        if err := r.EnsureID(); err != nil {
            return nil, err
        }
        ...

Finding 2: Pricing catalog ignores Provider, causing rate card clobbering across providers

  • Severity: P2
  • File: internal/usage/pricing.go:78
  • Anchor: +func pricingKey(model string) string { return strings.ToLower(strings.TrimSpace(model)) }
  • Concrete failure scenario: The Pricing struct, Record struct, and rate-card catalog schema explicitly specify a Provider field (e.g., firstParty, bedrock, vertex). When a pricing catalog defines rates for the same model across different providers (for example, Anthropic direct vs. AWS Bedrock for claude-opus-5), NewCatalog indexes entries strictly by pricingKey(e.CanonicalModel). This groups all provider entries for the same model into a single slice. Catalog.At() and Catalog.Calculate() do not accept or check Provider. As a result, the entry with the latest EffectiveFrom (or arbitrary order if timestamps match) clobbers other providers' rate cards, causing usage from one provider to be billed at another provider's rate.
  • Proposed fix: Key catalog rate cards by both model and provider (e.g., pricingKey(model, provider) or allow an empty provider to act as a fallback wildcard), and update Catalog.At() and Catalog.Calculate() to match on r.Provider.

Finding 3: Querying GET /api/v1/usage/records?model=... does not match records stored without canonical_model

  • Severity: P2
  • File: internal/usage/store.go:283
  • Anchor: + if f.CanonicalModel != "" {
  • Concrete failure scenario: In Record, ModelKey is required while CanonicalModel is optional. The API documentation example shows records ingested with model_key only ({ "model_key": "claude-opus-5", ... }). When such records are ingested, CanonicalModel is stored as an empty string in Postgres. When an operator queries GET /api/v1/usage/records?model=claude-opus-5, usageFilterFromQuery sets f.CanonicalModel = "claude-opus-5", and f.where() builds canonical_model = $1. The query returns 0 records because canonical_model is empty, even though model_key matches. Furthermore, GetUsageSummary grouped by canonical_model groups these records under an empty key "".
  • Proposed fix: Either default CanonicalModel to ModelKey in Record.EnsureID() when CanonicalModel is empty:
    if r.CanonicalModel == "" {
        r.CanonicalModel = r.ModelKey
    }
    or update Filter.where() to filter on:
    add("(canonical_model = $%d OR (canonical_model = '' AND model_key = $%d))", f.CanonicalModel)

Finding 4: No test visible in this diff for LoadCatalogFile and ParseCatalog

  • Severity: P3
  • File: internal/usage/catalog_file.go:44
  • Anchor: +func LoadCatalogFile(path string) (*Catalog, error) {
  • Concrete failure scenario: There is no test visible in this diff verifying that LoadCatalogFile and ParseCatalog correctly load rate cards from disk, handle malformed JSON documents, or report clear errors when the catalog file path does not exist.
  • Proposed fix: Add unit tests in internal/usage/catalog_file_test.go covering ParseCatalog with valid and invalid rate-card JSON, and LoadCatalogFile with existing and missing files.

Finding 5: ListUsageRecords success response path is not covered in handler tests

  • Severity: P3
  • File: internal/api/handlers_usage_test.go:105
  • Anchor: +func TestUsageHandlers_NilStoreIs503NotEmpty(t *testing.T) {
  • Concrete failure scenario: While handlers_usage_test.go tests 503 nil store handling, auth boundaries, bad query parameter rejection, and GetUsageSummary, it does not test the successful execution of ListUsageRecords over HTTP to ensure filtering, limit handling, and the JSON payload structure ({"records": [...], "count": ...}) serialize as expected.
  • Proposed fix: Add a test function in internal/api/handlers_usage_test.go that seeds records, calls h.ListUsageRecords, and asserts HTTP 200 with the expected records slice and count integer.

Comment thread internal/usage/pricing.go Outdated
if c == nil {
return Pricing{}, false
}
list := c.byModel[pricingKey(model)]

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 — Pricing.Provider is accepted, documented in the rate-card example (docs/model-usage-ledger.md:38, catalog_file.go:37) and then never consulted. NewCatalog keys byModel on pricingKey(e.CanonicalModel) alone (line 68) and At resolves on model + instant only, so a Bedrock card and a firstParty card for the same canonical model land in one list and whichever has the later effective_from prices both. Those rates genuinely differ per provider, so this silently produces plausible-looking wrong money — the exact failure the package doc says it exists to prevent.

Two ways it bites:

  1. [{provider: "bedrock", effective_from: 2026-09-01, input_per_mtok: 20}, {provider: "firstParty", effective_from: 2026-01-01, input_per_mtok: 10}] prices every firstParty invocation after September at the Bedrock rate.
  2. Two cards for the same model with the same effective_from and different providers: sort.Slice (line 73) is not stable, so which one wins is unspecified and can differ between process restarts. Identical records priced differently across restarts is directly against invariant 7.

Either key the catalog on (canonical_model, provider) with a provider-agnostic fallback, or reject a catalog that carries a non-empty provider until it is honoured. Also worth making the sort deterministic (sort.SliceStable, or tie-break on Version) and rejecting duplicate (model, provider, effective_from) triples in NewCatalog. No test covers a multi-provider catalog today.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 5d56091. The catalog now keys on (canonical_model, provider); a card with no provider is an explicit wildcard used only when the provider has no card of its own. sort.SliceStable, and two cards sharing (model, provider, effective_from) are now rejected at load rather than ordered — as you say, there is no correct answer there, and picking one silently breaks invariant 7 from a second direction. Covered by TestPricingIsResolvedPerProvider, TestProviderlessCardIsAWildcard and TestCatalogRejectsTwoCardsEffectiveAtTheSameInstant. agy raised the same defect independently (its finding 2).

Comment thread internal/usage/types.go Outdated
if strings.TrimSpace(r.ModelKey) == "" {
return fmt.Errorf("%w: model_key is required", ErrInvalidRecord)
}
if r.ID == "" {

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 — the deterministic id is only used when the producer did not supply one, and Record.ID is decoded straight off the wire (json:"id", line 88) by ingestUsageRequest. So the guarantee the PR description calls "idempotency is the primary key" is opt-out: a producer that sets "id": "<uuid4>" on each delivery gets a new row per retry and double-counts a paid invocation, and a producer with an id-generation bug can pre-occupy the sha256 of a future real invocation, which ON CONFLICT DO NOTHING then silently drops.

Every test in this PR omits id, so nothing catches a producer that sends one. Suggest recomputing unconditionally here:

r.ID = DeterministicID(r.SessionID, r.ResultUUID, r.ModelKey, r.NumTurns)

or, if a supplied id must be accepted, comparing it against the derived value and returning ErrInvalidRecord on mismatch. The field can still be serialised on read — it is only the ingest direction that should not trust it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Correct, and the sharpest finding in the round — the guarantee the PR description leads with was opt-out. Fixed in 5d56091: EnsureID now derives the id unconditionally, and a supplied id that disagrees returns ErrInvalidRecord rather than being silently overwritten, so a producer computing the key wrongly finds out instead of quietly double-counting. TestSuppliedIDCannotBypassTheDedupeKey covers both directions, and TestUsageHandlers_ClientSuppliedIDIsRejected covers it over HTTP, which is the path that actually mattered.

Comment thread internal/usage/store.go Outdated
SUM(provider_reported_cost), SUM(calculated_cost), SUM(invoice_reconciled_cost)
FROM model_usage_records%s
GROUP BY %s
ORDER BY COUNT(*) DESC, key ASC`, col, where, col)

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 — Summary emits no LIMIT and ignores Filter.Limit, and a zero Filter matches everything. GET /api/v1/usage/summary?group_by=temporal_workflow_id (or work_item_id) with no since/until is a legitimate operator query that returns one bucket per distinct workflow id in the whole ledger — unbounded rows, all accumulated into out and then JSON-encoded in one buffer. List is capped at 200/1000 rows; the aggregate path, whose cardinality is the one that actually grows without bound over months, has no cap at all.

Suggest applying Filter.Limit (with the same default/ceiling as List) plus an explicit LIMIT $n here, and signalling truncation in the response so an operator cannot read a clipped bucket list as the complete breakdown.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 5d56091. Summary now takes the same page bounds as List and the response carries truncated and limit. Your point about which side needed it more is the right one: List caps rows that are bounded per query anyway, while the group-by cardinality on temporal_workflow_id/work_item_id grows for as long as the ledger exists. TestSummaryIsBoundedAndSaysSo asserts both the cap and the flag.

Comment thread internal/usage/store.go Outdated
where, args := f.where()
limit := f.Limit
if limit <= 0 || limit > 1000 {
limit = 200

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 — asking for more rows silently returns fewer. usageFilterFromQuery only rejects limit <= 0, so ?limit=5000 reaches here and falls into the limit > 1000 branch, which resets to 200 rather than clamping to 1000. A caller who asks for 5000 gets a smaller page than a caller who asks for 1000, and nothing in the response says so — ListUsageRecords returns "count": len(records), which reads as "this is how many records match".

That matters more here than on a status endpoint: this file is a financial read model, and anyone summing records[].calculated_cost client-side over a silently clipped page understates real spend. Two things worth fixing together:

  • clamp instead of shrink (if limit > 1000 { limit = 1000 }), or reject limit > 1000 as a 400 in the handler — the same "an unparseable filter is an error, never a dropped predicate" rule handlers_usage.go:129 states;
  • make truncation visible (a truncated flag or a cursor), since with no pagination at all any result at the cap is indistinguishable from a complete one.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 5d56091, both halves. The store clamps to the ceiling instead of resetting to the default, and the handler rejects limit>1000 as a 400 — consistent with the "an unparseable filter is an error, never a dropped predicate" rule you quoted from the same file. Truncation is now visible on the summary side; on List the clamp plus the 400 means a caller can no longer receive silently fewer rows than it asked for. TestOverLargeLimitClampsRatherThanShrinks and TestUsageHandlers_OverLargeLimitIsRejected.

Comment thread internal/usage/types.go Outdated
}
parts = []string{sessionID, modelKey, turns}
}
sum := sha256.Sum256([]byte(strings.Join(parts, "|")))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 — the parts are joined with an unescaped |, so distinct field tuples can produce the same digest, and a collision here is a silently dropped paid invocation (ON CONFLICT DO NOTHING). Two shapes:

  • delimiter injection: ("a", "b|c", "d") and ("a", "b", "c|d") both hash a|b|c|d;
  • the fallback produces a 3-part string of the same shape as the uuid path, so (session, resultUUID: "X", model: "3") collides with (session, resultUUID: "", model: "X", numTurns: 3).

Unlikely with uuid-shaped session ids, but free to rule out: hash length-prefixed parts, or give the fallback its own discriminator (e.g. "v1:noresult" as a leading element). TestDeterministicIDFallsBackWithoutResultUUID covers the non-colliding case only.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Taken, despite the low probability — the consequence is what decided it: a collision here is not a bad read but a paid invocation that ON CONFLICT DO NOTHING discards silently, and it costs nothing to rule out by construction. 5d56091 length-prefixes every part and gives the two shapes distinct discriminators (v1.uuid / v1.noresult), so neither of your cases can collide. TestDeterministicIDResistsDelimiterInjection covers both.

Comment thread internal/usage/store.go Outdated
web_search_requests BIGINT,

provider_reported_cost DOUBLE PRECISION,
calculated_cost DOUBLE PRECISION,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 — DOUBLE PRECISION for money in a ledger whose stated purpose is reproducible cost. SUM(double precision) in Postgres is order-dependent, and under a parallel aggregate plan the partial-sum order is not fixed, so the same GET /usage/summary can return slightly different totals on successive calls over a large table. That undercuts the reproducibility the calculated_cost_requires_version CHECK just above is there to protect.

NUMERIC would make both the stored value and SUM() exact and deterministic. Cheap to change now while the table is empty; a type change later is a migration on a table nobody wants to rewrite.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Taken. NUMERIC in 5d56091. Your framing decided it: the table is empty today and a type change later is a migration nobody wants, so the cost of doing it now is approximately zero and the cost of deferring is not. I re-ran the Postgres-backed tests against a freshly dropped table rather than the existing one, since CREATE TABLE IF NOT EXISTS would have left the old DOUBLE PRECISION columns in place and the change would have gone untested.

}
r.Body = http.MaxBytesReader(w, r.Body, usageMaxBodyBytes)
var req ingestUsageRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 — a batch that trips the 1 MiB MaxBytesReader surfaces as 400 "invalid JSON body". The producer has no way to tell "your JSON is malformed" (do not retry) from "your batch is too big" (split and retry), and it is inconsistent with the record-count cap eleven lines below, which correctly answers 413. docs/model-usage-ledger.md:77 presents both caps as one rule.

errors.As(err, new(*http.MaxBytesError)) → 413 with a "batch exceeds 1 MiB" message would make both caps self-describing.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 5d56091 — errors.As(err, new(*http.MaxBytesError)) → 413 with a byte-count message. You are right that it was inconsistent with the record-count cap eleven lines below and that the doc presents both as one rule; the doc now states both as 413. TestUsageHandlers_OversizedBodyIs413NotMalformedJSON.

Comment thread internal/usage/store.go Outdated
}
}
if err := r.Validate(); err != nil {
return nil, err

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 — one bad record rejects the whole batch. Validation runs over every record before the transaction opens, so a single malformed entry in a 500-record batch means none of the other 499 paid invocations land, and the handler turns it into a 400 that gives the producer no way to know which record was at fault. If the defect is in the producer rather than transient, that batch never lands at all — which is the "the money is spent whether or not the row lands" failure the router comment at router.go:472 says the ledger cannot absorb.

The all-or-nothing write is right; it is the all-or-nothing validation that costs here. Consider partitioning: insert the valid records, and return the rejected ids with their reasons ({"accepted": n, "deduped": n, "rejected": [{"index": i, "reason": "..."}]}), which also removes the ambiguity in the current 400.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Partially taken, and I want to be explicit about the part I did not take. The ambiguity is fixed: a rejection now names the offending index (record 7: ...), so a producer knows what to correct rather than re-sending 500 records blind. I did not implement partial acceptance. It changes the ingest contract rather than fixing a defect, and the argument for it depends on a producer that emits malformed records persistently — which does not exist yet, since the first producer arrives with .github#50. Deferring it also keeps the success response one shape rather than two. If the pilot shows records being rejected in practice, that is the evidence for doing it, and it should be its own PR so it can roll back independently. Flagging the disposition rather than silently declining.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Has P1/P2 findings, changes requested: 0 P1, 4 P2, 3 P3.

Well-structured ledger and the invariant-per-layer test design is genuinely good. Four things block:

  • Pricing is provider-blind (pricing.go). Pricing.Provider is parsed, documented in the rate-card example, and never consulted — a Bedrock card can price a firstParty invocation. Equal effective_from entries are also resolved by a non-stable sort.Slice, so pricing can differ between restarts.
  • Client-supplied id bypasses the dedupe key (types.go:181). EnsureID only derives the sha256 when id" is empty, and id` is decoded straight off the wire — so the central idempotency guarantee is opt-out, and no test sends one.
  • Summary is unbounded (store.go:429). No LIMIT, ignores Filter.Limit, and a zero filter matches everything: group_by=temporal_workflow_id returns a bucket per workflow in the whole ledger.
  • limit>1000 silently returns 200 rows (store.go:315), fewer than asking for 1000, with no truncation signal — on a surface whose own doc says a filter must never be silently dropped.

P3s: hash delimiter ambiguity, DOUBLE PRECISION for money, 1 MiB overflow reported as "invalid JSON body", all-or-nothing batch validation. Note I could not execute go build/go test in this environment, so this is a static review; the Postgres-backed tests were not re-run here.

Claude raised 4 P2 / 3 P3; agy raised 3 P2 (one of which claude did not find)
and 2 P3. All verified against the code before fixing; none dismissed.

P2 — a null record panicked the process (agy 1). {"records":[null]} decodes to
a nil element and the ingest loop dereferenced it. Reproduced as a panic first,
then fixed: nil is rejected as ErrInvalidRecord naming the index, and the guard
also sits on EnsureID/Validate so a non-HTTP caller cannot reach it either.

P2 — the dedupe key was opt-out (claude 2). EnsureID only derived the sha256
when `id` was empty, and `id` decodes straight off the wire, so a producer
emitting a uuid per delivery would get a row per retry and double-count a paid
invocation. The id is now always derived; a supplied id that disagrees is a 400
rather than a silent overwrite, so a producer computing it wrongly finds out.

P2 — pricing was provider-blind (claude 1, agy 2). Pricing.Provider was parsed,
documented in the rate-card example and never consulted, so a Bedrock card
priced a firstParty invocation at Bedrock rates. The catalog now resolves on
(canonical_model, provider) with a provider-less card as an explicit wildcard.
sort.Slice is also not stable, so two cards sharing an effective_from could
resolve differently between restarts — those are now rejected at load, since
two cards in force at one instant have no correct answer.

P2 — Summary was unbounded (claude 3). No LIMIT and a zero filter matches
everything, so group_by=temporal_workflow_id returned a bucket per workflow
that ever ran. Now capped like List, and the response carries truncated/limit:
a clipped breakdown is otherwise indistinguishable from a complete one.

P2 — a model filter missed its own records (agy 3). A producer reporting only
model_key leaves canonical_model empty, so filtering on canonical_model omitted
those rows and a repo's spend read lower than it was. The filter now matches the
model_key fallback rather than writing model_key into canonical_model, which
would have made the record claim something the provider never reported.

P2 — an over-large limit shrank the page (claude 4). limit>1000 reset to the
200 default, so asking for 5000 returned fewer rows than asking for 1000, with
nothing saying so. The handler now rejects it as a 400 like every other filter,
and the store clamps for direct callers.

P3s taken: the id parts are length-prefixed with per-shape discriminators, so no
field tuple can collide with another (a collision is a paid invocation that ON
CONFLICT DO NOTHING discards); money is NUMERIC, since SUM(double precision) is
order-dependent under a parallel plan and the table is empty now; a body over
the byte cap answers 413 instead of "invalid JSON body", so a producer can tell
"do not retry" from "split and retry"; a rejected batch names the offending
index. Catalog loading and the list success path gained the tests agy noted
were missing.

Not taken: partial batch acceptance (claude P3, store.go:195). Returning
per-record rejects changes the ingest contract rather than fixing a defect, and
the ambiguity it was aimed at is gone now that the error names the index. Worth
its own PR if the producer in .github#50 turns out to need it.

Lint: gosec G304 on the catalog read is annotated at the call site rather than
excluded repo-wide — the path is an operator-set env var, and a repo-wide
exclude would hide the next variable-path read. Dropped an unused test helper.

Every fix has a regression test, and the Postgres-backed ones were re-run
against a freshly dropped table so the NUMERIC change is actually exercised
rather than inheriting the old column type.

Claude-Session: https://claude.ai/code/session_01TpRNAyt63CpPb9xpQPBy2G
@claude

claude Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 7m 35s —— View job


Code review complete

  • Read the pinned diff (945fa83...5d56091)
  • Verify the round-1 fixes actually landed (id trust, pricing provider, Summary bound, limit clamp, nil record, model filter)
  • Review store + SQL
  • Review pricing catalog + cost derivation
  • Review handlers + routes + wiring
  • Post inline findings and submit verdict

Verdict submitted against 5d56091: changes requested — 0 P1, 1 P2, 5 P3. Six inline comments posted.

Round 1 is genuinely cleared

Checked against the code, not the commit message. The catalog keys on (canonical_model, provider) with a provider-less wildcard and rejects two cards effective at the same instant rather than resolving them by an unstable sort; the id is derived unconditionally and a disagreeing supplied id is a 400, covered over HTTP as well as in unit tests; Summary is capped and reports truncated/limit; an over-large limit is a 400 in the handler with a clamp behind it. The P3s landed too — length-prefixed id parts with per-shape discriminators, NUMERIC money, 413 for an oversized body, the offending index named in the rejection. Declining partial-batch acceptance with a stated reason is a fair call.

Blocking (P2)

Where Finding
internal/api/handlers_usage.go:232 List truncation is still silent. Summary got truncated/limit on the argument that a clipped list reads as a complete one; the record list still returns count: len(records) against an undocumented default of 200 and a 1000 ceiling, with no offset or cursor. A caller summing records[].calculated_cost over a busy week understates spend and cannot tell. Same fix as the function below it: select limit+1, report truncated.

Non-blocking (P3)

  • store.go:493 — the summary truncates by COUNT(*) DESC on a ledger read for money, so a small number of expensive invocations can be clipped out by volume; truncated does not say the cut was by row count.
  • store.go:209 — ErrNoPricing swallowed with no log or counter. A card naming a provider while the producer omits one, or a catalog that fails to load at boot, makes every row costless with nothing pointing at why.
  • store.go:486 — group_by=canonical_model still buckets model_key-only records under '', though the filter learned that fallback in this round.
  • store_test.go:415 — TestOverLargeLimitClampsRatherThanShrinks passes against the pre-fix code: 3 rows cannot distinguish a clamp to 1000 from a reset to 200.
  • handlers_usage.go:50 — usageMaxLimit duplicates the store ceiling across a package boundary; drift turns valid limits into 400s.

What holds up well

The (run, model) grain, absent-vs-zero carried consistently from *int64 through nullable columns to nullable summary buckets, cost derived once at ingest and stored with its rate-card version, the calculated_cost_requires_version CHECK as defence in depth, the closed GroupBy set, nil store → 503, and TestRecordHasNoFreeTextContentField asserting the absence of a field rather than testing a scrubber. Every parameter is still parameterised — no injection path. The new regression tests name the finding they lock in, which makes the second round easy to verify.

Caveat

go build, go vet and go test could not be executed here (the sandbox declined them), so this is a static review and the Postgres-backed tests were not re-run.
· branch feat/api-266-model-usage-ledger

@mashkovd

Copy link
Copy Markdown
Member Author

Round 1 addressed in 5d56091. Answering agy here, since its findings arrive as a comment rather than as threads.

Finding 1 (P2) — nil pointer on a null record: correct, and the most valuable finding of the round. claude did not catch this one. {"records":[null]} decodes to a nil *Record and the ingest loop dereferenced it. I reproduced it as an actual panic before fixing rather than taking it on description:

PANIC CONFIRMED: runtime error: invalid memory address or nil pointer dereference

Any authenticated admin could have crashed the process with a three-word body. Fixed with a nil check that names the index, plus guards on EnsureID/Validate so a non-HTTP caller cannot reach it either. TestIngestRejectsNilRecordInBatch and TestUsageHandlers_NullRecordIsRejectedNotPanicked.

Finding 2 (P2) — provider-blind pricing: correct, same defect claude raised independently. Catalog now keys on (canonical_model, provider); details on that thread.

Finding 3 (P2) — ?model= misses records stored without canonical_model: correct. A producer reporting only model_key leaves canonical_model empty, so the filter omitted exactly those rows and a repository’s spend read lower than it was — the quiet direction of wrong, which is the bad one here.

I fixed it in the filter rather than by writing model_key into canonical_model at ingest. Normalising would have been less code, but the column would then assert a canonical model the provider never reported, and ADR-012 sources that field from ModelUsage.canonicalModel specifically. TestModelFilterMatchesRecordsWithoutCanonicalModel.

Findings 4 and 5 (P3) — missing tests: correct, both added. TestParseCatalogReadsARateCardDocument covers the loader including a malformed document and a version-less card; TestUsageHandlers_ListReturnsIngestedRecords covers the list success path including that the derived cost comes back with the version that produced it.

Nothing was dismissed this round — all 5 P2 and 4 of 5 P3 are fixed with regression tests. The one I did not take is partial batch acceptance (claude P3), dispositioned on its own thread: it changes the ingest contract rather than fixing a defect, and the ambiguity it targeted is gone now that errors name the index.

One note on verification, since claude flagged that it could not execute anything: the Postgres-backed tests were re-run against a freshly dropped table. CREATE TABLE IF NOT EXISTS would have left the old DOUBLE PRECISION columns in place, so the NUMERIC change would have passed locally while being entirely untested. Confirmed the columns are numeric after the rebuild.

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.8-flash-medium · commit 5d56091.

No significant issues found.

Comment thread internal/api/handlers_usage.go Outdated
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"records": records,
"count": len(records),

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 — List is the one surface left where truncation is silent. Round 2 gave Summary a truncated/limit pair on exactly this argument ("a clipped bucket list is indistinguishable from a complete one", store.go:453-457) and gave the handler a 400 for limit > 1000, but the record list still answers {"records": [...], "count": len(records)} with no cap signal at all.

Concretely: GET /api/v1/usage/records?repository=mctlhq/mctl-api&since=... over a busy week matches 4,000 rows. The store returns the newest 200 — the default, whose rule docs/model-usage-ledger.md documents but whose value it never states — count reads 200, and a caller summing records[].calculated_cost reports about 5% of real spend as the whole. Asking for the 1000 ceiling has the same shape one order of magnitude up, and since there is no offset and no cursor, the remaining rows are not reachable by any request at all.

That is the failure mode this endpoint's own 400-on-over-large-limit was added to prevent ("a caller summing a clipped page understates real spend", handlers_usage.go:198-202); clamping the page fixed the smaller half of it.

Cheapest fix is the one already applied one function down: select limit+1 in Store.List, drop the extra row, and return truncated plus the effective limit next to count. Keyset paging would also make the rest reachable rather than merely visible as missing — ORDER BY recorded_at DESC, id ASC is already a unique sort key, so a cursor is nearly free.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Taken in 56cac52. You are right, and the inconsistency was mine: I made exactly this argument for Summary in round 1 and then left List returning count: len(records) against an undocumented default. List now returns a ListResult with truncated and limit, selected as limit+1 so truncation is detected rather than inferred. TestUsageHandlers_ListReportsTruncation asserts a clipped page says so, and the docs now state that count is the size of this page, not the number of matching records.

Comment thread internal/usage/store.go
SUM(provider_reported_cost), SUM(calculated_cost), SUM(invoice_reconciled_cost)
FROM model_usage_records%s
GROUP BY %s
ORDER BY COUNT(*) DESC, key ASC

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 — the summary clips by volume on a ledger that is read for money. ORDER BY COUNT(*) DESC with LIMIT limit+1 means a bucket holding three very expensive invocations loses its place to 200 cheap ones, so group_by=target_repo truncated at the default 200 can drop precisely the repo an operator is hunting for. truncated tells them something was cut, but not that the cut was by row count rather than by spend — and on this table the bucket that matters most is the one most likely to be missing.

ORDER BY SUM(calculated_cost) DESC NULLS LAST, COUNT(*) DESC, key ASC would make the retained set the one the question is about while keeping the tie-break deterministic.

Worth documenting the 200-bucket default in the summary section of docs/model-usage-ledger.md too: the records section states its limit rule, the summary section states none, so an operator has no way to know a breakdown is capped until they happen to read truncated: true.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fair, and taken in 56cac52 — as a disclosure rather than a reordering. The response now carries truncated_by: "record_count".

I did not reorder buckets by cost. calculated_cost is nullable by design (a model with no rate card, or a provider-reported cost under a future provider), so ordering by it would sort unpriced records to one end and bury exactly the records whose absence of cost an operator most needs to notice. Ordering by volume and naming the axis keeps the failure visible instead of moving it somewhere harder to see. If a cost-ranked breakdown turns out to be what operators actually ask for, that is a query parameter with its own null-handling decision, not a change to the default.

Comment thread internal/usage/store.go Outdated
// the token counts are still the truth, and a cost can be derived
// later once a card exists. Dropping the record would lose the
// measurement permanently.
if err := s.catalog.Calculate(r); err != nil && !errors.Is(err, ErrNoPricing) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 — ErrNoPricing is swallowed with no trace anywhere. Not rejecting the record is right, and the comment above makes the case well, but nothing records that a ledger configured with a catalog just stored an unpriced row.

Two realistic paths land here every time rather than occasionally:

  1. A card naming provider: "firstParty" — the shape both docs/model-usage-ledger.md:38 and catalog_file.go:37 show — while the producer omits provider. The field is omitempty, and At falls back only to a provider-less wildcard card, never to another provider's, so every record from that producer is costless.
  2. A catalog that fails to load at boot: cmd/api/main.go logs that once, the store runs with catalog == nil from then on, and this branch is never even reached.

In both the ledger answers calculated_cost: null for every row. The absent-is-not-zero design means a reader can at least tell "not measured" from "zero", which is why this is P3 — but nothing points at why, and the discovery moment is the first reconciliation rather than the first ingest.

A slog.Warn with model, provider and recorded_at (no content — the type has no field for it), or an Unpriced []string beside Accepted/Deduped in IngestResult, would surface it where it happens.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Correct and taken in 56cac52. The two cases you name are the ones that worried me too: a catalog that fails to load at boot and one whose cards all name a provider the producer omits both end with every row costless, and "spend is zero" then reads identically to "spend was never priced". Now a slog.Warn naming canonical model, model key and provider. Still non-fatal — the token counts remain true and a cost can be derived later — which is why it needed to be loud rather than an error.

Comment thread internal/usage/store.go
// One row beyond the limit, so truncation is detected rather than guessed.
args = append(args, limit+1)
q := fmt.Sprintf(`
SELECT %s AS key,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 — the model_key fallback landed on the filter but not on the aggregation. Filter.where now matches model_key when canonical_model is empty (store.go:303-314), so ?model=claude-opus-5 finds a record whose producer reported only model_key. group_by=canonical_model still groups that same record under ''.

So the two surfaces disagree about one row: the filter returns it as that model's spend, the per-model breakdown files it under an empty key alongside every other model's unattributed spend. Same class of answer the filter fix was for — visible rather than silent, hence P3, but still not that model's cost.

COALESCE(NULLIF(canonical_model, ''), model_key) in both the projection and the GROUP BY for GroupByModel keeps them consistent. TestModelFilterMatchesRecordsWithoutCanonicalModel covers the filter side; the summary side has no equivalent.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Correct and taken in 56cac52 — the filter learned the fallback in round 1 and the group-by did not, which is precisely the half-fix worth catching. groupColumns[GroupByModel] is now COALESCE(NULLIF(canonical_model, ), model_key). The map values remain fixed SQL fragments rather than request data, so the closed GroupBy set still keeps a query parameter out of the query. TestSummaryByModelUsesTheModelKeyFallback.

Comment thread internal/usage/store_test.go Outdated
if err != nil {
t.Fatalf("List: %v", err)
}
if len(huge) != 3 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 — this test cannot fail on the regression it names. It seeds 3 rows and asserts 3 come back, which the pre-fix code satisfies just as well: the old branch reset limit > maxQueryLimit to defaultQueryLimit (200), and clamping to 1000 versus resetting to 200 are indistinguishable at any row count below 200.

To actually lock the fix in, either seed more than defaultQueryLimit records and assert more than 200 come back, or factor the clamp shared by List and Summary into a helper (clampLimit(int) int) and table-test it directly — the latter also covers it on a laptop with no TEST_DATABASE_URL, where this test silently skips.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Correct, and this is the finding I am most glad you caught — the test claimed to lock in a fix it could not detect. Three rows cannot distinguish a clamp to 1000 from a reset to 200, so it passed against the unfixed code.

Rewritten in 56cac52 with DefaultQueryLimit+1 rows, and this time I re-introduced the original bug and watched it fail before keeping the fix:

rows = 200, want 201; an over-large limit did not clamp to the ceiling

It now also asserts that the default page reports truncated. I mutation-checked the other regressions in this PR and simply did not do it here; that was the gap, not the row count.

Comment thread internal/api/handlers_usage.go Outdated

// usageMaxLimit mirrors the store's page ceiling so the handler can reject an
// over-large request instead of letting the store clamp it invisibly.
const usageMaxLimit = 1000

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 — usageMaxLimit restates usage.maxQueryLimit (store.go:147) across a package boundary, and the comment above says so. The two can drift: raise the store's ceiling and the handler keeps rejecting at 1000, so every limit between the two values becomes a 400 for a page the store would have served, with an error message quoting a number that is no longer the ceiling.

Exporting the store's constant (usage.MaxQueryLimit) and referring to it here removes the possibility rather than documenting it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Taken in 56cac52. DefaultQueryLimit and MaxQueryLimit are exported from the store and the handler reads the ceiling from there, so the two cannot drift. Your failure mode is the right one to name: a store ceiling raised without the handler constant would have valid limits answering 400, which looks like a client bug and is not.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Has P1/P2 findings, changes requested: 0 P1, 1 P2, 5 P3.

Round 1 is genuinely cleared. I re-checked each of the four P2s against the code rather than against the commit message: the catalog now keys on (canonical_model, provider) with a provider-less wildcard and rejects two cards effective at the same instant instead of resolving them by an unstable sort; the id is derived unconditionally and a disagreeing supplied id is a 400, covered over HTTP as well as in the unit tests; Summary is capped and reports truncated/limit; and the over-large limit is a 400 in the handler with a clamp behind it. The P3s landed too — length-prefixed id parts with per-shape discriminators, NUMERIC money, 413 for an oversized body, the offending index named in the rejection. Declining partial-batch acceptance with a reason is a fair call.

One thing blocks:

  • List truncation is still silent (handlers_usage.go:232). Summary got truncated/limit on the argument that a clipped list reads as a complete one; the record list still returns count: len(records) against an undocumented default of 200 and a 1000 ceiling, with no offset or cursor, so a caller summing records[].calculated_cost over a busy week understates spend and cannot tell. Same fix as the function below it: select limit+1, report truncated.

P3s: summary truncates by COUNT(*) rather than by spend on a cost ledger; ErrNoPricing swallowed with no log or counter, which a provider-less record or a catalog that failed to load turns into null cost on every row; group_by=canonical_model still buckets model_key-only records under '' though the filter learned that fallback; TestOverLargeLimitClampsRatherThanShrinks passes against the pre-fix code; usageMaxLimit duplicates the store ceiling across a package boundary.

Caveat unchanged from round 1: go build, go vet and go test were not executable in this environment (the sandbox declined them), so this is a static read and the Postgres-backed tests were not re-run here.

claude: 0 P1, 1 P2, 5 P3 on 5d56091, and it confirmed round 1 cleared by
reading the code rather than the commit message. agy: PASS. All six taken.

P2 — List truncation was still silent. Round 1 gave Summary truncated/limit on
the argument that a clipped result reads as a complete one, and then left the
record list returning count: len(records) against an undocumented 200 default.
The inconsistency was mine and the argument applies identically: a caller
summing records[].calculated_cost over a busy window understates spend with
nothing to warn it. List now returns a ListResult carrying truncated and limit,
selected as limit+1 so truncation is detected rather than guessed.

P3 — the clamp test was worthless. This is the one worth calling out: round 1's
TestOverLargeLimitClampsRatherThanShrinks used three records, and three records
cannot tell a clamp to 1000 from a reset to 200. It passed against the code it
claimed to lock in. Rewritten with DefaultQueryLimit+1 rows, and this time I
re-introduced the original bug to watch it fail first:

  rows = 200, want 201; an over-large limit did not clamp to the ceiling

Same discipline as the other regressions in this PR; I simply skipped it here.

P3 — ErrNoPricing was swallowed. A catalog that fails to load at boot, or one
whose cards all name a provider the producer omits, made every row costless
with nothing pointing at why, and "spend is zero" is indistinguishable from
"spend was never priced". Now a warning naming model and provider. Still not
fatal: the token counts are true and a cost can be derived later.

P3 — group_by=canonical_model ignored the fallback the filter learned in round
1, bucketing model_key-only records under the empty string, so a per-model
breakdown showed their spend under "" instead of the model that incurred it.
The mapped value is a fixed SQL fragment, so the closed GroupBy set still keeps
request data out of the query.

P3 — summary truncation drops the tail by volume. Buckets order by COUNT(*),
so a few expensive invocations can be clipped by many cheap ones. Not reordered
by cost — cost is nullable and ordering by it would bury unpriced records — but
the response now says truncated_by: "record_count", because an operator needs
to know the axis before concluding where the money went.

P3 — the handler's limit ceiling duplicated the store's across a package
boundary. Exported DefaultQueryLimit/MaxQueryLimit and took the ceiling from
the store; drift would have turned valid limits into 400s.

Docs updated for all of it, including that count is the size of this page and
not the number of matching records.

Claude-Session: https://claude.ai/code/session_01TpRNAyt63CpPb9xpQPBy2G
@claude

claude Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 3m 56s —— View job


Code review complete

  • Read the pinned diff (945fa83...56cac52)
  • Verify the round-2 fixes landed (List truncation, summary axis, ErrNoPricing visibility, group-by model fallback, clamp test, shared limit constant)
  • Review store + SQL
  • Review pricing catalog + cost derivation
  • Review handlers + routes + wiring
  • Post inline findings and submit verdict

Verdict submitted against 56cac52: changes requested — 0 P1, 1 P2, 3 P3. Four inline comments posted.

Round 2 is cleared

Checked against the code, not the commit message. List selects limit+1 and returns truncated with the effective limit, and TestUsageHandlers_ListReportsTruncation seeds 201 records so the clip is actually observable. clampLimit is now one helper shared by List and Summary, and TestOverLargeLimitClampsRatherThanShrinks seeds DefaultQueryLimit+1 rows — it can finally fail on the regression it names. group_by=canonical_model uses the same model_key fallback the filter does, with TestSummaryByModelUsesTheModelKeyFallback. usage.MaxQueryLimit is exported and the duplicated handler constant is gone. ErrNoPricing is logged rather than swallowed.

Blocking (P2)

Where Finding
internal/api/handlers_usage.go:273 truncated_by never reaches the response. Round 2 added it to SummaryResult (store.go:507-511) and docs/model-usage-ledger.md:129-133 tells operators the summary carries truncated, truncated_by and limit — but GetUsageSummary hand-builds its response map with only group_by, buckets, truncated, limit, so the value set at store.go:567 is discarded before serialisation. No test references TruncatedBy or truncated_by, which is why it survived. The fix for "buckets are clipped by volume, not by spend" therefore reaches no consumer.

Non-blocking (P3)

  • store.go:224 — the unpriced-record slog.Warn is inside the per-record loop, so the two failure modes it exists for (a producer that omits provider, a catalog that failed to load at boot) emit one line per record forever rather than occasionally. A per-batch warn, a counter, or Unpriced []string on IngestResult bounds it.
  • types.go:218 — recorded_at is silently stamped with ingest time when omitted, and that field also selects the rate card (pricing.go:154) and drives every since/until window. The doc's ingest example omits it, so the shape a producer copies is the one that takes this path.
  • store.go:107 — model_usage_model_idx ON (canonical_model, provider) covers neither model-dimension query now that the filter is an OR across two columns and the group-by is a COALESCE expression.

What holds up well

The (run, model) grain; absent-vs-zero carried from *int64 through nullable columns to nullable summary buckets; cost derived once at ingest and stored with its rate-card version; the calculated_cost_requires_version CHECK; provider-aware pricing with an explicit wildcard and same-instant cards rejected at load; the unconditional id derivation with a disagreeing supplied id as a 400; nil store → 503; and TestRecordHasNoFreeTextContentField asserting the absence of a field rather than testing a scrubber. Every predicate is parameterised and the group-by column comes from a closed map — no injection path. Each new test names the finding it locks in, which makes each round easy to verify.

Caveat

go build, go vet and go test were declined by the sandbox in this environment, so this is a static read — the Postgres-backed tests were not re-run here. The finding above is read off the code path directly.
· branch feat/api-266-model-usage-ledger

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.8-flash-medium · commit 56cac52.

Finding 1 (P2)

  • File: internal/api/handlers_usage.go (approx. line 271)
  • Anchor: + writeJSON(w, http.StatusOK, map[string]interface{}{
  • Concrete failure scenario: docs/model-usage-ledger.md specifies that for GET /api/v1/usage/summary, "Results are capped the same way records are and the response carries truncated, truncated_by and limit", and explains that truncated_by: "record_count" informs the operator that the tail was dropped by volume rather than spend. While usage.SummaryResult in internal/usage/store.go sets res.TruncatedBy = "record_count", the handler constructs an explicit response map that omits truncated_by. Consequently, API consumers never receive truncated_by when a summary response is truncated, violating the documented API contract.
  • Proposed fix: Include truncated_by in the HTTP response map when res.Truncated is true (or when res.TruncatedBy != ""):
    resp := map[string]interface{}{
        "group_by":  string(by),
        "buckets":   res.Buckets,
        "truncated": res.Truncated,
        "limit":     res.Limit,
    }
    if res.TruncatedBy != "" {
        resp["truncated_by"] = res.TruncatedBy
    }
    writeJSON(w, http.StatusOK, resp)

Finding 2 (P3)

  • File: internal/api/handlers_usage_test.go (approx. line 420)
  • Anchor: +func TestUsageHandlers_ListReportsTruncation(t *testing.T) {
  • Concrete failure scenario: While TestUsageHandlers_ListReportsTruncation validates that GET /api/v1/usage/records reports truncated and limit, there is no test visible in this diff verifying truncation behavior for GET /api/v1/usage/summary over HTTP. Because GetUsageSummary is only tested against small batches in TestUsageHandlers_SummaryAnswersOperatorQuestion, the omission of truncated_by in the HTTP summary response was not caught by the test suite.
  • Proposed fix: Add a test in internal/api/handlers_usage_test.go (e.g., TestUsageHandlers_SummaryReportsTruncation) that seeds more buckets than limit or usage.DefaultQueryLimit, issues a GET /api/v1/usage/summary, and asserts that the decoded HTTP response contains both "truncated": true and "truncated_by": "record_count".

Comment thread internal/api/handlers_usage.go Outdated
writeJSON(w, http.StatusOK, map[string]interface{}{
"group_by": string(by),
"buckets": res.Buckets,
"truncated": res.Truncated,

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 — truncated_by never leaves the process. Round 2 added it to SummaryResult with a deliberate justification (store.go:507-511: "an operator seeing truncation needs to know the tail was dropped by volume, not by spend"), and docs/model-usage-ledger.md:129-133 tells operators the response carries truncated, truncated_by and limit. This handler builds the response map by hand and lists only group_by, buckets, truncated, limit — so res.TruncatedBy, set at store.go:567, is discarded before it reaches the wire.

Concretely: GET /api/v1/usage/summary?group_by=target_repo over a ledger with more than 200 repos answers 200 buckets with truncated: true and limit: 200, and no truncated_by. An operator who read the doc looks for it, does not find it, and is back in exactly the position round 2 identified — knowing something was cut but not that the cut was by row count. The repo with three very expensive invocations that lost its place to 200 cheap ones stays invisible, with no signal that spend was the wrong axis to clip on. omitempty also makes its absence here indistinguishable from the field being present and empty.

Nothing catches it: the summary handler test decodes only group_by, buckets and truncated; TestSummaryIsBoundedAndSaysSo asserts Truncated and Limit at the store layer; and no test in the PR references TruncatedBy or truncated_by at all. Adding "truncated_by": res.TruncatedBy here plus one assertion in TestSummaryIsBoundedAndSaysSo closes both halves.

The general hazard is worth noting too: both read handlers hand-build an untyped map, so a field added to the store result type is silently dropped unless someone remembers to update the literal. Writing res straight out — it already carries the right JSON tags — would make the struct the contract rather than a copy of it.

Comment thread internal/usage/store.go
// cards all name a provider the producer omits, makes every
// row costless, and "spend is zero" is indistinguishable from
// "spend was never priced" without this line.
slog.Warn("usage record stored without calculated cost: no pricing card matched",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 — the warning sits inside the per-record loop, so the two failure modes it was added for are exactly the ones that make it unbounded. A producer whose cards all name a provider it omits, or a catalog that failed to load at boot, does not produce an occasional unpriced row — it produces one for every record, so a 500-record batch emits 500 identical warn lines and steady ingestion emits one per invocation indefinitely. The signal that a rate card is missing then arrives as log volume, which is the shape operators mute.

The content is right — model, provider, instant, and no session text. It is the cardinality that wants bounding: one warn per batch carrying the distinct model/provider pairs and a count, or the alternative from the round-2 thread (Unpriced []string beside Accepted/Deduped) so the producer sees it in its own response rather than in someone else’s logs. A counter would also make "how many rows are we storing costless" answerable without grepping.

Comment thread internal/usage/types.go
}
r.ID = derived
if r.RecordedAt.IsZero() {
r.RecordedAt = time.Now().UTC()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 — recorded_at is silently stamped with ingest time when the producer omits it, and that field is not just a timestamp on this table: Catalog.Calculate resolves the rate card at r.RecordedAt (pricing.go:154) and every since/until query filters on it. So a record that sat in a producer-side buffer, or that is replayed from an export weeks later, is priced with the card in force at ingest rather than at invocation and lands in the wrong week of every spend report — with ingested_at already recording ingest time separately, so the column that was meant to be the invocation instant now silently holds a copy of it.

The doc does not mention recorded_at at all and its ingest example (docs/model-usage-ledger.md:80) shows a record without one, so the shape a producer is most likely to copy is exactly the one that takes this path. Validate rejects a negative token count and an unknown outcome but not a missing instant.

Given how carefully the rest of this package refuses to invent values it was not given — absent is not zero, a client id is a 400 rather than an overwrite — requiring recorded_at would be the consistent call: ErrInvalidRecord when it is zero, and update the doc example. If defaulting has to stay for the pilot, it is worth saying so in the doc and in this comment, since the consequence is a mispriced row rather than a missing field.

Comment thread internal/usage/store.go
CREATE INDEX IF NOT EXISTS model_usage_repo_issue_idx ON model_usage_records (target_repo, issue_number);
CREATE INDEX IF NOT EXISTS model_usage_repo_pr_idx ON model_usage_records (target_repo, pr_number);
CREATE INDEX IF NOT EXISTS model_usage_agent_idx ON model_usage_records (agent, devloop_stage);
CREATE INDEX IF NOT EXISTS model_usage_model_idx ON model_usage_records (canonical_model, provider);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 — model_usage_model_idx ON (canonical_model, provider) no longer covers either query that reads the model dimension, because both learned the model_key fallback in later rounds. The filter is canonical_model = $1 OR (canonical_model = '' AND model_key = $1) (store.go:328-329), and the summary groups on COALESCE(NULLIF(canonical_model, ''), model_key) (store.go:461) — an OR across two columns and an expression, neither of which this index can serve, so both fall back to a sequential scan that grows with the ledger.

Not urgent on an empty table, and the recorded_at index still bounds the windowed queries. But an expression index matching the group-by exactly — ON (COALESCE(NULLIF(canonical_model, ''), model_key), provider) — would cost nothing to add now while the schema is being created for the first time, and is the same "cheap now, migration later" argument that moved money to NUMERIC in round 2.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Has P1/P2 findings, changes requested: 0 P1, 1 P2, 3 P3.

Round 2 is cleared, checked against the code rather than the commit message. List now selects limit+1 and returns truncated plus the effective limit, with a handler test that seeds 201 records and asserts the clip is visible; clampLimit is a shared helper and TestOverLargeLimitClampsRatherThanShrinks now seeds more than the default page, so it can actually fail on the regression it names; group_by=canonical_model uses the same model_key fallback the filter does, with a test; usage.MaxQueryLimit is exported and the duplicated handler constant is gone; ErrNoPricing is logged rather than swallowed.

One thing blocks:

  • truncated_by never reaches the response (handlers_usage.go:273). Round 2 added it to SummaryResult and docs/model-usage-ledger.md:129-133 tells operators the summary carries it, but GetUsageSummary hand-builds its response map with only group_by, buckets, truncated, limit, so the field set at store.go:567 is dropped before serialisation. No test references TruncatedBy or truncated_by, which is why it survived. The remediation for "buckets are clipped by volume, not by spend" therefore reaches no consumer.

P3s: the unpriced-record warning is inside the per-record loop, so the two failure modes it exists for (a provider-less producer, a catalog that failed to load) emit one line per record forever rather than occasionally; recorded_at is silently stamped with ingest time when omitted, and that field also selects the rate card and drives every since/until window, while the doc example omits it; model_usage_model_idx no longer covers either model-dimension query now that both use the model_key fallback.

Caveat unchanged from the previous rounds: go build, go vet and go test were declined by the sandbox here, so this is a static read and the Postgres-backed tests were not re-run.

… hand

agy: FAIL:P2 on 56cac52 (it passed 5d56091). Both findings are correct and
verified against the code.

P2 — `truncated_by` never reached a client. Round 2 added it to SummaryResult
and documented it, but GetUsageSummary builds an explicit response map, so the
field was set on the struct, promised in docs/model-usage-ledger.md, and
dropped on the way to the wire. An operator seeing a truncated summary could
not tell the tail was cut by volume rather than by spend, which is the whole
reason the field exists.

Fixed at the mechanism rather than the field. Both handlers now return types
that embed the store result (listUsageResponse, summaryUsageResponse) instead
of copying its fields into a map, so the wire shape follows the type and a
field added to a result cannot silently fail to reach a client again. The
hand-built map was the defect; `truncated_by` was only the first field to fall
through it.

P3 — agy also named why nothing caught it: List truncation had an HTTP test and
Summary did not, so the gap sat exactly where there was no coverage. Added
TestUsageHandlers_SummaryReportsTruncationAndItsAxis, asserting over the wire
rather than on the struct, since the struct was already right. Verified by
restoring the hand-built map and watching it fail first:

  truncated_by = "", want "record_count"

Round 2's P3 about ordering by COUNT(*) stands as dispositioned — the axis is
now actually disclosed, which was the condition that disposition relied on.

Claude-Session: https://claude.ai/code/session_01TpRNAyt63CpPb9xpQPBy2G
@mashkovd

Copy link
Copy Markdown
Member Author

Round 3 (agy FAIL:P2 on 56cac52) addressed in e681181. Both findings verified against the code and correct.

Finding 1 (P2) — truncated_by never reached a client: confirmed. Round 2 added it to SummaryResult and documented it in docs/model-usage-ledger.md, but GetUsageSummary builds an explicit response map — so the field was set on the struct, promised in the docs, and dropped on the way to the wire:

writeJSON(w, http.StatusOK, map[string]interface{}{
    "group_by": string(by), "buckets": res.Buckets,
    "truncated": res.Truncated, "limit": res.Limit,   // truncated_by absent
})

This one stings a little, because the field exists precisely so an operator can tell a summary was cut by volume rather than by spend — and I argued that point on the round-2 thread while shipping a handler that never sent it.

Fixed at the mechanism, not the field. Both handlers now return types that embed the store result (listUsageResponse, summaryUsageResponse) rather than copying fields into a map, so the wire shape follows the type. The hand-built map was the actual defect; truncated_by was just the first field to fall through it, and the next field added would have done the same.

Finding 2 (P3) — correct, and it explains the miss. List truncation had an HTTP test; Summary truncation did not, so the gap sat exactly where there was no coverage. Added TestUsageHandlers_SummaryReportsTruncationAndItsAxis, asserting over the wire rather than on the struct — the struct was already right, and a struct-level assertion would have passed against the broken handler.

Verified by restoring the hand-built map and watching the new test fail first:

truncated_by = "", want "record_count"

This is now the third round where a reviewer caught a test that could not detect the bug it named (round 2: the three-row clamp test). Both are the same mistake on my side — asserting at the layer that was already correct instead of the one that was not.

Round 2’s P3 about ordering buckets by COUNT(*) stands as dispositioned, and is now actually true: that disposition rested on the axis being disclosed, which it finally is.

CI green on 56cac52 (lint, test, govulncheck, trivy, CodeQL); re-running on e681181.

@claude

claude Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 4m 1s —— View job


Code review complete

  • Read the pinned diff (945fa83...e681181)
  • Verify the round-3 fix landed (truncated_by on the wire)
  • Review store + SQL
  • Review pricing catalog + cost derivation
  • Review handlers + routes + wiring
  • Post inline findings and submit verdict

Verdict submitted against e681181: approved — 0 P1, 0 P2, 3 P3. Three inline comments posted.

Round 3 is cleared

Checked against the code, not the commit message. truncated_by reaches the wire: both read handlers now serialise *usage.SummaryResult / *usage.ListResult by embedding instead of hand-building a map, so the wire shape follows the type. That closes the whole class of drift rather than the one instance — the fix and its justification are the same size as the problem. group_by and count sit at the outer level and collide with nothing in the embedded types, so the documented shapes are otherwise unchanged. TestUsageHandlers_SummaryReportsTruncationAndItsAxis asserts the field over HTTP, which is the layer where the bug actually lived.

Non-blocking (P3)

Where Finding
internal/api/handlers_usage.go:97 ingestUsageResponse is the one response still projected by hand — and IngestResult is the type with an open proposal (Unpriced []string) to grow a field, so the drift this commit just eliminated survives in the one place the fix was not applied. It also flattens Accepted/Deduped into a single ids list, so a producer cannot tell which ids collided.
internal/api/handlers_usage_test.go:481 The new summary test filters on repository=mctlhq/mctl-api, hardcoded by every fixture in both test files, rather than on its own prefix — the only test in the file not scoped to its own data. Safe today given per-test cleanup and -p 1, but the assertion is a lower bound, so foreign rows can only mask a regression.
internal/usage/usage_test.go:39 The privacy assertion uses exact field-name equality, so PromptText, UserMessage or ResponseBody would pass the guard whose stated purpose is surviving a producer who never read the ADR. strings.Contains is strictly stronger and passes against the current type unchanged — I checked all 35 field names.

Still open from round 3, deferred rather than re-argued

The unpriced-record slog.Warn is per record rather than per batch; recorded_at is defaulted to ingest time when omitted, and that field also selects the rate card and drives every since/until window; model_usage_model_idx no longer covers either model-dimension query now that both use the model_key fallback. All P3, none blocking.

What holds up well

The (run, model) grain; absent-vs-zero carried from *int64 through nullable columns to nullable summary buckets; cost derived once at ingest and stored with its rate-card version; the calculated_cost_requires_version CHECK; provider-aware pricing with an explicit wildcard and same-instant cards rejected at load; unconditional id derivation with a disagreeing supplied id as a 400; clampLimit shared by both read paths; nil store → 503. Every predicate is parameterised and the group-by column comes from a closed map — no injection path. No goroutines and no shared mutable state: the catalog is read-only after build, records are per-request.

Caveat

go build, go vet and go test were declined by the sandbox in this environment, so this is a static read and the Postgres-backed tests were not re-run here. Unchanged across all four rounds.
· branch feat/api-266-model-usage-ledger

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.8-flash-medium · commit e681181.

No significant issues found.

*usage.SummaryResult
}

type ingestUsageResponse struct {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 — the one response left hand-built is ingestUsageResponse, and it is the one with an open proposal to grow a field.

This commit's argument is right and worth applying consistently: listUsageResponse and summaryUsageResponse now embed the store result so a field added to the type reaches the wire, but IngestResult (store.go:181-184) still gets projected by hand here — and it already loses information in the projection. Accepted and Deduped arrive as two distinct []string, and ids concatenates them, so a producer that receives {"accepted": 3, "deduped": 2, "ids": [...5 ids...]} cannot tell which two collided. The counts are the same, but the per-id answer is gone.

The drift half is concrete rather than hypothetical: the still-open unpriced-record thread proposes Unpriced []string beside Accepted/Deduped on IngestResult as the bounded alternative to the per-record slog.Warn. Adding it there would set the field, and this literal would silently drop it — exactly the failure truncated_by just had, in the one place the fix was not applied.

IngestResult already carries json:"accepted" / json:"deduped" tags, so embedding it and keeping the counts as the extra fields would preserve both shapes:

type ingestUsageResponse struct {
    *usage.IngestResult
    AcceptedCount int `json:"accepted_count"`
    DedupedCount  int `json:"deduped_count"`
}

If the counts-only shape is deliberate for payload size, that is a fair call — worth saying so in the comment above the type, since the file now reads as though embedding is the rule.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Taken in 3b238a3 — you caught the fix being applied everywhere except the one place it mattered most, in the same commit that argued for it. ingestUsageResponse now embeds *usage.IngestResult with explicit accepted_count/deduped_count.

The information loss is the part worth fixing rather than the inconsistency: a producer told {"accepted": 3, "deduped": 2} could not tell which two collided, and on a ledger whose whole claim is "no paid invocation is counted twice", "two of these five were already recorded" is exactly the answer a producer wants to be able to check. Nothing consumes this shape yet — the first producer arrives with .github#50 — so changing it is free today and would not have been later.

Comment thread internal/api/handlers_usage_test.go Outdated
}

req := adminCtx(httptest.NewRequest("GET",
"/api/v1/usage/summary?group_by=temporal_workflow_id&repository=mctlhq/mctl-api&limit=2", nil))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 — this is the only test in the file whose query is not scoped to its own fixtures. Every other one filters on workflow_id=<prefix>-wf; this one filters on repository=mctlhq/mctl-api, which usageRecordBody hardcodes for every record in both this file and store_test.go.

It happens to be safe today — each test deletes its own rows in t.Cleanup and the job runs go test -p 1 ./..., so no foreign rows are in the table when this runs. But the assertion it protects is a lower-bound one (truncated must be true because 3 buckets exceed limit=2), and foreign rows can only push bucket count up. So if this test's own ingest ever stopped producing three distinct workflow ids, leftover rows from a crashed run — t.Cleanup ignores its DELETE error — would keep truncated: true and the test would still pass while measuring nothing it claims to.

The prefix is available and unused here; scoping through a field the group-by does not consume keeps it honest:

rec["work_item_id"] = prefix
...
"/api/v1/usage/summary?group_by=temporal_workflow_id&work_item_id="+prefix+"&limit=2"

Same cost, and it reads consistently with the rest of the file.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 3b238a3. You are right that it is safe today and right that it is the wrong shape anyway: the assertion is a lower bound (truncated true because 3 buckets > limit=2), and foreign rows can only push the count up — so the failure mode is this test quietly passing after its own ingest stopped producing three distinct workflow ids. Now scoped to a per-test work_item_id like every other test in the file.

Comment thread internal/usage/usage_test.go Outdated
for i := 0; i < rt.NumField(); i++ {
name := strings.ToLower(rt.Field(i).Name)
for _, bad := range forbidden {
if name == bad {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 — the privacy invariant is the strongest idea in this PR and the assertion implementing it is the weakest form of itself. name == bad is exact equality on the lowercased field name, so the forbidden list catches a field called Prompt and misses PromptText, UserMessage, MessageText, ResponseBody, RawContent — the names a producer is actually more likely to reach for than the bare noun.

That matters because of what the test is for. The comment above it makes the right argument: this proves there is no field to scrub, and it is the guarantee that survives "a future producer written by someone who never read the ADR". But the person who adds PromptSummary to Record is precisely that person, and the test greets them green.

strings.Contains(name, bad) is strictly stronger and passes against the current type unchanged — I checked every field: none of prompt, completion, message, content, text, body, response appears as a substring of any of the 35 field names (recordedat, terminalreason, stopreason and the rest are all clear).

if strings.Contains(name, bad) {

Worth applying the same widening to the JSON-shape guard in handlers_usage_test.go:277, which checks for "text" and "content" with the quotes attached and so would miss "output_text" — the one name the forbidden list here already anticipates.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Taken in 3b238a3, and thank you for pushing on this one — it is the finding I would least have wanted to ship.

You are right that the assertion was the weakest form of the idea it protects. name == bad catches Prompt and waves through PromptText, UserMessage, MessageText, ResponseBody — and the whole point of the test is surviving someone who never read ADR-012, who is exactly the person reaching for those names rather than the bare noun. Now strings.Contains.

Verified rather than assumed, in both directions: it passes against all current field names unchanged, and adding the exact field you named fails it —

Record has field "PromptText", whose name contains "prompt": it can carry model content,
and ADR-012 invariant 8 forbids the field existing at all

The three P3s you listed as deferred from round 3 I have left deferred and named in the commit message: the per-record unpriced warning, recorded_at defaulting to ingest time while also selecting the rate card, and model_usage_model_idx no longer covering either model-dimension query after the model_key fallback. The middle one is the one I would look at first if this ledger starts ingesting late — a record that arrives an hour after the fact gets priced at the ingest instant, which is almost always the same rate card but not guaranteed to be.

claude[bot]
claude Bot previously approved these changes Sep 22, 2026

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No P1/P2 findings (3 P3). Good to merge.

Round 3 is cleared, checked against the code rather than the commit message. truncated_by now reaches the wire: GetUsageSummary and ListUsageRecords serialise *usage.SummaryResult / *usage.ListResult by embedding instead of hand-building a map, so the wire shape follows the type and the whole class of drift goes away rather than the one instance. TestUsageHandlers_SummaryReportsTruncationAndItsAxis asserts truncated_by: "record_count" over HTTP, which is the layer where the bug lived — the struct was already correct. group_by and count stay at the outer level and collide with nothing in the embedded types, so the documented response shapes are unchanged apart from the field that was missing.

Everything from rounds 1 and 2 still holds: unconditional id derivation with a disagreeing supplied id as a 400, provider-aware pricing with an explicit wildcard and same-instant cards rejected at load, nil-record guard, clampLimit shared by List and Summary, truncated/limit on both read paths, model_key fallback in both the filter and the group-by, NUMERIC money, the calculated_cost_requires_version CHECK, nil store to 503, and TestRecordHasNoFreeTextContentField asserting the absence of a field rather than testing a scrubber. Every predicate is parameterised and the group-by column comes from a closed map — no injection path. No goroutines, no shared mutable state: the catalog is read-only after build and records are per-request.

P3s, none blocking:

  • handlers_usage.go:97 — ingestUsageResponse is the one response still projected by hand, and IngestResult is the type with an open proposal (Unpriced []string) to grow a field. It also flattens Accepted/Deduped into one ids list, so a producer cannot tell which ids collided.
  • handlers_usage_test.go:481 — the new summary test filters on repository=mctlhq/mctl-api, shared by every fixture in both test files, rather than on its own prefix. Safe today given per-test cleanup and -p 1, but its assertion is a lower bound, so foreign rows could only mask a regression.
  • usage_test.go:39 — the privacy assertion uses exact field-name equality, so PromptText or UserMessage would pass. strings.Contains is strictly stronger and passes against the current type unchanged.

Still open and consciously deferred from round 3, listed for the record rather than re-argued: the unpriced-record slog.Warn is per record rather than per batch; recorded_at is defaulted to ingest time when omitted, and that field also selects the rate card; model_usage_model_idx no longer covers either model-dimension query now that both use the model_key fallback.

Caveat unchanged across all four rounds: go build, go vet and go test were declined by the sandbox here, so this is a static read and the Postgres-backed tests were not re-run.

… fix

claude approved e681181 (0 P1, 0 P2, 3 P3) and agy passed, so the gate was
already met. Taking all three anyway: two are one-liners and the third is about
the invariant this whole ledger rests on.

The privacy guard matched field names by exact equality, so the forbidden list
caught `Prompt` and waved through `PromptText`, `UserMessage`, `MessageText`,
`ResponseBody` — the names someone is actually more likely to reach for. The
test exists precisely to survive a producer written by someone who never read
ADR-012, and it was the weakest form of itself. Now substring matching, which
passes against all 35 current field names unchanged. Verified by adding the
exact field claude named and watching it fail:

  Record has field "PromptText", whose name contains "prompt"

`ingestUsageResponse` was the one response still projected by hand, in the same
commit that argued hand-built maps are the defect — and it lost information
doing it: Accepted and Deduped arrive as two lists and `ids` concatenated them,
so a producer told "3 accepted, 2 deduped" could not tell WHICH two collided.
Now embeds IngestResult with explicit counts. Nothing consumes this shape yet —
the first producer arrives with .github#50 — so changing it is free today and
would not be later.

The summary truncation test filtered on the repository every fixture hardcodes
rather than its own prefix. Its assertion is a lower bound, so foreign rows
could only mask a regression in its own ingest. Scoped to a per-test
work_item_id like every other test in the file.

Deferred, as claude listed them and I agree they are not this PR's scope: the
unpriced warning is per record rather than per batch; recorded_at defaults to
ingest time while also selecting the rate card; model_usage_model_idx no longer
covers either model-dimension query now that both use the model_key fallback.

Claude-Session: https://claude.ai/code/session_01TpRNAyt63CpPb9xpQPBy2G
@claude

claude Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Claude finished @mashkovd's task in 4m 2s —— View job


Code review complete

  • Read the pinned diff (945fa83...3b238a3)
  • Verify the round-3 fixes landed (ingestUsageResponse embedding, summary-test scoping, privacy guard widening)
  • Review store + SQL
  • Review pricing catalog + cost derivation
  • Review handlers + routes + wiring
  • Post inline findings and submit verdict

Verdict submitted against 3b238a3: approved — 0 P1, 0 P2, 2 P3. Two inline comments posted.

Round 3 is cleared

Checked against the code, not the commit message.

  • ingestUsageResponse embeds *usage.IngestResult and keeps the counts as accepted_count/deduped_count, so accepted and deduped now arrive as the two distinct id lists the store actually computed — a producer can tell which ids collided, not just how many. docs/model-usage-ledger.md:87 matches the new shape, and nothing else in the tree consumed the old ids field. With this, all three response types follow their struct rather than a hand-built literal, so the truncated_by class of drift is closed everywhere instead of at one site.
  • TestUsageHandlers_SummaryReportsTruncationAndItsAxis is scoped through a per-test work_item_id rather than the repository every fixture hardcodes.
  • The privacy guard is strings.Contains, the form that survives PromptText/UserMessage/ResponseBody. Checked against all 35 current field names — none contains any forbidden substring, so the stronger assertion passes unchanged.

Non-blocking (P3)

Where Finding
internal/usage/store_test.go:378 TestSummaryIsBoundedAndSaysSo still filters on TargetRepo: "mctlhq/mctl-api" — the exact pattern its handler-side sibling moved off in this commit, and its Truncated assertion is lower-bound in the same way, so leftover rows can only mask a regression in its own ingest. Fix this →
internal/usage/usage_test.go:39 "text" as a bare substring will false-positive on a plausible future ContextWindowTokens field, with a message asserting the opposite of the truth — and the tempting fix is to drop "text", losing input_text/output_text. Anchoring as "_text"/"text_" and reading the JSON tag rather than the Go field name keeps both properties. The companion guard at handlers_usage_test.go:277 was left on quoted-key matching, which misses "output_text". Fix this →

Still open from earlier rounds, deferred rather than re-argued

The unpriced-record slog.Warn is per record rather than per batch; recorded_at is defaulted to ingest time when omitted, and that field also selects the rate card and drives every since/until window; model_usage_model_idx covers neither model-dimension query now that both use the model_key fallback; the summary section of the doc still does not state the 200-bucket default. All P3, none blocking.

What holds up well

The (run, model) grain; absent-vs-zero carried from *int64 through nullable columns to nullable summary buckets; cost derived once at ingest and stored with its rate-card version; the calculated_cost_requires_version CHECK; provider-aware pricing with an explicit wildcard and same-instant cards rejected at load; unconditional id derivation with a disagreeing supplied id as a 400; clampLimit shared by both read paths; nil store → 503. Every predicate is parameterised and the group-by column comes from a closed map — no injection path. No goroutines and no shared mutable state: the catalog is read-only after build, records are per-request.

Caveat

go build, go vet and go test were declined by the sandbox in this environment, so this is a static read and the Postgres-backed tests were not re-run here. Unchanged across all four rounds.
· branch feat/api-266-model-usage-ledger

@github-actions

Copy link
Copy Markdown
Contributor

Antigravity (agy) review — informational

Model: gemini-3.8-flash-medium · commit 3b238a3.

No significant issues found.

t.Fatalf("Ingest: %v", err)
}

res, err := s.Summary(ctx, Filter{TargetRepo: "mctlhq/mctl-api", Limit: 2}, GroupByWorkflow)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 — this commit fixed the handler-side sibling of this test and left this one on the old pattern.

TestUsageHandlers_SummaryReportsTruncationAndItsAxis moved off repository=mctlhq/mctl-api to a per-test work_item_id this round, with a comment explaining exactly why: the assertion is a lower bound, so foreign rows can only mask a regression in the test's own ingest. This test makes the same assertion (res.Truncated must be true because 5 workflow buckets exceed Limit: 2) through the same shared filter — TargetRepo: "mctlhq/mctl-api" is hardcoded by testRecord here and by usageRecordBody in handlers_usage_test.go, so it matches every fixture in both files.

Concretely: if this test's own ingest ever stopped producing five distinct temporal_workflow_id values, rows left behind by a crashed run — t.Cleanup ignores its DELETE error — would keep the bucket count above 2 and both assertions would still pass while measuring nothing they claim to. Safe today under per-test cleanup and go test -p 1, but that is the property the round-4 fix declined to rely on next door.

prefix is already in scope and each record already gets %s-wf-%d, so scoping through a dimension the group-by does not consume is the same one-line change that was applied to the handler test:

r.WorkItemID = prefix + "-bounded"
...
s.Summary(ctx, Filter{WorkItemID: prefix + "-bounded", Limit: 2}, GroupByWorkflow)

// UserMessage or ResponseBody long before the bare noun — exact equality
// would wave every one of them through. Checked against all current field
// names: none contains any of these, so the stronger form costs nothing.
forbidden := []string{"prompt", "completion", "message", "content", "text", "body", "response"}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 — the widening is the right call, and "text" has one foreseeable false positive worth pre-empting: "context" contains it.

ContextWindow, ContextWindowTokens or MaxContextTokens are plausible additions to a model usage record — context window size is a usage dimension, not content — and each would fail this test with a message asserting the opposite of the truth: Record has field "ContextWindowTokens", whose name contains "text": it can carry model content. Whoever hits that has to read the guard to work out it is a substring artefact, and the tempting fix is to delete "text" from the list, which loses input_text/output_text — the two names the round-3 thread named specifically.

Failing safe is the right default, so this is a nit rather than a defect. Anchoring the two that are actually compound would keep both properties:

forbidden := []string{"prompt", "completion", "message", "content", "body", "response", "_text", "text_"}

with name taken from the JSON tag rather than the Go field name, so Aux string \x60json:"prompt"\x60 is caught too — the wire shape is what leaks.

Same widening is worth applying to the companion guard at handlers_usage_test.go:277, which was left on "\"text\"" / "\"content\"" this round: quoted-key matching misses "output_text", the one name this list already anticipates.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No P1/P2 findings (2 P3). Good to merge.

Round 3 is cleared, checked against the code rather than the commit message.

  • ingestUsageResponse now embeds *usage.IngestResult and keeps the counts as accepted_count/deduped_count, so accepted and deduped arrive as the two distinct id lists the store actually computed — a producer can now tell which ids collided, not just how many. The doc example matches the new shape, and no other consumer of the old ids field exists in the tree. With that, all three response types follow their struct rather than a hand-built literal, which closes the truncated_by class of drift everywhere instead of at one site.
  • The summary truncation test is scoped through a per-test work_item_id rather than the repository every fixture hardcodes.
  • The privacy guard is strings.Contains, which is the form that survives PromptText/UserMessage/ResponseBody. I checked it against all 35 current field names — none contains prompt, completion, message, content, text, body or response, so the stronger assertion passes unchanged.

P3s: TestSummaryIsBoundedAndSaysSo (store_test.go:378) is still on the hardcoded target_repo filter — the exact pattern its handler-side sibling moved off in this commit, and its assertion is lower-bound in the same way; "text" as a bare substring will false-positive on a future ContextWindow* field with a message asserting the opposite of the truth, and the companion JSON-shape guard at handlers_usage_test.go:277 was left on quoted-key matching, which misses "output_text".

Still open from earlier rounds, deferred rather than re-argued: the unpriced-record slog.Warn is per record rather than per batch; recorded_at is defaulted to ingest time when omitted, and that field also selects the rate card and drives every since/until window; model_usage_model_idx covers neither model-dimension query now that both use the model_key fallback; the summary section of the doc still does not state the 200-bucket default. All P3, none blocking.

What holds up: the (run, model) grain; absent-vs-zero carried from *int64 through nullable columns to nullable summary buckets; cost derived once at ingest and stored with its rate-card version; the calculated_cost_requires_version CHECK; provider-aware pricing with an explicit wildcard and same-instant cards rejected at load; unconditional id derivation with a disagreeing supplied id as a 400; clampLimit shared by both read paths; nil store to 503. Every predicate is parameterised and the group-by column comes from a closed map — no injection path. No goroutines and no shared mutable state: the catalog is read-only after build, records are per-request.

Caveat unchanged across all four rounds: go build, go vet and go test were declined by the sandbox here, so this is a static read and the Postgres-backed tests were not re-run.

@mashkovd
mashkovd merged commit c963963 into main Sep 22, 2026
10 checks passed
@mashkovd
mashkovd deleted the feat/api-266-model-usage-ledger branch September 22, 2026 21:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(agent-finops): persist model usage ledger and expose cost queries

2 participants