Skip to content

feat: add closed month recaps and goal completions - #484

Merged
thomasluizon merged 13 commits into
mainfrom
feature/ticket-341-wrapped-closed-month
Aug 23, 2026
Merged

thomasluizon merged 13 commits into
mainfrom
feature/ticket-341-wrapped-closed-month

Conversation

@thomasluizon

@thomasluizon thomasluizon commented Aug 23, 2026 •

Copy link
Copy Markdown
Owner

Closes #341

Summary

  • Adds paired optional year and month query parameters to the existing recap route for closed calendar months.
  • Preserves every rolling period request, the five period tokens, the free access path, and the /r/{code} referral route.
  • Returns resolved closed month bounds and a real goal completion count derived from nondeleted goals in the user's timezone.
  • Computes the first closed month response from the recap only historical occurrence path, then persists the complete response by user, DateFrom, and DateTo.
  • Serves every later request from the stored row, including goal completions and the share link, so cadence edits and process restarts cannot change a previously opened month.
  • Uses a transaction advisory lock and a unique database index to keep concurrent first requests on one immutable snapshot.
  • Serializes first snapshot creation and full account reset on the same account scoped transaction lock.
  • Makes reset wait for an in progress snapshot and delete it, while a recap that waits for reset reads only post reset data before storing.
  • Clears the target user's stored recap snapshots inside the existing account reset transaction while preserving other users' rows.
  • Restores live streak and legacy metric scheduling to their current DueDate alignment instead of the recap only historical anchor.
  • Keeps RetrospectivePeriodRange and the retrospective payload unchanged, and documents that weekly consistency is ordered Monday through Sunday.

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

  • dotnet build Orbit.slnx completed with 0 errors.
  • dotnet test Orbit.slnx passed all 5,838 tests.
  • The focused recap and reset suite passed 19 tests.
  • The coordinated race tests force both lock orders and prove no pre reset snapshot survives.
  • The cadence change streak regression, cadence change closed recap snapshot, and account reset isolation cases pass.
  • The additive PersistClosedMonthRecaps migration uses EF table and index operations and keys rows by UserId, DateFrom, and DateTo.

Assumptions

  • The closed request uses year and month beside period=month; I rejected new parameter names because these match the route fields and keep shared links readable.
  • The account's first localized calendar month is addressable even when signup occurred after its first day; I rejected requiring a full post signup month because the ticket says the first account month onward.
  • A habit remains applicable on its localized deletion date; I rejected excluding the whole date because the habit can have applied and been completed before deletion that day.
  • The habit model has no pause field or pause history; I used its persisted ScheduledStartDate, CreatedAtUtc, EndDate, and DeletedAtUtc lifecycle evidence and rejected inventing a pause history schema in this ticket.
  • The ticket's transcribed Orbit Wrapped canvas requirements are the design authority in this run; DesignSync was unavailable and the work order prohibited browser access, so I rejected adding behavior not stated in the ticket.

@thomasluizon

Copy link
Copy Markdown
Owner Author

Intended approach

I 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.

pullfrog[bot]
pullfrog Bot previously approved these changes Aug 23, 2026

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ No 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.

Pullfrog  | View workflow run | Using GPT Sol | 𝕏

@pullfrog pullfrog 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.

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 year and month query 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.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using GPT Sol | 𝕏

Comment thread src/Orbit.Application/Gamification/Queries/GetRecapQuery.cs
@thomasluizon

Copy link
Copy Markdown
Owner Author

@pullfrog review

@pullfrog pullfrog 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.

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 year and month parameters, 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.

Pullfrog  | Fix it ➔ | View workflow run | Using GPT Sol | 𝕏

@thomasluizon

Copy link
Copy Markdown
Owner Author

Round 1 approach

I 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.

@pullfrog pullfrog 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.

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 ScheduledStartDate based 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.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using GPT Sol | 𝕏

Comment thread src/Orbit.Application/Gamification/Queries/GetRecapQuery.cs Outdated
Comment thread src/Orbit.Application/Habits/Services/HabitScheduleService.cs
…apped-closed-month

# Conflicts:
#	architecture.html
#	architecture.json
#	tests/Orbit.Infrastructure.Tests/Controllers/GamificationControllerTests.cs
@thomasluizon

Copy link
Copy Markdown
Owner Author

Round 2 approach

I 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.

@thomasluizon

Copy link
Copy Markdown
Owner Author

@pullfrog review

pullfrog[bot]
pullfrog Bot previously approved these changes Aug 23, 2026

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ No 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 to ScheduledStartDate.

  • Added regression coverage: Added exact closed-recap and historical-schedule tests for unlogged completions and postponed one-time tasks.

Pullfrog  | View workflow run | Using GPT Sol | 𝕏

@pullfrog pullfrog 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.

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 year and month parameters, 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.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using GPT Sol | 𝕏

Comment thread src/Orbit.Application/Habits/Services/HabitScheduleService.cs
@thomasluizon

Copy link
Copy Markdown
Owner Author

Round 3 approach

I 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.

@pullfrog pullfrog 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.

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 DueDate anchor.
  • 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.

Pullfrog  | Fix it ➔ | View workflow run | Using GPT Sol | 𝕏

@thomasluizon

Copy link
Copy Markdown
Owner Author

Round 4 approach

I 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.

@pullfrog pullfrog 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.

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 ClosedMonthRecaps deletion 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.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using GPT Sol | 𝕏

Comment thread src/Orbit.Infrastructure/Persistence/AccountResetRepository.cs
…apped-closed-month

# Conflicts:
#	architecture.html
#	architecture.json
@thomasluizon

Copy link
Copy Markdown
Owner Author

@pullfrog review

@pullfrog pullfrog 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.

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 year and month parameters, 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 DueDate alignment 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.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using GPT Sol | 𝕏

Comment thread src/Orbit.Infrastructure/Persistence/AccountResetRepository.cs
@thomasluizon

Copy link
Copy Markdown
Owner Author

Round 3 approach

I 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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ No 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.

Pullfrog  | View workflow run | Using GPT Sol | 𝕏

@sonarqubecloud

Copy link
Copy Markdown

@thomasluizon
thomasluizon merged commit e5af3a7 into main Aug 23, 2026
23 checks passed
@thomasluizon
thomasluizon deleted the feature/ticket-341-wrapped-closed-month branch August 23, 2026 05:43
thomasluizon added a commit that referenced this pull request Sep 9, 2026
…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
thomasluizon added a commit that referenced this pull request Sep 9, 2026
* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant