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 localv1.0.0tag (2026-08-22) that was never pushed to any remote and only ever set the build string throughgit describe. 1.0 is a commitment about external compatibility, so the label is withdrawn and the release line begins atv0.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
commandandansiblecarry the caps-only--runnerwording. 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'--runnerhelp 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
commandandansiblerecord a privileged run at enqueue time. The no-daemon inline path appended the attributedrun requested UNSANDBOXEDrecord 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--egressport set (Egress ports) and the--ssh-identityfiles (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 thePrivilegedtile 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, andansibleenqueue throughqueue.Specdirectly; they now appendservice.RecordPrivilegedlike 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
--runnerstops implying a container tier that does not exist. Onrun-cli,playbook run,runandworkspace 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, andprivileged. 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 getandjobs listname a privileged run. The JSON face already carriedprivileged, but the human text did not, so an operator reading the CLI could not tell a run was unsandboxed.jobs getprints aprivilegedline in the enqueue warning's own words (UNSANDBOXED (no kernel sandbox, full host access));jobs listcarries theunsandboxedfact beside the source (tree · unsandboxed), matching the web list. The local and--remoterenderers 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
unsandboxedand 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 getandjobs list --output jsoncarryprivileged. Aprivileged: truefield 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'sschema_versionstays1, the same as the earlierprogressaddition.
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 thesudodenial already did. A run that already declared--ssh-identityis 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-identityreaches the ad-hoc verbs.exocomp commandandexocomp ansibleaccept--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
unsandboxedchip 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 awarn, none is the quietokstate. It is one probe, soexocomp doctor(text and--output json),GET /api/v1/health, the web Doctor verb's findings rows, and the/systemidentity 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=trueaccepted any positive?timeout=, so?timeout=999hpinned a handler and its connection indefinitely. It is now capped at5m; an over-cap value is a400naming 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-Keyreturns the originaljob_id, and?wait=trueon that retry waits on the original job — poll-without-enumeration without handing a managed host a read path. Recorded as Decision 0021.
2026-10-14 — review fixes: ntfy token, back link, secret log
- 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:
sealedwhen set, a quietnoneon ON, and the fail-tintedmissingonly on REFUSED, where the absent token is the cause. - The
/systemleg pages name their parent./system/deadmanand/system/litestreamset the app-bar back label toSystem, 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 logsweb: 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 rotateis nowsecrets replace, withrotatekept 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}/rotateand therotatealias are unchanged.- The TUI's
ROTATEDcolumn is nowCHANGED— one honest header for a mixed list; the cell still readsneveror a date. - The store's comments match.
RotatedAtrecords when the sealed copy last changed (a rotation for a generated secret, a replacement for a user one) andExpiresAtmarks 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 (tokenreadsmissing) beside its sentence; OFF shows its sentence alone. - The detail meaning has one source. The card's
detailfact 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/Restorepair 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):
fullnames the workspace apply (or the runbook a step gates) alongside the count and TTL and keeps the Approve/Reject buttons;summaryhides the detail and the buttons, leaving a one-line "what kind of thing needs attention" and the open link;tersenames nothing at all. The olddescriptivesetting normalizes tofull, so nothing changes on upgrade. - The buttons are now gated by the rung. They carry a single-use capability
URL, so
summaryandtersedrop 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.
2026-10-14 — ntfy pushes deep-link into the app
- 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 byserve --public-url), soexocomp://approvals/42becomeshttps://exocomp.example/exocomp/approvals/42and 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
clickfield as well as theX-Clickheader, 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-betweenhead, 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.
ConfigandRestoreare 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 sealntfy/url/ntfy/topic, and the now-unusedsealConfigSecrethelper is gone.
2026-10-13 — ntfy: one modal, no drawer
- The ntfy drawer is gone; the modal is the whole channel.
/system/ntfyand 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/ntfynow carries the same ON/REFUSED/OFF chip as the/systemcard, 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 quietNONEwhen an unset key is legal (a private server needs no token; an unconfigured channel is a gap), and the redMISSINGonly 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-waitchip 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 reloadandSIGHUPrebuild 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.redriveis registry-owned. The kind moved from the command intointernal/wirealongside the otherKind*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.Execstubbed; 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 --waitis 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--waitwhenEXOCOMP_JOB_IDmarks 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.
restartno longer proceeds on an unresolvable on-disk binary. A path that cannot be resolved is a refusal, not a skipped precheck.jobs redriveskips 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.reloadreports the store count honestly. The CLI printsschedules 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
serveis 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/interruptedrows;jobs redrivere-drives them. It selects the cut-off rows (default--state interrupted, repeatable withcancelled), 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-runprints what would go and enqueues nothing; a real run needs--yes(or an answered prompt) and refuses on--no-inputwithout 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 restartis the first-class restart for a changed binary. It cordons a running daemon (the same cordondrainsets), 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 likecordon). The request is a one-way signal and returns at once;--waitpolls until the daemon goes down and printsdaemon restarting(--output jsonre-emits themaintenance.restartenvelope).- 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);
--forcecancels the remaining jobs and restarts at once. An unchanged on-disk binary is refused as a conflict; the replacement is prechecked withversion; 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 reloadis the first-class schedule reload. It drives the same pathSIGHUPand--reload-intervalalready did — rebuilding the daemon's schedule registrations from the database — over the operator API (POST /api/v1/maintenance/reload, operator scope likecordon), and reports the schedule count (schedules reloaded (N);--output jsonre-emits themaintenance.reloadenvelope). 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
serveflag change), with theExecReloadstep 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 justFAILEDwhen 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.drawerset nomax-width, so the UA dialog cap left a strip of the page showing behind the drawer;max-width: 100vwcloses it and the desktop 460px drawer is unchanged. /eventskeeps 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-schemeis set on all four theme roots, so the dark surface no longer paints light scrollbars. - A non-ansible failure no longer claims Ansible.
verdictSentencehad 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. /checksno 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'sawaymiss 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 runverb. 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-galaxyread atype: dirortype: fileentry — 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.DiscoverGalaxynow 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. Thepre-commit/pre-push/commit-msgshims were one-lineexec pre-commit run --hook-stage …wrappers around the framework;pre-commit installgenerates those itself. Activation is nowpip install pre-commit && make hooks(the newmake hookstarget, which clearscore.hooksPath) instead ofgit config core.hooksPath .agents/hooks. The repo-specificcommit-msg-extras.shlocal hook moved to.agents/scripts/.
2026-10-06 — the installer becomes recovery.sh
packaging/install.shis nowpackaging/recovery.sh. Itsinstallandrollbackmodes were stubs (they only echoed "would …"; the real rollback is the binary'sexocomp rollback), andharden-host-keymoved todocs/operations/bootstrap.mdas the documented one-offsystemd-credsstep. What remains is the one thing that must stay out of the binary — the invariant-7 bare-metal restore chain (delegating toexocomp restorewhen 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
DCOfile is gone. The project certified contributions by the DCO's "submit = certify" reading, with noSigned-off-bytrailer (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.shis 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 localCLAUDE.mdpointer 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.
2026-10-05 — the launch drawer footer sits below the scroll body
- 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 90pxcolumns around a bare1frPLAYBOOK track, so below ~1250px the playbook column collapsed to ~0, its title wrapped, and the row's fixedheight: 56pxlet 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_staterow,reactive.Eventno longer carriesHosts/event.hosts, and aplaybookrule 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_upremain as inert schema: the file is forward-only, and deleting it would make a database already atuser_version41 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, andtlsnow apply the address-class refusal thehttp/metricclient already did wheneverAllowLoopbackis false (the sweep andcheck 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 exampletcp 127.0.0.1) now reads down on the sweep — the guard doing its job. Atlstarget with no host (:443) is refused at validation instead of dialing the local host. --limit-hostsrefuses ansible magic names. A declared target literally namedall/ungrouped, or beginning with-, is refused before it reaches the--limitpattern, so it cannot widen a rule's run to the whole inventory.- Threat-model residuals recorded. The
--limit-hostsguard 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_checksays it performs live network I/O now; a check's status is the last sweep's stored observation, not live;check_updatestates that omittingaggregate/min_upkeeps the stored rollup settings and that a dropped/renamed target loses its state;check_snoozenotes suppressed transitions are not replayed;check_rmis irreversible; netgroupawayis 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 ascheck_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 storedcheck_state.netgroupis<name>, reusing the sweep's classification rather than re-classifying.- API aggregate parity.
POST /api/v1/checksandPUT /api/v1/checks/{name}acceptaggregate/min_upthrough the same store verbs as the CLI; an out-of-rangemin_upis refused. rule list --output jsoncarrieslimit_hosts. The governed host-set expansion flag is visible to machine consumers.serve --check-canaryis validated at startup. A non-empty value that is notip:portis refused, so a typo cannot silently turn every down target into a missed observation.- One probe runner.
check probe, MCPprobe_check, and the API probe route shareinternal/checkprobewith 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, soexport | apply -f -is a no-op.--pruneneeds--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(bounded0..len(targets)), the/checksadd and edit dialogs, and MCPcheck_add/check_update(aggregate,min_up) all write the opt-in rollup.check listgains an AGGREGATE column and the check card badges "aggregate · min N up";check.viewexposesaggregate/min_up. rule add --limit-hosts. A check-sourcedplaybookrule derives its ansible--limitfrom the sweep event's present host set instead of a fixed pattern. Add refuses it off--source checkor off theplaybookaction, 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 listshows(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 singlecheck:<check>:aggregateevent carryinghosts— the sorted present names — pluslabels.up/labels.total. Default off: a check without the flag behaves exactly as before. hostson the event.Event.Hostsis readable from CEL asevent.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 updateflag, the web control, and the--limit-hostsexpansion follow in a later change.
2026-09-16 — checks and netgroups reach agents and the API
- MCP read tools.
list_checks,get_check, andlist_netgroupsproject the samecheck.list/check.get/netgroup.listwire bytes as--output jsonand/api/v1: each check target's stored config plus its last status, stored netgroup, and probe duration. probe_checkruns 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 optionaltarget.- MCP write tools
check_add,check_update,check_enable,check_disable,check_rm,check_snooze,check_unsnooze,netgroup_add,netgroup_update, andnetgroup_rmsit under the operator floor and call the same store verbs the CLI and the web form use; a kind-inapplicable field is aninvalid_argument. /api/v1gets the matching write routes —POST/PUT/DELETEfor checks and netgroups, plusPOST /checks/{name}/probeat thereadonlyfloor. Creates and updates returncheck.get/netgroup.get, deletes204, a duplicate name409, and bad input400.
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 toawayeven when an include also covers it. Stored in the existingprefixescolumn — no migration. netgroup update <name> <cidr[,...]>replaces a group's includes and excludes in place without disturbing storedcheck_state;netgroup show <addr>prints the group and winning prefix (oraway) 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
netgrouplabel. Webhook ingress now writeslabels["netgroup"]alongsidepeer_netgroupandpeer_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 namedaway, which is byte-identical to the miss label. - The netgroups tab renders exclusions on their own line,
netgroup list --output jsonand/api/v1/netgroupscarryexcludes, 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 addandcheck updatecarry 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--opand--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 testis the dry run: it validates and runs a one-off target set without storing a check.- The
/checksadd 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 usesprobe.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/proberunner backs checks and verify gates. Both build one unionSpecand call oneRun;verifykeeps 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'sExpectStatus == 0accepts 2xx/3xx, a gate's accepts 2xx only. check_row.kindwidens todns,tls, andmetric. Migration 0040 rebuildscheck_rowandcheck_statetogether — 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_jsonfields (dns_name,expect_addr,min_days,op,value), so a previously stored target still decodes.check list --output jsonand/api/v1/checksproject the new fields. Only an address literal classifies into a netgroup, sodns/tls/metric(likehttp) 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>andcheck unsnooze <name>lift and set a check-level snooze from the CLI.--foris 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 listgains NETGROUP and DURATION columns and names an active snooze in place of ENABLED; the/checkspage uses the stored netgroup andlast_duration_msfor 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
/checkspage can re-point a check in place. The edit dialog callsUpdateCheckoverPOST /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 mirrorcheck add(422 with every field's message). /metricsaddsexocomp_check_target_up,exocomp_check_probe_duration_seconds, andexocomp_check_snoozed, withCheckTargetDownAtHomeandCheckSnoozedalert 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_stategainsnetgroupandlast_duration_ms;check_rowgainssnoozed_until. The sweep classifies each address target once at observation time and measures each probe, socheck list --output jsonand/api/v1/checksproject 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] Workspacesempty state renders the three-line block the design specifies, not a compact one-liner:no workspaces yet, theterraform dirs exocomp watches for drift and plans on your behalfexplanation, and theadd the first: exocomp workspace add ./infracommand. 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 keepsID SOURCE STATE EXIT, BackupsTARGET STATE SNAPSHOT ERROR, WorkspacesNAME SERIAL DRIFT, and SchedulesNAME 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'sSetSizeswaps 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-wideevents-120and 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.Initreturns every load as a batch, so Bubble Tea paintedViewbefore any*LoadedMsgarrived and a populated store flashed an empty-state sentence on its first frame.Modelnow carries a per-load flag —loadedGlance(Home, Approvals, Events),loadedJobs,loadedBackups,loadedWorkspaces,loadedSchedules— each set on its*LoadedMsgcase 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 cancelconfirms.bon[2] backups,lon[3] workspaces, andxon[4] schedulesrelease work through the shared spec builders (backup.Spec,PlanWorkspacewithservice.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.con[1] jobsnow 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
bbackup,lplan, andxschedule keys could not enqueue because the spec assembly lived inpackage mainor was unexported.backup.Spec(name, trigger)is the one spec shape the CLI and the TUI both enqueue;service.TriggerTUIand a widened daemon seal gate let a TUI plan enqueue without sealing itself; andsched.Specs(ctx, st, log, sc, trigger)exports the schedule-kind to specs builder while(*Scheduler).buildSpecsstays 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 -jsoncompanion. 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-levelplanShowBlobresolver mirrors the web's: it prefers thereactive.PlanShowArtifactKindcompanion captured on the same job and falls back to the plan artifact's own bytes for a plan sealed before the capture existed.
2026-09-15 — the launch drawer reserves its footer on the form
- 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 innerform.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-surfacefragment, so the bar's primary —Add checkon Probes,Add netgroupon Netgroups — stayed whatever the last full load rendered. The fragment response now also carries the app bar's add region (a sharedappbarAddcomponent 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: anywhereto a rule that kept the basewhite-space: nowrap, so a longok=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, whosemin-width: autofloor widened the track to the nowrap recap and made the basetext-overflow: ellipsisdead; both becomeminmax(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.
RestoreandAdd targetread as 40px, 14/500 page verbs instead of the 48px touch floor, and the ACTIONS cell becomes labelled bar-size ghosts —Run nowandMore ▾, 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 onereads 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 shortMorewith 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 setwith a bar-sizeChangeghost; 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 · titleleft 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 bareexit 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.wraptoggle 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 · offreads 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
.verbshape. 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 14pxdefeated 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.cellis an<a>; it now renders in ink like the jobs list. - The events funnel and rows work on phone. The six
n labelcounts flow and wrap to fit the card in 12px mono with the proportional bar hidden; a row whose rule is unset no longer printssource → -, 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-actionsfooter 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:contentswon 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-outcomeevent matched by a rule of the same name) prints the value once on the phone line 2 —source → rule— instead ofjob-outcome → job-outcome. The footer'sshowing 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-rownow 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 targetheading 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 closesnote is sticky at the drawer's bottom with the safe-area inset, empty inputs keep the standard control height, the leaked·beforemay end DEGRADEDis 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-controlon press. A page's primary lives in the new content.page-actionsrow (40px at ≥720px, one filled accent,gap:8pxwrap); 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(themargin-left:autoslot) 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-findingsthree-cell row becomesname · chipon line 1 with the detail full width on line 2, wrapping anywhere — the desktop 130px detail column no longer clipshttp://127.0.0.1:39099/me…. - The verdict area hangs left on phone. The run card's chips · title ·
exit 1figure (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.wraprule (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-contentJobs / #4breadcrumb 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-wrapby 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 · reasonmeta · clock), and the live rows collapse to a title · chip line. The desktop five-column grid is unchanged: the same.line/.cellnodes re-flow throughdisplay:contents. - The stray
idleword above the queue card is gone. When the queue is idle the section label renders nothing — the card's own○ QUEUE IDLEline 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-12TriggerMeta · Source · ran · durationmeta with the FAILED/DEGRADED reason appended last (the operator folds intoTriggerMeta, 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.drillstructured 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 carriesok:falsewith 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--snapshotalongside--from-litestreamis 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 saysexocomp 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 plainexocomp 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/kitroute serves the raw render astext/plainwithContent-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 pagewith the success-only flash. The web Server's Deriver slice grows aKit()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 asdead-man none/ntfy off/disk 42% free, then the signed-in operator. - The dead-man gap drops its
— set onesuffix. 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 ntfyrefusedstate 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
sudowhose shared-object loader was denied, the sandbox refusing the un-granted/usr/libexec/sudo— the failure growsthe 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
::beforethe 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 ranrenders 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=separatorhairline 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:flexon 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-cellwrapper. The row keepsmin-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:64pxwith 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-identitycarries 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
—withno channel · nothing sealedas the note, instead ofno channelat 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
Changeis pressed — the stray ~216px input and its meta line below the strip are gone (hiddenbeats 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 / nas01no longer re-rides a topbar that already saysSystem · Backups · nas01; the card keepsSnapshot 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 commandcan never read as re-pressable — the verb relabelsShownand disables; the paged fact's hierarchy splitsntfy(loud) fromonce · {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.
rpois retired: the litestream segment readslitestream 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 ▾ gainshistory, and rows are two-line 64px with the drill outcome leading LAST RUN's second line (drill —/drill 3d ago ok) and onlyjob #Nlinking. The history page'sBack to targetsbecomes a‹ targetslink 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 regainspinged 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-forgetjob the web verb enqueues now has a daemon-side leg: internal/sched routes the kind to the backup Runner, whose newrunForgetleg parses the argv (exocomp-forget {target} {snapshot-id}), resolves the target, and runsrestic forget {id} --pruneagainst 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 structuredtarget {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
clearverb. The source cell names the alert an alert-scoped mute covers or the wordglobal; 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 samestore.DeleteNotifyMutethe CLI'smute rmand 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 thisat 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_targetandlitestream.replica_unencrypted_ack— on the landed fieldInput/formStatus anatomy, with the CLI echo (exocomp litestream target … --ack-unencryptedfor 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/drillschedule row on /schedules links back to its target. The row's new Target cell renders the target name — the sameArgs.Targetreference the scheduler's fire-time concurrency keysbackup:<target>anddrill:<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 plaindrill {age} ago okmark; 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-forgetjob (argvexocomp-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} FAILINGstill 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 anX-Requested-With: fragmentheader and swaps the#checks-surfaceregion in place, so switching tabs no longer reloads the page. The active tab'saria-selectedrides 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 doctorstays the scriptable twin. - The /secrets page says who uses each sealed secret. The
ntfy/*keys anddeadman/heartbeat_urllist 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 nobackup/*/passwordkey 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 oneManageverb linking to/system/backups, and a sentence that a running run does not move the age (A9). /system/backupslands 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--urithat 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 ano schedule / add onegap, and the newest run with its bytes · duration · job link ·drill N ago okmark · 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_sftpis cut, no engine or CLI handles it) · Repository · Retention cells (daily · weekly · keep-all) · Schedule prefilled from the newest sibling. The primaryAdd 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 RECOVERYkicker, 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, andType 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'sfrom=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 waitUNPARSEABLEfor 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'srposegment now reads failrpo —for a set target with no engine instead of dimunknown. - The
/system/litestreamleg page lands: the full-width card plus the ▾ views.configrenders the generated litestream.yml read-only — the same bytesexocomp litestream configemits, with credentials referenced by name in the emitted file, never a value — andrestore from replicadeep-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 withSeal token→/secrets?key=ntfy/token(the row focus lands with A2), the why-refused sentence, and the "pages nothing" fact. OFF readsno channel · nothing sealedwith a quietSet up→ Secrets and the sentence namingexocomp setup notify. - The
/system/ntfydrawer (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 persistsnotify.ntfy_detailvia 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):newNtfySeamre-derives the NewNtfy rule and sends one test push (exocomp: test push · {host} · {HH:MM:SS}) through the sameLoadNtfytransport the daemon builds, writing the delivered or refused status line. Bothserveand 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 — notoff— 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 singleArmverb as the fix; ARMED draws the time-left (32h 30m· "until it trips") with the last-good/threshold/ heartbeat facts and a pencilArm; TRIPPED flips to time-since (49h 02m· "since a backup succeeded · threshold 48h") with newest-runs, heartbeat andpaged ntfy · once · 1h 02m agofacts, and the primary verb becomes the fix:Queue backup — {newest target}.Disarmlives 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 asdeadman/heartbeat_url. The URL is atype=passwordfield 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/deadmanpackage). 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, stampingdeadman.pinged_atand clearing thedeadman.paged_atonce-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 URLvalue plus a quietChangebutton posting to the landed/system/public-urlbackend (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 readsdead-man none — set oneuntil 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 newlift.cssasset (embedded, version-hashed and loaded likeapp.css), with the handoff's.powerlinerenamed to.sys-powerlineso 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'snone.
2026-09-12 — the shared row-actions verbs render their refusals on the row
- Every single-button row verb that renders through the shared
rowActionspartial 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
tfstateserials 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-showcompanion 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
formStatusprimitive) 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) andformStatus(…, 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 rawhttp.Errorlines, 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 postedconfirm_tokennever 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
%vanderr.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.orGive 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; acloselistener 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 queueheading andall jobs →link are gone; the queue rows link to the jobs themselves.
2026-09-12 — base-path exposure and ingress-consistent public URL
/systemshows the effective base path the surface is served under: the origin root whenserveruns 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(andexocomp web --base-path) strips the prefix inbound (http.StripPrefix) and re-prefixes every Go-built URL and redirect outbound, so a request to/exocomp/loginrenders, 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/exocomplands 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 thepage.Baseseam — 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,/tokenshrefs, the row-action forms (rowAction.Form), the phone decision/kill forms, the login form action, and the client-side SSE/event/data-tlsURLs — 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=1mode 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 destroyis 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'sDestroyverb on the workspace detail page (its confirm dialog repeats "Destroys — no undo" and requires the hiddenconfirmed=1field, the same guard every destructive form carries), andexocomp workspace destroy <name>locally or with--remote. The enqueued row recordstofu destroy -auto-approveagainst 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, andrule add --action playbook --ssh-identity <path>andexocomp run --ssh-identitydo 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
HOMEonto it, and nono refused to launch: the recursive grant covered nono's own protected state root under thatHOME(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 ofcache/collections, still inside the backed-up cache tree) and the resolver'sHOMEunder it is deliberately not granted — the profile grants only the directories ansible-galaxy writes (ANSIBLE_HOME's~/.ansibleandTMPDIR), 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 · ▾:planis 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 agoin 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-cellwasdisplay:flexon 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 +Nis a toolbar verb, not a floating button — it moved out of the log box into the Output toolbar beforeRaw ↗, 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-cardspacing both sides now. - The funnel counts read
2 in/1 ignored, not2in/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 · alertsis a segmented switch (a scope — never accent,allresets);ignored · matched · suppressed · gated · fired · errorare chips always present in funnel order, zero-count ones dim and unlinked. The URL composes?source=checks&outcome=fired(legacy?filter=values and the singularsource=checkstill work); the active chip is the row's only accent fill and its trailing × clears it; the footer restates the filter and offersshow all. Counts are right-aligned tabular cells (compact12.4kfrom five digits), and theokey 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(orLOG CUT · N lineswhen the stream ended without the run's verdict) — neverFOLLOWING 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 readsaway — 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-jobflex layout had droppedgap). - A terminal job shows ONE status chip. Previously a terminal page rendered
both the streaming pin (stale at
FOLLOWING TAIL) and the separateENDED · nothing to followpill; 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 wascheck rm+check add, which cascades thecheck_staterows 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 rewritestargets_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 ascheck adddoes. An omitted--intervalkeeps 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
enforcingtier 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 withplaybook run --egress <port>(repeatable, or comma-separated),run --egress, or a playbook rule'srule 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-readabilityrequest): inlineold → newfor 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. Noline-throughanywhere — 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 withreflect.DeepEqual(order-independent) and flattens maps to leaf rows (data.marker,metadata[0]), so the row cellN changed · M sameand theN attributes unchanged · showfooter count the same list and always agree. - The literal
continuetext is gone. The template rendered a Gocontinuein 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; theshow/hidedisclosure lists the unchanged attributes (objects as1 block · N keys · unchanged), and arawdrawer opens the minified before/after pair. - NO-OP resources render
unchangedon the plan-detail page (previously dropped); the phone and/api/v1still 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/.../planwere feeding the saved plan's sealed binary to the classifier, which readstofu 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-runningtofu showat render time cannot be hermetic, because it needs the run's privateTF_DATA_DIR(where the run installed providers), which is gone after the run. - The executor now captures
tofu show -jsonat 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 newtofu-plan-showkind, 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 sameclassifyPlanredaction the approval phone and/api/v1use. 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 −Nverdict, and a structured diff parsed fromtofu show -jsonthrough the SAME classifier the phone approval and/api/v1use — 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, andN changed · M same, expanding to the old→new strike of the moved attributes with the unchanged remainder collapsed. The footer verbApply plan #id →and the header's apply glyph open the confirm.- The apply confirm is a native
<dialog>that repeats the diff and asks: kickerAPPLY 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 approvalfor a require-approval verdict, orPlan againfor a stale plan. A deny verdict showsDENIED · <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=1renders 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 #jobwith 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/v1plan 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 −Ncounters, 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 Nand 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 previousmax-height: 60vhbox 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|offflips a workspace's offline-provider posture: on makes every tofu run resolve providers from the local filesystem mirror alone, with noregistry.opentofu.orgaccess — 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 theworkspacerow (migration 0035), and threaded into both the inline and daemon driver paths.- The offline path was already proven (
TestTofuPlanRunsOfflineFromPluginCacheruns a network-denied init and plan from the cache); the gap was that no CLI or store surface could setOfflineProviders. 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 onetofu init -force-copy(which implies-migrate-state) with anunencryptedread fallback in theTF_ENCRYPTIONdocument, so an old localterraform.tfstatefrom a priorbackend "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, andpre-pushunder.agents/hooks/are now one-line shims that hand off topre-commit run; the rules live in a declarative.pre-commit-config.yamlinstead of shell. - Standard hooks for hygiene.
trailing-whitespace,end-of-file-fixer,check-yaml,check-json,check-merge-conflict, andcheck-added-large-filesfrompre-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-commitvalidates the commit format. Type + optional scope + subject; non-strict mode keepsfixup!/squash!/amend!andMergesubjects legal, andrevertjoins the allowed types forgit revertsubjects.- Repo-specific rules stay repo-local. A
commit-msg-extrashook keeps the title at 52 chars, forbids attribution trailers, and rejects AI-slop emoji — the checksconventional-pre-commitcannot express. A pygrep hook forbids real host paths (/home/<user>/…) indocs/. make checkandmake check-racerun 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-hygieneruns 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
(
inink-100,matchedink-200,suppressed/gatedwait,firedaccent when non-zero) with a 6px proportional bar; the "last 24h ·" note leaves the funnel for the app bar, which now readslast 24hin 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 · rulewith{HH:MM} · {rel} agoon the right, whererelis the age from the event's received time. The chevron still toggles ▸/▾ andj/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 aShow normalized payload →button (payloadis 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-gridcard-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 anall jobs →link; the tray shows the live rows, the idle line (○ QUEUE IDLE+ the copy sentence) when nothing is moving, and — under aLAST {n} RUNS · {m} FAILEDrule row — the newest terminal jobs: FAILED and DEGRADED rows carry their reason and aRerun →verb (a sibling link to/jobs/{id}?rerun=1that 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 recentlysubline 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 existingevent_received_atindex — 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 newNotSourceclause 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/kmove the row, Enter andeexpand (ealso 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 rmprints 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} toleratedverdict 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 isdegraded: {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 #Nchip, and the filter is placeholder-only.
2026-09-06 — degraded is a first-class job state
- A tolerated unreachable run settles in the
degradedstate, notsucceeded+ apartialflag.--tolerate-unreachableruns whose only non-success is offline hosts now end indegraded— 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.partialremains as the legacy marker, still written beside the state for readers that predate it. Schema: migration 0034 rebuildsjobwith 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
--limitset) falls through tofailed: 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 MCPlist_jobsfilter, andGET /api/v1/jobs?state=degradedall accept the new state, and the drift sweep treats a degraded--checkrun 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 byansible-galaxyas a subprocess of the claimed job when the hash changes (a matching ready marker is a no-op); the sharedcache/collectionsdir is demoted to a legacy fallback for collections no source declares. playbook vendoris now source-keyed, resolving the source's.exocomp/requirements.yml(else repo-rootrequirements.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>andcache/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}/rawin a new tab: the job's full event log astext/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 syncrefuses a non-git source — the tree is already on the box and read directly), soNeverSynced()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 firedreads 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
.actionsblock (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 resetsfont-variant-numeric— tabular-nums is re-asserted after it on.mono/code/pre/.logand 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
.queuemetarule 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-urlnote 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 (
.addbtnno 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 theexocomp source syncnote 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-textnow sit after.meta, and.kv dd.wait-textbeats the.kv ddcolour.
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 towhoami.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 innormalized_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 anawaiting_approvaljob: the pending approval's fail-closed deadline, so a phone queue row renders theexpires {hh:mm:ss} · {mm:ss} leftcountdown tail. The web API, MCP reads, and the CLI's localjobs list/get --jsonand gatedrun-cli --jsonall 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/xrun-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
EventViewgains three additive,omitemptyfields the mobile companion consumes:name(the human-facing event name),status(a check transition's direction as the sweep observed it —firing/resolved), andnetgroup(a check event's netgroup classification — the group whose prefix covered the target, orawaywhen none did, the same valueCheckTargetViewcarries; absent for alert events and http URL targets). Existing rows and clients are unaffected — empty values stay absent from the JSON.- The web's
eventNamederivation moved intointernal/wireaswire.EventNameand is now the one implementation the web pages and the API render through (dashboard_data.go's local copy andapi.go'salertNameare gone; web output is byte-identical). The TUI and CLI surfaces name an event by its matched rule, not this derivation.NewEventView/NewEventListpopulate the fields from the sameNormalizedJSON the web reads, error-tolerantly. - The phone companion's wire model already declares optional
name/status/netgroupon itsReactiveEvent, 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 tostore.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
--runbookfilters on,rule:prefix included — so a value read off a list is pasteable back into a filter, whilejobs getkeeps both the stripped rule name and the raw line; the web job list and detail still strip the prefix for display, and the wireJobView.Runbookstays raw. - The full gate requires the real tools again.
requireToolskips only under-shortand fails the fullmake check-racewhen 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/streamre-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 distinctstreams_over_capcode (process exit 7) so a phone orjobs follow --remotenever mistakes a saturated stream for the auth gate'skdf_busy. Streams end at server shutdown soexocomp webCtrl-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=1field but nodata-confirmmarker, 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.ErrValidationfor 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
.mt26that a later.approve-blockrule would collapse to 14px; the compound.approve-block.mt26selector 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 inapp.css, each reproducing its value exactly. The!importantoverride that fought the header-adjacent card margin is dropped with the inline margin — both selectors stay as plainmargin-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 sametailApprovalsthe 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 firstapprovalsevent 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.jsinclude 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-confirmmessage that opens a shared native-<dialog>confirm modal in the layout; Confirm re-submits the form (whose hiddenconfirmed=1field the write handlers already required), Cancel, the backdrop, or Esc drops it. The app's modal idiom — neverwindow.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 ofs.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
checkevent 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.
.phonewidens 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 checkrunsfmt-check vet lint tidy-checkplusgo test -short ./...; the heavy race suite moved to a newmake check-racetarget (-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 undertesting.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 forcheck add, blank-skipping for the checks form),store.ParsePrefixList,store.ClassifyNetgroups(one-pass address classification), andstore.CheckIntervalLabelare the one home both surfaces parse through; the rule label helpers (action, source, cooldown, cap, approval) now live ininternal/reactive, sorule listand 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,humanAgeofcheck_state.LastChange) orunobservedwhen the sweep has never seen the target. The classification reuses the engine's label semantics: a miss readsaway, 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 listprints them (shell argv, tofu-plan|apply workspace, playbook source playbook (limit), notify; cooldown, daily cap, approval) and the CEL expression verbatim.GET /rulesjoins 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 /rulesand, 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.Controlhook (the push transport's T16 dial-time SSRF check, scoped) that refuses loopback, link-local unicast — covering the cloud metadata address169.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.
/netgroupsand/checksrender their rows — checks with each target's last observed status fromcheck_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 addsGET /api/v1/netgroupsandGET /api/v1/checks(wire kindsnetgroup.list,check.list), readable by any authed token. The check-add flow exercises the tcp/http kinds; kindpingstill 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 +
ParsePrefixlist 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.mdgains a Checks subsection beside Drift and theplaybookrule action, andoperations/reactive.mdgains 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 kindcheck.list) managescheck_rowrows; the daemon'sSweepChecks, called on the same--sweep-intervaltick 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:tcpdialsip:port(the address is the location),pingruns a pinnedping -c1 -W2subprocess (exit code only),httpreads 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 carrieslabels["netgroup"]— the group containing the probed address, oraway— and no group for an http URL target (its liveness is not a location verdict). Implemented per design/check-triggered-runs.md §5. check_stateschema refinement vs the design doc §4.2. There is nounknownstatus: a missing row IS the unobserved cold-start state, and the first probe seeds a row (status=result, consecutive=1, emitted=result) without emitting.emittedis 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.consecutivecarries the hysteresis counter andemittedthe last-declared status, so a steady state never emits.- Per-target cooldown is check-only, not sweep-source-wide (design §5.5
narrowed).
guardCooldownreads the fire ledger per dedup key (check:<check>:<target>, a bounded daemon-controlled set) only for thechecksource; 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_statecontinuity, 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.
netgrouprows name a comma-separated CIDR set, managed byexocomp netgroup add/list/rm(wire kindnetgroup.list); prefix validity is enforced at write time, so a row that reads parses. Every webhook the ingress records now carrieslabels["peer_ip"](the peer address, unmapped) andlabels["peer_netgroup"]— the registered group whose prefix most specifically contains the peer, orawaywhen 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 CGNAT100.64.0.0/10address, 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 newreactive.PlaybookBuilderseam — 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. --limitis 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, becauserule addalready binds--sourceto the event-source filter; the store field stayssource.- 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.ymland the data-dir override), because a rule never composes the lifecycle workflowexocomp runowns.
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.CountFailedJobsSincebacks 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}/rerunclones the original row'sJobArgsinto aqueue.Spec, applies the overrides on the cloned argv (add/remove--check/--diff, replace--limit/--tags, setPrivileged), and enqueues a fresh job (triggerrerun) throughservice.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
togglecomponent (a CSS switch over a realrole="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.
SourceridesJobArgs/queue.SpeclikeInventory/RunnerName— a name on the job row'sargs_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
runningjob event no longer dumps an absolute checkout path. With a source it now readsrunning <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/getand the wirejob.list/job.getJSON gain asourcefield; 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). Newpush_endpointstore table + CRUD (migration 0028),wire.PushEndpointView(notify.endpoint), and a store-backednotify.UnifiedPushtransport 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 anet.Dialer.Controlhook 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.
--privilegedon 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 thedisabledtier — no kernel policy, full host access — even when the daemon default isenforcing. 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 typedArgsblob (no schema migration), the queue threads it onto theexecutor.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--runneris 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 newstore.CountEventsByOutcome, the sameeventtable andoutcomecolumnGET /eventsandevent listread, so the two can never disagree. All-time, matchingGET /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
breadcrumbtrail (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
--sourceplaybook run now executes with the checkout as its working directory. Before, the hookless--sourcebranch rebased the playbook onto the checkout but left the run'sDirempty, so the sandboxed job fell back to its scratch dir and the checkout's./ansible.cfg(and its relativeinventory,roles_path,collections_path) was never discovered — inventory came up empty and roles read as "not found".service.PlaybookInputnow carriesDir/DirReadOnly, resolved from the samesourceCheckoutRoothelper the run-cli and ad-hoc paths use, so the CLI grants exactly what the lifecycleexocomp runplay 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 existingANSIBLE_LOCAL_TEMP/ANSIBLE_REMOTE_TMP/TMPDIR) into the per-job granted tree, so a repoansible.cfgwith a relativelocal_tmporcontrol_path_dircannot force a write onto a read-only checkout — the env vars win overansible.cfg. The data-dir/keys guard is unchanged: the checkout grant never extends to the database,host.key, orkeyring.age. - The web + API
--sourceplaybook paths now setDirtoo. The browser launcher (handleSourceRunPlaybook) andPOST /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'sansible.cfg. Both now resolve the checkout viaresolveSourceCheckoutand passDir/DirReadOnly, matching the CLI — so inventory and roles resolve on every face.
2026-08-25 — phone-companion API round 2
GET /whoaminow carriesserver: {version, hostname}. The daemon's build version (internal/version) and its host ride the identity reflection, so the Connect screen can renderexocompd <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/mutesexpose the notify-layer mute (store.NotifyMute) the CLI'smute add/list/rmalready 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 aforduration or an absoluteexpires_at; writes are operator-scoped, the list readable by any authed token.- Structured
stale+provenanceon the approval.GET /approvals/{id}now carries astale: {plan_serial, current_serial, moved_by_job, moved_via, moved_at}block (present only when the workspace moved under the saved plan) and aprovenance: {origin, alert, rule, matched_at}block (origin∈ alert/manual/schedule). The samestaleblock rides the409 stale_plandecision error body undererror.stale. Both are sourced from the store — a newstore.ApprovalStalenessreturns 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 newSweepJobNotifyRequestsraises the page, sharingSweepJobOutcomes' checkpoint so a job that also fails is never paged twice. An already-terminal job is a409; a repeat request is idempotent.
2026-08-25 — QR pairing, schedule detail, sidebar logo
- 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 sharedinternal/pairingpackage renders an SVG for the web and a half-block terminal QR for the CLI; the base URL resolves fromdaemon.public_url(set byserve --public-urlor 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/v1endpoints land the mobile handoff:GET /whoamireflects the caller's own token;GET /approvals/{id}/planopens the sealed plan and returns it as a structured, redacted diff (destroys/changes/creates) through the samePlanOpener.OpenSealedFile+classifyPlancore 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}/cancelmoves a non-terminal job tocancelled(409 terminal, 404 unknown);POST/DELETE /silencesmute/unmute through the store. All four are operator, bearer-only. - Approvals SSE.
GET /api/v1/approvals/streammirrors 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 ofGET /approvals. SSEtsnow formats with the same RFC3339 the REST views use, so one parser covers both. operations/api.md updated. - ntfy privacy toggle. A
notify.ntfy_detailsetting switches the ntfy transport betweendescriptive(today's title/body/deep-link) andterse(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 ondoneto 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.writeformselect 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
/systempage 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-onlyis 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.
resolveRunSecretsrefuses 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
--inventorydefers to ansible.AnsibleArgv/AnsibleAdhocArgvno longer add--inventorywhen none is given, so ansible discovers its own (the checkout'sansible.cfg, then its default hosts file) instead of being pinned tolocalhost,. The venv-interpreter force now applies only to an explicitlocalhost,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 undermax-ageno 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;mainfills 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.LooksLikeGitURLroutes 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
httpblock, and a state-only add form (POST /workspaces, matchingworkspace 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.
--helpgroups the ~48 top-level verbs into seven titled sections (run, sources, secrets, automation, hosts, backup, service) — presentation only, thecommandsmachine 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; thetoken newname-collision error now names the revoked holder and points atrotateandtoken list --all. A newRunAdhocservice verb assembles the ansible ad-hoc queue spec and gates it through approval likeApplyWorkspace.
2026-08-25 — source kinds collapsed; runs move to the source page
- Source kinds are now
gitand a singlepath(an absolute path, or one relative to the data volume), with awritableflag (default read-only, now enforced in the sandbox). Thehostpath/volpathsplit 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.