585fd30440
Establishes the cargo workspace and the shared `blocker-core` crate. The engine is deliberately written before any platform code, because CLAUDE.md section 2 makes it the single source of decision truth and M1 acceptance requires it to be pure. **Workspace** — five crates plus a test crate, zero external dependencies. Section 3 requires every crate to justify its binary-size cost, so M0 adds none. [profile.release] is workspace-only (cargo rejects it in members) and carries the section 5 settings that make the 5 MB stripped budget reachable. **Purity contract** — `evaluate(&EngineInput) -> Decision` reads no clock, no disk and no OS API; everything arrives in the input. `tests/purity.rs` enforces this mechanically by asserting blocker-core has zero dependencies, so a stray I/O reach-in fails the build rather than silently making the golden tables useless as a parity contract. Decisions worth calling out: - **Monotonic time is a separate type from wall time.** Section 1.5.3 requires a system date change never to shorten a lock. Keeping them apart makes that structural rather than a rule to remember. - **`start > end` means overnight.** No `crosses_midnight` flag: the comparison already encodes it and a second field is a second way to be wrong. The weekday for a window that started yesterday evening is yesterday's, which `overnight_window_survives_past_midnight` pins down. - **`next_wakeup` is armed over every non-paused schedule, not only active ones.** Filtering to active conditions first would let the daemon sleep straight through the moment a schedule is due to start. - **Allowlist needs the installed-app inventory.** `EngineInput` carries `installed_apps`; an empty slice fails open rather than guessing. - **Wall-clock correction extends, never shortens, and clamps to the 24h cap.** Extending unbounded would satisfy anti-tamper and violate section 1.5.4. - **`cast_possible_truncation` is denied workspace-wide.** Truncating a millisecond counter would corrupt a lock timer. Verified: 22 tests pass, `cargo fmt --check` clean, `cargo clippy -D warnings` clean, stripped release daemon 0.279 MB against the 5 MB budget. Co-Authored-By: Claude Code <noreply@anthropic.com>
243 lines
8.1 KiB
Rust
243 lines
8.1 KiB
Rust
//! The golden decision tables (CLAUDE.md section 7).
|
|
//!
|
|
//! A table is a list of cases. Each case is schedules plus an input plus an
|
|
//! expected decision, and running the same table against the engine must
|
|
//! reproduce the expectation exactly.
|
|
//!
|
|
//! # Format
|
|
//!
|
|
//! JSON, one file per concern, under `golden/`. JSON rather than TOML because
|
|
//! `serde_json` is already in the dependency tree via `rusqlite` and `uniffi`
|
|
//! (both listed in CLAUDE.md section 3), so TOML would add a crate for no gain.
|
|
//!
|
|
//! ```json
|
|
//! {
|
|
//! "concern": "overnight",
|
|
//! "cases": [
|
|
//! {
|
|
//! "name": "mon 22:00-06:00 window still active at tue 03:00",
|
|
//! "covers": "7:overnight time ranges",
|
|
//! "schedules": [
|
|
//! { "id": "s1", "name": "Night", "mode": "Blocklist",
|
|
//! "targets": [{"type": "app", "app": "com.example.social"}],
|
|
//! "conditions": [{"type": "Time", "days": [0],
|
|
//! "start": "22:00", "end": "06:00"}] }
|
|
//! ],
|
|
//! "input": { "civil_time": "2026-10-06T03:00:00+02:00" },
|
|
//! "expect": {
|
|
//! "blocked_apps": [{"app": "com.example.social", "schedule": "s1"}],
|
|
//! "next_wakeup": "2026-10-06T06:00:00+02:00"
|
|
//! }
|
|
//! }
|
|
//! ]
|
|
//! }
|
|
//! ```
|
|
//!
|
|
//! # Status in M0
|
|
//!
|
|
//! The shapes below are the **contract**, written down before any parse layer
|
|
//! exists so M1 implements against a fixed format rather than retrofitting one.
|
|
//! `serde::Deserialize` is deliberately not derived yet — adding serde is a
|
|
//! dependency decision (CLAUDE.md section 0 rule 3) and is deferred to M1,
|
|
//! where it arrives with `rusqlite` under one ADR entry.
|
|
//!
|
|
//! # The clock-tampering exception
|
|
//!
|
|
//! Most cases are pure data. Clock tampering is not: proving that moving the
|
|
//! system date never shortens a lock requires actually moving a clock, which a
|
|
//! data file cannot express. Those cases live in [`crate::purity`] as code
|
|
//! fixtures instead, and the two coexist deliberately — the tables describe
|
|
//! decisions, the fixtures describe the passage of time.
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
/// One table, deserialised from one `golden/*.json` file.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Table {
|
|
/// Which concern this table covers, e.g. `overnight`.
|
|
pub concern: String,
|
|
/// The cases in the table.
|
|
pub cases: Vec<Case>,
|
|
}
|
|
|
|
/// One case: setup, input, expected decision.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Case {
|
|
/// Human-readable, and the primary test failure message.
|
|
pub name: String,
|
|
/// Which CLAUDE.md section 7 scenario this covers, so coverage is auditable
|
|
/// rather than assumed. Checked against [`MANDATORY_COVERAGE`].
|
|
pub covers: String,
|
|
/// The schedules under test.
|
|
pub schedules: Vec<ScheduleSpec>,
|
|
/// The input to the engine.
|
|
pub input: InputSpec,
|
|
/// The decision that must result.
|
|
pub expect: ExpectSpec,
|
|
}
|
|
|
|
/// A schedule as it appears in a table.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ScheduleSpec {
|
|
/// Stable id, referenced by `BlockedSpec::schedule`.
|
|
pub id: String,
|
|
/// Display name, shown on the block screen.
|
|
pub name: String,
|
|
/// `"Blocklist"` or `"Allowlist"`.
|
|
pub mode: String,
|
|
/// What this schedule targets.
|
|
pub targets: Vec<TargetSpec>,
|
|
/// Its conditions. At least one must hold for the schedule to be active.
|
|
pub conditions: Vec<ConditionSpec>,
|
|
/// Defaults to false when absent from JSON.
|
|
pub paused: bool,
|
|
}
|
|
|
|
/// A target, tagged by type so the JSON is self-describing.
|
|
#[derive(Debug, Clone)]
|
|
pub enum TargetSpec {
|
|
/// An app, by platform-native identifier.
|
|
App {
|
|
/// Package name on Android, desktop-file id or exe path elsewhere.
|
|
app: String,
|
|
},
|
|
/// A registrable domain. Subdomains are implied.
|
|
Website {
|
|
/// e.g. `example.com`.
|
|
website: String,
|
|
},
|
|
/// A case-insensitive substring matched against the URL.
|
|
Keyword {
|
|
/// The substring to match.
|
|
keyword: String,
|
|
},
|
|
}
|
|
|
|
/// A condition, tagged by type.
|
|
#[derive(Debug, Clone)]
|
|
pub enum ConditionSpec {
|
|
/// Block while local time is inside the window.
|
|
Time {
|
|
/// Weekdays on which the window **starts**. Monday = 0, matching
|
|
/// `time::Weekday`.
|
|
days: Vec<u8>,
|
|
/// `HH:MM`. When `start > end` the window spans midnight — there is
|
|
/// deliberately no `crosses_midnight` flag, because the comparison
|
|
/// already encodes it and a second field is a second way to be wrong.
|
|
start: String,
|
|
/// `HH:MM`. See `start`.
|
|
end: String,
|
|
},
|
|
/// Block after a foreground-time threshold.
|
|
UsageLimit {
|
|
/// Threshold in minutes.
|
|
limit_minutes: u32,
|
|
/// `"Daily"` or `"Hourly"`.
|
|
window: String,
|
|
},
|
|
/// Block after a launch-count threshold.
|
|
LaunchCount {
|
|
/// Threshold count.
|
|
limit: u32,
|
|
/// `"Daily"` or `"Hourly"`.
|
|
window: String,
|
|
},
|
|
/// Block while connected to a named network.
|
|
WifiSsid {
|
|
/// The SSID to match exactly.
|
|
ssid: String,
|
|
},
|
|
/// Block inside or outside a radius. Mobile only (section 6.4).
|
|
Location {
|
|
/// Circle centre latitude in degrees.
|
|
latitude: f64,
|
|
/// Circle centre longitude in degrees.
|
|
longitude: f64,
|
|
/// Radius in metres.
|
|
radius_meters: u32,
|
|
/// Invert the test: block when *outside* the radius. Defaults false.
|
|
block_when_outside: bool,
|
|
},
|
|
}
|
|
|
|
/// The engine input for a case.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct InputSpec {
|
|
/// RFC 3339 with an explicit offset, e.g. `2026-10-06T03:00:00+02:00`.
|
|
/// The offset is mandatory so a DST case is unambiguous.
|
|
pub civil_time: String,
|
|
/// Monotonic milliseconds since boot. Absent means 0.
|
|
pub monotonic_ms: u64,
|
|
/// Boot id. Absent means 0.
|
|
pub boot_id: u64,
|
|
/// Accumulated usage.
|
|
pub usage: UsageSpec,
|
|
/// Platform-observed context.
|
|
pub context: ContextSpec,
|
|
}
|
|
|
|
/// Usage counters for a case. All fields default to empty.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct UsageSpec {
|
|
/// Foreground minutes per target, daily bucket.
|
|
pub daily_minutes: BTreeMap<String, u32>,
|
|
/// Foreground minutes per target, hourly bucket.
|
|
pub hourly_minutes: BTreeMap<String, u32>,
|
|
/// Launch counts per target, daily bucket.
|
|
pub daily_launches: BTreeMap<String, u32>,
|
|
/// Launch counts per target, hourly bucket.
|
|
pub hourly_launches: BTreeMap<String, u32>,
|
|
}
|
|
|
|
/// Platform context for a case.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct ContextSpec {
|
|
/// Current Wi-Fi SSID, if any.
|
|
pub current_ssid: Option<String>,
|
|
/// Current `(latitude, longitude)`, if any.
|
|
pub current_location: Option<(f64, f64)>,
|
|
}
|
|
|
|
/// The decision a case expects.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ExpectSpec {
|
|
/// Apps that must be blocked, each attributed to a schedule.
|
|
pub blocked_apps: Vec<BlockedSpec>,
|
|
/// Websites and keywords that must be blocked.
|
|
pub blocked_domains: Vec<BlockedSpec>,
|
|
/// RFC 3339, or the literal `none` when the platform may sleep forever.
|
|
pub next_wakeup: String,
|
|
}
|
|
|
|
/// One expected blocked item.
|
|
#[derive(Debug, Clone)]
|
|
pub struct BlockedSpec {
|
|
/// The target that must be blocked.
|
|
pub app: String,
|
|
/// The id of the schedule that must block it.
|
|
pub schedule: String,
|
|
}
|
|
|
|
/// Every scenario in [`MANDATORY_COVERAGE`] must be claimed by at least one
|
|
/// `covers` field across the tables, and `coverage.rs` asserts it — so a
|
|
/// scenario cannot silently lose its test.
|
|
pub const MANDATORY_COVERAGE: &[&str] = &[
|
|
"7:overnight time ranges",
|
|
"7:DST changes",
|
|
"7:timezone changes",
|
|
"7:clock tampering",
|
|
"7:strictest-wins",
|
|
"7:allowlist mode",
|
|
"7:invalid condition combos",
|
|
"7:strict mode lock rules",
|
|
// The section 1.2 combination table, itemised so a missing pair is visible.
|
|
"1.2:time+usage rejected",
|
|
"1.2:time+launch rejected",
|
|
"1.2:usage+launch rejected",
|
|
"1.2:usage+location rejected",
|
|
"1.2:launch+location rejected",
|
|
// Section 1.5.4 safety guarantees.
|
|
"1.5.4:never-block list is unblockable",
|
|
"1.5.4:worst case 24h",
|
|
];
|