Lacunari

Writing

What lac doctor actually checks

It's the command to run when something feels wrong and you don't know where to start — end-to-end, one screen, no guessing which migration you skipped or whether anyone's actually watching the queue.

lac doctor is a single case block in bin/lac, and every line in it exists because something specific went wrong once and looked fine until someone went looking. Here's what a run actually prints, and then what each line means.

$ lac doctor LACUNARI DOCTOR database postgres://you@localhost:5432/lac identity your-name repo /home/you/lacunari server postgres 16.3 CLI ships through 025 (this checkout: /home/you/lacunari) layers all installed land locks on — a changeset cannot land on a path someone holds tables all present budgets lane:research $5.00, global $10.00 spend today $0.0400 + 12m human views all present policies 3 active, 0 drifting team mode off — identity is self-asserted (fine solo; see docs/TEAM.md) keeper NOT scheduled — stale claims will never be released executor (unset — queued work will not run unattended) escalation (unset — blocked work only reaches the bus, which nobody reads) open 6 · claimed 2 · blocked 1 · waiting on a human 1

The connection block

database, identity and repo print what the CLI resolved before doing anything else — the DSN it's using, LAC_NAME (or its alias CLAUDE_NAME, kept for older deployments), and the checkout path. If it can't reach the database at all, it stops right there with "✗ cannot reach the database" and exits 1 — nothing below that line is worth trusting until the connection works, so it doesn't try.

CLI ships through N

This isn't a diff against what's applied — it's the highest-numbered migration file sitting in this checkout's schema/ directory. On its own it's just a number. It earns its keep together with the next line: if layers reports something missing, this is the number that tells you whether your checkout is newer than what got applied, or the other way around.

tables and layers

tables checks nine relations that the core install can't function without — lac_messages, lac_tasks, lac_agents and six more. Missing any of them means ./install.sh hasn't been run, full stop.

layers is longer: fifteen optional pieces, each named with the exact migration file that installs it — library (008_library.sql), goals (012_goals.sql), the code corpus (013_code.sql), changesets, merge, path locks, and so on. A partial install used to surface as a raw Postgres "relation does not exist" on whatever command happened to touch the missing piece first — telling you a command failed is a much worse way to learn which migration you skipped than a status line naming it up front.

Two migrations don't fit that check at all: 021_goal_kind.sql and 024_validated_attestations.sql only alter an existing table — add a column, extend a constraint — so there's no new relation for an existence check to key on. Left alone, those two silently reported "all installed" even when they weren't, which is exactly backward: a column or constraint change is the kind of migration most likely to be the actual gap. lac doctor checks those two by behavior instead — does lac_goals have a kind column, does the attestation constraint mention validated — and folds any miss into the same layers line.

land locks

This one's checked by behavior for the same reason: 020_land_locks.sql redefines a function rather than adding a table, so lac doctor asks Postgres whether lac_change_lock_conflicts(integer) exists. If changesets are installed but that function isn't, you get a yellow warning: land locks are off, meaning a changeset can land on a path someone else is holding — a quiet way for two people's work to collide that looks, from the outside, exactly like the guarantee is working.

budgets, spend, views, policies

Unglamorous and load-bearing. budgets lists every scope with a cap set — lane or global, dollars or minutes — or says none set plainly rather than staying silent about it. spend today sums lac_costs since midnight, model spend and human minutes both, because a number that only counts API calls tells a project lead nothing. views checks the six reporting views everything else is built against — missing any means schema/views.sql hasn't run. policies reports how many are active and how many tasks are "drifting": still open or claimed against a policy that no longer matches what's actually enforced.

The three that actually decide whether this runs unattended

These are the ones worth reading twice, because a fresh install shows all three in the state that most needs attention before you walk away from it.

team mode off — is that actually a problem?

Not for one person. Off means identity is self-asserted — LAC_NAME=someone-else genuinely works, and that's fine when the only one holding the connection string is you. It stops being fine the moment a second person or a second untrusted agent gets the DSN, because at that point anyone can post or claim work as anyone else. Turning it on moves identity into Postgres itself — each member gets their own login role, and the server stamps who sent what rather than trusting the client's word. docs/TEAM.md has the setup.

keeper NOT scheduled — what actually breaks?

A worker that dies mid-task — killed, crashed, laptop closed — leaves its claim behind. Nothing releases it automatically; that's the keeper's one job, run from tasks/lac-keeper.sh on a schedule. Skip scheduling it and a dead agent's claim just sits there, permanently, looking exactly like someone is still working on it. The check itself has a specific history worth knowing: it used to look only at crontab -l and reported "NOT INSTALLED" on a box where the keeper was running perfectly from /etc/cron.d — a false alarm about the one component whose real absence is silent, which is the worst kind of false alarm to ship. It now checks the user crontab, /etc/cron.d and /etc/crontab, and systemd timers, before saying not-scheduled.

executor (unset) — what does that actually stop?

lac work refuses to start at all without LAC_EXECUTOR set, unless you pass --dry-run. With it unset, tasks just accumulate in open state — nothing is claiming them, nothing is failing either, which is easy to mistake for "caught up" instead of "nobody's working." Point it at executors/claude-code, executors/shell, executors/openhands, or your own program that honors the same contract: task on stdin, exit 0 done, 2 refused, anything else blocked.

escalation (unset) — is the bus not enough?

Blocked tasks and unclaimed high-priority work escalate on a 30-minute/2-hour/6-hour backoff. With no LAC_ESCALATE_CMD, that escalation lands on the bus — which only helps if someone is actually watching it in real time. Set it to something that reaches an actual human through an actual channel: a webhook, a paging tool, a script that sends a text. This is called out directly in the project's own list of things a naive setup gets wrong, for good reason.

The last line

open · claimed · blocked · waiting on a human is the entire state of the board in four numbers. It's the fastest way to tell, at a glance, whether a fleet that's been running unattended for a week is actually fine or quietly stuck — and paired with the three checks above, it's usually obvious which.


How Lacunari works →  ·  Read the source