Skip to content

Rule hard cap (10) + N=1 time-limit arming - #49

Merged
brendan-ch merged 9 commits into
mainfrom
feat/rules-hard-cap
Jul 9, 2026
Merged

Rule hard cap (10) + N=1 time-limit arming#49
brendan-ch merged 9 commits into
mainfrom
feat/rules-hard-cap

Conversation

@brendan-ch

Copy link
Copy Markdown
Owner

Summary

Two coupled changes:

1. N=1 time-limit arming (a deliberate on-device trial). RuleScheduler.dayActivityHorizon drops 2 → 1, so a time-limit rule now arms only today's per-day DeviceActivity activities and relies on the monitor's midnight self-arm (reArmNextScheduledDay) for the next day. This is intentional: it removes the foreground next-day buffer so the (device-unverified) self-arm becomes observable on TestFlight, and it halves the per-rule activity cost — which is what makes the cap below fit Apple's ceiling.

2. A hard cap of 10 rules. New pure RuleCreationPolicy (maxRuleCount = 10, single source of truth) wired into RulesListView: tapping New Rule at 10 rules shows a "Rule limit reached" alert instead of the editor. The cap is chosen so the worst case under N=1 — a nudge-on time-limit rule = 2 activities — stays within DeviceActivity's ~20 concurrent-activity ceiling (10 × 2 = 20). It counts all rules (enabled or not) as a safe over-approximation.

Includes copy strings (Copy.xcstrings + CopyKey), an at-rule-cap UI-test seed scenario (seeds exactly maxRuleCount rules), and RuleLimitUITests. Docs updated: the design spec/plan (Docs/Agents/Specs|Plans/…RULE_HARD_CAP_AND_N1_ARMING…), TIME_LIMIT_DAY_KEYED_ENFORCEMENT.md (§5/§9/§10/§13/§14), and AGENTS.md.

⚠️ Depends on #48

This branch merges in #48 (fix: run rule enforcement off the main thread…) so the two changes coexist — #48 changes RuleScheduler.sync to take RuleSnapshotDTOs, which overlaps this branch's scheduler + scheduler-test edits. Merge #48 first; once it lands on main, this PR's diff reduces to just the cap + N=1 changes. The one merge conflict (the scheduler test suites) was resolved by keeping this branch's N=1 assertions on top of #48's sync(snapshots:) API.

Test plan

Verified locally (Xcode, iPhone 17 / iOS 27 simulator):

  • Full suite greenRunAllTests410/410 passing (unit + UI), after the merge. Build clean, zero warnings.
  • N=1 arming — RED captured (5/5 scheduler tests fail when the constant is toggled back to 2), GREEN at N=1.
  • Cap alertRuleLimitUITests: at 10 rules the alert shows and the editor does not; below the cap the editor opens with no alert (RED captured against the un-wired view).
  • Copy catalog invariant (CopyCatalogTests) and the at-rule-cap seed count both green.

Needs on-device verification (the point of the N=1 trial — the simulator delivers no DeviceActivity callbacks):

  • Watch for a next-day enforcement lapse when the app stays closed across midnight — with N=1 there is no foreground next-day buffer, so a time-limit rule's next day depends entirely on the monitor's midnight self-arm firing. This is the specific behavior to confirm on TestFlight.
  • Hard Mode / Uninstall Protection background coverage over multi-day app-closed spans (now one day shorter before a lapse).

🤖 Generated with Claude Code

https://claude.ai/code/session_013F5PrZFxkSz3uDrs79nKxK

brendan-ch and others added 9 commits July 8, 2026 21:44
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013F5PrZFxkSz3uDrs79nKxK
Pure, unit-tested policy type capping the app at 10 rules. Counts all
rules (enabled or not) as a safe over-approximation of Apple's ~20
concurrent-DeviceActivity ceiling under N=1 time-limit arming.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013F5PrZFxkSz3uDrs79nKxK
… self-arm

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013F5PrZFxkSz3uDrs79nKxK
Xcode writes Copy.xcstrings as `"key" : {` (space before colon, 2-space
indent, sorted). A Python json.dump re-serialize would reformat all 2448
lines, so the controller adds the two catalog entries via a surgical edit
in Xcode's exact format instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013F5PrZFxkSz3uDrs79nKxK
Adds LaunchConfiguration.SeedScenario.atRuleCap, which seeds exactly
RuleCreationPolicy.maxRuleCount rules for UI tests exercising the
New Rule button's cap alert, plus the two CopyKey cases for the
rule-limit alert title/message (catalog entries already present).

The at-rule-cap seed test lives as a @test in the existing
SampleRulesTests suite (LaunchSupportTests.swift) to avoid a
duplicate top-level struct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013F5PrZFxkSz3uDrs79nKxK
Route the toolbar and empty-state New Rule buttons through a shared
attemptNewRule() that checks RuleCreationPolicy.canCreateRule before
presenting the New Rule sheet, showing a cap alert instead once the
10-rule limit is reached.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013F5PrZFxkSz3uDrs79nKxK
…I hang

Committing a rule or app-list edit could freeze the UI for tens of seconds.
The enforcement refresh ran synchronously on the main actor, and
DeviceActivityCenter.startMonitoring blocks ~31s per current-day time-limit
activity (device-confirmed via timing instrumentation). An app-list selection
change restarts both the block and warn activities — ~62s on the UI thread.

Move all shield + DeviceActivity + uninstall I/O off the main thread:

- Add `actor RuleEnforcementEngine`, which performs the I/O from Sendable
  `RuleSnapshotDTO`s and serializes overlapping refreshes (edit, 30s loop,
  scenePhase) so they can't race on DeviceActivityCenter.
- `RuleEnforcer` stays @MainActor/@observable but now does only the SwiftData
  @model work (pause expiry, day-start, snapshotting) on main, awaits the
  engine, and publishes `blockingRuleIDs` back on main. refresh/pause/resume
  are now async; call sites wrap in Task.
- `RuleScheduler.sync` and its plan helpers take `[RuleSnapshotDTO]` instead of
  `@Model` objects so they can run off-main.
- Mark the pure logic / naming / service types nonisolated + Sendable
  (RulePolicy, RuleStatus, RuleActivation, RuleSchedule, Calendar+NextMidnight,
  MonitoringPlan, AppSelectionCodec, ShieldApplying, ActivityMonitoring,
  UsageReading, OpenSessionReading, RuleScheduler, stores). RulePolicy.pause /
  resume stay @mainactor since they mutate the @model. AppSettingsStore is not
  sent off-main — its one Bool is read on main and passed in.

Also switch NotificationScheduler to the async UNUserNotificationCenter.add,
clearing a pre-existing warning. Behavior-preserving: all 404 tests pass, no
concurrency warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PoojuBiBZwpV5UqGuAcHNy
Matches the descriptive @test("...") strings every sibling in the
SampleRulesTests suite carries (final-review Minor).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013F5PrZFxkSz3uDrs79nKxK
Brings the off-main-thread rule-enforcement fix into this branch so the
10-rule cap + N=1 arming work builds on top of it. #48 changes
RuleScheduler.sync to take RuleSnapshotDTOs (value types) instead of
@model rules.

Conflicts (both in the scheduler test suites) resolved by keeping this
branch's N=1 assertions (today-only arming: startCallCount 1/2, tomorrow
not pre-armed, self-arm adds no start) on top of #48's sync(snapshots:)
API. Full suite: 410/410 passing, build clean, zero warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013F5PrZFxkSz3uDrs79nKxK
@brendan-ch
brendan-ch merged commit 218295b into main Jul 9, 2026
1 of 2 checks passed
@brendan-ch
brendan-ch deleted the feat/rules-hard-cap branch July 9, 2026 05:06
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