Skip to content

feat(usage): preserve usage history past the management read window with a daily rollup sidecar - #1008

Open
lidge-jun wants to merge 4 commits into
devfrom
codex/260804-usage-rollup
Open

feat(usage): preserve usage history past the management read window with a daily rollup sidecar#1008
lidge-jun wants to merge 4 commits into
devfrom
codex/260804-usage-rollup

Conversation

@lidge-jun

@lidge-jun lidge-jun commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Problem

usage.jsonl is append-only and unbounded. The management reader caps reads at managementUsageMaxReadBytes (64 MiB default) plus a 200k-entry cap, so once the file outgrows the window the oldest days silently vanish from every /api/usage consumer. This happened in production on 2026-08-04: a 157 MB / 380k-row log lost 11 of 30 days in the 30d view (historyTruncated: true, truncatedPrefixBytes: 97.5 MB). Raising the caps only defers the loss and slows every read.

Design

Fold rows that leave the read window into a daily-aggregate rollup sidecar, and make the summary reader merge rollup (old days) + raw tail (recent days):

  • Files: usage-rollup.jsonl (append-only aggregates) + usage-rollup-meta.json (lineage/fingerprint/throttle), both 0600 and ownership-registered. The raw log is never truncated — the rollup is a rebuildable derived cache.
  • Commit-row authority: each fold appends group rows first and a commit row last (one fsync). A segment is visible only when its commit row validates by attemptId + rowCount + payloadDigest; the effective cutline and the raw-tail start both derive from the same committed segments, so there is no crash window where data double-counts. Partial appends are invisible garbage; the append boundary is ftruncate-repaired before a retry.
  • Stability watermark: only days ≥ 9 local days old fold, so the whole 7d window (plus requests7d in the API-key view) stays raw-exact. 30d includes rolled-up days at day granularity (the one boundary day is included whole, ≤ ~24h overcount — documented).
  • Cost: long-context tiers depend on per-request input tokens, so the rollup stores fold-time cost sums; a wide price fingerprint (generated jawcode table fingerprint + expected-price overlays + priority multipliers + context tiers + a hand-bumped semantics version) forces a full rebuild on any pricing change, preserving display-time retroactivity by rebuild.
  • Validity is synchronous: readRollupSnapshot() verifies version/lineage/fingerprint before returning; any mismatch serves the legacy raw-tail path while the background rebuild proceeds. usageRollupEnabled: false restores today's behavior entirely.

Design provenance: devlog/_plan/260804_usage_rollup_preservation/ — a prior-art survey (Kafka segment compaction, SQLite WAL ordering, logrotate copytruncate pitfalls, TSDB downsampling, high-water-mark patterns; Tier-2 source-verified) plus a 4-round independent design audit whose blockers (crash protocol, distinct-request exactness domain, requests7d ownership, fingerprint completeness, same-lineage mutation) are each closed in the decade docs.

Tests

  • tests/usage-rollup.test.ts (12): fold aggregates vs hand-computed fixtures, crash injection at every boundary (mid-append truncation, abandoned-attempt + retry collision, rowCount/digest mismatch, missing meta), lineage/fingerprint/size rebuild triggers, min-age watermark, mid-line cutline rejection.
  • tests/usage-rollup-merge.test.ts (6): property test summarize(fold(prefix) ⊕ tail) === summarize(full raw) for range all (totals, days, models, providers, cost to 1e-9), boundary-day additivity, surface predicates, 7d-never-touches-rollup + 30d day-grain, route cache invalidation on fold advance + flag-off legacy path, API-key totals across the fold boundary.
  • Full suite: 8230 pass / 10 skip / 0 fail (531 files) on Linux; typecheck, GUI lint, and privacy scan green.

Real-data validation

Against a copy of the 157 MB production log (381,096 rows): the merged all-range summary equals the unbounded full-raw parse — zero diffs across summary totals, all 39 day rows, all 90 model rows, and all providers (cost delta 2.5e-13 USD, float ordering). truncatedPrefixBytes: 0 with the rollup caught up. Fold of the 110 MB prefix: 1.7 s once; subsequent merged reads parse only the ~104k-row tail (378 ms vs full-file parses today).

Docs

reference/configuration/server.md gains the usageRollupEnabled row; reference/management-api.md documents the GET /api/usage rollup semantics (locale files intentionally untouched pending translation sync).

Follow-ups (out of scope)

Raw-file truncation/archival policy (needs writer coordination), GUI surfacing beyond existing truncation metadata.

Summary by CodeRabbit

  • New Features

    • Usage history is now preserved through daily rollup caches, preventing data loss during log rotation
    • New usageRollupEnabled server configuration (enabled by default) controls usage-history preservation
  • Bug Fixes

    • Fixed cache-key collision in cost fingerprinting that could produce ambiguous lookups
  • Documentation

    • Updated management API documentation to describe historical usage summaries and rollup-backed querying
  • Tests

    • Added comprehensive test coverage for usage rollup aggregation and merge scenarios

@github-actions github-actions Bot added the enhancement New feature or request label Aug 4, 2026
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds a rebuildable usage-rollup sidecar (usage-rollup.jsonl + usage-rollup-meta.json) that folds aged entries from usage.jsonl into daily/model/provider/key aggregates, merged at read time with the raw tail to preserve history beyond the fixed read window. It adds config flag usageRollupEnabled, offset-aware log reads, summary/API-key/route merge wiring, jawcode fingerprint export, design docs, and tests.

Changes

Usage Rollup Preservation

Layer / File(s) Summary
Design docs: research, roadmap, and audit
devlog/_plan/260804_usage_rollup_preservation/*, docs-site/src/content/docs/reference/configuration/server.md, docs-site/src/content/docs/reference/management-api.md
Documents the truncation problem, prior-art survey, design conclusions, roadmap, two audit rounds, and updates config/API docs for usageRollupEnabled.
Price and jawcode fingerprinting
scripts/generate-jawcode-metadata.ts, src/usage/cost.ts
Adds stableStringify, SHA256-based JAWCODE_TABLE_FINGERPRINT export, ROLLUP_COST_SEMANTICS_VERSION constant, and a collision-safe null-byte cache key in resolveMatchedPrice.
Config flag
src/config.ts, src/types.ts
Adds usageRollupEnabled (default true) to schema, defaults, and OcxConfig.
Offset-aware raw log reads
src/usage/log.ts
Exports normalizeUsageEntry; adds fromOffset support to readUsageEntriesFullCooperatively and readUsageSnapshotForManagement with validation and cache-key updates.
Rollup core module
src/usage/rollup.ts
Implements the full rollup subsystem: types, fingerprinting, segment validation/parsing/merging, snapshot reads, eligible-prefix folding, atomic durable writes, foldUsagePrefix, and throttled ensureRollupCurrent.
Summary aggregation merge
src/usage/summary.ts
Adds RollupContribution, surface/date filters, exports attribution helpers, and seeds buildDayGrid/buildModels/buildProviders/summarizeUsage with rollup data additively.
Route and API-key wiring
src/server/management/logs-usage-routes.ts, src/server/management/api-key-usage.ts, tests/api-usage.test.ts
Wires rollup refresh/read into the usage route and API-key aggregation, including cutline-aware cache keys.
Rollup fixtures and test suites
tests/helpers/usage-rollup-fixtures.ts, tests/usage-rollup.test.ts, tests/usage-rollup-merge.test.ts
Adds a seeded randomized fixture generator and tests for aggregation, crash recovery, invalidation, watermarking, permissions, and reader-merge exactness.

Estimated code review effort: 4 (Complex) | ~75 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Route as logs-usage-routes
  participant Rollup as rollup.ts
  participant Log as usage/log.ts
  participant Summary as summary.ts

  Route->>Rollup: ensureRollupCurrent()
  Rollup->>Log: read eligible aged prefix
  Rollup->>Rollup: foldUsagePrefix() append + fsync commit row
  Rollup->>Rollup: write usage-rollup-meta.json atomically
  Route->>Rollup: readRollupSnapshot()
  Rollup-->>Route: RollupSnapshot (cutline, day/model/provider/key rows)
  Route->>Log: readUsageSnapshotForManagement(fromOffset=cutline)
  Log-->>Route: raw tail entries
  Route->>Summary: summarizeUsage(entries, rollup=RollupContribution)
  Summary-->>Route: merged usage summary
Loading

Possibly related PRs

Suggested reviewers: ingwannu, wibias

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.69% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the primary change: introducing a daily rollup sidecar to preserve usage history beyond the management API read window.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/260804-usage-rollup

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b0d5417d8b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/usage/rollup.ts
const fromOffset = parsed.snapshot.cutlineOffset;
const toOffset = eligibleCutline(rawFd, size, fromOffset, now);
if (toOffset > fromOffset) {
const entries = await parseUsageRange(rawFd, fromOffset, toOffset);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Aggregate folded rows without retaining the full prefix

When rebuilding from a large existing usage.jsonl, foldUsagePrefix first retains every normalized entry in the eligible prefix and only then calls accumulateEntries; a 157 MB log can therefore expand to several times that size in heap, and the append-only log makes the allocation unbounded for older installations. Because this derived sidecar is started from the management request path, an out-of-memory failure can terminate the proxy rather than fall back to the raw tail; parse and aggregate incrementally instead of materializing the prefix.

AGENTS.md reference: src/AGENTS.md:L17-L17

Useful? React with 👍 / 👎.

Comment thread src/usage/rollup.ts
Comment on lines +450 to +452
for (let position = fromOffset; position < size;) {
const length = Math.min(READ_CHUNK_BYTES, size - position);
const chunk = readExactly(fd, length, position);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Yield while locating the eligible cutline

On the first GET /api/usage for a large existing log, the fire-and-forget call still executes synchronously until its first await, and eligibleCutline performs synchronous reads, JSON parsing, and date conversion over the entire old prefix before that point. Thus the advertised background fold blocks Bun's event loop and delays this and unrelated management requests; make the cutline scan cooperative or defer it before doing synchronous work.

Useful? React with 👍 / 👎.

Comment thread src/usage/rollup.ts
Comment on lines +803 to +805
if (meta && currentLineage && meta.version === 1 && meta.lineageKey === currentLineage
&& meta.priceFingerprint === fingerprint
&& Date.now() - meta.lastFoldAttemptAt < ROLLUP_ATTEMPT_THROTTLE_MS) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Validate the committed boundary before throttling

If usage.jsonl is truncated or rewritten in place within ten minutes of a fold, its inode/birthtime and the metadata fingerprint still match, so this early return leaves readRollupSnapshot serving the stale cutline. A truncation below that offset makes /api/usage return read_failed until the throttle expires, while a same-sized rewrite merges stale aggregates; validate the committed size/boundary before honoring the throttle so the optional sidecar can immediately fall back to the raw-tail path.

AGENTS.md reference: src/AGENTS.md:L17-L17

Useful? React with 👍 / 👎.

Comment thread src/usage/rollup.ts
Comment on lines +463 to +465
try { parsed = JSON.parse(line); } catch { return lineStart; }
if (!isObject(parsed) || !usableTimestamp(parsed.timestamp)
|| localDateKey(parsed.timestamp) >= cutoff) return lineStart;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Advance past complete malformed usage rows

If an append-only log contains one complete malformed or timestamp-less JSONL row, eligibleCutline permanently returns that row's offset on every later fold. Although the existing raw reader deliberately skips malformed complete rows and continues, the sidecar can never fold any subsequent old history, so once the suffix exceeds the management byte window the preservation feature regresses to truncation; skip complete invalid rows while advancing and reserve the stop behavior for an incomplete trailing row or a valid young entry.

Useful? React with 👍 / 👎.

Comment on lines +160 to +161
const folded = readRollupSnapshot();
const cutlineOffset = folded?.cutlineOffset ?? 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Honor usageRollupEnabled in API-key summaries

When an operator disables usageRollupEnabled after a sidecar has already been created, GET /api/keys still unconditionally loads that sidecar and reads the raw log from its cutline. This contradicts the flag's documented raw-tail-only behavior and means disabling a suspect rollup does not disable it for per-key totals; pass the flag through the API-key caller and use folded = null with offset zero when it is false.

Useful? React with 👍 / 👎.

Comment thread src/usage/rollup.ts
for (const entry of entries) {
if (!usableTimestamp(entry.timestamp)) continue;
oldestTimestampMs = oldestTimestampMs === null ? entry.timestamp : Math.min(oldestTimestampMs, entry.timestamp);
const date = localDateKey(entry.timestamp);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Rebuild local-date aggregates after timezone changes

If the service folds history in one timezone and the host timezone or TZ later changes, the lineage and price fingerprint remain valid, so these persisted date keys continue using the old timezone while tail rows and range boundaries use the new one. Requests near midnight are then assigned to inconsistent days, and a 30-day query can include or exclude the wrong rolled-up boundary day; include the timezone in rollup validity or store timezone-neutral data that can be rebucketed when read.

Useful? React with 👍 / 👎.

Comment thread src/usage/summary.ts
providers: buildProviders(filteredEntries, totals.totalTokens),
days: buildDayGrid(
range, since, now, filteredEntries, rollupDays, rollupModels,
range === "all" ? rollup?.oldestTimestampMs ?? null : null,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Scope the all-range oldest timestamp to the selected surface

For range=all with a surface filter, this passes the rollup's global oldest timestamp even though rollupDays has already been filtered by surface. If older folded Claude traffic predates all Grok traffic, for example, the Grok day grid gains leading zero days back to the Claude timestamp, unlike the pre-rollup behavior based on filtered entries; retain an oldest timestamp per surface or derive it from the filtered rollup contribution.

Useful? React with 👍 / 👎.

| `GET /api/debug/injection-logs` | Read bounded guidance-injection debug entries | — |
| `GET /api/claude/inbound-debug` | Read Claude inbound debug state and entries | — |
| `GET /api/usage` | Summarize usage by range and client surface | Returns an `error: "read_failed"` summary if storage cannot be read |
| `GET /api/usage` | Summarize usage by range and client surface. History older than the bounded read window is served from the daily rollup sidecar (`usage-rollup.jsonl`), so `all`-range summaries keep the full history; `7d` stays raw-exact, and `30d` includes rolled-up days at day granularity. Deleting the rollup files is safe — they rebuild in the background. | Returns an `error: "read_failed"` summary if storage cannot be read |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Qualify exactness claims when the raw tail is truncated

For a high-volume installation whose last nine days alone exceed managementUsageMaxReadBytes or the 200,000-entry cap, the rollup cannot cover the omitted recent prefix: 7d loses rows and even all reports historyTruncated rather than retaining full history. Qualify the all and 7d claims on the raw tail fitting those bounds so the public API documentation matches the implemented truncation metadata.

AGENTS.md reference: docs-site/AGENTS.md:L8-L8

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 21

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@devlog/_plan/260804_usage_rollup_preservation/010_rollup_core.md`:
- Line 106: Add a language tag such as text to the opening pseudo-code fence at
the affected documentation section, keeping the fenced content unchanged so
markdownlint no longer reports MD040.
- Around line 162-167: The readRollupSnapshot() function in src/usage/rollup.ts
currently does not validate whether the live raw file is large enough to support
the committed cutlineOffset. Add a synchronous check within the validity gate
(around lines 424-432) that compares the currentRawSize against cutlineOffset
and returns null when the file is shorter than the cutline, preventing invalid
offsets from reaching downstream functions like
readUsageSnapshotForManagement(). Then extend the test file
tests/usage-rollup.test.ts (around lines 260-270) to call readRollupSnapshot()
after truncating the raw file below the cutline and verify it returns null
instead of proceeding with an invalid snapshot.

In `@docs-site/src/content/docs/reference/management-api.md`:
- Line 127: Update the GET /api/usage documentation row to qualify rollup
behavior: all-range summaries retain full history only when the rollup is valid
and caught up; 7d is raw-exact only when truncatedPrefixBytes is 0; and 30d may
include up to approximately 24 hours from the rolled-up boundary day. State that
disabled, invalid, rebuilding, or gapped rollups serve raw-tail-only data, and
deleting usage-rollup.jsonl can temporarily restore truncation until background
rebuilding completes.

In `@src/config.ts`:
- Line 951: Update the usageRollupEnabled schema definition to add a true
fallback for invalid values, while retaining the existing default for missing
values. Follow the established .catch(...) pattern used by nearby configuration
fields so hand-edited non-boolean values remain enabled and do not enter
loadConfig’s backup-and-defaults reset path.

In `@src/server/management/api-key-usage.ts`:
- Around line 160-173: Update the API-key usage aggregation around
readRollupSnapshot and readUsageSnapshotForManagement to accept and honor the
configured usageRollupEnabled flag: when disabled, use cutlineOffset 0 and pass
no folded keys or attributionSinceMs; when enabled, preserve the existing rollup
behavior. Include the flag in rollupCache.revisionKey or invalidate rollupCache
when it changes, and add a regression test covering a created rollup snapshot
followed by disabling the feature through the shared routing/configuration
layers.

In `@src/usage/rollup.ts`:
- Around line 171-178: Make the shared stableStringify implementation injective
by encoding undefined with an explicit sentinel before handling arrays and
objects, then update both src/usage/rollup.ts:171-178 and
scripts/generate-jawcode-metadata.ts:59-66 to reuse that exported helper rather
than maintaining duplicate copies. Preserve all existing canonicalization
behavior for other values; the resulting fingerprint change should trigger one
rollup rebuild. Bump ROLLUP_COST_SEMANTICS_VERSION in src/usage/cost.ts only if
making the rebuild explicit in metadata.
- Around line 479-508: The `parseUsageRange` function accumulates all entries
into the `entries` array with no cap, causing memory spikes on large log files.
Cap the `toOffset` parameter in `foldUsagePrefix` (the caller of
`parseUsageRange`) to limit the byte range processed per invocation, such as
`fromOffset + N` bytes or to a single day boundary. This allows the throttled
`ensureRollupCurrent` process to walk through the file in smaller segments,
which the existing segment chain in `parseRollup` already supports, without
requiring format changes.
- Line 613: Instead of building a composite key string at line 613 by joining
date, admissionKind, and apiKeyId with null-byte delimiters and then parsing it
back at line 650, store these identity fields directly on the accumulator value
object itself so they can be retrieved without re-deriving them from the key.
Apply this same pattern change to the model rows at lines 575/628 and the
provider rows at lines 586/640. Verify that mergeGroupRow at line 415 continues
to work correctly since it reconstructs keys from the row fields rather than
from the composite key string.
- Around line 807-809: Record rollup fold failures without changing the existing
raw-tail-only fallback: add module-level rollup statistics alongside the other
state, increment the failure counter in the catch around foldUsagePrefix, and
store only a safe error summary without raw usage-row content or credentials.
Keep the current retry and degradation behavior unchanged.
- Around line 310-320: Update parseRollup/readRollupSnapshot to cache the parsed
snapshot and avoid rereading and splitting the full rollup on every /api/usage
request. Add bounded growth by compacting the rollup atomically when its file
size or segment count crosses a threshold, ensuring foldUsagePrefix continues
appending safely and cached snapshots are invalidated or refreshed after
compaction.
- Around line 461-466: Update eligibleCutline to advance over complete
newline-terminated rows when JSON parsing fails or the parsed timestamp is
unusable, matching parseUsageRange and accumulateEntries behavior; stop only
when a valid timestamp is recent. Preserve lineStart for incomplete trailing
rows, and add regression tests covering malformed JSON and unusable timestamps.
- Around line 424-436: Update readRollupSnapshot() to parse the snapshot, obtain
currentUsageLogRevision().size, and return null when the revision is unavailable
or snapshot.cutlineOffset exceeds the current raw log size. Preserve existing
metadata validation and return the parsed snapshot only when the offset is
valid, then add a regression test covering in-place log truncation.
- Around line 772-782: Update the rollup append flow around repairAppendBoundary
to acquire a cross-process lock before repairing and writing, holding it through
fsyncSync and releasing it in the existing cleanup path. Open the file with
O_APPEND and change the writeSync calls to pass null for the position,
preserving the loop so the complete append is serialized across processes.

In `@src/usage/summary.ts`:
- Around line 164-170: Update rollupDateOverlapsRange and the surrounding
summary merge logic so a rollup day that begins before a partial since timestamp
is excluded from aggregates, leaving that boundary day to the exact
filteredEntries raw tail; continue including complete days wholly within the
range. Add a regression test with entries both before and after since on the
same local date and verify the merged result does not include the pre-since
usage.
- Around line 419-429: Update the rollup overflow handling around
dayModelSeedRequests and the “other” row construction to preserve request
identity across models, so a request attributed to multiple overflow models is
counted once like the live shared requests Set path. Persist or propagate enough
request-group information to deduplicate merged overflow requests rather than
summing per-model row.requests. Add coverage using more than
MAX_USAGE_MODEL_BREAKDOWN_ROWS models with one request attributed to multiple
overflow models, and verify rollup and unbounded results match.

In `@tests/helpers/usage-rollup-fixtures.ts`:
- Around line 35-48: Preserve the intended fold-boundary coverage by updating
test 1 in usage-rollup-merge.test.ts to retain the fixture-generated timestamps
for boundary rows instead of rewriting every generated entry. Because generated
is filtered, use fixture.foldBoundaryTimestamp for boundary assertions rather
than recomputing the original index modulus; keep the remaining synthetic
timestamp behavior unchanged.
- Line 86: Replace the random comparator in the entries shuffle with a seeded
Fisher–Yates loop, iterating from the final index down to 1 and selecting each
swap position with random() across the inclusive range. Preserve the seeded
random source and swap entries in place so permutation and random-call behavior
are deterministic across engines.

In `@tests/usage-rollup-merge.test.ts`:
- Around line 295-301: Add an explicit raw usage-log size precondition in the
phase-2 setup before starting the legacy server or asserting the response,
matching phase 1’s readFileSync(usageLogPath()).byteLength check and verifying
it exceeds the configured 300-byte managementUsageMaxReadBytes cap. Keep the
existing truncation assertions unchanged.
- Line 313: Remove the explicit apiKeyId: undefined from the entry fixture in
the usage-rollup merge test, leaving the optional property omitted while
preserving the loopback entry’s other fields and runtime behavior. Also update
the analogous usage fixture in the usage-rollup test to omit optional fields
currently assigned undefined, including usage and totalTokens.
- Around line 116-119: Update canonicalSummary’s day-row mapping to normalize
estimatedCostUsd consistently with the existing model and provider
normalization, if day rows expose that field, while preserving the day.models
sorting. Add an explicit toBeCloseTo assertion for the day-level cost in the
corresponding exact-equality test so the value remains covered rather than
discarded.

In `@tests/usage-rollup.test.ts`:
- Around line 185-194: The test fails to isolate the rowCount validation because
the digest restoration at line 190 inadvertently reapplies the tampered digest
instead of restoring the original. Save the original digest value from the
commit object immediately after line 186 before any tampering occurs, then use
that saved value to restore commit.payloadDigest at line 190 instead of
re-reading from commits() which returns the tampered version from disk. This
ensures that only the rowCount modification is active when line 193 validates,
properly isolating the rowCount check from the digest check.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 49484994-d35a-423c-b8ea-98d0015839e5

📥 Commits

Reviewing files that changed from the base of the PR and between e44d234 and b0d5417.

⛔ Files ignored due to path filters (1)
  • src/generated/jawcode-model-metadata.ts is excluded by !**/generated/**
📒 Files selected for processing (21)
  • devlog/_plan/260804_usage_rollup_preservation/000_research.md
  • devlog/_plan/260804_usage_rollup_preservation/001_roadmap.md
  • devlog/_plan/260804_usage_rollup_preservation/002_audit_synthesis.md
  • devlog/_plan/260804_usage_rollup_preservation/010_rollup_core.md
  • devlog/_plan/260804_usage_rollup_preservation/020_reader_merge.md
  • devlog/_plan/260804_usage_rollup_preservation/030_validation_docs_pr.md
  • docs-site/src/content/docs/reference/configuration/server.md
  • docs-site/src/content/docs/reference/management-api.md
  • scripts/generate-jawcode-metadata.ts
  • src/config.ts
  • src/server/management/api-key-usage.ts
  • src/server/management/logs-usage-routes.ts
  • src/types.ts
  • src/usage/cost.ts
  • src/usage/log.ts
  • src/usage/rollup.ts
  • src/usage/summary.ts
  • tests/api-usage.test.ts
  • tests/helpers/usage-rollup-fixtures.ts
  • tests/usage-rollup-merge.test.ts
  • tests/usage-rollup.test.ts


## Fold algorithm (`foldUsagePrefix`) — rev2

```

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add a language tag to the pseudo-code fence.

The opening fence on Line 106 has no language tag. Add text or another suitable language so markdownlint does not report MD040.

🧰 Tools
🪛 markdownlint-cli2 (0.23.1)

[warning] 106-106: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@devlog/_plan/260804_usage_rollup_preservation/010_rollup_core.md` at line
106, Add a language tag such as text to the opening pseudo-code fence at the
affected documentation section, keeping the fenced content unchanged so
markdownlint no longer reports MD040.

Source: Linters/SAST tools

Comment on lines +162 to +167
**Synchronous validity gate (R2-2):** `readRollupSnapshot()` itself validates
version, lineage (against the live raw file), and priceFingerprint BEFORE
returning; any mismatch or absent/corrupt meta returns null, and the caller
serves raw-tail-only (cutline 0, legacy behavior) while the background rebuild
proceeds. Stale-cost data is structurally unreachable regardless of the async
fold's timing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- candidate files ---'
git ls-files | rg '(^|/)(010_rollup_core\.md|.*rollup.*|.*usage.*)' | head -200

printf '%s\n' '--- target section ---'
target='devlog/_plan/260804_usage_rollup_preservation/010_rollup_core.md'
wc -l "$target"
sed -n '120,210p' "$target"

printf '%s\n' '--- related symbols and size checks ---'
rg -n -C 4 'readRollupSnapshot|cutlineOffset|currentRawSize|raw.*size|usage\.jsonl|fold preflight|lineage|priceFingerprint' . \
  -g '!node_modules' -g '!dist' -g '!build' | head -500

Repository: lidge-jun/opencodex

Length of output: 50377


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- rollup implementation map ---'
ast-grep outline src/usage/rollup.ts
printf '%s\n' '--- rollup implementation ---'
sed -n '1,280p' src/usage/rollup.ts

printf '%s\n' '--- rollup tests map and relevant cases ---'
ast-grep outline tests/usage-rollup.test.ts
rg -n -C 5 'readRollupSnapshot|live size|cutline|truncate|lineage|fingerprint|ensureRollupCurrent' \
  src/usage/rollup.ts tests/usage-rollup.test.ts src/server/management/logs-usage-routes.ts \
  devlog/_plan/260804_usage_rollup_preservation/020_reader_merge.md

Repository: lidge-jun/opencodex

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- synchronous reader gate ---'
sed -n '420,438p' src/usage/rollup.ts

printf '%s\n' '--- asynchronous truncation recovery ---'
sed -n '729,760p' src/usage/rollup.ts

printf '%s\n' '--- route merge path ---'
sed -n '180,225p' src/server/management/logs-usage-routes.ts

printf '%s\n' '--- raw snapshot reader ---'
ast-grep outline src/usage/summary.ts
rg -n -C 8 'readUsageSnapshotForManagement|fromOffset|truncatedPrefixBytes|readUsage' \
  src/usage/summary.ts src/usage/log.ts src/server/management/logs-usage-routes.ts | head -260

printf '%s\n' '--- truncation regression coverage ---'
sed -n '248,273p' tests/usage-rollup.test.ts

Repository: lidge-jun/opencodex

Length of output: 22530


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from pathlib import Path
import re

rollup = Path("src/usage/rollup.ts").read_text()
reader = re.search(
    r"export function readRollupSnapshot\(\): RollupSnapshot \| null \{(?P<body>.*?)\n\}",
    rollup,
    re.S,
)
if not reader:
    raise SystemExit("readRollupSnapshot body not found")
body = reader.group("body")

print("readRollupSnapshot_has_fstat:", "fstatSync" in body)
print("readRollupSnapshot_has_size_cutline_comparison:",
      bool(re.search(r"(rawSize|currentRawSize|size).*cutlineOffset|cutlineOffset.*(rawSize|currentRawSize|size)", body)))
print("readRollupSnapshot_returns_parsed_snapshot:",
      "return parseRollup(currentLineage).snapshot;" in body)

fold = re.search(
    r"export async function foldUsagePrefix\(\): Promise<void> \{(?P<body>.*?)\n\}",
    rollup,
    re.S,
)
if not fold:
    raise SystemExit("foldUsagePrefix body not found")
fold_body = fold.group("body")
print("fold_has_async_size_cutline_check:",
      "rawSize < parsed.snapshot.cutlineOffset" in fold_body)

tests = Path("tests/usage-rollup.test.ts").read_text()
case = re.search(r'test\("4c\. live raw size below.*?(?=\n\s*test\(|\Z)', tests, re.S)
print("truncation_test_present:", bool(case))
if case:
    text = case.group(0)
    print("truncation_test_calls_read_before_rebuild:",
          bool(re.search(r"truncateSync.*?readRollupSnapshot\(\)", text, re.S)))
    print("truncation_test_calls_rebuild_before_read:",
          bool(re.search(r"truncateSync.*?ensureRollupCurrent\(\).*?readRollupSnapshot\(\)", text, re.S)))
PY

Repository: lidge-jun/opencodex

Length of output: 462


Invalidate snapshots when the raw file is shorter than the committed cutline.

readRollupSnapshot() does not check the live raw-file size. After in-place truncation below cutlineOffset, /api/usage passes the invalid offset to readUsageSnapshotForManagement(), which throws and returns read_failed until the asynchronous rebuild completes.

Add a synchronous size check in src/usage/rollup.ts:424-432 and return null when currentRawSize < cutlineOffset. Extend tests/usage-rollup.test.ts:260-270 to call readRollupSnapshot() before rebuilding and expect null.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@devlog/_plan/260804_usage_rollup_preservation/010_rollup_core.md` around
lines 162 - 167, The readRollupSnapshot() function in src/usage/rollup.ts
currently does not validate whether the live raw file is large enough to support
the committed cutlineOffset. Add a synchronous check within the validity gate
(around lines 424-432) that compares the currentRawSize against cutlineOffset
and returns null when the file is shorter than the cutline, preventing invalid
offsets from reaching downstream functions like
readUsageSnapshotForManagement(). Then extend the test file
tests/usage-rollup.test.ts (around lines 260-270) to call readRollupSnapshot()
after truncating the raw file below the cutline and verify it returns null
instead of proceeding with an invalid snapshot.

| `GET /api/debug/injection-logs` | Read bounded guidance-injection debug entries | — |
| `GET /api/claude/inbound-debug` | Read Claude inbound debug state and entries | — |
| `GET /api/usage` | Summarize usage by range and client surface | Returns an `error: "read_failed"` summary if storage cannot be read |
| `GET /api/usage` | Summarize usage by range and client surface. History older than the bounded read window is served from the daily rollup sidecar (`usage-rollup.jsonl`), so `all`-range summaries keep the full history; `7d` stays raw-exact, and `30d` includes rolled-up days at day granularity. Deleting the rollup files is safe — they rebuild in the background. | Returns an `error: "read_failed"` summary if storage cannot be read |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Qualify the rollup availability and exactness conditions.

The route serves raw-tail-only data when rollup is disabled, invalid, rebuilding, or has a residual gap. Therefore, all is full-history only when the rollup is valid and caught up. 7d is raw-exact only when truncatedPrefixBytes === 0. For 30d, the whole rolled-up boundary day can add up to approximately 24 hours outside the exact window.

Update this row to state these conditions. Also state that deleting the sidecar can temporarily restore truncation until the rebuild completes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs-site/src/content/docs/reference/management-api.md` at line 127, Update
the GET /api/usage documentation row to qualify rollup behavior: all-range
summaries retain full history only when the rollup is valid and caught up; 7d is
raw-exact only when truncatedPrefixBytes is 0; and 30d may include up to
approximately 24 hours from the rolled-up boundary day. State that disabled,
invalid, rebuilding, or gapped rollups serve raw-tail-only data, and deleting
usage-rollup.jsonl can temporarily restore truncation until background
rebuilding completes.

Source: Path instructions

Comment thread src/config.ts
const configSchema = z.object({
port: z.number().int().min(0).max(65535).default(10100),
managementUsageMaxReadBytes: z.number().int().positive().default(64 * 1024 * 1024),
usageRollupEnabled: z.boolean().default(true),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Add .catch(true) so a hand-edited bad value cannot trigger the backup-and-defaults reset.

usageRollupEnabled: z.boolean().default(true) fails the parse for any non-boolean value. Trace what a hand-edited "usageRollupEnabled": "true" (a string) does in loadConfig() at Lines 1617-1666:

  1. Line 1630 configSchema.safeParse(parsed) fails with invalid_type.
  2. Line 1644 builds merged = { ...defaults, ...parsed }. The spread order puts the parsed bad value last, so the merge cannot repair a field that is present-but-invalid — it only repairs missing fields.
  3. Line 1649 retry fails for the same reason.
  4. Line 1660 calls warnAndBackupInvalidConfig and returns getDefaultConfig(), discarding providers, codexAccounts, and apiKeys.

This is the exact outcome the surrounding comments say to avoid. Lines 985-1002 state that a hand-edited typo "must never trip the backup-and-defaults repair path below and wipe providers/pool accounts", and note that an emptied apiKeys array is worse than cosmetic because assertServerAuthConfig refuses to start without a data credential. streamMode, hostname, injectionModel, and syncCodexSubagentDefaults all carry .catch(...) for this reason.

Degrading is safe for this field. The consumer in src/server/management/logs-usage-routes.ts tests config.usageRollupEnabled !== false, so any degraded value keeps the rollup enabled — matching the documented default in src/types.ts:546-547.

🛡️ Proposed fix
-  usageRollupEnabled: z.boolean().default(true),
+  // Degrade-don't-reject, same rationale as `streamMode` below: a hand-edited
+  // non-boolean must not trip the backup-and-defaults repair path and wipe
+  // providers/apiKeys for one optional derived-cache flag.
+  usageRollupEnabled: z.boolean().default(true).catch(true),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
usageRollupEnabled: z.boolean().default(true),
// Degrade-don't-reject, same rationale as `streamMode` below: a hand-edited
// non-boolean must not trip the backup-and-defaults repair path and wipe
// providers/apiKeys for one optional derived-cache flag.
usageRollupEnabled: z.boolean().default(true).catch(true),
🧰 Tools
🪛 ast-grep (0.45.0)

[warning] Importing child_process exposes a command-execution surface; ensure any command/argument built from input is validated, and prefer execFile/spawn with an argument array over exec.
Context: import { execFileSync } from "node:child_process";
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(detect-child-process-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/config.ts` at line 951, Update the usageRollupEnabled schema definition
to add a true fallback for invalid values, while retaining the existing default
for missing values. Follow the established .catch(...) pattern used by nearby
configuration fields so hand-edited non-boolean values remain enabled and do not
enter loadConfig’s backup-and-defaults reset path.

Comment on lines +160 to +173
const folded = readRollupSnapshot();
const cutlineOffset = folded?.cutlineOffset ?? 0;
const observedKey = `${usageLogRevisionKey(currentUsageLogRevision())}|${cutlineOffset}|${idsKey}`;
if (rollupCache?.revisionKey === observedKey && now < rollupCache.expiresAt) {
return rollupCache.snapshot;
}

const snapshot = await readUsageSnapshotForManagement(maxReadBytes);
const snapshot = await readUsageSnapshotForManagement(maxReadBytes, cutlineOffset);
const rolled = {
...rollupApiKeyUsage(snapshot.entries, configuredIds, now),
...rollupApiKeyUsage(snapshot.entries, configuredIds, now, folded?.keys, folded?.attributionSinceMs ?? null),
...(snapshot.truncatedPrefixBytes > 0 || snapshot.entriesTruncated ? { historyTruncated: true as const } : {}),
};
rollupCache = {
revisionKey: `${usageLogRevisionKey(snapshot.revision)}|${idsKey}`,
revisionKey: `${usageLogRevisionKey(snapshot.revision)}|${cutlineOffset}|${idsKey}`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Honor usageRollupEnabled in API-key usage aggregation.

This path always reads readRollupSnapshot(). If a user disables rollups after sidecar files exist, /api/usage ignores them but API-key usage continues to merge them and starts the raw read at their cutline.

Pass the configured flag into this path. When the flag is false, use cutlineOffset = 0 and omit folded.keys and folded.attributionSinceMs. Include the flag in the cache identity or clear the cache when it changes.

Add a regression test that creates a rollup snapshot and then disables the feature.

As per path instructions, runtime changes must not bypass the shared routing/config layers. <path_instructions>

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/management/api-key-usage.ts` around lines 160 - 173, Update the
API-key usage aggregation around readRollupSnapshot and
readUsageSnapshotForManagement to accept and honor the configured
usageRollupEnabled flag: when disabled, use cutlineOffset 0 and pass no folded
keys or attributionSinceMs; when enabled, preserve the existing rollup behavior.
Include the flag in rollupCache.revisionKey or invalidate rollupCache when it
changes, and add a regression test covering a created rollup snapshot followed
by disabling the feature through the shared routing/configuration layers.

Source: Path instructions

const duplicateRequestId = `property-${seed}-duplicate`;
entries.push({ ...entries[0]!, requestId: duplicateRequestId, timestamp: now - 15 * dayMs });
entries.push({ ...entries[1]!, requestId: duplicateRequestId, timestamp: now - 12 * dayMs });
entries.sort(() => random() - 0.5);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

ECMAScript Array.prototype.sort inconsistent comparator implementation-defined order JavaScriptCore

💡 Result:

In ECMAScript, the behavior of Array.prototype.sort is explicitly defined as implementation-defined if the provided comparator is not consistent [1][2]. A consistent comparator is required to satisfy specific mathematical properties, including reflexivity, symmetry of anti-symmetry, and transitivity [2][3]. If these conditions are not met, the resulting order of the array elements is not guaranteed by the ECMAScript specification [1][2]. For JavaScriptCore (the engine used by Safari and other WebKit-based browsers), the implementation of Array.prototype.sort has been updated over time to better align with the tightened ECMAScript specification [4]. While the specification allows for implementation-defined behavior when a comparator is inconsistent, major engines, including JavaScriptCore, generally aim to follow the formal requirements for well-behaved comparators [4]. If a non-well-formed comparator—one that violates anti-symmetry or transitivity—is used, the sort result can and often will differ across JavaScript engines (e.g., JavaScriptCore, V8, and SpiderMonkey) [5]. This occurs because different engines may employ different internal sorting algorithms (such as variations of Timsort or Quicksort) which handle inconsistent input in engine-specific ways [6][7]. Consequently, developers are strongly advised to ensure that their comparator functions are "well-formed" and consistent, as relying on engine-specific handling of inconsistent comparators can lead to unpredictable or non-portable code behavior [5].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- fixture ---'
cat -n tests/helpers/usage-rollup-fixtures.ts | sed -n '1,115p'

printf '%s\n' '--- consumer references ---'
rg -n -C 8 'usage-rollup-fixtures|ageDays|foldBoundaryTimestamp|entries' tests/usage-rollup-merge.test.ts tests/helpers

Repository: lidge-jun/opencodex

Length of output: 1924


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
# Read-only behavioral probe for the random comparator and seeded Fisher-Yates.
# This models Array#sort with a deliberately inconsistent comparator so that the
# PRNG draw count and resulting permutation are visible without executing repo code.
class Lcg:
    def __init__(self, seed):
        self.state = seed
        self.draws = 0
    def random(self):
        self.state = (1664525 * self.state + 1013904223) & 0xffffffff
        self.draws += 1
        return self.state / 2**32

def random_comparator_sort(values, seed, algorithm):
    rng = Lcg(seed)
    a = list(values)

    def cmp(x, y):
        return -1 if rng.random() < 0.5 else 1

    if algorithm == "insertion":
        for i in range(1, len(a)):
            value = a[i]
            j = i
            while j > 0 and cmp(a[j - 1], value) > 0:
                a[j] = a[j - 1]
                j -= 1
            a[j] = value
    elif algorithm == "merge":
        def sort(xs):
            if len(xs) < 2:
                return xs
            mid = len(xs) // 2
            left, right = sort(xs[:mid]), sort(xs[mid:])
            out = []
            while left and right:
                if cmp(left[0], right[0]) <= 0:
                    out.append(left.pop(0))
                else:
                    out.append(right.pop(0))
            return out + left + right
        a = sort(a)
    return a, rng.draws

def fisher_yates(values, seed):
    rng = Lcg(seed)
    a = list(values)
    for i in range(len(a) - 1, 0, -1):
        j = int(rng.random() * (i + 1))
        a[i], a[j] = a[j], a[i]
    return a, rng.draws

values = list(range(12))
for algorithm in ("insertion", "merge"):
    print(algorithm, random_comparator_sort(values, 12345, algorithm))
print("fisher-yates", fisher_yates(values, 12345))
PY

Repository: lidge-jun/opencodex

Length of output: 1924


Replace the random comparator with a seeded Fisher–Yates shuffle.

entries.sort(() => random() - 0.5) uses an inconsistent comparator. ECMAScript does not guarantee the resulting order in this case. The engine's comparison sequence controls both the permutation and the number of random() calls.

This permutation affects the age-based fold/tail split in tests/usage-rollup-merge.test.ts:157. Bun upgrades can therefore change fixture coverage without changing the seed.

Use a seeded Fisher–Yates shuffle at tests/helpers/usage-rollup-fixtures.ts:86:

for (let index = entries.length - 1; index > 0; index -= 1) {
  const swap = Math.floor(random() * (index + 1));
  [entries[index], entries[swap]] = [entries[swap]!, entries[index]!];
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/helpers/usage-rollup-fixtures.ts` at line 86, Replace the random
comparator in the entries shuffle with a seeded Fisher–Yates loop, iterating
from the final index down to 1 and selecting each swap position with random()
across the inclusive range. Preserve the seeded random source and swap entries
in place so permutation and random-call behavior are deterministic across
engines.

Comment on lines +116 to +119
const days = summary.days.map(day => ({
...day,
models: [...day.models].sort((a, b) => `${a.provider}/${a.model}`.localeCompare(`${b.provider}/${b.model}`)),
}));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

canonicalSummary normalizes cost for models and providers but not for day rows, which can make line 188 float-fragile.

Lines 112 and 114 deliberately coerce estimatedCostUsd on model and provider rows, and test 1 then zeroes those fields at lines 184-187 before the exact toEqual at line 188. The day mapping at lines 116-119 does neither — it only re-sorts day.models.

The failure mode: per the PR objectives, the rollup stores fold-time cost sums, while the reference summarizeUsage(entries, "all", FIXED_NOW) at line 176 recomputes cost from raw entries. The two paths sum the same floating-point values in a different order, so they can differ in the last bits. Test 1 guards against exactly that for the summary (line 179, toBeCloseTo(..., 9)), for models (line 191), and for providers (line 195) — but line 188 still compares day rows with exact structural equality. If UsageSummary["days"][number] or its nested models carry estimatedCostUsd, this test can fail intermittently on a corpus reshuffle, and the failure would look like an unrelated rollup bug.

If day rows do carry a cost field, zero it the same way:

♻️ Proposed fix: normalize day-level cost too
   const days = summary.days.map(day => ({
     ...day,
-    models: [...day.models].sort((a, b) => `${a.provider}/${a.model}`.localeCompare(`${b.provider}/${b.model}`)),
+    ...(("estimatedCostUsd" in day) ? { estimatedCostUsd: 0 } : {}),
+    models: [...day.models]
+      .map(row => ({ ...row, ...(("estimatedCostUsd" in row) ? { estimatedCostUsd: 0 } : {}) }))
+      .sort((a, b) => `${a.provider}/${a.model}`.localeCompare(`${b.provider}/${b.model}`)),
   }));

Then add an explicit toBeCloseTo check for the day-level cost, mirroring lines 189-196, so the field stays covered rather than silently dropped.

Run this to confirm whether day rows and per-day model rows expose estimatedCostUsd:

#!/bin/bash
# Description: Determine whether UsageSummary day rows carry a cost field that line 188 compares exactly.
set -euo pipefail

echo "=== Locate the summary types ==="
ast-grep outline src/usage/summary.ts --items all

echo "=== Day/model row interfaces ==="
rg -nP --type=ts -A 25 '(interface|type)\s+(UsageSummary|UsageDay\w*|UsageModel\w*|UsageProvider\w*)\b' src/usage/summary.ts

echo "=== Where day rows receive estimatedCostUsd ==="
rg -nP --type=ts -C4 'estimatedCostUsd' src/usage/summary.ts
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/usage-rollup-merge.test.ts` around lines 116 - 119, Update
canonicalSummary’s day-row mapping to normalize estimatedCostUsd consistently
with the existing model and provider normalization, if day rows expose that
field, while preserving the day.models sorting. Add an explicit toBeCloseTo
assertion for the day-level cost in the corresponding exact-equality test so the
value remains covered rather than discarded.

Comment on lines +295 to +301
saveConfig(baseConfig({ managementUsageMaxReadBytes: 300, usageRollupEnabled: false }));
writeRaw([firstOld, entry({ requestId: "old-2", timestamp: FIXED_NOW - 12 * DAY_MS })]);
const legacyServer = startServer(0);
try {
const legacy = await managementFetch(new URL("/api/usage?range=all", legacyServer.url)).then(response => response.json()) as UsageRouteBody;
expect(legacy.historyTruncated).toBe(true);
expect(legacy.truncatedPrefixBytes).toBeGreaterThan(0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Phase 2 asserts truncation without asserting the precondition that makes truncation possible.

Phase 1 pins its precondition explicitly at line 285:

expect(readFileSync(usageLogPath()).byteLength).toBeGreaterThan(300);

Phase 2 sets the same managementUsageMaxReadBytes: 300 cap at line 295, writes two entries at line 296, then asserts historyTruncated === true at line 300 — with no equivalent size check.

The two entries currently serialize to roughly 420 bytes, so the test passes today. The margin is thin and undefended. If a field is later removed from the entry() helper at lines 52-58, or the requestId values shorten, the raw log can drop under 300 bytes. Then historyTruncated becomes false and line 300 fails with a bare expected true, received false — a message that points at the rollup flag logic rather than at the real cause, which is fixture size.

Add the same precondition assertion phase 1 already uses:

♻️ Proposed fix: pin the phase-2 precondition
   saveConfig(baseConfig({ managementUsageMaxReadBytes: 300, usageRollupEnabled: false }));
   writeRaw([firstOld, entry({ requestId: "old-2", timestamp: FIXED_NOW - 12 * DAY_MS })]);
+  // Truncation only happens above the cap; fail loudly if the fixture shrinks.
+  expect(readFileSync(usageLogPath()).byteLength).toBeGreaterThan(300);
   const legacyServer = startServer(0);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/usage-rollup-merge.test.ts` around lines 295 - 301, Add an explicit raw
usage-log size precondition in the phase-2 setup before starting the legacy
server or asserting the response, matching phase 1’s
readFileSync(usageLogPath()).byteLength check and verifying it exceeds the
configured 300-byte managementUsageMaxReadBytes cap. Keep the existing
truncation assertions unchanged.

const entries = [
entry({ requestId: "key-old", timestamp: FIXED_NOW - 15 * DAY_MS, admissionKind: "configured", apiKeyId: "key-a" }),
entry({ requestId: "key-recent", timestamp: FIXED_NOW - 2 * DAY_MS, admissionKind: "configured", apiKeyId: "key-a" }),
entry({ requestId: "loopback", timestamp: FIXED_NOW - DAY_MS, admissionKind: "loopback", apiKeyId: undefined }),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

apiKeyId: undefined may not typecheck, and it is redundant.

Line 313 assigns undefined explicitly to apiKeyId, which src/usage/log.ts lines 46-89 declare as an optional property (apiKeyId?: string). If this repo enables exactOptionalPropertyTypes, assigning undefined to an optional property is a type error; the property must be omitted instead.

There is direct evidence in this PR that the flag is likely on. The new fixture helper tests/helpers/usage-rollup-fixtures.ts goes out of its way to avoid emitting undefined for the same optional fields, using conditional spreads at lines 69-70 and 74-76:

...(index % 3 === 0 ? { admissionKind: "configured" as const, apiKeyId: `key-${index % 5}` } : {}),

That pattern only exists to satisfy exactOptionalPropertyTypes. The same concern applies to tests/usage-rollup.test.ts line 123 (usage: undefined, totalTokens: undefined).

Omit the key. The runtime behavior is identical, because rollupApiKeyUsage gates on !entry.apiKeyId (see src/server/management/api-key-usage.ts line 92).

♻️ Proposed fix
-      entry({ requestId: "loopback", timestamp: FIXED_NOW - DAY_MS, admissionKind: "loopback", apiKeyId: undefined }),
+      entry({ requestId: "loopback", timestamp: FIXED_NOW - DAY_MS, admissionKind: "loopback" }),

Run this to confirm whether exactOptionalPropertyTypes is enabled and whether a typecheck gate exists:

#!/bin/bash
# Description: Check exactOptionalPropertyTypes and any typecheck CI gate.
set -euo pipefail

echo "=== tsconfig files ==="
fd -H -t f 'tsconfig.*json' --exec sh -c 'echo "--- $1 ---"; cat "$1"' _ {}

echo "=== exactOptionalPropertyTypes / strict settings ==="
rg -nP 'exactOptionalPropertyTypes|"strict"|noUncheckedIndexedAccess' -g 'tsconfig*.json'

echo "=== typecheck scripts ==="
rg -nP '"(typecheck|check|lint|test)"\s*:' package.json
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/usage-rollup-merge.test.ts` at line 313, Remove the explicit apiKeyId:
undefined from the entry fixture in the usage-rollup merge test, leaving the
optional property omitted while preserving the loopback entry’s other fields and
runtime behavior. Also update the analogous usage fixture in the usage-rollup
test to omit optional fields currently assigned undefined, including usage and
totalTokens.

Comment on lines +185 to +194
const rows = parsedRollupLines();
const commit = rows.at(-1)!;
commit.payloadDigest = "0".repeat(64);
writeFileSync(rollupPath(), `${rows.map(row => JSON.stringify(row)).join("\n")}\n`);
expect(readRollupSnapshot()).toMatchObject({ cutlineOffset: 0, days: [] });
commit.payloadDigest = (commits()[0]?.payloadDigest ?? "0".repeat(64));
commit.rowCount = Number(commit.rowCount) + 1;
writeFileSync(rollupPath(), `${rows.map(row => JSON.stringify(row)).join("\n")}\n`);
expect(readRollupSnapshot()).toMatchObject({ cutlineOffset: 0, days: [] });
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Test 2c never exercises the rowCount check in isolation, because line 190 restores the tampered digest instead of the original one.

Trace the state of the file across the test:

  1. Line 185-186: rows is read from disk, and commit is a reference into that array. Its payloadDigest is the valid digest.
  2. Line 187: commit.payloadDigest = "0".repeat(64) overwrites the valid digest in memory. The original value is now lost — nothing saved it.
  3. Line 188: the tampered rows are written to disk.
  4. Line 189: the snapshot is rejected. Correct, and this is a real digest-mismatch assertion.
  5. Line 190: commit.payloadDigest = (commits()[0]?.payloadDigest ?? "0".repeat(64)). commits() re-reads usage-rollup.jsonl from disk — which now holds the tampered commit from step 3. So commits()[0].payloadDigest is "0".repeat(64). The row exists, so the ?? fallback never fires. The assignment is a no-op that re-applies the zeroed digest.
  6. Line 191: rowCount is incremented.
  7. Line 193: the snapshot is rejected — but now two validators are violated at once: the digest and the row count.

The failure mode: if the rowCount validation were deleted from src/usage/rollup.ts, this test would still pass at line 193, because the zeroed payloadDigest alone forces rejection. The test title promises "row-count or payload-digest mismatch", and the PR objectives list rowCount as one of the three commit-row authorities alongside attemptId and payloadDigest. This is the only test that touches rowCount, so that validator currently has no effective coverage.

Capture the original digest before tampering, and restore it from the captured value.

🐛 Proposed fix: isolate each validator
     const rows = parsedRollupLines();
     const commit = rows.at(-1)!;
+    const validDigest = commit.payloadDigest;
+    const validRowCount = commit.rowCount;
     commit.payloadDigest = "0".repeat(64);
     writeFileSync(rollupPath(), `${rows.map(row => JSON.stringify(row)).join("\n")}\n`);
     expect(readRollupSnapshot()).toMatchObject({ cutlineOffset: 0, days: [] });
-    commit.payloadDigest = (commits()[0]?.payloadDigest ?? "0".repeat(64));
-    commit.rowCount = Number(commit.rowCount) + 1;
+    // Restore the valid digest so only the row count is wrong on this pass.
+    commit.payloadDigest = validDigest;
+    commit.rowCount = Number(validRowCount) + 1;
     writeFileSync(rollupPath(), `${rows.map(row => JSON.stringify(row)).join("\n")}\n`);
     expect(readRollupSnapshot()).toMatchObject({ cutlineOffset: 0, days: [] });
+    // Sanity check: with both fields restored the segment is accepted again,
+    // which proves the two rejections above came from the tampering.
+    commit.rowCount = validRowCount;
+    writeFileSync(rollupPath(), `${rows.map(row => JSON.stringify(row)).join("\n")}\n`);
+    expect(readRollupSnapshot()?.days.length).toBeGreaterThan(0);

The added final block is the part that makes the two negative assertions meaningful. Without a positive control, a validator that rejects unconditionally would also satisfy lines 189 and 193.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const rows = parsedRollupLines();
const commit = rows.at(-1)!;
commit.payloadDigest = "0".repeat(64);
writeFileSync(rollupPath(), `${rows.map(row => JSON.stringify(row)).join("\n")}\n`);
expect(readRollupSnapshot()).toMatchObject({ cutlineOffset: 0, days: [] });
commit.payloadDigest = (commits()[0]?.payloadDigest ?? "0".repeat(64));
commit.rowCount = Number(commit.rowCount) + 1;
writeFileSync(rollupPath(), `${rows.map(row => JSON.stringify(row)).join("\n")}\n`);
expect(readRollupSnapshot()).toMatchObject({ cutlineOffset: 0, days: [] });
});
const rows = parsedRollupLines();
const commit = rows.at(-1)!;
const validDigest = commit.payloadDigest;
const validRowCount = commit.rowCount;
commit.payloadDigest = "0".repeat(64);
writeFileSync(rollupPath(), `${rows.map(row => JSON.stringify(row)).join("\n")}\n`);
expect(readRollupSnapshot()).toMatchObject({ cutlineOffset: 0, days: [] });
// Restore the valid digest so only the row count is wrong on this pass.
commit.payloadDigest = validDigest;
commit.rowCount = Number(validRowCount) + 1;
writeFileSync(rollupPath(), `${rows.map(row => JSON.stringify(row)).join("\n")}\n`);
expect(readRollupSnapshot()).toMatchObject({ cutlineOffset: 0, days: [] });
// Sanity check: with both fields restored the segment is accepted again,
// which proves the two rejections above came from the tampering.
commit.rowCount = validRowCount;
writeFileSync(rollupPath(), `${rows.map(row => JSON.stringify(row)).join("\n")}\n`);
expect(readRollupSnapshot()?.days.length).toBeGreaterThan(0);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/usage-rollup.test.ts` around lines 185 - 194, The test fails to isolate
the rowCount validation because the digest restoration at line 190 inadvertently
reapplies the tampered digest instead of restoring the original. Save the
original digest value from the commit object immediately after line 186 before
any tampering occurs, then use that saved value to restore commit.payloadDigest
at line 190 instead of re-reading from commits() which returns the tampered
version from disk. This ensures that only the rowCount modification is active
when line 193 validates, properly isolating the rowCount check from the digest
check.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant