🚧 Status: ALPHA — personal infrastructure, incomplete, and unsupported. Use at your own risk. Several modules are declared-but-not-implemented, behavior varies by device/OS, and the Android agent can lock or (in the worst case) require a factory-reset of a device — read docs/SAFETY.md first. Expect to read code, not just run it.
A personal, multi-device commitment device. A central control plane (self-hosted, reachable only over Tailscale) holds the desired-state policy for every enrolled device. Each device runs an agent that pulls policy, reconciles local state to match, enforces locally, and reports back.
The commitment property comes from one asymmetry: you cannot weaken the rules from the device you're trying to restrict. You must go to the plane, and weakening there is delayed (default 24h). Strengthening is instant.
This is personal infra, not a product. It is not parental control or commercial MDM. Honesty about how strong each tier actually is (below) is a feature.
⚠️ Before you install the Android agent — read docs/SAFETY.mdThe Android agent provisions itself as a Device Owner and uses Lock Task (kiosk) mode. It is designed to be hard to remove. On a release build there is no in-app escape hatch — if you get locked and can't reach it over
adb, your only way out is a factory reset that erases the whole device. This is not a normal app. Practice on a spare device or emulator with a debug build first, keepadbworking, and back up before you provision. Only use it on a device you own. Used carelessly it can brick your daily phone. See docs/SAFETY.md for the full danger + recovery guide.
Operator creates a "Block YouTube" rule on the plane; the phone pulls it and the Device Owner hard-blocks the app — the phone can't lift it, only the plane can (after the cooldown).
Full walkthrough (pair a device → confirm → create a rule → enforce): docs/DEMO.md. Reproduce it yourself on an emulator (no real phone, no Tailscale): docs/LOCAL-TESTING.md.
| Path | What | Status |
|---|---|---|
schema/ |
Shared policy schema + the strength partial order that drives the asymmetric delay, plus Ed25519 signed-policy primitives. Canonical Python models + generated policy.schema.json. |
✅ built, 57 tests |
plane/ |
FastAPI + Postgres control plane: pairing, per-device policy versioning (anti-rollback), change-intent queue (asymmetric delay), operator console behind auth + CSRF, signed-policy delivery, signals/health read side, Activity logbook, places, food. Editorial Calm web UI. Docker (plane + db + Radicale CalDAV). | ✅ built, 141 tests, deployed |
agent-linux/ |
Soft-tier Python + systemd agent: pull/reconcile/report, /etc/hosts blocks, managed-process termination, fail-secure cache, plain-language status command. |
✅ built, 21 tests, live-demoed |
agent-android/ |
Device Owner enforcement app: Lock Task enforcement, fail-secure enforcement engine, signed-policy verification, boot-resume; the stateful condition engine (lock-until-HR-goal), durable Room store, adaptive goal layer, watch signals, places/dwell, vitals UI, and on-device Gemma food journaling. (Unstoppable alarm, call screening: planned.) | ✅ built, 190 JVM + 9 instrumented tests, installed & enforcing |
All four share one policy schema (commitment-schema), so a rule defined once is
understood by the plane and every agent. See docs/IMPLEMENTED.md for a
full technical map of what's built, PORTING.md for the self-hosting guide, and
VISION.md for where this is heading.
- Fail-secure, never fail-trapped. Lost connectivity → keep enforcing the last
cached policy. But every release condition is locally evaluable (NFC, geofence,
elapsed time, steps) so a dead tailnet can never trap you either. Only
remote_approvalneeds the plane, and its absence degrades gracefully. - Asymmetric change delay. Strengthening applies immediately; weakening waits a
cooldown. Always-stricter-now, relaxing-always-waits. (Anything ambiguous/mixed is
conservatively treated as a weakening — see
schema/commitment_schema/strength.py.) - Phone is a read-only endpoint. No rule-editing on the phone. All rule CRUD lives in the plane.
- Honest strength per platform. Android + Device Owner is a genuine wall. A Linux box you have root on is an advisory speed bump — the agent lock is undoable by definition. We say so in code and UI and don't pretend otherwise.
On the host that will run the plane (it needs Docker + Tailscale):
cd plane
cp .env.example .env
# Edit .env: set POSTGRES_PASSWORD and TAILSCALE_IP (run `tailscale ip -4`).
docker compose up -d --buildThe plane binds only to your Tailscale IP on port 8080 — no public listener, and
Postgres is never published at all. Open http://<tailscale-ip>:8080/ (or the MagicDNS
name) from a tailnet device to reach the web UI.
The web UI is built for a non-technical operator: the dashboard shows each device's live 🔒 Locked / 🔓 Open state, and each device opens a guided rule builder — plain-language "when does it lock / how does it unlock / what's still allowed", day and time pickers, app chips, and a live English summary of every rule. No JSON required (a raw-JSON editor is tucked under "Advanced"). Strengthening a rule applies instantly; loosening or deleting one is queued and shown under "Pending changes" until the 24h cooldown elapses.
- tailnet lock:
tailscale lock initand sign nodes, so a stolen auth key can't add a rogue device. - ACLs: see
plane/tailscale-acl.example.json— onlytag:agentandtag:adminmay reachtag:plane:8080; nothing is public. - MagicDNS: address the plane by name (e.g.
plane.your-tailnet.ts.net) instead of a raw IP.
The plane sends a data-only "sync now" nudge; the agent then pulls over Tailscale. No secrets or policy ever ride in the push payload.
- Create a Firebase project, download a service-account JSON.
- Mount it into the container and set
FCM_CREDENTIALS_PATHin.env(uncomment thevolumes:line indocker-compose.yml). - Without it, the plane uses a log-only pusher; agents still pull on a timer and on boot, so convergence is just slower, never broken.
The unauthenticated /enroll endpoint is gone (audit #1). Bootstrapping a device now goes
through a confirmed pairing flow — there is no agent token until the operator approves:
- Bring the device onto the tailnet (install Tailscale, join, get ACL'd in).
- The operator generates a one-time pairing code in the console.
- The agent calls
POST /pair/claimwith the code (+ its name/platform/capabilities) and gets aflow_id+ per-claimclaim_token; it then pollsPOST /pair/poll. - The operator confirms the pairing in the console; the next poll returns the
device_id+agent_token. From there the agent pulls policy and reconciles.
Adding a device later is the same flow — the system is built to grow.
| Call | Direction | Purpose |
|---|---|---|
POST /pair/claim + POST /pair/poll |
agent → plane | confirmed pairing → device_id + agent_token |
GET /policy?device=&since= |
agent → plane | pull {version, content_hash, body} (signed when enabled) |
POST /state |
agent → plane | report sessions/alarms/journal/health/signals |
POST /devices/{id}/intents |
device/UI → plane | propose an edit (classified + delayed) |
| data-only FCM | plane → agent | "sync now" wake |
The reconcile loop is idempotent: applying the same version twice is a no-op. Device tokens are scoped to their own policy, and policy versions are monotonic (anti-rollback).
sudo cp -r agent-linux /opt/commitment-agent && cd /opt/commitment-agent
sudo pip install ./ ../schema # or use a venv
sudo mkdir -p /etc/commitment-agent
sudo cp config.example.json /etc/commitment-agent/config.json # then edit
sudo commitment-agent --config /etc/commitment-agent/config.json enroll --name "$(hostname)"
sudo cp systemd/commitment-agent.service /etc/systemd/system/
sudo systemctl enable --now commitment-agentThe shared policy decides when this box is locked (schedule / manual). The local
config.json decides what this advisory tier does about it: which managed_processes
it will terminate and which block_domains it null-routes via /etc/hosts.
Check it in plain English anytime — no JSON, no daemon required:
commitment-agent --config /etc/commitment-agent/config.json status
# 🔒 Locked right now (advisory — this tier is a speed bump, not a wall)
# "Deep work" is active — unlocks after 1 hour
# ... or ...
# 🔓 Open — no rule is locking this machine right now.Honest caveat: with root you can stop the service or undo the hosts edit. This is digital-sabbath friction, not a wall. We deliberately don't over-engineer tamper resistance on a box you own root on.
⚠️ Read docs/SAFETY.md first. This step makes the phone hard to get out of. On a release build, recovery from an active lock may require a factory reset. Rehearse teardown on a spare device with a debug build before doing this for real.
The phone is the genuine wall, via Device Owner (one-time, over ADB):
# Cleanest right after a factory reset. Device Owner refuses to set if any
# account is present, so remove ALL accounts first.
adb install agent-android/app/build/outputs/apk/release/app-release.apk
adb shell dpm set-device-owner com.commitment.agent/.admin.LockAdminReceiver
# expect: SuccessDevice Owner is what unlocks unrestricted Lock Task Mode, uninstall protection, and the
user-restriction levers (DISALLOW_ADJUST_VOLUME, DISALLOW_SAFE_BOOT) the unstoppable
alarm needs. Without it the app degrades to a weak Accessibility blocker — not the target.
Teardown: adb shell dpm remove-active-admin com.commitment.agent/.admin.LockAdminReceiver
(only with no active session) or factory reset. Factory reset is the ultimate escape and
is documented as such. Full recovery ladder + the release-build "no escape hatch" caveat:
docs/SAFETY.md.
The plane is still you. The asymmetric delay on weakening edits is the primary mitigation — it removes easy in-the-moment temptation. For something closer to truly binding, gate weakening behind a second authenticated device or a trusted person. We state this honestly: it's the difference between "removes easy temptation" and "genuinely can't get out."
python3 -m venv .venv && . .venv/bin/activate
pip install -e ./schema -e "./plane[dev]" -e "./agent-linux[dev]"
pytest schema plane agent-linux # 219 tests (+ ~199 Android tests via Gradle)
python schema/export_schema.py > schema/policy.schema.json # regenerate JSON SchemaGNU AGPL-3.0. If you run a modified version as a network service, you must make your source available to its users. This is personal infrastructure shared as-is, with no warranty — see docs/SAFETY.md before running the Android agent.

