ORB-187 Make AI message quota daily - #492
Conversation
Intended approach
I am keeping the period neutral DTOs and the rewarded ad fields and endpoints unchanged. I rejected a rolling 24 hour timestamp because the ticket requires local midnight, and I rejected broader ad removal or client schema edits because those belong to separate tickets. |
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes at 27bc5b0, this PR contains only branch initialization and has no file diff.
- Initialization commit:
chore: start ORB-187leaves the tree identical to the base; the quota implementation described in the PR body has not landed yet.
GPT Sol | 𝕏
There was a problem hiding this comment.
Important
The daily quota has two rollover paths that can either deny a valid allowance or remove the cap entirely.
Reviewed changes since the prior Pullfrog review at 27bc5b0; this run covered the complete implementation added at 3dbc329.
- Daily quota enforcement: Replaced monthly limits with five free and 50 Pro messages per user-local day while retaining atomic optimistic-concurrency consumption.
- Local-date persistence: Replaced the rolling reset timestamp with a local-date bucket, reset existing counters through a reversible migration, and changed the database config rows to daily limits.
- Client and agent surfaces: Kept period-neutral profile and subscription fields, updated Astra and MCP wording, and removed ad reward bonuses from usable limits.
- Operational reporting: Added free and Pro cap hits, mean usage, and p95 usage to the existing daily AI summary log.
- Unit coverage: Added forward-rollover, timezone-boundary, plan-limit, concurrency, migration-shape, and quota-metric tests.
GPT Sol | 𝕏
|
Addressed both rollover findings in ebbb175. Profile, subscription, and MCP reads now report zero when the stored quota date differs from the current local date. Consumption starts a bounded bucket for every supported date transition, including backward movement after timezone travel. Regression coverage exercises a capped prior day read and repeated consumption after moving to an earlier local date. Full build passes with 0 errors and all 5,954 tests pass. |
There was a problem hiding this comment.
Important
The rollover fixes close the prior defects, but the new mismatch reset still allows the daily quota to be refilled repeatedly through timezone changes.
Reviewed changes since Pullfrog's prior coverage at 31385c5; this run reviewed the behavioral fixes in ebbb175.
- Normalized displayed usage: Projected mismatched stored quota dates as zero across profile, subscription, and MCP surfaces.
- Changed date transition handling: Reset the quota bucket on any local date mismatch and added backward transition tests.
GPT Sol | 𝕏
|
Addressed the repeated timezone transition finding in 7cb6d15. The persisted quota date now advances monotonically, and enforcement carries the active count when the resolved local date moves backward. A regression exhausts date A, exhausts date B, returns to date A, and confirms no third allowance is created. Full build passes with 0 errors and all 5,956 tests pass. |
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes since the prior Pullfrog review at ebbb175; this run covered the timezone quota hardening in 7cb6d15.
- Hardened quota enforcement: Counted the newest stored bucket when the resolved local date moves backward, preventing a date mismatch from bypassing the limit.
- Made bucket transitions monotonic: Opened a fresh allowance only for a strictly newer local date while preserving the newest bucket across backward timezone travel.
- Added regression coverage: Verified that backward and alternating local-date sequences cannot mint a third allowance while normal next-day rollover still refills the quota.
GPT Sol | 𝕏
…ily-ai-quota # Conflicts: # architecture.html # architecture.json # src/Orbit.Application/Chat/Content/FeatureExplanations/paygate.md # src/Orbit.Application/Common/AppConstants.cs
|
Merged current main into this branch and resolved four conflicts, all from #490 and #491 landing first:
Verified locally on the merged tree before pushing: build 0 errors, full suite 5,955 passed / 0 failed. |
|
@pullfrog review |
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes since the prior Pullfrog review at 7cb6d15; this run covered the merged main integration in 2e2649f.
- Integrated the habit ceiling: Preserved the 1000 live top-level habit abuse guard in the conflict-resolved constants, paygate explanation, and test setup without changing the daily AI quota defaults.
- Integrated free goal creation: Removed goals from the Pro unlock list and retained Pro gating only for AI goal reviews, including the corresponding test setup changes.
- Refreshed architecture artifacts: Combined the new base branch coverage counts and references with the daily quota architecture changes.
GPT Sol | 𝕏
|
There was a problem hiding this comment.
Important
The server's effective quota can disagree with every reported usage surface after backward timezone travel, and the new quota summary cannot produce complete local-day metrics from its UTC snapshot.
Reviewed changes at 2e2649f; this run reviewed the complete daily quota implementation after its merge from main.
- Daily quota enforcement: Replaced monthly limits with five free and 50 Pro messages per user-local day while preserving optimistic-concurrency consumption and smoke-account bypass.
- Timezone-aware state: Replaced the rolling reset timestamp with a local-date bucket, made transitions monotonic against repeated timezone changes, and projected lazy resets into profile, subscription, and MCP responses.
- Persistence and configuration: Added a reversible migration that resets existing counters and swaps monthly configuration rows for daily limits.
- Client and operational surfaces: Kept period-neutral profile fields, removed ad rewards from effective limits, updated Astra wording, and added cap-hit, mean, and p95 metrics with unit coverage.
GPT Sol | 𝕏
| } | ||
|
|
||
| public int GetAiMessagesUsedToday(DateOnly userToday) => | ||
| AiMessagesLocalDate == userToday ? AiMessagesUsedToday : 0; |
There was a problem hiding this comment.
When the resolved date moves backward, GetAiMessagesUsedForQuota preserves the future bucket for enforcement, but this method reports zero through profile, subscription, and MCP. Both shipped chat composers then advertise available quota and submit a request that the server rejects until the local date catches up.
Technical details
# Keep reported usage aligned with effective enforcement
## Affected sites
- `src/Orbit.Domain/Entities/User.cs:356-360`: display usage requires exact date equality while enforcement counts a stored future bucket.
- `src/Orbit.Application/Profile/Queries/GetProfileQuery.cs:121`: exposes zero even when `TryConsumeAiMessage` will reject at the preserved limit.
- `src/Orbit.Application/Subscriptions/Queries/GetSubscriptionStatusQuery.cs:30`: exposes the same inconsistent count.
- `src/Orbit.Api/Mcp/Tools/SubscriptionTools.cs:48`: labels the inconsistent value as daily usage.
- `orbit-ui-mobile/apps/mobile/hooks/use-chat-composer.ts:153-155`: derives the local free-plan block from the reported count.
- `orbit-ui-mobile/apps/web/hooks/use-chat-composer.ts:177-179`: derives the same local block from the reported count.
## Required outcome
- Report the same effective usage that quota enforcement applies when the stored bucket is in the future, while still reporting zero for an expired previous-day bucket after normal midnight rollover.
- Cover a profile or subscription read after a backward local-date transition with the preserved bucket already at its limit.
## Suggested approach
- Reuse the effective quota projection for response surfaces instead of maintaining exact-match and monotonic projections with different behavior.|
|
||
| var activeUsers = await dbContext.Users | ||
| .AsNoTracking() | ||
| .Where(user => user.AiMessagesLocalDate == yesterday && user.AiMessagesUsedToday > 0) |
There was a problem hiding this comment.
yesterday is a UTC date and the job runs at 00:05 UTC, but this mutable bucket follows each user's local date. At that instant west-offset users are counted before their day ends, while east-offset users who sent after local midnight have already overwritten the prior bucket, so cap hits, mean, and p95 are partial or omitted.
Technical details
# Finalize quota metrics against completed local days
## Affected sites
- `src/Orbit.Infrastructure/Services/AiUsageSummaryService.cs:60`: chooses the previous UTC date.
- `src/Orbit.Infrastructure/Services/AiUsageSummaryService.cs:73-76`: reads only the user's current mutable local-date bucket.
- `src/Orbit.Infrastructure/Services/AiUsageSummaryService.cs:143-150`: calculates cap hits, mean, and p95 from that incomplete population.
- `tests/Orbit.Infrastructure.Tests/Services/AiUsageSummaryServiceGenerationTests.cs:63-71`: seeds all users directly into the UTC `Yesterday` bucket, so no offset can expose the loss.
## Required outcome
- Build each reported local-day metric from complete usage for that finished user-local date, without losing a bucket when the next local day starts.
- Add boundary coverage showing a west-offset user's still-open day and an east-offset user's already-advanced bucket at 00:05 UTC.
## Suggested approach
- Persist or otherwise finalize usage per user-local date before aggregating it into the single daily summary; one mutable current bucket cannot represent both days after rollover.|
@pullfrog review |
|




Implements ORB-187.
Summary
IUserDateServiceinside the atomic quota consumer, with UTC fallback when no timezone is set.AdRewardBonusMessagesfrom both limit calculations while leaving the rewarded ad contract in place for ORB-194.Contract and rollout
The profile and subscription DTO fields remain period neutral. The only contract surface removed is the raw
FreeAiMessagesPerMonthconfig key. A read oforbit-ui-mobilemain confirmed the web hook castsres.json()toAppConfig, the mobile hook requestsapiClient<AppConfig>, and neither hook invokesappConfigSchema, so the missing key does not cause runtime validation. No client repository change or minimum version gate is required.Between this deployment and ORB-193, shipped Android clients can still show the rewarded ad button even though its claim no longer changes the usable limit. The ticket's production read from 2026-08-05 found all 78 accounts at a zero bonus, a maximum bonus of zero, and one account that had ever claimed a reward, so this is a known low exposure interval.
Both limits remain database config rows and take effect through the existing cache. Deploy on a weekday morning so the first local midnight rollovers can be observed.
Testing
dotnet build Orbit.slnx: 0 errorsdotnet test --no-build: 5,956 passednode tools/check-dashes.mjs --files <changed files>passed.Assumptions
PayGateService, so the local date is resolved once inside that service; I rejected duplicating date resolution across four handlers and weakening the atomic boundary.Down; I rejected editing the applied 2026-03-19 seed because the ticket explicitly forbids it and rollback must restore monthly names.Userrows alone.External interface evidence
The migration test reads EF operation properties
InsertDataOperation.ValuesandDeleteDataOperation.KeyValues. The installedMicrosoft.EntityFrameworkCore.Relational10.0.10 XML reference documents both properties. Re-derive afterdotnet restorefrom%USERPROFILE%/.nuget/packages/microsoft.entityframeworkcore.relational/10.0.10/lib/net10.0/Microsoft.EntityFrameworkCore.Relational.xml.