The parity contract from CLAUDE.md section 7 now exists: 42 cases across 11 scenario groups, with a runner that asserts every case and a coverage check that asserts every mandatory scenario is claimed. **A design change, and why.** ADR-16 originally specified JSON tables, justified by "serde_json will already be in the tree". That was wrong — `rusqlite` does not pull `serde` by default and `uniffi` has not landed, so nothing provided it, and `serde` is not a section 3 dependency (rule 0.3). The tables are therefore a Rust builder DSL: the same declarations, but a typo in a field name is a compile error rather than a test that silently asserts the wrong thing. For a document whose entire job is to be a contract, compile-time checking beats diffability. Converting to JSON later is mechanical if serde is approved. **Two engine bugs the tables caught, both real:** 1. **Overnight windows woke the daemon immediately.** `time_window_boundary` computed the end as `end - minute` and clamped at zero. For a 22:00–06:00 window active at 23:00, "06:00 today" is in the past, so the boundary became *now* — and the daemon would wake, re-evaluate, get the same answer, and wake again, forever. Not a missed block, but a busy loop burning the section 5 idle budget. Fixed by detecting the late half of an overnight window and crossing midnight properly. 2. **An inactive schedule woke a day early.** The next-start search iterated day offsets to test membership but computed the delta as `start - minute` only, wrapping by 1440 without advancing the day. A Monday–Friday schedule evaluated on Saturday reported Monday's start as 21 hours away — Sunday 09:00, a day not in the set. The block still fired, because the next evaluation recomputed it, but the daemon woke a day early for nothing. Fixed by folding the day offset into the delta. **Four of my own table cases were wrong too**, which is the other half of why they are worth having: a UTC+5 offset is 15:00 local and therefore *inside* 09:00–17:00 (I had asserted outside); the same for the DST cases. The engine was right and the test was wrong in all four. **Coverage bookkeeping is checked, not promised.** `coverage::Location` records whether a scenario is covered by a table or by a code fixture — clock tampering and the 24-hour cap are about the passage of time and a bound across lock levels, not a decision at an instant, so they cannot be expressed as data. Both directions fail: an uncovered scenario is an error, and so is a `covers` tag that is not on the mandatory list, which is how a case quietly stops testing what its name claims. **`BlockReason` now carries its target.** A schedule may name twenty targets and they do not all become blocked — some are paused, some belong to a condition that is not currently active, some are on the never-block list. Without carrying which target a reason is about, two blocked apps under one schedule produce two identical reasons and the UI has to re-derive the pairing, which is exactly the drift `BlockReason` exists to prevent. The tables could not assert per-app attribution without it. **The DST gap is recorded, not papered over.** `CivilTime` is an epoch plus an offset with no timezone database behind it, so the tables pin that a fixed local window stays fixed as the offset moves — including across a DST transition — but cannot express a spring-forward time that does not exist or a fall-back time that is ambiguous. Those are section 7 cases that are genuinely not covered, and LIMITATIONS.md now says so. Co-Authored-By: Claude Code <noreply@anthropic.com>
9.2 KiB
Limitations
CLAUDE.md section 6 is titled "Known hard limits (tell me, don't hide them)". This file is that promise, kept in writing.
Two rules govern the wording here. Every limitation is stated as a fact about the operating system, not as a bug with a workaround. And no entry uses the words "unbypassable", "tamper-proof", "guaranteed", or "secure" — section 6.1 and 6.7 require those claims not be made, and writing around them is how a document quietly starts lying.
Nothing in this file is aspirational. Where a limitation is not yet implemented, it says so.
The safety guarantees, stated first
Before the limits, the guarantees. Section 1.5.4 makes these non-negotiable, and they constrain what the limitations below are allowed to be.
- Never-block list. Phone, dialler, emergency calls and SOS, and emergency
alerts can never be blocked. On desktop, the OS shell and the Settings basics
needed to keep the machine usable. Hardcoded in
core/src/never_block.rs, not a setting, with no flag that reverses it. The check happens inside the engine after schedule evaluation, so a schedule naming a protected target blocks its other targets and cannot block the protected one. - Finite worst case. The longest possible path to disabling blocking is 24
hours. No lock exists without an exit. Asserted by
worst_case_disable_path_is_capped. - Recovery is never interfered with. Factory reset, safe mode, and OS-level recovery are not blocked, and never will be. Section 6.7: that is correct behaviour, not a gap.
- Fail open. A service crash loop or a corrupt database must never leave a device unusable. Recovery renames the damaged file aside and starts clean rather than failing to launch.
- Opt-in only. Strict and Stubborn are never enabled by default, and never by a schedule template.
- Always escapable. The "How to turn this off" documentation is reachable without disabling anything, including under Strict Mode.
1. Desktop Strict Mode cannot be made tamper-proof against root or admin
Status: fundamental. Not being worked around.
On desktop, a user with administrative rights, or root, can stop the service, edit its files, delete it, or inspect its memory. No design prevents this. Section 6.1 requires this be documented honestly and that we not claim otherwise.
Mitigations actually implemented:
- A watchdog service that restarts the daemon if it dies.
- File ACLs, and where the filesystem supports it, an immutable flag on config.
- Restart-on-kill, so a kill is not durable.
- A tamper log, so the attempt is recorded even though it succeeds.
What none of this changes: a determined user with root removes the app. The goal is to outlast an impulse, not to defeat a determined person.
2. Desktop website blocking has no single clean mechanism
Status: decided (ADR-7), with a residual gap.
Shipping a Firefox extension over native messaging. That makes keyword and path-level blocking work, and unlike DNS or a hosts file it is not defeated by DoH, because it inspects the URL in the browser rather than the network path.
The costs are real: the user must install an extension and grant permissions that look alarming, and Firefox-only for now.
The residual gap: an extension covers browsers it is installed in. A
determined user can curl, or use a browser without it. This is section 6.7
territory — friction, not a wall.
DNS/hosts-only blocking was rejected because it cannot do keyword or path-level matching and cannot defeat DoH. Doing both was rejected because two mechanisms means two sources of truth, and this project has a strong interest in exactly one decision path.
3. Wayland focused-window detection is compositor-dependent
Status: partially implemented (ADR-14), partly not.
There is no portable API for "which window has focus" on Wayland. Each compositor has its own mechanism, and none of them is a standard.
Implemented:
- KDE Plasma — a script loaded over
org.kde.KWin /Scripting. Verified available on the development machine. - X11 —
x11rbon_NET_ACTIVE_WINDOW. Works on any X11 session, and on XWayland clients under Wayland. Blind to native Wayland apps, which is why it is a fallback rather than the primary. - Process-based fallback — no idea what has focus, acts on process creation, and cannot show a meaningful block screen.
Not implemented, still in M7:
- wlroots compositors, via
foreign-toplevel-management. - GNOME, via an extension.
A further degradation on this machine specifically: /proc/connectors does not
exist on its kernel, so the netlink proc-connector process watcher is
unavailable. The process fallback must use eBPF or /proc polling instead, both
of which cost CPU and none of which can produce a proper block screen.
4. Location schedules are mobile-only
Status: implemented as a deliberate absence on desktop.
There is no reliable desktop equivalent. The engine carries the condition and
validation rejects it on non-Android platforms
(ValidationError::LocationOnDesktop), and the desktop UI hides it.
This is a design choice rather than an omission. A condition that is offered but never fires is worse than one that is not offered: it teaches the user the app does not work.
5. Android OEMs kill background services aggressively
Status: not yet implemented. M3-Android.
Xiaomi, Huawei, Samsung and others kill background services in ways that vary by model and by version. An accessibility-service-based blocker is exactly the kind of process these vendors target.
The plan is a troubleshooting screen covering autostart and battery settings per OEM. Not yet written, and no OEM-specific workaround exists yet.
6. Android accessibility URL reading breaks when browser UIs change
Status: not yet implemented. M5.
Reading a browser's URL bar through the accessibility tree means depending on that browser's internal view structure, which changes without notice. Every browser needs its own resource-id table, and those tables rot.
The plan is a per-browser table with a maintenance path. No table exists yet, and there is no mechanism for a user to report a broken browser other than an issue. This will be a recurring support cost, not a one-time fix.
7. Friction is not a wall
Status: by design. Section 1.5.4 and 6.7.
A user with ADB, root, safe mode, a factory reset, or administrative rights on desktop can always remove this app. Blocking ADB, safe mode, or OS recovery is not attempted and never will be — ADR-11.
What the friction ladder does buy is time, in the moment, against an impulse. A user who decides calmly and deliberately still gets through, within a bounded 24 hours.
Stating this plainly matters for a reason beyond honesty: a product that claims to be unremovable is a product whose users cannot trust its other claims either.
8. Play Store policy is hostile to apps that resist uninstall
Status: accepted. Distribution decision not yet finalised.
Accessibility and Device Admin use require policy justification, and both are used here for core functionality. An app that resists uninstall is close to categorically unpublishable.
The plan is to keep the strongest friction behind an explicit opt-in and treat a sideload or F-Droid flavour as the primary distribution for it. Not yet built.
Limits specific to this implementation
Not platform limits — known shortfalls in the current code, recorded so they are not mistaken for platform facts.
ProcessBackendis not implemented. ADR-14 lists three backends; the KWin and X11 paths land in M2-Linux, and the process fallback is weaker than the spec assumes because of the/proc/connectorsgap above.rusqlitevs system-linked SQLite is unresolved (ADR-4). Bundled is built and costs ~1.1 MB of the 5 MB daemon budget. System-linking would reclaim most of that but adds a runtime dependency on the host's libsqlite3.- Lock state is not yet persisted or enforced.
core/src/lock.rshas the timers, the escalation, and the wall-clock correction, all tested. The state machine is not yet driven from a UI, andLockState::evaluatecurrently returns the Normal level unconditionally. - DST coverage is partial, and the boundary is stated.
CivilTimeis an epoch value plus a UTC offset, with no IANA timezone database behind it — thetimecrate is not a section 3 dependency (ADR-16). The golden tables pin the observable rule, that a fixed local window stays fixed as the offset moves, including across a DST transition. What they cannot express is a local time that does not exist because the clock sprang forward, or one that is ambiguous because it fell back. Those are cases section 7 lists that are not yet covered. This is the strongest argument for a timezone-aware time type. LockStatestill always evaluates toNormal— persistence works, but no UI drives the lock state machine yet.Condition::UsageLimitandLaunchCountsum across all targets rather than the schedule's own target set, until targets carry usage keys.- Windows is entirely unimplemented and cannot be compiled or tested on the development machine, so writing enforcement now would mean shipping unverified code.