Skip to content

Fix calendar event timezone projection - #521

Merged
thomasluizon merged 18 commits into
mainfrom
fix/ticket-526-calendar-tz
Sep 24, 2026
Merged

thomasluizon merged 18 commits into
mainfrom
fix/ticket-526-calendar-tz

Conversation

@thomasluizon

@thomasluizon thomasluizon commented Sep 13, 2026 •

Copy link
Copy Markdown
Owner

Closes #526

Summary

Timed Google events now report the account-local start date, start time, and usable end time. All-day dates remain floating. Stored suggestions use the same projection. End times without a matching instant or a later displayed minute remain null.

Timed recurring events enter either feed only when the server can prove one account-local weekday pattern and one displayed start minute across a year. The proof uses the recurrence-defined start, not the actual start of a moved instance. It checks recurring rules with and without BYDAY. A BYDAY rule must keep its source weekday. No recurrence rule is rewritten.

The same gate runs before auto-sync stores a suggestion and when either feed reads one. Pending legacy suggestions missing recurrence evidence no longer suppress live events. Auto-sync refreshes those pending rows in place after fetching the source zone and original start. Dismissed and imported rows keep their state.

SourceTimeZone and RecurrenceStartUtc are server-only fields hidden by JsonIgnore. The stored suggestion JSON carries both. EndUtc remains the optional response field added earlier in this pull request. No field was removed, renamed, or retyped.

Cost and follow-up

A recurring event without enough evidence is withheld, even if its schedule might be safe. The list gives no explanation. A Tokyo account can lose a UTC weekly event from import. thomasluizon/orbit-tickets#569 tracks a richer import path.

External interface evidence

The installed google.apis.calendar.v3 1.75.0.4206 XML at ~/.nuget/packages/google.apis.calendar.v3/1.75.0.4206/lib/net6.0/Google.Apis.Calendar.v3.xml:3377 defines Event.OriginalStartTime as the recurrence-defined start of an instance, including one moved to a different time. The same installed file defines EventDateTime.DateTimeDateTimeOffset at line 3720 and the master's recurrence timezone at lines 3726 to 3732. The fetcher reads the original start from an expanded instance and the source zone from its master.

Test evidence

Before changes, the unchanged tests SeasonallyShiftingByDaySeries_IsWithheldByBothFeeds and Handle_RecurringByDayEventOnSameLocalDateWhoseSeriesShiftsLater_OmitsEvent passed with the defects present:

dotnet test tests/Orbit.Application.Tests --filter 'FullyQualifiedName~CalendarFeedAgreementTests.SeasonallyShiftingByDaySeries_IsWithheldByBothFeeds|FullyQualifiedName~GetCalendarEventsQueryHandlerTests.Handle_RecurringByDayEventOnSameLocalDateWhoseSeriesShiftsLater_OmitsEvent' > /tmp/ticket-526-r7-baseline.txt 2>&1
2 passed, 0 failed

The new regressions then failed before the production fixes. Both Lisbon afternoon rules were admitted with a drifting clock. The moved first instance was admitted from its actual start. The pending legacy row hid the live event. The fetcher returned a null recurrence start for the moved instance:

dotnet test tests/Orbit.Application.Tests --filter 'FullyQualifiedName~Handle_RecurringLisbonAfternoonWithSeasonalClockDrift_OmitsEvent|FullyQualifiedName~Handle_RescheduledFirstInstance_UsesOriginalRecurrenceClock|FullyQualifiedName~LegacyPendingSuggestion_DoesNotHideLiveEventAndRefreshesOnAutoSync' > /tmp/ticket-526-r7-app-red.txt 2>&1
4 failed, 0 passed

dotnet test tests/Orbit.Infrastructure.Tests --filter 'FullyQualifiedName~FetchAsync_RescheduledFirstInstance_KeepsItsOriginalRecurrenceStart' > /tmp/ticket-526-r7-infra-red.txt 2>&1
1 failed, 0 passed

After the fix, the Lisbon master is withheld in January and July, with projected clocks of 12:00 and 11:00. The existing Lisbon 03:30 BYDAY case still proves the year walk catches its weekday change.

dotnet build Orbit.slnx
0 errors

dotnet test tests/Orbit.Application.Tests --filter 'FullyQualifiedName~Calendar'
217 passed, 0 failed

dotnet test tests/Orbit.Infrastructure.Tests --filter 'FullyQualifiedName~Calendar'
42 passed, 0 failed

LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 dotnet test Orbit.slnx
6320 passed, 0 failed

The unqualified dotnet test Orbit.slnx run had 10 culture-sensitive Infrastructure failures on this macOS host. They passed with the English locale above. No calendar test failed in either full run.

Current review round at head 0bbf276c:

  • Before the fix, the unchanged LegacyPendingSuggestion_DoesNotHideLiveEventAndRefreshesOnAutoSync passed with one owner: dotnet test tests/Orbit.Application.Tests --filter 'FullyQualifiedName~CalendarFeedAgreementTests.LegacyPendingSuggestion_DoesNotHideLiveEventAndRefreshesOnAutoSync' --no-restore (1 passed).
  • With the handler still unchanged, dotnet test tests/Orbit.Application.Tests --filter 'FullyQualifiedName~CalendarFeedAgreementTests.LegacyPendingSuggestion_WithDuplicateFetchedId_IsNotRefreshed' --no-restore failed (1 failed). legacy.RawEventJson unexpectedly gained SourceTimeZone and RecurrenceStartUtc from the first event with the shared ID.
  • After the fix, dotnet test tests/Orbit.Application.Tests --filter 'FullyQualifiedName~CalendarFeedAgreementTests.LegacyPendingSuggestion_WithDuplicateFetchedId_IsNotRefreshed' --no-build passed (1 passed).
  • dotnet build Orbit.slnx --no-restore passed with 0 errors.
  • dotnet test tests/Orbit.Application.Tests --filter 'FullyQualifiedName~Calendar' --no-build passed (218 passed).
  • LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 dotnet test Orbit.slnx --no-build passed (6,331 passed, 0 failed).

The original fetch result now decides refresh eligibility before duplicate normalization. A pending legacy row with two fetched owners keeps its stored JSON and title.

Assumptions

  • An expanded recurring instance without OriginalStartTime is withheld. Using its actual start was rejected because a moved instance can hide the schedule's drift.
  • A legacy row refreshes only when one fetched event owns its ID. Choosing among duplicate IDs was rejected because it could attach another calendar's evidence.
  • Pending legacy rows refresh in place. Deleting and reinserting was rejected because the unique event ID and review state belong to the existing row.

@thomasluizon

Copy link
Copy Markdown
Owner Author

Approach: add EndUtc beside StartUtc in CalendarEventItem and resolve both instants in GoogleCalendarEventFetcher. Add one shared account-timezone projection for timed events, then call it from GetCalendarEventsQueryHandler and GetCalendarSyncSuggestionsQuery before filtering or returning values. Update RunCalendarAutoSyncCommand to consume StartUtc directly. Add focused unit coverage in the existing calendar query, suggestion, fetcher, and auto-sync test files. This keeps the wire contract unchanged and preserves Google all-day dates. I rejected client conversion because the response omits the instant and offset. I rejected converting all-day values because they are floating dates, not instants.

@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

This PR currently contains no implementation to review, so merging it would not fix the calendar timezone projection defect.

Reviewed changes Reviewed the complete diff at 31b8b5a and the current calendar timezone seams.

  • Branch state The sole commit is empty and the head tree matches main, with zero changed files.
  • Planned scope The PR discussion describes changes to event mapping, timezone projection, auto sync, and tests, but none are present at this head.

⚠️ The timezone fix is absent

The current head is tree-identical to main, so timed events retain the existing projection behavior and the stated defect remains. Push the planned implementation and focused unit coverage before this PR is merged.

Technical details
# The timezone fix is absent

## Affected sites
- PR head `31b8b5a2157bc52ff84f583b622b35836129df28` contains zero changed files.

## Required outcome
- Implement and test projection of timed calendar event start and end values into the account timezone.
- Preserve Google all-day values as floating dates.

Pullfrog  | Fix it ➔ | 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 projected end-time representation must be made safe for persisted suggestions and account-local midnight crossings before merge.

Reviewed changes Reviewed the calendar timezone implementation added since the prior empty-scaffold review.

  • Captured UTC boundaries: Added EndUtc extraction alongside the existing StartUtc value for timed and all-day Google events.
  • Projected account-local values: Converted timed start dates, start times, and end times before returning manual-import events.
  • Adjusted stored suggestions: Persisted the new end instant for future suggestions and projected deserialized events before local-date filtering and legacy-key matching.
  • Added regression coverage: Covered opposite-direction date crossings, UTC fallback, all-day preservation, UTC extraction, and projected suggestion filtering.

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

Comment thread src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs Outdated
@thomasluizon

Copy link
Copy Markdown
Owner Author

Review round approach: narrow CalendarEventItem.ProjectTo so a projected end is emitted only when EndUtc exists and both projected boundaries share the account-local date. Update the existing events and stored-suggestions query tests to prove legacy rows, midnight crossings, same-day intervals, and shared projection behavior. The implementation stays in GetCalendarEventsQuery.cs because both query handlers already use that projection. I rejected repairing legacy EndTime values because their source timezone is unavailable, and I rejected changing HabitInvariants because its clock-only guard is 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

A DST fall-back can still make a valid projected event fail bulk import.

Reviewed changes Reviewed the projected end-time hardening added since the prior Pullfrog review.

  • Omitted unavailable ends: Timed events without EndUtc now return no projected end time instead of retaining a source-timezone value.
  • Suppressed cross-day ranges: Projected ends now become null when their local date differs from the projected start date.
  • Strengthened coverage: Added exact regressions for legacy suggestion JSON and local-midnight crossings.

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

Comment thread src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs Outdated
@thomasluizon

Copy link
Copy Markdown
Owner Author

Review round two approach: strengthen CalendarEventItem.ProjectTo so EndTime survives only when its account local date matches the start and its wall clock is strictly later. Add one New York fall back regression in GetCalendarEventsQueryHandlerTests. This keeps the shared projection authoritative for both queries and avoids changing the response contract or HabitInvariants.

@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 end time guard still permits an import invalid pair when distinct instants truncate to the same displayed minute.

Reviewed changes Reviewed the DST fallback hardening added since the prior Pullfrog review.

  • Rejected descending wall times: Omitted projected end times when the same date wall clock does not advance.
  • Added a fold regression: Covered a New York repeated hour event and asserted that its descending end becomes null.

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

Comment thread src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs Outdated
@thomasluizon

Copy link
Copy Markdown
Owner Author

@pullfrog review

pullfrog[bot]
pullfrog Bot previously approved these changes Sep 16, 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 Reviewed the minute-precision correction added since the prior Pullfrog review at cc86c61.

  • Aligned ordering with response precision: Compared projected HH:mm values so an end is retained only when the exact strings sent to clients form a valid range.
  • Covered boundary cases: Added regressions proving that distinct instants within one displayed minute omit the equal end while a one-minute event keeps its later end.

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

Projected recurring events can import on source-timezone weekdays instead of account-local weekdays when the conversion changes the calendar date.

Reviewed changes Reviewed the complete calendar timezone fix, including API projection, persisted suggestion compatibility, Google boundary mapping, shipped client consumption, and regression coverage.

  • UTC boundary capture Added EndUtc beside the existing optional StartUtc and populated both boundaries from Google event values.

  • Account timezone projection Centralized timed-event projection while preserving floating all-day dates.

  • Suggestion filtering Projected persisted events before account-local date filtering and legacy habit matching.

  • Import-safe end times Omitted unavailable, cross-day, descending, and same-minute end values that the habit invariant cannot represent.

  • Regression coverage Added focused tests for opposite timezone shifts, legacy rows, all-day events, midnight boundaries, DST folds, and minute precision.

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

Comment thread src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs Outdated
@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 existing recurrence weekday projection finding remains unresolved at this unchanged head. No additional Critical or High issues were found.

Reviewed changes Reviewed the complete calendar timezone implementation at 91da4ad, including API projection, persisted suggestion compatibility, Google mappings, and shipped web and mobile import behavior.

  • UTC boundary capture Added EndUtc beside the optional StartUtc and populated both boundaries from Google event values.
  • Account timezone projection Centralized timed event projection while preserving floating all day dates.
  • Suggestion handling Projected persisted events before account local date filtering and legacy habit matching.
  • Import safe ranges Omitted unavailable, cross day, descending, and same minute end values that the habit invariant cannot represent.
  • Regression coverage Added focused tests for opposite timezone shifts, legacy rows, all day events, midnight boundaries, DST folds, and minute precision.

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

@thomasluizon

Copy link
Copy Markdown
Owner Author

Approach for the current review round:

I will update CalendarEventItem.ProjectTo to clear RecurrenceRule only when a timed event moves to a different account local date. The API receives the raw master RRULE, while both clients parse BYDAY into imported habit weekdays. Rewriting that raw rule from one occurrence is unsafe because it may contain multiple or ordinal weekdays, and timezone date shifts can change across DST boundaries.

The production change will stay in src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs. The regression will stay in tests/Orbit.Application.Tests/Queries/Calendar/GetCalendarEventsQueryHandlerTests.cs. Both event queries already call the same ProjectTo method, so this keeps live events and stored suggestions aligned.

@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 recurrence fix still silently turns date-shifting recurring Google events into one-time habits in shipped clients.

Reviewed changes Reviewed the complete calendar timezone projection implementation at ee8f3e4, including the latest recurrence handling and its client import impact.

  • UTC boundary capture: Added optional end instants alongside start instants and populated both from Google event boundaries.
  • Account timezone projection: Projected timed event dates and times while preserving floating all-day values.
  • Stored suggestions: Persisted UTC boundaries and projected deserialized suggestions before account-local filtering.
  • Import-safe ranges: Omitted unavailable, cross-day, descending, and same-minute end values.
  • Recurrence handling: Cleared source recurrence rules when projection changes the displayed date and added a focused regression.

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

Comment thread src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs Outdated

@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

Retaining recurrence from a single matching occurrence can still produce a wrong schedule after a daylight saving offset change.

Reviewed changes Reviewed the recurrence projection correction added since the prior Pullfrog review at 91da4ad.

  • Suppressed shifted recurrence: Cleared RecurrenceRule when the fetched occurrence moves to another account-local date.
  • Strengthened the boundary regression: Made the Tokyo projection fixture recurring and asserted that cross-date projection omits its rule.

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

Comment thread src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs Outdated
@thomasluizon

Copy link
Copy Markdown
Owner Author

Round 2 approach: append the Google start timezone to CalendarEventItem, populate it in GoogleCalendarEventFetcher, and retain recurring rules only when four seasonal source-to-account projections keep the same local date. I will add one failing Lisbon regression, one stable companion, and verify the fetcher carries the SDK timezone. This avoids rewriting RRULE structure from one occurrence.

@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 recurrence stability work remains unsafe to merge: it retains rules whose projected local time changes by season, can miss transition-week date shifts, and now depends on an unverified Google response field. The existing recurrence threads remain open; one new external-interface finding is inline.

Reviewed changes Reviewed commit 3d59d9f since the prior Pullfrog review at ee8f3e4.

  • Carried the source timezone: Added optional StartTimeZone data from Google event starts and persisted it with newly created suggestions.
  • Sampled seasonal projections: Retained recurrence only when four source-to-account conversions keep the same calendar date.
  • Expanded recurrence coverage: Added Lisbon seasonal date cases and asserted the Google event mapper copies start.timeZone.

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

Comment thread src/Orbit.Infrastructure/Services/GoogleCalendarEventFetcher.cs Outdated

@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

Recurring imports can still lose recurrence or retain a seasonally wrong account-local time, and the load-bearing Google timezone field is not evidenced for this request shape.

Reviewed changes Reviewed all eight changed files at 3d59d9f, including Google event mapping, live and persisted projection, recurrence gating, and regression coverage.

  • UTC projection: Added optional start and end instants and projected timed events into the account timezone while preserving floating all-day dates.
  • Suggestion handling: Persisted new boundary data and projected stored suggestions before local-date filtering and legacy matching.
  • Import-safe ranges: Removed unsupported end times for missing boundaries, date crossings, repeated hours, and equal displayed minutes.
  • Recurrence gating: Added the Google start timezone and seasonal probes to decide whether to retain the source RRULE.
  • Regression coverage: Added focused query and fetcher tests for timezone boundaries, legacy JSON, DST behavior, and recurrence retention.

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

Comment thread src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs Outdated
Comment thread src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs Outdated
Comment thread src/Orbit.Infrastructure/Services/GoogleCalendarEventFetcher.cs Outdated
@thomasluizon

Copy link
Copy Markdown
Owner Author

Round 3 approach:

  • Replace recurrence suppression in CalendarEventItem.ProjectTo with BYDAY projection using the source date and projected account date.
  • Preserve ordinal prefixes and every non BYDAY term byte for byte.
  • Remove StartTimeZone and IsRecurrenceDateStable from the query, fetcher, and tests because recurrence projection no longer consumes Google timezone metadata.
  • Add red first query regressions for shifted, unchanged, ordinal, no BYDAY, Denver, and Lisbon cases.
  • Keep the existing UTC, all day, end time, and stored suggestion behavior unchanged.

This shape preserves recurring imports. Seasonal gating was rejected because a null rule silently creates a one time habit.

@pullfrog

pullfrog Bot commented Sep 18, 2026

Copy link
Copy Markdown

Run failed. View the logs →

Pullfrog  | Rerun failed job ➔ | View workflow run | via Pullfrog | Using GPT Sol | 𝕏

1 similar comment
@pullfrog

pullfrog Bot commented Sep 18, 2026

Copy link
Copy Markdown

Run failed. View the logs →

Pullfrog  | Rerun failed job ➔ | View workflow run | via Pullfrog | Using GPT Sol | 𝕏

@thomasluizon

Copy link
Copy Markdown
Owner Author

REQUEST CHANGES, head e8672fe84371a2aa714335154a47c67a2a44789b.

Pullfrog could not run this review. Codex and Pullfrog share one OpenAI meter, exhausted until 2026-09-22. A separate agent reviewed in the worktree carrying this exact head, ticket-526-calendar-tz, and the orchestrator re-checked the blocking finding by hand. The substitution is named so nobody later reads this as having had the usual reviewer.

git rev-parse HEAD printed e8672fe84371a2aa714335154a47c67a2a44789b. git status --porcelain was empty at the start and at the end; the build and test runs left only ignored bin/ and obj/ output.

The projection itself is right, and it fixes the cause rather than the symptom. The recurrence gate built on top of it is not: it withholds a whole class of series whose projection is provably the identity, and nothing in the suite can see it.

What ran

command result
dotnet build Orbit.slnx -v m 0 Erro(s), 20 Aviso(s)
dotnet test Orbit.slnx --no-build --no-restore 0 failed. 32 Analyzer, 588 Domain, 3432 Application, 2242 Infrastructure = 6294 passed
dotnet test tests/Orbit.Application.Tests --filter "~Calendar" 0 failed, 192 passed
dotnet test tests/Orbit.Infrastructure.Tests --filter "~Calendar" 0 failed, 41 passed
node tools/check-dashes.mjs on the 17 changed files, the baseline, and the live PR title and body exit 0
node tools/check-suppression-allowlist.mjs exit 0, 101 declared sites
dotnet format Orbit.slnx --verify-no-changes on the changed .cs exit 0
node tools/arch-map.mjs in a clean git archive HEAD export architecture.json and architecture.html match the committed copies exactly

The local SDK is 10.0.302, compiler 5.6.0.0, and Orbit.Analyzers.dll targets 5.9.0.0, so eight CS9057 warnings mean ORBIT0001..0005 did not run locally. Every changed .cs file was grepped by hand for a bare // or /* */: exactly one match, the pre-existing URL-linked WHY note at RunCalendarAutoSyncCommand.cs:235, which matches the body. The 20 warnings are all NU1608, CS0618 and CS9057, none from the changed files.

What was constructed, and what it did

The compiled CalendarEventItem.ProjectTo and HasUnrepresentableRecurrenceAfterProjection were driven directly from a scratch export of this head, not read from the diff.

The ticket's named cases all produce the right value:

case1 Tokyo 2026-07-15 08:00 -> Sao_Paulo   StartDate=2026-07-14  StartTime=20:00  EndTime=21:00
case2 Sao_Paulo 2026-07-15 23:00 -> Tokyo   StartDate=2026-07-16  StartTime=11:00  EndTime=12:00
case3 all-day -> Sao_Paulo / Tokyo          StartDate=2026-04-15  StartTime=null   EndTime=null
FindTimeZone(null)="UTC" ("")="UTC" (" ")="UTC" ("Not/AZone")="UTC" ("America/Sao_Paulo ")="UTC"

DST on both sides, and sub-hour zones:

July 2026-07-15 16:00Z -> New_York  12:00 (EDT)   Nov 2026-11-15 16:00Z -> New_York 11:00 (EST)
fall back  05:30Z and 06:30Z on 2026-11-01 -> both 01:30
spring fwd 06:30Z -> 01:30 ; 07:30Z -> 03:30 on 2026-03-08
Sydney July -> +10, Jan -> +11 ; Kathmandu +05:45 ; Chatham +12:45, Jan +13:45

All correct. The projection carries the zone through (ConvertTimeFromUtc on a stored instant, reformatted) rather than adjusting an offset at the boundary. EndUtc exists so the end can be projected from an instant too, and SourceTimeZone carries the source calendar's expansion zone from the recurring master into both feeds.

Falsification, 22 targeted mutations, one production behaviour at a time. 21 of 22 reddened at least one test. Removing the projection reddened 19; ProbeDays = 0 reddened 4; dropping the stored SourceTimeZone key reddened StableByDaySeries_IsOfferedByBothFeeds; un-projecting the reconciler reddened Handle_Success_ReconciliationMatchesOnTheAccountTimezoneDayAndTime; giving EndUtc the all-day fallback reddened FetchAsync_AllDayEvent_PreservesFloatingDateWithNullTimes. The tests are real. The one mutation that reddened nothing is the InvalidTimeZoneException widening in TimeZoneHelper, which the body already states is not unit proved, so that claim is honest. The four that reddened nothing are the findings below.


P1. Every DST zone loses an hour-wide band of recurring events, including when nothing can move

Introduced here. src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs:170-171:

            if (sourceTimeZone.IsInvalidTime(sourceLocal))
                return false;

Re-read by hand at this head, in context: the walk starts from the source wall clock at :162 and probes forward day by day from :166. An invalid source wall clock ends the walk with "unproved", which HasUnrepresentableRecurrenceAfterProjection turns into withhold the whole series.

But a wall clock that does not exist on one date is not an occurrence the series produces on that date. It is not evidence against the series. It is a date with nothing to check.

With the account zone set EQUAL to the source zone, so ProjectTo is provably the identity and no occurrence can move, sweeping every local start time in fifteen-minute steps against the compiled gate:

America/New_York       WITHHELD at 02:00, 02:15, 02:30, 02:45
America/Los_Angeles    WITHHELD at 02:00, 02:15, 02:30, 02:45
America/Chicago        WITHHELD at 02:00, 02:15, 02:30, 02:45
Europe/London          WITHHELD at 01:00, 01:15, 01:30, 01:45
Europe/Lisbon          WITHHELD at 01:00, 01:15, 01:30, 01:45
Europe/Berlin          WITHHELD at 02:00, 02:15, 02:30, 02:45
Europe/Paris           WITHHELD at 02:00, 02:15, 02:30, 02:45
America/Havana         WITHHELD at 00:00, 00:15, 00:30, 00:45
America/Santiago       WITHHELD at 00:00, 00:15, 00:30, 00:45
Asia/Beirut            WITHHELD at 00:00, 00:15, 00:30, 00:45
Atlantic/Azores        WITHHELD at 00:00, 00:15, 00:30, 00:45
Australia/Sydney       WITHHELD at 02:00, 02:15, 02:30, 02:45
Pacific/Auckland       WITHHELD at 02:00, 02:15, 02:30, 02:45
America/Sao_Paulo      no local start time is withheld
Asia/Tokyo             no local start time is withheld
America/Bogota         no local start time is withheld

Replaying the gate's own walk names the cause: America/New_York fails first at IsInvalidTime on 2027-03-14, Europe/London at 2027-03-28, America/Santiago at 2026-09-06.

Failure scenario. A person in New York, whose Google calendar is also America/New_York, has RRULE:FREQ=WEEKLY;BYDAY=TU at 02:30 local. Their account zone and the calendar zone are the same, so there is no timezone problem to solve at all. After this deploys the event vanishes from GET /calendar/events, never becomes an auto-sync suggestion, and is excluded from the suggestion count that drives the notification. The list gives no reason and the person cannot get it back. Before this pull request that event was returned correctly. Same for 01:00 to 01:59 in the UK and Portugal, and 00:00 to 00:59 in Cuba, Chile, Lebanon and the Azores.

Why the suite did not catch it. Changing return false to continue on those two lines and rerunning both calendar suites gives 0 failed, 192 passed and 0 failed, 41 passed. Nothing red. No test pins this branch in either direction.

The minimal fix is continue rather than return false, plus a test that goes red under return false. Two stronger options are worth taking in the same change: short-circuit when the two zones can never disagree, which makes this entire class of false refusal unreachable, or probe the normalized instant Google actually expands the occurrence to. The body's "The cost, restated" section names only the missing-source-zone case as withheld-though-safe; this one belongs there too, or better, should stop happening.

P2. The write-side gate in CreateSuggestions has no test at all

Introduced here. src/Orbit.Application/Calendar/Commands/RunCalendarAutoSyncCommand.cs:247, confirmed by hand at this head:

            if (ev.HasUnrepresentableRecurrenceAfterProjection(timeZone)) continue;

The round 7 section claims: "CreateSuggestions also applies the gate before it writes, so a series the events feed withholds never becomes a suggestion or a notification in the first place." Deleting that line and rerunning the Application calendar suite gives 0 failed, 192 passed. Nothing red.

CalendarFeedAgreementTests.SeasonallyShiftingByDaySeries_IsWithheldByBothFeeds stays green because the read-side gate at GetCalendarSyncSuggestionsQuery.cs:106 hides the row anyway. So the test proves the read path and says nothing about the write path, and the write path carries the consequence the read path cannot undo: newSuggestions is the count CreateSuggestionNotification uses.

Failure scenario: someone removes or reorders that line in a later refactor. Every gate stays green. Auto-sync writes suggestion rows for withheld series and pushes "you have N new calendar suggestions" to a person who opens the list and finds it empty, because the read-side gate filters exactly what the notification counted. Assert that the written suggestions are empty for the seasonally shifting series, or that the notification repository received nothing.

P2. Two false claims in the body about the openapi proof

The body lands in history on squash, so these outlive the branch. Stated twice, in round 6 and round 7:

src/Orbit.Api/openapi.json is unchanged, which is the proof that SourceTimeZone stayed off the wire.

That is not proof. grep -c "CalendarEventItem" src/Orbit.Api/openapi.json returns 0, and the 200 response for /api/Calendar/events at openapi.json:1960-1962 is "200": { "description": "OK" }. No content, no schema. That file could not describe CalendarEventItem before this pull request and cannot after it, so its stability is evidence of nothing. The same unchanged file would equally "prove" that EndUtc stayed off the wire, and EndUtc is on the wire.

The conclusion is nevertheless correct, and here is the actual proof, serializing the record with JsonSerializerDefaults.Web, which is what the API uses:

{"id":"ev-1","title":"Standup","description":null,"startDate":"2026-07-15","startTime":"21:00",
 "endTime":"22:00","isRecurring":true,"recurrenceRule":"RRULE:FREQ=WEEKLY;BYDAY=WE","reminders":[],
 "startUtc":"2026-07-15T12:00:00Z","calendarId":"cal","calendarName":"Cal","endUtc":"2026-07-15T13:00:00Z"}

sourceTimeZone is absent, because of [JsonIgnore]. Replace the openapi sentence with the [JsonIgnore] reason, or with a serialization test.

Second, "so no client contract changes" understates it. endUtc is a new field on that response, reaching both CalendarController and the MCP CalendarTools path. On the contract question: this is the allowed category. It is an added optional field, appended last so no existing positional parameter renumbers; nothing is renamed, removed or retyped; calendarSyncEventSchema in packages/shared/src/types/calendar.ts has no endUtc and is not .strict(), so Zod strips it and no shipped build reads it. Append-only and deploy-API-first are satisfied and no paired UI release is needed. Say "one added optional field, which is append-only safe" rather than "no contract change".

P3

  • Three production branches no assertion can distinguish. GetCalendarEventsQuery.cs:189-191, SourceOffsetsAt: replacing the ambiguous branch with a plain GetUtcOffset reddens nothing, although the branch has a dedicated doc comment and answers a named review case. :43, ProbeDays = 366, commented as "A full turn of both zones' adjustment rules": setting it to 180 reddens nothing, so the tests prove only that the probe reaches past the 60-day fetch window, not that it reaches a year. :124, DropsEndTimeAfterProjection: deleting the EndTime is not null half reddens nothing, and round 7 added that half deliberately, without which the Debug line fires for every all-day event on every calendar read. Take the first one at least: add a fall-back-hour series whose two instants disagree, so the ambiguous branch is the reason a test passes.
  • StoredCalendarEventJson.Deserialize can throw past its own guard, StoredCalendarEventJson.cs:40. A row whose SourceTimeZone key holds a number gives System.InvalidOperationException: An element of type 'Number' cannot be converted to a 'System.String'. GetCalendarSyncSuggestionsQueryHandler.DeserializeEvent catches only JsonException, so this escapes the per-row tolerance guard and fails the whole suggestions request instead of skipping one row. No production writer produces such a row, since Serialize only ever writes a string or null and RawEventJson is server-owned, so this is a robustness gap rather than a live bug. AsValue()?.TryGetValue<string>(out ...), or widening the catch, closes it.
  • BuildLegacyMatchKey is duplicated verbatim at RunCalendarAutoSyncCommand.cs:328-331 and GetCalendarSyncSuggestionsQuery.cs:170-173, identical bodies, both present at the merge base 7b593e4b. This pull request adds a doc comment to each asserting the other builds "the same key from the same projection", and nothing enforces that: CalendarFeedAgreementTests compares the two read feeds, not the two key builders. If one copy changes the doc becomes a lie and legacy habit linking silently stops working with every gate green. Pre-existing, so under D95 it gets its own ticket rather than a fix here.
  • The gate runs 367 timezone conversions per weekday-ruled event, on three paths. Measured: 100 gate calls in 22 ms, 0.23 ms each. It runs once per fetched event in Handle, once per fetched event in CreateSuggestions, and once per stored row on every read of GetCalendarSyncSuggestionsQuery. The real bound is distinct recurring masters, not occurrences, so 100 weekly series cost about 23 ms per request. Fine today; worth a memo per master if series counts grow.

The five questions

Cause or symptom: cause. The instant is carried through the zone and reformatted, EndUtc gives the end an instant to project from, and SourceTimeZone carries the master's expansion zone into both feeds and into RawEventJson. No offset is nudged at a boundary. The one place that reasons about the wrong thing is P1.

Assertions that cannot fail: none found, so nothing needs deleting. The gaps run the other way: four production behaviours with no assertion at all, listed as P2 and P3.

Contract change: yes, one, endUtc, in the allowed append-only category, detailed above. SourceTimeZone is server-only and verified absent from the wire. RawEventJson gains one key, and a pre-change row round-tripped through the new reader comes back as a plain CalendarEventItem with a null source timezone, so no backfill is needed.

Round 7 means six rounds came before: no earlier fix was reverted. The only merge on this branch, dbf3d00b, has parents 3d59d9f9 and 7b593e4b and brought exactly two files, GetHabitWidgetQuery.cs and its test; git diff dbf3d00b^1 dbf3d00b touches no calendar file. Every named artifact from rounds 5, 6 and 7 is present at head, and the four announced deletions are real: ProjectRecurrenceRule, ExpandedOccurrences, masterRRuleCache and ParseStartDateUtc each return 0 occurrences across src and tests.

Body claims: verified true are the 6294 total and the four per-project counts, the 192 and 41 calendar counts, 0 errors, 101 declared sites, the arch-map artifacts being current, the single bare comment at RunCalendarAutoSyncCommand.cs:235, the TimeZoneHelper branch being unproved, GoogleCalendarApi.cs:70-71 setting a 60-day window, HabitScheduleService.IsMonthlyMatch at HabitScheduleService.cs:691, and calendarSyncEventSchema carrying no endUtc and not being .strict(). The Google citation is exact against the installed source at google.apis.calendar.v3/1.75.0.4206/lib/net462/Google.Apis.Calendar.v3.xml:3729. False: the two openapi sentences. Overstated: "no client contract changes". Unverifiable from here: the round 5 and 6 historical run counts, not reproduced at those heads.

The disclosed product cost, that a genuinely date-shifting BYDAY series is withheld with no message, is a stated decision with #569 filed against it and is not re-raised here. P1 is different: it withholds series that do not shift at all.


What blocks the merge: P1, plus the two P2s alongside it because both are small. The rest can follow.

A probe date whose source wall clock a spring forward gap removes is a date
the series does not fire on, not evidence the series moves. The walk ended
there with "unproved" and the gate withheld the whole series, so an account
reading a calendar kept in its own zone lost an hour wide band in thirteen
DST zones even though the projection is the identity.

HasUnrepresentableRecurrenceAfterProjection now answers false as soon as
TimeZoneInfo.HasSameRules holds, and the walk skips a wall clock its own zone
removes instead of ending. RFC 5545 section 3.3.5 does define the missing
case, but probing that normalized instant changes no decision for any of the
14,752 zone pairs that could distinguish it, and asserting an expansion
Google does not document would let one guessed date withhold a year.

Also pins what earlier rounds argued: auto-sync refusing to store or notify a
withheld series, JsonIgnore keeping SourceTimeZone off the response body, the
repeated hour needing both of its instants, the probe reaching a full year,
and an all day event logging no dropped end time. A stored SourceTimeZone key
holding a number now reads as no source zone rather than failing the request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pullfrog

pullfrog Bot commented Sep 19, 2026

Copy link
Copy Markdown

Run failed. View the logs →

Pullfrog  | Rerun failed job ➔ | View workflow run | via Pullfrog | Using GPT Sol | 𝕏

1 similar comment
@pullfrog

pullfrog Bot commented Sep 19, 2026

Copy link
Copy Markdown

Run failed. View the logs →

Pullfrog  | Rerun failed job ➔ | View workflow run | via Pullfrog | Using GPT Sol | 𝕏

@thomasluizon

Copy link
Copy Markdown
Owner Author

APPROVE, head 774f26b1599103512b7235157296510e6a0ddf41.

Pullfrog could not run this review. Codex and Pullfrog share one OpenAI meter, exhausted until 2026-09-22. A separate agent reviewed in the worktree carrying this exact head. The substitution is named so nobody later reads this as having had the usual reviewer.

git rev-parse HEAD and gh pr view 521 --json headRefOid both return 774f26b1599103512b7235157296510e6a0ddf41. git status --porcelain was empty at the start and at the end, and every mutation ran in a separate git clone of the worktree at the same SHA. This is a narrow review of round 8; rounds 1 to 7 were not re-opened.

Round 8 is one commit touching 8 files, 2 production, 4 test, 2 generated, and it deletes exactly two production lines in the whole round: the return false; at the invalid-wall-clock probe, and the old ?.GetValue<string>() read. Both are the intended changes. Nothing from rounds 1 to 7 was reverted, and the diff from e8672fe8 contains no deleted test line.

The P1, re-measured

Driven through the real internal HasUnrepresentableRecurrenceAfterProjection from inside Orbit.Application.Tests, which already holds InternalsVisibleTo. Account zone equal to source zone, base date 2027-01-07, RRULE:FREQ=WEEKLY;BYDAY=TH, all 96 quarter-hour local start times, across the 13 named zones plus the 3 that lost nothing. round7-withheld is the same walk with the short circuit removed and return false restored.

America/New_York, Los_Angeles, Chicago, Berlin, Paris, Sydney, Auckland
    head-withheld = NONE      round7-withheld = 02:00..02:45 (4 of 96)
Europe/London, Europe/Lisbon
    head-withheld = NONE      round7-withheld = 01:00..01:45 (4 of 96)
America/Havana, America/Santiago, Asia/Beirut, Atlantic/Azores
    head-withheld = NONE      round7-withheld = 00:00..00:45 (4 of 96)
America/Sao_Paulo, Asia/Tokyo, America/Bogota
    head-withheld = NONE      round7-withheld = NONE
copy-vs-real disagreements: 0

The defect reproduces exactly as reported, in all thirteen zones. At this head the gate withholds nothing at any of the 1,536 (zone, wall clock) points.

And it still withholds what it should

A gate that stops refusing everything is as broken as one that refused too much, so the other direction was checked too. Named cases through the same real method: Lisbon to Sao Paulo at 03:30, Beirut to Athens at 23:30, Kiritimati to Midway at 02:30, Kolkata to New York at 02:30 and Sydney to Los Angeles at 02:30 are all still withheld, while New York to Chicago at 02:30 and Lisbon to London at 01:30 are allowed.

Then a full differential sweep, 30 zones as source and as account, 48 wall clocks each:

cases = 43200
withheld by head   = 13638
withheld by round7 = 14338
withheld by round7 only = 700
withheld by head only   =   0
real-vs-copy disagreements = 0

Head withholds a strict subset and never withholds something round 7 allowed. All 700 relaxed cases are the gap artifact, which follows from the two implementations differing only at an invalid wall clock. The gate did not become permissive anywhere else.

The write-side gate now has a test

Deleting RunCalendarAutoSyncCommand.cs:247 reddens SeasonallyShiftingByDaySeries_IsNeitherStoredNorNotifiedByAutoSync, which asserts the count, the written rows and the notification rather than the feed that follows them. StableByDaySeries_IsStoredAndNotifiedByAutoSync is its control on the same account zone and proves the harness really writes a row and really pushes a notification, so the absence the first test asserts is a real absence.

The write path was also checked for a second store site: StoredCalendarEventJson.Serialize has exactly one caller, RunCalendarAutoSyncCommand.cs:251, with the gate immediately above it at :247. The three gate call sites are GetCalendarEventsQuery.cs:261, GetCalendarSyncSuggestionsQuery.cs:106 and RunCalendarAutoSyncCommand.cs:247.

Mutation table

Each mutation applied alone to the clone, built, then the whole Orbit.Application.Tests project run, and the tree restored after each.

mutation result what reddened
none, as shipped 0 failed, 3451 passed
continue back to return false 1 failed Handle_ByDaySeriesOnAWallClockTheSourceGapRemoves_IsKeptWhenTheDateCannotMove
short circuit deleted 0 failed none, and that is correct, see below
both deleted, the round-7 gate 8 failed the above plus all 7 rows of ..._IsKeptWhenTheAccountReadsItsOwnZone
RunCalendarAutoSyncCommand.cs:247 deleted 1 failed SeasonallyShiftingByDaySeries_IsNeitherStoredNorNotifiedByAutoSync
[JsonIgnore] removed from SourceTimeZone 4 failed ResponseBody_CarriesEndUtcAndOmitsTheSourceTimeZone plus 3 rows
ReadSourceTimeZone back to ?.GetValue<string>() 4 failed Handle_StoredRowWhoseSourceTimeZoneIsANumber_StillReturnsTheOtherRows plus 3
ambiguous branch to plain GetUtcOffset 0 failed none, disclosed
ambiguous branch to the RFC 5545 first instant 1 failed the Beirut test
EndTime is not null half removed 1 failed Handle_AllDayEvent_LogsNoOmittedEndTime
ProbeDays = 364 1 failed the Beirut test
ProbeDays = 365 0 failed disclosed

Every claim the body makes about a mutation reproduces. Nothing it claims red is green.

The short circuit reddening nothing is a property of the code, not a gap

The body says no test can redden the short circuit alone. That claim was tested rather than accepted: removing it and walking every ordered same-rule zone pair the runtime holds, on five base dates across the year, at every quarter hour, gives 175 same-rule pairs, 84,000 cases, and 0 that the walk alone would still withhold. The walk never refuses a same-rule pair, so no input separates the two paths. The body states this plainly instead of hiding it, which is the right call.

The 14,752 claim, reproduced

The commit message claims probing the RFC 5545 normalized instant changes no decision for 14,752 cases. Reproduced to the unit:

system zones = 141
gap quarter-hours in 2026-2027 across rule sets = 312
GetUtcOffset(invalid) == offset two days earlier: 312 of 312
candidate triples whose normalized instant lands on another account date = 14752
of those, gate decision changes when probing instead of skipping = 0
candidates with no valid anchor date, not compared = 0

The premise the argument rests on was confirmed too: TimeZoneInfo.GetUtcOffset returns the pre-gap offset for an invalid wall clock in all 312 gap quarter hours, so deleting the check really would carry the probe to the RFC instant. Deduplicating the 141 ids by HasSameRules gives 128 rule sets, 12,396 candidates, again zero decision changes.

Counts

dotnet build Orbit.slnx -v m: 0 errors, 20 warnings, all CS9057, NU1608 or CS0618.

0 failed,   32 passed  Orbit.Analyzers.Tests
0 failed,  588 passed  Orbit.Domain.Tests
0 failed, 3451 passed  Orbit.Application.Tests
0 failed, 2242 passed  Orbit.Infrastructure.Tests

6313 total, up from 6294. Round 8 adds 19 Application tests: 10 in GetCalendarEventsQueryHandlerTests, 6 in the new CalendarEventItemSerializationTests, 2 in CalendarFeedAgreementTests, 1 in GetCalendarSyncSuggestionsQueryHandlerTests. The body's counts match exactly.

Both false claims are corrected

openapi.json: the body no longer offers the unchanged file as proof. It says the file carries no schema for this response at all and names [JsonIgnore] as the real reason, and the correction appears in all three places the old claim appeared. Verified in the worktree: grep -c "CalendarEventItem" src/Orbit.Api/openapi.json returns 0 and /api/Calendar/events carries "200": { "description": "OK" } at :1961-1962.

endUtc: the body no longer says "no client contract changes". It says the response gains one optional field which is append-only safe, names both the CalendarController and MCP CalendarTools paths, and states that calendarSyncEventSchema has no endUtc and is not .strict(). The doc comment at GetCalendarEventsQuery.cs:32 still reads "so no client contract changes", but it says that about SourceTimeZone, which is the field it describes, and that is true. Both halves are now pinned by an assertion rather than by prose, since removing [JsonIgnore] reddens a test.

Other checks

#623 is correctly left alone: BuildLegacyMatchKey is still duplicated at RunCalendarAutoSyncCommand.cs:328 and GetCalendarSyncSuggestionsQuery.cs:170, and round 8 touches neither file. That is right under D95.

ORBIT0001 does not run in the local SDK, so the added lines were grepped by hand: round 8 adds zero bare // or /* */ comments, and the only match in the whole changed file set is the pre-existing URL-linked WHY note at RunCalendarAutoSyncCommand.cs:235.

node tools/arch-map.mjs in the clone regenerates architecture.json and architecture.html byte-identically to the committed copies.

CI at this exact head, every check run reporting head_sha 774f26b1: Build, Unit Tests, Mutation (application) and OpenAPI Breaking-Change Gate all success, plus CodeQL, SonarCloud, Dash Ban and Root Allowlist. pullfrog-approval absent, as expected. The green Linux run settles the portability question on the Asia/Beirut test, which the body raised itself.

Remaining findings, none blocking

  • P3, the commit message counts triples and calls them zone pairs. The number 14,752 is right; the noun is not. It counts (source zone, account zone, gap quarter-hour) triples. There are only 19,740 ordered pairs of the 141 ids and only 128 distinct rule sets, so "14,752 zone pairs" cannot be read literally. The body has the same slip, treating the 141 ids as rule sets when HasSameRules collapses them to 128. Nothing downstream depends on it, but a later reader trying to reproduce the number from the noun will fail.
  • P3, the measured counts are specific to this runtime. GetSystemTimeZones() returns 141 here, the Windows list; the Linux CI image enumerates the full IANA set, so neither 14,752 nor the 1,388 figure in the round 7 section reproduces there. The surviving conclusion, zero decision changes, has no reason to be platform-bound, but the body presents the counts as absolute. One sentence naming the runtime closes it, and the body already handles the related Africa/Casablanca portability trap correctly.
  • P3, the reconciler holds a different policy from the writer on the same projection. Pre-existing from round 7. RunCalendarAutoSyncCommand.cs:183 builds eventsByKey from ev.ProjectTo(timeZone) with no gate call, while CreateSuggestions refuses that same projection at :247. So an event the gate calls unrepresentable can still reach habit.SetGoogleEventId(...) at :217 if a pre-existing habit's title, date and time match. No user-visible harm was found, because the reconciler writes no date and no rule and the key usually will not match, but two write paths answer one question two ways. Round 8 must not fix it and #569 does not cover it. Its own ticket.
  • P3, disclosed and accepted: ProbeDays = 366 is pinned only down to 365, the leap day no 2026 or 2027 case exercises; and the ambiguous branch is half pinned, since the plain GetUtcOffset arm reddens nothing while the RFC arm reddens the Beirut test. The body states both, names the 4 Africa/Casablanca cases that would pin the first half, and explains why they would pass here and fail on Linux CI. That call is right.

Not a finding: ..._IsKeptWhenTheAccountReadsItsOwnZone names 7 of the 13 zones rather than all 13. The other 6 would be pure duplication, because HasSameRules answers every identity case before the walk starts, so all 13 take one path.

What could not be verified

The counterfactual arms are not all against a shipped binary. The differential sweep and the 14,752 check used a reimplementation of KeepsItsLocalDateForAYear with one flag for the invalid-wall-clock branch. That copy was validated against the real internal method on all 44,736 swept points with zero disagreements, and its round-7 arm was corroborated against a real mutated build producing the 8 red tests the copy predicts. That is as close as a read-only review of a compiled internal method gets.

Pullfrog could not run. This replaces it and is not the same thing.


This pull request targets protected main. It cannot merge before 2026-09-22 while the shared allowance is out, and Thomas merges and deploys 521 by hand, so this verdict is advice for that moment rather than a merge trigger.

@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 strengthened recurrence proof still has one correctness bypass, and rollout handling can make existing recurring suggestions unavailable for up to 14 days.

Reviewed changes Reviewed the recurrence hardening added after the prior Pullfrog review at 44611e5f.

  • Persisted recurrence evidence: Carries the Google source timezone into server-owned suggestion JSON and applies the same gate to live events, suggestion reads, and auto-sync writes.
  • Proved both timezone rules: Replaces sampled transition dates with a 367-day source-to-account date-stability walk.
  • Handled DST edge cases: Skips nonexistent source wall clocks and verifies both offsets when a source wall clock is ambiguous.
  • Synced current main: The merge commit changes no net feature behavior in this PR.

⚠️ Existing recurring suggestions disappear during rollout

Rows written before this change do not contain SourceTimeZone, so every pending timed BYDAY suggestion is now withheld. The same row's event ID still suppresses the live event and prevents auto-sync replacement, while cleanup waits 14 days, leaving valid recurring events absent from both import feeds during rollout.

Technical details

Affected sites

  • GetCalendarSyncSuggestionsQuery.cs:103-108 rejects the legacy row because its source zone is null.
  • GetCalendarEventsQuery.cs:326-333 still treats that suggestion's event ID as imported and filters the live event.
  • RunCalendarAutoSyncCommand.cs:235-248 reserves existing suggestion event IDs and will not refresh the row with the newly fetched source zone.
  • SyncCleanupService.cs:25,84-92 does not remove unimported rows until the 14-day retention cutoff.

Required outcome
Refresh, replace, or deliberately expire predeployment pending rows promptly enough that safe recurring events remain available, while continuing to withhold recurrences whose source-to-account date stability cannot be proved.

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

Comment thread src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs Outdated

@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

Timed recurrences can still be imported at a seasonally wrong clock time. The rollout and moved-instance findings in the current-head review also remain unresolved.

Reviewed changes Reviewed the full calendar timezone projection at 1bd91785, including live events, stored suggestions, auto sync, recurrence gating, Google mappings, shipped web and mobile import behavior, and regression coverage.

  • Account timezone projection: Carries UTC start and end instants, projects timed boundaries into the account zone, preserves floating all-day dates, and omits end values that the habit contract cannot represent.
  • Recurrence refusal: Reads the recurring master's source zone, persists it in server-owned suggestion JSON, and filters BYDAY series whose projected account date is not stable.
  • Auto sync compatibility: Projects legacy matching keys, applies recurrence filtering on writes and reads, and handles ambiguous projected keys conservatively.
  • Timezone validation: Trims new user timezone values, logs unusable stored values, and falls back to UTC for reads.
  • Regression coverage: Adds focused application, domain, infrastructure, serialization, feed agreement, DST, and sub-hour offset tests.

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

Comment thread src/Orbit.Application/Calendar/Queries/GetCalendarEventsQuery.cs Outdated
@thomasluizon

Copy link
Copy Markdown
Owner Author

Review-body finding "Existing recurring suggestions disappear during rollout" is fixed in 64ebb3e: a pending legacy row without SourceTimeZone no longer suppresses the live event or blocks its auto-sync refresh, and it is refreshed with the fetched source zone on the next auto-sync.

@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 legacy refresh can still associate a pending suggestion with an ambiguous fetched event ID and then suppress the correct live event.

Reviewed changes Reviewed commit 64ebb3eb since the prior Pullfrog review at 1bd91785, including the recurrence proof, Google mapping, persisted evidence, legacy refresh, and focused regressions.

  • Captured recurrence timing: Read OriginalStartTime from expanded instances and persisted it as server-only recurrence evidence.
  • Strengthened recurrence refusal: Required every timed recurrence to preserve its account-local displayed minute and day relationship across the yearly probe.
  • Handled moved instances: Compared actual and recurrence-defined projected schedules before admitting the representative event.
  • Refreshed legacy suggestions: Stopped evidence-less pending rows from hiding live events and refreshed eligible rows during auto-sync.
  • Expanded regression coverage: Added Lisbon clock-drift, moved-first-instance, serialization, and legacy-refresh cases.

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

Comment thread src/Orbit.Application/Calendar/Commands/RunCalendarAutoSyncCommand.cs Outdated
@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

No additional Critical or High issues were found, but the existing duplicate ID refresh finding remains unresolved at this unchanged head. Refresh eligibility still needs to be evaluated before duplicate event IDs are collapsed.

Reviewed changes Reviewed the complete 19-file PR at 64ebb3eb, including the prior rollout fix, recurrence proof, Google mapping, persisted suggestion compatibility, mobile import contract, and regression coverage.

  • Account-local projection: Projects timed start and end instants into the account timezone while preserving floating all-day dates and omitting unrepresentable end clocks.
  • Recurrence safety: Uses the master timezone and recurrence-defined instance start to withhold schedules whose account-local weekday or displayed minute cannot be proven stable.
  • Persisted compatibility: Stores server-only recurrence evidence additively and lets evidence-less pending rows remain visible until auto-sync refreshes them.
  • Auto-sync rollout: Refreshes eligible pending rows in place and applies the same recurrence gate before storing or returning suggestions.
  • Contract and coverage: Adds optional endUtc, keeps server evidence off the wire, and covers live events, stored rows, DST boundaries, legacy reconciliation, and Google fetch mapping.

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

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

✅ No new issues found.

Reviewed changes Reviewed the duplicate ID legacy refresh correction added since the prior Pullfrog review at 64ebb3eb.

  • Preserved provider multiplicity: Kept the original fetched list available to CreateSuggestions so refresh eligibility is decided before duplicate event IDs are normalized.
  • Kept downstream deduplication: Continued using the normalized list for reconciliation and new suggestion creation while using the raw list only for ownership proof.
  • Added regression coverage: Added a two-event shared-ID case that verifies the pending row's stored JSON and title remain unchanged; the focused test passes.

Pullfrog  | View workflow run | Using GPT Sol | 𝕏

@sonarqubecloud

Copy link
Copy Markdown

@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 Reviewed the complete calendar timezone projection at 0bbf276c, including live events, stored suggestions, auto sync, Google recurrence mapping, shipped client behavior, legacy rollout handling, and focused regression coverage.

  • Account timezone projection: Projects timed start and end instants into the account timezone, preserves floating all day dates, and omits end clocks the habit contract cannot represent.
  • Recurrence safety: Uses the recurring master's source timezone and each instance's recurrence defined start to withhold schedules whose account local weekday or displayed minute cannot be proven stable.
  • Stored suggestion compatibility: Persists server only recurrence evidence additively, tolerates legacy JSON, and refreshes eligible pending rows without letting duplicate fetched IDs choose an arbitrary owner.
  • Feed agreement: Applies the same recurrence gate to live events, suggestion writes, and suggestion reads while keeping legacy habit matching account local.
  • Timezone resilience: Trims newly stored timezone IDs and logs unusable legacy values before falling back to UTC.
  • Regression coverage: Adds serialization, application, domain, infrastructure, DST boundary, moved instance, and duplicate ownership tests; the focused application and infrastructure calendar suites pass at this head.

Pullfrog  | View workflow run | Using GPT Sol | 𝕏

@thomasluizon
thomasluizon merged commit e315e06 into main Sep 24, 2026
27 checks passed
@thomasluizon
thomasluizon deleted the fix/ticket-526-calendar-tz branch September 24, 2026 19:35
thomasluizon added a commit that referenced this pull request Sep 25, 2026
* Add habit widget empty reason (#527)

* Start #372

* Add habit widget empty reason

* ORB-223: Generate gating matrix from PayGateService (#520)

* chore: start ORB-223

* feat: generate gating matrix

* fix: fail closed on unreadable plan gates

* fix: close gating matrix parser gaps

* fix: preserve plans in combined guards

* fix: distinguish quota lifted plans

* fix: carry plan provenance through quota aliases

Derived quota variables were recorded unscoped and only the variable that
literally contains the plan ternary was relabelled afterwards, so plan
provenance did not survive a local alias. A semantics-preserving
`var selectedLimit = user.HasProAccess ? proLimit : freeLimit;
var messageLimit = selectedLimit;` generated cleanly and changed
CanSendAiMessage.quotaLiftedByPlan from "Pro" to null.

The directly plan-selected variables are now labelled first, and the
fixed-point propagation carries the source variable's exact plan instead of
null. An assignment deriving from two different plans cannot have its
provenance proven, so generation fails closed with the same
"cannot derive plan requirement" error the other unprovable shapes use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: fail closed on unknown gating sources

* fix: derive feature flags from migrations

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: fall back to Claude only when the Codex reviewer step fails (#529)

Mirrors thomasluizon/orbit-ui-mobile#1022.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Let the executor own the MCP confirmation gate (#599) (#534)

* fix: let the executor own the MCP confirmation gate (#599)

Every MCP tool whose capability requires a confirmation or a step-up was
refused forever. The selective-auth middleware evaluated the policy a second
time, in front of the executor, and it never received the caller's
confirmation token. A client that stepped up and retried with a valid token
got the identical refusal, because the token never reached that evaluation.

Carrying the token into the middleware cannot fix it. A confirmation token is
single use and is bound to the pending operation's fingerprint, and the
middleware computes a different fingerprint from the raw MCP arguments than
the executor computes from the operation id and its snake_case argument
object. One token cannot satisfy two gates.

So the middleware now steps aside for a confirmation-gated capability, the
same way it already steps aside for execute_agent_operation_v2. Both reach
IAgentOperationExecutor, which evaluates access and confirmation together,
holds the token, and writes the audit row.

Two guard tests pin the invariants the deferral rests on: a confirmation
requirement always sits on a mutation, and every confirmation-gated MCP tool
reaches the executor through McpExecutorBridge.

Also corrected: the step-up message and the three tool parameter descriptions
named verify_step_up_agent_operation_v2 as the source of the confirmation
token. It does not return one. confirm_agent_operation_v2 does.

Removed AgentPolicyEvaluationContext.StepUpSatisfied. Nothing ever set it and
nothing ever read it; the real step-up state lives on
PendingAgentOperationState.StepUpSatisfiedAtUtc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: thread the confirmation token through the bulk habit tools (#599)

bulk_log_habits and bulk_skip_habits declared no confirmationToken
parameter and their shared helper hardcoded confirmationToken: null, so
their HabitsBulkWrite capability (FreshConfirmation) could never see a
token and both tools stayed permanently refused. Both now declare the
parameter and ExecuteBulkHabitOperationAsync forwards it.

Three new guards in ConfirmationGatedMcpToolsRouteThroughExecutorTests
pin what the old one missed. The scan now asserts it reaches exactly the
catalog's gated MCP tools, that each tool's forwarded operation id
resolves to a capability with the same ConfirmationRequirement, and that
each tool accepts and forwards a confirmation token. The source scan
walks subfolders and keys members by their declaration rather than by the
first invocation-shaped token in the chunk.

AgentOperationExecutor writes an AgentAuditLogs row before returning
UnknownOperation, restoring the trail the middleware used to leave.

McpConfirmationGateTests now drives the real AgentTools recovery methods
end to end and pins that all three refuse an API-key credential.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: resolve the forwarded MCP confirmation token back to the tool parameter (#599)

The token guard whitelisted one spelling of null. It flagged a bridge call only
when the confirmation-token argument was absent or the literal `null`, so
`confirmationToken: default`, `null!`, `(string?)null` and `""` all passed while
the tool stayed refused forever. A longer literal list does not close that,
because the next spelling is not on the list either.

Assert the structural property instead: a gated tool declares a
`string? confirmationToken` parameter, and the expression it forwards into the
executor's token slot resolves back to that identifier, directly or through one
helper hop. Every other expression fails, whatever it spells.

The declaration check now reads the tool's parameter list rather than its whole
body, so a local named `confirmationToken` no longer satisfies it.

Also add `forwarded.Id == tool.Capability.Id` to the operation-id guard. It
compared only the confirmation requirement, so a gated tool could forward
another gated capability's operation id, keep confirmation firing, and have the
executor enforce the wrong scope.

The file's doc comment said four invariants and listed four; the file holds
five. Name the source-scan invariant the other three rest on.

Six mutations, each red on its own, real tree green at every step:
`confirmationToken: default`, `null!`, `(string?)null`, `""`, a gated tool
forwarding a different local, and `delete_tag` forwarding `delete_goal`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: strip only a real named-argument prefix in the MCP guard parser (#599)

ArgumentAt treated any colon as a named-argument separator, so a conditional
expression in the token slot or the operation-id slot collapsed to its
else-branch. `ids.Count > 0 ? null : confirmationToken` read as
`confirmationToken` and `tagId.Length > 0 ? "delete_goal" : "delete_tag"` read
as `"delete_tag"`, and both guards stayed green over the restored defect.

Match `^\w+\s*:(?!:)` instead, so only a leading `name:` prefix is stripped and
every other expression reaches the resolver whole. The anchor keeps a colon
inside a string literal and a `::` qualifier from matching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: separate an omitted caller argument from a non-parameter expression (#599)

ResolveExpression returned the expression itself both when it was not a callee
parameter and when the caller supplied nothing at that index. The second case
handed back the helper's own parameter name, which then compared equal to
`confirmationToken` and passed. Giving the helper an optional
`string? confirmationToken = null` and calling it without that argument restored
the #599 defect with the guard green.

Return the `<omitted>` sentinel for the second case instead. It can never equal
the parameter name, and it resolves to no capability in the operation-id slot,
so both guards fail closed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: read a gated tool's direct and helper bridge calls together (#599)

CollectBridgeCalls returned early as soon as the tool body held one bridge
call, so a correct call in a dead branch hid every helper call in the same
tool. A decoy `if (tagId.Length == 0)` block forwarding `confirmationToken`
plus a same-file helper forwarding `null` restored the #599 defect with the
guard green.

Collect both sets instead. Every bridge call a gated tool can reach, directly
or through one helper hop, now has to forward the parameter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: refuse a gated tool that assigns to its own confirmationToken (#599)

The rule compared the forwarded expression's text only, so
`confirmationToken = null;` above the bridge call restored the #599 defect with
the forwarded identifier unchanged and the guard green.

Add an offender when the tool body, or a same-file helper it reaches, assigns
to the parameter. MemberSource now carries its statements with the parameter
list excluded, so the declaration's own `string? confirmationToken = null`
default does not match. The pattern `\bconfirmationToken\s*=[^=>]` leaves `==`,
`!=`, `>=`, `<=`, `??=` and a lambda arrow alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: say what the MCP token resolver models, not more (#599)

Invariant 5 claimed "any other expression in the token slot refuses the tool
forever" and that the tool forwards the identifier "directly or through a
helper it calls". A conditional refuses the tool on some paths only, and the
resolver models exactly one same-file helper hop matched by argument position.

Replace both sentences with what the scan actually does, and state its limits:
two or more hops read as no bridge call and fail the routing guard, and
aliasing, reflection and an interface call are outside the model.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: catch a compound assignment to confirmationToken (#599)

The assignment rule was anchored on `\s*=`, and `\s*` cannot cross the `??`
of `??=` or the `+` of `+=`. So `confirmationToken ??= tagId;` above the
bridge call compiled green with all five guards green, and a later change
that writes `confirmationToken ??= await ResolveStoredTokenAsync(...)` would
hand the executor a token the MCP caller never sent: HasFreshConfirmation is
then satisfied from state the caller does not control, while the middleware
has already stepped aside.

Admit the two compound operators a `string?` can carry. `==` still fails on
the second `=`, `!=`, `>=` and `<=` still fail because those characters break
`\s*` and are not in the alternation, `=>` still fails on the trailing
character class, and a bare `??` with no `=` still fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: read every same-named helper an MCP tool could be calling (#599)

The member index was a dictionary keyed on the bare name and built with
group.First(), so two same-named members collapsed to whichever was declared
first. The scan then analysed a method the tool never calls. It broke both
ways: a decoy overload declared after the real helper and forwarding null was
green, and a gated tool calling a correct overload reddened when an unrelated
same-named member happened to be declared first.

Hold every member under its name and, at each call site, read every candidate
whose parameter count can admit that many arguments. One candidate resolves
the call; more than one is ambiguous, so all of them are read and any bad one
reddens the tool. That fails closed instead of guessing, and it costs no false
red in the case above, where only the real helper carries a bridge call. A
name that admits no candidate stays unresolved, which hides nothing: the
bridge call inside it is not collected either, so the routing guard reddens
the tool.

Arity, not the parameter type, is what decides a candidate. The scan is a text
scan, so it cannot type-check an argument.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: say what invariant 5 now refuses and what it still cannot see (#599)

The general claim "refuses a tool that assigns to the parameter" is now exact:
`=`, `??=` and `+=`. The helper hop no longer claims a "matching argument
position", because the resolver pairs by position alone and strips a
named-argument prefix without reading the name, so arguments named out of the
declared order are read wrong. The comment said nothing about overloads, so
say that an ambiguous name is read as every candidate and reddens the tool.

Close with the honest limit: a text scan is defeatable by an author who sets
out to defeat it, and what the guard closes is every shape an ordinary
refactor produces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* Fix calendar event timezone projection (#521)

* Start calendar timezone fix

* Fix calendar event timezone projection

* Fix projected calendar end times

* Fix calendar end time during DST fallback

* Fix calendar end time minute precision

* Fix projected calendar recurrence rule

* Guard calendar recurrence across timezone shifts

* Project calendar recurrence weekdays

* Keep calendar recurrence unchanged

* Omit unrepresentable recurring calendar events

* Gate projected calendar recurrence on the whole fetch window

Closes the six review findings on pull request 521.

1. The weekday gate now walks every expanded occurrence Google returned for a
   recurring master, not the one sampled instance. The fetcher collects each
   instance start with the source calendar's own offset into a JsonIgnore'd
   ExpandedOccurrences list, so a Lisbon BYDAY=TH series that is stable in
   January and shifts to Wednesday in July is refused. The empty list still
   falls back to the sampled instance for an all-day series and for a
   suggestion row read back from the database.
2. The legacy title plus date plus time key only excludes a suggestion when
   exactly one candidate carries it. Inside a fall-back repeated hour two
   events project to the same local time, so the key proves nothing and both
   stay. This mirrors the group.Count() == 1 guard the auto sync reconciler
   already applies to the same key.
3. An omitted end time now logs its reason at Debug with the event id and the
   user id. EndUtc already ships the real duration, so the client keeps it.
4. The refusal here and the clamp in HabitScheduleService.IsMonthlyMatch are
   reconciled in a doc comment: Orbit clamps a rule it owns and refuses a rule
   it imports and cannot re-express without inventing a weekday.
5. Both new call sites pass the logger and the user id to FindTimeZone, which
   also catches InvalidTimeZoneException so a corrupt zone stops escaping as a
   500. User.SetTimeZone trims at the boundary and rejects a blank id.
6. Asia/Kathmandu at plus 05:45 and Pacific/Chatham at plus 12:45 cover the
   sub hour offset gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Prove calendar recurrence stability from both zones

Round 6 gated a BYDAY series on the expanded instances Google returned, and
GoogleCalendarApi only asks for sixty days, so a series whose transition falls
outside that window was admitted and a stored suggestion row never carried the
instances at all.

The gate now reads the source calendar's own timezone from the recurring master
the fetcher already fetches, and walks a year of dates at the occurrence's source
wall clock through both zones' rules. A series it cannot prove stable is withheld
from both feeds. StoredCalendarEventJson carries the source zone beside the stored
suggestion, so the suggestion feed judges a row on the same evidence the events
feed had.

Also in scope: the auto-sync reconciler projects a fetched event into the account
timezone before matching a legacy habit, the end-time omission logs whatever EndUtc
holds, and an all-day event no longer reports Google's exclusive end date as an
end instant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Keep calendar series a spring forward gap cannot move

A probe date whose source wall clock a spring forward gap removes is a date
the series does not fire on, not evidence the series moves. The walk ended
there with "unproved" and the gate withheld the whole series, so an account
reading a calendar kept in its own zone lost an hour wide band in thirteen
DST zones even though the projection is the identity.

HasUnrepresentableRecurrenceAfterProjection now answers false as soon as
TimeZoneInfo.HasSameRules holds, and the walk skips a wall clock its own zone
removes instead of ending. RFC 5545 section 3.3.5 does define the missing
case, but probing that normalized instant changes no decision for any of the
14,752 zone pairs that could distinguish it, and asserting an expansion
Google does not document would let one guessed date withhold a year.

Also pins what earlier rounds argued: auto-sync refusing to store or notify a
withheld series, JsonIgnore keeping SourceTimeZone off the response body, the
repeated hour needing both of its instants, the probe reaching a full year,
and an all day event logging no dropped end time. A stored SourceTimeZone key
holding a number now reads as no source zone rather than failing the request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Fix recurring calendar timezone proof and legacy refresh

* Avoid refreshing legacy calendar rows with duplicate event IDs

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* Bump FluentAssertions and 23 others (#538)

Bumps FluentAssertions from 8.10.0 to 8.11.0
Bumps Google.Apis.AndroidPublisher.v3 from 1.75.0.4246 to 1.76.0.4277
Bumps Hangfire.AspNetCore from 1.8.24 to 1.8.25
Bumps Hangfire.Core from 1.8.24 to 1.8.25
Bumps Microsoft.AspNetCore.Authentication.JwtBearer from 10.0.11 to 10.0.12
Bumps Microsoft.AspNetCore.OpenApi from 10.0.11 to 10.0.12
Bumps Microsoft.EntityFrameworkCore from 10.0.11 to 10.0.12
Bumps Microsoft.EntityFrameworkCore.Design from 10.0.11 to 10.0.12
Bumps Microsoft.EntityFrameworkCore.InMemory from 10.0.11 to 10.0.12
Bumps Microsoft.EntityFrameworkCore.Relational from 10.0.11 to 10.0.12
Bumps Microsoft.EntityFrameworkCore.Sqlite from 10.0.11 to 10.0.12
Bumps Microsoft.Extensions.ApiDescription.Server from 10.0.11 to 10.0.12
Bumps Microsoft.Extensions.Caching.Abstractions from 10.0.11 to 10.0.12
Bumps Microsoft.Extensions.Caching.Memory from 10.0.11 to 10.0.12
Bumps Microsoft.Extensions.Caching.StackExchangeRedis from 10.0.11 to 10.0.12
Bumps Microsoft.Extensions.Http from 10.0.11 to 10.0.12
Bumps Microsoft.IdentityModel.JsonWebTokens from 8.22.0 to 8.23.0
Bumps Microsoft.NET.Test.Sdk from 18.9.0 to 18.10.1
Bumps OpenAI from 2.13.0 to 2.14.0
Bumps PostHog from 2.14.0 to 2.15.7
Bumps PostHog.AspNetCore from 2.9.0 to 2.9.7
Bumps Scalar.AspNetCore from 2.17.1 to 2.17.9
Bumps Sentry.AspNetCore from 6.9.0 to 6.11.1
Bumps Stripe.net from 52.3.0 to 52.4.2

---
updated-dependencies:
- dependency-name: Google.Apis.AndroidPublisher.v3
  dependency-version: 1.76.0.4277
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Hangfire.AspNetCore
  dependency-version: 1.8.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Hangfire.Core
  dependency-version: 1.8.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Hangfire.Core
  dependency-version: 1.8.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.AspNetCore.Authentication.JwtBearer
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.AspNetCore.OpenApi
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore.Design
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore.Relational
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.Extensions.ApiDescription.Server
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.Extensions.Caching.Abstractions
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.Extensions.Caching.StackExchangeRedis
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.Extensions.Http
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.IdentityModel.JsonWebTokens
  dependency-version: 8.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: OpenAI
  dependency-version: 2.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: PostHog
  dependency-version: 2.15.7
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: PostHog.AspNetCore
  dependency-version: 2.9.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Scalar.AspNetCore
  dependency-version: 2.17.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Sentry.AspNetCore
  dependency-version: 6.11.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Stripe.net
  dependency-version: 52.4.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: FluentAssertions
  dependency-version: 8.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore.InMemory
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.Extensions.Caching.Memory
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: FluentAssertions
  dependency-version: 8.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: FluentAssertions
  dependency-version: 8.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore.InMemory
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore.Sqlite
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Give API key management one authorization concept with two doors (#528)

* Start #529 API key step up

* Require API key management step up

* chore: regenerate the architecture map

The step up change moved the API key endpoints' shape, so `architecture.json`
and `architecture.html` no longer matched the tree and the `drift` check
failed on `git diff --exit-code`. Regenerated with `node tools/arch-map.mjs`,
which reports 45 entities and 0 untested feature folders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Give API key management one authorization concept with two doors

Listing, creating and revoking API keys now read one grant. Two doors open
it, and both end in a six-digit code emailed to the account owner: the HTTP
challenge, and a verified agent step-up.

Creating key material spends the grant. Listing and revoking only read it,
so one emailed code authorizes a management session instead of exactly one
revoke. A revoke no longer locks the person out of the key list.

get_api_keys gets a door. RequireApiKeyCreationStepUp raises the read
capability to a step-up inside the executor, so the read opens a pending
operation the caller can step up against, and the MCP read routes through
McpExecutorBridge like the mutation does.

AppConfigService now parses a stored row strictly and throws by name when a
row is malformed, so a typo cannot leave the gate off while the read-back
reports it on.

Both controller actions declare 403 and 428, and openapi.json carries them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Cover API key step up through merged MCP gate

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Expose habit emoji in MCP tools (#513) (#546)

* Open ticket 513 implementation review

* Expose habit emoji across MCP tools (#513)

* Prevent stale pull request workflow runs for #659 (#555)

* Start #659 workflow concurrency change

* Group core PR workflows by pull request for #659

* Group remaining PR workflows by pull request for #659

* Fix #657 logout revocation across a session family (#553)

* Start #657 session family fix

* Revoke the full auth session after logout (#657)

* Explain temporary legacy token allowance (#657)

* Handle duplicate refresh history race (#657)

* Fix #588 bulk habit interval weeks (#539)

* Start #588 bulk interval weeks fix

* Fix #588 bulk habit interval weeks mapping

* Guarantee crisis resources in Astra chat for #319 (#547)

* Add crisis guidance to Astra static prompt for #319

* Add curated crisis detection and regression cases for #319

* Wire crisis guard across chat delivery and metrics for #319

* Refine crisis delivery and cover fallback regression for #319

* Return static crisis support when AI chat fails for #319

* Keep crisis FAQ regression isolated for #319

* Fix crisis replies to bypass AI quota and provider

* Record streak freeze source for #571 (#540)

* Start #571 freeze source work

* Record streak freeze origin for #571

* Require explicit origin for new streak freezes

* Keep support tool available for support entry point (#541)

* Expose scheduled streak repair gap dates for #505 (#542)

* Fix standalone sub habit title validation for #628 (#543)

* Start #628 sub habit title validation fix

* Use sub habit title messages in standalone validation (#628)

* Carry the onboarding repeat interval into the created habit (#596) (#533)

A signed-out person who set a weekly repeat interval during onboarding lost
it at sign-up. ApplyHabitInput carried no interval field at all, so the apply
path dropped the value between the screen and the record.

Add an optional IntervalWeeks to ApplyHabitInput, additive only, and pass it
to Habit.Create alongside the other schedule options. No existing field
changes name, shape or nullability, so a client that sends nothing keeps the
behaviour it has today: IntervalWeeks stays null and the habit repeats every
week.

Validate the new field with the shared SharedHabitRules.AddIntervalWeeksRules
range, the same 1 to 52 bound every other create path uses.

Regenerate openapi.json and architecture.json/html for the new field and for
the test class that now touches HabitScheduleService.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* Expose recurrence time zone in calendar events for #591 (#544)

* Start #591 recurrence time zone work

* Expose recurring calendar event time zone for #591

* Restore recurrence time zone in legacy suggestions for #591

* Project safe calendar BYDAY recurrences (#569) (#545)

* Start calendar BYDAY projection (#569)

* Project uniform calendar BYDAY recurrences (#569)

* Cover alternate week recurrence projection (#569)

* Keep shifted calendar rules importable by installed clients

* Add closed week and year recap ranges for #369 (#548)

* Fix Google sign-in after concurrent user updates (#551)

* chore: start ticket 324 review

* fix: retry concurrent Google sign-in updates (#324)

* Fix duplicate agent confirmation consumption (#557)

* Start fix for ticket 671 confirmation race

* Claim agent confirmation once under concurrent saves

* chore: regenerate the architecture map for the confirmation claim migration (#671)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix: show newest habit completion date in profile (#549)

* chore: start ticket 391

* fix: expose latest habit completion date in profile

* fix: exclude bad habit slips from last completion date

* fix: pass the freeze origin in the profile freeze test after #540 (#391)

The merge with main brought StreakFreeze.Create's required origin
parameter (#540). The test now asserts that a freeze without a
completion leaves lastCompletionDate null for both origins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: preserve last completion across habit cleanup (#391)

* Preserve descendant completions during sync cleanup

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix: resolve stored recap week anchors without the current preference

A closed week anchor lives in the recap share link and in the stored
snapshot key. Validating it against today's WeekStartDay rejected a
Monday anchor after the person moved to Sunday, before the stored
snapshot could be read. Accept any supported week start (Sunday or
Monday) for an explicit closed anchor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
thomasluizon added a commit that referenced this pull request Sep 25, 2026
…20) (#532)

* Add habit widget empty reason (#527)

* Start #372

* Add habit widget empty reason

* ORB-223: Generate gating matrix from PayGateService (#520)

* chore: start ORB-223

* feat: generate gating matrix

* fix: fail closed on unreadable plan gates

* fix: close gating matrix parser gaps

* fix: preserve plans in combined guards

* fix: distinguish quota lifted plans

* fix: carry plan provenance through quota aliases

Derived quota variables were recorded unscoped and only the variable that
literally contains the plan ternary was relabelled afterwards, so plan
provenance did not survive a local alias. A semantics-preserving
`var selectedLimit = user.HasProAccess ? proLimit : freeLimit;
var messageLimit = selectedLimit;` generated cleanly and changed
CanSendAiMessage.quotaLiftedByPlan from "Pro" to null.

The directly plan-selected variables are now labelled first, and the
fixed-point propagation carries the source variable's exact plan instead of
null. An assignment deriving from two different plans cannot have its
provenance proven, so generation fails closed with the same
"cannot derive plan requirement" error the other unprovable shapes use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: fail closed on unknown gating sources

* fix: derive feature flags from migrations

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: fall back to Claude only when the Codex reviewer step fails (#529)

Mirrors thomasluizon/orbit-ui-mobile#1022.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Redesign the transactional emails, AI push copy and error messages

Closes thomasluizon/orbit-tickets#75 (R20).

Emails. All 7 types plus the shared wrapper move to the granted canon: the
violet #7F46F7 becomes the warm orange #C4530F, the gradient header band and
every CTA glow are gone (DESIGN.md bans both), Rubik and Inter become the
Geist Sans and Geist Mono stacks, and the planet emoji and sparkle bullets
give way to the mark and a plain numbered list. Emails now author light and
override dark through prefers-color-scheme, which matches "light mode
MANDATORY, dark primary" and survives a forced inversion far better than the
dark only document did. Copy is rewritten in both languages to say what
happened before what to do next. Support copy moves out of its template into
EmailCopy so markup holds no strings.

AI push copy. The slip alert, proactive check in and daily summary prompts
demonstrated an exclamation mark and a doubled hyphen inside their own worked
examples, and at temperature 0.9 the model reproduced both. One shared voice
contract, NotificationVoice, now states each ban and carries no example at
all. The fallback strings carried the same two tells and are rewritten.

Error messages. A domain guard firing is an ordinary event, but its message
is the sentence a developer wrote, and on 2026-08-05 a tester read "Days can
only be set when frequency quantity is 1." verbatim, in English, inside a
pt-BR interface. ErrorCopy now maps every error code to user facing copy in
both languages, and one result filter swaps the message at the response
boundary. DomainErrors stays developer facing, as the ticket requires. The
catalog is total over ErrorCodes, ErrorMessages and DomainErrors, and a
reflection test fails the build when a constant arrives without copy, so the
raw message fallback is unreachable. ErrorMessages now takes its English from
the same catalog rather than repeating 145 sentences in a second file.

AppError and Result carry the arguments behind a {0} placeholder so the
localized template can be formatted with the same values, which the eagerly
formatted message alone no longer exposed.

Also removed: five pay gate strings ending in "Upgrade to unlock!", a banned
word and an exclamation on a sell; inlined copies of error sentences in three
chat tools; and a hardcoded 500 envelope message.

Tests: 6980 passing, 0 failing. Line coverage 86.40%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Build the banned dashes from code points and refresh the architecture map

Two red checks on PR 532 at c50f052.

Dash Ban. The three new voice tests detected a banned dash by embedding one,
and the gate scans a test file like any other. The two constants are now built
from their code points, ((char)0x2014) and ((char)0x2013), so no literal
character sits in the source. The escape form these files were written with
did not survive: the lefthook pre-commit step runs dotnet format, which
normalized "—" back into the raw character before the commit landed. A
code point arithmetic expression has no literal for a formatter to normalize.

The assertions are unchanged. Verified by injecting a real em dash into one
ErrorCopy entry and one EmailCopy string: EveryEntryObeysTheErrorVoice and
NoEmailStringCarriesADashCharacter both went red, then green again once the
injection was reverted.

drift. architecture.json and architecture.html were stale against the new
test classes and the changed file counts. Regenerated with node
tools/arch-map.mjs and committed here, in the change that requires them. The
generator is deterministic: two consecutive runs are byte identical.

tools/dash-baseline.json is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Fix the four blocking findings on the email and error copy pass

C1: the deletion email promised a permanent wipe while the product only
deactivates. The intro now states the grace window, read from
AppConstants.MaxDeletionGraceDays, and that signing in again cancels it.

C2: the copy pass had moved the wrong-code failure onto a new error code
both shipped clients have no branch for, which sends Orbit 1.3.31 and the
web step-up screen to a generic error. The code returns to
INVALID_VERIFICATION_CODE, and the count rides in a counted copy variant
that keeps the trailing token those clients parse.

C3: the reply language came from Accept-Language alone, which neither the
Android app nor the web Server Actions send, so every signed-in pt-BR
reader got English. RequestLanguageResolver reads the stored
User.Language first and keeps the header as the anonymous fallback. The
header parse now honours quality values.

C4: the slip alert claimed the usual time had arrived, while the
scheduler sends two hours early and at 08:00 when no peak hour exists.
The prompt states when the push lands and the fallback makes no time
claim without a peak hour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Make the copy claims match the code that produces them

C5: the check-in fallback called one open habit "a few" and promised that
Astra records the rest. The scheduler sends as soon as one habit is off
track, and LogHabitTool records only the habit a person names.

C6: the 429 body was an anonymous object the localizing filter could not
match, so the RATE_LIMITED copy was unreachable. It is built from
ErrorMessages.TooManyRequests now, which also gives the 429 an error code
for the first time. Retry-After and the request id already ride in
headers.

C8: the sync copy promised a reload and a later send that no caller
performs. C9: a challenge does not take part, people do. C10: the recap
arrives only for a reader with a push subscription, so the copy points at
coming back instead.

D1 and D4: two doc comments claimed more than the code does. The filter
covers ErrorResponse bodies only, and a FluentValidation message never
reaches it. A missing catalog entry throws on the first request, not at
startup.

D3: BuildSummaryPrompt is internal and joins both voice theory sources,
so the guard written for its doubled-hyphen leak now reaches it.

D5: AppError compared Args by reference, so two errors formatted with the
same count came out unequal. Equality walks the arguments now.

The three voice tests that read NotificationVoice.Rules' own literals
back out of it now assert the bans over every prompt a model receives.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Refresh the architecture map for the review round

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Let the executor own the MCP confirmation gate (#599) (#534)

* fix: let the executor own the MCP confirmation gate (#599)

Every MCP tool whose capability requires a confirmation or a step-up was
refused forever. The selective-auth middleware evaluated the policy a second
time, in front of the executor, and it never received the caller's
confirmation token. A client that stepped up and retried with a valid token
got the identical refusal, because the token never reached that evaluation.

Carrying the token into the middleware cannot fix it. A confirmation token is
single use and is bound to the pending operation's fingerprint, and the
middleware computes a different fingerprint from the raw MCP arguments than
the executor computes from the operation id and its snake_case argument
object. One token cannot satisfy two gates.

So the middleware now steps aside for a confirmation-gated capability, the
same way it already steps aside for execute_agent_operation_v2. Both reach
IAgentOperationExecutor, which evaluates access and confirmation together,
holds the token, and writes the audit row.

Two guard tests pin the invariants the deferral rests on: a confirmation
requirement always sits on a mutation, and every confirmation-gated MCP tool
reaches the executor through McpExecutorBridge.

Also corrected: the step-up message and the three tool parameter descriptions
named verify_step_up_agent_operation_v2 as the source of the confirmation
token. It does not return one. confirm_agent_operation_v2 does.

Removed AgentPolicyEvaluationContext.StepUpSatisfied. Nothing ever set it and
nothing ever read it; the real step-up state lives on
PendingAgentOperationState.StepUpSatisfiedAtUtc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: thread the confirmation token through the bulk habit tools (#599)

bulk_log_habits and bulk_skip_habits declared no confirmationToken
parameter and their shared helper hardcoded confirmationToken: null, so
their HabitsBulkWrite capability (FreshConfirmation) could never see a
token and both tools stayed permanently refused. Both now declare the
parameter and ExecuteBulkHabitOperationAsync forwards it.

Three new guards in ConfirmationGatedMcpToolsRouteThroughExecutorTests
pin what the old one missed. The scan now asserts it reaches exactly the
catalog's gated MCP tools, that each tool's forwarded operation id
resolves to a capability with the same ConfirmationRequirement, and that
each tool accepts and forwards a confirmation token. The source scan
walks subfolders and keys members by their declaration rather than by the
first invocation-shaped token in the chunk.

AgentOperationExecutor writes an AgentAuditLogs row before returning
UnknownOperation, restoring the trail the middleware used to leave.

McpConfirmationGateTests now drives the real AgentTools recovery methods
end to end and pins that all three refuse an API-key credential.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: resolve the forwarded MCP confirmation token back to the tool parameter (#599)

The token guard whitelisted one spelling of null. It flagged a bridge call only
when the confirmation-token argument was absent or the literal `null`, so
`confirmationToken: default`, `null!`, `(string?)null` and `""` all passed while
the tool stayed refused forever. A longer literal list does not close that,
because the next spelling is not on the list either.

Assert the structural property instead: a gated tool declares a
`string? confirmationToken` parameter, and the expression it forwards into the
executor's token slot resolves back to that identifier, directly or through one
helper hop. Every other expression fails, whatever it spells.

The declaration check now reads the tool's parameter list rather than its whole
body, so a local named `confirmationToken` no longer satisfies it.

Also add `forwarded.Id == tool.Capability.Id` to the operation-id guard. It
compared only the confirmation requirement, so a gated tool could forward
another gated capability's operation id, keep confirmation firing, and have the
executor enforce the wrong scope.

The file's doc comment said four invariants and listed four; the file holds
five. Name the source-scan invariant the other three rest on.

Six mutations, each red on its own, real tree green at every step:
`confirmationToken: default`, `null!`, `(string?)null`, `""`, a gated tool
forwarding a different local, and `delete_tag` forwarding `delete_goal`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: strip only a real named-argument prefix in the MCP guard parser (#599)

ArgumentAt treated any colon as a named-argument separator, so a conditional
expression in the token slot or the operation-id slot collapsed to its
else-branch. `ids.Count > 0 ? null : confirmationToken` read as
`confirmationToken` and `tagId.Length > 0 ? "delete_goal" : "delete_tag"` read
as `"delete_tag"`, and both guards stayed green over the restored defect.

Match `^\w+\s*:(?!:)` instead, so only a leading `name:` prefix is stripped and
every other expression reaches the resolver whole. The anchor keeps a colon
inside a string literal and a `::` qualifier from matching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: separate an omitted caller argument from a non-parameter expression (#599)

ResolveExpression returned the expression itself both when it was not a callee
parameter and when the caller supplied nothing at that index. The second case
handed back the helper's own parameter name, which then compared equal to
`confirmationToken` and passed. Giving the helper an optional
`string? confirmationToken = null` and calling it without that argument restored
the #599 defect with the guard green.

Return the `<omitted>` sentinel for the second case instead. It can never equal
the parameter name, and it resolves to no capability in the operation-id slot,
so both guards fail closed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: read a gated tool's direct and helper bridge calls together (#599)

CollectBridgeCalls returned early as soon as the tool body held one bridge
call, so a correct call in a dead branch hid every helper call in the same
tool. A decoy `if (tagId.Length == 0)` block forwarding `confirmationToken`
plus a same-file helper forwarding `null` restored the #599 defect with the
guard green.

Collect both sets instead. Every bridge call a gated tool can reach, directly
or through one helper hop, now has to forward the parameter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: refuse a gated tool that assigns to its own confirmationToken (#599)

The rule compared the forwarded expression's text only, so
`confirmationToken = null;` above the bridge call restored the #599 defect with
the forwarded identifier unchanged and the guard green.

Add an offender when the tool body, or a same-file helper it reaches, assigns
to the parameter. MemberSource now carries its statements with the parameter
list excluded, so the declaration's own `string? confirmationToken = null`
default does not match. The pattern `\bconfirmationToken\s*=[^=>]` leaves `==`,
`!=`, `>=`, `<=`, `??=` and a lambda arrow alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: say what the MCP token resolver models, not more (#599)

Invariant 5 claimed "any other expression in the token slot refuses the tool
forever" and that the tool forwards the identifier "directly or through a
helper it calls". A conditional refuses the tool on some paths only, and the
resolver models exactly one same-file helper hop matched by argument position.

Replace both sentences with what the scan actually does, and state its limits:
two or more hops read as no bridge call and fail the routing guard, and
aliasing, reflection and an interface call are outside the model.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: catch a compound assignment to confirmationToken (#599)

The assignment rule was anchored on `\s*=`, and `\s*` cannot cross the `??`
of `??=` or the `+` of `+=`. So `confirmationToken ??= tagId;` above the
bridge call compiled green with all five guards green, and a later change
that writes `confirmationToken ??= await ResolveStoredTokenAsync(...)` would
hand the executor a token the MCP caller never sent: HasFreshConfirmation is
then satisfied from state the caller does not control, while the middleware
has already stepped aside.

Admit the two compound operators a `string?` can carry. `==` still fails on
the second `=`, `!=`, `>=` and `<=` still fail because those characters break
`\s*` and are not in the alternation, `=>` still fails on the trailing
character class, and a bare `??` with no `=` still fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: read every same-named helper an MCP tool could be calling (#599)

The member index was a dictionary keyed on the bare name and built with
group.First(), so two same-named members collapsed to whichever was declared
first. The scan then analysed a method the tool never calls. It broke both
ways: a decoy overload declared after the real helper and forwarding null was
green, and a gated tool calling a correct overload reddened when an unrelated
same-named member happened to be declared first.

Hold every member under its name and, at each call site, read every candidate
whose parameter count can admit that many arguments. One candidate resolves
the call; more than one is ambiguous, so all of them are read and any bad one
reddens the tool. That fails closed instead of guessing, and it costs no false
red in the case above, where only the real helper carries a bridge call. A
name that admits no candidate stays unresolved, which hides nothing: the
bridge call inside it is not collected either, so the routing guard reddens
the tool.

Arity, not the parameter type, is what decides a candidate. The scan is a text
scan, so it cannot type-check an argument.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: say what invariant 5 now refuses and what it still cannot see (#599)

The general claim "refuses a tool that assigns to the parameter" is now exact:
`=`, `??=` and `+=`. The helper hop no longer claims a "matching argument
position", because the resolver pairs by position alone and strips a
named-argument prefix without reading the name, so arguments named out of the
declared order are read wrong. The comment said nothing about overloads, so
say that an ambiguous name is read as every candidate and reddens the tool.

Close with the honest limit: a text scan is defeatable by an author who sets
out to defeat it, and what the guard closes is every shape an ordinary
refactor produces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* Fix calendar event timezone projection (#521)

* Start calendar timezone fix

* Fix calendar event timezone projection

* Fix projected calendar end times

* Fix calendar end time during DST fallback

* Fix calendar end time minute precision

* Fix projected calendar recurrence rule

* Guard calendar recurrence across timezone shifts

* Project calendar recurrence weekdays

* Keep calendar recurrence unchanged

* Omit unrepresentable recurring calendar events

* Gate projected calendar recurrence on the whole fetch window

Closes the six review findings on pull request 521.

1. The weekday gate now walks every expanded occurrence Google returned for a
   recurring master, not the one sampled instance. The fetcher collects each
   instance start with the source calendar's own offset into a JsonIgnore'd
   ExpandedOccurrences list, so a Lisbon BYDAY=TH series that is stable in
   January and shifts to Wednesday in July is refused. The empty list still
   falls back to the sampled instance for an all-day series and for a
   suggestion row read back from the database.
2. The legacy title plus date plus time key only excludes a suggestion when
   exactly one candidate carries it. Inside a fall-back repeated hour two
   events project to the same local time, so the key proves nothing and both
   stay. This mirrors the group.Count() == 1 guard the auto sync reconciler
   already applies to the same key.
3. An omitted end time now logs its reason at Debug with the event id and the
   user id. EndUtc already ships the real duration, so the client keeps it.
4. The refusal here and the clamp in HabitScheduleService.IsMonthlyMatch are
   reconciled in a doc comment: Orbit clamps a rule it owns and refuses a rule
   it imports and cannot re-express without inventing a weekday.
5. Both new call sites pass the logger and the user id to FindTimeZone, which
   also catches InvalidTimeZoneException so a corrupt zone stops escaping as a
   500. User.SetTimeZone trims at the boundary and rejects a blank id.
6. Asia/Kathmandu at plus 05:45 and Pacific/Chatham at plus 12:45 cover the
   sub hour offset gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Prove calendar recurrence stability from both zones

Round 6 gated a BYDAY series on the expanded instances Google returned, and
GoogleCalendarApi only asks for sixty days, so a series whose transition falls
outside that window was admitted and a stored suggestion row never carried the
instances at all.

The gate now reads the source calendar's own timezone from the recurring master
the fetcher already fetches, and walks a year of dates at the occurrence's source
wall clock through both zones' rules. A series it cannot prove stable is withheld
from both feeds. StoredCalendarEventJson carries the source zone beside the stored
suggestion, so the suggestion feed judges a row on the same evidence the events
feed had.

Also in scope: the auto-sync reconciler projects a fetched event into the account
timezone before matching a legacy habit, the end-time omission logs whatever EndUtc
holds, and an all-day event no longer reports Google's exclusive end date as an
end instant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Keep calendar series a spring forward gap cannot move

A probe date whose source wall clock a spring forward gap removes is a date
the series does not fire on, not evidence the series moves. The walk ended
there with "unproved" and the gate withheld the whole series, so an account
reading a calendar kept in its own zone lost an hour wide band in thirteen
DST zones even though the projection is the identity.

HasUnrepresentableRecurrenceAfterProjection now answers false as soon as
TimeZoneInfo.HasSameRules holds, and the walk skips a wall clock its own zone
removes instead of ending. RFC 5545 section 3.3.5 does define the missing
case, but probing that normalized instant changes no decision for any of the
14,752 zone pairs that could distinguish it, and asserting an expansion
Google does not document would let one guessed date withhold a year.

Also pins what earlier rounds argued: auto-sync refusing to store or notify a
withheld series, JsonIgnore keeping SourceTimeZone off the response body, the
repeated hour needing both of its instants, the probe reaching a full year,
and an all day event logging no dropped end time. A stored SourceTimeZone key
holding a number now reads as no source zone rather than failing the request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Fix recurring calendar timezone proof and legacy refresh

* Avoid refreshing legacy calendar rows with duplicate event IDs

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* Bump FluentAssertions and 23 others (#538)

Bumps FluentAssertions from 8.10.0 to 8.11.0
Bumps Google.Apis.AndroidPublisher.v3 from 1.75.0.4246 to 1.76.0.4277
Bumps Hangfire.AspNetCore from 1.8.24 to 1.8.25
Bumps Hangfire.Core from 1.8.24 to 1.8.25
Bumps Microsoft.AspNetCore.Authentication.JwtBearer from 10.0.11 to 10.0.12
Bumps Microsoft.AspNetCore.OpenApi from 10.0.11 to 10.0.12
Bumps Microsoft.EntityFrameworkCore from 10.0.11 to 10.0.12
Bumps Microsoft.EntityFrameworkCore.Design from 10.0.11 to 10.0.12
Bumps Microsoft.EntityFrameworkCore.InMemory from 10.0.11 to 10.0.12
Bumps Microsoft.EntityFrameworkCore.Relational from 10.0.11 to 10.0.12
Bumps Microsoft.EntityFrameworkCore.Sqlite from 10.0.11 to 10.0.12
Bumps Microsoft.Extensions.ApiDescription.Server from 10.0.11 to 10.0.12
Bumps Microsoft.Extensions.Caching.Abstractions from 10.0.11 to 10.0.12
Bumps Microsoft.Extensions.Caching.Memory from 10.0.11 to 10.0.12
Bumps Microsoft.Extensions.Caching.StackExchangeRedis from 10.0.11 to 10.0.12
Bumps Microsoft.Extensions.Http from 10.0.11 to 10.0.12
Bumps Microsoft.IdentityModel.JsonWebTokens from 8.22.0 to 8.23.0
Bumps Microsoft.NET.Test.Sdk from 18.9.0 to 18.10.1
Bumps OpenAI from 2.13.0 to 2.14.0
Bumps PostHog from 2.14.0 to 2.15.7
Bumps PostHog.AspNetCore from 2.9.0 to 2.9.7
Bumps Scalar.AspNetCore from 2.17.1 to 2.17.9
Bumps Sentry.AspNetCore from 6.9.0 to 6.11.1
Bumps Stripe.net from 52.3.0 to 52.4.2

---
updated-dependencies:
- dependency-name: Google.Apis.AndroidPublisher.v3
  dependency-version: 1.76.0.4277
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Hangfire.AspNetCore
  dependency-version: 1.8.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Hangfire.Core
  dependency-version: 1.8.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Hangfire.Core
  dependency-version: 1.8.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.AspNetCore.Authentication.JwtBearer
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.AspNetCore.OpenApi
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore.Design
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore.Relational
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.Extensions.ApiDescription.Server
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.Extensions.Caching.Abstractions
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.Extensions.Caching.StackExchangeRedis
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.Extensions.Http
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.IdentityModel.JsonWebTokens
  dependency-version: 8.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: OpenAI
  dependency-version: 2.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: PostHog
  dependency-version: 2.15.7
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: PostHog.AspNetCore
  dependency-version: 2.9.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Scalar.AspNetCore
  dependency-version: 2.17.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Sentry.AspNetCore
  dependency-version: 6.11.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Stripe.net
  dependency-version: 52.4.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: FluentAssertions
  dependency-version: 8.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore.InMemory
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.Extensions.Caching.Memory
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: FluentAssertions
  dependency-version: 8.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: FluentAssertions
  dependency-version: 8.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore.InMemory
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore.Sqlite
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Give API key management one authorization concept with two doors (#528)

* Start #529 API key step up

* Require API key management step up

* chore: regenerate the architecture map

The step up change moved the API key endpoints' shape, so `architecture.json`
and `architecture.html` no longer matched the tree and the `drift` check
failed on `git diff --exit-code`. Regenerated with `node tools/arch-map.mjs`,
which reports 45 entities and 0 untested feature folders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Give API key management one authorization concept with two doors

Listing, creating and revoking API keys now read one grant. Two doors open
it, and both end in a six-digit code emailed to the account owner: the HTTP
challenge, and a verified agent step-up.

Creating key material spends the grant. Listing and revoking only read it,
so one emailed code authorizes a management session instead of exactly one
revoke. A revoke no longer locks the person out of the key list.

get_api_keys gets a door. RequireApiKeyCreationStepUp raises the read
capability to a step-up inside the executor, so the read opens a pending
operation the caller can step up against, and the MCP read routes through
McpExecutorBridge like the mutation does.

AppConfigService now parses a stored row strictly and throws by name when a
row is malformed, so a typo cannot leave the gate off while the read-back
reports it on.

Both controller actions declare 403 and 428, and openapi.json carries them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Cover API key step up through merged MCP gate

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Expose habit emoji in MCP tools (#513) (#546)

* Open ticket 513 implementation review

* Expose habit emoji across MCP tools (#513)

* Prevent stale pull request workflow runs for #659 (#555)

* Start #659 workflow concurrency change

* Group core PR workflows by pull request for #659

* Group remaining PR workflows by pull request for #659

* Fix #657 logout revocation across a session family (#553)

* Start #657 session family fix

* Revoke the full auth session after logout (#657)

* Explain temporary legacy token allowance (#657)

* Handle duplicate refresh history race (#657)

* Fix #588 bulk habit interval weeks (#539)

* Start #588 bulk interval weeks fix

* Fix #588 bulk habit interval weeks mapping

* Guarantee crisis resources in Astra chat for #319 (#547)

* Add crisis guidance to Astra static prompt for #319

* Add curated crisis detection and regression cases for #319

* Wire crisis guard across chat delivery and metrics for #319

* Refine crisis delivery and cover fallback regression for #319

* Return static crisis support when AI chat fails for #319

* Keep crisis FAQ regression isolated for #319

* Fix crisis replies to bypass AI quota and provider

* Record streak freeze source for #571 (#540)

* Start #571 freeze source work

* Record streak freeze origin for #571

* Require explicit origin for new streak freezes

* Refactor locale selection and notification fallbacks

* Keep support tool available for support entry point (#541)

* Expose scheduled streak repair gap dates for #505 (#542)

* Fix standalone sub habit title validation for #628 (#543)

* Start #628 sub habit title validation fix

* Use sub habit title messages in standalone validation (#628)

* Carry the onboarding repeat interval into the created habit (#596) (#533)

A signed-out person who set a weekly repeat interval during onboarding lost
it at sign-up. ApplyHabitInput carried no interval field at all, so the apply
path dropped the value between the screen and the record.

Add an optional IntervalWeeks to ApplyHabitInput, additive only, and pass it
to Habit.Create alongside the other schedule options. No existing field
changes name, shape or nullability, so a client that sends nothing keeps the
behaviour it has today: IntervalWeeks stays null and the habit repeats every
week.

Validate the new field with the shared SharedHabitRules.AddIntervalWeeksRules
range, the same 1 to 52 bound every other create path uses.

Regenerate openapi.json and architecture.json/html for the new field and for
the test class that now touches HabitScheduleService.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* Expose recurrence time zone in calendar events for #591 (#544)

* Start #591 recurrence time zone work

* Expose recurring calendar event time zone for #591

* Restore recurrence time zone in legacy suggestions for #591

* Project safe calendar BYDAY recurrences (#569) (#545)

* Start calendar BYDAY projection (#569)

* Project uniform calendar BYDAY recurrences (#569)

* Cover alternate week recurrence projection (#569)

* Keep shifted calendar rules importable by installed clients

* Add closed week and year recap ranges for #369 (#548)

* Fix Google sign-in after concurrent user updates (#551)

* chore: start ticket 324 review

* fix: retry concurrent Google sign-in updates (#324)

* Fix duplicate agent confirmation consumption (#557)

* Start fix for ticket 671 confirmation race

* Claim agent confirmation once under concurrent saves

* chore: regenerate the architecture map for the confirmation claim migration (#671)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix: show newest habit completion date in profile (#549)

* chore: start ticket 391

* fix: expose latest habit completion date in profile

* fix: exclude bad habit slips from last completion date

* fix: pass the freeze origin in the profile freeze test after #540 (#391)

The merge with main brought StreakFreeze.Create's required origin
parameter (#540). The test now asserts that a freeze without a
completion leaves lastCompletionDate null for both origins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: preserve last completion across habit cleanup (#391)

* Preserve descendant completions during sync cleanup

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix: drop the duplicate workflow concurrency keys the merge-forward created

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
thomasluizon added a commit that referenced this pull request Sep 25, 2026
…531)

* Add habit widget empty reason (#527)

* Start #372

* Add habit widget empty reason

* ORB-223: Generate gating matrix from PayGateService (#520)

* chore: start ORB-223

* feat: generate gating matrix

* fix: fail closed on unreadable plan gates

* fix: close gating matrix parser gaps

* fix: preserve plans in combined guards

* fix: distinguish quota lifted plans

* fix: carry plan provenance through quota aliases

Derived quota variables were recorded unscoped and only the variable that
literally contains the plan ternary was relabelled afterwards, so plan
provenance did not survive a local alias. A semantics-preserving
`var selectedLimit = user.HasProAccess ? proLimit : freeLimit;
var messageLimit = selectedLimit;` generated cleanly and changed
CanSendAiMessage.quotaLiftedByPlan from "Pro" to null.

The directly plan-selected variables are now labelled first, and the
fixed-point propagation carries the source variable's exact plan instead of
null. An assignment deriving from two different plans cannot have its
provenance proven, so generation fails closed with the same
"cannot derive plan requirement" error the other unprovable shapes use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: fail closed on unknown gating sources

* fix: derive feature flags from migrations

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: fall back to Claude only when the Codex reviewer step fails (#529)

Mirrors thomasluizon/orbit-ui-mobile#1022.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Resolve every stored colour scheme to the granted accent

The design decision that granted one warm orange accent deleted colour as
data, but the API still reported whichever of the six historical schemes an
account had stored. An account carrying "rose" rendered rose.

This is the deploy-API-first half, and it is append-only. The colorScheme
field stays in every DTO, the set_color_scheme operation stays, the six
values stay writable, and no migration touches a stored row. Only the read
paths change: GetProfileQuery, ExportUserDataQuery and the Astra context
snapshot now report ColorSchemes.Granted instead of the stored value, so an
existing account renders warm orange on a shipped client with no app update.

The stored column becomes write-only on purpose. Keeping the writes means
reverting this commit restores the old behaviour with every value intact.

The two agent tools stopped echoing the requested value back, because
reporting "rose" to a model that then tells the user their accent is rose is
now false. Their descriptions say the same thing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Collapse the colour scheme write and keep the export truthful

Round 2 review on pull request 531 found the read and the write disagreeing.
GetProfileQuery answered the granted accent while SetColorSchemeCommand still
stored the requested key, so a shipped client's tap stored "blue", refetched
"orange" and snapped back.

User.SetColorScheme now still accepts every historical key, so an old client
never sees a 400, and stores ColorSchemes.Granted instead of the request. A
null request still clears the preference, because no read surfaces the stored
value and writing one on a clear would only add data nobody reads.

ExportUserDataQuery goes back to user.ColorScheme. A subject-access export
answers what the database holds, and no migration rewrites rows written before
the collapse, so those rows still report their historical key.

AgentContextSnapshot drops ColorScheme. Every account resolved to the same
constant, so the prompt line carried no information and the nullable field
could no longer be produced.

ColorSchemes.AcceptedValues becomes a FrozenSet so the domain validation rule
cannot be rewritten through the public static reference.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Make the agent catalog agree with the data export

The data catalog told Astra that every account reads back the same colour
scheme, while the export on the same field returns the raw column on purpose.
A row written before the collapse still holds its own key, so the two surfaces
answered one question two ways.

The catalog now states both halves: the profile reads back the granted value
and a pre-collapse row keeps its original key. The sweep it asked for found no
twin carrying the same claim, and a test over every catalog entry, capability,
surface, chat tool and MCP tool description now keeps it that way.

Three write-side sentences said "store the user's colour scheme preference"
while the write stores the granted value. They now say the value is accepted
and the stored one becomes the granted one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Let the executor own the MCP confirmation gate (#599) (#534)

* fix: let the executor own the MCP confirmation gate (#599)

Every MCP tool whose capability requires a confirmation or a step-up was
refused forever. The selective-auth middleware evaluated the policy a second
time, in front of the executor, and it never received the caller's
confirmation token. A client that stepped up and retried with a valid token
got the identical refusal, because the token never reached that evaluation.

Carrying the token into the middleware cannot fix it. A confirmation token is
single use and is bound to the pending operation's fingerprint, and the
middleware computes a different fingerprint from the raw MCP arguments than
the executor computes from the operation id and its snake_case argument
object. One token cannot satisfy two gates.

So the middleware now steps aside for a confirmation-gated capability, the
same way it already steps aside for execute_agent_operation_v2. Both reach
IAgentOperationExecutor, which evaluates access and confirmation together,
holds the token, and writes the audit row.

Two guard tests pin the invariants the deferral rests on: a confirmation
requirement always sits on a mutation, and every confirmation-gated MCP tool
reaches the executor through McpExecutorBridge.

Also corrected: the step-up message and the three tool parameter descriptions
named verify_step_up_agent_operation_v2 as the source of the confirmation
token. It does not return one. confirm_agent_operation_v2 does.

Removed AgentPolicyEvaluationContext.StepUpSatisfied. Nothing ever set it and
nothing ever read it; the real step-up state lives on
PendingAgentOperationState.StepUpSatisfiedAtUtc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: thread the confirmation token through the bulk habit tools (#599)

bulk_log_habits and bulk_skip_habits declared no confirmationToken
parameter and their shared helper hardcoded confirmationToken: null, so
their HabitsBulkWrite capability (FreshConfirmation) could never see a
token and both tools stayed permanently refused. Both now declare the
parameter and ExecuteBulkHabitOperationAsync forwards it.

Three new guards in ConfirmationGatedMcpToolsRouteThroughExecutorTests
pin what the old one missed. The scan now asserts it reaches exactly the
catalog's gated MCP tools, that each tool's forwarded operation id
resolves to a capability with the same ConfirmationRequirement, and that
each tool accepts and forwards a confirmation token. The source scan
walks subfolders and keys members by their declaration rather than by the
first invocation-shaped token in the chunk.

AgentOperationExecutor writes an AgentAuditLogs row before returning
UnknownOperation, restoring the trail the middleware used to leave.

McpConfirmationGateTests now drives the real AgentTools recovery methods
end to end and pins that all three refuse an API-key credential.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: resolve the forwarded MCP confirmation token back to the tool parameter (#599)

The token guard whitelisted one spelling of null. It flagged a bridge call only
when the confirmation-token argument was absent or the literal `null`, so
`confirmationToken: default`, `null!`, `(string?)null` and `""` all passed while
the tool stayed refused forever. A longer literal list does not close that,
because the next spelling is not on the list either.

Assert the structural property instead: a gated tool declares a
`string? confirmationToken` parameter, and the expression it forwards into the
executor's token slot resolves back to that identifier, directly or through one
helper hop. Every other expression fails, whatever it spells.

The declaration check now reads the tool's parameter list rather than its whole
body, so a local named `confirmationToken` no longer satisfies it.

Also add `forwarded.Id == tool.Capability.Id` to the operation-id guard. It
compared only the confirmation requirement, so a gated tool could forward
another gated capability's operation id, keep confirmation firing, and have the
executor enforce the wrong scope.

The file's doc comment said four invariants and listed four; the file holds
five. Name the source-scan invariant the other three rest on.

Six mutations, each red on its own, real tree green at every step:
`confirmationToken: default`, `null!`, `(string?)null`, `""`, a gated tool
forwarding a different local, and `delete_tag` forwarding `delete_goal`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: strip only a real named-argument prefix in the MCP guard parser (#599)

ArgumentAt treated any colon as a named-argument separator, so a conditional
expression in the token slot or the operation-id slot collapsed to its
else-branch. `ids.Count > 0 ? null : confirmationToken` read as
`confirmationToken` and `tagId.Length > 0 ? "delete_goal" : "delete_tag"` read
as `"delete_tag"`, and both guards stayed green over the restored defect.

Match `^\w+\s*:(?!:)` instead, so only a leading `name:` prefix is stripped and
every other expression reaches the resolver whole. The anchor keeps a colon
inside a string literal and a `::` qualifier from matching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: separate an omitted caller argument from a non-parameter expression (#599)

ResolveExpression returned the expression itself both when it was not a callee
parameter and when the caller supplied nothing at that index. The second case
handed back the helper's own parameter name, which then compared equal to
`confirmationToken` and passed. Giving the helper an optional
`string? confirmationToken = null` and calling it without that argument restored
the #599 defect with the guard green.

Return the `<omitted>` sentinel for the second case instead. It can never equal
the parameter name, and it resolves to no capability in the operation-id slot,
so both guards fail closed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: read a gated tool's direct and helper bridge calls together (#599)

CollectBridgeCalls returned early as soon as the tool body held one bridge
call, so a correct call in a dead branch hid every helper call in the same
tool. A decoy `if (tagId.Length == 0)` block forwarding `confirmationToken`
plus a same-file helper forwarding `null` restored the #599 defect with the
guard green.

Collect both sets instead. Every bridge call a gated tool can reach, directly
or through one helper hop, now has to forward the parameter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: refuse a gated tool that assigns to its own confirmationToken (#599)

The rule compared the forwarded expression's text only, so
`confirmationToken = null;` above the bridge call restored the #599 defect with
the forwarded identifier unchanged and the guard green.

Add an offender when the tool body, or a same-file helper it reaches, assigns
to the parameter. MemberSource now carries its statements with the parameter
list excluded, so the declaration's own `string? confirmationToken = null`
default does not match. The pattern `\bconfirmationToken\s*=[^=>]` leaves `==`,
`!=`, `>=`, `<=`, `??=` and a lambda arrow alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: say what the MCP token resolver models, not more (#599)

Invariant 5 claimed "any other expression in the token slot refuses the tool
forever" and that the tool forwards the identifier "directly or through a
helper it calls". A conditional refuses the tool on some paths only, and the
resolver models exactly one same-file helper hop matched by argument position.

Replace both sentences with what the scan actually does, and state its limits:
two or more hops read as no bridge call and fail the routing guard, and
aliasing, reflection and an interface call are outside the model.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: catch a compound assignment to confirmationToken (#599)

The assignment rule was anchored on `\s*=`, and `\s*` cannot cross the `??`
of `??=` or the `+` of `+=`. So `confirmationToken ??= tagId;` above the
bridge call compiled green with all five guards green, and a later change
that writes `confirmationToken ??= await ResolveStoredTokenAsync(...)` would
hand the executor a token the MCP caller never sent: HasFreshConfirmation is
then satisfied from state the caller does not control, while the middleware
has already stepped aside.

Admit the two compound operators a `string?` can carry. `==` still fails on
the second `=`, `!=`, `>=` and `<=` still fail because those characters break
`\s*` and are not in the alternation, `=>` still fails on the trailing
character class, and a bare `??` with no `=` still fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: read every same-named helper an MCP tool could be calling (#599)

The member index was a dictionary keyed on the bare name and built with
group.First(), so two same-named members collapsed to whichever was declared
first. The scan then analysed a method the tool never calls. It broke both
ways: a decoy overload declared after the real helper and forwarding null was
green, and a gated tool calling a correct overload reddened when an unrelated
same-named member happened to be declared first.

Hold every member under its name and, at each call site, read every candidate
whose parameter count can admit that many arguments. One candidate resolves
the call; more than one is ambiguous, so all of them are read and any bad one
reddens the tool. That fails closed instead of guessing, and it costs no false
red in the case above, where only the real helper carries a bridge call. A
name that admits no candidate stays unresolved, which hides nothing: the
bridge call inside it is not collected either, so the routing guard reddens
the tool.

Arity, not the parameter type, is what decides a candidate. The scan is a text
scan, so it cannot type-check an argument.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: say what invariant 5 now refuses and what it still cannot see (#599)

The general claim "refuses a tool that assigns to the parameter" is now exact:
`=`, `??=` and `+=`. The helper hop no longer claims a "matching argument
position", because the resolver pairs by position alone and strips a
named-argument prefix without reading the name, so arguments named out of the
declared order are read wrong. The comment said nothing about overloads, so
say that an ambiguous name is read as every candidate and reddens the tool.

Close with the honest limit: a text scan is defeatable by an author who sets
out to defeat it, and what the guard closes is every shape an ordinary
refactor produces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* Fix calendar event timezone projection (#521)

* Start calendar timezone fix

* Fix calendar event timezone projection

* Fix projected calendar end times

* Fix calendar end time during DST fallback

* Fix calendar end time minute precision

* Fix projected calendar recurrence rule

* Guard calendar recurrence across timezone shifts

* Project calendar recurrence weekdays

* Keep calendar recurrence unchanged

* Omit unrepresentable recurring calendar events

* Gate projected calendar recurrence on the whole fetch window

Closes the six review findings on pull request 521.

1. The weekday gate now walks every expanded occurrence Google returned for a
   recurring master, not the one sampled instance. The fetcher collects each
   instance start with the source calendar's own offset into a JsonIgnore'd
   ExpandedOccurrences list, so a Lisbon BYDAY=TH series that is stable in
   January and shifts to Wednesday in July is refused. The empty list still
   falls back to the sampled instance for an all-day series and for a
   suggestion row read back from the database.
2. The legacy title plus date plus time key only excludes a suggestion when
   exactly one candidate carries it. Inside a fall-back repeated hour two
   events project to the same local time, so the key proves nothing and both
   stay. This mirrors the group.Count() == 1 guard the auto sync reconciler
   already applies to the same key.
3. An omitted end time now logs its reason at Debug with the event id and the
   user id. EndUtc already ships the real duration, so the client keeps it.
4. The refusal here and the clamp in HabitScheduleService.IsMonthlyMatch are
   reconciled in a doc comment: Orbit clamps a rule it owns and refuses a rule
   it imports and cannot re-express without inventing a weekday.
5. Both new call sites pass the logger and the user id to FindTimeZone, which
   also catches InvalidTimeZoneException so a corrupt zone stops escaping as a
   500. User.SetTimeZone trims at the boundary and rejects a blank id.
6. Asia/Kathmandu at plus 05:45 and Pacific/Chatham at plus 12:45 cover the
   sub hour offset gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Prove calendar recurrence stability from both zones

Round 6 gated a BYDAY series on the expanded instances Google returned, and
GoogleCalendarApi only asks for sixty days, so a series whose transition falls
outside that window was admitted and a stored suggestion row never carried the
instances at all.

The gate now reads the source calendar's own timezone from the recurring master
the fetcher already fetches, and walks a year of dates at the occurrence's source
wall clock through both zones' rules. A series it cannot prove stable is withheld
from both feeds. StoredCalendarEventJson carries the source zone beside the stored
suggestion, so the suggestion feed judges a row on the same evidence the events
feed had.

Also in scope: the auto-sync reconciler projects a fetched event into the account
timezone before matching a legacy habit, the end-time omission logs whatever EndUtc
holds, and an all-day event no longer reports Google's exclusive end date as an
end instant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Keep calendar series a spring forward gap cannot move

A probe date whose source wall clock a spring forward gap removes is a date
the series does not fire on, not evidence the series moves. The walk ended
there with "unproved" and the gate withheld the whole series, so an account
reading a calendar kept in its own zone lost an hour wide band in thirteen
DST zones even though the projection is the identity.

HasUnrepresentableRecurrenceAfterProjection now answers false as soon as
TimeZoneInfo.HasSameRules holds, and the walk skips a wall clock its own zone
removes instead of ending. RFC 5545 section 3.3.5 does define the missing
case, but probing that normalized instant changes no decision for any of the
14,752 zone pairs that could distinguish it, and asserting an expansion
Google does not document would let one guessed date withhold a year.

Also pins what earlier rounds argued: auto-sync refusing to store or notify a
withheld series, JsonIgnore keeping SourceTimeZone off the response body, the
repeated hour needing both of its instants, the probe reaching a full year,
and an all day event logging no dropped end time. A stored SourceTimeZone key
holding a number now reads as no source zone rather than failing the request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Fix recurring calendar timezone proof and legacy refresh

* Avoid refreshing legacy calendar rows with duplicate event IDs

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* Bump FluentAssertions and 23 others (#538)

Bumps FluentAssertions from 8.10.0 to 8.11.0
Bumps Google.Apis.AndroidPublisher.v3 from 1.75.0.4246 to 1.76.0.4277
Bumps Hangfire.AspNetCore from 1.8.24 to 1.8.25
Bumps Hangfire.Core from 1.8.24 to 1.8.25
Bumps Microsoft.AspNetCore.Authentication.JwtBearer from 10.0.11 to 10.0.12
Bumps Microsoft.AspNetCore.OpenApi from 10.0.11 to 10.0.12
Bumps Microsoft.EntityFrameworkCore from 10.0.11 to 10.0.12
Bumps Microsoft.EntityFrameworkCore.Design from 10.0.11 to 10.0.12
Bumps Microsoft.EntityFrameworkCore.InMemory from 10.0.11 to 10.0.12
Bumps Microsoft.EntityFrameworkCore.Relational from 10.0.11 to 10.0.12
Bumps Microsoft.EntityFrameworkCore.Sqlite from 10.0.11 to 10.0.12
Bumps Microsoft.Extensions.ApiDescription.Server from 10.0.11 to 10.0.12
Bumps Microsoft.Extensions.Caching.Abstractions from 10.0.11 to 10.0.12
Bumps Microsoft.Extensions.Caching.Memory from 10.0.11 to 10.0.12
Bumps Microsoft.Extensions.Caching.StackExchangeRedis from 10.0.11 to 10.0.12
Bumps Microsoft.Extensions.Http from 10.0.11 to 10.0.12
Bumps Microsoft.IdentityModel.JsonWebTokens from 8.22.0 to 8.23.0
Bumps Microsoft.NET.Test.Sdk from 18.9.0 to 18.10.1
Bumps OpenAI from 2.13.0 to 2.14.0
Bumps PostHog from 2.14.0 to 2.15.7
Bumps PostHog.AspNetCore from 2.9.0 to 2.9.7
Bumps Scalar.AspNetCore from 2.17.1 to 2.17.9
Bumps Sentry.AspNetCore from 6.9.0 to 6.11.1
Bumps Stripe.net from 52.3.0 to 52.4.2

---
updated-dependencies:
- dependency-name: Google.Apis.AndroidPublisher.v3
  dependency-version: 1.76.0.4277
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Hangfire.AspNetCore
  dependency-version: 1.8.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Hangfire.Core
  dependency-version: 1.8.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Hangfire.Core
  dependency-version: 1.8.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.AspNetCore.Authentication.JwtBearer
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.AspNetCore.OpenApi
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore.Design
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore.Relational
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.Extensions.ApiDescription.Server
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.Extensions.Caching.Abstractions
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.Extensions.Caching.StackExchangeRedis
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.Extensions.Http
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.IdentityModel.JsonWebTokens
  dependency-version: 8.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: OpenAI
  dependency-version: 2.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: PostHog
  dependency-version: 2.15.7
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: PostHog.AspNetCore
  dependency-version: 2.9.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Scalar.AspNetCore
  dependency-version: 2.17.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Sentry.AspNetCore
  dependency-version: 6.11.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Stripe.net
  dependency-version: 52.4.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: FluentAssertions
  dependency-version: 8.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore.InMemory
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.Extensions.Caching.Memory
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: FluentAssertions
  dependency-version: 8.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: FluentAssertions
  dependency-version: 8.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore.InMemory
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore.Sqlite
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Give API key management one authorization concept with two doors (#528)

* Start #529 API key step up

* Require API key management step up

* chore: regenerate the architecture map

The step up change moved the API key endpoints' shape, so `architecture.json`
and `architecture.html` no longer matched the tree and the `drift` check
failed on `git diff --exit-code`. Regenerated with `node tools/arch-map.mjs`,
which reports 45 entities and 0 untested feature folders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Give API key management one authorization concept with two doors

Listing, creating and revoking API keys now read one grant. Two doors open
it, and both end in a six-digit code emailed to the account owner: the HTTP
challenge, and a verified agent step-up.

Creating key material spends the grant. Listing and revoking only read it,
so one emailed code authorizes a management session instead of exactly one
revoke. A revoke no longer locks the person out of the key list.

get_api_keys gets a door. RequireApiKeyCreationStepUp raises the read
capability to a step-up inside the executor, so the read opens a pending
operation the caller can step up against, and the MCP read routes through
McpExecutorBridge like the mutation does.

AppConfigService now parses a stored row strictly and throws by name when a
row is malformed, so a typo cannot leave the gate off while the read-back
reports it on.

Both controller actions declare 403 and 428, and openapi.json carries them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Cover API key step up through merged MCP gate

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Expose habit emoji in MCP tools (#513) (#546)

* Open ticket 513 implementation review

* Expose habit emoji across MCP tools (#513)

* Prevent stale pull request workflow runs for #659 (#555)

* Start #659 workflow concurrency change

* Group core PR workflows by pull request for #659

* Group remaining PR workflows by pull request for #659

* Fix #657 logout revocation across a session family (#553)

* Start #657 session family fix

* Revoke the full auth session after logout (#657)

* Explain temporary legacy token allowance (#657)

* Handle duplicate refresh history race (#657)

* Fix #588 bulk habit interval weeks (#539)

* Start #588 bulk interval weeks fix

* Fix #588 bulk habit interval weeks mapping

* Guarantee crisis resources in Astra chat for #319 (#547)

* Add crisis guidance to Astra static prompt for #319

* Add curated crisis detection and regression cases for #319

* Wire crisis guard across chat delivery and metrics for #319

* Refine crisis delivery and cover fallback regression for #319

* Return static crisis support when AI chat fails for #319

* Keep crisis FAQ regression isolated for #319

* Fix crisis replies to bypass AI quota and provider

* Record streak freeze source for #571 (#540)

* Start #571 freeze source work

* Record streak freeze origin for #571

* Require explicit origin for new streak freezes

* Keep support tool available for support entry point (#541)

* Expose scheduled streak repair gap dates for #505 (#542)

* Fix standalone sub habit title validation for #628 (#543)

* Start #628 sub habit title validation fix

* Use sub habit title messages in standalone validation (#628)

* Carry the onboarding repeat interval into the created habit (#596) (#533)

A signed-out person who set a weekly repeat interval during onboarding lost
it at sign-up. ApplyHabitInput carried no interval field at all, so the apply
path dropped the value between the screen and the record.

Add an optional IntervalWeeks to ApplyHabitInput, additive only, and pass it
to Habit.Create alongside the other schedule options. No existing field
changes name, shape or nullability, so a client that sends nothing keeps the
behaviour it has today: IntervalWeeks stays null and the habit repeats every
week.

Validate the new field with the shared SharedHabitRules.AddIntervalWeeksRules
range, the same 1 to 52 bound every other create path uses.

Regenerate openapi.json and architecture.json/html for the new field and for
the test class that now touches HabitScheduleService.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* Expose recurrence time zone in calendar events for #591 (#544)

* Start #591 recurrence time zone work

* Expose recurring calendar event time zone for #591

* Restore recurrence time zone in legacy suggestions for #591

* Project safe calendar BYDAY recurrences (#569) (#545)

* Start calendar BYDAY projection (#569)

* Project uniform calendar BYDAY recurrences (#569)

* Cover alternate week recurrence projection (#569)

* Keep shifted calendar rules importable by installed clients

* Add closed week and year recap ranges for #369 (#548)

* Fix Google sign-in after concurrent user updates (#551)

* chore: start ticket 324 review

* fix: retry concurrent Google sign-in updates (#324)

* Fix duplicate agent confirmation consumption (#557)

* Start fix for ticket 671 confirmation race

* Claim agent confirmation once under concurrent saves

* chore: regenerate the architecture map for the confirmation claim migration (#671)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix: show newest habit completion date in profile (#549)

* chore: start ticket 391

* fix: expose latest habit completion date in profile

* fix: exclude bad habit slips from last completion date

* fix: pass the freeze origin in the profile freeze test after #540 (#391)

The merge with main brought StreakFreeze.Create's required origin
parameter (#540). The test now asserts that a freeze without a
completion leaves lastCompletionDate null for both origins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: preserve last completion across habit cleanup (#391)

* Preserve descendant completions during sync cleanup

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix: drop the duplicate workflow concurrency keys the merge-forward created

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
thomasluizon added a commit that referenced this pull request Sep 26, 2026
* Add habit widget empty reason (#527)

* Start #372

* Add habit widget empty reason

* ORB-223: Generate gating matrix from PayGateService (#520)

* chore: start ORB-223

* feat: generate gating matrix

* fix: fail closed on unreadable plan gates

* fix: close gating matrix parser gaps

* fix: preserve plans in combined guards

* fix: distinguish quota lifted plans

* fix: carry plan provenance through quota aliases

Derived quota variables were recorded unscoped and only the variable that
literally contains the plan ternary was relabelled afterwards, so plan
provenance did not survive a local alias. A semantics-preserving
`var selectedLimit = user.HasProAccess ? proLimit : freeLimit;
var messageLimit = selectedLimit;` generated cleanly and changed
CanSendAiMessage.quotaLiftedByPlan from "Pro" to null.

The directly plan-selected variables are now labelled first, and the
fixed-point propagation carries the source variable's exact plan instead of
null. An assignment deriving from two different plans cannot have its
provenance proven, so generation fails closed with the same
"cannot derive plan requirement" error the other unprovable shapes use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: fail closed on unknown gating sources

* fix: derive feature flags from migrations

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: fall back to Claude only when the Codex reviewer step fails (#529)

Mirrors thomasluizon/orbit-ui-mobile#1022.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Let the executor own the MCP confirmation gate (#599) (#534)

* fix: let the executor own the MCP confirmation gate (#599)

Every MCP tool whose capability requires a confirmation or a step-up was
refused forever. The selective-auth middleware evaluated the policy a second
time, in front of the executor, and it never received the caller's
confirmation token. A client that stepped up and retried with a valid token
got the identical refusal, because the token never reached that evaluation.

Carrying the token into the middleware cannot fix it. A confirmation token is
single use and is bound to the pending operation's fingerprint, and the
middleware computes a different fingerprint from the raw MCP arguments than
the executor computes from the operation id and its snake_case argument
object. One token cannot satisfy two gates.

So the middleware now steps aside for a confirmation-gated capability, the
same way it already steps aside for execute_agent_operation_v2. Both reach
IAgentOperationExecutor, which evaluates access and confirmation together,
holds the token, and writes the audit row.

Two guard tests pin the invariants the deferral rests on: a confirmation
requirement always sits on a mutation, and every confirmation-gated MCP tool
reaches the executor through McpExecutorBridge.

Also corrected: the step-up message and the three tool parameter descriptions
named verify_step_up_agent_operation_v2 as the source of the confirmation
token. It does not return one. confirm_agent_operation_v2 does.

Removed AgentPolicyEvaluationContext.StepUpSatisfied. Nothing ever set it and
nothing ever read it; the real step-up state lives on
PendingAgentOperationState.StepUpSatisfiedAtUtc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: thread the confirmation token through the bulk habit tools (#599)

bulk_log_habits and bulk_skip_habits declared no confirmationToken
parameter and their shared helper hardcoded confirmationToken: null, so
their HabitsBulkWrite capability (FreshConfirmation) could never see a
token and both tools stayed permanently refused. Both now declare the
parameter and ExecuteBulkHabitOperationAsync forwards it.

Three new guards in ConfirmationGatedMcpToolsRouteThroughExecutorTests
pin what the old one missed. The scan now asserts it reaches exactly the
catalog's gated MCP tools, that each tool's forwarded operation id
resolves to a capability with the same ConfirmationRequirement, and that
each tool accepts and forwards a confirmation token. The source scan
walks subfolders and keys members by their declaration rather than by the
first invocation-shaped token in the chunk.

AgentOperationExecutor writes an AgentAuditLogs row before returning
UnknownOperation, restoring the trail the middleware used to leave.

McpConfirmationGateTests now drives the real AgentTools recovery methods
end to end and pins that all three refuse an API-key credential.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: resolve the forwarded MCP confirmation token back to the tool parameter (#599)

The token guard whitelisted one spelling of null. It flagged a bridge call only
when the confirmation-token argument was absent or the literal `null`, so
`confirmationToken: default`, `null!`, `(string?)null` and `""` all passed while
the tool stayed refused forever. A longer literal list does not close that,
because the next spelling is not on the list either.

Assert the structural property instead: a gated tool declares a
`string? confirmationToken` parameter, and the expression it forwards into the
executor's token slot resolves back to that identifier, directly or through one
helper hop. Every other expression fails, whatever it spells.

The declaration check now reads the tool's parameter list rather than its whole
body, so a local named `confirmationToken` no longer satisfies it.

Also add `forwarded.Id == tool.Capability.Id` to the operation-id guard. It
compared only the confirmation requirement, so a gated tool could forward
another gated capability's operation id, keep confirmation firing, and have the
executor enforce the wrong scope.

The file's doc comment said four invariants and listed four; the file holds
five. Name the source-scan invariant the other three rest on.

Six mutations, each red on its own, real tree green at every step:
`confirmationToken: default`, `null!`, `(string?)null`, `""`, a gated tool
forwarding a different local, and `delete_tag` forwarding `delete_goal`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: strip only a real named-argument prefix in the MCP guard parser (#599)

ArgumentAt treated any colon as a named-argument separator, so a conditional
expression in the token slot or the operation-id slot collapsed to its
else-branch. `ids.Count > 0 ? null : confirmationToken` read as
`confirmationToken` and `tagId.Length > 0 ? "delete_goal" : "delete_tag"` read
as `"delete_tag"`, and both guards stayed green over the restored defect.

Match `^\w+\s*:(?!:)` instead, so only a leading `name:` prefix is stripped and
every other expression reaches the resolver whole. The anchor keeps a colon
inside a string literal and a `::` qualifier from matching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: separate an omitted caller argument from a non-parameter expression (#599)

ResolveExpression returned the expression itself both when it was not a callee
parameter and when the caller supplied nothing at that index. The second case
handed back the helper's own parameter name, which then compared equal to
`confirmationToken` and passed. Giving the helper an optional
`string? confirmationToken = null` and calling it without that argument restored
the #599 defect with the guard green.

Return the `<omitted>` sentinel for the second case instead. It can never equal
the parameter name, and it resolves to no capability in the operation-id slot,
so both guards fail closed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: read a gated tool's direct and helper bridge calls together (#599)

CollectBridgeCalls returned early as soon as the tool body held one bridge
call, so a correct call in a dead branch hid every helper call in the same
tool. A decoy `if (tagId.Length == 0)` block forwarding `confirmationToken`
plus a same-file helper forwarding `null` restored the #599 defect with the
guard green.

Collect both sets instead. Every bridge call a gated tool can reach, directly
or through one helper hop, now has to forward the parameter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: refuse a gated tool that assigns to its own confirmationToken (#599)

The rule compared the forwarded expression's text only, so
`confirmationToken = null;` above the bridge call restored the #599 defect with
the forwarded identifier unchanged and the guard green.

Add an offender when the tool body, or a same-file helper it reaches, assigns
to the parameter. MemberSource now carries its statements with the parameter
list excluded, so the declaration's own `string? confirmationToken = null`
default does not match. The pattern `\bconfirmationToken\s*=[^=>]` leaves `==`,
`!=`, `>=`, `<=`, `??=` and a lambda arrow alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: say what the MCP token resolver models, not more (#599)

Invariant 5 claimed "any other expression in the token slot refuses the tool
forever" and that the tool forwards the identifier "directly or through a
helper it calls". A conditional refuses the tool on some paths only, and the
resolver models exactly one same-file helper hop matched by argument position.

Replace both sentences with what the scan actually does, and state its limits:
two or more hops read as no bridge call and fail the routing guard, and
aliasing, reflection and an interface call are outside the model.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: catch a compound assignment to confirmationToken (#599)

The assignment rule was anchored on `\s*=`, and `\s*` cannot cross the `??`
of `??=` or the `+` of `+=`. So `confirmationToken ??= tagId;` above the
bridge call compiled green with all five guards green, and a later change
that writes `confirmationToken ??= await ResolveStoredTokenAsync(...)` would
hand the executor a token the MCP caller never sent: HasFreshConfirmation is
then satisfied from state the caller does not control, while the middleware
has already stepped aside.

Admit the two compound operators a `string?` can carry. `==` still fails on
the second `=`, `!=`, `>=` and `<=` still fail because those characters break
`\s*` and are not in the alternation, `=>` still fails on the trailing
character class, and a bare `??` with no `=` still fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: read every same-named helper an MCP tool could be calling (#599)

The member index was a dictionary keyed on the bare name and built with
group.First(), so two same-named members collapsed to whichever was declared
first. The scan then analysed a method the tool never calls. It broke both
ways: a decoy overload declared after the real helper and forwarding null was
green, and a gated tool calling a correct overload reddened when an unrelated
same-named member happened to be declared first.

Hold every member under its name and, at each call site, read every candidate
whose parameter count can admit that many arguments. One candidate resolves
the call; more than one is ambiguous, so all of them are read and any bad one
reddens the tool. That fails closed instead of guessing, and it costs no false
red in the case above, where only the real helper carries a bridge call. A
name that admits no candidate stays unresolved, which hides nothing: the
bridge call inside it is not collected either, so the routing guard reddens
the tool.

Arity, not the parameter type, is what decides a candidate. The scan is a text
scan, so it cannot type-check an argument.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: say what invariant 5 now refuses and what it still cannot see (#599)

The general claim "refuses a tool that assigns to the parameter" is now exact:
`=`, `??=` and `+=`. The helper hop no longer claims a "matching argument
position", because the resolver pairs by position alone and strips a
named-argument prefix without reading the name, so arguments named out of the
declared order are read wrong. The comment said nothing about overloads, so
say that an ambiguous name is read as every candidate and reddens the tool.

Close with the honest limit: a text scan is defeatable by an author who sets
out to defeat it, and what the guard closes is every shape an ordinary
refactor produces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* Fix calendar event timezone projection (#521)

* Start calendar timezone fix

* Fix calendar event timezone projection

* Fix projected calendar end times

* Fix calendar end time during DST fallback

* Fix calendar end time minute precision

* Fix projected calendar recurrence rule

* Guard calendar recurrence across timezone shifts

* Project calendar recurrence weekdays

* Keep calendar recurrence unchanged

* Omit unrepresentable recurring calendar events

* Gate projected calendar recurrence on the whole fetch window

Closes the six review findings on pull request 521.

1. The weekday gate now walks every expanded occurrence Google returned for a
   recurring master, not the one sampled instance. The fetcher collects each
   instance start with the source calendar's own offset into a JsonIgnore'd
   ExpandedOccurrences list, so a Lisbon BYDAY=TH series that is stable in
   January and shifts to Wednesday in July is refused. The empty list still
   falls back to the sampled instance for an all-day series and for a
   suggestion row read back from the database.
2. The legacy title plus date plus time key only excludes a suggestion when
   exactly one candidate carries it. Inside a fall-back repeated hour two
   events project to the same local time, so the key proves nothing and both
   stay. This mirrors the group.Count() == 1 guard the auto sync reconciler
   already applies to the same key.
3. An omitted end time now logs its reason at Debug with the event id and the
   user id. EndUtc already ships the real duration, so the client keeps it.
4. The refusal here and the clamp in HabitScheduleService.IsMonthlyMatch are
   reconciled in a doc comment: Orbit clamps a rule it owns and refuses a rule
   it imports and cannot re-express without inventing a weekday.
5. Both new call sites pass the logger and the user id to FindTimeZone, which
   also catches InvalidTimeZoneException so a corrupt zone stops escaping as a
   500. User.SetTimeZone trims at the boundary and rejects a blank id.
6. Asia/Kathmandu at plus 05:45 and Pacific/Chatham at plus 12:45 cover the
   sub hour offset gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Prove calendar recurrence stability from both zones

Round 6 gated a BYDAY series on the expanded instances Google returned, and
GoogleCalendarApi only asks for sixty days, so a series whose transition falls
outside that window was admitted and a stored suggestion row never carried the
instances at all.

The gate now reads the source calendar's own timezone from the recurring master
the fetcher already fetches, and walks a year of dates at the occurrence's source
wall clock through both zones' rules. A series it cannot prove stable is withheld
from both feeds. StoredCalendarEventJson carries the source zone beside the stored
suggestion, so the suggestion feed judges a row on the same evidence the events
feed had.

Also in scope: the auto-sync reconciler projects a fetched event into the account
timezone before matching a legacy habit, the end-time omission logs whatever EndUtc
holds, and an all-day event no longer reports Google's exclusive end date as an
end instant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Keep calendar series a spring forward gap cannot move

A probe date whose source wall clock a spring forward gap removes is a date
the series does not fire on, not evidence the series moves. The walk ended
there with "unproved" and the gate withheld the whole series, so an account
reading a calendar kept in its own zone lost an hour wide band in thirteen
DST zones even though the projection is the identity.

HasUnrepresentableRecurrenceAfterProjection now answers false as soon as
TimeZoneInfo.HasSameRules holds, and the walk skips a wall clock its own zone
removes instead of ending. RFC 5545 section 3.3.5 does define the missing
case, but probing that normalized instant changes no decision for any of the
14,752 zone pairs that could distinguish it, and asserting an expansion
Google does not document would let one guessed date withhold a year.

Also pins what earlier rounds argued: auto-sync refusing to store or notify a
withheld series, JsonIgnore keeping SourceTimeZone off the response body, the
repeated hour needing both of its instants, the probe reaching a full year,
and an all day event logging no dropped end time. A stored SourceTimeZone key
holding a number now reads as no source zone rather than failing the request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Fix recurring calendar timezone proof and legacy refresh

* Avoid refreshing legacy calendar rows with duplicate event IDs

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* Bump FluentAssertions and 23 others (#538)

Bumps FluentAssertions from 8.10.0 to 8.11.0
Bumps Google.Apis.AndroidPublisher.v3 from 1.75.0.4246 to 1.76.0.4277
Bumps Hangfire.AspNetCore from 1.8.24 to 1.8.25
Bumps Hangfire.Core from 1.8.24 to 1.8.25
Bumps Microsoft.AspNetCore.Authentication.JwtBearer from 10.0.11 to 10.0.12
Bumps Microsoft.AspNetCore.OpenApi from 10.0.11 to 10.0.12
Bumps Microsoft.EntityFrameworkCore from 10.0.11 to 10.0.12
Bumps Microsoft.EntityFrameworkCore.Design from 10.0.11 to 10.0.12
Bumps Microsoft.EntityFrameworkCore.InMemory from 10.0.11 to 10.0.12
Bumps Microsoft.EntityFrameworkCore.Relational from 10.0.11 to 10.0.12
Bumps Microsoft.EntityFrameworkCore.Sqlite from 10.0.11 to 10.0.12
Bumps Microsoft.Extensions.ApiDescription.Server from 10.0.11 to 10.0.12
Bumps Microsoft.Extensions.Caching.Abstractions from 10.0.11 to 10.0.12
Bumps Microsoft.Extensions.Caching.Memory from 10.0.11 to 10.0.12
Bumps Microsoft.Extensions.Caching.StackExchangeRedis from 10.0.11 to 10.0.12
Bumps Microsoft.Extensions.Http from 10.0.11 to 10.0.12
Bumps Microsoft.IdentityModel.JsonWebTokens from 8.22.0 to 8.23.0
Bumps Microsoft.NET.Test.Sdk from 18.9.0 to 18.10.1
Bumps OpenAI from 2.13.0 to 2.14.0
Bumps PostHog from 2.14.0 to 2.15.7
Bumps PostHog.AspNetCore from 2.9.0 to 2.9.7
Bumps Scalar.AspNetCore from 2.17.1 to 2.17.9
Bumps Sentry.AspNetCore from 6.9.0 to 6.11.1
Bumps Stripe.net from 52.3.0 to 52.4.2

---
updated-dependencies:
- dependency-name: Google.Apis.AndroidPublisher.v3
  dependency-version: 1.76.0.4277
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Hangfire.AspNetCore
  dependency-version: 1.8.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Hangfire.Core
  dependency-version: 1.8.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Hangfire.Core
  dependency-version: 1.8.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.AspNetCore.Authentication.JwtBearer
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.AspNetCore.OpenApi
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore.Design
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore.Relational
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.Extensions.ApiDescription.Server
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.Extensions.Caching.Abstractions
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.Extensions.Caching.StackExchangeRedis
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.Extensions.Http
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.IdentityModel.JsonWebTokens
  dependency-version: 8.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: OpenAI
  dependency-version: 2.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: PostHog
  dependency-version: 2.15.7
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: PostHog.AspNetCore
  dependency-version: 2.9.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Scalar.AspNetCore
  dependency-version: 2.17.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Sentry.AspNetCore
  dependency-version: 6.11.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Stripe.net
  dependency-version: 52.4.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: FluentAssertions
  dependency-version: 8.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore.InMemory
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.Extensions.Caching.Memory
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: FluentAssertions
  dependency-version: 8.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: FluentAssertions
  dependency-version: 8.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore.InMemory
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore.Sqlite
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Give API key management one authorization concept with two doors (#528)

* Start #529 API key step up

* Require API key management step up

* chore: regenerate the architecture map

The step up change moved the API key endpoints' shape, so `architecture.json`
and `architecture.html` no longer matched the tree and the `drift` check
failed on `git diff --exit-code`. Regenerated with `node tools/arch-map.mjs`,
which reports 45 entities and 0 untested feature folders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Give API key management one authorization concept with two doors

Listing, creating and revoking API keys now read one grant. Two doors open
it, and both end in a six-digit code emailed to the account owner: the HTTP
challenge, and a verified agent step-up.

Creating key material spends the grant. Listing and revoking only read it,
so one emailed code authorizes a management session instead of exactly one
revoke. A revoke no longer locks the person out of the key list.

get_api_keys gets a door. RequireApiKeyCreationStepUp raises the read
capability to a step-up inside the executor, so the read opens a pending
operation the caller can step up against, and the MCP read routes through
McpExecutorBridge like the mutation does.

AppConfigService now parses a stored row strictly and throws by name when a
row is malformed, so a typo cannot leave the gate off while the read-back
reports it on.

Both controller actions declare 403 and 428, and openapi.json carries them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Cover API key step up through merged MCP gate

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Expose habit emoji in MCP tools (#513) (#546)

* Open ticket 513 implementation review

* Expose habit emoji across MCP tools (#513)

* Prevent stale pull request workflow runs for #659 (#555)

* Start #659 workflow concurrency change

* Group core PR workflows by pull request for #659

* Group remaining PR workflows by pull request for #659

* Fix #657 logout revocation across a session family (#553)

* Start #657 session family fix

* Revoke the full auth session after logout (#657)

* Explain temporary legacy token allowance (#657)

* Handle duplicate refresh history race (#657)

* Fix #588 bulk habit interval weeks (#539)

* Start #588 bulk interval weeks fix

* Fix #588 bulk habit interval weeks mapping

* Guarantee crisis resources in Astra chat for #319 (#547)

* Add crisis guidance to Astra static prompt for #319

* Add curated crisis detection and regression cases for #319

* Wire crisis guard across chat delivery and metrics for #319

* Refine crisis delivery and cover fallback regression for #319

* Return static crisis support when AI chat fails for #319

* Keep crisis FAQ regression isolated for #319

* Fix crisis replies to bypass AI quota and provider

* Record streak freeze source for #571 (#540)

* Start #571 freeze source work

* Record streak freeze origin for #571

* Require explicit origin for new streak freezes

* Keep support tool available for support entry point (#541)

* Expose scheduled streak repair gap dates for #505 (#542)

* Fix standalone sub habit title validation for #628 (#543)

* Start #628 sub habit title validation fix

* Use sub habit title messages in standalone validation (#628)

* Carry the onboarding repeat interval into the created habit (#596) (#533)

A signed-out person who set a weekly repeat interval during onboarding lost
it at sign-up. ApplyHabitInput carried no interval field at all, so the apply
path dropped the value between the screen and the record.

Add an optional IntervalWeeks to ApplyHabitInput, additive only, and pass it
to Habit.Create alongside the other schedule options. No existing field
changes name, shape or nullability, so a client that sends nothing keeps the
behaviour it has today: IntervalWeeks stays null and the habit repeats every
week.

Validate the new field with the shared SharedHabitRules.AddIntervalWeeksRules
range, the same 1 to 52 bound every other create path uses.

Regenerate openapi.json and architecture.json/html for the new field and for
the test class that now touches HabitScheduleService.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* Expose recurrence time zone in calendar events for #591 (#544)

* Start #591 recurrence time zone work

* Expose recurring calendar event time zone for #591

* Restore recurrence time zone in legacy suggestions for #591

* Project safe calendar BYDAY recurrences (#569) (#545)

* Start calendar BYDAY projection (#569)

* Project uniform calendar BYDAY recurrences (#569)

* Cover alternate week recurrence projection (#569)

* Keep shifted calendar rules importable by installed clients

* Add closed week and year recap ranges for #369 (#548)

* Fix Google sign-in after concurrent user updates (#551)

* chore: start ticket 324 review

* fix: retry concurrent Google sign-in updates (#324)

* Fix duplicate agent confirmation consumption (#557)

* Start fix for ticket 671 confirmation race

* Claim agent confirmation once under concurrent saves

* chore: regenerate the architecture map for the confirmation claim migration (#671)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix: show newest habit completion date in profile (#549)

* chore: start ticket 391

* fix: expose latest habit completion date in profile

* fix: exclude bad habit slips from last completion date

* fix: pass the freeze origin in the profile freeze test after #540 (#391)

The merge with main brought StreakFreeze.Create's required origin
parameter (#540). The test now asserts that a freeze without a
completion leaves lastCompletionDate null for both origins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: preserve last completion across habit cleanup (#391)

* Preserve descendant completions during sync cleanup

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Recover concurrent habit log duplicates (#325) (#552)

* Start #325 habit log duplicate recovery

* Recover duplicate habit logs without losing idempotency response

* chore(deps): bump the github-actions group across 1 directory with 5 updates (#530)

* chore(deps): bump the github-actions group across 1 directory with 5 updates

Bumps the github-actions group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `5` | `7` |
| [github/codeql-action/init](https://github.com/github/codeql-action) | `4.37.8` | `4.38.1` |
| [github/codeql-action/analyze](https://github.com/github/codeql-action) | `4.37.8` | `4.38.1` |
| [actions/setup-java](https://github.com/actions/setup-java) | `5` | `6` |
| [oasdiff/oasdiff-action/breaking](https://github.com/oasdiff/oasdiff-action) | `0.1.13` | `0.1.17` |



Updates `actions/upload-artifact` from 5 to 7
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](actions/upload-artifact@v5...v7)

Updates `github/codeql-action/init` from 4.37.8 to 4.38.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](github/codeql-action@db488dd...1c5b675)

Updates `github/codeql-action/analyze` from 4.37.8 to 4.38.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](github/codeql-action@db488dd...1c5b675)

Updates `actions/setup-java` from 5 to 6
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](actions/setup-java@v5...v6)

Updates `oasdiff/oasdiff-action/breaking` from 0.1.13 to 0.1.17
- [Release notes](https://github.com/oasdiff/oasdiff-action/releases)
- [Commits](oasdiff/oasdiff-action@2649ebe...5e81b5c)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: github/codeql-action/init
  dependency-version: 4.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: oasdiff/oasdiff-action/breaking
  dependency-version: 0.1.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix: restore optional response removal gate (#664)

* fix: keep the oasdiff levels file out of the repository root and allowlist .oasdiff.yaml (#664)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Thomas Luizon Rodrigues Gregorio <thomaslrgregorio@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* ORB-101: verify Turnstile tokens on anonymous writes (#550)

* chore: open ORB-101 review branch

* feat: gate anonymous writes with optional Turnstile verification

* test: cover Turnstile gate and verifier failures

* test: assert valid Turnstile request returns success

* test: ground Turnstile responses in observed Siteverify results

Document web, Android, and landing activation checks for ORB-101.

* Fix Astra goal access for every plan (#677) (#560)

* fix: accept stored recap week anchors after preference changes (#683) (#559)

* fix: resolve stored recap week anchors without the current preference

A closed week anchor lives in the recap share link and in the stored
snapshot key. Validating it against today's WeekStartDay rejected a
Monday anchor after the person moved to Sunday, before the stored
snapshot could be read. Accept any supported week start (Sunday or
Monday) for an explicit closed anchor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: use closed week anchor for recap cache misses

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Prune idle Hangfire session connections for #564 (#554)

* Bump Scalar.AspNetCore from 2.17.9 to 2.17.10 (#558)

---
updated-dependencies:
- dependency-name: Scalar.AspNetCore
  dependency-version: 2.17.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Make unsubscribe token tampering test byte-accurate (#673) (#561)

* Make goal completion timestamp test deterministic (#676) (#562)

* Start #676 goal clock fix

* Make goal completion timestamp test deterministic (#676)

* chore: regenerate the architecture map for the goal clock test

The test now references HabitLog, so node tools/arch-map.mjs adds it to
the test's references. Two runs produce the same output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Pin Pullfrog reviewer to GPT 6 Sol medium (#636) (#537)

* Pin Pullfrog reviewer model and effort for #636

* Set Pullfrog effort to medium on Thomas's decision (#636)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Describe the pinned Pullfrog reviewer in the workflow comment (#636)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: run the pinned Pullfrog commit so GPT-6 Sol medium applies

The published pullfrog 0.1.82 has no gpt-6-sol entry, so the effort
input was dropped. The review step now runs the action's own code at
pullfrog/pullfrog main 405f60c2, whose model table has gpt-6-sol with a
medium rung. Return to @v0 once 0.1.83 is published.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Take Pullfrog model and effort from the repository config (#565)

The app dispatch payload carries model and effort and outranks action
inputs (utils/payload.ts:320-326 at 405f60c2), so the inputs from #537
never applied (run 36187465385 resolved effort xhigh). Effort is now
0.25 (medium on GPT-6 Sol) in the Pullfrog repo config.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Fix relative reminders across local day boundaries (#566)

* chore: start reminder boundary fix for #695

* test: inject a fixed clock into relative reminder checks

* fix: send relative reminders on their local occurrence day

* chore: ratchet the scheduler suppression count and regenerate the map

#695 removed the two relative-reminder UTC-date suppressions, so the
closed allowlist now declares 2 ORBIT0004 sites in
ReminderSchedulerService.cs. The architecture map is regenerated for
the new clock dependency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: fire relative reminders at the first local due time across DST

Pullfrog review of 9ebf379: on a fall-back day a 01:30 due time is
ambiguous and ConvertTimeToUtc picked the later occurrence, an hour
after the old scheduler. A repeated hour now resolves to its first
occurrence, and a due time inside a spring-forward gap resolves to the
moment the clock jumps past it instead of being skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Fix bulk habit replay idempotency (#567)

* chore: open ticket 389 review early

* fix: make bulk habit writes replay safe

* fix: scope idempotency ledger by command content

* fix: scope idempotency ledger by request ordinal

* Use published Pullfrog v0 for Codex review (#568)

* Start #700 Pullfrog action update

* Use published Pullfrog v0 for Codex review

* ci: pin both Pullfrog steps to the v0 release commit

SonarCloud githubactions:S7637 flags a moving tag as an unpinned dependency.
Pin the commit the v0 tag named on 2026-09-26, release 0.1.83.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* docs: align worker PR handoff with ticket orders (#570)

* Fix child habit title messages on update for #651 (#572)

* Fix explicit culture formatting for #704 (#569)

* chore: begin #704 culture formatting work

* fix: format #704 notification prompts logs and goal tools explicitly

* fix: enforce invariant formatting across #704 prompts and machine text

* test: cover invariant decimal formatting for #704

* chore: refresh architecture artifacts for #704

* test: cover remaining culture formatting paths for #704

* Fix last completion date after habit type changes (#574)

* fix: preserve habit log slip kind at write time

* test: include slip kind in concurrent log fixture

* fix: classify old habit logs during rolling deploys

* Clean stale text in API repository (#575)

* Use neutral names in test fixtures

* Make repository text timeless

* Remove disallowed narration comment

* Add timeless text gate to API repository (#576)

* Add timeless text gate to API repository

* fix: read comments in XML build files

* fix: match script end tags with attributes

* fix: end a script only at a real end-tag delimiter

* Prepare timeless cleanup for #715

* Preserve main culture formatting in merged code

* fix: sync the checker's quote and staged-blob fixes

* fix: let a reviewed directory entry exempt any rule but machine-path

* fix: sync the final timeless checker

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
thomasluizon added a commit that referenced this pull request Sep 26, 2026
* Add habit widget empty reason (#527)

* Start #372

* Add habit widget empty reason

* ORB-223: Generate gating matrix from PayGateService (#520)

* chore: start ORB-223

* feat: generate gating matrix

* fix: fail closed on unreadable plan gates

* fix: close gating matrix parser gaps

* fix: preserve plans in combined guards

* fix: distinguish quota lifted plans

* fix: carry plan provenance through quota aliases

Derived quota variables were recorded unscoped and only the variable that
literally contains the plan ternary was relabelled afterwards, so plan
provenance did not survive a local alias. A semantics-preserving
`var selectedLimit = user.HasProAccess ? proLimit : freeLimit;
var messageLimit = selectedLimit;` generated cleanly and changed
CanSendAiMessage.quotaLiftedByPlan from "Pro" to null.

The directly plan-selected variables are now labelled first, and the
fixed-point propagation carries the source variable's exact plan instead of
null. An assignment deriving from two different plans cannot have its
provenance proven, so generation fails closed with the same
"cannot derive plan requirement" error the other unprovable shapes use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: fail closed on unknown gating sources

* fix: derive feature flags from migrations

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: fall back to Claude only when the Codex reviewer step fails (#529)

Mirrors thomasluizon/orbit-ui-mobile#1022.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Let the executor own the MCP confirmation gate (#599) (#534)

* fix: let the executor own the MCP confirmation gate (#599)

Every MCP tool whose capability requires a confirmation or a step-up was
refused forever. The selective-auth middleware evaluated the policy a second
time, in front of the executor, and it never received the caller's
confirmation token. A client that stepped up and retried with a valid token
got the identical refusal, because the token never reached that evaluation.

Carrying the token into the middleware cannot fix it. A confirmation token is
single use and is bound to the pending operation's fingerprint, and the
middleware computes a different fingerprint from the raw MCP arguments than
the executor computes from the operation id and its snake_case argument
object. One token cannot satisfy two gates.

So the middleware now steps aside for a confirmation-gated capability, the
same way it already steps aside for execute_agent_operation_v2. Both reach
IAgentOperationExecutor, which evaluates access and confirmation together,
holds the token, and writes the audit row.

Two guard tests pin the invariants the deferral rests on: a confirmation
requirement always sits on a mutation, and every confirmation-gated MCP tool
reaches the executor through McpExecutorBridge.

Also corrected: the step-up message and the three tool parameter descriptions
named verify_step_up_agent_operation_v2 as the source of the confirmation
token. It does not return one. confirm_agent_operation_v2 does.

Removed AgentPolicyEvaluationContext.StepUpSatisfied. Nothing ever set it and
nothing ever read it; the real step-up state lives on
PendingAgentOperationState.StepUpSatisfiedAtUtc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: thread the confirmation token through the bulk habit tools (#599)

bulk_log_habits and bulk_skip_habits declared no confirmationToken
parameter and their shared helper hardcoded confirmationToken: null, so
their HabitsBulkWrite capability (FreshConfirmation) could never see a
token and both tools stayed permanently refused. Both now declare the
parameter and ExecuteBulkHabitOperationAsync forwards it.

Three new guards in ConfirmationGatedMcpToolsRouteThroughExecutorTests
pin what the old one missed. The scan now asserts it reaches exactly the
catalog's gated MCP tools, that each tool's forwarded operation id
resolves to a capability with the same ConfirmationRequirement, and that
each tool accepts and forwards a confirmation token. The source scan
walks subfolders and keys members by their declaration rather than by the
first invocation-shaped token in the chunk.

AgentOperationExecutor writes an AgentAuditLogs row before returning
UnknownOperation, restoring the trail the middleware used to leave.

McpConfirmationGateTests now drives the real AgentTools recovery methods
end to end and pins that all three refuse an API-key credential.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: resolve the forwarded MCP confirmation token back to the tool parameter (#599)

The token guard whitelisted one spelling of null. It flagged a bridge call only
when the confirmation-token argument was absent or the literal `null`, so
`confirmationToken: default`, `null!`, `(string?)null` and `""` all passed while
the tool stayed refused forever. A longer literal list does not close that,
because the next spelling is not on the list either.

Assert the structural property instead: a gated tool declares a
`string? confirmationToken` parameter, and the expression it forwards into the
executor's token slot resolves back to that identifier, directly or through one
helper hop. Every other expression fails, whatever it spells.

The declaration check now reads the tool's parameter list rather than its whole
body, so a local named `confirmationToken` no longer satisfies it.

Also add `forwarded.Id == tool.Capability.Id` to the operation-id guard. It
compared only the confirmation requirement, so a gated tool could forward
another gated capability's operation id, keep confirmation firing, and have the
executor enforce the wrong scope.

The file's doc comment said four invariants and listed four; the file holds
five. Name the source-scan invariant the other three rest on.

Six mutations, each red on its own, real tree green at every step:
`confirmationToken: default`, `null!`, `(string?)null`, `""`, a gated tool
forwarding a different local, and `delete_tag` forwarding `delete_goal`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: strip only a real named-argument prefix in the MCP guard parser (#599)

ArgumentAt treated any colon as a named-argument separator, so a conditional
expression in the token slot or the operation-id slot collapsed to its
else-branch. `ids.Count > 0 ? null : confirmationToken` read as
`confirmationToken` and `tagId.Length > 0 ? "delete_goal" : "delete_tag"` read
as `"delete_tag"`, and both guards stayed green over the restored defect.

Match `^\w+\s*:(?!:)` instead, so only a leading `name:` prefix is stripped and
every other expression reaches the resolver whole. The anchor keeps a colon
inside a string literal and a `::` qualifier from matching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: separate an omitted caller argument from a non-parameter expression (#599)

ResolveExpression returned the expression itself both when it was not a callee
parameter and when the caller supplied nothing at that index. The second case
handed back the helper's own parameter name, which then compared equal to
`confirmationToken` and passed. Giving the helper an optional
`string? confirmationToken = null` and calling it without that argument restored
the #599 defect with the guard green.

Return the `<omitted>` sentinel for the second case instead. It can never equal
the parameter name, and it resolves to no capability in the operation-id slot,
so both guards fail closed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: read a gated tool's direct and helper bridge calls together (#599)

CollectBridgeCalls returned early as soon as the tool body held one bridge
call, so a correct call in a dead branch hid every helper call in the same
tool. A decoy `if (tagId.Length == 0)` block forwarding `confirmationToken`
plus a same-file helper forwarding `null` restored the #599 defect with the
guard green.

Collect both sets instead. Every bridge call a gated tool can reach, directly
or through one helper hop, now has to forward the parameter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: refuse a gated tool that assigns to its own confirmationToken (#599)

The rule compared the forwarded expression's text only, so
`confirmationToken = null;` above the bridge call restored the #599 defect with
the forwarded identifier unchanged and the guard green.

Add an offender when the tool body, or a same-file helper it reaches, assigns
to the parameter. MemberSource now carries its statements with the parameter
list excluded, so the declaration's own `string? confirmationToken = null`
default does not match. The pattern `\bconfirmationToken\s*=[^=>]` leaves `==`,
`!=`, `>=`, `<=`, `??=` and a lambda arrow alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: say what the MCP token resolver models, not more (#599)

Invariant 5 claimed "any other expression in the token slot refuses the tool
forever" and that the tool forwards the identifier "directly or through a
helper it calls". A conditional refuses the tool on some paths only, and the
resolver models exactly one same-file helper hop matched by argument position.

Replace both sentences with what the scan actually does, and state its limits:
two or more hops read as no bridge call and fail the routing guard, and
aliasing, reflection and an interface call are outside the model.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: catch a compound assignment to confirmationToken (#599)

The assignment rule was anchored on `\s*=`, and `\s*` cannot cross the `??`
of `??=` or the `+` of `+=`. So `confirmationToken ??= tagId;` above the
bridge call compiled green with all five guards green, and a later change
that writes `confirmationToken ??= await ResolveStoredTokenAsync(...)` would
hand the executor a token the MCP caller never sent: HasFreshConfirmation is
then satisfied from state the caller does not control, while the middleware
has already stepped aside.

Admit the two compound operators a `string?` can carry. `==` still fails on
the second `=`, `!=`, `>=` and `<=` still fail because those characters break
`\s*` and are not in the alternation, `=>` still fails on the trailing
character class, and a bare `??` with no `=` still fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: read every same-named helper an MCP tool could be calling (#599)

The member index was a dictionary keyed on the bare name and built with
group.First(), so two same-named members collapsed to whichever was declared
first. The scan then analysed a method the tool never calls. It broke both
ways: a decoy overload declared after the real helper and forwarding null was
green, and a gated tool calling a correct overload reddened when an unrelated
same-named member happened to be declared first.

Hold every member under its name and, at each call site, read every candidate
whose parameter count can admit that many arguments. One candidate resolves
the call; more than one is ambiguous, so all of them are read and any bad one
reddens the tool. That fails closed instead of guessing, and it costs no false
red in the case above, where only the real helper carries a bridge call. A
name that admits no candidate stays unresolved, which hides nothing: the
bridge call inside it is not collected either, so the routing guard reddens
the tool.

Arity, not the parameter type, is what decides a candidate. The scan is a text
scan, so it cannot type-check an argument.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: say what invariant 5 now refuses and what it still cannot see (#599)

The general claim "refuses a tool that assigns to the parameter" is now exact:
`=`, `??=` and `+=`. The helper hop no longer claims a "matching argument
position", because the resolver pairs by position alone and strips a
named-argument prefix without reading the name, so arguments named out of the
declared order are read wrong. The comment said nothing about overloads, so
say that an ambiguous name is read as every candidate and reddens the tool.

Close with the honest limit: a text scan is defeatable by an author who sets
out to defeat it, and what the guard closes is every shape an ordinary
refactor produces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* Fix calendar event timezone projection (#521)

* Start calendar timezone fix

* Fix calendar event timezone projection

* Fix projected calendar end times

* Fix calendar end time during DST fallback

* Fix calendar end time minute precision

* Fix projected calendar recurrence rule

* Guard calendar recurrence across timezone shifts

* Project calendar recurrence weekdays

* Keep calendar recurrence unchanged

* Omit unrepresentable recurring calendar events

* Gate projected calendar recurrence on the whole fetch window

Closes the six review findings on pull request 521.

1. The weekday gate now walks every expanded occurrence Google returned for a
   recurring master, not the one sampled instance. The fetcher collects each
   instance start with the source calendar's own offset into a JsonIgnore'd
   ExpandedOccurrences list, so a Lisbon BYDAY=TH series that is stable in
   January and shifts to Wednesday in July is refused. The empty list still
   falls back to the sampled instance for an all-day series and for a
   suggestion row read back from the database.
2. The legacy title plus date plus time key only excludes a suggestion when
   exactly one candidate carries it. Inside a fall-back repeated hour two
   events project to the same local time, so the key proves nothing and both
   stay. This mirrors the group.Count() == 1 guard the auto sync reconciler
   already applies to the same key.
3. An omitted end time now logs its reason at Debug with the event id and the
   user id. EndUtc already ships the real duration, so the client keeps it.
4. The refusal here and the clamp in HabitScheduleService.IsMonthlyMatch are
   reconciled in a doc comment: Orbit clamps a rule it owns and refuses a rule
   it imports and cannot re-express without inventing a weekday.
5. Both new call sites pass the logger and the user id to FindTimeZone, which
   also catches InvalidTimeZoneException so a corrupt zone stops escaping as a
   500. User.SetTimeZone trims at the boundary and rejects a blank id.
6. Asia/Kathmandu at plus 05:45 and Pacific/Chatham at plus 12:45 cover the
   sub hour offset gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Prove calendar recurrence stability from both zones

Round 6 gated a BYDAY series on the expanded instances Google returned, and
GoogleCalendarApi only asks for sixty days, so a series whose transition falls
outside that window was admitted and a stored suggestion row never carried the
instances at all.

The gate now reads the source calendar's own timezone from the recurring master
the fetcher already fetches, and walks a year of dates at the occurrence's source
wall clock through both zones' rules. A series it cannot prove stable is withheld
from both feeds. StoredCalendarEventJson carries the source zone beside the stored
suggestion, so the suggestion feed judges a row on the same evidence the events
feed had.

Also in scope: the auto-sync reconciler projects a fetched event into the account
timezone before matching a legacy habit, the end-time omission logs whatever EndUtc
holds, and an all-day event no longer reports Google's exclusive end date as an
end instant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Keep calendar series a spring forward gap cannot move

A probe date whose source wall clock a spring forward gap removes is a date
the series does not fire on, not evidence the series moves. The walk ended
there with "unproved" and the gate withheld the whole series, so an account
reading a calendar kept in its own zone lost an hour wide band in thirteen
DST zones even though the projection is the identity.

HasUnrepresentableRecurrenceAfterProjection now answers false as soon as
TimeZoneInfo.HasSameRules holds, and the walk skips a wall clock its own zone
removes instead of ending. RFC 5545 section 3.3.5 does define the missing
case, but probing that normalized instant changes no decision for any of the
14,752 zone pairs that could distinguish it, and asserting an expansion
Google does not document would let one guessed date withhold a year.

Also pins what earlier rounds argued: auto-sync refusing to store or notify a
withheld series, JsonIgnore keeping SourceTimeZone off the response body, the
repeated hour needing both of its instants, the probe reaching a full year,
and an all day event logging no dropped end time. A stored SourceTimeZone key
holding a number now reads as no source zone rather than failing the request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Fix recurring calendar timezone proof and legacy refresh

* Avoid refreshing legacy calendar rows with duplicate event IDs

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* Bump FluentAssertions and 23 others (#538)

Bumps FluentAssertions from 8.10.0 to 8.11.0
Bumps Google.Apis.AndroidPublisher.v3 from 1.75.0.4246 to 1.76.0.4277
Bumps Hangfire.AspNetCore from 1.8.24 to 1.8.25
Bumps Hangfire.Core from 1.8.24 to 1.8.25
Bumps Microsoft.AspNetCore.Authentication.JwtBearer from 10.0.11 to 10.0.12
Bumps Microsoft.AspNetCore.OpenApi from 10.0.11 to 10.0.12
Bumps Microsoft.EntityFrameworkCore from 10.0.11 to 10.0.12
Bumps Microsoft.EntityFrameworkCore.Design from 10.0.11 to 10.0.12
Bumps Microsoft.EntityFrameworkCore.InMemory from 10.0.11 to 10.0.12
Bumps Microsoft.EntityFrameworkCore.Relational from 10.0.11 to 10.0.12
Bumps Microsoft.EntityFrameworkCore.Sqlite from 10.0.11 to 10.0.12
Bumps Microsoft.Extensions.ApiDescription.Server from 10.0.11 to 10.0.12
Bumps Microsoft.Extensions.Caching.Abstractions from 10.0.11 to 10.0.12
Bumps Microsoft.Extensions.Caching.Memory from 10.0.11 to 10.0.12
Bumps Microsoft.Extensions.Caching.StackExchangeRedis from 10.0.11 to 10.0.12
Bumps Microsoft.Extensions.Http from 10.0.11 to 10.0.12
Bumps Microsoft.IdentityModel.JsonWebTokens from 8.22.0 to 8.23.0
Bumps Microsoft.NET.Test.Sdk from 18.9.0 to 18.10.1
Bumps OpenAI from 2.13.0 to 2.14.0
Bumps PostHog from 2.14.0 to 2.15.7
Bumps PostHog.AspNetCore from 2.9.0 to 2.9.7
Bumps Scalar.AspNetCore from 2.17.1 to 2.17.9
Bumps Sentry.AspNetCore from 6.9.0 to 6.11.1
Bumps Stripe.net from 52.3.0 to 52.4.2

---
updated-dependencies:
- dependency-name: Google.Apis.AndroidPublisher.v3
  dependency-version: 1.76.0.4277
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Hangfire.AspNetCore
  dependency-version: 1.8.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Hangfire.Core
  dependency-version: 1.8.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Hangfire.Core
  dependency-version: 1.8.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.AspNetCore.Authentication.JwtBearer
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.AspNetCore.OpenApi
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore.Design
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore.Relational
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.Extensions.ApiDescription.Server
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.Extensions.Caching.Abstractions
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.Extensions.Caching.StackExchangeRedis
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.Extensions.Http
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.IdentityModel.JsonWebTokens
  dependency-version: 8.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: OpenAI
  dependency-version: 2.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: PostHog
  dependency-version: 2.15.7
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: PostHog.AspNetCore
  dependency-version: 2.9.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Scalar.AspNetCore
  dependency-version: 2.17.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Sentry.AspNetCore
  dependency-version: 6.11.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Stripe.net
  dependency-version: 52.4.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: FluentAssertions
  dependency-version: 8.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore.InMemory
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.Extensions.Caching.Memory
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: FluentAssertions
  dependency-version: 8.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: FluentAssertions
  dependency-version: 8.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore.InMemory
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore.Sqlite
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Give API key management one authorization concept with two doors (#528)

* Start #529 API key step up

* Require API key management step up

* chore: regenerate the architecture map

The step up change moved the API key endpoints' shape, so `architecture.json`
and `architecture.html` no longer matched the tree and the `drift` check
failed on `git diff --exit-code`. Regenerated with `node tools/arch-map.mjs`,
which reports 45 entities and 0 untested feature folders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Give API key management one authorization concept with two doors

Listing, creating and revoking API keys now read one grant. Two doors open
it, and both end in a six-digit code emailed to the account owner: the HTTP
challenge, and a verified agent step-up.

Creating key material spends the grant. Listing and revoking only read it,
so one emailed code authorizes a management session instead of exactly one
revoke. A revoke no longer locks the person out of the key list.

get_api_keys gets a door. RequireApiKeyCreationStepUp raises the read
capability to a step-up inside the executor, so the read opens a pending
operation the caller can step up against, and the MCP read routes through
McpExecutorBridge like the mutation does.

AppConfigService now parses a stored row strictly and throws by name when a
row is malformed, so a typo cannot leave the gate off while the read-back
reports it on.

Both controller actions declare 403 and 428, and openapi.json carries them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Cover API key step up through merged MCP gate

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Expose habit emoji in MCP tools (#513) (#546)

* Open ticket 513 implementation review

* Expose habit emoji across MCP tools (#513)

* Prevent stale pull request workflow runs for #659 (#555)

* Start #659 workflow concurrency change

* Group core PR workflows by pull request for #659

* Group remaining PR workflows by pull request for #659

* Fix #657 logout revocation across a session family (#553)

* Start #657 session family fix

* Revoke the full auth session after logout (#657)

* Explain temporary legacy token allowance (#657)

* Handle duplicate refresh history race (#657)

* Fix #588 bulk habit interval weeks (#539)

* Start #588 bulk interval weeks fix

* Fix #588 bulk habit interval weeks mapping

* Guarantee crisis resources in Astra chat for #319 (#547)

* Add crisis guidance to Astra static prompt for #319

* Add curated crisis detection and regression cases for #319

* Wire crisis guard across chat delivery and metrics for #319

* Refine crisis delivery and cover fallback regression for #319

* Return static crisis support when AI chat fails for #319

* Keep crisis FAQ regression isolated for #319

* Fix crisis replies to bypass AI quota and provider

* Record streak freeze source for #571 (#540)

* Start #571 freeze source work

* Record streak freeze origin for #571

* Require explicit origin for new streak freezes

* Keep support tool available for support entry point (#541)

* Expose scheduled streak repair gap dates for #505 (#542)

* Fix standalone sub habit title validation for #628 (#543)

* Start #628 sub habit title validation fix

* Use sub habit title messages in standalone validation (#628)

* Carry the onboarding repeat interval into the created habit (#596) (#533)

A signed-out person who set a weekly repeat interval during onboarding lost
it at sign-up. ApplyHabitInput carried no interval field at all, so the apply
path dropped the value between the screen and the record.

Add an optional IntervalWeeks to ApplyHabitInput, additive only, and pass it
to Habit.Create alongside the other schedule options. No existing field
changes name, shape or nullability, so a client that sends nothing keeps the
behaviour it has today: IntervalWeeks stays null and the habit repeats every
week.

Validate the new field with the shared SharedHabitRules.AddIntervalWeeksRules
range, the same 1 to 52 bound every other create path uses.

Regenerate openapi.json and architecture.json/html for the new field and for
the test class that now touches HabitScheduleService.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* Expose recurrence time zone in calendar events for #591 (#544)

* Start #591 recurrence time zone work

* Expose recurring calendar event time zone for #591

* Restore recurrence time zone in legacy suggestions for #591

* Project safe calendar BYDAY recurrences (#569) (#545)

* Start calendar BYDAY projection (#569)

* Project uniform calendar BYDAY recurrences (#569)

* Cover alternate week recurrence projection (#569)

* Keep shifted calendar rules importable by installed clients

* Add closed week and year recap ranges for #369 (#548)

* Fix Google sign-in after concurrent user updates (#551)

* chore: start ticket 324 review

* fix: retry concurrent Google sign-in updates (#324)

* Fix duplicate agent confirmation consumption (#557)

* Start fix for ticket 671 confirmation race

* Claim agent confirmation once under concurrent saves

* chore: regenerate the architecture map for the confirmation claim migration (#671)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix: show newest habit completion date in profile (#549)

* chore: start ticket 391

* fix: expose latest habit completion date in profile

* fix: exclude bad habit slips from last completion date

* fix: pass the freeze origin in the profile freeze test after #540 (#391)

The merge with main brought StreakFreeze.Create's required origin
parameter (#540). The test now asserts that a freeze without a
completion leaves lastCompletionDate null for both origins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: preserve last completion across habit cleanup (#391)

* Preserve descendant completions during sync cleanup

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Recover concurrent habit log duplicates (#325) (#552)

* Start #325 habit log duplicate recovery

* Recover duplicate habit logs without losing idempotency response

* chore(deps): bump the github-actions group across 1 directory with 5 updates (#530)

* chore(deps): bump the github-actions group across 1 directory with 5 updates

Bumps the github-actions group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `5` | `7` |
| [github/codeql-action/init](https://github.com/github/codeql-action) | `4.37.8` | `4.38.1` |
| [github/codeql-action/analyze](https://github.com/github/codeql-action) | `4.37.8` | `4.38.1` |
| [actions/setup-java](https://github.com/actions/setup-java) | `5` | `6` |
| [oasdiff/oasdiff-action/breaking](https://github.com/oasdiff/oasdiff-action) | `0.1.13` | `0.1.17` |



Updates `actions/upload-artifact` from 5 to 7
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](actions/upload-artifact@v5...v7)

Updates `github/codeql-action/init` from 4.37.8 to 4.38.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](github/codeql-action@db488dd...1c5b675)

Updates `github/codeql-action/analyze` from 4.37.8 to 4.38.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](github/codeql-action@db488dd...1c5b675)

Updates `actions/setup-java` from 5 to 6
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](actions/setup-java@v5...v6)

Updates `oasdiff/oasdiff-action/breaking` from 0.1.13 to 0.1.17
- [Release notes](https://github.com/oasdiff/oasdiff-action/releases)
- [Commits](oasdiff/oasdiff-action@2649ebe...5e81b5c)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: github/codeql-action/init
  dependency-version: 4.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: oasdiff/oasdiff-action/breaking
  dependency-version: 0.1.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix: restore optional response removal gate (#664)

* fix: keep the oasdiff levels file out of the repository root and allowlist .oasdiff.yaml (#664)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Thomas Luizon Rodrigues Gregorio <thomaslrgregorio@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* ORB-101: verify Turnstile tokens on anonymous writes (#550)

* chore: open ORB-101 review branch

* feat: gate anonymous writes with optional Turnstile verification

* test: cover Turnstile gate and verifier failures

* test: assert valid Turnstile request returns success

* test: ground Turnstile responses in observed Siteverify results

Document web, Android, and landing activation checks for ORB-101.

* Fix Astra goal access for every plan (#677) (#560)

* fix: accept stored recap week anchors after preference changes (#683) (#559)

* fix: resolve stored recap week anchors without the current preference

A closed week anchor lives in the recap share link and in the stored
snapshot key. Validating it against today's WeekStartDay rejected a
Monday anchor after the person moved to Sunday, before the stored
snapshot could be read. Accept any supported week start (Sunday or
Monday) for an explicit closed anchor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: use closed week anchor for recap cache misses

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Prune idle Hangfire session connections for #564 (#554)

* Bump Scalar.AspNetCore from 2.17.9 to 2.17.10 (#558)

---
updated-dependencies:
- dependency-name: Scalar.AspNetCore
  dependency-version: 2.17.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Make unsubscribe token tampering test byte-accurate (#673) (#561)

* Make goal completion timestamp test deterministic (#676) (#562)

* Start #676 goal clock fix

* Make goal completion timestamp test deterministic (#676)

* chore: regenerate the architecture map for the goal clock test

The test now references HabitLog, so node tools/arch-map.mjs adds it to
the test's references. Two runs produce the same output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Pin Pullfrog reviewer to GPT 6 Sol medium (#636) (#537)

* Pin Pullfrog reviewer model and effort for #636

* Set Pullfrog effort to medium on Thomas's decision (#636)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Describe the pinned Pullfrog reviewer in the workflow comment (#636)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: run the pinned Pullfrog commit so GPT-6 Sol medium applies

The published pullfrog 0.1.82 has no gpt-6-sol entry, so the effort
input was dropped. The review step now runs the action's own code at
pullfrog/pullfrog main 405f60c2, whose model table has gpt-6-sol with a
medium rung. Return to @v0 once 0.1.83 is published.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Take Pullfrog model and effort from the repository config (#565)

The app dispatch payload carries model and effort and outranks action
inputs (utils/payload.ts:320-326 at 405f60c2), so the inputs from #537
never applied (run 36187465385 resolved effort xhigh). Effort is now
0.25 (medium on GPT-6 Sol) in the Pullfrog repo config.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Fix relative reminders across local day boundaries (#566)

* chore: start reminder boundary fix for #695

* test: inject a fixed clock into relative reminder checks

* fix: send relative reminders on their local occurrence day

* chore: ratchet the scheduler suppression count and regenerate the map

#695 removed the two relative-reminder UTC-date suppressions, so the
closed allowlist now declares 2 ORBIT0004 sites in
ReminderSchedulerService.cs. The architecture map is regenerated for
the new clock dependency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: fire relative reminders at the first local due time across DST

Pullfrog review of 9ebf379: on a fall-back day a 01:30 due time is
ambiguous and ConvertTimeToUtc picked the later occurrence, an hour
after the old scheduler. A repeated hour now resolves to its first
occurrence, and a due time inside a spring-forward gap resolves to the
moment the clock jumps past it instead of being skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Fix bulk habit replay idempotency (#567)

* chore: open ticket 389 review early

* fix: make bulk habit writes replay safe

* fix: scope idempotency ledger by command content

* fix: scope idempotency ledger by request ordinal

* Use published Pullfrog v0 for Codex review (#568)

* Start #700 Pullfrog action update

* Use published Pullfrog v0 for Codex review

* ci: pin both Pullfrog steps to the v0 release commit

SonarCloud githubactions:S7637 flags a moving tag as an unpinned dependency.
Pin the commit the v0 tag named on 2026-09-26, release 0.1.83.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* docs: align worker PR handoff with ticket orders (#570)

* Fix child habit title messages on update for #651 (#572)

* Fix explicit culture formatting for #704 (#569)

* chore: begin #704 culture formatting work

* fix: format #704 notification prompts logs and goal tools explicitly

* fix: enforce invariant formatting across #704 prompts and machine text

* test: cover invariant decimal formatting for #704

* chore: refresh architecture artifacts for #704

* test: cover remaining culture formatting paths for #704

* Fix last completion date after habit type changes (#574)

* fix: preserve habit log slip kind at write time

* test: include slip kind in concurrent log fixture

* fix: classify old habit logs during rolling deploys

* Clean stale text in API repository (#575)

* Use neutral names in test fixtures

* Make repository text timeless

* Remove disallowed narration comment

* Add timeless text gate to API repository (#576)

* Add timeless text gate to API repository

* fix: read comments in XML build files

* fix: match script end tags with attributes

* fix: end a script only at a real end-tag delimiter

* Sync timeless checker with mobile main (#578)

* Sync timeless checker with mobile main

* fix: let a reviewed directory entry exempt any rule but machine-path

* fix: align timeless checker with reviewed version (#580)

Add regression coverage for hidden comments and introduced findings.

Refs thomasluizon/orbit-tickets#731

* Fix bulk habit replay plans across chat retries (#581)

* Fix bulk habit replay when selected chunks change

* Preserve bulk habit replay plans across retries

* Replay legacy bulk chunks and share replay planning

* Scope bulk replay plans by invocation and refuse legacy chat retries

* Fix bulk chat replay plan identity by invocation order

* Pin FluentValidation default messages to English (#582)

* fix: pin FluentValidation default messages to English

* chore: regenerate the architecture map

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Return a code for habit ceiling failures (#583)

* fix: return a code when the habit ceiling is reached

* chore: regenerate the architecture map

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Fix Google signup race during session save (#584)

* Fix Google signup race tracker cleanup

* Pin validator message test culture

* chore: generate architecture map outside git (#585)

* Cascade bulk habit deletion through subtrees (#587)

* fix: cascade bulk habit deletion through subtrees

* fix: keep bulk delete results aligned with requested habits

* Fix habit checklist reset and general-habit end dates (#586)

* Fix habit checklist, date, and reminder invariants

* Verify stored reminder JSON fields in migration test

* Keep habit checklist and end date fixes

* fix: keep database pool connections warm (#590)

* Complete error copy for synced habit guards

* Refresh gating matrix provenance after copy sync

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
thomasluizon added a commit that referenced this pull request Sep 27, 2026
* Add habit widget empty reason (#527)

* Start #372

* Add habit widget empty reason

* ORB-223: Generate gating matrix from PayGateService (#520)

* chore: start ORB-223

* feat: generate gating matrix

* fix: fail closed on unreadable plan gates

* fix: close gating matrix parser gaps

* fix: preserve plans in combined guards

* fix: distinguish quota lifted plans

* fix: carry plan provenance through quota aliases

Derived quota variables were recorded unscoped and only the variable that
literally contains the plan ternary was relabelled afterwards, so plan
provenance did not survive a local alias. A semantics-preserving
`var selectedLimit = user.HasProAccess ? proLimit : freeLimit;
var messageLimit = selectedLimit;` generated cleanly and changed
CanSendAiMessage.quotaLiftedByPlan from "Pro" to null.

The directly plan-selected variables are now labelled first, and the
fixed-point propagation carries the source variable's exact plan instead of
null. An assignment deriving from two different plans cannot have its
provenance proven, so generation fails closed with the same
"cannot derive plan requirement" error the other unprovable shapes use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: fail closed on unknown gating sources

* fix: derive feature flags from migrations

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: fall back to Claude only when the Codex reviewer step fails (#529)

Mirrors thomasluizon/orbit-ui-mobile#1022.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Let the executor own the MCP confirmation gate (#599) (#534)

* fix: let the executor own the MCP confirmation gate (#599)

Every MCP tool whose capability requires a confirmation or a step-up was
refused forever. The selective-auth middleware evaluated the policy a second
time, in front of the executor, and it never received the caller's
confirmation token. A client that stepped up and retried with a valid token
got the identical refusal, because the token never reached that evaluation.

Carrying the token into the middleware cannot fix it. A confirmation token is
single use and is bound to the pending operation's fingerprint, and the
middleware computes a different fingerprint from the raw MCP arguments than
the executor computes from the operation id and its snake_case argument
object. One token cannot satisfy two gates.

So the middleware now steps aside for a confirmation-gated capability, the
same way it already steps aside for execute_agent_operation_v2. Both reach
IAgentOperationExecutor, which evaluates access and confirmation together,
holds the token, and writes the audit row.

Two guard tests pin the invariants the deferral rests on: a confirmation
requirement always sits on a mutation, and every confirmation-gated MCP tool
reaches the executor through McpExecutorBridge.

Also corrected: the step-up message and the three tool parameter descriptions
named verify_step_up_agent_operation_v2 as the source of the confirmation
token. It does not return one. confirm_agent_operation_v2 does.

Removed AgentPolicyEvaluationContext.StepUpSatisfied. Nothing ever set it and
nothing ever read it; the real step-up state lives on
PendingAgentOperationState.StepUpSatisfiedAtUtc.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: thread the confirmation token through the bulk habit tools (#599)

bulk_log_habits and bulk_skip_habits declared no confirmationToken
parameter and their shared helper hardcoded confirmationToken: null, so
their HabitsBulkWrite capability (FreshConfirmation) could never see a
token and both tools stayed permanently refused. Both now declare the
parameter and ExecuteBulkHabitOperationAsync forwards it.

Three new guards in ConfirmationGatedMcpToolsRouteThroughExecutorTests
pin what the old one missed. The scan now asserts it reaches exactly the
catalog's gated MCP tools, that each tool's forwarded operation id
resolves to a capability with the same ConfirmationRequirement, and that
each tool accepts and forwards a confirmation token. The source scan
walks subfolders and keys members by their declaration rather than by the
first invocation-shaped token in the chunk.

AgentOperationExecutor writes an AgentAuditLogs row before returning
UnknownOperation, restoring the trail the middleware used to leave.

McpConfirmationGateTests now drives the real AgentTools recovery methods
end to end and pins that all three refuse an API-key credential.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: resolve the forwarded MCP confirmation token back to the tool parameter (#599)

The token guard whitelisted one spelling of null. It flagged a bridge call only
when the confirmation-token argument was absent or the literal `null`, so
`confirmationToken: default`, `null!`, `(string?)null` and `""` all passed while
the tool stayed refused forever. A longer literal list does not close that,
because the next spelling is not on the list either.

Assert the structural property instead: a gated tool declares a
`string? confirmationToken` parameter, and the expression it forwards into the
executor's token slot resolves back to that identifier, directly or through one
helper hop. Every other expression fails, whatever it spells.

The declaration check now reads the tool's parameter list rather than its whole
body, so a local named `confirmationToken` no longer satisfies it.

Also add `forwarded.Id == tool.Capability.Id` to the operation-id guard. It
compared only the confirmation requirement, so a gated tool could forward
another gated capability's operation id, keep confirmation firing, and have the
executor enforce the wrong scope.

The file's doc comment said four invariants and listed four; the file holds
five. Name the source-scan invariant the other three rest on.

Six mutations, each red on its own, real tree green at every step:
`confirmationToken: default`, `null!`, `(string?)null`, `""`, a gated tool
forwarding a different local, and `delete_tag` forwarding `delete_goal`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: strip only a real named-argument prefix in the MCP guard parser (#599)

ArgumentAt treated any colon as a named-argument separator, so a conditional
expression in the token slot or the operation-id slot collapsed to its
else-branch. `ids.Count > 0 ? null : confirmationToken` read as
`confirmationToken` and `tagId.Length > 0 ? "delete_goal" : "delete_tag"` read
as `"delete_tag"`, and both guards stayed green over the restored defect.

Match `^\w+\s*:(?!:)` instead, so only a leading `name:` prefix is stripped and
every other expression reaches the resolver whole. The anchor keeps a colon
inside a string literal and a `::` qualifier from matching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: separate an omitted caller argument from a non-parameter expression (#599)

ResolveExpression returned the expression itself both when it was not a callee
parameter and when the caller supplied nothing at that index. The second case
handed back the helper's own parameter name, which then compared equal to
`confirmationToken` and passed. Giving the helper an optional
`string? confirmationToken = null` and calling it without that argument restored
the #599 defect with the guard green.

Return the `<omitted>` sentinel for the second case instead. It can never equal
the parameter name, and it resolves to no capability in the operation-id slot,
so both guards fail closed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: read a gated tool's direct and helper bridge calls together (#599)

CollectBridgeCalls returned early as soon as the tool body held one bridge
call, so a correct call in a dead branch hid every helper call in the same
tool. A decoy `if (tagId.Length == 0)` block forwarding `confirmationToken`
plus a same-file helper forwarding `null` restored the #599 defect with the
guard green.

Collect both sets instead. Every bridge call a gated tool can reach, directly
or through one helper hop, now has to forward the parameter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: refuse a gated tool that assigns to its own confirmationToken (#599)

The rule compared the forwarded expression's text only, so
`confirmationToken = null;` above the bridge call restored the #599 defect with
the forwarded identifier unchanged and the guard green.

Add an offender when the tool body, or a same-file helper it reaches, assigns
to the parameter. MemberSource now carries its statements with the parameter
list excluded, so the declaration's own `string? confirmationToken = null`
default does not match. The pattern `\bconfirmationToken\s*=[^=>]` leaves `==`,
`!=`, `>=`, `<=`, `??=` and a lambda arrow alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: say what the MCP token resolver models, not more (#599)

Invariant 5 claimed "any other expression in the token slot refuses the tool
forever" and that the tool forwards the identifier "directly or through a
helper it calls". A conditional refuses the tool on some paths only, and the
resolver models exactly one same-file helper hop matched by argument position.

Replace both sentences with what the scan actually does, and state its limits:
two or more hops read as no bridge call and fail the routing guard, and
aliasing, reflection and an interface call are outside the model.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: catch a compound assignment to confirmationToken (#599)

The assignment rule was anchored on `\s*=`, and `\s*` cannot cross the `??`
of `??=` or the `+` of `+=`. So `confirmationToken ??= tagId;` above the
bridge call compiled green with all five guards green, and a later change
that writes `confirmationToken ??= await ResolveStoredTokenAsync(...)` would
hand the executor a token the MCP caller never sent: HasFreshConfirmation is
then satisfied from state the caller does not control, while the middleware
has already stepped aside.

Admit the two compound operators a `string?` can carry. `==` still fails on
the second `=`, `!=`, `>=` and `<=` still fail because those characters break
`\s*` and are not in the alternation, `=>` still fails on the trailing
character class, and a bare `??` with no `=` still fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: read every same-named helper an MCP tool could be calling (#599)

The member index was a dictionary keyed on the bare name and built with
group.First(), so two same-named members collapsed to whichever was declared
first. The scan then analysed a method the tool never calls. It broke both
ways: a decoy overload declared after the real helper and forwarding null was
green, and a gated tool calling a correct overload reddened when an unrelated
same-named member happened to be declared first.

Hold every member under its name and, at each call site, read every candidate
whose parameter count can admit that many arguments. One candidate resolves
the call; more than one is ambiguous, so all of them are read and any bad one
reddens the tool. That fails closed instead of guessing, and it costs no false
red in the case above, where only the real helper carries a bridge call. A
name that admits no candidate stays unresolved, which hides nothing: the
bridge call inside it is not collected either, so the routing guard reddens
the tool.

Arity, not the parameter type, is what decides a candidate. The scan is a text
scan, so it cannot type-check an argument.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: say what invariant 5 now refuses and what it still cannot see (#599)

The general claim "refuses a tool that assigns to the parameter" is now exact:
`=`, `??=` and `+=`. The helper hop no longer claims a "matching argument
position", because the resolver pairs by position alone and strips a
named-argument prefix without reading the name, so arguments named out of the
declared order are read wrong. The comment said nothing about overloads, so
say that an ambiguous name is read as every candidate and reddens the tool.

Close with the honest limit: a text scan is defeatable by an author who sets
out to defeat it, and what the guard closes is every shape an ordinary
refactor produces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* Fix calendar event timezone projection (#521)

* Start calendar timezone fix

* Fix calendar event timezone projection

* Fix projected calendar end times

* Fix calendar end time during DST fallback

* Fix calendar end time minute precision

* Fix projected calendar recurrence rule

* Guard calendar recurrence across timezone shifts

* Project calendar recurrence weekdays

* Keep calendar recurrence unchanged

* Omit unrepresentable recurring calendar events

* Gate projected calendar recurrence on the whole fetch window

Closes the six review findings on pull request 521.

1. The weekday gate now walks every expanded occurrence Google returned for a
   recurring master, not the one sampled instance. The fetcher collects each
   instance start with the source calendar's own offset into a JsonIgnore'd
   ExpandedOccurrences list, so a Lisbon BYDAY=TH series that is stable in
   January and shifts to Wednesday in July is refused. The empty list still
   falls back to the sampled instance for an all-day series and for a
   suggestion row read back from the database.
2. The legacy title plus date plus time key only excludes a suggestion when
   exactly one candidate carries it. Inside a fall-back repeated hour two
   events project to the same local time, so the key proves nothing and both
   stay. This mirrors the group.Count() == 1 guard the auto sync reconciler
   already applies to the same key.
3. An omitted end time now logs its reason at Debug with the event id and the
   user id. EndUtc already ships the real duration, so the client keeps it.
4. The refusal here and the clamp in HabitScheduleService.IsMonthlyMatch are
   reconciled in a doc comment: Orbit clamps a rule it owns and refuses a rule
   it imports and cannot re-express without inventing a weekday.
5. Both new call sites pass the logger and the user id to FindTimeZone, which
   also catches InvalidTimeZoneException so a corrupt zone stops escaping as a
   500. User.SetTimeZone trims at the boundary and rejects a blank id.
6. Asia/Kathmandu at plus 05:45 and Pacific/Chatham at plus 12:45 cover the
   sub hour offset gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Prove calendar recurrence stability from both zones

Round 6 gated a BYDAY series on the expanded instances Google returned, and
GoogleCalendarApi only asks for sixty days, so a series whose transition falls
outside that window was admitted and a stored suggestion row never carried the
instances at all.

The gate now reads the source calendar's own timezone from the recurring master
the fetcher already fetches, and walks a year of dates at the occurrence's source
wall clock through both zones' rules. A series it cannot prove stable is withheld
from both feeds. StoredCalendarEventJson carries the source zone beside the stored
suggestion, so the suggestion feed judges a row on the same evidence the events
feed had.

Also in scope: the auto-sync reconciler projects a fetched event into the account
timezone before matching a legacy habit, the end-time omission logs whatever EndUtc
holds, and an all-day event no longer reports Google's exclusive end date as an
end instant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Keep calendar series a spring forward gap cannot move

A probe date whose source wall clock a spring forward gap removes is a date
the series does not fire on, not evidence the series moves. The walk ended
there with "unproved" and the gate withheld the whole series, so an account
reading a calendar kept in its own zone lost an hour wide band in thirteen
DST zones even though the projection is the identity.

HasUnrepresentableRecurrenceAfterProjection now answers false as soon as
TimeZoneInfo.HasSameRules holds, and the walk skips a wall clock its own zone
removes instead of ending. RFC 5545 section 3.3.5 does define the missing
case, but probing that normalized instant changes no decision for any of the
14,752 zone pairs that could distinguish it, and asserting an expansion
Google does not document would let one guessed date withhold a year.

Also pins what earlier rounds argued: auto-sync refusing to store or notify a
withheld series, JsonIgnore keeping SourceTimeZone off the response body, the
repeated hour needing both of its instants, the probe reaching a full year,
and an all day event logging no dropped end time. A stored SourceTimeZone key
holding a number now reads as no source zone rather than failing the request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Fix recurring calendar timezone proof and legacy refresh

* Avoid refreshing legacy calendar rows with duplicate event IDs

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* Bump FluentAssertions and 23 others (#538)

Bumps FluentAssertions from 8.10.0 to 8.11.0
Bumps Google.Apis.AndroidPublisher.v3 from 1.75.0.4246 to 1.76.0.4277
Bumps Hangfire.AspNetCore from 1.8.24 to 1.8.25
Bumps Hangfire.Core from 1.8.24 to 1.8.25
Bumps Microsoft.AspNetCore.Authentication.JwtBearer from 10.0.11 to 10.0.12
Bumps Microsoft.AspNetCore.OpenApi from 10.0.11 to 10.0.12
Bumps Microsoft.EntityFrameworkCore from 10.0.11 to 10.0.12
Bumps Microsoft.EntityFrameworkCore.Design from 10.0.11 to 10.0.12
Bumps Microsoft.EntityFrameworkCore.InMemory from 10.0.11 to 10.0.12
Bumps Microsoft.EntityFrameworkCore.Relational from 10.0.11 to 10.0.12
Bumps Microsoft.EntityFrameworkCore.Sqlite from 10.0.11 to 10.0.12
Bumps Microsoft.Extensions.ApiDescription.Server from 10.0.11 to 10.0.12
Bumps Microsoft.Extensions.Caching.Abstractions from 10.0.11 to 10.0.12
Bumps Microsoft.Extensions.Caching.Memory from 10.0.11 to 10.0.12
Bumps Microsoft.Extensions.Caching.StackExchangeRedis from 10.0.11 to 10.0.12
Bumps Microsoft.Extensions.Http from 10.0.11 to 10.0.12
Bumps Microsoft.IdentityModel.JsonWebTokens from 8.22.0 to 8.23.0
Bumps Microsoft.NET.Test.Sdk from 18.9.0 to 18.10.1
Bumps OpenAI from 2.13.0 to 2.14.0
Bumps PostHog from 2.14.0 to 2.15.7
Bumps PostHog.AspNetCore from 2.9.0 to 2.9.7
Bumps Scalar.AspNetCore from 2.17.1 to 2.17.9
Bumps Sentry.AspNetCore from 6.9.0 to 6.11.1
Bumps Stripe.net from 52.3.0 to 52.4.2

---
updated-dependencies:
- dependency-name: Google.Apis.AndroidPublisher.v3
  dependency-version: 1.76.0.4277
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Hangfire.AspNetCore
  dependency-version: 1.8.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Hangfire.Core
  dependency-version: 1.8.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Hangfire.Core
  dependency-version: 1.8.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.AspNetCore.Authentication.JwtBearer
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.AspNetCore.OpenApi
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore.Design
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore.Relational
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.Extensions.ApiDescription.Server
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.Extensions.Caching.Abstractions
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.Extensions.Caching.StackExchangeRedis
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.Extensions.Http
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.IdentityModel.JsonWebTokens
  dependency-version: 8.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: OpenAI
  dependency-version: 2.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: PostHog
  dependency-version: 2.15.7
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: PostHog.AspNetCore
  dependency-version: 2.9.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Scalar.AspNetCore
  dependency-version: 2.17.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Sentry.AspNetCore
  dependency-version: 6.11.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Stripe.net
  dependency-version: 52.4.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: FluentAssertions
  dependency-version: 8.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore.InMemory
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.Extensions.Caching.Memory
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: FluentAssertions
  dependency-version: 8.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: FluentAssertions
  dependency-version: 8.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore.InMemory
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.EntityFrameworkCore.Sqlite
  dependency-version: 10.0.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
- dependency-name: Microsoft.NET.Test.Sdk
  dependency-version: 18.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Give API key management one authorization concept with two doors (#528)

* Start #529 API key step up

* Require API key management step up

* chore: regenerate the architecture map

The step up change moved the API key endpoints' shape, so `architecture.json`
and `architecture.html` no longer matched the tree and the `drift` check
failed on `git diff --exit-code`. Regenerated with `node tools/arch-map.mjs`,
which reports 45 entities and 0 untested feature folders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Give API key management one authorization concept with two doors

Listing, creating and revoking API keys now read one grant. Two doors open
it, and both end in a six-digit code emailed to the account owner: the HTTP
challenge, and a verified agent step-up.

Creating key material spends the grant. Listing and revoking only read it,
so one emailed code authorizes a management session instead of exactly one
revoke. A revoke no longer locks the person out of the key list.

get_api_keys gets a door. RequireApiKeyCreationStepUp raises the read
capability to a step-up inside the executor, so the read opens a pending
operation the caller can step up against, and the MCP read routes through
McpExecutorBridge like the mutation does.

AppConfigService now parses a stored row strictly and throws by name when a
row is malformed, so a typo cannot leave the gate off while the read-back
reports it on.

Both controller actions declare 403 and 428, and openapi.json carries them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Cover API key step up through merged MCP gate

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Expose habit emoji in MCP tools (#513) (#546)

* Open ticket 513 implementation review

* Expose habit emoji across MCP tools (#513)

* Prevent stale pull request workflow runs for #659 (#555)

* Start #659 workflow concurrency change

* Group core PR workflows by pull request for #659

* Group remaining PR workflows by pull request for #659

* Fix #657 logout revocation across a session family (#553)

* Start #657 session family fix

* Revoke the full auth session after logout (#657)

* Explain temporary legacy token allowance (#657)

* Handle duplicate refresh history race (#657)

* Fix #588 bulk habit interval weeks (#539)

* Start #588 bulk interval weeks fix

* Fix #588 bulk habit interval weeks mapping

* Guarantee crisis resources in Astra chat for #319 (#547)

* Add crisis guidance to Astra static prompt for #319

* Add curated crisis detection and regression cases for #319

* Wire crisis guard across chat delivery and metrics for #319

* Refine crisis delivery and cover fallback regression for #319

* Return static crisis support when AI chat fails for #319

* Keep crisis FAQ regression isolated for #319

* Fix crisis replies to bypass AI quota and provider

* Record streak freeze source for #571 (#540)

* Start #571 freeze source work

* Record streak freeze origin for #571

* Require explicit origin for new streak freezes

* Keep support tool available for support entry point (#541)

* Expose scheduled streak repair gap dates for #505 (#542)

* Fix standalone sub habit title validation for #628 (#543)

* Start #628 sub habit title validation fix

* Use sub habit title messages in standalone validation (#628)

* Carry the onboarding repeat interval into the created habit (#596) (#533)

A signed-out person who set a weekly repeat interval during onboarding lost
it at sign-up. ApplyHabitInput carried no interval field at all, so the apply
path dropped the value between the screen and the record.

Add an optional IntervalWeeks to ApplyHabitInput, additive only, and pass it
to Habit.Create alongside the other schedule options. No existing field
changes name, shape or nullability, so a client that sends nothing keeps the
behaviour it has today: IntervalWeeks stays null and the habit repeats every
week.

Validate the new field with the shared SharedHabitRules.AddIntervalWeeksRules
range, the same 1 to 52 bound every other create path uses.

Regenerate openapi.json and architecture.json/html for the new field and for
the test class that now touches HabitScheduleService.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>

* Expose recurrence time zone in calendar events for #591 (#544)

* Start #591 recurrence time zone work

* Expose recurring calendar event time zone for #591

* Restore recurrence time zone in legacy suggestions for #591

* Project safe calendar BYDAY recurrences (#569) (#545)

* Start calendar BYDAY projection (#569)

* Project uniform calendar BYDAY recurrences (#569)

* Cover alternate week recurrence projection (#569)

* Keep shifted calendar rules importable by installed clients

* Add closed week and year recap ranges for #369 (#548)

* Fix Google sign-in after concurrent user updates (#551)

* chore: start ticket 324 review

* fix: retry concurrent Google sign-in updates (#324)

* Fix duplicate agent confirmation consumption (#557)

* Start fix for ticket 671 confirmation race

* Claim agent confirmation once under concurrent saves

* chore: regenerate the architecture map for the confirmation claim migration (#671)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix: show newest habit completion date in profile (#549)

* chore: start ticket 391

* fix: expose latest habit completion date in profile

* fix: exclude bad habit slips from last completion date

* fix: pass the freeze origin in the profile freeze test after #540 (#391)

The merge with main brought StreakFreeze.Create's required origin
parameter (#540). The test now asserts that a freeze without a
completion leaves lastCompletionDate null for both origins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: preserve last completion across habit cleanup (#391)

* Preserve descendant completions during sync cleanup

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Recover concurrent habit log duplicates (#325) (#552)

* Start #325 habit log duplicate recovery

* Recover duplicate habit logs without losing idempotency response

* chore(deps): bump the github-actions group across 1 directory with 5 updates (#530)

* chore(deps): bump the github-actions group across 1 directory with 5 updates

Bumps the github-actions group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `5` | `7` |
| [github/codeql-action/init](https://github.com/github/codeql-action) | `4.37.8` | `4.38.1` |
| [github/codeql-action/analyze](https://github.com/github/codeql-action) | `4.37.8` | `4.38.1` |
| [actions/setup-java](https://github.com/actions/setup-java) | `5` | `6` |
| [oasdiff/oasdiff-action/breaking](https://github.com/oasdiff/oasdiff-action) | `0.1.13` | `0.1.17` |



Updates `actions/upload-artifact` from 5 to 7
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](actions/upload-artifact@v5...v7)

Updates `github/codeql-action/init` from 4.37.8 to 4.38.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](github/codeql-action@db488dd...1c5b675)

Updates `github/codeql-action/analyze` from 4.37.8 to 4.38.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](github/codeql-action@db488dd...1c5b675)

Updates `actions/setup-java` from 5 to 6
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](actions/setup-java@v5...v6)

Updates `oasdiff/oasdiff-action/breaking` from 0.1.13 to 0.1.17
- [Release notes](https://github.com/oasdiff/oasdiff-action/releases)
- [Commits](oasdiff/oasdiff-action@2649ebe...5e81b5c)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: github/codeql-action/init
  dependency-version: 4.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: oasdiff/oasdiff-action/breaking
  dependency-version: 0.1.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix: restore optional response removal gate (#664)

* fix: keep the oasdiff levels file out of the repository root and allowlist .oasdiff.yaml (#664)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Thomas Luizon Rodrigues Gregorio <thomaslrgregorio@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* ORB-101: verify Turnstile tokens on anonymous writes (#550)

* chore: open ORB-101 review branch

* feat: gate anonymous writes with optional Turnstile verification

* test: cover Turnstile gate and verifier failures

* test: assert valid Turnstile request returns success

* test: ground Turnstile responses in observed Siteverify results

Document web, Android, and landing activation checks for ORB-101.

* Fix Astra goal access for every plan (#677) (#560)

* fix: accept stored recap week anchors after preference changes (#683) (#559)

* fix: resolve stored recap week anchors without the current preference

A closed week anchor lives in the recap share link and in the stored
snapshot key. Validating it against today's WeekStartDay rejected a
Monday anchor after the person moved to Sunday, before the stored
snapshot could be read. Accept any supported week start (Sunday or
Monday) for an explicit closed anchor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: use closed week anchor for recap cache misses

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Prune idle Hangfire session connections for #564 (#554)

* Bump Scalar.AspNetCore from 2.17.9 to 2.17.10 (#558)

---
updated-dependencies:
- dependency-name: Scalar.AspNetCore
  dependency-version: 2.17.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Make unsubscribe token tampering test byte-accurate (#673) (#561)

* Make goal completion timestamp test deterministic (#676) (#562)

* Start #676 goal clock fix

* Make goal completion timestamp test deterministic (#676)

* chore: regenerate the architecture map for the goal clock test

The test now references HabitLog, so node tools/arch-map.mjs adds it to
the test's references. Two runs produce the same output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Pin Pullfrog reviewer to GPT 6 Sol medium (#636) (#537)

* Pin Pullfrog reviewer model and effort for #636

* Set Pullfrog effort to medium on Thomas's decision (#636)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Describe the pinned Pullfrog reviewer in the workflow comment (#636)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: run the pinned Pullfrog commit so GPT-6 Sol medium applies

The published pullfrog 0.1.82 has no gpt-6-sol entry, so the effort
input was dropped. The review step now runs the action's own code at
pullfrog/pullfrog main 405f60c2, whose model table has gpt-6-sol with a
medium rung. Return to @v0 once 0.1.83 is published.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Take Pullfrog model and effort from the repository config (#565)

The app dispatch payload carries model and effort and outranks action
inputs (utils/payload.ts:320-326 at 405f60c2), so the inputs from #537
never applied (run 36187465385 resolved effort xhigh). Effort is now
0.25 (medium on GPT-6 Sol) in the Pullfrog repo config.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Fix relative reminders across local day boundaries (#566)

* chore: start reminder boundary fix for #695

* test: inject a fixed clock into relative reminder checks

* fix: send relative reminders on their local occurrence day

* chore: ratchet the scheduler suppression count and regenerate the map

#695 removed the two relative-reminder UTC-date suppressions, so the
closed allowlist now declares 2 ORBIT0004 sites in
ReminderSchedulerService.cs. The architecture map is regenerated for
the new clock dependency.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: fire relative reminders at the first local due time across DST

Pullfrog review of 9ebf379: on a fall-back day a 01:30 due time is
ambiguous and ConvertTimeToUtc picked the later occurrence, an hour
after the old scheduler. A repeated hour now resolves to its first
occurrence, and a due time inside a spring-forward gap resolves to the
moment the clock jumps past it instead of being skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Fix bulk habit replay idempotency (#567)

* chore: open ticket 389 review early

* fix: make bulk habit writes replay safe

* fix: scope idempotency ledger by command content

* fix: scope idempotency ledger by request ordinal

* Use published Pullfrog v0 for Codex review (#568)

* Start #700 Pullfrog action update

* Use published Pullfrog v0 for Codex review

* ci: pin both Pullfrog steps to the v0 release commit

SonarCloud githubactions:S7637 flags a moving tag as an unpinned dependency.
Pin the commit the v0 tag named on 2026-09-26, release 0.1.83.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* docs: align worker PR handoff with ticket orders (#570)

* Fix child habit title messages on update for #651 (#572)

* Fix explicit culture formatting for #704 (#569)

* chore: begin #704 culture formatting work

* fix: format #704 notification prompts logs and goal tools explicitly

* fix: enforce invariant formatting across #704 prompts and machine text

* test: cover invariant decimal formatting for #704

* chore: refresh architecture artifacts for #704

* test: cover remaining culture formatting paths for #704

* Fix last completion date after habit type changes (#574)

* fix: preserve habit log slip kind at write time

* test: include slip kind in concurrent log fixture

* fix: classify old habit logs during rolling deploys

* Clean stale text in API repository (#575)

* Use neutral names in test fixtures

* Make repository text timeless

* Remove disallowed narration comment

* Add timeless text gate to API repository (#576)

* Add timeless text gate to API repository

* fix: read comments in XML build files

* fix: match script end tags with attributes

* fix: end a script only at a real end-tag delimiter

* Sync timeless checker with mobile main (#578)

* Sync timeless checker with mobile main

* fix: let a reviewed directory entry exempt any rule but machine-path

* fix: align timeless checker with reviewed version (#580)

Add regression coverage for hidden comments and introduced findings.

Refs thomasluizon/orbit-tickets#731

* Fix bulk habit replay plans across chat retries (#581)

* Fix bulk habit replay when selected chunks change

* Preserve bulk habit replay plans across retries

* Replay legacy bulk chunks and share replay planning

* Scope bulk replay plans by invocation and refuse legacy chat retries

* Fix bulk chat replay plan identity by invocation order

* Pin FluentValidation default messages to English (#582)

* fix: pin FluentValidation default messages to English

* chore: regenerate the architecture map

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Return a code for habit ceiling failures (#583)

* fix: return a code when the habit ceiling is reached

* chore: regenerate the architecture map

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Fix Google signup race during session save (#584)

* Fix Google signup race tracker cleanup

* Pin validator message test culture

* chore: generate architecture map outside git (#585)

* Cascade bulk habit deletion through subtrees (#587)

* fix: cascade bulk habit deletion through subtrees

* fix: keep bulk delete results aligned with requested habits

* Fix habit checklist reset and general-habit end dates (#586)

* Fix habit checklist, date, and reminder invariants

* Verify stored reminder JSON fields in migration test

* Keep habit checklist and end date fixes

* fix: keep database pool connections warm (#590)

* Project scheduler reads to required columns (#589)

* Project scheduler reads to notification fields

* Deduplicate reminder scheduler projections

* Project streak and achievement reads to reduce database egress (#591)

* Project distinct streak completion dates in SQL

* Project achievement streak and time log fields

* Project gamification log inputs for streaks and awards

* Bound goal and skip log reads to relevant windows

* Project habit schedule fields for streak computations

* Update streak repair fixtures for projected reads

* fix: preserve backdated standard goal completions

* Bound Today schedule log reads (#592)

* fix: bound today schedule log reads

* test: cover today schedule log read branches

* Fix repeated bulk habit calls in keyed requests (#595)

* fix: preserve distinct bulk habit call positions (#751)

* test: update habit log index expectations (#751)

* fix: enforce flexible habit completion target in domain (#751)

* test: keep interval month fixture within flexible target (#751)

* Preserve relative reminders at local times for due habits (#594)

* Fix relative reminders across due times and daylight saving

* Align habit tool tests with clock reminder storage

* Guard relative reminders without due times and legacy sends

* Keep folded clock reminders visible to legacy clients

* Fix relative reminder defaults and delivery deduplication

* Fix repeat sends for after-due reminders across UTC boundary

* Cache reminder scheduler reads between changes (#596)

* fix: cache reminder scheduler reads between changes

Refresh candidates when habit or user data changes, and bound cached history to one hour. Preserve database uniqueness when scheduler instances overlap.

Refs thomasluizon/orbit-tickets#760

* chore: drop the reminder scheduler allowlist entry its suppression no longer needs

Refs thomasluizon/orbit-tickets#760

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Reduce completion date query rows for #762 (#598)

* test: pin completion date behavior for ticket 762

* fix: project distinct completion dates for freeze and history

* fix: narrow achievement streak evidence read

* test: measure projected rows for large account

* Reduce daily summary log egress for ticket 763 (#599)

* test: pin daily summary log behavior for ticket 763

* fix: narrow daily summary log reads to needed dates and values

* test: compare narrowed summary prompt with legacy read

* test: assert summary log SQL omits unused columns

* Add account event stream for live account updates (#601)

* Add account event stream with commit-aware delivery

* Map event routes and stabilize achievement fixture

* Verify reset resync after transaction commit

* Verify event ticket claims from a validated access token

* fix: publish notification account events

* Share habit schedule reads across logging checks (#597)

* Share rule eligible habit schedule across log checks

* Assert excluded habit logs still count toward achievements

* Prove shared schedule SQL on large account seed

* Fix habit schedule snapshot invalidation

* fix: invalidate habit schedule on transaction retry

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Add child creation timestamps to habit responses (#604)

* ORB-194 part 1: remove the ad reward from Astra and deprecate its route (#600)

* Remove ad reward claim paths in ORB-194 part 1

* Restore deprecated ad reward endpoint

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* fix: keep user id out of shared schedule snapshots (#605)

* Aggregate reminder scheduler probe (#606)

* fix: aggregate reminder scheduler probe

* fix: support in memory scheduler tests

* fix: preserve reminder probe invalidation across owner swaps

* fix: invalidate reminder snapshot on account reset

* chore: drop obsolete reminder fold trigger (#608)

* fix: keep user id out of shared schedule snapshots (#605)

(cherry picked from commit b81d4bf)

* Aggregate reminder scheduler probe (#606)

* fix: aggregate reminder scheduler probe

* fix: support in memory scheduler tests

* fix: preserve reminder probe invalidation across owner swaps

* fix: invalidate reminder snapshot on account reset

(cherry picked from commit 2103aec)

* chore: drop obsolete reminder fold trigger (#608)

(cherry picked from commit 0a66234)

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.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