Operations changelog

The software release narrative of features, fixes and behaviour changes, newest first, kept separate from the knowledge-base evolution log.

Software releases, features, and fixes. This is the project's release narrative, kept separately from the knowledge-base evolution log (docs/log.md), which records only bundle-structural history and corrections of fact.

Newest first.

2026-10-15 — the release line is pre-1.0

  • The version line starts at v0.1.0. The project carried a local v1.0.0 tag (2026-08-22) that was never pushed to any remote and only ever set the build string through git describe. 1.0 is a commitment about external compatibility, so the label is withdrawn and the release line begins at v0.1.0; release-please owns later versions from a 0.x manifest (Decision 0022). The acceptance bar stays met and gate-tested — the label moved, not the evidence.

2026-10-14 — the ad-hoc verbs name the --runner tier plainly

  • command and ansible carry the caps-only --runner wording. The (caps only; no runner image is built or run — that tier is deferred) phrase commit ddd634c added to run-cli, playbook run, run, and workspace plan/apply/destroy was never added to the shared ad-hoc flags, so those two verbs' --runner help still read as an elevation path. It now reads the same everywhere, so the surface matches the code.

2026-10-14 — the ad-hoc inline run records the privilege at enqueue

  • command and ansible record a privileged run at enqueue time. The no-daemon inline path appended the attributed run requested UNSANDBOXED record only after the job drained and only when it succeeded, so a hard enqueue or drain error dropped it and the ledger carried it after the execution-time events. It is now appended right after the job is enqueued, before it is drained, matching the run-cli, gated, and daemon paths.

2026-10-14 — the web run config names the sandbox grants

  • The run configuration tile surfaces a sandboxed run's grants. The job detail already marked a run unsandboxed, but nothing showed what a sandboxed run was permitted to touch. The RUN CONFIGURATION grid now carries the declared --egress port set (Egress ports) and the --ssh-identity files (SSH identity) a sandboxed run could read, each a label over a mono value in the same tile grid. A run with no declaration is the quiet default posture and carries neither tile; a privileged run forfeited the sandbox, so no grant applied and the grant tiles stay absent while the Privileged tile names the posture. A file is shown as a PATH, never as key material.

2026-10-14 — close the privileged attribution gap for CLI runs

  • Attributed enqueue-time privileged records on every path. run-cli, command, and ansible enqueue through queue.Spec directly; they now append service.RecordPrivileged like the playbook and web paths, so CLI unsandboxed runs are attributed to the local operator too.

2026-10-14 — be honest that --runner is caps only

  • --runner stops implying a container tier that does not exist. On run-cli, playbook run, run and workspace plan/apply/destroy, the flag's help text, and in the runner docs, now state plainly that it applies only the named runner's per-job cgroup caps; no runner image is built or run. The container tier stays deferred, so the surface matches the code instead of reading as an elevation path.

2026-10-14 — one authority for the job field roster

  • The agent-interface design doc defers on job fields. §3.2 listed them inline, already stale — it omitted source, progress, plan, expires_at, and privileged. It now points at machine-output.md as the authoritative field reference and keeps only the contract's shape and the values-free discipline, so a view's fields are documented once instead of drifting across two lists.

2026-10-14 — the CLI text marks the unsandboxed run

  • jobs get and jobs list name a privileged run. The JSON face already carried privileged, but the human text did not, so an operator reading the CLI could not tell a run was unsandboxed. jobs get prints a privileged line in the enqueue warning's own words (UNSANDBOXED (no kernel sandbox, full host access)); jobs list carries the unsandboxed fact beside the source (tree · unsandboxed), matching the web list. The local and --remote renderers agree; a sandboxed run carries neither.

2026-10-14 — the privilege posture reaches the rerun dialog, the TUI, and the API

  • The rerun dialog names the original's posture and starts the escalation off. Rerunning a privileged job silently de-escalated it: the v2 rewrite dropped the toggle, so a scope that needs in-sandbox sudo failed with no way to re-escalate from the UI. The dialog now states that the job ran unsandboxed and carries the "Run unsandboxed (privileged)" toggle unchecked — re-escalating is a deliberate act, and the handler still never trusts the client to keep an escalation.
  • The TUI jobs table marks the unsandboxed run. The SOURCE cell carries · unsandboxed (fail-tinted) on a privileged row at both the 120 full and 80 degraded widths. The TUI has no job detail, so the ledger is the only place the posture shows.
  • jobs get and jobs list --output json carry privileged. A privileged: true field on a run that opted out of the kernel sandbox (absent on a sandboxed run) lets an agent reading a job over the API see the posture. It is an additive optional field, so the envelope's schema_version stays 1, the same as the earlier progress addition.

2026-10-14 — sandbox denials name their fix, ad-hoc runs get --ssh-identity

  • A denied ssh key read is classified. A sandboxed ansible run that fails because the profile grants no read of an identity key (Load key "…": Permission denied, Permission denied (publickey), no such identity) now appends an operator-facing hint naming --ssh-identity <path>, the way the sudo denial already did. A run that already declared --ssh-identity is not offered the hint again.
  • The elevation hint leads with the sandbox-preserving fix. It no longer opens with --privileged; it leads with reaching the host over SSH, so the escalation runs on the far side, and names --privileged (operator-gated, unsandboxed) only as the last resort. When the run's inventory is the controller itself (localhost, or an ad-hoc loopback pattern), the hint says the connection type is the cause and points at ssh-to-self.
  • --ssh-identity reaches the ad-hoc verbs. exocomp command and exocomp ansible accept --ssh-identity <path> and carry it through the queue to the sandbox's single-file read grant, so a one-off against a key-only host no longer forces --privileged.

2026-10-14 — the privilege posture is visible on every read surface

  • A privileged run is distinguishable from a sandboxed one at a glance. The job detail's run card wears an unsandboxed chip in the fail signal the launcher's privileged block already uses; the jobs list carries the same fact beside the source (tree · unsandboxed) and in the phone meta. The run's log renders the enqueue-time audit record as an attributed ▸ run requested UNSANDBOXED … band rather than dimming it among the system lines.

  • The doctor reports the privilege posture. A sixth health finding, privilege_posture, counts how many runs queued in the last 30 days ran unsandboxed (3 of 240 runs in the last 30 days ran unsandboxed); any such run is a warn, none is the quiet ok state. It is one probe, so exocomp doctor (text and --output json), GET /api/v1/health, the web Doctor verb's findings rows, and the /system identity strip all agree.

  • The launcher names the sandbox-preserving alternative. The privileged toggle's help now points at reaching the host over SSH when only the far side needs elevation, rather than offering the unsandboxed run alone.

2026-10-14 — host requests: bounded wait, honest states, the polling contract

  • The host request's blocking wait is bounded. POST /hosts/self/requests?wait=true accepted any positive ?timeout=, so ?timeout=999h pinned a handler and its connection indefinitely. It is now capped at 5m; an over-cap value is a 400 naming the maximum rather than a silent clamp.
  • The wait reports the waiter, not the job. A client disconnect set job_state: "cancelled", claiming the job was cancelled when only the caller went away. It now returns "disconnected". The deadline value "pending" was already honest and is unchanged.
  • The host scope stays write-only, and its response is the polling surface. Reissuing a request with the same Idempotency-Key returns the original job_id, and ?wait=true on that retry waits on the original job — poll-without-enumeration without handing a managed host a read path. Recorded as Decision 0021.
  • An ON ntfy channel no longer alarms about a missing token. A self-hosted channel is legal without one, so the tile's token fact branches on the judgment: sealed when set, a quiet none on ON, and the fail-tinted missing only on REFUSED, where the absent token is the cause.
  • The /system leg pages name their parent. /system/deadman and /system/litestream set the app-bar back label to System, so the link announces "Back to System" rather than the job-detail fallback's "Back to jobs".
  • The secret-change log names the honest verb. A successful change to an operator-supplied secret logs web: secret replace; only a generated secret logs web: secret rotate. The route, handler and store method keep their compatibility names.

2026-10-14 — the sidebar toggle stops reading as "back"

  • The rail toggle is a panel mark, not a back chevron. The sidebar's top-left collapse control drew a lone ‹ — the universal back affordance — while it toggles a layout. It now draws a rounded panel outline with the divider on its left edge, the conventional sidebar/panel mark, so the glyph names the thing it toggles. The id, handler, aria-label/title, and the collapsed "Open sidebar" flyout are unchanged; the collapsed rail no longer mirrors the glyph into a panel-right.
  • The app bar's parent link renders on desktop too. The ‹ {parent} back link was phone-shell chrome, hidden above the phone breakpoint, so a desktop detail page had no "up" at all. It now renders on the desktop bar when the page sets a parent (p.BackHref) — a job to Jobs, the backup history to targets — and stays absent on top-level pages like /system. It is the one shared element, so the phone shell is unchanged.

2026-10-14 — secret management names rotation honestly

  • secrets rotate is now secrets replace, with rotate kept as an alias. The verb, the CLI output, the web dialogs and the TUI now branch on a secret's source: a generated secret (exocomp minted it, nothing upstream holds the old value) is genuinely rotated; an operator-supplied one is only replaced — exocomp swaps the sealed copy, and the credential itself is reissued where it is minted, by the operator. No command or URL breaks: POST /secrets/{name}/rotate and the rotate alias are unchanged.
  • The TUI's ROTATED column is now CHANGED — one honest header for a mixed list; the cell still reads never or a date.
  • The store's comments match. RotatedAt records when the sealed copy last changed (a rotation for a generated secret, a replacement for a user one) and ExpiresAt marks when the credential should be renewed or removed.

2026-10-14 — the ntfy tile shows its channel again

  • The ntfy card carries a facts block again. The tile names the configured server URL (ellipsized so a long URL truncates rather than wraps), the token's presence (sealed/missing), and the push-detail rung with its one-line meaning. The URL and topic are ordinary settings, so naming the URL is safe; only the token stays presence-only. REFUSED shows the same block (token reads missing) beside its sentence; OFF shows its sentence alone.
  • The detail meaning has one source. The card's detail fact and the modal's field help both render from one mapping, so the tile and the modal cannot disagree.

2026-10-14 — the leg tile head wraps instead of clipping

  • The state chip no longer runs under the tile verbs. At a narrow card the identity cluster and the Config/Restore pair cannot share one line, and the shrinking chip pushed its label ("REPLICA") beneath the first button. The head now wraps, so the verbs drop to their own line and stay visible — no tile hides its views behind a dropdown (the tile-verbs change above).

2026-10-14 — push detail is a three-rung ladder

  • One control, three rungs (modelled on Android's lock-screen setting): full names the workspace apply (or the runbook a step gates) alongside the count and TTL and keeps the Approve/Reject buttons; summary hides the detail and the buttons, leaving a one-line "what kind of thing needs attention" and the open link; terse names nothing at all. The old descriptive setting normalizes to full, so nothing changes on upgrade.
  • The buttons are now gated by the rung. They carry a single-use capability URL, so summary and terse drop them — no Approve/Reject on an unread relay or a lock screen; the operator taps the notification (the public-URL deep link) and decides in the app. Previously the buttons rode even in terse.
  • The rich rung is richer. An approval's body names what it is about (apply prod-net, or the runbook a step gates) instead of "open to see what it wants to do", and a job-failure page names the workspace it ran in. The event, the host, the command, and the plan still stay behind the tap; the host/workspace identifiers still attribute the push for scoped mutes.
  • A push's tap target is now the app page. The ntfy transport roots a notification's deep link at the deployment's public URL (store.SettingPublicURL, set on the system page or by serve --public-url), so exocomp://approvals/42 becomes https://exocomp.example/exocomp/approvals/42 and a tap opens the approval instead of an unhandled scheme. The mapping is explicit per resource — a workspace's drift link and a secret's link each open the page that shows them, never a guessed prefix swap — and an unknown or already-absolute link is left alone. With no public URL set the link stays the raw deep link.
  • The click rides in the body too. The resolved link is set in the message's click field as well as the X-Click header, so a client that reads the body gets it.
  • The daemon refreshes it. The transport takes the public URL at load and again on exocomp reload, so changing the address on the system page retargets the next push without a restart.

2026-10-13 — system tiles: uniform verbs, one chip slot

  • The leg tiles share one button. Arm, Edit/Set up, Manage and the Litestream views all render as the same quiet 30px button in the tile head, instead of a primary Arm, a quiet Edit, a text-link Manage and a ▾ menu.
  • The state chip sits with the name. The chip was the middle child of a space-between head, so it floated to the centre and landed at a different x on every tile; it now shares the identity cluster with the name.
  • Litestream's ▾ is two buttons. Config and Restore are header buttons now (the same deep links), so no leg tile hides its views behind a menu.

2026-10-13 — ntfy config: the setting wins, and one reader

  • A setting now wins over a legacy sealed ntfy/url/ntfy/topic. The readers were secret-first, so a sealed row from before the settings move could silently shadow the value the UI showed. The lookup checks the setting first and keeps the sealed row only as a fallback, so the value shown is the value the transport uses; the modal's save already clears the stale rows.
  • One reader, not four. The daemon's transport lookup and the web seam now share sealedLookup; exocomp setup's test push reads through it too, so a server URL set on the surface makes the setup push work.
  • The operator copy matches the model. The no-transport warning, the setup hand-over line and LoadNtfy's half-configured error no longer tell the operator to seal ntfy/url/ntfy/topic, and the now-unused sealConfigSecret helper is gone.

2026-10-13 — ntfy: one modal, no drawer

  • The ntfy drawer is gone; the modal is the whole channel. /system/ntfy and its page are removed. The ntfy tile's Edit (or Set up) opens one modal carrying the server URL, the topic, the access token, the push-detail switch, the push preview and the test — so the channel is configured, previewed and tested in one place.
  • The tile is pruned. The ntfy card drops its facts block and the fan-out mini table; it keeps the state chip, the topic figure and the Edit verb.
  • Mutes and What-pages leave the web UI. The drawer's MUTES section (and its clear verb) and the fan-out table are removed; mutes stay a CLI/API surface (exocomp mute add/rm, POST/DELETE /api/v1/mutes) and the banner still reports a held push.
  • URL and topic read as configuration, not secrets. The modal labels the server URL and topic "plain configuration, not a secret"; only the access token is sealed. The stale "values never render" and "rotate in Secrets" copy is gone.
  • Send test saves and pushes in one action, and its result lands in a status box in the modal (the ok line, the refusal copy, or a classified delivery failure), so a bad endpoint is debuggable without leaving the dialog. Both detail positions render the same three preview slots, so toggling never changes the box height.
  • A save clears a legacy sealed ntfy/url/ntfy/topic. Those moved to settings, and the readers are secret-first, so a stale sealed row from an older install would silently win over the setting just saved; the save removes them. A URL carrying credentials is refused — the token is where a credential belongs.

2026-10-13 — ntfy drawer: verdict, presence, and copy pass

  • The drawer leads with the verdict. /system/ntfy now carries the same ON/REFUSED/OFF chip as the /system card, so a deep link states the channel's health instead of making the operator infer it.
  • A legally absent key is no longer an alarm. The presence rows read SEALED, a quiet NONE when an unset key is legal (a private server needs no token; an unconfigured channel is a gap), and the red MISSING only on a refused channel — the token a public host demands.
  • The app-bar meta is the page's own fact. It names the topic and server class (exocomp-7f3a · self-hosted), not the title again.
  • Priority is a word, not a wait chip. The fan-out's PRIORITY column reads plain mono (high, default); the amber --signal-wait chip is no longer spent on a config value.
  • One surface language. WHAT PAGES and MUTES rest on the same card as the presence list and the push sample, not bare tables on the void.
  • The Detail switch carries its own Save detail, replacing the detached bottom Save; the empty MUTES line names the verb (exocomp mute add), and the duplicate mute sentence is gone.
  • The sample's Approve/Reject are a preview (aria-hidden button-shaped spans), not disabled controls.
  • A failed test names its reason — timed out, host not found, connection refused, or an untrusted certificate — never the raw error, which can carry the server host.

2026-10-09 — read-once reload audit, redrive durability, re-exec test

  • A reload now refreshes the push transports too. exocomp reload and SIGHUP rebuild the notify fan-out (ntfy and GoAlert from their sealed secrets, plus the ntfy Detail setting) through the same seam they already drive the schedule registrations, so sealing, rotating, or clearing a transport no longer needs a restart. A refused configuration leaves the running transports alone. The read-once audit and the full matrix are in serve.md.
  • The redrive key is the durable marker. Job rows are never pruned (the store has no job retention), so the deterministic redrive:<oldID> idempotency key on the re-driven row is what makes a repeat a no-op; no schema column is needed.
  • job.redrive is registry-owned. The kind moved from the command into internal/wire alongside the other Kind* constants.
  • The re-exec boundary is covered end-to-end. A new in-process test drives the drain controller → serve loop → clean shutdown → re-exec chain with only syscall.Exec stubbed; a real subprocess restart is not exercised because the no-op gate refuses an unchanged on-disk binary.

2026-10-09 — restart, reload, and redrive verification fixes

  • restart --wait is refused inside a job. A job that waited on the restart would wait on the daemon that is waiting for it to exit, so the CLI refuses --wait when EXOCOMP_JOB_ID marks it as a job (the executor sets the marker on every job subprocess) and points at the bare fire-and-forget form. The bare form still works from a job.
  • A concurrent second restart is a no-op. The pending reservation is taken atomically before the precheck, so two simultaneous requests cannot both run the gate; a refused gate clears the reservation. The run-secret staging session directory is closed before the re-exec, so an in-place exec leaves no secret-bearing directory for the pid-unchanged startup sweep to skip.
  • restart no longer proceeds on an unresolvable on-disk binary. A path that cannot be resolved is a refusal, not a skipped precheck.
  • jobs redrive skips workflow steps and is idempotent. A workflow-step row is the sequencer's to re-arm, so it is reported as skipped instead of run twice; each redrive lands under a deterministic key, so a repeated redrive reports the original as already re-driven and enqueues nothing.
  • reload reports the store count honestly. The CLI prints schedules reloaded (N enabled in the store); N is an upper bound on the live set, because a row the cron parser rejects is left out of the registrations.
  • The scheduler handle is wired under the cordon lock, so a request that lands while serve is still starting cannot read a nil scheduler and report cordoned while skipping the pause.

2026-10-09 — exocomp jobs redrive re-drives jobs a restart cut off

  • A forced restart or a crash leaves cancelled/interrupted rows; jobs redrive re-drives them. It selects the cut-off rows (default --state interrupted, repeatable with cancelled), re-enqueues each as a fresh queued job carrying the same spec through the one re-enqueue path, and reports each old-to-new id pair (text and --output json). A clean restart-after-drain interrupts nothing, so there is nothing to recover.
  • Bulk re-runs need confirmation. --dry-run prints what would go and enqueues nothing; a real run needs --yes (or an answered prompt) and refuses on --no-input without it. Auto-redrive is deliberately off: a cut-off destructive run is the operator's call, never auto-rerun. See Recovering jobs a restart cut off.

2026-10-09 — exocomp restart restarts after draining, in place

  • exocomp restart is the first-class restart for a changed binary. It cordons a running daemon (the same cordon drain sets), lets the jobs already running finish, shuts down cleanly, and re-execs the on-disk binary in place — same pid, no systemd restart — over the operator API (POST /api/v1/maintenance/restart, operator scope like cordon). The request is a one-way signal and returns at once; --wait polls until the daemon goes down and prints daemon restarting (--output json re-emits the maintenance.restart envelope).
  • A job can restart the daemon without deadlocking against it. The requesting job counts as in flight until it is terminal, so the drain waits for the job that asked rather than the daemon being restarted around it.
  • The restart is fail-safe. A drain that does not finish within the bound aborts and lifts the cordon (work is never cancelled implicitly); --force cancels the remaining jobs and restarts at once. An unchanged on-disk binary is refused as a conflict; the replacement is prechecked with version; the exec only follows a clean shutdown; and a second request is a no-op.

2026-10-09 — exocomp reload rebuilds schedules without a restart

  • exocomp reload is the first-class schedule reload. It drives the same path SIGHUP and --reload-interval already did — rebuilding the daemon's schedule registrations from the database — over the operator API (POST /api/v1/maintenance/reload, operator scope like cordon), and reports the schedule count (schedules reloaded (N); --output json re-emits the maintenance.reload envelope). A reload that cannot read the database leaves the running set alone.
  • The reload/restart line is documented. Running the daemon names what is live (secrets/redaction, mutes, approvals), what reloads (schedules), and what still needs a restart (a binary or serve flag change), with the ExecReload step for the Ansible-managed unit (Decision 0020).

2026-10-07 — the secrets list scans, with a details view

  • The secrets table drops to the columns you scan — Name · Kind · Source · Expiry · Last used · Actions. Created, Last rotated, and Used by move off the row, so the nine-column table no longer squeezes values or scrolls at laptop width (the tray fits at 1280, rows 44px).
  • Any row opens a details view (a native dialog) with the full record — Kind, Source, Created, Last rotated, Last used, Expiry, and the run-secret bindings — plus the row's actions. The value is never rendered; reveal stays the separate logged, token-gated action.
  • The action cluster holds its slots. The constant Rotate/Remove verbs keep a fixed x from the right edge and the conditional Bindings chevron is prepended left, so the cluster no longer shifts row to row.

2026-10-07 — the job detail run card and verdict band compact

  • The run card leads with a compact head. The chip row and the verdict figure share one line; the title and job # are dropped (the app bar already names them) and the two meta lines collapse into one (Queued by … · ran on … · took …). The card is ~37% shorter on desktop and ~25% on phone, so the verdict band and the log sit above the fold.
  • The verdict band is denser. The keyword no longer repeats the exit figure the run card carries (FAILED — 2 OF 3 HOSTS UNREACHABLE, or just FAILED when no host was unreachable); host rows are 32px on desktop while phone keeps the 44px floor; the recap prints only non-zero counts (ok=313 changed=4 skipped=130); and the rerun verb shares a line with its settle note. A degraded run's host-list chip now wraps instead of clipping on phone.

2026-10-07 — web UI review fixes

  • The launch drawer is full-width on phone. .modal.drawer set no max-width, so the UA dialog cap left a strip of the page showing behind the drawer; max-width: 100vw closes it and the desktop 460px drawer is unchanged.
  • /events keeps its counts on phone. The compact count form was hidden globally and the full form hidden at ≤719px, so both vanished; the phone block now re-enables the compact form.
  • Source playbook rows wrap instead of clipping on phone. A long nested path overflowed the row and pushed the chevron out of view; the row now wraps (overflow-wrap: anywhere) and the chevron holds its place (flex: none).
  • The phone row-action "More" label meets AA. It inherited the chevron's ink-600 (3.6:1); the label is now ink-500 while the glyph keeps ink-600.
  • Scrollbars follow the theme. color-scheme is set on all four theme roots, so the dark surface no longer paints light scrollbars.
  • A non-ansible failure no longer claims Ansible. verdictSentence had no kind guard, so a failed shell job read "Ansible could not reach the target."; non-ansible kinds now say the command failed and point at the log.
  • /checks no longer prints a false location. A target with no netgroup rendered the literal "on away"; it now reads "no netgroup", while a real netgroup still renders "on {netgroup}" and the store's away miss label is kept.

2026-10-06 — the Queue run button stops repeating the playbook path

  • The launch drawer's submit button is the bare Queue run verb. It restated the playbook path (Queue run — <path> @ <ref>) directly above the footer line that already states the whole ask (asks nothing else · same as exocomp run <source> <path> [--egress …]), so a long collection path wrapped the primary across two lines. The footer is now the one place the contract is stated and the button stays one line at any path length. The Command tab, which had been mislabelled with the playbook name, is fixed by the same change.

2026-10-06 — a local-directory collection resolves under the sandbox

  • A requirements file naming a collection on the local disk no longer fails the deps step under the enforcing tier. ansible-galaxy read a type: dir or type: file entry — a collection checkout outside the source tree, e.g. …/ansible-unifi-network — which the resolver sandbox did not grant, so the install died with [Errno 13] Permission denied: …/galaxy.yml. deps.DiscoverGalaxy now reports the existing local artifact paths a requirements file names, and the sandboxed collections resolve grants exactly those read-only on top of the project root; the profile guard still refuses any that would reach the data dir. Galaxy-server, git, and url entries are dialed, not read, and get no grant.

2026-10-06 — pre-commit's own hooks replace the custom dispatchers

  • .agents/hooks/ is gone. The pre-commit/pre-push/commit-msg shims were one-line exec pre-commit run --hook-stage … wrappers around the framework; pre-commit install generates those itself. Activation is now pip install pre-commit && make hooks (the new make hooks target, which clears core.hooksPath) instead of git config core.hooksPath .agents/hooks. The repo-specific commit-msg-extras.sh local hook moved to .agents/scripts/.

2026-10-06 — the installer becomes recovery.sh

  • packaging/install.sh is now packaging/recovery.sh. Its install and rollback modes were stubs (they only echoed "would …"; the real rollback is the binary's exocomp rollback), and harden-host-key moved to docs/operations/bootstrap.md as the documented one-off systemd-creds step. What remains is the one thing that must stay out of the binary — the invariant-7 bare-metal restore chain (delegating to exocomp restore when a binary is present). The bootstrap docs now describe the real deploy (image + Quadlet + exocomp init) instead of an installer that never installed.

2026-10-06 — drop the DCO file

  • The root DCO file is gone. The project certified contributions by the DCO's "submit = certify" reading, with no Signed-off-by trailer (invariant 9); with no outside contributions the file was dead weight. AGENTS.md, README.md, and the contribution guide drop their references — invariant 9 still carries the no-trailer rule.

2026-10-06 — drop the .agents/bootstrap.sh setup shim

  • .agents/bootstrap.sh is gone. The one-command activation it wrapped — git config core.hooksPath .agents/hooks — is already the documented step (AGENTS.md invariant 12, the contribution guide, the repo-conventions skill), and no tooling invoked the script. Its other jobs (a local CLAUDE.md pointer and .claude/ agent/skill symlinks) are Claude-Code-local shims, not part of the repo contract; the docs, the skill, and the pre-commit comment now name the command directly.
  • The drawer's Queue run footer no longer overlays the form. The sticky footer was a child of each form inside the scrolling body, so at rest it sat on top of the last field group (the privileged block). The footer is now a flex sibling below the scroll body — the body's viewport ends where the footer begins — so no field can hide under it at any scroll position, and the footer stays pinned at the panel bottom. The submit button lives outside its form, associated by id (form="launch-…-form"), and the tab switch toggles the matching footer.

2026-10-05 — the dashboard queue card fits a narrow desktop

  • The LAST N RUNS rows no longer overlap on a narrow laptop window. The queue row's desktop grid used fixed 200px 170px 130px 90px columns around a bare 1fr PLAYBOOK track, so below ~1250px the playbook column collapsed to ~0, its title wrapped, and the row's fixed height: 56px let the content spill over the next row. The columns now give the playbook a 140px floor and let the rest shrink, the row grows to its content (height: auto; min-height: 56px), and the cell facts ellipsize instead of wrapping.

2026-09-26 — aggregate check events removed

  • The opt-in aggregate check feature is removed. A check no longer carries aggregate/min_up, the sweep no longer derives a check-level rollup or its reserved * check_state row, reactive.Event no longer carries Hosts / event.hosts, and a playbook rule no longer accepts --limit-hosts. Per-host events are simpler to reason about and debug, and the "one run over the present host set" capability was not wanted (Decision 0019).
  • Migration 0041 stays. check_row.aggregate/min_up remain as inert schema: the file is forward-only, and deleting it would make a database already at user_version 41 unreadable by the binary. Decision 0018 is superseded by Decision 0019.

2026-09-25 — probe guard on every dialing kind, and honest residuals

  • Every dialing check kind refuses the loopback class. tcp, ping, and tls now apply the address-class refusal the http/metric client already did whenever AllowLoopback is false (the sweep and check probe), so a readonly token can no longer probe the daemon's loopback or the cloud metadata address; a gate keeps probing unguarded. A check whose target is the daemon's own loopback (for example tcp 127.0.0.1) now reads down on the sweep — the guard doing its job. A tls target with no host (:443) is refused at validation instead of dialing the local host.
  • --limit-hosts refuses ansible magic names. A declared target literally named all/ungrouped, or beginning with -, is refused before it reaches the --limit pattern, so it cannot widen a rule's run to the whole inventory.
  • Threat-model residuals recorded. The --limit-hosts guard cannot see an inventory group whose name collides with a target name, and the check_/netgroup_ config writes leave no actor or ledger row; both are named with their owning files.
  • MCP check/netgroup descriptions clarified. probe_check says it performs live network I/O now; a check's status is the last sweep's stored observation, not live; check_update states that omitting aggregate/min_up keeps the stored rollup settings and that a dropped/renamed target loses its state; check_snooze notes suppressed transitions are not replayed; check_rm is irreversible; netgroup away is reserved and a rename is not supported.

2026-09-25 — checks: slow-target signal and stored-netgroup filter

  • labels["duration_ms"] on check events. A declared transition's event carries the probe's measured duration — the value already persisted as check_state.last_duration_ms — so a rule can fire on "up but slow" without a second probe.
  • check list --netgroup <name>. Narrows the listing to checks with at least one target whose stored check_state.netgroup is <name>, reusing the sweep's classification rather than re-classifying.
  • API aggregate parity. POST /api/v1/checks and PUT /api/v1/checks/{name} accept aggregate/min_up through the same store verbs as the CLI; an out-of-range min_up is refused.
  • rule list --output json carries limit_hosts. The governed host-set expansion flag is visible to machine consumers.
  • serve --check-canary is validated at startup. A non-empty value that is not ip:port is refused, so a typo cannot silently turn every down target into a missed observation.
  • One probe runner. check probe, MCP probe_check, and the API probe route share internal/checkprobe with unchanged output, still writing nothing.

2026-09-16 — probe estates as code: export and apply

  • check export / netgroup export. Emit every check (kind, interval as a Go duration, enabled, aggregate, min_up, and each target's kind fields) and every netgroup (includes, excludes) as one applyable YAML document (default) or JSON (--output json), ordered by name so a re-export diffs cleanly.
  • check apply -f / netgroup apply -f. Reconcile the store to the file, idempotent by name: create, update in place, or report unchanged, with a per-row and total summary. Validation is the store's own verbs (ParseCheckTargets/ParsePrefixList, create/update rules). A check's kind is not updatable — a changed kind is refused by name rather than recreated. -f - reads stdin, so export | apply -f - is a no-op.
  • --prune needs --yes-really. Deleting every row absent from the file is confirmed with the same idiom as a global silence (or the global --assume-yes); an unconfirmed prune is refused non-interactively.

2026-09-16 — aggregate check surfaces and governed --limit-hosts

  • Set the aggregate everywhere. check add/update --aggregate --min-up N (bounded 0..len(targets)), the /checks add and edit dialogs, and MCP check_add/check_update (aggregate, min_up) all write the opt-in rollup. check list gains an AGGREGATE column and the check card badges "aggregate · min N up"; check.view exposes aggregate/min_up.
  • rule add --limit-hosts. A check-sourced playbook rule derives its ansible --limit from the sweep event's present host set instead of a fixed pattern. Add refuses it off --source check or off the playbook action, and the builder refuses an empty set and any host that is not a declared target of the named check, so only a daemon-produced sweep event can shape the run (trusted-source line, design §3.3). rule list shows (limit: event hosts).
  • The reserved * rollup row is never a target. The metrics gauges and the CLI, web, API, and MCP check views skip it, so it cannot surface as a probed host or be counted twice.

2026-09-16 — check-level aggregate events (core)

  • Opt-in aggregate. A check row can carry aggregate/min_up; when set, the sweep also derives one check-level transition from the present target set (the targets currently up) and emits a single check:<check>:aggregate event carrying hosts — the sorted present names — plus labels.up/labels.total. Default off: a check without the flag behaves exactly as before.
  • hosts on the event. Event.Hosts is readable from CEL as event.hosts, and the event wire view exposes it (omitempty). Only a daemon-produced sweep event carries it; a webhook normalizer leaves it empty, so no rule can be made to run against an attacker-supplied host set (trusted-source line, design §3.3).
  • Migration 0041 adds check_row.aggregate/min_up, both defaulting off, with a pinned in-place upgrade test. The reserved * rollup state row is not a target, so it is excluded from the dashboard up/down count and preserved by a check re-point.
  • The check add/check update flag, the web control, and the --limit-hosts expansion follow in a later change.

2026-09-16 — checks and netgroups reach agents and the API

  • MCP read tools. list_checks, get_check, and list_netgroups project the same check.list/check.get/netgroup.list wire bytes as --output json and /api/v1: each check target's stored config plus its last status, stored netgroup, and probe duration.
  • probe_check runs a check's probes now and writes nothing — no state row, no event (D3) — so it needs only the read floor. It returns per target {target, up, duration_ms, netgroup, detail} and takes an optional target.
  • MCP write tools check_add, check_update, check_enable, check_disable, check_rm, check_snooze, check_unsnooze, netgroup_add, netgroup_update, and netgroup_rm sit under the operator floor and call the same store verbs the CLI and the web form use; a kind-inapplicable field is an invalid_argument.
  • /api/v1 gets the matching write routes — POST/PUT/DELETE for checks and netgroups, plus POST /checks/{name}/probe at the readonly floor. Creates and updates return check.get/netgroup.get, deletes 204, a duplicate name 409, and bad input 400.

2026-09-16 — netgroups become a primitive: exclusions, update, inspection

  • Netgroups carry exclusions. A CIDR prefixed with ! in the add/update list (home 192.168.0.0/16,!192.168.5.0/24) is a hole in the set: an address inside any group's exclude classifies to away even when an include also covers it. Stored in the existing prefixes column — no migration.
  • netgroup update <name> <cidr[,...]> replaces a group's includes and excludes in place without disturbing stored check_state; netgroup show <addr> prints the group and winning prefix (or away) for one address; netgroup usage [<name>] is the read-only report of which check targets and observed webhook peers classify into each group.
  • Every source stamps one netgroup label. Webhook ingress now writes labels["netgroup"] alongside peer_netgroup and peer_ip, so a webhook rule and a check rule read the same key; the two are always equal for one peer. A group may no longer be named away, which is byte-identical to the miss label.
  • The netgroups tab renders exclusions on their own line, netgroup list --output json and /api/v1/netgroups carry excludes, and the add form accepts ! members through the shared parser.

2026-09-16 — the new check kinds get surfaces and an on-demand probe

  • check add and check update carry the new kinds' flags. --min-days (tls), --expect-addr (dns), and --op/--value (metric) apply to the parsed targets; a flag that does not belong to the kind is refused, and a metric target requires both --op and --value.
  • check probe <name> [--target <t>] runs a stored check's probes now, writing nothing: status, duration, location, and failure detail per target, with the sweep's options and no daemon needed. check test is the dry run: it validates and runs a one-off target set without storing a check.
  • The /checks add dialog widens to all six kinds and renders the per-kind fields; the edit dialog renders the stored kind's fields with the kind fixed. Refusals mirror the CLI at 422 with every field's message at once, and target classification now uses probe.Location, so a hostname or URL target no longer parses as an address.

2026-09-16 — one probe runner, and checks gain dns/tls/metric kinds

  • A shared internal/probe runner backs checks and verify gates. Both build one union Spec and call one Run; verify keeps its argv encoding, job routing, and public API, and now also runs tcp/ping specs its encoder does not emit. A check refuses the loopback, link-local, unspecified, and multicast dial classes a gate allows, and the two keep their differing http defaults: a check's ExpectStatus == 0 accepts 2xx/3xx, a gate's accepts 2xx only.
  • check_row.kind widens to dns, tls, and metric. Migration 0040 rebuilds check_row and check_state together — the child first, because of its incoming foreign key — and carries a pinned upgrade test that proves seeded rows survive the rebuild and the widened CHECK admits a tls row.
  • Check targets carry the new kinds' config as additive targets_json fields (dns_name, expect_addr, min_days, op, value), so a previously stored target still decodes. check list --output json and /api/v1/checks project the new fields. Only an address literal classifies into a netgroup, so dns/tls/metric (like http) carry none, and no read path parses a hostname or URL as an address.

2026-09-16 — checks show, snooze, and edit their observation state

  • check snooze <name> --for <duration> and check unsnooze <name> lift and set a check-level snooze from the CLI. --for is mandatory, like a silence: a snooze the operator forgets is how a check stops noticing.
  • The check surfaces render the stored location, probe duration, and snooze. check list gains NETGROUP and DURATION columns and names an active snooze in place of ENABLED; the /checks page uses the stored netgroup and last_duration_ms for observed targets (live classification still backs the never-probed cold-start tile) and badges a snoozed check with the time left.
  • A down target that is classified into a home netgroup is flagged distinctly from an ordinary roaming down: the card counts "at home" and the tile reads "down while home — expected reachable".
  • The /checks page can re-point a check in place. The edit dialog calls UpdateCheck over POST /checks/{name}/update: targets replace the set, interval replaces the cadence, the kind is fixed, and a kept target name keeps its state row. Refusals mirror check add (422 with every field's message).
  • /metrics adds exocomp_check_target_up, exocomp_check_probe_duration_seconds, and exocomp_check_snoozed, with CheckTargetDownAtHome and CheckSnoozed alert rules seeded beside them.

2026-09-15 — checks record where, how long, and when muted

  • A check's observation state now carries its location, probe duration, and snooze. check_state gains netgroup and last_duration_ms; check_row gains snoozed_until. The sweep classifies each address target once at observation time and measures each probe, so check list --output json and /api/v1/checks project the stored values instead of recomputing a netgroup map. An http target stores no netgroup.
  • serve --check-canary <ip:port> gates a sweep on the daemon's own reachability. When a target reads down and the canary is also unreachable, the check's sweep is a missed observation — no state advances, nothing emits — so a lost uplink no longer reads as mass-down. Empty disables it.
  • A snoozed check keeps probing but emits nothing until its expiry passes, so hysteresis stays current. Probing a check's targets now uses a small worker pool, with state application and emission still sequential: per-target transitions are neither lost nor duplicated.

2026-09-15 — the workspaces empty state shows the design block

  • The live [3] Workspaces empty state renders the three-line block the design specifies, not a compact one-liner: no workspaces yet, the terraform dirs exocomp watches for drift and plans on your behalf explanation, and the add the first: exocomp workspace add ./infra command. The block is shared with the golden renderer, so the pinned frame and the binary can no longer drift.

2026-09-15 — the TUI core tables degrade below 120 columns

  • The four core tables drop metadata columns at width < 120 instead of overflowing. Each *Columns() now takes the terminal width: Jobs keeps ID SOURCE STATE EXIT, Backups TARGET STATE SNAPSHOT ERROR, Workspaces NAME SERIAL DRIFT, and Schedules NAME KIND CRON ENABLED NEXT, each the full set at width >= 120. The surviving columns are unchanged in order; only the dropped ones go, so state words and key glyphs never move. A table's SetSize swaps columns and the matching row cells together (the bubbles renderer indexes row cells against columns), which is what makes a resize safe. On the events screen the REASON cell is clipped with … below 120 so the line fits; OUTCOME keeps its fixed column and is never truncated. The fixtures are byte-identical: the 120-wide events-120 and home frames did not move.

2026-09-15 — the TUI screens say "loading", not a false empty

  • The seven v0.2 screens hold a dim -- until their first load lands. Init returns every load as a batch, so Bubble Tea painted View before any *LoadedMsg arrived and a populated store flashed an empty-state sentence on its first frame. Model now carries a per-load flag — loadedGlance (Home, Approvals, Events), loadedJobs, loadedBackups, loadedWorkspaces, loadedSchedules — each set on its *LoadedMsg case before the error branch, so a failed load renders its error instead of a permanent placeholder. The placeholder is the same ASCII -- an unset value already uses. The fixture renderers, their golden frames, and every empty-state wording are unchanged.

2026-09-15 — the TUI run-now keys are live

  • The TUI's deferred run-now keys now enqueue, and c cancel confirms. b on [2] backups, l on [3] workspaces, and x on [4] schedules release work through the shared spec builders (backup.Spec, PlanWorkspace with service.TriggerTUI, sched.Specs), each guarded on the write surface so a read-only TUI stays inert; a schedule that builds no spec flashes instead of enqueueing nothing. c on [1] jobs now opens a confirmation and moves the job only on enter, with the state read at press time so a job that moved meanwhile conflicts rather than racing. The help bars and the [?] overlay name the live keys.

2026-09-15 — the run-now spec seams are importable

  • The TUI's deferred run-now keys now have seams to call. The b backup, l plan, and x schedule keys could not enqueue because the spec assembly lived in package main or was unexported. backup.Spec(name, trigger) is the one spec shape the CLI and the TUI both enqueue; service.TriggerTUI and a widened daemon seal gate let a TUI plan enqueue without sealing itself; and sched.Specs(ctx, st, log, sc, trigger) exports the schedule-kind to specs builder while (*Scheduler).buildSpecs stays as the fire-triggered wrapper. No CLI or daemon behaviour changes.

2026-09-15 — the TUI plan diff reads the show-json companion

  • The approval plan diff and the destroy typed-confirm now parse the sealed tofu show -json companion. Both opened the approval's saved plan artifact and JSON-parsed it, but that artifact is tofu's binary .tfplan, so on a real destroy-carrying plan the typed-confirm modal counted zero destroys and the [d] pager rendered empty. A package-level planShowBlob resolver mirrors the web's: it prefers the reactive.PlanShowArtifactKind companion captured on the same job and falls back to the plan artifact's own bytes for a plan sealed before the capture existed.
  • The phone launch drawer's sticky footer now pins to the scrollport bottom. The 719px block reserved the footer's 96px on .modal.drawer .modal-body, but the footer's containing block is the inner form.writeform, so the form's box ended 96px above the scrollport bottom and the footer pinned there, leaving a band through which the privileged description showed. The reservation moves to the form and the body's bottom padding zeroes, so the footer meets the panel bottom and the last control still clears it.

2026-09-15 — the checks tab swap moves the app-bar add verb

  • Switching the Checks tab now switches the app bar's add button. The tab strip's client-side swap replaced only the #checks-surface fragment, so the bar's primary — Add check on Probes, Add netgroup on Netgroups — stayed whatever the last full load rendered. The fragment response now also carries the app bar's add region (a shared appbarAdd component keyed #appbar-add), and the swap JS replaces it in place, so the bar tracks the active tab.

2026-09-15 — the sandboxed elevation hint fires on the real signature

  • The become-denied elevation hint now fires. The observer added 2026-09-12 only retained level=error (stderr) events, but ansible reports the become failure on STDOUT (level=info), so the signature was never seen and the actionable line never appeared. The observer now scans every emitted message (stdout or stderr) for the loader-denied sudo signature and keeps only a flag, so a normal run's memory is unchanged. The regression test prints the signature to stdout, matching production job #5.

2026-09-14 — the verdict-band host recap wraps

  • The HOST table RECAP wraps on phone instead of overflowing. The 719 block only added overflow-wrap: anywhere to a rule that kept the base white-space: nowrap, so a long ok=2 changed=0 unreachable=0 … recap could not wrap and ran off the row. The phone rule now resets the nowrap and the full-width line 2 wraps anywhere.
  • The desktop HOST table truncates instead of overflowing. The head and row grids used a bare 1fr, whose min-width: auto floor widened the track to the nowrap recap and made the base text-overflow: ellipsis dead; both become minmax(0, 1fr) so the recap ellipsizes at the column.

2026-09-14 — the phone backups and system surfaces tighten

  • The backups topbar verbs drop to the row scale. Restore and Add target read as 40px, 14/500 page verbs instead of the 48px touch floor, and the ACTIONS cell becomes labelled bar-size ghosts — Run now and More ▾, right-aligned, gap 8 — replacing the bare ▷/⌄.
  • The backups posture cells stack key over value on phone. KIND, RETENTION and SCHEDULE left-align their label over the value instead of hanging the value right beside a 120px void; the override is scoped to those three cells so the shared stackable-table rule is untouched. add one reads as a real link.
  • The launch drawer reserves its footer. The scroll body pads past the sticky footer's height plus the safe-area inset, so the last control (the Run as allowed… toggle) never hides under it; the options summary becomes a short More with its field list as a 12px helper beneath it.
  • The system page names what is not set and drops empty leg figures. The identity strip shows public URL · not set with a bar-size Change ghost; a phone leg row whose figure is the — stub hides the line and reads 48px; and the clipped bar subtitle is hidden on phone by a page class, so jobs and events keep theirs.
  • The phone shell loses two stray marks. The first powerline segment zeroes its left padding, and the hamburger drops its border and shadow to match the bare brandmark.

2026-09-14 — the phone job surfaces tighten

  • The jobs list row reads #id · title left with the chip right. The number and title sat apart on phone; line 1 now joins them (#id · in the number slot, the title ellipsizing beside it) and the state chip pins to the right edge. The row's line-2 meta drops a bare exit 1 — the FAILED chip already says it — while a reason with a cause (exit 4 · 2 hosts unreachable) stays. The card's redundant "All jobs" heading is gone (the title lives in the bar).
  • The job detail bar gets a real back chevron. The slot rendered a double-slash SVG that inherited the link purple; it is now a joined chevron in ink. The run card drops its kind chip when it repeats the title (a backup job's backup), keeping it when the tool is a real distinction (ansible).
  • The job log wraps by default on phone and shares one gutter. The 719 block now sets pre-wrap (the round-2 capture read raw only because it was fine-pointer), so the .wrap toggle is the raw state and reads as a 36px ghost beside the Raw verb; the log lines take one clock column (60px phone, 70px desktop) from CSS instead of a 70px inline that dead-lettered the phone rule.
  • A single-tile run configuration collapses to one line. A run whose config is just Tolerate unreachable · off reads as one 12px mono line under the queued-by sentence; a multi-tile playbook run keeps the tile grid.

2026-09-14 — the phone read surfaces tighten

  • The dashboard insecure banner stacks. At 390px its kicker and sentence were two flex columns and the sentence wrapped one word per line with the last line clipped; the wrap's real cause was the TLS verb carrying the global 34px glyph-button .verb shape. On phone the kicker takes line 1 (11px mono), the sentence sits below it, and the verb is a plain text link — unclipped.
  • The dashboard queue idle kicker realigns and the row name is ink. The base queue block is authored after the 719 block, so its padding: 0 14px defeated the phone inset — a phone re-assertion aligns the kicker with the rows, and the LAST RUNS rule drops its 14px base inset to the same 12px. The recent row name was accent purple only because its .cell is an <a>; it now renders in ink like the jobs list.
  • The events funnel and rows work on phone. The six n label counts flow and wrap to fit the card in 12px mono with the proportional bar hidden; a row whose rule is unset no longer prints source → -, a rule-unset marker hides the rule cell on phone; the outcome chips stay 32px mono 12.
  • The secrets card drops a redundant line. The "All secrets" heading is gone — the kicker and the empty-state sentence already say it.

2026-09-14 — three phone layout defects fixed

  • The launch drawer's Queue run stays above the powerline. The primary sits in a sticky .drawer-actions footer pinned to the panel bottom; the long form scrolls under it, the button no longer lays out below the panel (where the 36px powerline foot clipped it), and the safe-area inset pads the footer.
  • The dashboard queue recent rows keep the playbook and the clock. The base queue grid block was authored after the 719 block, so its equal-specificity display:grid/display:contents won on phone and the two-line form never applied — the playbook cell collapsed and the clock laid out past the viewport. A phone re-assertion restores the stacked row: playbook + chip on line 1, #id · reason + clock on line 2, all on-screen.
  • The backups LAST RUN cell separates the chip from the reason. The cell stacks its label, chip+reason line and meta in a column; the chip holds its width and the reason wraps inside the cell instead of overlapping the chip.

2026-09-14 — the remaining pages get the phone form

  • Events rows dedup and the footer count reads the row population. A row whose source equals its matched rule (a job-outcome event matched by a rule of the same name) prints the value once on the phone line 2 — source → rule — instead of job-outcome → job-outcome. The footer's showing latest {n} of {m} now reads the same un-windowed ledger the tray rows come from, so a store whose events all predate the 24h funnel stops reading "3 of 0". The phone row re-flows to alert · received clock · outcome chip on line 1 and the source → rule pair on line 2; the source switch is 44px and the outcome chips 32px mono 12.
  • The system phone leg rows surface real state. Each .leg-row now shows the leg's figure, its state chip (NONE/ARMED/TRIPPED, ON/REFUSED/OFF, REPLICATING…, backups OK/NONE) and a › at 56px tappable, instead of the hardcoded — stub.
  • The leg pages carry one-line titles with a back chevron. Dead-man and Litestream split their two-part System · {Leg} bar title to the one-line leg name with a back chevron to /system; the dead-man heartbeat fact wraps under its key on phone instead of ellipsizing a sealed URL.
  • The litestream Set-target form stacks on phone. The Replica target heading reads over its meta sentence, the encryption checkbox row is a 44px tap target across its label, and the form card carries the bottom padding that clears the powerline foot.
  • The launch drawer sits above the powerline with a sticky footer. Its full-viewport height no longer hides the phone signals — it ends above the 36px foot, the Esc closes note is sticky at the drawer's bottom with the safe-area inset, empty inputs keep the standard control height, the leaked · before may end DEGRADED is gone, and the More disclosure carries a rotating chevron.
  • Discovered playbooks are tappable rows. Each playbook is a 44px row with a › and a press surface that opens the launcher prefilled, not a bare mono line.

2026-09-14 — the §2 button system lands on phone

  • The app bar holds no filled accent. Every bar-slot action — the Add verb (Add secret, Configure, …) and the text actions — is now a 36px ghost: resting transparent, --surface-control on press. A page's primary lives in the new content .page-actions row (40px at ≥720px, one filled accent, gap:8px wrap); the backups topbar's Restore / Add target pair is the first group.
  • The backups topbar stacks on phone. The meta sentence reads full width on its own line under the bar instead of a ~140px column beside the buttons; the Restore / Add target verbs become the row-size group with Add target the one filled accent, and the ▾ holds: footer string no longer bakes a bare · ─ · between drill and remove.
  • Add secret and Configure sit at the bar's right edge. They render inside .appbar-actions (the margin-left:auto slot) as bar-size ghosts, not filled 48px blocks.
  • The powerline's first segment draws no leading |. The dead-man segment's left separator is dropped, so no stray rule reads before the first signal.

2026-09-14 — the job detail page gets the phone form

  • The doctor self-check rows stack on phone. The .doctor-findings three-cell row becomes name · chip on line 1 with the detail full width on line 2, wrapping anywhere — the desktop 130px detail column no longer clips http://127.0.0.1:39099/me….
  • The verdict area hangs left on phone. The run card's chips · title · exit 1 figure (now 28px) · ran line are left-aligned at 390px instead of hanging right beside a 200px void.
  • The verdict sentence never reads "reach ." A FAILED run with no parsed host rows now says "Ansible could not reach the target." instead of joining the empty host list to a trailing dot; a run that names hosts still names them.
  • The output card gets a bounded pane and a real wrap toggle. On phone the log pane is bounded by max-height:60vh (flexible, not a fixed viewport height), the Output header is two rows, and the wrap toggle is wired to a real .log.wrap rule (raw horizontal-scroll mode) instead of a no-op against the pre-wrap default.
  • The stray / breadcrumb separator is gone from the phone bar; the page title stays. The job page's in-content Jobs / #4 breadcrumb is removed (the app bar back chevron already navigates), and the shared .breadcrumb (with its stray "/") hides on phone. The page title remains in the bar per SPEC §2.0 — the stacked title-wrap over its mono-11 sub still renders below the back chevron.
  • The log wraps by default on coarse-pointer devices. A real phone's log is pre-wrap by default under @media (pointer: coarse) (the 719 block's raw column is the wrap toggle's pressed state); pressing the toggle still flips to the raw horizontal-scroll column.

2026-09-14 — the queue and jobs tables get the phone form

  • The dashboard queue card is two-line rows on phone. The LAST N RUNS five-column grid stacks into line 1 (playbook · state chip right) over line 2 (the #id · reason meta · clock), and the live rows collapse to a title · chip line. The desktop five-column grid is unchanged: the same .line/.cell nodes re-flow through display:contents.
  • The stray idle word above the queue card is gone. When the queue is idle the section label renders nothing — the card's own ○ QUEUE IDLE line is the statement, so a bare word never floats above the card.
  • The jobs list rows carry a flat mono meta line on phone. Line 1 is the one-row #id · title · chip (the name ellipsizes with the chip pinned right; the reason never joins it); line 2 is the mono-12 TriggerMeta · Source · ran · duration meta with the FAILED/DEGRADED reason appended last (the operator folds into TriggerMeta, never a standalone field), replacing the stacked desktop trigger/ran cells.

2026-09-13 — the drill's outcome is structured

  • The scheduled drill's outcome rides the job event's raw_json. The backup-drill job attaches a backup.drill structured payload — source, snapshot, RPO age, ok, reason, verified file/byte counts — to the same event whose line stays the readable "restore drill OK/FAILED: …" summary. The source is scrubbed of embedded userinfo exactly like the line, and the queue redacts the payload again before it is stored (invariant 4); a failed drill carries ok:false with its reason.
  • The web reads the verified-bytes fact from the payload, not the line. The drill mark's "N MB verified" figure on /system/backups and the target history page now comes from raw_json; the line-text byte parser is gone.

2026-09-13 — restore names the snapshot it would rebuild

  • exocomp restore --snapshot <id> rebuilds a chosen restic snapshot. The id is a restic snapshot id, full or an unambiguous prefix — the same first-12 short id the web drawer shows. With no --snapshot, restore stays newest-only. Git targets refuse the selector (their leg keeps exactly one snapshot), and --snapshot alongside --from-litestream is refused because the replica leg has nothing to select.
  • The DR drawer names --snapshot {id} when an older restic snapshot is selected. The RUN ON THE HOST command says exocomp restore --snapshot <id> for a selected non-newest restic row — the command restores what the operator picked; the newest row, a git row, and replica mode keep the plain exocomp restore / --from-litestream.

2026-09-13 — the Recovery-kit modal regenerates

  • The Recovery-kit glyph opens a stable modal instead of only flashing. The identity strip's kit glyph is now data-modal-open="kit" (Regenerate): the modal shows the regenerated kit — the envelope's public render, KitStructure().Render() — recipients and fingerprints, the HKDF salt and the derivation recipe, in a scrollable mono block with the shared Copy glyph. The recovery secret never renders: the modal and the Download both serve stored public data only.
  • Download pulls the kit as a file. The new GET /system/kit route serves the raw render as text/plain with Content-Disposition: attachment; filename="recovery-kit.txt"; a surface holding no envelope (a web-only process) fails it closed to 503, like the Sealer.
  • The modal's primary records the print. The reprint POST is unchanged — it stamps the printed date and resets the changes counter — and the modal relabels it Printed — destroy the old page with the success-only flash. The web Server's Deriver slice grows a Kit() method for the render; the daemon keeps passing the envelope.

2026-09-13 — the powerline reconciles

  • The /system page-level powerline is gone. The strip of per-leg anchors under the identity strip (version · litestream · dead-man · ntfy · backups) duplicated the global bottom .powerline, which already carries the liveness signals; the page now ends at the four leg cards. Litestream and backups stay on their /system cards — they are not merged into the global footer.
  • The bottom powerline takes the | separators. The .pl-segs carry the page-level look — 14px side padding with an inset hairline — and the powerline's flex gap drops to zero, so the hairline is the separator. Dead-man / ntfy / disk render as dead-man none / ntfy off / disk 42% free, then the signed-in operator.
  • The dead-man gap drops its — set one suffix. The segment reads the bare word (dead-man none) in wait — the fix verb belongs to the card, and the same rule now colours the ntfy refused state in fail on the one remaining strip.

2026-09-12 — the sandboxed elevation denial names its fix

  • A failed sandboxed run whose become's sudo was loader-denied gets the action line. When the run's stderr carries the become-denied signature — a line naming sudo whose shared-object loader was denied, the sandbox refusing the un-granted /usr/libexec/sudo — the failure grows the sandbox denied privilege escalation (sudo) — re-run with --privileged (operator- gated, unsandboxed) or run the elevated part outside the agent surface. Diagnosis copy only: escalation stays denied, and the hint never fires on a plain task failure or a privileged run.

2026-09-12 — the backups 08 nits close

  • The freshness ladder drops onto its own line at ≤1440. The /system/backups footer stacks the two sentences in a column with a 4px gap — the wrapped rows were crammed at gap 0 — and the ladder's join dot is now a ::before the stacked line drops, so the second row reads cleanly.
  • The posture cells' second lines are 12px mono. RETENTION and SCHEDULE meta join the KIND path at 12px JetBrains Mono ink-400 — the three second lines are uniform; the 11px sans sub-line is gone.
  • never ran renders at the meta size. A never-run target's LAST RUN shows the word on the small second line, never as the oversized 13px first line.
  • The ▾ menu's danger item gets a real rule. A role=separator hairline divider introduces the destructive item — the design's ─ · remove asks first — and the "asks first" note sits inline beside the verb instead of floating to the menu edge, where it read as a stray — item.
  • The play glyph carries its label. The run verb on an enabled row renders with title/aria-label "Queue backup — {target}" (verified live; the ACTIONS cell uses the shared rowActions partial).
  • R2b re-verified live: the closed identity strip is clean. The /system strip at rest renders the hidden pubform inside the strip and nothing below it — a 20px grid gap to the legs, no stray input or meta at 1280 or 1920.

2026-09-12 — the backups row keeps its table height

  • The /system/backups row renders at ~64px, uniform across cells. The KIND / RETENTION / SCHEDULE cells stay table-cells — the display:flex on the <td>s that escaped them from the table's row layout and ballooned the row to 132px is gone — and the two-line column moves onto an inner .bk-cell wrapper. The row keeps min-height:64px; the six cells share one height.

2026-09-12 — the v0.13 round-3 residuals close

  • The /system/backups row stops clipping. The rows are min-height:64px with centered cells — never a fixed height — so the two-line cells grow instead of truncating; only the KIND path ellipsizes (now at 12px ink-400, kind line at mono 13). The topbar meta pluralises (1 target / N targets) and the footer splits to two lines at 1280: the verbs left, the freshness ladder right, in a wrapping flex row.
  • The identity strip sits on the grid. .sys-identity carries the 20px bottom gap that was missing between the strip and the card grid.
  • The appbar bottom reads as a real boundary. The bottom border is the full hairline, not the faint one, so the cut holds when the strip scrolls under it.
  • The ntfy OFF figure quiets down. The leg figures a dim — with no channel · nothing sealed as the note, instead of no channel at figure size.

2026-09-12 — the v0.13 round-2 residuals close

  • The public-URL Change form is gone when closed. The form now lives INSIDE the identity strip and renders nothing until Change is pressed — the stray ~216px input and its meta line below the strip are gone (hidden beats the display rule). Open, it expands to a full-width surface-rail row within the strip with its own meta line.
  • The fan-out and Backups mini tables own their grids. Both take explicit columns (minmax(0,1fr) 72px 120px / minmax(0,1fr) 88px 64px), all left-aligned at 24px rows, instead of auto-justified thirds with a wide gap.
  • The /system/backups rows map their columns. KIND owns the kind + path, RETENTION the policy + consequence, SCHEDULE no schedule / add one; each row pads 12px 0 and every posture cell stays exactly two lines. The footer keeps the · separator (the right-aligned ladder is dropped).
  • The history card drops its repeated kicker. SYSTEM / BACKUPS / nas01 no longer re-rides a topbar that already says System · Backups · nas01; the card keeps Snapshot history.
  • The round-2 nits. The dead-man card's last-good snapshot id is the 12-char cut like everywhere else; the Restore drawer's reveal hides the confirm form (and the pre-confirm drawer hides the run-host block) so Show the restore command can never read as re-pressable — the verb relabels Shown and disables; the paged fact's hierarchy splits ntfy (loud) from once · {age} ago (dim).

2026-09-12 — the v0.13 designer-review close-out

  • The identity strip's Change form rests closed. The public-URL form collapses to the one resting row (public URL · value · Change) and expands inline below the strip on click; a 422 re-render and JS-off both still show it. Doctor and Recovery-kit glyphs sit at the strip's far right on the same row, and the phone footer reads one line (public URL not set · change).
  • The Restore drawer stops pretending. Before confirm the verb is Show the restore command (quiet, fail ring, still gated by the typed id); a valid confirm changes the drawer body to the RUN ON THE HOST block — the recovery-kit command (exocomp restore / --from-litestream) with a Copy verb and the sentence that the daemon cannot replace the store it serves. The From switch takes the segmented shape, the snapshot rows become custom 44px radios (16px dot, inline label) instead of a browser fieldset, WHAT HAPPENS gains its kicker, and the loss's (fail) renders as a coloured span, never a raw word.
  • The Backups card reads the ONE age rule. Its figure quotes the newest SUCCEEDED-run age the Dead-man card quotes, snapshot ids truncate to the 12-char short form everywhere (the full 64-hex never reaches /system), the targets mini table reads name · state · age, a disabled target never pushes the chip off OK, and the sentence shortens to the DR path.
  • The self-care powerline names the leg, not the metric. rpo is retired: the litestream segment reads litestream no sidecar (fail) or the age, and the dead-man NONE ring is pinned to wait (amber) with the chip dot in wait — accent stays reserved for the Arm primary inside the card.
  • The backups list and history headers close. The /system/backups topbar is the breadcrumb (meta left, verbs right, the card's repeated kicker dropped), the footer joins its sentences with ·, the row ▾ gains history, and rows are two-line 64px with the drill outcome leading LAST RUN's second line (drill — / drill 3d ago ok) and only job #N linking. The history page's Back to targets becomes a ‹ targets link at the topbar's left, its meta names drilled snapshots, and the no-verb row's ACTIONS cell reads —.
  • The ntfy drawer reads as a drawer. The Detail switch's segments never wrap, the × close sits in the head, WHAT A PUSH SAYS is the phone-push card (surface-rail, 12px pad, Approve/Reject as quiet buttons), and the topic presence row shows its label.
  • The leg pages carry the System breadcrumb. /system/litestream, /system/deadman and /system/backups read System · {Leg} in the topbar. The Arm dialog's heartbeat meta regains pinged after every succeeded backup · printed on the recovery kit, and the phone powerline scrolls as one row instead of wrapping.

2026-09-12 — the per-snapshot forget verb runs end to end (SPEC §7 A12)

  • Forget goes executable. The backup-forget job the web verb enqueues now has a daemon-side leg: internal/sched routes the kind to the backup Runner, whose new runForget leg parses the argv (exocomp-forget {target} {snapshot-id}), resolves the target, and runs restic forget {id} --prune against its repository with the derived password carried by a 0600 file — the same shape the backup engine's own prune path uses — deciding success by exit code alone and settling the job with a structured target {t}: forgot snapshot={id} outcome event.
  • The guardrails ride on the runner too. As defense-in-depth beside the web page's gate, the leg refuses the newest snapshot and any drilled one (both are the recovery point) and refuses an id that is no succeeded run of the target, so a stale or crafted id never reaches restic; a refused or failed forget settles the job failed like a bad drill does. A forget is keyed under the target's backup concurrency key, so it can never race a backup or a retention prune of the same repository. The verb names one snapshot, so the command is a plain forget {id} --prune — the target's retention policy is re-applied on the next backup's prune.

2026-09-12 — scoped mutes surface on /system/ntfy (SPEC §7 A8)

  • Mutes stop being invisible. The /system/ntfy drawer gains a MUTES section (SPEC §7 A8): one row per active mute across SOURCE · HOST · UNTIL, newest first, each with a one-click clear verb. The source cell names the alert an alert-scoped mute covers or the word global; the host cell the host a host-scoped mute covers; until is the countdown face (in 2h 05m). A global mute is the safety valve — its clear asks first through the shared confirm dialog, a scoped mute's clear is one click. Clearing lifts the row through the same store.DeleteNotifyMute the CLI's mute rm and the API's DELETE /mutes call — a direct store call, not a queued job, exactly how mutes are set. The What-pages meta's quiet-hours phrase now links here, so "quiet hours are a scoped mute" lands on the list that shows them.

2026-09-12 — the public-URL Change form gets its spec (SPEC §7 A7)

  • The identity strip's public-URL Change form carries the forms anatomy. The url field's meta line always renders — approval links and the kit use this at rest, the ▲ sentence in signal-fail on a refusal — above the 20px form-status line and the Change primary, so an error never resizes the strip. The validPublicURL guard's refusal re-renders /system at 422 with the field meta filled and the typed value kept: a path-bearing or malformed URL is refused on the surface with human copy, never a flash, and the pending state reads "● Changing".

2026-09-12 — the litestream target gets a web form (SPEC §7 A6)

  • The /system/litestream page can set the replica target. The A6 form writes the two settings the daemon can persist — litestream.replica_target and litestream.replica_unencrypted_ack — on the landed fieldInput/formStatus anatomy, with the CLI echo (exocomp litestream target … --ack-unencrypted for a remote target). A remote target without the ack is refused on the checkbox at 422, the same caveat the CLI enforces at persistence; a local target writes ack=false. The form states its own boundary in its header: the daemon cannot verify a target (no probe exists), so it writes the two settings and no more — there is still no pause verb (Decision 0012).

2026-09-12 — the schedules↔backups linkage reads both ways (SPEC §7 A5)

  • A backup/drill schedule row on /schedules links back to its target. The row's new Target cell renders the target name — the same Args.Target reference the scheduler's fire-time concurrency keys backup:<target> and drill:<target> derive from — as a link to /system/backups, the target's row on the backups management list. An every-target schedule reads "every target" and a play row's standing inventory renders plain, so the link stays a backup/drill fact. The backups SCHEDULE cell's link to /schedules is unchanged; the linkage now reads both ways.

2026-09-12 — the backups audit lands (SPEC §7 A4 · A12 · A10)

  • The drill outcome has a surface. A succeeded restore drill now reads as drill {age} ago ok · {bytes} verified · {duration} in the LAST RUN second line and on the target's history page — the restored bytes parsed from the drill job's own outcome event, the duration its wall time. A drill whose job left no outcome still draws the plain drill {age} ago ok mark; the exit code stays the judge (A4).
  • Each target gets a snapshot history page. /system/backups/{name}/history (the TARGET name now links to it) lists one snapshot per row — id, age, size, the drilled mark and outcome on the newest of a drilled target. Per-snapshot forget lives under a forgettable row's ▾ in fail, asks first, and is ABSENT on the newest snapshot and on any drilled one — server-side too, so a crafted POST cannot forget the recovery point (A12).
  • Forget goes through the one queue. The verb enqueues one kind backup-forget job (argv exocomp-forget {target} {snapshot-id}) under the target's backup concurrency key, so a forget can never race a backup or a retention prune of the same repository. The daemon-side runner leg for the kind is the follow-up (the web names only the target and snapshot through the queue). Forget never appears on the Restore drawer — a delete verb beside the restore radios is a mis-click.
  • The Backups card chip fails at two. 1 OF {m} FAILING still reads wait, but TWO failing targets read fail (A10).

2026-09-12 — the checks tab strip swaps in place

  • The /checks Probes↔Netgroups strip becomes a client-side fragment swap. The tab links carry data-tab; a delegated handler fetches the same ?tab= URL with an X-Requested-With: fragment header and swaps the #checks-surface region in place, so switching tabs no longer reloads the page. The active tab's aria-selected rides the server-rendered region, and the swapped region's one-shot form wiring is re-run per the 422 form-fragment precedent. JS-off keeps the plain full-load anchors; the swap is a GET, so it can never re-fire a consumed flash; back/forward replay the swap through pushState.

2026-09-12 — the audit HIGHs land (SPEC §7 A1–A3)

  • The Doctor glyph on /system is now a verb. It queues the self-check as a kind=doctor job through the one queue (invariant 5); the daemon routes it to a new driver that runs the one health.Probe in-process and emits one finding event per subsystem entry. The job page renders the findings as rows (name · status chip · detail) under a count header, and the identity strip reads doctor {age} ago · {n} findings, linking to the job page. The exit code is the worst status: a FAIL finding fails the job, every other status is a state to read. exocomp doctor stays the scriptable twin.
  • The /secrets page says who uses each sealed secret. The ntfy/* keys and deadman/heartbeat_url list their /system leg with a link in the Used by cell, so a rotation names its consumer; /secrets?key=ntfy/token (the href m75's Seal-token verb already renders) focuses that row. The leg map is exact — a backup target's restic password is derived from the DEK (restic-repo:<target>), never a sealed row, so no backup/*/password key exists to annotate.
  • The recovery kit never goes stale silently. The identity strip's Recovery-kit glyph is the reprint verb: it stamps the printed date and resets the staleness counter, and the strip reads kit printed {date} · {n} changes since. The counter is fed by the three write verbs that invalidate the kit's printed contents — dead-man arm (the heartbeat URL), backup add-target (the target list), and secret rotation.

2026-09-12 — the backups leg lands (SPEC §4)

  • The /system Backups card draws real freshness. The stub becomes the §0.1 card: a state chip (OK · {n} OF {m} FAILING · NONE), a target-freshness mini table (name · kind · snapshot/● RUNNING · job #… on the left, state word and age on the right), the one Manage verb linking to /system/backups, and a sentence that a running run does not move the age (A9).
  • /system/backups lands as the management list (SPEC §4.1). Every target renders as a two-line row across TARGET · KIND · RETENTION · SCHEDULE · LAST RUN · ACTIONS: enabled/disabled word, scrubbed-kind URI (a --uri that embeds credentials renders ***@, never the token), the parsed retention policy (daily 7 · weekly 4, keep all / never prunes · grows, git's —), the kind=backup schedule cadence or a no schedule / add one gap, and the newest run with its bytes · duration · job link · drill N ago ok mark · failed-in-a- row count. The run verb is absent on a DISABLED row and the chevron never moves. A9: a RUNNING run draws ● RUNNING · {elapsed} · job #{id}.
  • The Add-target dialog (SPEC §4.2) is ONE verb. Name · Kind segmented (the three real kinds — restic_sftp is cut, no engine or CLI handles it) · Repository · Retention cells (daily · weekly · keep-all) · Schedule prefilled from the newest sibling. The primary Add and queue first backup — {name} registers the target, creates its kind=backup schedule, and queues the first backup through the one queue (invariant 5); a schedule-name collision or a failed enqueue rolls the target back. The repository password is never a form field — it is derived on the daemon side when the first backup runs.
  • The store's unwired verbs (store/backup.go:101-133) get web controls. run / drill-now enqueue the same kind jobs the CLI and a drill schedule fire; enable/disable and edit-retention write the row; remove asks first (the shared confirm dialog). A crafted run POST against a disabled target is refused on the row.
  • The Restore DR drawer (SPEC §4.3) is the round's typed-confirm gate. Fail ring, SYSTEM · BACKUPS · DISASTER RECOVERY kicker, From switch snapshot|replica, snapshot radios newest-first across targets with the drilled ok mark (the new cross-target read, fold S-3), WHAT HAPPENS with the loss in fail, and Type the snapshot id to confirm — the danger button rings until the typed id matches, then fills. The live daemon cannot replace the store it runs on, so a confirmed restore names the recovery-kit CLI path (exocomp restore --snapshot {id} / --from-litestream) rather than pretending. Replica mode deep-links via /system/backups?restore=replica (m76's litestream leg keys off the drawer's from=replica + word confirm).

2026-09-12 — the /system Litestream leg: six word-states and the config view

  • The Litestream leg of the self-care page grows from a stub to the six word-states (SPEC §3). The card now derives one state from the persisted replica target (litestream.replica_target), the sidecar binary's presence (litestream.Presence(), which the web never called before), and the replica's newest frame's age: REPLICATING (ok, the lag figure) · STALE (wait, ≥10m) · BEHIND (fail, ≥1h) · REMOTE (dim — only litestream can read a remote replica's lag; the unencrypted-ack setting is made visible) · NO REPLICA (dim) · NO SIDECAR (fail — a configured leg with no engine, which today collapsed into "no replica") · plus the wait UNPARSEABLE for a stored target that no longer parses. Facts render the replica uri (userinfo scrubbed), the checkpoint frame (number + time, read from the replica's own frame files) and the sidecar fact; the sentence names the host command (systemctl status litestream) where the daemon cannot tell. The powerline's rpo segment now reads fail rpo — for a set target with no engine instead of dim unknown.
  • The /system/litestream leg page lands: the full-width card plus the ▾ views. config renders the generated litestream.yml read-only — the same bytes exocomp litestream config emits, with credentials referenced by name in the emitted file, never a value — and restore from replica deep-links into the DR drawer's replica mode (/system/backups?from=litestream#restore; the drawer template and typed confirm land with the backups lane). The whole surface is read-only by design (Decision 0012): the daemon generates config and observes, it never runs litestream, so there is no pause verb anywhere on it.

2026-09-12 — /system's ntfy leg: card, drawer, and a real push path

  • The ntfy leg card lands the three-word judgment ON / REFUSED / OFF (SPEC §2.1). ON figures the topic label (notify.ntfy_topic, the one unsealed display string) with the server class — a fact about the URL, never the URL — and carries the sealed-key facts plus the 3-row fan-out mini table. REFUSED is the NewNtfy refusal re-derived from the sealed secrets the daemon holds (a public topic without a token): a fail chip with Seal token → /secrets?key=ntfy/token (the row focus lands with A2), the why-refused sentence, and the "pages nothing" fact. OFF reads no channel · nothing sealed with a quiet Set up → Secrets and the sentence naming exocomp setup notify.
  • The /system/ntfy drawer (SPEC §2.2) renders sealed keys as presence rows — key · dot string · SEALED/MISSING — with the binding meta "values never render · rotate in Secrets"; a value never reaches the page (invariant 4). The Detail two-position switch persists notify.ntfy_detail via Save, WHAT A PUSH SAYS shows the live descriptive/terse sample, and What pages is a read-only EVENT · PRIORITY · RE-NOTIFY table (no routing knobs exist in code; quiet hours are a scoped mute elsewhere).
  • The Test verb finally has a push path. The web surface holds no open envelope, so the ntfy leg's judgment and test push ride a Maintenance-style daemon seam (web.NtfyDriver): newNtfySeam re-derives the NewNtfy rule and sends one test push (exocomp: test push · {host} · {HH:MM:SS}) through the same LoadNtfy transport the daemon builds, writing the delivered or refused status line. Both serve and the standalone web process wire it.
  • A11: the powerline stops lying. A public topic without a token now reads ntfy refused (fail) on the powerline — not off — re-derived from the sealed keys' presence, never a rendered value.

2026-09-12 — the dead-man leg lands (SPEC §1)

  • The dead-man card reads ARMED / NONE / TRIPPED, the figure changing meaning per state but never its slot. NONE draws — ("no threshold, no heartbeat · nothing trips if backups stop") with the single Arm verb as the fix; ARMED draws the time-left (32h 30m · "until it trips") with the last-good/threshold/ heartbeat facts and a pencil Arm; TRIPPED flips to time-since (49h 02m · "since a backup succeeded · threshold 48h") with newest-runs, heartbeat and paged ntfy · once · 1h 02m ago facts, and the primary verb becomes the fix: Queue backup — {newest target}. Disarm lives under the card's ▾, in fail, asking first. Every face's age comes from the store's ONE rule (LastSucceededBackupTime) — a FAILED or RUNNING newer run never masks an older good backup, and the /system powerline's dead-man segment takes the card's chip colour without recomputing the age.
  • The Arm dialog sets deadman.threshold (segmented 24h · 48h · 72h · 7d, default 48h) and seals the heartbeat URL as deadman/heartbeat_url. The URL is a type=password field with a Show/Hide eye that exists only while the input is unsealed, is validated against the push-endpoint SSRF guard, and is never echoed — a re-render or the card can never show the value. The read-only AT ZERO block states the three consequences; the threshold alone arms the internal half (heartbeat: not wired). Disarm removes both halves.
  • The daemon runs a trip monitor (new internal/deadman package). It compares the ONE age rule to the threshold on a 30s cadence, pages the notify fan-out exactly once per trip (dead-man tripped · high · the body naming the time-since and host), and pings the sealed heartbeat URL on every new succeeded backup, stamping deadman.pinged_at and clearing the deadman.paged_at once-marker so a later trip pages again. The push rides the notifier the daemon already builds (the maintenance-seam shape), never a second transport.

2026-09-12 — /system becomes the self-care shell

  • The /system page restructures to the v0.13 self-care anatomy (SPEC §0). One identity strip leads the page — exocomp {version} · commit · {os}/{arch} · on {host} · up {uptime} — with the one existing write verb on the right: public URL value plus a quiet Change button posting to the landed /system/public-url backend (markup only; the write is untouched). Below it, the four leg cards (Dead-man · ntfy · Litestream · Backups) render in a 2×2 grid as stub partials carrying the §0.1 anatomy — header glyph · name · verbs slot, dim — figure, empty facts grid, sentence — under the ids the powerline anchors land on. The page's own powerline (36px surface-rail, a separate strip from the app-wide footer) renders {version} · rpo {lag} · dead-man {age} · ntfy {state} · backups {state}, each leg segment an <a href="/system#leg"> anchor coloured by its card's state; the dead-man segment quotes the store's ONE succeeded-age rule and reads dead-man none — set one until the first success. At ≤720px the cards collapse to two-line rows linking /system/{deadman,ntfy,litestream,backups} (the leg pages land in the same round). Doctor and Recovery kit render as read-only header glyphs naming their CLI (exocomp doctor / exocomp kit) until the doctor job and kit reprint verbs land. The round's page CSS ships as a new lift.css asset (embedded, version-hashed and loaded like app.css), with the handoff's .powerline renamed to .sys-powerline so it cannot collide with the global footer strip of the same class. A never-run backup target reads as a wait gap (backups none, never fail) on the powerline, matching the dead-man's none.

2026-09-12 — the shared row-actions verbs render their refusals on the row

  • Every single-button row verb that renders through the shared rowActions partial adopts the forms-pattern refusal contract. The schedules and checks remove/disable/enable verbs, the netgroups and secrets remove verbs, and the workspaces row apply/plan verbs now re-render the surface holding the row at 422 with the refusal in a ▲ status line on that row (the shared partial's own line, no template churn), carrying human copy — a refused bound-secret remove, a policed fresh apply, and a confirmed-less remove all say what happened and that nothing was removed. A refusal never flashes; success keeps the one success-only flash. A row verb whose row has already vanished keeps an appropriate 404 with human copy.

2026-09-12 — the workspace detail gains the Tofu state tray

  • The workspace detail page's identity card becomes the SPEC §2.1 4-tile grid (Dir · Backend · Provider mirror · Tofu state), and the Tofu state tile links to a new read-only STATE tray (SPEC §2.3) beside the Backend card. The tray lists the workspace's stored tfstate serials newest-first from the store (ListTFStates), adoption as the first serial, each with the writer (job link, "outside exocomp", or the adoption line) and when it was written.
  • Browse renders a sealed diff only where one exists. A serial browses to its plan's show-json diff (the plan-detail page) when a sealed tofu-plan-show companion was captured for a plan made against it; every other serial reads a "no sealed diff" note. Read-only: no rollback op exists, so the tray offers none. The §5 TOFU STATE list column is unchanged (regression-covered).

2026-09-12 — workspaces verb forms adopt the forms pattern

  • The workspaces write verbs render their refusals on the surface, never a flash. Force-unlock, the destroy confirm, and the plan confirm's apply now re-render the page holding the button at 422 with a ▲ status line (the shared formStatus primitive) carrying human copy, instead of a bare error page; a refusal never flashes. Success keeps the one success-only flash. The destroy and plan-confirm dialogs stay open on the refusal re-render so the copy sits where the operator acted. The backend-reveal fetch already renders its refusal on the surface and never navigates, so it is unchanged.

2026-09-12 — the jobs, source-launcher, and approval forms join the forms pattern

  • Every typed-input web form now renders through the forms primitives — fieldInput (label · control · always-present meta line) and formStatus(…, verb) — and a field refusal re-renders the same surface at 422 with the typed values kept, exactly like the landed netgroup and source-add exemplars. The job-detail Schedule dialog (bad name or cron expression), the Rerun modal's Limit field, the source launcher's Playbook tab (playbook/limit/egress/timeout) and Command tab (host/command/timeout), and the approval surface's Silence & global kill expiry all follow it. The banned refusals are gone from those surfaces: the schedule handler's "invalid schedule name" and store-wrapped validation flash, and the launcher's raw http.Error lines, are replaced with human sentences on the field or the status line ("Choose a playbook, or give a path relative to the checkout.", "Egress ports are numbers from 1 to 65535 — like 22, 443."). A refusal never flashes; the flash stays success-only. A posted confirm_token never echoes on any 422 re-render (the source git reauth exception the primitives document). The button-verb forms on these surfaces — source disable/enable/rm and the approval Reject/Approve — already flashed success-only and keep rendering their refusals on the surface, so they are pinned by tests rather than changed.

2026-09-12 — the secrets and tokens write forms adopt the forms anatomy

  • The seal-a-secret, mint-token, and per-row rotate dialogs, plus the revoke button, now render through the shared field-input and form-status primitives. Every typed field carries an always-present meta line (help text at rest, a ▲ human-copy refusal on error) and a form-status line sits above each primary. A refused submit re-renders the same dialog at HTTP 422 with the non-secret typed values (name, kind, scope, expires) kept and the right dialog re-opened; a sealed secret's value and the confirm token never re-render, so a refusal cannot echo the plaintext (invariant 4). Refusals that used to answer as bare error pages — a duplicate secret or token name, an unparseable duration, a self rotate or revoke, a missing revoke confirmation — now render on the surface with human copy, and flash remains success-only.

2026-09-12 — checks, schedules, policies, runners add forms adopt the forms pattern

  • The four resource add dialogs now refuse per-field, at 422, never by flash. The checks, schedules, policies, and runners add forms carry the forms-round anatomy: every field has an always-present meta line (help at rest, ▲ message on refusal), a refusal re-renders the same dialog with the typed values kept, and flash is success-only — a refusal never leaves the surface it belongs to. The raw validator words (a duration parser's text, a cron error, a CEL compile error, a name constraint) go to the log, never the operator.
  • Checks: interval, kind, targets, expected status, duplicate, and name refusals — previously green flashes carrying %v and err.Error() copy — are now per-field sentences (Kind is tcp, ping or http., Add at least one target, like laptop=192.168.1.50:22., A check named at-home already exists.). The kind select and the repeated target group get always-present meta lines; the Add-target control still grows the form (capped at ten) and re-wires after a refused submit.
  • Schedules: the banned "invalid schedule name" flash is gone — the name field says Names can't have spaces. or Give the schedule a name. — and the cron, argv, target, concurrency-key, and jitter refusals each land on their own field's meta line (e.g. Give a 5-field cron or an @every — like 0 */5 * * * * or @every 5m.). The deep-link prefill (?from_job=, ?name=, …) still lands filled and auto-opens the dialog.
  • Policies: an empty workspace, an unknown action, an uncompilable expression, and a duplicate name — previously bare 500s and a 409 — are now field sentences at 422. The action select gets an always-present meta line. Removing a policy that is already gone re-renders the surface at 404 with No policy named {name} exists to remove. shown on it, replacing the bare error page.
  • Runners: a refused name, mode, base, or both-models conflict — previously bare 500s and a 409 — are now field sentences at 422. The mode select and the bootstrap textarea get always-present meta lines. Removing a runner that is already gone re-renders the surface at 404 with the sentence shown on it.

2026-09-12 — three web surface nits

  • The netgroup-add modal resets on close. The form is a native <dialog> rendered once, so a typed value or refusal survived close/reopen; a close listener inside the form's own JS now clears the fields and any painted error, so the next open starts clean. The shared modal primitives stay generic.
  • The app bar's glyph actions right-align. The header's Raw log / Rerun actions (job detail) and plan verbs now carry margin-left: auto, so a page that renders actions without a meta slot still sits them at the bar's right end.
  • The dashboard queue label drops its dead links. The In the queue heading and all jobs → link are gone; the queue rows link to the jobs themselves.

2026-09-12 — base-path exposure and ingress-consistent public URL

  • /system shows the effective base path the surface is served under: the origin root when serve runs without --base-path, otherwise the prefix (e.g. /exocomp), read-only.
  • The public-URL write refuses a URL carrying a path. The reactive ingress serves /hook/{token} and /approval/{id}/{decision} on the loopback listener at the origin root with no path support, and decision links are rooted at the public URL — so a path-bearing public URL would emit decision links nothing serves. The refusal says that in plain words.

2026-09-12 — serve --base-path web-surface spine

  • The web surface can be served under an origin sub-path. exocomp serve --base-path /exocomp (and exocomp web --base-path) strips the prefix inbound (http.StripPrefix) and re-prefixes every Go-built URL and redirect outbound, so a request to /exocomp/login renders, an unauthenticated request bounces to /exocomp/login?next=/exocomp/... (exactly one prefix), post-write flash redirects land back inside the prefix, and the Go-built hrefs on the job detail, plan detail, checks, workspaces, and events pages carry the prefix. A bare /exocomp lands on the dashboard. The default (no --base-path) behavior is unchanged. The template URL sweep also landed: every template-emitted root-absolute site re-prefixes through the page.Base seam — the embedded assets, the sidebar/rail/app-bar brand and nav links, the powerline, every resource add-form action, the dynamic /jobs, /sources, /schedules, /workspaces, /tokens hrefs, the row-action forms (rowAction.Form), the phone decision/kill forms, the login form action, and the client-side SSE/event/data-tls URLs — while the Go-emitted hrefs (pageAction.Href, BackHref, rowAction.Href, events chips/reason links, configTile.Link, degraded-run verbs) render as-is, never double-prefixed. The reactive ingress on the loopback listener is untouched by this change.

2026-09-12 — the plan detail page loses its destroy mode

  • The plan-detail ?destroy=1 mode is removed — the confirm dialog is apply-only. The mode's primary posted to the workspace apply route, so "Queue destroy — " enqueued an apply, never a destroy; repointing it at the workspace destroy verb would tear down the whole workspace when the dialog showed only one plan's diff. The workspace-level Destroy verb on the workspace detail page (this release) is the one destroy affordance. A saved plan's diff still shows the resources it tears down; only the dead mode is gone.

2026-09-12 — the destroy apply verb

  • tofu destroy is now a live verb, not a render-only affordance. A workspace's resources can be torn down through the one queue, exactly like a plan or an apply: POST /api/v1/workspaces/{name}/destroy (operator bearer), the browser's Destroy verb on the workspace detail page (its confirm dialog repeats "Destroys — no undo" and requires the hidden confirmed=1 field, the same guard every destructive form carries), and exocomp workspace destroy <name> locally or with --remote. The enqueued row records tofu destroy -auto-approve against the workspace's concurrency key, so a destroy never races a plan or an apply. A destroy has no plan for a policy to read, so it is a plain enqueue — never gated, and refused outright without confirmation on the browser face.

2026-09-12 — per-run ssh identity read grant

  • A sandboxed ansible run can load an ssh identity key stored outside the source checkout. playbook run --ssh-identity <path> (repeatable) grants one run read access to the named key files, and rule add --action playbook --ssh-identity <path> and exocomp run --ssh-identity do the same for a playbook rule's runs and a lifecycle workflow's play step. Each path is granted as a single file, never a directory, and the declaration is per run and empty by default — a run that declares nothing keeps the sandbox's default-deny posture. A relative path is refused when declared, and an identity pointing at the data dir is refused by the profile guard. Previously a key outside the checkout failed with "Load key … Permission denied", and the ansible-e2e rig worked around it by copying the per-run key into the checkout.

2026-09-12 — sandboxed collections resolve no longer trips over the galaxy cache

  • The resolver's galaxy cache moved out of the collections cache root. The collections resolver granted its cache dir write and redirected the resolver's HOME onto it, and nono refused to launch: the recursive grant covered nono's own protected state root under that HOME (cache/collections/.galaxy-cache/.local/state/nono), so every playbook whose source declared collections failed its deps step under the enforcing sandbox tier. The galaxy cache now lives at <data dir>/cache/galaxy (a sibling of cache/collections, still inside the backed-up cache tree) and the resolver's HOME under it is deliberately not granted — the profile grants only the directories ansible-galaxy writes (ANSIBLE_HOME's ~/.ansible and TMPDIR), the same shape the ansible run driver already uses for its own ~/.ansible. A sandboxed resolve that could not reach ansible-galaxy before now provisions normally.

2026-09-11 — the add-source dialog validates per-field

  • The source-add modal gets the forms pattern. Name and path/git-URL fields now carry always-present meta lines, the name is validated on blur (a space or special character is refused with human copy), and a server-side refusal re-renders the dialog at 422 with the typed values kept and the ▲ field message — the same treatment the netgroup form got in v0.8. A bad name previously surfaced as a bare 500 "internal error"; a missing location was a bare 400. Submit-time refusals (a duplicate name, a missing envelope sealer) now land in the form's status line instead of error pages.

2026-09-11 — designer QA pass 2 (v0.12)

  • The workspaces-list verbs match SCREENS 1c — [apply] · plan · ▾: plan is a labeled text verb, not the plus-shaped clipboard glyph (the + had no in-row meaning), so the fixed right-edge slots read correctly.
  • The Rules ACTIONS slot has no stray separator — the explain verb is a clean info icon (a true circle + i) beside the ▾, never a pipe-like artifact; a separator only ever sits between two rendered verbs.
  • The Events expand glyph is a real chevron — ▸ at 12px ink-400 in the 20px slot (it had collapsed to a ~2px dot at the old font size).

2026-09-11 — designer QA deltas (v0.12)

  • The Events tray is the seven-column grid again (SPEC §1a): RECEIVED · EVENT · SOURCE · MATCHED RULE · OUTCOME · REASON · ▸ in one 44px row. The OUTCOME glyph+keyword sit left-aligned in their 96px slot; RECEIVED reads HH:MM:SS · Nm ago in one cell; and the REASON column now tells the story in the row itself — job #N · <state> · exit <c> (linked), no rule matched, rule <name> · not yet run, cooldown <t> left / duplicate of #N, awaiting approval (linked). The funnel caption is gone.
  • The Workspaces list is one row again — .ws-cell was display:flex on the <td>, which broke the table layout so POSTURE · DRIFT · TOFU STATE · ACTIONS piled into one stacked column. The flex moved to an inner wrapper (tds stay table-cells, min-width:0) and the columns are pinned.
  • Rules ACTIONS has no orphan separator — a separator only ever sits between two rendered verbs; the explain·▾ disclosure renders without a stray marker.
  • Tail +N is a toolbar verb, not a floating button — it moved out of the log box into the Output toolbar before Raw ↗, hidden until the operator scrolls up.

2026-09-11 — Events page spacing fixes

  • The filter row is centered between the funnel card and the tray — the source switch + outcome chips had no vertical margin, so they sat flush against the funnel card (0px above vs the 12px below). Equal --gap-card spacing both sides now.
  • The funnel counts read 2 in / 1 ignored, not 2in / 1ignored — a 6px gap between each count number and its label.

2026-09-11 — events filters + job output surface (v0.12)

  • The Events filter row is a source switch + outcome chips. all · checks · alerts is a segmented switch (a scope — never accent, all resets); ignored · matched · suppressed · gated · fired · error are chips always present in funnel order, zero-count ones dim and unlinked. The URL composes ?source=checks&outcome=fired (legacy ?filter= values and the singular source=check still work); the active chip is the row's only accent fill and its trailing × clears it; the footer restates the filter and offers show all. Counts are right-aligned tabular cells (compact 12.4k from five digits), and the o key cycles the outcome group (TUI parity). The keys hint moved onto the funnel card.
  • The job Output status is one pin chip, left. A terminal job reads LOG COMPLETE · N lines (or LOG CUT · N lines when the stream ended without the run's verdict) — never FOLLOWING TAIL, and never a second static chip. The server renders the initial state; JS only moves it. The chip sits in a fixed left slot (min 208px) so the filter · wrap · Raw controls never shift, and the last log line repeats the completion (▸ succeeded — exit 0 · N lines · log complete).
  • The Output bar gained a Raw ↗ verb (plain-text log, new tab) beside the header's Raw-log glyph.
  • Actions are right-anchored, and the apply glyph means applicable. Plans tray and workspaces-list ACTIONS cells right-align; the constant verb (diff / plan / ▾) holds the same slot on every row; the apply play glyph appears only on the latest SAVED plan whose serial equals the Tofu state — a stale, consumed, or superseded plan carries no marker, and stale is said in the LIFECYCLE cell (stale · serial 10).
  • Smaller surfaces: each check row's second line opens with a route glyph + on <netgroup> link · age (a never-seen target reads away — not probed from here); the source Playbook launcher preselects the first found playbook and gains Limit / Egress / Tolerate fields with the CLI echo; the Rules page is a two-line row (one-line CEL expression · then + guards · LAST FIRED) with the full expression expandable inline.

2026-09-11 — job-log regressions fixed (spacing + status chip)

  • The log clock and the message are separated again (70px clock column + 8px gap, per SPEC §3) — the column gap the shell grid lost was squeezing the timestamp against the message.
  • The run card and the output card keep their 12px gap again on the no-scroll job page (the .page-job flex layout had dropped gap).
  • A terminal job shows ONE status chip. Previously a terminal page rendered both the streaming pin (stale at FOLLOWING TAIL) and the separate ENDED · nothing to follow pill; the pin now renders only while a job is live, so a finished job reads exactly one ended state.

2026-09-11 — check update re-points a probe in place (G4)

  • exocomp check update <name> --target … [--interval …] replaces a check's targets and cadence without losing its history. Previously a re-point was check rm + check add, which cascades the check_state rows away — so the re-created check cold-started, seeding without emitting, and a re-point could never produce a transition. The update keeps the row and rewrites targets_json/interval_s/updated_at; because state keys on (check_id, target_name), a re-point that KEEPS a target's name lets the sweep flip the preserved row and emit a real transition (the lima port-churn case). Kind is read from the stored row and is not updatable; targets and interval validate exactly as check add does. An omitted --interval keeps the stored cadence. The web checks page is unchanged (CLI-only this round).

2026-09-11 — per-job ansible egress ports (G3)

  • A sandboxed ansible job can open a nonstandard inventory port. The enforcing tier granted only TCP 22, so an inventory host answering on a high port (a lima guest's loopback forward) was denied at connect time. A job can now declare extra outbound ports with playbook run --egress <port> (repeatable, or comma-separated), run --egress, or a playbook rule's rule add --action playbook --egress; the API (egress_ports) and the source-detail launcher carry the same field. The grant is additive on top of port 22, validated (1..65535), and empty by default: a job or rule that declares nothing keeps exactly the port-22 posture. Ports are threaded through the whole run path — queue, job row, executor sandbox profile, remote playbook request, and web rerun — so no face silently drops the declaration, and the port is sorted/de-duplicated into the sandbox profile like the tofu egress grants.

2026-09-11 — the plan-diff view is readable (v0.11, §4.1)

  • The plan-detail diff renders three value shapes (SPEC §4.1, the plan-diff-readability request): inline old → new for a short scalar change, two full-width − / + lines for a longer scalar, and an indented <pre> object diff with key-by-key − / + leaves for a map or list value. Server-set fields (uid, resource_version) render [redacted · server-set] and never count as a change; secret-backed values stay [redacted · secret] on every surface. No line-through anywhere — old and new are carried by colour, tint, and the arrow, matching the phone approval.
  • The false diffs are gone. The attribute classifier compared before/after as JSON strings, and Go map key order is not stable, so byte-identical maps read as changed (id → id, metadata → metadata). It now compares the decoded values with reflect.DeepEqual (order-independent) and flattens maps to leaf rows (data.marker, metadata[0]), so the row cell N changed · M same and the N attributes unchanged · show footer count the same list and always agree.
  • The literal continue text is gone. The template rendered a Go continue in a loop body as text; it now loops the changed set only.
  • Each attribute is its own grid row (150px minmax(0,1fr)), so a wrapped value never pushes its key out of column; the show/hide disclosure lists the unchanged attributes (objects as 1 block · N keys · unchanged), and a raw drawer opens the minified before/after pair.
  • NO-OP resources render unchanged on the plan-detail page (previously dropped); the phone and /api/v1 still filter them out.
  • The apply/destroy confirm reuses the same grid, capped at three attribute rows per resource then N more →.

2026-09-10 — the plan-detail diff renders from a captured show-json (v0.10, fix)

  • The plan-detail page and the approval surfaces show the diff again. /workspaces/{ws}/plans/{id}, the approval plan view, and /api/v1/.../plan were feeding the saved plan's sealed binary to the classifier, which reads tofu show -json (resource_changes), so every plan read "nothing to diff". A saved plan is the binary tofu wrote, not a show-json document — and re-running tofu show at render time cannot be hermetic, because it needs the run's private TF_DATA_DIR (where the run installed providers), which is gone after the run.
  • The executor now captures tofu show -json at plan time, while the run env is live, and writes it beside the plan file as <plan>.show.json. The sealer records it as a second artifact on the same job under a new tofu-plan-show kind, sealed with the same envelope; the plan artifact's digest stays the plan's. The web renders the diff from the show-json artifact and never from the plan binary, through the same classifyPlan redaction the approval phone and /api/v1 use. A plan sealed before the capture existed (or an operator's own plan file) falls back to the old feed.

2026-09-10 — plan detail page and the CONSUMED lifecycle (v0.10, part 3)

  • /workspaces/{ws}/plans/{id} is live (SPEC §4): the tray's diff/play deep-links now render the plan-detail page instead of 404ing. One card with the lifecycle chips (SAVED · LATEST, DRIFT · FINDING when the plan job recorded drift), against serial N · recorded <time>, the +N ~N −N verdict, and a structured diff parsed from tofu show -json through the SAME classifier the phone approval and /api/v1 use — so a value one surface redacts is redacted on every surface. Destroys render first in their own fail block; each resource row shows the action keyword with its signal, the address, and N changed · M same, expanding to the old→new strike of the moved attributes with the unchanged remainder collapsed. The footer verb Apply plan #id → and the header's apply glyph open the confirm.
  • The apply confirm is a native <dialog> that repeats the diff and asks: kicker APPLY PLAN #id · WORKSPACE X, Change N resource(s) on <ws>, a four-row fact grid (Summary · Against · Policy · Runner), and one primary — Queue apply — ws @ plan #id, Queue apply — awaits approval for a require-approval verdict, or Plan again for a stale plan. A deny verdict shows DENIED · <policy> with Cancel only, never a primary. The stale refusal is copy (REFUSED · Tofu state moved to serial N), never a disabled button (invariant 7). ?launch=1 renders the dialog open with JS off; the same dialog doubles as the destroy confirm (?destroy=1, kicker DESTROY, −N counters, danger ring).
  • CONSUMED is a real lifecycle word. An apply that re-recorded its sealed plan (the CLI's same-SHA re-record) now dedupes by SHA256, so a second apply of the same plan never piles up a tray row and "N plans" reads honestly; the tray and plan page render CONSUMED #job with the applying job as a link, and the one apply affordance stays on the newest unconsumed plan.
  • The plan diff API grew before. The /api/v1 plan projection now carries each attribute's pre-apply value (before), empty for a created attribute, so the phone can render the same old→new strike.

2026-09-10 — workspace detail: plans tray and reference backend block (v0.10, part 2)

  • The workspace detail page's Saved plans list is now a read-only tray-table (SPEC §2.2): one row per plan with PLAN / SERIAL / SEALED / CHANGES · VERDICT / LIFECYCLE / ACTIONS columns. LIFECYCLE reads SAVED for the newest plan and SUPERSEDED for every older one (CONSUMED lands with the plan-detail lane); CHANGES reads the plan outcome's +N ~N −N counters, resource names, and drift note; the policy verdict the plan job recorded (deny · , require-approval · pending, or no policy matched) rides in the same cell. A SAVED plan whose serial the Tofu state has moved past reads stale · state at serial N and loses its apply affordance.

  • One apply glyph, deep-linked. The old per-row "Apply plan" button and the plan= hidden field are gone; the single play glyph sits on the latest SAVED non-stale row and links to /workspaces/{ws}/plans/{id}, the plan-detail page that will own the apply confirm. Each row keeps a diff glyph to the same route.

  • The Backend card emits a reference, never the bearer (SPEC §2.4): the pasted block carries the address and lock plumbing only, and the init line tofu init -backend-config=<(exocomp workspace backend <ws> --config) fills the value at init time. A reveal glyph shows the derived bearer beside the block for 30 s and logs the reveal to the web server's own log; copy copies the block as printed, never the value. Reveal is operator-scoped.

  • New web surface tests pin the single-apply rule (mutation check: a second row renders no apply glyph), the LIFECYCLE words, the CHANGES/VERDICT cells from append-only job events, the reveal endpoint's operation-only answer, and the leak check (the pasted block carries no bearer).

  • The workspaces list grew the T7 columns. Each row is now the two-line 64px density: NAME · POSTURE (runs-in-server with its newest job, or state-only with "external tofu") · DRIFT (NONE / PRESENT / LOCKED chip) · TOFU STATE (serial · serials · size with the ENCRYPTED keyword and its writer meta) · ACTIONS. The row keeps the Plan glyph; the Apply glyph appears only while a SAVED plan exists (invariant: one apply per workspace) and a state-only row's Backend glyph links the detail page.

  • A LOCKED row is actionable from the list. The TOFU STATE meta names the lock holder and since when, ending in a force-unlock button — the same audited verb as exocomp workspace unlock: a live job's lock is refused, the break is recorded on the job the lock named (or the workspace's newest), and the row returns to its ordinary state.

  • A drifted row links its open approval. The DRIFT chip reads PRESENT with an approval link to the pending decision, so a workspace waiting on a human is one click from the ask.

2026-09-09 — the job log owns its scroll (v0.10, part 1)

  • The job-detail page is now a no-page-scroll flex column (scoped to <main class="page-job">): the log box ends above the powerline, owns its own scroll, and the page never scrolls to reach it. The previous max-height: 60vh box let a long log's tail fall below the viewport fold with the page itself as the scroller.
  • A streaming pin. While a job runs, the Output bar shows a FOLLOWING TAIL pill; scrolling up pauses it (PAUSED · scrolled up) and a floating Tail +N button returns to the bottom. End of run shows ENDED · nothing to follow.
  • Header verbs are glyphs (T6). Raw log and Rerun in the app bar are now 34px stroke-SVG buttons (document · rotate), right-aligned, with aria-labels carrying the word.
  • New invariants (docs/design/INVARIANTS.md "Tofu surfaces v0.10"): a log owns its scroll; one apply per workspace; secrets never render in a paste block; mutations preview their diff.

2026-09-09 — workspaces can resolve tofu providers offline

  • exocomp workspace offline <name> on|off flips a workspace's offline-provider posture: on makes every tofu run resolve providers from the local filesystem mirror alone, with no registry.opentofu.org access — the amendment-8 offline guarantee, now a surface instead of an executor option nothing set. A provider missing from the cache fails the run rather than falling back to the network (fail closed). The posture is per-workspace, stored on the workspace row (migration 0035), and threaded into both the inline and daemon driver paths.
  • The offline path was already proven (TestTofuPlanRunsOfflineFromPluginCache runs a network-denied init and plan from the cache); the gap was that no CLI or store surface could set OfflineProviders. Now it can, with an upgrade test pinning the column and a CLI test proving the posture is per-workspace.

2026-09-09 — OpenTofu state adoption works end to end

  • exocomp workspace adopt <name> migrates a workspace's pre-existing local tofu state onto the HTTP backend. It runs one tofu init -force-copy (which implies -migrate-state) with an unencrypted read fallback in the TF_ENCRYPTION document, so an old local terraform.tfstate from a prior backend "local" is read, migrated, and drained (tofu keeps a .backup). This closes the gap that made the documented adoption runbook fail with "encountered unencrypted payload without unencrypted method configured".
  • The unencrypted fallback is one-shot and never the default. A normal run's encryption document stays aes_gcm-only; the fallback exists exactly for the adoption run, and a normal plan over migrated state reads it cleanly. Tofu's own backend-change guard still protects a workspace that grows local state without an explicit adoption.
  • Verified against the real tofu binary (executor e2e fixture): local state established with a local backend, adopted, then re-planned to no changes.

2026-09-09 — gates move to the pre-commit framework

  • The three bespoke hook scripts become pre-commit dispatchers. commit-msg, pre-commit, and pre-push under .agents/hooks/ are now one-line shims that hand off to pre-commit run; the rules live in a declarative .pre-commit-config.yaml instead of shell.
  • Standard hooks for hygiene. trailing-whitespace, end-of-file-fixer, check-yaml, check-json, check-merge-conflict, and check-added-large-files from pre-commit/pre-commit-hooks. The TUI golden frames (internal/tui/testdata/frames/, docs/design/reference/tui/) and the store migrations are excluded — the frames are byte-pinned terminal widths, the migrations final at merge.
  • conventional-pre-commit validates the commit format. Type + optional scope + subject; non-strict mode keeps fixup!/squash!/amend! and Merge subjects legal, and revert joins the allowed types for git revert subjects.
  • Repo-specific rules stay repo-local. A commit-msg-extras hook keeps the title at 52 chars, forbids attribution trailers, and rejects AI-slop emoji — the checks conventional-pre-commit cannot express. A pygrep hook forbids real host paths (/home/<user>/…) in docs/.
  • make check and make check-race run as repo-local hooks against the working tree (no more index-export clean-room: there should be no unstaged edits at commit time). make check-hygiene runs every pre-commit hook over all tracked files.

2026-09-07 — events: phone-responsive funnel, stacked rows, expand pane

  • The funnel reads as a three-column grid of monospace counts. Each of the six closed outcomes is a 24px numeral over an 11px label, tinted per outcome (in ink-100, matched ink-200, suppressed/gated wait, fired accent when non-zero) with a 6px proportional bar; the "last 24h ·" note leaves the funnel for the app bar, which now reads last 24h in the meta slot.
  • Rows are two stacked lines instead of a seven-column table. Line one is the alert with its outcome keyword and chevron; line two is source · rule with {HH:MM} · {rel} ago on the right, where rel is the age from the event's received time. The chevron still toggles ▸/▾ and j/k/↵ still move and expand.
  • The reason leaves the row for the expanded pane. It now answers "why" only once the operator opens a row: reason · … sits at the top of the pane, the evaluation trace comes first, and the normalized payload sits behind a Show normalized payload → button (payload is second on a phone). The keyboard hint stays in the markup and is hidden on touch by the coarse pointer media query.
  • The footer drops the enum sentence. It reads showing latest {n} of {t} · retained 90d; the closed-enum prose moves to the docs.

2026-09-07 — dashboard: counts tiles, queue card, insecure-banner verb

  • The COUNTS band is a 2×2 grid of whole-link tiles (SPEC §2.1). Each tile is one <a class="tile"> to its full view — label, mono 26/600 tabular value, and, only at zero, the verbatim CLI verb that changes it (none waiting, exocomp ws add, exocomp schedule add, exocomp rule add). The old .counts-grid card-list markup is gone.
  • The queue card reads as one section. The card's "Queue" title is replaced by a In the queue · {idle | N running, oldest queued …} section label with an all jobs → link; the tray shows the live rows, the idle line (○ QUEUE IDLE + the copy sentence) when nothing is moving, and — under a LAST {n} RUNS · {m} FAILED rule row — the newest terminal jobs: FAILED and DEGRADED rows carry their reason and a Rerun → verb (a sibling link to /jobs/{id}?rerun=1 that opens the rerun dialog, JS-off included), SUCCEEDED rows are quiet. The group is three rows, or two while the queue is live.
  • The insecure banner ends in a verb. ▲ INSECURE HTTP + one sentence + Serve over TLS → linking this host over https, replacing the two-line fact with a decision.
  • The failed recently subline is retired. Its store COUNT (CountFailedJobsSince) is dropped with its test; the recent runs group is the one settled-history fact the glance carries.

2026-09-07 — netgroup form: real-time validation + multipart fix

  • The netgroup-add form validates on blur, not just on submit. A name with spaces or a malformed CIDR shows its ▲ message the moment the operator leaves the field, and clears when corrected — the daemon stays authoritative on uniqueness, so submit still asks the server.
  • The browser form-fetch path no longer loses the typed values. The JS submit POSTs a multipart FormData (a fetch body cannot carry urlencoded), but the write guard parsed only urlencoded forms, so a 422 re-render echoed empty fields and the dialog reset to an empty form after a refusal. The guard now parses multipart too, so a corrected re-submit keeps the values.

2026-09-07 — events: funnel, filter chips, tray with inline explain

  • The /events page leads with a last-24h funnel. One card tallies every outcome in the window (17 in · 11 ignored · … · 0 fired) against a proportional bar of the closed enum; the counts come from a new windowed store query (CountEventsByOutcomeSince) that rides the existing event_received_at index — no migration, no second table.
  • Filter chips replace the source dropdown. all, checks, alerts, and the six outcomes each re-run the page under ?filter=, counting the rows in view; the active chip wears the accent tint, and the row of chips hides entirely at zero events (R7). The alerts split is a new NotSource clause on the event list filter ("everything but check").
  • The tray table's rows expand to a two-pane. Every row is a button that opens the stored normalized payload beside the evaluation trace (the same PASS/STOP rendering the /events/{id} page uses, decoded from the row), with the reason column enriched per outcome: FIRED reads the live job (job 4 · succeeded · exit 0) and links to it, GATED names the approval and its TTL and links there, a suppressed silence or cooldown maps to the design copy, and ignored/matched are the fixed sentences. j/k move the row, Enter and e expand (e also brings the trace into view), Esc collapses.
  • The footer is honest about the ledger: showing latest {n} of {total} events · retained 90d · the enum is closed. The /events/{id} trace page is unchanged.

2026-09-07 — netgroup add refusals render on the form

  • Netgroup-add refusals render on the form that owns the input, not a flash. A refused name, CIDR, or duplicate re-renders the add dialog with the field's human sentence (▲ Names can't have spaces., ▲ 192.168.0/16 needs four octets — like 192.168.0.0/16., ▲ A netgroup called home already exists.) and its fail ring; a whole-submit failure reads on the status line (▲ Couldn't reach exocompd — nothing was saved. Retry). JS-off the same page returns HTTP 422 with the dialog open; JS swaps the form in place via fetch. Flash is success-only now ("home added (2 ranges)" — the flash cookie is ASCII-only, so the copy's · separator reads as a parenthetical), so a refusal can never wear the ok tint.
  • The field layout never moves. Every control carries an always-present meta line (min-height 18px) that shows help text at rest and the refusal when invalid, so an error never shifts the geometry of the field or the dialog. aria-invalid and aria-describedby tie the control to its meta; the first invalid field receives focus, and after a refusal a field re-validates on blur and clears when right. The reusable fieldInput/formStatus components (view_form.templ) make the pattern mechanical for the other forms.

2026-09-06 — source delete names its cache removal

  • Deleting a source states the full scope of the action. The source detail's Delete confirmation says the source's cached Python venv and ansible collections are removed with it, and source rm prints the same when it actually purged them (the shared-root guard still keeps a surviving source's caches). Deleting a source had already garbage-collected its dependency caches; this makes that explicit on both deletion surfaces.

2026-09-06 — jobs v2: run card, verdict band, reason lines

  • The job detail page is rebuilt around the verdict. A run card (state chip, exit {code} / exit {code} tolerated verdict number, RUN CONFIGURATION tile grid) is followed by a verdict band on FAILED and DEGRADED — keyword line, per-host HOST/RESULT/RECAP table, and the rerun-tolerating / open-checks / rerun-limited verbs — then the output tail. The list becomes two-line rows with a one-fact reason line (2 hosts unreachable, exit 4 · 2 hosts unreachable) and never a host list.
  • The DEGRADED state is surfaced everywhere it can land: web chips and verdict band (wait amber), the jobs list, the dashboard queue card's last-job line, and the TUI (◐ DEGRADED); the ntfy notify-request title is degraded: {playbook} — {n} hosts unreachable.
  • The rerun dialog and the source launch drawer gain a Tolerate unreachable hosts toggle (pre-highlighted when the prior run failed on unreachable hosts); the detail-page flash is dropped in favour of a dashed rerun of #N chip, and the filter is placeholder-only.

2026-09-06 — degraded is a first-class job state

  • A tolerated unreachable run settles in the degraded state, not succeeded + a partial flag. --tolerate-unreachable runs whose only non-success is offline hosts now end in degraded — a terminal "succeeded with warnings" that does not page. The job row keeps its true non-zero exit code; the CLI (reportJobOutcome, remoteJobExit) and a workflow step's ledger (recordOutcome) treat degraded as a success, so a tolerated offline host fails neither the caller nor the chain waiting on it. partial remains as the legacy marker, still written beside the state for readers that predate it. Schema: migration 0034 rebuilds job with the widened state CHECK and re-points every referencing table, preserving all rows.
  • A whole-inventory miss is not degraded. A run whose recap shows every addressed host unreachable (the full inventory, or the full --limit set) falls through to failed: with nothing reached, nothing converged. Only a run that reached some host and succeeded there may degrade.
  • The state filters admit it. jobs list --state degraded, the MCP list_jobs filter, and GET /api/v1/jobs?state=degraded all accept the new state, and the drift sweep treats a degraded --check run like a succeeded one — it opens (and a clean one never closes) a drift incident.

2026-09-06 — per-source ansible collections isolation

  • Ansible collections follow the venv model: per-source, hash-keyed, resolved at claim time (Decision 0015). A source's collections live in cache/collections/<source>/<requirements-hash>, provisioned by ansible-galaxy as a subprocess of the claimed job when the hash changes (a matching ready marker is a no-op); the shared cache/collections dir is demoted to a legacy fallback for collections no source declares.
  • playbook vendor is now source-keyed, resolving the source's .exocomp/requirements.yml (else repo-root requirements.yml) into the source's hash-keyed dir instead of the flat shared cache.
  • Collections gain lifecycle GC: deleting a source removes its cache/collections/<source> and cache/python/<source> (only when no other registered source resolves to the same root), and a requirements-hash change prunes that source's old hashes with the venv keep-N + since policy.

2026-09-06 — job log: search + view raw in a new tab

  • The Live tail gained a search filter. A "Search log" input above the log box filters the event lines in place (case-insensitive substring); clearing it restores the full tail, and lines the SSE stream appends are filtered to the same query live.
  • An icon-only "view raw log" button (the open-in-new-tab glyph) in the Live tail header opens GET /jobs/{id}/raw in a new tab: the job's full event log as text/plain, one already-redacted message per line — the raw output to copy into a terminal or log viewer, minus timestamps and level chrome.

2026-09-06 — path sources are never "never-synced"

  • A path source is no longer a never-synced wait state. It has no sync verb (source sync refuses a non-git source — the tree is already on the box and read directly), so NeverSynced() now reports false for a path source. The launch drawer's playbook select enables as soon as discovery finds playbooks, fixing the contradiction where the Playbooks card listed playbooks while the launcher stayed disabled with "none discovered yet" (the F14 gate, which reasoned "never-synced = no trusted tree" for git sources, had been applied to path sources too).
  • The source detail's Last-sync fact reads n/a (path source) for a path source instead of "never — sync to discover playbooks", and path sources drop the NEVER chip and attention row in the list and the dashboard never-synced count. The never-synced wait state is now git-only, where the sync verb is real.
  • A disabled launcher select (no playbooks found) names the reason via the same note as the Playbooks card: a never-synced git source says to sync; a synced source with an unusual layout says to give a path relative to the checkout.

2026-09-02 — v0.6 row-actions design (glyphs + chevron menu)

  • A shared row-actions partial renders a tray row's up-to-two verb glyphs then a chevron menu, with a fixed per-verb inline-SVG glyph set (plan, apply, enable, disable, open, rotate, remove, bindings), aria-labels on every control, a destructive-last menu with the "asks first" note, and the phone 48px floor. Views (Saved plans, Backend config, Bindings, Open) moved off the rows onto the resource pages.
  • Secrets and Workspaces wire to the partial: secrets Rotate opens a reauth dialog (value/generate/confirm-token) and Remove is a confirmed glyph; workspaces gain a detail page (Saved plans + Backend config live there, the row opens it and keeps Plan/Apply glyphs) — closing R8.
  • Schedules, Checks cards, and Netgroups wire to the partial: Disable/Enable glyph + Open/Remove menu, and a single Remove glyph for netgroups. "Run now" stays deferred — a schedule-fire route does not exist yet.
  • The trailing "…" is retired from buttons and copy (the prefix truncation marker and select sentinels stay).
  • INVARIANTS and the DESIGN.md component spec record the row-actions rule, the retired ellipsis, and the per-verb glyph set.

2026-09-02 — design-review v0.5 lane C (footnote, NITs, invariants)

  • The source sync note is a footnote, not an inline button-row text (F4): a never-synced source's detail page carries exocomp source sync <name> under the identity card, like the Sources list footnote — not jammed beside the Launch verb.
  • never fired reads as wait on schedules (R11), matching Sources' never-synced and the powerline dead-man — one colour for never.
  • The version string stops repeating itself (R12): a dev build reads "dev", not "dev (dev) built dev" — a segment equal to the version is omitted; a real stamp keeps the full form.
  • INVARIANTS additions: the row-action stance, the one button-height ladder, the zero-only list-card sentence, and the human insecure-warning copy rule. R9 (COUNTS zero-state) documented as zero-only (already the behaviour); R13 (light accent) verified as a fill-vs-tint reading, no token mismatch — both noted to the designer.

2026-09-02 — design-review v0.5 lane B (MED consistency)

  • Tray rows have no zebra (R3): the alternating stripe is gone, so the dark theme's static captures show plain rows with hover only. Light was already flat.
  • One button-height ladder (R4): 34px default, 30px in tray rows and card feet, 44px for the .actions block (modal submits, the launch drawer, login Sign in), and every button reaches the 48px phone floor below 560px.
  • One left edge on list cards (R5): the kicker and hint/body lines align with the title and table at the content edge — the title no longer floats right of them. The system cards gained kickers ("Control-plane health", "Backup freshness") so they share the alignment.
  • Events filter only when there is something to filter (R7): with zero events and no active source the filter row is hidden; an active filter keeps it and shows a filtered-empty line. The select applies on change (enhancement), the Filter button stays for JS-off.
  • Sources and Workspaces list cards gained kickers (R10): "Playbook origins" and "Remote state", so every list card has one.

2026-09-02 — design-review v0.5 lane A (HIGH + still-open)

  • Danger buttons are a ring, not a fill on the source-detail page (R1): Delete… is transparent with the fail ring until the confirm dialog, matching the tray-row and check-card danger buttons. The filled danger stays for the confirm dialog's Confirm.
  • Check card actions moved to the card foot (R2): the Disable/Remove… pair sits below the target grid so the header stays one row (name · kind · chip · down-count) on phones; the pair keeps the 30px row scale, ring-only danger, and the phone's 48px touch floor.
  • Rules and Schedules drop the empty ENABLED column when no row diverges (F12/W17), matching Sources.
  • System Save is control-width, not a full-width accent bar (W11).
  • The playbook select on a never-synced source was verified already-disabled (F14) — reported to the designer as fixed.

2026-09-02 — UX fix round (actions spacing, banner copy, mobile powerline)

  • Tray action buttons breathe. The tray Actions column is a spaced flex row (8px gap) with enough min-width that the common pairs — Rotate…/Remove…, Plan/Apply — sit on one line instead of flush together or wrapping. Applies to every tray table (workspaces, secrets, checks, netgroups, schedules, policies, runners, tokens).
  • The insecure-warning copy reads like a person wrote it. "This surface is served over cleartext. Tokens on the wire…" is now "This page is not secure. Anyone on this network could read your sign-in." — and the login warning says the same plainly.
  • Mobile powerline shows what needs a decision. On phones the ok and dim segments hide, so the strip renders only the signal that needs a look ("dead-man none — set one") instead of a cut-off scroll. A fully healthy phone shows a quiet empty strip. The icon-button direction stays deferred.

2026-09-02 — v0.3 review R2: web findings

  • HIGH 4 · design invariants: ink-600 is no longer text — the nav group labels, tray column headers, and log timestamps read ink-500 (the last AA text step); the crumb separator (a rule) stays ink-600. The font: shorthand on mono runs no longer resets font-variant-numeric — tabular-nums is re-asserted after it on .mono/code/pre/.log and the check-card header.
  • HIGH 5 · queue card fact: the queue card no longer hardcodes "last job — none yet" — it shows the active queue only, and the dead .queuemeta rule is gone.
  • HIGH 6 · dead-man via the store: the powerline dead-man reads store.LastSucceededBackupTime (the shared rule: succeeded runs only, always finished), so a failed or still-running newest run never masks an older good backup.
  • MEDIUM · check-card actions: the header's quiet/danger buttons get the 30px row scale and the phone's 48px touch floor.
  • MEDIUM · grid reflow: the attention/counts/target grids stack to one column below 720px instead of clipping a min-width track.
  • MEDIUM · count cards: a count card shows its zero-state verb only when the count is zero.
  • MEDIUM · launch drawer: the Command tab's Timeout field is restored.
  • MEDIUM · workspace apply now routes through the shared confirm dialog.
  • MEDIUM · schedules spec modal gains a keyboard-reachable trigger — the schedule name opens the dialog.
  • MEDIUM · job tail: aligned with the approvals tail — SSE headers are set after the cap check (a refused tail is a plain 503), the tail emits the idle keepalive heartbeat, and the page carries the stale-live-updates banner with the approvals reconnect semantics.
  • MEDIUM · stream cap reorder: regression tests for BOTH tails pin the per-token-then-global charge with undo — a refused attach leaves no phantom per-token slot.

2026-09-02 — design-review NIT pass

  • W13 · one left edge: the tray-table card titles align with their rows (the 12px indent is gone).
  • W14 · check card actions: the per-check Disable/Remove… buttons sit at the 30px quiet/danger row scale, and Remove… is a ring, not a fill, until the confirm.
  • W16 · netgroups CIDRs render space-separated with · (the comma form stays for the add form's accepted input).
  • W17 · empty ENABLED column: the Sources ENABLED column disappears when no row diverges from the default.
  • W18 · list-card kickers: the Jobs and Secrets cards gain kickers, so every list card has one.
  • W19 · one colour for never: a backup target with no run yet renders its NEVER fact as wait (a gap, not a failure), matching Sources' NEVER and the powerline dead-man.

2026-09-02 — design-review MED pass (consistency)

  • W4 · ligatures off: every mono run (code, pre, .log, .mono) turns ligatures and contextual alternates off, so == and -> in CEL expressions and log tails render as their own glyphs.
  • W5 · empty states name verb and trigger: approvals, jobs, events, and workspaces carry the "… and what makes them appear" copy, not a bare "no X".
  • W6 · no per-row hairlines: the system page's control-plane and backups tables use the tray surface (rows float, no cell rules).
  • W7 · identity once: the app bar's meta slot is the page's own fact (dashboard: host · up 6d 04h); the signed-in identity lives in the powerline only.
  • W8 · schedules row is the link: the redundant inline Details button is gone; the row opens the spec modal.
  • W9 · actions column + chips: tray action columns hold their quiet/danger pair on one row, and chips never wrap.
  • W10 · workspaces Configure is the header's primary verb (the app-bar slot, generalized to arbitrary label text), not an orphan row above the card.
  • W11 · system Save is a control-width button beside the field and the exocomp serve --public-url note renders once.
  • F12 extended: Rules and Schedules hide the default ENABLED chip (the column header carries the default), matching Checks and Sources.

2026-09-02 — design-review HIGH pass (W1/W2/W3 + F4 remainder)

  • W1 · header primary verb: the app-bar Add button is a 34px nowrap accent button (.addbtn no longer caps at 32px transparent and strips the fill); on phones the meta slot hides before the verb shrinks.
  • W2 · attention band truth: the NEEDS ATTENTION band shares the rail badge's one source of truth — a check estate with targets down surfaces as a fail chip + sentence + decision link ("2 DOWN · 2 of 2 targets down · Open checks →"). All six attention cards render the chip + one sentence + one decision link shape, never a count tile; the never-synced sources sentence names the sources.
  • W3 · phone powerline: below 640px the powerline is one horizontal row that scrolls instead of wrapping over the content; the identity segment hides (it is already in the header).
  • F4 remainder: the source detail gains a PLAYBOOKS card; the drawer's Options fold now holds the inventory/limit/tags/secrets fields closed by default; the footer button is full-width with the queue note on its own line; @ {ref} is omitted when a source has no ref (drawer meta, queue button, and the Ref fact row); the playbook select is disabled on any never-synced source (not just an empty discovery); and the exocomp source sync note is spaced from the Launch verb.
  • W12/W15 fixes: the never-synced detail fact actually renders wait and the check down-count actually renders fail — .fail-text/.wait-text now sit after .meta, and .kv dd.wait-text beats the .kv dd colour.

2026-09-01 — check-transition observation ledger

  • A check transition now carries the hysteresis ledger that produced it. The sweep records each probe into a bounded per-target observation ring (check_state.recent_observations, migration 0033) and embeds the transition's tail — filtered to the transition status, observed {status} · {i} of 3 — into the event row at emission (event.check_observations_json), so a historical event stays self-sufficient after the ring moves on. This is the phone Explain card's "WHY THIS COUNTED AS A TRANSITION" data (checks-round D4/17) and it closes the same gap on the web Explain page, which now renders the ledger, the hysteresis note, and the cold-start body anchored to whoami.server.started_at.
  • EventView.check_observations (additive, omitempty): the values-free ledger for a check event; absent for alert and non-check events. The ledger never lands in normalized_json — it is projected from the event row, one copy.

2026-09-01 — phone API: approval countdown + daemon start time

  • JobView.expires_at (RFC3339, omitempty) for an awaiting_approval job: the pending approval's fail-closed deadline, so a phone queue row renders the expires {hh:mm:ss} · {mm:ss} left countdown tail. The web API, MCP reads, and the CLI's local jobs list/get --json and gated run-cli --json all resolve it through one shared wire helper, so the bytes stay identical across every face. Terminal jobs omit it.
  • whoami.server.started_at (RFC3339, omitempty): the daemon's start time, so the events screen labels its cold-start state exactly ("since daemon start {hh:mm}") instead of approximating it from the earliest event. It is taken from the server's injectable clock so a test clock keeps it deterministic.

2026-09-01 — design-handoff v0.3 realignment

Lands the web and TUI surfaces against the v0.3 design handoff (the canon imported to .agents/brain/design-handoff/ — a local-only working directory, not tracked in this repo): the web realign findings F1–F15 and the TUI write round. tokens.css was already byte-identical to the handoff, so the foundation was untouched.

  • Web chrome. The shell owns the page title in a slim app bar (F15); the nav is grouped by verb — OPERATE / AUTOMATE / PROVISION / ACCESS — with actionable-state badges (down targets on Checks, never-synced sources and pending approvals) (F7). The powerline renders dot + word in each segment's signal color and links to where it is fixed (F10).
  • Web lists. List tables float in a padded tray with quiet mono headers and no per-cell hairlines (F11); per-row actions demoted to 30px quiet/danger ring buttons, Sources rows navigate and their disable/delete live on the source page (F8); labeled Add buttons replace the bare glyph (F9); Checks and Sources hide the default ENABLED chip (F12).
  • Checks rebuild. One card per check with a target grid on the tray (F1); reachability is the only chip and location reads as prose (F2); a never-seen target is a single dim UNOBSERVED chip with its cold-start reason (F3); the per-target clock right-aligns in the tile header (F13). Netgroups became a tab on /checks — the standalone page and its write redirects point there, the one route change (F7).
  • Sources. The detail leads with identity and facts; the launcher moved into a right-anchored drawer opened by intent, JS-off via ?launch=1 (F4). A never-synced source reads as a wait state everywhere — NEVER chip and attention row in the list, "never — sync to discover playbooks" on detail (sync is a CLI verb) (F5); an unsynced source with no discovered playbooks gets a disabled select with the path promoted (F14).
  • Dashboard. The glance screen ranks by signal — a NEEDS ATTENTION band of only the non-nominal cards, a wide QUEUE card, then quiet COUNTS whose empty states name the verb that changes them (F6).
  • TUI. Colors aligned to the semantic table (rules color, selected-row reverse video), the seven empty states wired with the verbatim copy, and the footer reports the real dead-man backup age. The interactive write keys are wired against the one queue and the store: pause/mute with mandatory expiry, approve/reject through the CLI's reactive seam (enqueue-only, never drained; stale plans refuse, destroy plans type-confirm), cancel, schedule toggle, follow, trace, outcome, filter, help, g/G, and tab/S-tab over every screen. The b/l/x run-now keys stay reserved but unwired: their spec construction lives in the CLI, and forking it would split the queue-facing contract.

2026-09-01 — event views carry check-field projections

  • EventView gains three additive, omitempty fields the mobile companion consumes: name (the human-facing event name), status (a check transition's direction as the sweep observed it — firing/resolved), and netgroup (a check event's netgroup classification — the group whose prefix covered the target, or away when none did, the same value CheckTargetView carries; absent for alert events and http URL targets). Existing rows and clients are unaffected — empty values stay absent from the JSON.
  • The web's eventName derivation moved into internal/wire as wire.EventName and is now the one implementation the web pages and the API render through (dashboard_data.go's local copy and api.go's alertName are gone; web output is byte-identical). The TUI and CLI surfaces name an event by its matched rule, not this derivation. NewEventView/NewEventList populate the fields from the same Normalized JSON the web reads, error-tolerantly.
  • The phone companion's wire model already declares optional name/status/netgroup on its ReactiveEvent, awaiting these fields.

2026-09-01 — saved-plan 409, runbook display parity, tool gate

  • A saved-plan apply on an unadjudicated plan is a 409, not a 500. The browser form's apply handler now maps the service's PlanUnadjudicatedError (which unwraps to store.ErrStateConflict) to a conflict response with its actionable message, matching the API face — reachable by planning an unpolicied workspace, adding an enabled policy, then applying the saved plan.
  • The job runbook is displayed consistently across surfaces. The CLI and the remote CLI lists print the raw stored runbook — the exact value --runbook filters on, rule: prefix included — so a value read off a list is pasteable back into a filter, while jobs get keeps both the stripped rule name and the raw line; the web job list and detail still strip the prefix for display, and the wire JobView.Runbook stays raw.
  • The full gate requires the real tools again. requireTool skips only under -short and fails the full make check-race when restic/age/git are absent — the fast commit gate stays hermetic, the merge gate re-asserts the toolchain.

2026-09-01 — approvals stream hardening

  • The approvals stream is auth-safe and robust. The browser /approvals/stream re-resolves the session per poll — the credential epoch is pinned at attach, so a rotated token (whose row stays Active) or an expired session ends the stream instead of continuing for up to 12h — the same check every ordinary request makes. Concurrent streams are capped (8 per process, 4 per identity: enough open tabs for a real operator while still leaving half the fleet for a second identity) in a shared funnel both the browser and API routes charge per-token before the global slot, so a readonly attach flood cannot push the fleet over the total transiently. The list-appropriate 2s poll interval, the idle heartbeat that keeps a quiet list alive through intermediary timeouts (the client reconnects instead of closing), and the server-computed fingerprint of the rendered pending set that closes the render-to-connect gap (no blind first-event skip) all stand. Job tails now share the same funnel — a per-identity cap, a per-poll session re-resolve, and reconnect-on-drop — and an over-cap stream is refused with a distinct streams_over_cap code (process exit 7) so a phone or jobs follow --remote never mistakes a saturated stream for the auth gate's kdf_busy. Streams end at server shutdown so exocomp web Ctrl-C no longer burns the full grace period.

2026-09-01 — destructive writes confirm and store validation flashes

  • The /secrets Delete form joins the shared confirm dialog. It carried the hidden confirmed=1 field but no data-confirm marker, so the dialog never intercepted it — every other operator-visible destructive form already had both. The six rm/revoke handlers (checks, netgroups, schedules, runners, tokens, secrets) now also enforce that hidden field server-side, matching policies and sources: a confirmed-less destructive POST reads 400.

  • Store-side operator-input refusals no longer read "internal error". CreateCheck/CreateNetgroup/CreateSchedule return the new exported store.ErrValidation for name/kind/target-shape typos, and the checks, netgroups,and schedules add handlers flash the message instead of routing it to the 500 path.

    CreateSchedule also gains the ValidName gate the web add form already had, closing the undeletable-schedule hole (a name the /schedules/{name} route could never address).

  • The phone approval surface's global-kill block keeps its deliberate 26px top separation. The inline style it replaced became a single-class .mt26 that a later .approve-block rule would collapse to 14px; the compound .approve-block.mt26 selector restores it.

2026-09-01 — workspace saved-plans modal

  • The workspace row's inline plan-id input becomes a Saved plans modal. A runs-in-server workspace row now lists its saved plan artifacts in a modal, each posting the artifact id to the existing apply route (which already routes saved plans through the plan gate); a fresh Apply stays one post away. State- only rows — never planned by the daemon — get no control or modal.

2026-09-01 — inline styles consolidated into classes

  • The web templates carry no inline styles. The 55 literal style="..." attributes (card top margins, inline action forms, the workspaces plan-id width, the phone kill-expiry field) move into named spacing/utility classes in app.css, each reproducing its value exactly. The !important override that fought the header-adjacent card margin is dropped with the inline margin — both selectors stay as plain margin-top: 0.

2026-09-01 — approvals list live-updates via SSE

  • The /approvals list now live-updates over SSE. The page opens a browser-session stream (GET /approvals/stream, cookie-auth, the same tailApprovals the phone API already serves) and reloads when the pending set changes, so a desk or phone operator no longer decides on a stale list. The first approvals event is the initial snapshot (the state the page already rendered), so the script ignores it and reloads only on a subsequent membership/state change; the dashboard card and /events live-update stay out of scope.

2026-09-01 — list-surface refinements

  • The list surfaces name their next action. Every "No X registered." empty state now says what changes it — "add one" where the web has a write surface (checks, netgroups, runners, schedules, sources), the CLI for rules, a workspace for state. The checks and netgroups surfaces gain a one-line hint that nothing fires until a rule matches, linking the read-only /rules page.
  • Schedule detail is keyboard-reachable. Each schedule row gains a focusable Details button that opens its spec modal (the row's mouse-click affordance remains).
  • The three detail pages share the breadcrumb — job, event trace, and workflow now trail back to their lists like approval/source/token already did.
  • The check-add modal grows targets. An "Add target" control clones the target input (capped), so a 5th+ target no longer forces the CLI.
  • htmx is dropped. The vendored, never-used htmx.min.js include and embed are removed; the docs' UI-stack references now name the vanilla JS the app actually uses.

2026-09-01 — dashboard glance and destructive confirms

  • The dashboard's Schedules card now summarises, not lists. It shows the registered total with an "N schedules · M disabled" tally, carrying the warn accent when any schedule is disabled (a disabled schedule has silently stopped firing), mirroring the other glance cards; the card is the link to /schedules, which keeps its full table with cron, next/last fire, and last outcome. No table remains on the glance screen.
  • Destructive actions confirm before they fire. Every Remove, Delete, and Revoke form across /checks, /netgroups, /schedules, /sources, /policies, /tokens, and /runners now carries a data-confirm message that opens a shared native-<dialog> confirm modal in the layout; Confirm re-submits the form (whose hidden confirmed=1 field the write handlers already required), Cancel, the backdrop, or Esc drops it. The app's modal idiom — never window.confirm — now guards the destructive write controls too.

2026-09-01 — write-form errors surface via flash

  • A bad form input no longer renders "internal error". The checks, netgroups, and schedules add handlers route every operator-input validation failure — interval/kind/target/expect for checks, prefix parse and duplicate names for netgroups, cron/argv/target/concurrency-key/jitter and duplicate names for schedules — through the existing ?flash= redirect to the owning page instead of s.fail's 500, so the operator sees the actual message and the form can be retried. Duplicate-name branches flip from a bare 409 text page to the same in-layout flash. Genuine store errors stay 500.

2026-09-01 — check-event traceability and events pagination

  • The check-triggered flow is now traceable in the web UI. A check event names its check and target on the events page and its trace instead of reading "check" for every one; an event that fired links to the job it enqueued; a check's name and an address target's netgroup chip link to the check-sourced event view, as do netgroup names.
  • Events paginate — a "Load more" link raises the 100-row cap up to 500, preserving the source filter.
  • A rule-runbook job displays without the rule: prefix in the jobs list and detail meta (the "fired by rule" provenance line already named it).

2026-08-31 — approval surface refinements

  • The approval phone now ticks and explains itself. The countdown div carries the expiry as a machine deadline (data-deadline, unix millis) and a client clock re-renders "Xm Ys left" each second, flipping to "Expired — the window closed" at zero; the server stays authoritative on expiry and the JS-off initial text remains. The provenance sentence names the triggering alert and the rule it matched when the job's event row exists, and keeps the standing sentence when none does.
  • The approval surface fits the desk. .phone widens to 720px at ≥821px while small screens keep the 390px column, and the mobile (≤560px) table-row button floor is now the hard 48px a11y target.

Correction: three back-dated log entries (list-surface refinements, write-form errors surface via flash, check-event traceability) now carry their true 09-01 dates; newest-first order restored.

2026-08-31 — gating split: fast commit gate, full race gate

  • The per-commit gate is now fast. make check runs fmt-check vet lint tidy-check plus go test -short ./...; the heavy race suite moved to a new make check-race target (-race ./...) that runs from the new pre-push hook and as the worktree-merge integration gate. Invariant 12 and the contribution guide describe the split; the heavy integration tests skip under testing.Short().

2026-08-31 — CLI/web check, rule, netgroup surfaces consolidated

  • The CLI and web now share the parsers that shape checks, rules, and netgroups. store.ParseCheckTargets (strict for check add, blank-skipping for the checks form), store.ParsePrefixList, store.ClassifyNetgroups (one-pass address classification), and store.CheckIntervalLabel are the one home both surfaces parse through; the rule label helpers (action, source, cooldown, cap, approval) now live in internal/reactive, so rule list and the /rules page render through the same code and cannot drift again. The deliberate unify of the CLI/web parsers is recorded here as the correction of the earlier "store remains the shared validator" framing.

2026-08-31 — checks page netgroup/recency; read-only rules page

  • The checks page shows the location and recency signals the feature is about. Each target now renders the netgroup its probed address belongs to as a small chip next to the status chip — the registered group name (ok) or away (dim) — with no badge for an http URL target, whose liveness is not a location verdict. Beside it, a dim meta line reads how long ago the status last changed (changed 3h ago, humanAge of check_state.LastChange) or unobserved when the sweep has never seen the target. The classification reuses the engine's label semantics: a miss reads away, the same word the engine stamps on the transition event, so the page and the events it describes agree.
  • New /rules page reads every rule with the action and guardrails exactly as rule list prints them (shell argv, tofu-plan|apply workspace, playbook source playbook (limit), notify; cooldown, daily cap, approval) and the CEL expression verbatim. GET /rules joins the post-v1 read surfaces with a Rules nav entry. The web now reads rules; mutation stays CLI (design/check-triggered-runs.md §9's "rules stay CLI" is about the builder, not browsing).

2026-08-31 — dashboard checks card, job provenance, events filter

  • The dashboard gains a probe-estate card. After the sources card, a Checks card shows the registered total, how many targets the sweep currently observes up (one aggregate count over check_state, never a per-check state loop), and the attention accent when a check is disabled — DOWN is normal for an away host, so it is not the signal.
  • Job detail links a rule-triggered run to its cause. A job whose runbook is rule:<name> renders "fired by rule " linking to /rules and, when the row is on record, the event that raised it (event #N, source, alert name, received time) linking to its trace. A manually enqueued job carrying a rule runbook shows just the rule line.
  • The events page filters by source. A GET ?source= filter narrows the received-alerts list to one pipeline (alertmanager, healthchecks, generic, drift, check); the form preserves the chosen source, so a filtered view is a linkable URL.

2026-08-31 — http check dial-time guard

  • The http probe refuses the addresses a check must not reach. Its HTTP client dials through a net.Dialer.Control hook (the push transport's T16 dial-time SSRF check, scoped) that refuses loopback, link-local unicast — covering the cloud metadata address 169.254.169.254 — unspecified, and multicast at the connected IP, while keeping the RFC1918/ULA LAN targets a check exists for. A check against a refused address observes down. security/threat-model.md T36 + the checks section of architecture/reactive.md. The probe's GET path is tested both ways: the guarded client refuses a loopback target through the sweep, and an internal test exercises the successful status check with an injected client.

2026-08-31 — web surface for netgroups and checks

  • The web surface gains netgroups and checks pages. /netgroups and /checks render their rows — checks with each target's last observed status from check_state — and carry operator-scope writes (add/rm, add/enable/disable/rm), plain operator scope like schedules: no reauth, no confirm_token. The read API adds GET /api/v1/netgroups and GET /api/v1/checks (wire kinds netgroup.list, check.list), readable by any authed token. The check-add flow exercises the tcp/http kinds; kind ping still refuses on a host that cannot actually send ICMP.
  • The web write handlers reimplement the CLI's parsing instead of calling it — the comma-split + ParsePrefix list for netgroups (cmd/exocomp/netgroup.go) and the name=spec target split for checks (cmd/exocomp/check.go) — so a row added from the browser is held to exactly the same rules as one added from the CLI; the store remains the shared validator.
  • Rules stay CLI. design/check-triggered-runs.md §9 drops the in-browser rule-builder promise and the design's Status is now the shipped-subset summary; architecture/reactive.md gains a Checks subsection beside Drift and the playbook rule action, and operations/reactive.md gains the netgroup/check verbs.

2026-08-30 — check-triggered runs engine

  • The check engine lands: named probe sets become reactive events. check add/list/enable/disable/rm (wire kind check.list) manages check_row rows; the daemon's SweepChecks, called on the same --sweep-interval tick as the drift sweep, probes every enabled check's targets on their interval and emits one event into the existing pipeline per declared transition. Kinds: tcp dials ip:port (the address is the location), ping runs a pinned ping -c1 -W2 subprocess (exit code only), http reads the status line and closes the body without reading it (AGENTS.md 4). A transition declares only after N=3 consecutive observations (hysteresis) in both directions, so a marginal-wifi host cannot flap a pending approval. A target's event carries labels["netgroup"] — the group containing the probed address, or away — and no group for an http URL target (its liveness is not a location verdict). Implemented per design/check-triggered-runs.md §5.
  • check_state schema refinement vs the design doc §4.2. There is no unknown status: a missing row IS the unobserved cold-start state, and the first probe seeds a row (status=result, consecutive=1, emitted=result) without emitting. emitted is seeded to the result, never '', so a daemon restart never spurious-fires every up target as a fresh down→up transition; a target home at start is seeded up and its join is missed once, the documented fail-safe. consecutive carries the hysteresis counter and emitted the last-declared status, so a steady state never emits.
  • Per-target cooldown is check-only, not sweep-source-wide (design §5.5 narrowed). guardCooldown reads the fire ledger per dedup key (check:<check>:<target>, a bounded daemon-controlled set) only for the check source; drift stays per-rule because its keys are per-job and per-key cooldown would bypass the throttle, and webhook keys are fingerprint-based where a varying body would get a fresh budget. The daily cap stays per-rule for every source: a second target joining inside the rule's window is throttled, and its event row still records the match as suppressed.
  • A check's effective interval is floored at the sweep tick. The in-memory due map arms the next probe one interval after the last on the engine clock, but the sweep itself runs on the 30s default --sweep-interval; a check interval shorter than the tick cannot probe more often than it.
  • check_state continuity, v1 has no target-edit verb. A check's targets are fixed at add; state rows persist for the check's life and cascade away with it on delete, so the sweep never re-seeds (and never re-misses) a target that was only being edited.

2026-08-30 — netgroups and peer stamping

  • Netgroups land as a store table, CLI verbs, and webhook peer stamping — phase 1 of design/check-triggered-runs.md. netgroup rows name a comma-separated CIDR set, managed by exocomp netgroup add/list/rm (wire kind netgroup.list); prefix validity is enforced at write time, so a row that reads parses. Every webhook the ingress records now carries labels["peer_ip"] (the peer address, unmapped) and labels["peer_netgroup"] — the registered group whose prefix most specifically contains the peer, or away when none does — stamped before the pipeline evaluates the event. The stamp is a network-path signal, not a location verdict: over tailnet the peer is the CGNAT 100.64.0.0/10 address, so a LAN netgroup marks an overlay-delivered hook away, the fail-safe direction for a "refuse from outside" rule.
  • Reconciliation vs the design doc. §8.1's "first registered group" is implemented as longest prefix, then name ascending. Code wins: "first" would silently change with insertion order on a later netgroup add, while a most-specific match is what an operator reading the prefixes expects, and the name tie-break keeps the verdict deterministic.

2026-08-30 — governed playbook rule action

  • A rule can now launch a governed ansible run instead of only a fixed shell argv. New ActionKind "playbook" carries the registered source, the playbook path, and the ansible passthrough (limit, tags, check, diff). A new reactive.PlaybookBuilder seam — interface in reactive, service-backed impl wired by the daemon — resolves the source checkout and confines the playbook to it; the engine still owns the queue, the gate, the approval, and the identity fields (Runbook/Trigger/DedupKey/ConcurrencyKey/ Timeout). Implemented per design/check-triggered-runs.md §7.
  • --limit is required and fixed at add time (default-deny): an un-limited rule would run the playbook against every host the inventory names. The CLI flag for the playbook's registered source is --repo-source, because rule add already binds --source to the event-source filter; the store field stays source.
  • A rule playbook is a plain, vault-less, inventory-discovery play for v1: no run-secrets or vault binding on rules. A hooked source is refused at add time and at fire time, covering both hook sources (the checkout's own .exocomp/hooks.yml and the data-dir override), because a rule never composes the lifecycle workflow exocomp run owns.

2026-08-26 — dashboard redesigned as at-a-glance summary cards

  • The dashboard's Pending approvals, Queue, Workspaces, Sources, and Workflows sections are summary cards, not embedded tables. Each card is itself the link to its full view (/approvals, /jobs, /workspaces, /sources, /workflows); no full list renders on the dashboard anymore, and the Queue card counts the active queue only — settled job history never reaches the page. Pending approvals leads the grid and carries the wait/amber attention accent with a one-line subline naming the soonest-to-expire item; workspace drift and source attention use the same accent. Workflows in flight hides entirely at zero rather than showing a permanent "None". The Schedules card is unchanged.
  • New store.CountFailedJobsSince backs the Queue card's "N failed recently" subline with a single COUNT query instead of a loaded-and-len'd list.

2026-08-26 — Run again resubmits with tweaks; run options are toggles

  • "Run again" is a resubmit modal, not a link to the source launcher. It shows for any terminal job carrying an argv (succeeded included, ad-hoc included — it no longer needs a source), prefilled from the finished run: Check/Diff/Privileged toggles, editable limit/tags, read-only source/command/inventory/extra-vars. A plain submit repeats the run.
  • POST /jobs/{id}/rerun clones the original row's JobArgs into a queue.Spec, applies the overrides on the cloned argv (add/remove --check/--diff, replace --limit/--tags, set Privileged), and enqueues a fresh job (trigger rerun) through service.Reenqueue — the same actor/privileged attribution tail the run verbs use. Only names travel (VaultProject/RunSecretBinding/Inventory/Source), never secret values.
  • Security: the rerun's operator-scope gate is writeGuard, run before the privileged flag is read, so a readonly token's rerun (privileged or not) is 403 and enqueues nothing.
  • Run-option checkboxes are toggle switches. A shared toggle component (a CSS switch over a real role="switch" checkbox — still the form field and JS-off fallback) replaces the source launcher's checkboxes and drives the rerun modal.

2026-08-26 — jobs carry and display their source name

  • A job now names the registered source it ran from, not just its runbook type. Source rides JobArgs/queue.Spec like Inventory/RunnerName — a name on the job row's args_json, no schema migration. Every source-scoped run path sets it: playbook run --source, run, ansible/command --source, run-cli --source, and the web source launcher; a hooked source's pre/post-run and gate steps inherit it too.
  • The running job event no longer dumps an absolute checkout path. With a source it now reads running <source>: <argv, relativized against Dir>; without one but with a working directory it still relativizes; a job with neither is unchanged.
  • Display: the web jobs list gets a Source column and job detail names it in its meta line (falling back to the old Dir→source reverse lookup for a pre-existing row); exocomp jobs list/get and the wire job.list/job.get JSON gain a source field; the TUI jobs table gets a SOURCE column.

2026-08-26 — UnifiedPush per-device push endpoints

  • New v1.1 push path: a device registers its UnifiedPush distributor URL over POST/GET/DELETE /api/v1/notify/endpoints (operator write; register/delete scoped to the owning token). New push_endpoint store table + CRUD (migration 0028), wire.PushEndpointView (notify.endpoint), and a store-backed notify.UnifiedPush transport wired into the serve-path Notifier alongside ntfy/goalert.
  • The endpoint URL is client-supplied and the daemon POSTs to it, so SSRF is guarded in two layers: registration rejects non-https/credentialed/private-resolving URLs (400), and the transport's HTTP client dials through a net.Dialer.Control hook that re-checks the actual connected IP — the layer that defeats DNS rebinding. Added to threat-model as T16.

2026-08-25 — per-run privileged (unsandboxed) tier option

  • A single run can now opt out of the job sandbox. --privileged on the run verbs (playbook run, run, ansible/command, run-cli) and a "Run unsandboxed (privileged)" checkbox on a source's web launcher run one job at the disabled tier — no kernel policy, full host access — even when the daemon default is enforcing. It is a per-spec override layered on top of the process-global tier resolver, not a change to it: the marker rides the job row's typed Args blob (no schema migration), the queue threads it onto the executor.Spec, and the three sandboxed drivers honour it by running their plain inner driver instead of wrapping it in nono. A privileged run that also named a --runner is refused — caps need the sandbox — never run silently uncapped.
  • Gated, off by default, audited. Secure-by-default (absent the flag nothing changes; the default stays enforcing). Operator-scoped only: the web and API write faces refuse a readonly or approve token by the same operator-scope guard every run passes; the local CLI operator is already trusted. Loud: the CLI prints an unsandboxed warning, the web control is a deliberately-warned non-default checkbox. Attributed: an enqueue-time ledger event names who asked, and the queue records the unsandboxed execution on every path. security/threat-model.md documents the escape hatch and its gating; operations/serve.md documents the per-run override alongside the tier.

2026-08-25 — GET /api/v1/events/summary

  • Event funnel as one object. Tallies every stored event by outcome — {received, ignored, matched, suppressed, gated, fired, error} — through a new store.CountEventsByOutcome, the same event table and outcome column GET /events and event list read, so the two can never disagree. All-time, matching GET /events' own unwindowed listing; readable by any authed token, same floor as the rest of the events reads.

2026-08-25 — breadcrumb navigation

  • Back-nav buttons replaced with breadcrumbs. Source detail, token mint/rotate result, approval decided/expired, source git-key result, and the secrets/sources/tokens reauth notices now show a shared breadcrumb trail (view_layout.templ) above the page content instead of an inline "back to X" button.

2026-08-25 — playbook run --source honors the checkout's ansible.cfg

  • A --source playbook run now executes with the checkout as its working directory. Before, the hookless --source branch rebased the playbook onto the checkout but left the run's Dir empty, so the sandboxed job fell back to its scratch dir and the checkout's ./ansible.cfg (and its relative inventory, roles_path, collections_path) was never discovered — inventory came up empty and roles read as "not found". service.PlaybookInput now carries Dir/DirReadOnly, resolved from the same sourceCheckoutRoot helper the run-cli and ad-hoc paths use, so the CLI grants exactly what the lifecycle exocomp run play step already grants: read on a read-only tree, read+write on a writable one. operations/playbook-run.md documents the new posture.
  • Ansible scratch is pinned off the (read-only) source tree. The sandboxed ansible driver now also exports ANSIBLE_SSH_CONTROL_PATH_DIR (alongside the existing ANSIBLE_LOCAL_TEMP/ANSIBLE_REMOTE_TMP/TMPDIR) into the per-job granted tree, so a repo ansible.cfg with a relative local_tmp or control_path_dir cannot force a write onto a read-only checkout — the env vars win over ansible.cfg. The data-dir/keys guard is unchanged: the checkout grant never extends to the database, host.key, or keyring.age.
  • The web + API --source playbook paths now set Dir too. The browser launcher (handleSourceRunPlaybook) and POST /api/v1/playbooks/run (apiPlaybookRun) mirrored the CLI's earlier gap: they set the source name but not the checkout working directory, so a source run enqueued over the web or API still fell back to the scratch dir and ignored the checkout's ansible.cfg. Both now resolve the checkout via resolveSourceCheckout and pass Dir/DirReadOnly, matching the CLI — so inventory and roles resolve on every face.

2026-08-25 — phone-companion API round 2

  • GET /whoami now carries server: {version, hostname}. The daemon's build version (internal/version) and its host ride the identity reflection, so the Connect screen can render exocompd <version> · <hostname> without fishing the host out of the URL. Both fields are omitempty; a missing hostname degrades the line, never the request.
  • GET/POST/DELETE /api/v1/mutes expose the notify-layer mute (store.NotifyMute) the CLI's mute add/list/rm already drives: the job still runs, only its announcement is held (distinct from a silence, which suppresses the rule). The write takes the scope ladder the phone's mute sheet offers — alert/host/global — and either a for duration or an absolute expires_at; writes are operator-scoped, the list readable by any authed token.
  • Structured stale + provenance on the approval. GET /approvals/{id} now carries a stale: {plan_serial, current_serial, moved_by_job, moved_via, moved_at} block (present only when the workspace moved under the saved plan) and a provenance: {origin, alert, rule, matched_at} block (origin ∈ alert/manual/schedule). The same stale block rides the 409 stale_plan decision error body under error.stale. Both are sourced from the store — a new store.ApprovalStaleness returns the movement structurally, and provenance reads the event-ledger row that named the job — never by parsing a rendered sentence, and both carry identifiers only, never a plan attribute value.
  • One-shot POST /jobs/{id}/notify. Arms an attributed, durable request to page once when a job reaches a terminal state — a clean finish included — so the phone's "Notify me" works even if the app was killed. The web route records one event-ledger row and runs nothing; the reactive daemon's new SweepJobNotifyRequests raises the page, sharing SweepJobOutcomes' checkpoint so a job that also fails is never paged twice. An already-terminal job is a 409; a repeat request is idempotent.
  • QR device pairing. The token-mint result (web) and exocomp token new (CLI) render a pairing QR carrying {v, url, token, fingerprint, scope, name} — a phone scans it to connect and TOFU-pin the certificate. A shared internal/pairing package renders an SVG for the web and a half-block terminal QR for the CLI; the base URL resolves from daemon.public_url (set by serve --public-url or the System page) else the request host. New operations/pairing.md documents the payload contract.
  • Schedule detail. A schedule row opens a spec modal — kind, cron, command, target, concurrency key, jitter, enabled, next/last fire, and last outcome.
  • Sidebar brand mark is rendered once, in a fixed column centered on the nav-glyph column, so it no longer shifts or resizes between the expanded sidebar and the rail.

2026-08-25 — phone-companion API surface

  • Four new /api/v1 endpoints land the mobile handoff: GET /whoami reflects the caller's own token; GET /approvals/{id}/plan opens the sealed plan and returns it as a structured, redacted diff (destroys/changes/creates) through the same PlanOpener.OpenSealedFile + classifyPlan core the web approval surface renders, so a secret-backed value the web redacts is never carried as plaintext, gated by the plan-view floor; POST /jobs/{id}/cancel moves a non-terminal job to cancelled (409 terminal, 404 unknown); POST/DELETE /silences mute/unmute through the store. All four are operator, bearer-only.
  • Approvals SSE. GET /api/v1/approvals/stream mirrors the job stream's registration/gating and per-poll revocation check, but has no terminal state — it sends one snapshot on connect and another only when the pending set changes, replacing a phone's poll of GET /approvals. SSE ts now formats with the same RFC3339 the REST views use, so one parser covers both. operations/api.md updated.
  • ntfy privacy toggle. A notify.ntfy_detail setting switches the ntfy transport between descriptive (today's title/body/deep-link) and terse (a fixed generic phrase) for operators who don't trust their relay.

2026-08-25 — job detail live-update fix

  • SSE backfill. The job-detail stream started its cursor at the latest event on connect, skipping everything written between page render and connect — a fast job stuck on QUEUED with no lines until reload. The client now passes ?since=<last-rendered-seq> to backfill the gap, and reloads on done to render the true terminal status, full log, and actions.
  • Schedule button moves from an always-shown page action to a small icon button in the header, enabled only after a successful run.

2026-08-25 — TUI palette matches design

The TUI color table realigns to the design handoff: magenta accent for keys/links/the active tab (was terminal blue), green for both succeeded and running, a new yellow for awaiting_approval, and the design's fail/heading/dim hexes. Truecolor values with lipgloss's ANSI fallback; color stays a hint — every state still reads as its word.

2026-08-25 — web UI: themed select, dialog modal, System page

  • Themed <select>. Every .writeform select gets an accessible themed listbox overlay (native select kept as the real form field and the JS-off fallback), so option lists follow the app theme cross-browser instead of the OS default.
  • Dialog modal rebuild. The modal moves onto the native <dialog> element (top layer), fixing an overlay pointer-events bug that blocked opening a select, focusing inputs, and backdrop/close-button dismissal.
  • System page. A new /system page holds the control-plane health card, backups, and the server version — moved off the dashboard and the old footer. A fixed-bottom powerline replaces the end-of-content footer, carrying the liveness signals and the signed-in operator. Nav reorders Dashboard-first then alphabetical, with a divider before System.
  • Sidebar collapse toggle moves to the top of the sidebar (Gemini-style); expanded, it sits at the right edge, and the collapsed rail rests on the exocomp brand mark — hovering or focusing swaps the mark for the open-chevron affordance, with the mark living inside the toggle button so the control stays keyboard-reachable.
  • Workspaces Configure modal. The page-level how-to card and inline "add state" form are dropped; a Configure modal presents OpenTofu/Terraform tabs with a single generic intro (stable modal size across tabs) instead of per-tab prose, and each backend-config code block scrolls inside its own box (a long address no longer widens the modal) with a copy-to-clipboard button. The list section renames to "States"; registering a state-only workspace from the web is retired — workspace add --state-only is CLI-only now.

2026-08-25 — dependency resolver sandbox grants uv config

uv's config discovery is mandatory and treats an unreadable config as fatal; the default-deny resolver sandbox profile hid the host's /etc/uv/uv.toml, so a job's uv venv died with EACCES. The sandbox now grants the existing system/user uv.toml read-only (host root stays out of scope), so the resolver reads the config the operator already trusts instead of failing closed on it.

2026-08-25 — access-token and inventory hardening

  • Confused-deputy guard. resolveRunSecrets refuses to inject a sealed value shaped like an exocomp access token (exo_<id>_<secret>) into a job's env, closing the path where an operator binds the daemon's own credential as a run-secret and turns a job into a deputy for the API. One choke point covers every job driver. security/threat-model.md D2 updated.
  • Threat model renumbered. The internal E/S/R/D/B/U/AI finding-id families (gaps, out-of-order entries, orphan residual refs) are replaced by a single sequential T1-T29 catalog in document reading order; residual bullets lead with a short descriptive phrase instead of a dangling id.
  • Empty --inventory defers to ansible. AnsibleArgv/AnsibleAdhocArgv no longer add --inventory when none is given, so ansible discovers its own (the checkout's ansible.cfg, then its default hosts file) instead of being pinned to localhost,. The venv-interpreter force now applies only to an explicit localhost, inline inventory. operations/adhoc-and-partial.md and operations/ansible-first-bootstrap.md updated.
  • Token rotate refuses self. Mirrors the existing revoke self-guard: rotating the token that authenticates the current session is rejected with 409 before reauth, since rotation would invalidate the secret making the request.
  • Cache-busted assets. /assets/* links carry a content-hash version query (app.css?v=<hash>), so a browser holding a prior build's CSS under max-age no longer renders fresh markup against stale styles.

2026-08-25 — sidebar nav, dashboard, and shared modal

  • Sidebar nav. The horizontal title-bar nav (trailing items scrolled off behind a fade) is replaced by a left sidebar with a logo mark, a glyph per section, a desktop icon-rail collapse (persisted), and an off-canvas drawer under 820px. Marketing tagline kickers ("Needs you", "Everything is a job", etc.) are dropped in favor of the section's own heading; functional kickers stay. Nav and brand links no longer take the visited-link purple or a hover underline — ink stays the resting/visited color, accent marks only the current or hovered item.
  • Dashboard. Cards pack into an auto-fill responsive grid instead of a fixed 2-column layout that left sparse cards as half-empty rows, and cards in a row stretch to equal height. Card order now leads with infra actions (approvals, queue, workspaces, workflows, sources, schedules); backups and the control-plane health card move to the bottom, and the OpenTofu-drift card is renamed Workspaces so ansible and tofu read as equal citizens. Rows in the Queue/Workflows/Jobs/Events lists carry data-href, so a click anywhere in the row navigates, without disturbing the existing id link or keyboard access.
  • Chrome fixes. The theme ground (html{background}) now paints from an inline pre-CSS <style> so the browser's white default never flashes during the load gap; main fills the content column at full width instead of capping at 1100px and leaving side gutters; the job-detail live tail renders one event per line with a dimmed timestamp instead of walling repeated stamps; the theme toggle becomes a compact icon button matching the sidebar collapse control.
  • Shared modal. A <dialog>-style modal component (focus trap, save/restore, a + button beside a list page's <h1>) replaces the always-visible inline add-form card on secrets, tokens, runners, policies, and schedules. Source add collapses to one auto-detecting field (source.LooksLikeGitURL routes to the path or git body); a job's detail page gains Schedule (prefilled from the job's own runbook/kind/argv) and Run-again actions.
  • Workspaces reframed. The Workspaces page becomes a managed-OpenTofu-state view: an explainer, a per-row Backend config modal with a paste-ready backend http block, and a state-only add form (POST /workspaces, matching workspace add --state-only). operations/sources.md notes the web UI names this "Workspaces" while the CLI verb and store/API keep their existing shape.
  • CLI. --help groups the ~48 top-level verbs into seven titled sections (run, sources, secrets, automation, hosts, backup, service) — presentation only, the commands machine tree is unchanged. token rotate <name> reseals a fresh secret onto the existing row (keeping id/name/scope for audit continuity) and drops sessions from the old secret; the token new name-collision error now names the revoked holder and points at rotate and token list --all. A new RunAdhoc service verb assembles the ansible ad-hoc queue spec and gates it through approval like ApplyWorkspace.

2026-08-25 — source kinds collapsed; runs move to the source page

  • Source kinds are now git and a single path (an absolute path, or one relative to the data volume), with a writable flag (default read-only, now enforced in the sandbox). The hostpath/volpath split is gone. operations/sources.md and the Decision 0010 addendum carry the model.
  • Runs are launched from a source's page. Each source has a playbook/command launcher; the standalone run surface is retired. Reflected in operations/serve.md.