Repository navigation
feat: add closed month recaps and goal completions - #484
Conversation
Intended approachI will extend the existing gamification recap route with optional year and month query parameters. The controller will resolve either the unchanged rolling range or a new closed calendar month range after obtaining the user's local today and account creation instant. The closed resolver will live beside RetrospectivePeriodRange without modifying it. I will add the resolved optional date bounds and goal completion count to RecapResponse, preserve the period token, and keep the existing /r/{code} referral link. GetRecapQueryHandler will count nondeleted goals by CompletedAtUtc within timezone converted UTC bounds. I will use a closed month memory cache keyed by user, year, and month with no expiration; the database remains the durable source, so any process restart can recompute every historical month. The implementation will land in the gamification controller and application query, validator, a new closed month resolver, the goal read abstraction and implementation if the existing repository cannot express the count, dependency registration only if a new service requires it, and focused unit tests in the matching test projects. This keeps one route and one response contract. I rejected extending RetrospectivePeriodRange because its contract is rolling, adding another endpoint because GetRecapQuery already accepts date bounds, and changing RetrospectiveMetrics because that would alter the retrospective contract. |
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes I reviewed the initial scaffold commit at 9380b05; it contains no code or behavioral changes.
- Scaffold only: The single commit changes zero files, so no implementation, contract, or tests are present yet.
GPT Sol | 𝕏
There was a problem hiding this comment.
Important
Closed-month recaps can cache incorrect historical habit metrics and should be corrected before merge.
Reviewed changes I reviewed the closed-month recap implementation added since the prior Pullfrog review.
-
Resolved closed months: Added paired
yearandmonthquery parameters, local-calendar closure checks, account-age validation, and named failures while preserving rolling period requests. -
Expanded recap data: Added timezone-bounded goal completion counts, optional resolved date bounds, and calendar parameters in referral links.
-
Cached closed responses: Added per-user, per-month in-memory snapshots without expiration while retaining database recomputation after process restart.
-
Extended coverage and documentation: Added resolver, validator, handler, controller, timezone-boundary, free-access, and cache tests, plus the Monday-through-Sunday consistency contract.
GPT Sol | 𝕏
|
@pullfrog review |
There was a problem hiding this comment.
Important
The latest architecture-map refresh introduces no new defect, but the prior blocking finding remains unresolved: closed-month habit metrics still use each habit's live DueDate and can permanently cache incorrect historical values.
Reviewed changes I reviewed the complete branch at 5485ff5, including the generated architecture refresh added after the prior Pullfrog review.
- Closed-month routing: The existing recap endpoint accepts paired
yearandmonthparameters, resolves completed local calendar months, and preserves rolling-period behavior. - Historical recap assembly: The query adds account-age checks, timezone-bounded goal completions, resolved date bounds, and calendar-aware referral links.
- Closed response caching: Each user-month response is stored in memory without expiration and returned on later requests.
- Tests and architecture map: Resolver, validator, handler, controller, timezone-boundary, plan-access, and cache coverage are accompanied by regenerated architecture artifacts.
- Cross-repo contract: The API additions are append-only; the current shared consumer still exposes only the rolling-period request and ignores the new response fields.
GPT Sol | 𝕏
Round 1 approachI will add one historical occurrence path beside the live forward scheduler. It will align recurrence to ScheduledStartDate, bound activity by the localized creation, end, and deletion dates, and retain the live GetScheduledDates behavior unchanged. Closed month recap metrics will select that path and load soft deleted habits with their active logs so later deletion does not erase a month that already happened. I will replace the calendar token cache key with the resolved DateFrom and DateTo values. The cache remains an in process permanent snapshot, and a deployment restart deliberately recomputes from the corrected historical path so values produced by the earlier DueDate based implementation do not survive. The changes will land in HabitScheduleService, RetrospectiveMetricsCalculator, the recap handler, the generic read port needed to include filtered history, and focused application and infrastructure tests. I rejected changing GetScheduledDates because live callers require its moving DueDate cursor, and I rejected persisting recap snapshots because this ticket introduces no snapshot schema or close job. |
There was a problem hiding this comment.
Important
The historical fix prevents live DueDate advancement from corrupting recurring metrics, but it reintroduces deleted logs and misdates postponed one-time tasks.
Reviewed changes I reviewed ab46bdb, which replaces the previous closed-month scheduling path and broadens the historical schedule helper's callers.
- Anchored recurring history: Switched closed recaps to
ScheduledStartDatebased recurrence bounded by localized habit lifecycle dates. - Restored deleted habit history: Added an unfiltered repository read so habits deleted after the requested month remain available to historical metrics.
- Unified historical scheduling: Reused the new historical path in streak scheduling and legacy habit-metric inference.
- Strengthened regressions: Added focused tests for advanced due dates, localized deletion bounds, ignored root filters, and byte-identical closed metrics.
GPT Sol | 𝕏
…apped-closed-month # Conflicts: # architecture.html # architecture.json # tests/Orbit.Infrastructure.Tests/Controllers/GamificationControllerTests.cs
Round 2 approachI will make active log filtering explicit in both the recap include and RetrospectiveMetricsCalculator so IgnoreQueryFilters can recover a deleted habit without recovering its deleted completion logs. I will add coverage proving a removed completion does not contribute to closed month totals or active days. I will keep ScheduledStartDate as the recurrence anchor only for recurring habits. A one time task has one current scheduled occurrence, so its historical date will remain DueDate after postponement. I will add a focused scheduler regression for that case and leave recurring schedule behavior unchanged. |
|
@pullfrog review |
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes I reviewed the five-file a84f330 delta that addresses the two remaining historical recap regressions.
-
Excluded removed activity: Added explicit soft-delete predicates to unfiltered log loading and every retrospective completion aggregation.
-
Preserved postponed tasks: Historical one-time occurrences now use the current
DueDate, while recurring schedules remain anchored toScheduledStartDate. -
Added regression coverage: Added exact closed-recap and historical-schedule tests for unlogged completions and postponed one-time tasks.
GPT Sol | 𝕏
There was a problem hiding this comment.
Important
The new historical scheduler regresses streak calculations after a started habit changes cadence.
Reviewed changes I reviewed the complete branch at a84f330, including the closed-month fixes added since the prior Pullfrog review.
- Closed-month routing: The existing recap endpoint now accepts paired
yearandmonthparameters, resolves completed local calendar months, and preserves rolling-period behavior. - Historical recap data: Closed recaps load deleted habit roots, exclude deleted logs, derive recurring occurrences from historical scheduling, count timezone-bounded goal completions, and return resolved date bounds.
- Caching and links: Closed responses are cached by user and resolved date window, and referral links preserve the requested calendar parameters.
- Shared scheduling: The historical occurrence helper now serves recap metrics, streak scheduling, and legacy habit-metric inference, with focused regression coverage.
- Contracts and generated artifacts: The response additions remain append-only for installed clients, while OpenAPI and architecture artifacts reflect the API changes.
GPT Sol | 𝕏
Round 3 approachI will restore live streak scheduling to the existing forward occurrence path and reserve the historical scheduler for first time closed month recap computation. Its documentation will state the cadence history limitation and why stored snapshots are required. I will add an additive closed recap snapshot entity and migration keyed by user, DateFrom, and DateTo. The recap handler will read that row first, compute the response only when no row exists, then persist the complete closed month payload including goal completions. Rolling requests will continue to bypass snapshot storage, and the closed month resolver will continue to reject an unfinished month before the handler can store anything. The persistence change will land in the domain model, application persistence port and recap handler, infrastructure mapping and migration, and the existing focused test projects. I will add regressions for live streaks after cadence changes and byte identical closed month responses after cadence changes. I rejected memory caching because cadence has no history, so recomputation after a process restart cannot reproduce the original response. |
There was a problem hiding this comment.
Important
Account resets retain durable closed-month snapshots, so reset users can still receive progress data that should have been cleared.
Reviewed changes I reviewed the persistence and scheduling changes added in bf4e03a since the prior Pullfrog review.
- Persisted closed recaps: Replaced process-local caching with a database snapshot keyed by user and resolved month, protected by a transaction advisory lock and unique index.
- Restored live schedule alignment: Separated recap-only historical recurrence from live streak and legacy metric scheduling so current cadence uses the current
DueDateanchor. - Added persistence coverage: Added the snapshot entity and guards, EF configuration and migration, storage tests, immutable response regressions, and a cadence-change streak regression.
⚠️ Account resets retain persisted recaps
ResetAccountCommand delegates to AccountResetRepository.DeleteAllUserDataAsync, but that cleanup does not delete the new ClosedMonthRecaps rows. Because reset preserves the User row, requesting a previously opened month after reset returns the old immutable snapshot instead of the cleared account history.
Technical details
# Account resets retain closed month snapshots
## Affected sites
- `src/Orbit.Infrastructure/Persistence/AccountResetRepository.cs:8-152` does not delete rows from `ClosedMonthRecaps`.
- `src/Orbit.Infrastructure/Persistence/OrbitDbContext.cs:73,137-144` adds the user-owned snapshot table with cascade behavior that only runs when the user row is deleted.
- `src/Orbit.Application/Profile/Commands/ResetAccountCommand.cs:29-35` clears owned data while deliberately preserving the user row.
- `src/Orbit.Application/Gamification/Queries/GetRecapQuery.cs:76-82` returns a stored snapshot before reading the reset account's current data.
## Required outcome
- Remove the target user's closed month snapshots inside the account-reset transaction while preserving other users' rows.
- Add reset repository coverage proving a target snapshot is deleted and an unrelated user's snapshot remains.GPT Sol | 𝕏
Round 4 approachI will extend AccountResetRepository's existing user-owned data cleanup to delete ClosedMonthRecaps for the target user inside the same reset transaction. The user row remains intact, so the table's account deletion cascade cannot cover this reset path. I will add repository coverage with one snapshot for the reset user and one for another user, then assert that reset removes only the target snapshot. This lands only in the reset repository and its persistence tests because the snapshot entity, route behavior, and migration are already correct. |
There was a problem hiding this comment.
Important
Account reset can still retain pre-reset recap data when a first-time recap is persisted concurrently.
Reviewed changes I reviewed the account-reset cleanup added since the prior Pullfrog review at bf4e03a.
- Deleted stored recaps: Added target-user
ClosedMonthRecapsdeletion inside the existing reset transaction. - Added isolation coverage: Seeded snapshots for the reset user and another user, then asserted that only the target user's rows are removed.
GPT Sol | 𝕏
…apped-closed-month # Conflicts: # architecture.html # architecture.json
|
@pullfrog review |
There was a problem hiding this comment.
Important
Account reset can still retain and serve a pre-reset closed-month snapshot when first-time recap creation commits after the reset.
Reviewed changes I reviewed the complete branch at 1fe77fd, including the latest architecture refresh and merge from main.
- Closed-month routing: The recap endpoint accepts paired
yearandmonthparameters, resolves completed local calendar months, and preserves rolling-period requests. - Durable immutable recaps: First-time responses are stored by user and resolved date window behind a transaction advisory lock and unique index.
- Historical metrics: Closed recaps recover deleted habit roots, exclude deleted logs, bound occurrences by localized lifecycle dates, and count goal completions using timezone-converted UTC bounds.
- Live scheduling isolation: Live streak and legacy metric paths retain current
DueDatealignment while recap-only scheduling uses the historical anchor. - Lifecycle cleanup: Account reset deletes existing recap rows for the target user, but it is not synchronized with concurrent snapshot creation.
- Contract review: The response additions and query parameters are additive; the current mobile shared schema ignores the new fields and has no paired open consumer PR.
GPT Sol | 𝕏
Round 3 approachI will give closed month recap creation and account reset one shared advisory lock key derived only from the account id. Recap creation will keep its first storage probe as a fast path, then acquire the account lock and perform every source data read used for a new snapshot after that lock. Reset will acquire the same lock inside its existing transaction before any deletion. The implementation will land in a shared application lock key helper, GetRecapQuery, ResetAccountCommand, and focused application tests. I will first add a coordinated race test that pauses recap creation at the lock, lets a full reset win, then releases recap creation and proves no pre reset figures are persisted. I will also cover the reverse order and assert the lock precedes reset deletion. I rejected a conditional insert because the existing transaction advisory lock already supplies the required serialization primitive. I also rejected retaining the month in the lock key because reset must serialize with every first time recap creation for the account. |
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes I reviewed the two commits added since the prior Pullfrog review at 1fe77fd.
- Serialized recap creation and reset: Replaced month-specific recap locks with a shared per-user transaction advisory lock acquired before recap source reads and reset deletion.
- Covered both race orderings: Added coordinated tests proving reset removes an in-progress recap snapshot and a recap waiting for reset reads only post-reset data.
- Refreshed architecture artifacts: Updated the generated architecture maps to include the shared lock and race tests.
GPT Sol | 𝕏
|
…ter (#484) Pullfrog was right that `HabitCeilingLock` did not deliver the boundary this pull request claimed. Counted live on `main`: `DeleteHabitCommand`, `SkipHabitCommand`, `BulkLogHabitsCommand` and `BulkSkipHabitsCommand` had ZERO references to it, and `LogHabitCommand` had one, on the unlog branch only. So a soft delete could commit between the repair's eligibility read and its spend, leaving a banked freeze paid for a schedule that no longer existed. Every persisted mutation of habit schedule, completion logs or freeze currency now commits inside the lock: - `DeleteHabitCommand`, `BulkDeleteHabitsCommand` - `SkipHabitCommand`, `BulkSkipHabitsCommand` - `LogHabitCommand`'s log path, `BulkLogHabitsCommand` - `RepairStreakCommand`, the other currency spender, which had the same read-then-write shape as the gap repair and none of its protection - the chat tools' single commit point in `ProcessUserChatCommand.Persistence`, which is where every Astra habit mutation actually lands - the offline-sync habit delete replay in `SyncController` A writer only holds the lock across the save. The lock is transaction scoped, so what it serializes is the interval from acquisition to commit; derived work that follows, such as streak recalculation, gamification, challenges and referrals, stays outside it and keeps writing the xmin-tokened user row. Two unique-violation catches moved OUT of their transactions. A failed statement aborts a PostgreSQL transaction block, so the reply query each one runs could not have executed inside it. `HabitCeilingLock` becomes public for the one caller outside `Orbit.Application`. The regression runs `DeleteHabitCommandHandler` concurrently with the repair against a serializing unit of work and asserts the interleaving, not a call count. Removing the lock from the delete alone turns it red in 10 seconds. The serializing fake moved out of `HabitCeilingLockTests` so both suites share one. Full suite green: 6,156 tests across analyzers, domain, application and infrastructure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Yk8nzfE1spBP4VbMo4o53
* Prepare atomic streak gap repair (#442) * Add atomic repair for contiguous streak gaps (#442) * Preserve freeze award history across streak gap repair (#442) * Protect legacy streak award cursor (#442) * Refresh architecture map (#442) * fix: make gap repair follow the schedule, not the calendar Streak continuity runs over SCHEDULED occurrences, never calendar days: a weekly habit's streak survives the six unscheduled days between two occurrences. Three places asserted calendar adjacency instead, so a weekly or every-N-day user could never repair a gap at all. The day before the gap is not a scheduled occurrence, so it carries neither a completion nor a freeze, and the check always refused. The rule moves to where the schedule is actually known: - UserStreakService walks the ordered scheduled occurrences. The selection must be an unbroken run of them, so a caller still cannot omit a missed occurrence inside the gap, and the PRECEDING SCHEDULED occurrence must be completed or frozen. - StreakFreeze.CreateGap drops calendar-consecutiveness. That entity cannot see a schedule, so it was rejecting valid gaps before one was ever loaded. It keeps what a date list alone can prove: real dates, no duplicates, and a last date of local yesterday. - RepairStreakGapCommandValidator likewise checks duplicates only. Red first, on the half that carries the defect. Reverting just the preceding occurrence lookup to `gapStart.AddDays(-1)` fails the new weekly case; restored, the suite is 23/23. The second new case keeps the widening honest: when the prior week's occurrence was ALSO missed, one selected date is not a whole gap and repair stays refused. Schedule-awareness widens which gaps are contiguous; it never waives the precondition. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uhu7gcHwQ2n4V1fx6yZ71B * fix: carry the scheduled predecessor, and reach it at the yearly boundary Three defects from the same root as the previous commit: continuity is defined over scheduled occurrences, and three more places still assumed calendar days. 1. THE AWARD CURSOR. The handler passed `gap[0].UsedOnDate.AddDays(-1)` to RestoreStreakAfterGapRepair while the service had already computed the real scheduled predecessor. On a sparse cadence those differ, the cursor match in User.RestoreStreakAfterGapRepair failed, and the derived-cursor fallback marked a newly crossed seven-day milestone as awarded WITHOUT granting its freeze, permanently suppressing the reward. The predecessor now travels on UserStreakState instead of being re-derived a layer up, the same one-source shape the readiness fallback needed. 2. THE YEARLY BOUNDARY. A yearly occurrence on yesterday has its predecessor 366 days before the gap, but history loaded only MaxStreakLookbackDays = 365. The predecessor fell outside the window, so the gap was index 0 and every yearly and every-365-day gap was refused. The window widens by MaxScheduleSpanDays, the longest span between two consecutive occurrences of a supported cadence, so it stays bounded. The GAP itself is still confined to the ordinary streak window; only its predecessor may sit in the widened margin. 3. Three tests pinned the calendar rule the previous commit removed. Each is updated rather than deleted, because the behaviour they guard survives: a calendar-sparse selection is still refused and still spends no freeze. It is now refused by the schedule-aware evaluation rather than the domain factory, which is exactly the relocation that makes a real weekly or yearly gap repairable. Full orbit-api suite: 6150 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uhu7gcHwQ2n4V1fx6yZ71B * fix: union both schedule windows instead of widening one past its cap The previous commit widened the schedule request to reach a yearly predecessor and broke ordinary habits doing it. GetStreakScheduledDates caps a generated range at MaxRangeDays, so one 731-day request truncated the RECENT end: for a habit created before the widened start, the gap ending yesterday fell outside the generated dates and a valid repair reported unavailable. My yearly fixture hid it because that habit starts AT its predecessor, so effectiveFrom moved forward and the truncation never fired. Two bounded queries now, unioned. Each is inside the cap, so nothing is lost at either end. The yearly case then failed for a different reason, found by probing the helper rather than by reasoning about it: both queries returned exactly the right dates, but ComputeStreakAsOf still walked from the recent bound, so the predecessor that MAKES the repair worth anything sat outside the walk. The repaired streak scored no gain over the current one and the repair was refused as useless. The walk now starts at the earlier bound, which is where the schedule now starts. With the union in hand the predecessor is simply scheduled[gapStartIndex - 1] again, so the second lookup branch is gone. Regression added, per the required outcome: a daily habit created 800 days ago, older than the widened start, still repairs a gap ending yesterday. Full orbit-api suite: 6151 passed, 0 failed. One unrelated accountability test flaked once mid-session and passes 3/3 in isolation and in the two runs either side of it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uhu7gcHwQ2n4V1fx6yZ71B * fix: decide eligibility over the window the streak engine actually uses Reverts the widened history window. Widening only the repair path made eligibility and representation disagree, and the review caught the consequence: - EvaluateGapRepairAsync accepts the repair on widened history and spends a freeze; - the handler builds its response through GetStreakInfoQuery, which calls CalculateStateAsync on the ORDINARY 365-day window; - that window sees yesterday's freeze without the preceding completion, so it computes a zero streak; - a later RecalculateAsync persists that zero, after the freeze was spent. The user pays a freeze and gets nothing. Refusing a repair is strictly better than accepting one that cannot survive the next recalculation. So: ONE window, the engine's own, and the invariant written down. Eligibility is decided over exactly the history that can represent the result. Weekly and every-N-day gaps, which is what this ticket asked for, work entirely inside it. Yearly gaps stay refused, exactly as they were before this PR, so nothing regressed. The test now pins WHY rather than asserting they work, and the fix belongs to the engine's window: filed as thomasluizon/orbit-tickets#483 with the two traps recorded, including that GetStreakScheduledDates caps a per-habit range at MaxRangeDays so any future widening must query in bounded slices. MaxScheduleSpanDays goes with the approach that needed it. Full orbit-api suite: 6151 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uhu7gcHwQ2n4V1fx6yZ71B * fix: state the CreateGap rationale as XML-doc, not narration The Roslyn ORBIT0001 gate caught my own violation: a five-line `//` block narrating why calendar-consecutiveness is absent. Only XML-doc or a WHY note linking an upstream URL is allowed. The rationale is worth keeping, so it becomes a summary plus remarks on the method rather than being deleted. Verified the way CI builds it: `dotnet build --no-incremental` is clean with zero ORBIT diagnostics. The earlier local `dotnet test` runs never recompiled this file against the analyzer, which is why CI saw it first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uhu7gcHwQ2n4V1fx6yZ71B * fix: keep the first break's cursor, and bound the legacy fallback Two award-cursor defects, both of which silently cost the user a freeze. 1. A LATER DECREASE OVERWROTE THE SNAPSHOT. SetStreakState captured the pre-gap cursor on EVERY decrease, so log, unlog and relog of today after missing yesterday replaced (7, Sep 4), which identifies the repairable gap, with (0, Sep 6), which describes the break itself. Repair's exact match then failed and the derived cursor skipped a milestone that was never granted. Only the FIRST decrease captures now, and the pair is captured together so the date can never belong to a different run than the streak beside it. 2. THE LEGACY FALLBACK ROUNDED THE WRONG STREAK. With no matching snapshot, which is every pre-migration row, the cursor came from the FULL repaired streak, so milestones crossed by completions AFTER the gap were recorded as already awarded and their freezes were never issued. A row with six completions before the gap and one after reached 7, spent its banked freeze, and could never receive the one it had just earned. The fallback is now bounded by the streak going INTO the gap, which the service computes as of the scheduled predecessor and carries on UserStreakState. The cursor cannot advance past what was earned before the gap, so a newly crossed milestone stays awardable. `RestoreStreakAfterGapRepair` takes preGapStreak as a required parameter rather than an optional one: every caller should have to say what was earned before the gap, and there is exactly one production caller. Full orbit-api suite: 6153 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uhu7gcHwQ2n4V1fx6yZ71B * test: prove the cursor fixes where they can actually fail The review asked for these regressions to run through UserStreakService and the handler rather than mutating User directly. One of the two cannot honestly be written that way, and the first attempt at it was a test that could not fail. The legacy-fallback case IS now handler-level: six completions before the gap, one after, a pre-migration row with no snapshot and a banked freeze, driven through RepairStreakGapCommandHandler. It proves the milestone crossed after the gap stays awardable. The capture-once case stays on the entity, and the test says why. Driving it through RecalculateAsync needs an increase followed by a decrease, and this fixture cannot produce one: measured, Unlog leaves the recalculated streak at 1 rather than returning it to 0, so the service never sees a second decrease. The service-level version therefore passed with the fix REVERTED and proved nothing, which is worse than no test because its name claimed coverage it did not have. Red first, both, against the same revert: ASecondDecrease_KeepsTheSnapshotFromTheFirstBreak FAIL LegacyRowCrossingAMilestoneAfterTheGap_StillEarnsItsFreeze FAIL restored: 28 passed BuildRepairHandler is extracted now that a third caller exists, and the existing milestone theory uses it instead of building the same eight substitutes inline. Full orbit-api suite: 6153 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uhu7gcHwQ2n4V1fx6yZ71B * fix: grant the milestone repair earns, and cap a stale saved cursor Two more cursor defects, both mine. 1. REPAIR MADE A MILESTONE ELIGIBLE AND NOTHING GRANTED IT. Restoring the cursor only clears the way; the response is built by GetStreakInfoQuery, which calls CalculateAsync rather than RecalculateAsync, so an award earned by the repaired run sat pending and was lost the next time the streak reset. The repair now grants it in the same save that spends the bank. My earlier test hid this by calling AwardStreakFreezeIfEligible by hand, which production never does; it now asserts the freeze is actually banked. 2. THE SAVED CURSOR OUTRANKED THE RUN IT DESCRIBED. The snapshot deliberately survives later recalculations, so it can describe a LONGER run than the one being repaired: unlog an older pre-gap completion and a cursor of 14 still matches on date while the run is now 7, suppressing awards the shorter run has re-earned. The saved value is capped by what the current pre-gap run supports. And a hazard in my own design, caught by two existing tests going red: PreGapStreak defaulted to 0, so any caller that did not supply it read as "the run into the gap was zero", collapsing the cursor and re-granting milestones the user already had. It is nullable now. Unknown means DO NOT BOUND, which is the behaviour that stood before the bound existed, rather than silently meaning zero. Full orbit-api suite: 6154 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uhu7gcHwQ2n4V1fx6yZ71B * fix: move the cursor rationale into XML doc for ORBIT0001 CI rejected an eight-line narration block in User.cs. The rationale is worth keeping, so it moves onto the preGapStreak parameter documentation, where it also reaches anyone reading the signature rather than only someone inside the method. A correction worth recording, because I got this wrong twice tonight: the ORBIT Roslyn analyzers DO NOT run in the local `dotnet build`. A clean local build, even `--no-incremental`, proves nothing about ORBIT0001. Only CI catches it, and only for the Orbit.Domain project, which is why identical `//` comments in Orbit.Application and Orbit.Infrastructure pass. Check Domain comments by eye or push and read CI; do not trust a green local build for this rule. Full orbit-api suite: 6154 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uhu7gcHwQ2n4V1fx6yZ71B * fix(gamification): use XML block comments so ORBIT0001 accepts them CI failed the Build on ten ORBIT0001 diagnostics in `Orbit.Application`, not in `Orbit.Domain`: seven in `RepairStreakGapCommand` and three in `RepairStreakGapCommandValidator`. The analyzer covers every project except migrations and generated files, so the project is not what made the difference. A clean local build proves nothing here, and now there is a measurement for why. `dotnet build` on this tree emits `warning CS9057`: the analyzer assembly references compiler version 5.9.0.0 while the local SDK runs 5.6.0.0, so Roslyn DISABLES the analyzer and the build reports 0 errors on the exact file CI then rejects. Locally the ORBIT rules do not run at all, in any project. `NoCommentsAnalyzer.IsAllowed` accepts a multi-line comment that starts with `/**`, so the three narration blocks keep every word and change only their form. Nothing was deleted: each one records a defect this pull request fixed, and a sweep of the whole diff for added `//` lines outside `///` now returns nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LguAmjM5WjvsPonsJh4tJ * fix(streaks): finish the ORBIT0001 sweep across every analyzed project The previous commit fixed `Orbit.Application` and CI then failed on nineteen more diagnostics in `Orbit.Infrastructure`. The build stops at the first failing project, so fixing one only reveals the next, and my first sweep was truncated by a `head -30` and reported clean when it was not. This is the whole sweep, run to completion. `Directory.Build.props` attaches the analyzer to EVERY project except the analyzers themselves, tests included, so the pass covers `src/` and `tests/` together: five blocks in `UserStreakService` and four in `StreakGapRepairTests`. Grepping the complete repository for an own-line `//` outside `///`, a URL note, a migration and generated output now returns nothing. Every word is kept. `NoCommentsAnalyzer.IsAllowed` accepts a multi-line comment that starts `/**`, so only the form changed, and the converter refuses a block containing `*/` rather than silently ending the comment early. Local evidence and its limit: `dotnet build` reports 0 errors on this tree and proves nothing about ORBIT0001. Every project emits `warning CS9057`, because the analyzer assembly references compiler 5.9.0.0 while the local SDK runs 5.6.0.0, so Roslyn disables it. The rules do not run locally at all, in any project. Only CI can confirm this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LguAmjM5WjvsPonsJh4tJ * fix(streaks): repair inside the habit lock, so eligibility cannot go stale Pullfrog raised this three times and was right every time. I resolved the first two claiming it was pre-existing and architectural. That premise is false, and two commands disprove it: git log --oneline origin/main -S "EvaluateGapRepairAsync" -- src/ no output git cat-file -e origin/main:.../RepairStreakGapCommand.cs absent Both the evaluation and the handler are introduced by THIS pull request, so the read-then-write hazard is created here and cannot be deferred to a ticket. The defect: EvaluateGapRepairAsync decides from the habits, the completion logs, the freezes and the schedule derived from them, and the save then spends the freeze bank. Those were two separate snapshots. `User.xmin` cannot hold them together, because a schedule-only habit edit commits WITHOUT touching the user row, so the optimistic token this command already retries on never fires. A cadence or due-date change landing between the two left a freeze spent on a date the committed schedule no longer accepts, with the bank charged all the same. The boundary already existed and this handler simply never joined it. HabitCeilingLock takes a transaction-scoped per-user advisory lock, and UpdateHabitCommand, LogHabitCommand, CreateHabitCommand, MoveHabitParentCommand and RestoreHabitCommand all hold it. The repair now runs inside HabitCeilingLock.ExecuteAsync, so evaluation and the spend happen in one transaction under the same key every writer whose output it reads must take. The user is loaded inside the lock too: a load taken before it would carry a pre-lock snapshot of the bank into a post-lock decision. The regression asserts ORDER rather than presence, because presence proves nothing about the window: lock, then evaluate, then save, with the key read from HabitCeilingLock.ForUser itself so a rename cannot leave it passing against a lock nobody else holds. Wiring the transaction exposed six failing end-to-end cases in StreakGapRepairTests: a substituted IUnitOfWork returns null from the transaction wrapper unless the operation is invoked. Both sites now run it as the real one does, which is also what makes those cases exercise the real boundary. Ticket 484 stays open, scope corrected, for what this does not cover: writing the invariant down, sweeping other handlers that spend a bank from an unprotected Habit or HabitLog read, and deciding whether those tables should carry a concurrency token so the hazard is detectable rather than only preventable. Test evidence: dotnet test, 6155 passed, 0 failed Orbit.Analyzers.Tests 32 passed Orbit.Domain.Tests 582 passed Orbit.Application.Tests 3313 passed Orbit.Infrastructure.Tests 2228 passed Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012LguAmjM5WjvsPonsJh4tJ * fix(gamification): extend the habit consistency boundary to every writer (#484) Pullfrog was right that `HabitCeilingLock` did not deliver the boundary this pull request claimed. Counted live on `main`: `DeleteHabitCommand`, `SkipHabitCommand`, `BulkLogHabitsCommand` and `BulkSkipHabitsCommand` had ZERO references to it, and `LogHabitCommand` had one, on the unlog branch only. So a soft delete could commit between the repair's eligibility read and its spend, leaving a banked freeze paid for a schedule that no longer existed. Every persisted mutation of habit schedule, completion logs or freeze currency now commits inside the lock: - `DeleteHabitCommand`, `BulkDeleteHabitsCommand` - `SkipHabitCommand`, `BulkSkipHabitsCommand` - `LogHabitCommand`'s log path, `BulkLogHabitsCommand` - `RepairStreakCommand`, the other currency spender, which had the same read-then-write shape as the gap repair and none of its protection - the chat tools' single commit point in `ProcessUserChatCommand.Persistence`, which is where every Astra habit mutation actually lands - the offline-sync habit delete replay in `SyncController` A writer only holds the lock across the save. The lock is transaction scoped, so what it serializes is the interval from acquisition to commit; derived work that follows, such as streak recalculation, gamification, challenges and referrals, stays outside it and keeps writing the xmin-tokened user row. Two unique-violation catches moved OUT of their transactions. A failed statement aborts a PostgreSQL transaction block, so the reply query each one runs could not have executed inside it. `HabitCeilingLock` becomes public for the one caller outside `Orbit.Application`. The regression runs `DeleteHabitCommandHandler` concurrently with the repair against a serializing unit of work and asserts the interleaving, not a call count. Removing the lock from the delete alone turns it red in 10 seconds. The serializing fake moved out of `HabitCeilingLockTests` so both suites share one. Full suite green: 6,156 tests across analyzers, domain, application and infrastructure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Yk8nzfE1spBP4VbMo4o53 * fix(gamification): use doc comments so ORBIT0001 accepts the boundary notes The Roslyn analyzer bans `//` comments and it did not run locally: the build printed CS9057 and skipped `Orbit.Analyzers.dll`, because that assembly references a newer compiler than the installed SDK. So the ban only fired in CI. Every note added by the previous commit is now a `/** */` block, matching the file it sits in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Yk8nzfE1spBP4VbMo4o53 * chore(arch): refresh the generated map for the new boundary test Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Yk8nzfE1spBP4VbMo4o53 * fix(tests): finish the ORBIT0001 sweep in the concurrency regression The previous sweep covered src/ and missed the new test file, so the same analyzer that does not run locally failed the build again on three comments. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Yk8nzfE1spBP4VbMo4o53 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>




Closes #341
Summary
Persistence choice
Persistence was chosen over in process caching because habit cadence has no history. Recomputing after a cadence edit or process restart cannot reproduce the schedule that existed when the month was first opened. The stored row is therefore the durable source for that account and resolved calendar window.
Validation
Assumptions