Rule hard cap (10) + N=1 time-limit arming - #49
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two coupled changes:
1. N=1 time-limit arming (a deliberate on-device trial).
RuleScheduler.dayActivityHorizondrops 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 intoRulesListView: 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), anat-rule-capUI-test seed scenario (seeds exactlymaxRuleCountrules), andRuleLimitUITests. 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), andAGENTS.md.This branch merges in #48 (
fix: run rule enforcement off the main thread…) so the two changes coexist — #48 changesRuleScheduler.syncto takeRuleSnapshotDTOs, which overlaps this branch's scheduler + scheduler-test edits. Merge #48 first; once it lands onmain, 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'ssync(snapshots:)API.Test plan
Verified locally (Xcode, iPhone 17 / iOS 27 simulator):
RunAllTests→ 410/410 passing (unit + UI), after the merge. Build clean, zero warnings.RuleLimitUITests: 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).CopyCatalogTests) and theat-rule-capseed count both green.Needs on-device verification (the point of the N=1 trial — the simulator delivers no DeviceActivity callbacks):
🤖 Generated with Claude Code
https://claude.ai/code/session_013F5PrZFxkSz3uDrs79nKxK