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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.3.1</string>
<string>0.3.2</string>
<key>CFBundleVersion</key>
<string>11</string>
<string>12</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.developer-tools</string>
<key>LSMinimumSystemVersion</key>
Expand Down
7 changes: 4 additions & 3 deletions Sources/CodexLimits/GrokBillingClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,10 @@ struct GrokAllowanceSnapshot: Codable, Equatable, Sendable {
let source: String
if config.keys.contains("creditUsagePercent")
|| config.keys.contains("currentPeriod") {
// A period-only reply still carries current facts; absence is not a zero reading.
used = number(config["creditUsagePercent"])
guard !config.keys.contains("creditUsagePercent") || used != nil else {
// GrokCreditsConfig uses an implicit-presence proto3 float: omitted means zero.
// Validate the current period below before accepting that default.
used = config.keys.contains("creditUsagePercent") ? number(config["creditUsagePercent"]) : 0
guard used != nil else {
throw GrokBillingError.missingAllowance
}
guard let current = config["currentPeriod"] as? [String: Any] else {
Expand Down
2 changes: 1 addition & 1 deletion Sources/CodexLimits/GrokIntegration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ final class GrokIntegrationStore: ObservableObject {
guard self.isCurrent(expected) else { return }
self.snapshot = cached
self.hasStoredData = cached != nil
if let cached {
if let cached, cached.remainingPercent != nil {
self.nextRefreshAt = min(cached.observedAt.addingTimeInterval(600), cached.resetsAt)
}
} catch {
Expand Down
26 changes: 14 additions & 12 deletions Tests/CodexLimitsTests/GrokBillingClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,20 @@ final class GrokBillingClientTests: XCTestCase {
private let observedAt = Date(timeIntervalSince1970: 1_700_000_000)
private let current = #"{"config":{"creditUsagePercent":25.5,"currentPeriod":{"type":"USAGE_PERIOD_TYPE_WEEKLY","start":"2030-01-01T00:00:00+00:00","end":"2030-01-08T00:00:00.123456+00:00"},"prepaidBalance":{},"onDemandUsed":{"val":125},"onDemandCap":{"val":2500},"isUnifiedBillingUser":true},"subscription_tier":"Super\u0007Grok","private":"do-not-retain"}"#

func testPeriodWithoutPercentageRetainsFactsWithoutInventingUsage() throws {
let partial = try decode(current.replacingOccurrences(of: #""creditUsagePercent":25.5,"#, with: ""))
XCTAssertNil(partial.reportedUsedPercent)
XCTAssertNil(partial.remainingPercent)
XCTAssertNil(partial.historyObservation)
XCTAssertTrue(partial.isValid)
XCTAssertEqual(partial.period, .weekly)
XCTAssertEqual(partial.resetsAt.timeIntervalSince1970, 1_894_060_800.123456, accuracy: 0.001)
XCTAssertEqual(partial.subscriptionTier, "SuperGrok")
XCTAssertEqual(partial.prepaidBalanceUSD, 0)
XCTAssertEqual(try JSONDecoder().decode(GrokAllowanceSnapshot.self, from: JSONEncoder().encode(partial)), partial)
XCTAssertEqual(try decode(current.replacingOccurrences(of: "25.5", with: "0")).remainingPercent, 100)
func testOmittedCurrentPercentageUsesProtoZeroDefault() throws {
let omitted = current.replacingOccurrences(of: #""creditUsagePercent":25.5,"#, with: "")
for reply in [omitted, omitted.replacingOccurrences(of: "TYPE_WEEKLY", with: "TYPE_MONTHLY")] {
let zero = try decode(reply)
XCTAssertEqual(zero.reportedUsedPercent, 0)
XCTAssertEqual(zero.remainingPercent, 100)
XCTAssertEqual(zero.historyObservation?.remainingPercent, 100)
XCTAssertTrue(zero.isValid)
XCTAssertEqual(zero.resetsAt.timeIntervalSince1970, 1_894_060_800.123456, accuracy: 0.001)
XCTAssertEqual(zero.subscriptionTier, "SuperGrok")
XCTAssertEqual(zero.prepaidBalanceUSD, 0)
XCTAssertEqual(try JSONDecoder().decode(GrokAllowanceSnapshot.self, from: JSONEncoder().encode(zero)), zero)
XCTAssertEqual(try decode(reply.replacingOccurrences(of: #""config":{"#, with: #""config":{"creditUsagePercent":0,"#)), zero)
}
}

func testCurrentAndLegacyAllowancesKeepOnlyValidatedFacts() throws {
Expand Down
58 changes: 38 additions & 20 deletions Tests/CodexLimitsTests/GrokIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import ClaudeIntegrationCore

@MainActor
final class GrokIntegrationTests: XCTestCase {
func testPeriodOnlyRefreshAfterResetSurvivesRelaunchAndRecoversWithoutInventedHistory() async throws {
func testOmittedZeroAfterResetSurvivesRelaunchAndTracksSubsequentUsage() async throws {
let clock = GrokTestClock()
let first = fixture(at: clock.now())
let source = GrokFetchProbe(snapshot: first)
Expand All @@ -20,42 +20,60 @@ final class GrokIntegrationTests: XCTestCase {
"start":"\(formatter.string(from: clock.now()))","end":"\(formatter.string(from: reset))"},\
"prepaidBalance":{}},"subscription_tier":"Example plan"}
"""
let partial = try GrokAllowanceSnapshot.decode(Data(reply.utf8), observedAt: clock.now(), sourceVersion: "1.2.3")
await source.setSnapshot(partial)
let zero = try GrokAllowanceSnapshot.decode(Data(reply.utf8), observedAt: clock.now(), sourceVersion: "1.2.3")
await source.setSnapshot(zero)
await store.refresh()
XCTAssertNil(store.error)
XCTAssertEqual(store.currentSnapshot?.resetsAt, reset)
XCTAssertEqual(store.snapshot?.observedAt, clock.now())
XCTAssertEqual(store.snapshot?.subscriptionTier, "Example plan")
XCTAssertEqual(store.statusText, "Usage percentage unavailable")
XCTAssertEqual(store.menuBarText, "—")
XCTAssertEqual(store.history, originalHistory)
XCTAssertEqual(store.menuBarText, "100%")
let zeroHistory = originalHistory + [try XCTUnwrap(zero.historyObservation)]
XCTAssertEqual(store.history, zeroHistory)
let chart = try XCTUnwrap(IntegrationAllowanceChart(
metric: zero.historyMetric, observations: store.history, current: zero.historyObservation,
now: clock.now(), isStale: store.isStale, safetyBuffer: 3
))
XCTAssertTrue(chart.chart.currentProjection.isEmpty, "An estimate cannot cross the reset")
await store.setVisible(true, includeHistory: false)
XCTAssertNil(store.overview, "An old period must not remain the current thumbnail")
XCTAssertEqual(store.overview?.latest?.remaining, 100)
await store.setEnabled(false)

let restored = makeStore(clock: clock, source: source, cache: cache)
await restored.setVisible(true)
XCTAssertEqual(restored.snapshot, partial)
XCTAssertEqual(restored.history, originalHistory)
XCTAssertNil(restored.currentSnapshot?.remainingPercent)
XCTAssertEqual(restored.snapshot, zero)
XCTAssertEqual(restored.history, zeroHistory)
XCTAssertEqual(restored.currentSnapshot?.remainingPercent, 100)

clock.advance(600)
let zeroReply = reply.replacingOccurrences(of: #""config":{"#, with: #""config":{"creditUsagePercent":0,"#)
let zero = try GrokAllowanceSnapshot.decode(Data(zeroReply.utf8), observedAt: clock.now(), sourceVersion: "1.2.3")
await source.setSnapshot(zero)
let usedReply = reply.replacingOccurrences(of: #""config":{"#, with: #""config":{"creditUsagePercent":1,"#)
let used = try GrokAllowanceSnapshot.decode(Data(usedReply.utf8), observedAt: clock.now(), sourceVersion: "1.2.3")
await source.setSnapshot(used)
await restored.refresh()
XCTAssertNil(restored.error)
XCTAssertEqual(restored.menuBarText, "100%")
XCTAssertEqual(restored.history, originalHistory + [try XCTUnwrap(zero.historyObservation)])
let chart = try XCTUnwrap(IntegrationAllowanceChart(
metric: zero.historyMetric, observations: restored.history, current: zero.historyObservation,
now: clock.now(), isStale: restored.isStale, safetyBuffer: 3
))
XCTAssertTrue(chart.chart.currentProjection.isEmpty, "An estimate cannot cross the reset or missing reading")
XCTAssertEqual(restored.menuBarText, "99%")
XCTAssertEqual(restored.history, zeroHistory + [try XCTUnwrap(used.historyObservation)])
await restored.deleteData()
}

func testFreshCacheWithoutPercentageFetchesBeforeShowingCurrentUsage() async throws {
let clock = GrokTestClock()
let current = fixture(at: clock.now())
let source = GrokFetchProbe(snapshot: current)
let cache = temporaryDirectory().appendingPathComponent("snapshot.json")
var saved = try XCTUnwrap(JSONSerialization.jsonObject(with: JSONEncoder().encode(current)) as? [String: Any])
saved.removeValue(forKey: "reportedUsedPercent")
try FileManager.default.createDirectory(at: cache.deletingLastPathComponent(), withIntermediateDirectories: true)
try JSONSerialization.data(withJSONObject: saved).write(to: cache)
let store = makeStore(clock: clock, source: source, cache: cache)
await store.setVisible(true)
let calls = await source.calls
XCTAssertEqual(calls, 1, "A partial 0.3.1 cache must not delay a current measurement")
XCTAssertEqual(store.menuBarText, "75%")
XCTAssertEqual(store.history, [try XCTUnwrap(current.historyObservation)])
await store.deleteData()
}

func testOverviewReadsCurrentPeriodWithoutRetainingDetailAndFallsBackToLatest() async throws {
let clock = GrokTestClock()
let current = fixture(at: clock.now())
Expand Down
2 changes: 1 addition & 1 deletion docs/MEASUREMENT-CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ An Integration may publish Account Allowance only when the source identifies the
- A percentage must be finite and source-valid. Missing, null, malformed, or semantically unknown values are unavailable, never zero.
- Claude Code `used_percentage` is valid only inside `0...100`; convert it to `remaining = 100 - used`. Reject an out-of-range value.
- Grok `creditUsagePercent` is a used percentage. Preserve the finite original as `reportedUsedPercent`, apply the official `0...100` clamp, and convert it to remaining. Record `measurementSource` as `creditUsagePercent` or `legacyCredits`, with the CLI version when available. Use legacy `100 * used.val / monthlyLimit.val` only when both current fields are absent; a zero or missing legacy limit is unavailable. A present empty Cent object means zero, while a missing monetary field remains unavailable. The collector sends `_x.ai/billing` on the ACP wire; the internal handler name omits the underscore.
- A valid Grok `currentPeriod` without `creditUsagePercent` is a successful partial observation: retain its period, reset, observation time, and available account facts, but leave the percentage unavailable. It replaces the latest snapshot without adding an allowance-history point or forecast. Never substitute zero, a previous period’s percentage, or legacy credits for the omitted value. A present null or malformed percentage remains an invalid response. Historic charts identify their range as the last recorded window when no current allowance observation exists; `Last received` dates the latest accepted response rather than the latest refresh attempt.
- In the current Grok credits schema, `credit_usage_percent` is an implicit-presence proto3 float: an omitted `creditUsagePercent` with a validated `currentPeriod` means 0% used / 100% remaining. Preserve reset, observation time and available account facts, and record the numeric observation normally. This does not authorize zero for absent configuration, unknown periods, invalid dates or unusable legacy limits. A present null or malformed percentage remains invalid at the ACP boundary. Old 0.3.1 caches without a percentage are refreshed on demand, not rewritten into historical zeros. See [provider schema evidence](research/grok-zero-usage-2026-09-16.md). Historic charts identify their range as the last recorded window when no current allowance observation exists; `Last received` dates the latest accepted response rather than the latest refresh attempt.
- A direct remaining percentage is validated without changing its orientation.
- Every allowance window carries its provider window ID or period type, observed time, reset time, and duration when known.
- Weekly and monthly Grok periods remain distinct. Retain a supplied `currentPeriod.start` or legacy `billingPeriodStart` only after supported finite-date and start-before-reset validation. A missing start remains absent; never invent a monthly start from a fixed duration. An unknown period type is unavailable rather than relabeled as weekly.
Expand Down
4 changes: 3 additions & 1 deletion docs/adr/0015-ship-claude-as-experimental.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ The eligible-account check is recorded as `Waived`, not `Passed`, and no longer

The product owner also explicitly waived the expanded all-enabled idle comparison and eight-hour mixed lifecycle soak for release 0.3.0 on 2026-09-10. Neither test was performed. The validator accepts only an exception for the exact release version. CI, deterministic lifecycle tests, native QA, universal packaging, and update-signature validation remain required.

On 2026-09-16, the product owner explicitly renewed both performance exceptions for release 0.3.1. The current PRD rows are `Waived for 0.3.1`; neither check was run or passed, and this renewal does not cover future versions. The owner also authorized an administrative merge of PR #74 without a second/code-owner approval after its tests and CodeQL pass.
On 2026-09-16, the product owner explicitly renewed both performance exceptions for release 0.3.1. The PRD rows were recorded as `Waived for 0.3.1`; neither check was run or passed, and this renewal does not cover future versions. The owner also authorized an administrative merge of PR #74 without a second/code-owner approval after its tests and CodeQL pass.

On 2026-09-16, after reviewing the corrected zero-usage behavior and PR #75, the product owner explicitly renewed both performance exceptions for 0.3.2 and authorized its publication after green tests and CodeQL. The owner also authorized an administrative merge of PR #75 without a second/code-owner approval. The current PRD rows are `Waived for 0.3.2`; neither performance check was run or passed, and this approval does not cover later versions.

## Consequence

Expand Down
8 changes: 4 additions & 4 deletions docs/prd/multi-integration-workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Status: Accepted for v1 implementation

Release 0.3.1 is accepted with the explicit owner exceptions recorded in [ADR-0015](../adr/0015-ship-claude-as-experimental.md). Unperformed checks remain marked as waived rather than passed.
Release 0.3.2 is accepted with the explicit owner exceptions recorded in [ADR-0015](../adr/0015-ship-claude-as-experimental.md). Unperformed checks remain marked as waived rather than passed.

## Destination

Expand Down Expand Up @@ -329,16 +329,16 @@ Disabling Codex pauses its account timer, local collection, history exchange, an

## v1 release boundary and gates

Claude Code is opt-in `Experimental` and Grok is opt-in `Beta`; release 0.3.1 is accepted with the recorded owner exceptions below. OpenCode remains deferred. The maturity label appears in Settings and the Integration detail header, not beside every value. Grok also displays CLI-version provenance because its custom ACP billing extension is not a versioned public billing API.
Claude Code is opt-in `Experimental` and Grok is opt-in `Beta`; release 0.3.2 is accepted with the recorded owner exceptions below. OpenCode remains deferred. The maturity label appears in Settings and the Integration detail header, not beside every value. Grok also displays CLI-version provenance because its custom ACP billing extension is not a versioned public billing API.

| Release gate | Current evidence | Status |
|---|---|---|
| Grok and OpenCode source decision | Grok 1.0.25 accepts correctly prefixed ACP billing with CLI-owned authentication; OpenCode remains excluded by RSS/write budgets | Passed |
| Bounded Codex history and serialized demand-driven collection | 3,650-day fixture, 32-candidate reconciliation, idle process release, deterministic lifecycle tests | Passed |
| Claude relay, setup, privacy, deletion, executable selection, and boundary behavior | Packaged helper checks and deterministic Release tests | Passed |
| All-enabled idle comparison | Expanded comparison not run; product owner renewed the 0.3.0 exception on 2026-09-16 specifically for release 0.3.1; ADR-0015 | Waived for 0.3.1 |
| All-enabled idle comparison | Expanded comparison not run; product owner renewed the prior exceptions on 2026-09-16 specifically for release 0.3.2; ADR-0015 | Waived for 0.3.2 |
| Eligible Claude account observation | Product owner waived this check on 2026-09-10 for the experimental Claude Code release; ADR-0015 | Waived |
| Eight-hour mixed lifecycle soak | Not run; product owner renewed the 0.3.0 exception on 2026-09-16 specifically for release 0.3.1; ADR-0015 | Waived for 0.3.1 |
| Eight-hour mixed lifecycle soak | Not run; product owner renewed the prior exceptions on 2026-09-16 specifically for release 0.3.2; ADR-0015 | Waived for 0.3.2 |

`Scripts/validate-release.sh` requires this document to be exactly `Accepted for v1 implementation`. Each required performance row must be `Passed` or explicitly `Waived for VERSION` for the version being released. A version-specific exception does not satisfy later releases; deterministic tests and short comparisons do not count as evidence for an unperformed gate.

Expand Down
2 changes: 1 addition & 1 deletion docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Losing the EdDSA private key prevents ad-hoc-signed installations from trusting
## Release flow

1. Ask Codex to prepare a release and provide the stable version number.
2. Codex confirms that the multi-integration PRD is `Accepted for v1 implementation` and that `All-enabled idle comparison` and `Eight-hour mixed lifecycle soak` are recorded as `Passed` or explicitly `Waived for VERSION` for that release, runs the Release tests and QA, then updates `CFBundleShortVersionString` and increments `CFBundleVersion`. The eligible Claude account observation is waived for the experimental release; [ADR-0015](adr/0015-ship-claude-as-experimental.md) records separate owner approvals of both performance exceptions for 0.3.0 and 0.3.1. No unperformed check is recorded as passed. The validator checks the status and both required rows; it does not block development while they remain pending. The historical Codex-plus-Claude idle comparison does not cover the restored Grok scope or the new provider-local history readers. QA includes history persistence, old-cache seeding, reset and forecast boundaries, and separate Claude/Grok data deletion.
2. Codex confirms that the multi-integration PRD is `Accepted for v1 implementation` and that `All-enabled idle comparison` and `Eight-hour mixed lifecycle soak` are recorded as `Passed` or explicitly `Waived for VERSION` for that release, runs the Release tests and QA, then updates `CFBundleShortVersionString` and increments `CFBundleVersion`. The eligible Claude account observation is waived for the experimental release; [ADR-0015](adr/0015-ship-claude-as-experimental.md) records separate owner approvals of both performance exceptions for 0.3.0, 0.3.1 and 0.3.2. No unperformed check is recorded as passed. The validator checks the status and both required rows; it does not block development while they remain pending. The historical Codex-plus-Claude idle comparison does not cover the restored Grok scope or the new provider-local history readers. QA includes history persistence, old-cache seeding, reset and forecast boundaries, and separate Claude/Grok data deletion.
3. Run `Scripts/validate-release.sh VERSION` and the `Release` workflow with `dry_run` enabled.
4. Inspect the universal app archive, both universal app/helper executables, signed `appcast.xml`, generated notes, and workflow result.
5. Run the workflow with `dry_run` disabled. It creates a Draft Release only.
Expand Down
Loading