Skip to content

Raise habit ceiling to abuse guard - #490

Merged
thomasluizon merged 10 commits into
mainfrom
feature/ticket-192-habit-ceiling
Aug 23, 2026
Merged

Raise habit ceiling to abuse guard#490
thomasluizon merged 10 commits into
mainfrom
feature/ticket-192-habit-ceiling

Conversation

@thomasluizon

@thomasluizon thomasluizon commented Aug 23, 2026

Copy link
Copy Markdown
Owner

Summary

  • Raises the live top-level habit ceiling to 1000.
  • Enforces the same configured ceiling for free and Pro users in direct, bulk, duplicate, chat, and onboarding creation.
  • Applies the same serialized live-root entry invariant to restore, child promotion, completed-root unlog, end-date reactivation, and IsGeneral reactivation.
  • Routes both registered chat parent-move tools through the guarded command.
  • Holds HabitCeilingLock through allowance checks, persistence, and transaction commit.
  • Returns a neutral validation failure with no entitlement error code or upgrade prompt.
  • Migrates the existing configuration row and updates the assistant explanation.

Dependency

Do not merge until #168 is merged.

Testing

  • dotnet build Orbit.slnx --no-restore: 0 errors.
  • Focused review-fix suites: 177 passed.
  • dotnet test --no-build: 5,950 passed.
  • Generated migration SQL from both migration 0 and the immediately preceding migration ends by updating FreeMaxHabits to 1000 with the abuse-guard description.

Assumptions

  • Derived the exact refusal text by removing the existing plan qualifier and upgrade sentence, yielding You've reached the 1000 habit limit.; rejected adding new explanatory copy.
  • Treated the ceiling as a committed invariant for every mutation entering the live undeleted root set; rejected limiting serialization to creation endpoints.

External interface evidence

The migration test reads Entity Framework Core 10.0.10 UpdateDataOperation after schema introspection of the installed Microsoft.EntityFrameworkCore.Relational.dll. The complete property schema returned was: Columns string[], ColumnTypes string[], IsDestructiveChange bool, IsReadOnly bool, Item object, KeyColumns string[], KeyColumnTypes string[], KeyValues object[,], Schema string, Table string, and Values object[,]. Re-derive it by loading the installed 10.0.10 Entity Framework Core and Relational assemblies and calling GetProperties() on Microsoft.EntityFrameworkCore.Migrations.Operations.UpdateDataOperation.

Closes #192

Ticket: ORB-186, migrated as #192 in the ticket repository, https://github.com/thomasluizon/orbit-tickets/issues/192

@thomasluizon

Copy link
Copy Markdown
Owner Author

Approach:

  1. Update AppConstants, PayGateService, and ApplyOnboardingCommand so the configured 1000 habit ceiling is enforced identically for free and Pro users, with one neutral refusal message. This keeps the existing configuration path and count semantics instead of introducing a second tier limit.
  2. Add a new Orbit.Infrastructure migration that updates the current AppConfigs row and description. I will preserve the historical seed because changing migration history would not update deployed databases.
  3. Rewrite the habit section in Chat/Content/FeatureExplanations/paygate.md so product guidance describes one abuse guard across both plans.
  4. Extend the existing focused application and migration tests for the 999, 1000, bulk count, Pro parity, onboarding, exact message, migrated row, and fallback scenarios. I will use current test fixtures and migration conventions instead of adding a database integration harness.

Expected files are the named application and content files, a new migration with its generated metadata and model snapshot updates if required by the repository pattern, plus the existing focused test files that cover those paths.

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

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes The complete diff for 55dcfaa was reviewed; this revision contains no code, configuration, test, or contract changes.

  • Placeholder commit 55dcfaa matches its main parent exactly and only establishes the branch for the planned ORB-186 work.
  • Behavioral scope There is no implementation to assess yet; implementation commits will need a fresh review when they land.

Pullfrog  | View workflow run | Using GPT Sol𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Important

The shared ceiling is not atomic under concurrent creation, and its refusal still carries an entitlement code that redirects users to the upgrade screen.

Reviewed changes I reviewed the implementation added since the prior Pullfrog review at 55dcfaa.

  • Raised the fallback ceiling Changed the default live root-habit limit from 10 to 1000.

  • Applied one ceiling to both plans Removed the Pro bypass from direct creation and onboarding allowance trimming.

  • Migrated persisted configuration Updated the existing FreeMaxHabits row and added migration operation coverage.

  • Updated guidance and boundaries Reworded the assistant explanation and added free, Pro, bulk, onboarding, fallback, and refusal tests.

⚠️ The abuse guard is not atomic

At 999 live roots, two concurrent creates can both read 999 and then commit, yielding 1001; concurrent bulk requests can overshoot farther. The onboarding lock uses a different key and no other creation path shares it, so the new ceiling does not hold under adversarial concurrency.

Technical details
# Enforce the live root-habit ceiling atomically

## Affected sites
- `src/Orbit.Application/Common/PayGateService.cs:19-24` — counts live roots and returns before any mutation transaction or shared lock begins.
- `src/Orbit.Application/Habits/Commands/CreateHabitCommand.cs:51-118` — checks the count, then inserts independently.
- `src/Orbit.Application/Habits/Commands/BulkCreateHabitsCommand.cs:71-111` — checks before its transaction and can insert a full batch after another request passes the same count.
- `src/Orbit.Application/Habits/Commands/DuplicateHabitCommand.cs:34-63` — checks and inserts with no shared serialization boundary.
- `src/Orbit.Application/Chat/Tools/Implementations/CreateHabitTool.cs:152-176` — repeats the same uncoordinated check and insert.
- `src/Orbit.Application/Profile/Commands/ApplyOnboardingCommand.cs:92,144-150` — serializes only onboarding requests under `onboarding-apply:{userId}`, which does not contend with the other creation paths.

## Required outcome
- Make the configured live root-habit ceiling invariant across concurrent direct, bulk, duplicate, chat, and onboarding creation.
- Add a concurrent creation unit test that starts below the boundary and proves the committed live-root count cannot exceed the configured ceiling.

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

Comment thread src/Orbit.Application/Common/PayGateService.cs Outdated
@thomasluizon

Copy link
Copy Markdown
Owner Author

Resolved the review findings in f92cbd4. Habit creation now uses one per-user transaction advisory lock across direct, bulk, duplicate, chat, and onboarding paths, with a deterministic concurrent boundary test. The ceiling refusal is now a neutral failure with no entitlement error code.

@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 shared lock still excludes paths that reactivate completed roots, so the 1000 live-habit invariant remains bypassable under concurrency.

Reviewed changes I reviewed f92cbd4, which was pushed after the prior Pullfrog review at 771c6f1.

  • Serialized creation callers Wrapped direct, bulk, duplicate, chat, and onboarding creation with one per-user transaction advisory lock.
  • Neutralized ceiling refusals Replaced PAY_GATE with a generic failure and updated assertions so clients no longer treat the abuse ceiling as an upgrade prompt.
  • Added concurrency coverage Added deterministic lock serialization coverage and caller lock assertions.

⚠️ Reactivations bypass the new serialization boundary

Completed root unlogs and end-date reactivations still perform a count followed by a later mutation without acquiring habit-ceiling:{userId}. At 999 live roots, a reactivation can pass alongside a locked creation or another reactivation and both can commit, leaving 1001.

Technical details
# Serialize every path that increases the live root count

## Affected sites
- `src/Orbit.Application/Common/PayGateService.cs:260-291``HabitReactivationAllowance` checks `CanCreateHabits` and applies the transition without the shared lock.
- `src/Orbit.Application/Habits/Commands/LogHabitCommand.cs:117-158` — a completed-root unlog passes the count check and persists later.
- `src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs:77-135` — an end-date change can reactivate a root before a later save.
- `src/Orbit.Application/Chat/Tools/Implementations/UpdateHabitTool.cs:103-117` — the chat update path performs the same unlocked reactivation transition.
- `tests/Orbit.Application.Tests/Common/HabitCeilingLockTests.cs:12-52` — the new concurrency test covers only two operations that both acquire `HabitCeilingLock`.

## Required outcome
- Make every transition that increases the live top-level root count contend on the same per-user lock, with the lock held until persistence commits.
- Add a deterministic unit test that overlaps creation with reactivation and proves the committed live-root count cannot exceed the configured ceiling.

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.

Important

The 1000 live-root ceiling is still bypassable because several transitions that enter the counted set do not use the new allowance and serialization boundary.

Reviewed changes I reviewed the complete five-commit branch at 8c65f20, including the habit ceiling behavior, every habit state transition that affects the count, transaction semantics, migration, and tests.

  • Universal abuse ceiling Raises the configured live top-level habit limit to 1000 for free and Pro users and returns a neutral failure at the boundary.
  • Creation serialization Uses one per-user transaction advisory lock for direct, bulk, duplicate, chat, and onboarding creation.
  • Persistence and guidance Migrates the existing configuration row, updates assistant guidance, and regenerates the architecture map.
  • Coverage Adds caller lock assertions, deterministic create serialization coverage, migration assertions, and free and Pro boundary tests.

⚠️ The ceiling is attached to creators, not the counted state

The new lock prevents concurrent creates from overshooting, but other commands can add a live root without participating. At 1000 live roots, restoring an uncompleted deleted root or promoting an active sub-habit commits 1001 sequentially; completed-root reactivation also retains the concurrent check-then-mutate race.

Technical details
# Enforce the ceiling on every transition into the live-root set

## Affected sites
- `src/Orbit.Application/Habits/Commands/RestoreHabitCommand.cs:26-64` restores an uncompleted root without an allowance check or `HabitCeilingLock`.
- `src/Orbit.Application/Habits/Commands/MoveHabitParentCommand.cs:30-34` promotes an active sub-habit to a root without an allowance check or lock.
- `src/Orbit.Application/Habits/Commands/UpdateHabitCommand.cs:77-107` and `src/Orbit.Domain/Entities/Habit.cs:405-410` can clear completion through `IsGeneral` without recognizing that the root re-enters the counted set.
- `src/Orbit.Application/Common/PayGateService.cs:260-291`, `src/Orbit.Application/Habits/Commands/LogHabitCommand.cs:117-158`, and `src/Orbit.Application/Chat/Tools/Implementations/UpdateHabitTool.cs:103-117` check reactivation allowance without acquiring the shared transaction lock through persistence.

## Required outcome
- Any mutation that changes a habit from child, completed, or deleted into an uncompleted, undeleted root must check the configured ceiling while holding `HabitCeilingLock.ForUser(userId)` through save and commit.
- Mutations that remain outside the counted set must not consume a root slot.
- Cover sequential restore and child-to-root promotion at the ceiling, plus an overlap between locked creation and completed-root reactivation.

## Suggested approach
- Put the live-root entry invariant behind one shared application transaction boundary used by creation, restore, reparenting, and reactivation instead of maintaining a list of creator wrappers.

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

@thomasluizon

Copy link
Copy Markdown
Owner Author

Review fix approach: centralize live root entry behind the existing per user transaction helper so it acquires HabitCeilingLock, checks capacity only when the mutation enters the counted set, and holds the lock through persistence and commit. Route restore, child promotion, regular update reactivation, chat update reactivation, and completed root unlog through that boundary. Extend the entry predicate to include IsGeneral clearing completion, preserve mutations that remain deleted, completed, or children without consuming capacity, and add sequential ceiling coverage plus deterministic creation and reactivation overlap coverage. Files will stay within the reviewed application handlers, the shared ceiling helper, and their focused tests.

@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 registered move_habit chat tool still bypasses the serialized ceiling and can promote an active child past the 1000-root guard.

Reviewed changes I reviewed the behavioral and generated changes added since the prior Pullfrog review at 8c65f20.

  • Unified guarded transitions Added shared live-root entry helpers that check allowance under HabitCeilingLock.
  • Covered commands and update tools Routed restore, parent promotion, completed-root unlog, end-date reactivation, and IsGeneral reactivation through the guarded transaction boundary.
  • Expanded regression coverage Added sequential transition tests and an overlapping creation-versus-reactivation test.
  • Regenerated architecture metadata Refreshed the architecture artifacts after the application and test changes.

⚠️ The live-root ceiling is still bypassable through move_habit

The incremental fix routes move_habit_parent through the guarded command, but move_habit remains registered and directly clears ParentHabitId without an allowance check or lock. At the ceiling, that path succeeds and the chat persistence step commits a 1001st live root.

Technical details
# Route every chat promotion through the live-root entry invariant

## Affected sites
- `src/Orbit.Application/Chat/Tools/Implementations/MoveHabitTool.cs:31-58` loads a tracked habit and promotes it directly with `SetParentHabitId(null)`.
- `src/Orbit.Infrastructure/Services/AgentCatalogService.Capabilities.cs:172-183` keeps both `move_habit` and the guarded `move_habit_parent` tool active in the habit-write capability.
- `src/Orbit.Application/Chat/Commands/ProcessUserChatCommand.Persistence.cs:12-18` later persists the direct tracked mutation.
- `src/Orbit.Application/Habits/Commands/MoveHabitParentCommand.cs:21-29` is the corrected path that checks `HabitLiveRootEntry.FromPromotion` while holding the shared lock.

## Required outcome
- Ensure every active chat tool that can promote a child either delegates to `MoveHabitParentCommand` or applies the same serialized live-root entry boundary.
- Refuse an active-child promotion at the configured ceiling without mutating or persisting the child.
- Add coverage for the registered `move_habit` surface, or remove that legacy surface if `move_habit_parent` replaces it.

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 move_habit fix still reads transition state from an entity tracked before the advisory lock, so concurrent chat writes can promote an active root beyond the 1000 habit ceiling.

Reviewed changes I reviewed b180598, the delta since the prior Pullfrog review at 4772809.

  • Guarded legacy moves Routed the registered move_habit tool through MoveHabitParentCommand instead of mutating the tracked entity directly.
  • Updated regression coverage Replaced direct-mutation tests with MediatR dispatch, failure propagation, tenant isolation, and ceiling-refusal assertions.
  • Refreshed architecture artifacts Regenerated metadata to include the command dependency and updated test references.

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

Comment thread src/Orbit.Application/Chat/Tools/Implementations/MoveHabitTool.cs

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes I reviewed 86108de, the delta since the prior Pullfrog review at b180598.

  • Moved entity lookup behind the guard Removed the pre-dispatch tracked habit loads from both registered move tools, so MoveHabitParentCommand now classifies promotion state after acquiring HabitCeilingLock.
  • Preserved result metadata safely Added post-success FindAsync lookups, which use AsNoTracking and cannot pollute the command handler's tracked entity.
  • Aligned regression fixtures Updated the move-tool tests and shared tool factory to exercise command-first dispatch, failure propagation, and post-command display lookup behavior.

Pullfrog  | View workflow run | Using GPT Sol𝕏

@sonarqubecloud

Copy link
Copy Markdown

@thomasluizon
thomasluizon merged commit 8c24c11 into main Aug 23, 2026
23 checks passed
@thomasluizon
thomasluizon deleted the feature/ticket-192-habit-ceiling branch August 23, 2026 22:47
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