Echo Dashboard
Status: Design + partial ship (PR-A.A shipped 2026-04-26: page shell + retention-paused list + Scan / Re-enable; typed-confirmation Purge shipped 2026-07-01 — dashboard fragment + POST /api/v1/echo/retention-paused/purge, §3.4; persistence still deferred. PR-H shipped 2026-06-18: process tree viewer, as-built §5 — local-Echo-data-only. See docs/roadmap.md Phase 15.)
Audience: Server engineers, dashboard UI engineers, Echo plugin maintainers
Owners: architect (page architecture), plugin-developer (Echo action surface), security-guardian (SQL execution surface), docs-writer (DSL + REST docs)
Related: docs/scope-walking-design.md (the cross-cutting result-set primitive this page consumes), docs/yuzu-guardian-design-v1.1.md (the agent tamper-resistance pillar that the process tree viewer’s data quality depends on), agents/plugins/echo/ (the data plane).
1. Why a dedicated Echo page
The Echo plugin is one of the highest-leverage operator surfaces in Yuzu — it answers the “what happened on this machine” question that drives forensics, compliance, and reactive IR. Today it is reachable only via the generic instruction-launch flow and the /fragments/echo-sql HTMX fragment embedded in another page; the surface area is large but the affordances are scattered. Operators repeatedly asked “where do I go to do Echo” — the answer needs to be a single dashboard page.
The page is also the home for several affordances that did not have anywhere to live:
- Retention-paused source list — the operator-facing view of
<source>_enabled=falseper device, born from issue #539. Without it, an operator who disabledprocess_enabledon a device for forensic preservation has no aggregate view of which boxes are accumulating non-aging data. - Process tree viewer — reconstructed per-host from that host’s local Echo warehouse (
$Process_Live+$TCP_Live) only, with no seed (as-built; see §5). Forensic timeline of every process born under a parent over a chosen window, with an honest “no-seed” completeness caveat (Windows is names-only). The pane also embeds device-level DNS-cache + ARP-table panels (ADR-0015) — dispatched read-onlyecho.sqlover$DNS_Live/$ARP_Liveto the selected host, rendered beside the tree. These are device views, not per-process (the DNS cache carries no pid); they auditecho.dns.read/echo.arp.read(DNS is usage-class PII, separately countable). - Capture sources frame (ADR-0015) — a per-device toggle table for the eleven core sources (
process,tcp,service,user,perf,procperf,netqual,module,arp,dns,registry; the rest are toggled throughecho.configure), with a category filter (six chips: Processes, Network, Services, User sessions, Performance and, forregistry, System; theregistryrow also carries a NEW badge) and a staged-then-push guardrail: toggling a source only stages the change; nothing is dispatched until the operator clicks Push (guards against an accidental toggle of a usage-class source in either direction). Readsecho status+echo compatibility; Push dispatchesecho configure <source>_enabledper change (auditedecho.sources.read/echo.sources.configure). The same configure path is reachable via MCP/RESTcrossplatform.echo.configure.
The page is also the surface where scope walking (per docs/scope-walking-design.md) becomes most visibly useful — the Echo SQL query is the canonical iterative-narrowing tool (it now starts from the Result sets page, From Echo query, rather than from a frame on /echo), and result-set chips are the operator’s reasoning chain made visible.
2. Information architecture
Dashboard ──────────────────────────────────────────────────────────────────
│
├─ Devices (existing)
├─ Inventory (existing)
├─ Instructions (existing)
├─ Policies (existing)
├─ Echo (new — this page) ──────────────────────────────────────────┐
│ │
│ /echo │
│ ┌────────────────────────────────────────────────────────────────────┐ │
│ │ [Scope chip ▼] windows-chrome-suspects · 2,798 devices · pinned │ │
│ │ ─── lineage breadcrumb (per scope-walking §8.2) ──────────────── │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ ┌─ Retention-paused sources (PR-A) ──────────────────────────────────┐ │
│ │ device source paused since live rows oldest │ │
│ │ prod-eu-1 (Linux) process 2026-04-25 18,402 31d 4h │ │
│ │ prod-eu-1 (Linux) tcp 2026-04-25 4,193 31d 4h │ │
│ │ ... │ │
│ │ [Re-enable] [Purge data] [Export CSV] │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ ┌─ Echo SQL (PR-D, scope-walking-aware) ──────────────────────────────┐ │
│ │ $Echo > SELECT name, COUNT(*) FROM process_hourly GROUP BY name │ │
│ │ [Run scoped to: windows-chrome-suspects] │ │
│ │ │ │
│ │ results... (streamed) │ │
│ │ [Save as result set ▼ "chrome-procs"] │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ ┌─ Process tree viewer (PR-H) ───────────────────────────────────────┐ │
│ │ device: [pick from scope ▼] │ │
│ │ │ │
│ │ 1 systemd │ │
│ │ ├── 234 sshd │ │
│ │ │ └── 1402 bash (user=alice) │ │
│ │ │ └── 1419 python3 train.py ← still running │ │
│ │ ├── 412 docker │ │
│ │ │ ├── 998 containerd │ │
│ │ │ └── ... │ │
│ │ ├── 814 yuzu-agent (★ self) │ │
│ │ ... │ │
│ │ Tree as observed since 2026-04-12 09:14 UTC (agent install) │ │
│ └────────────────────────────────────────────────────────────────────┘ │
│ │
├─ Settings (existing) │
└────────────────────────────────────────────────────────────────────────────┘
URL structure:
| Path | Purpose | Required permission |
|---|---|---|
/echo | The page | Infrastructure:Read |
/fragments/echo/retention-paused | HTMX fragment for the paused-sources table | Infrastructure:Read |
/fragments/echo/sql | (retired) the SQL frame fragment; Ad-hoc Echo SQL now starts from Result sets > From Echo query (POST /api/v1/result-sets/from-echo-query) | Execution:Execute |
/fragments/echo/process-tree | HTMX fragment for the tree viewer | Infrastructure:Read |
/api/v1/echo/retention-status | JSON: per-device per-source enabled/disabled | Infrastructure:Read |
/api/v1/echo/process-tree/{device_id} | JSON: reconstructed tree for one device — DEFERRED, not implemented (§5.6); the shipped viewer is HTMX-fragment-only | Infrastructure:Read |
3. PR-A — Retention-paused source list
Goal: every device × source pair where <source>_enabled=false, surfaced in a sortable, filterable, exportable table, with one-click re-enable and an explicit “purge data” action gated behind a typed-hostname confirmation.
3.1 Data flow
The source of truth is the agent’s echo_config table — the <source>_enabled keys, set by the echo.configure action. The server does not currently maintain a centralised “Echo config” mirror; the data path is request-driven:
- Page load fans out
echo.status(extended in PR-A to emit the four<source>_enabledvalues + the<source>_live_rowscount +<source>_oldest_tsper source) to every device the page’s scope resolves to. Scope is the active scope chip (default__all__for fresh page load). - Responses stream back via the standard agent-streaming pipeline.
- Server collates into a transient in-memory table; renders as HTMX OOB swaps.
- Empty rows (
enabled=truefor all four sources) are dropped — the table only shows the paused state, not the entire fleet.
Tri-state enabled values + dedup (#560/#558/#561). The <source>_enabled value is read as a tri-state: true → dropped (collecting normally); false → normal paused row; any other value (errored from a corrupt/tampered agent DB, or garbage) → a value-error badge row showing the reported value (it is NOT silently dropped — that would hide a paused/broken source). A disabled source with paused_at == 0 (a pre-v0.12.0 agent) renders a “schema older than server” badge and sorts as the oldest (top), instead of a bare — sunk to the bottom. Both value-error and unknown-paused_at rows float to the top. Before parsing, responses are deduplicated to the most-recent per agent by the server-stamped received_at_ms (never the agent-claimed timestamp, which a compromised agent could backdate to hide its state) — bounding render cost to O(agents × sources) against a response-spam DoS. The complementary store-side per-(command_id, agent_id) cap is a tracked follow-up.
This avoids a new persistent server-side mirror, which would have to be reconciled with agent state on every config change. The trade-off is a 5-15s page-load cost on a large fleet; mitigated by:
- Scope-restricted queries (the operator usually narrows first via a result set)
- Manual Refresh button for ad-hoc re-fan-out (automatic background refresh is planned for Phase 15.G operational hardening)
- Manual “Refresh” button for ad-hoc re-fan-out
Bounded scan read (#4644). The scan’s response read goes through the byte-aware store fetch (10,000 rows / 50 MiB of response payload). A cut is reported, never silent: the page shows a “Partial result” banner, qualifies its header counts and, with no rows in the part that was read, replaces the “still in progress” / “all clear” empty states with one saying the page cannot tell;
GET /api/v1/echo/retention-pausedandlist_echo_retention_pausedcarryresult_truncated_by_cap: trueand also setstore_degraded: true. This page is a dispatch-and-poll view, not a capture source, so the CORE capture-source pattern (§8 of the implementer doc) does not apply.
3.2 Extending echo.status
The action’s response gains four lines per source (so 16 new lines total for the 4 sources):
config|process_enabled|true
config|process_live_rows|18402
config|process_oldest_ts|1730000000
config|process_paused_at|0 # 0 = never; otherwise the ts when last set to false
config|tcp_enabled|false
config|tcp_live_rows|4193
config|tcp_oldest_ts|1733000000
config|tcp_paused_at|1735689600
...
config|<source>_paused_at|... is new — it requires the do_configure handler in echo_plugin.cpp to write this timestamp whenever it transitions a source from enabled to disabled (and clear it on the reverse). This is the operator-facing “paused since” column and is only meaningful at the server’s wall-clock. Backwards-compatible — if the field is absent, the dashboard renders — and falls back to “unknown.”
3.3 Re-enable action
Single-row “Re-enable” button → confirm modal → server dispatches echo.configure with the appropriate <source>_enabled=true to the named device. On success, the row drops out of the table on the next refresh; on failure the row turns yellow with the error tooltip.
Per-source independence (the #539 invariant) means re-enabling one source on a device does not touch the other three — the dispatched parameter is per-source.
3.4 Purge action
The “Purge data” button on a row is more dangerous than re-enable — it tells the agent to drop every row from <source>_live, <source>_hourly, <source>_daily, <source>_monthly for that source, without re-enabling the collector. The use case is “we paused process collection 8 weeks ago for forensic preservation; we have what we need, the disk is filling up, drop the rows but keep the collector paused.”
Server dispatches a new agent action echo.purge_source with {source: <name>}. The action is gated behind a typed-hostname confirmation (“type the device hostname to confirm purge of process data”), per the destructive-action discipline in the project root CLAUDE.md. Audit row: action: echo.source.purge, principal: <session>, result: success|failure, detail: device=<id> source=<name> — the dispatch audit row does not carry rows_deleted (see the as-shipped note below).
As shipped (Phase 15.A, 2026-07-01). Route
POST /fragments/echo/retention-paused/purge; audit verbecho.source.purge(matching theecho.source.reenablesibling); metricyuzu_echo_source_purge_total{result}. The typed confirmation is a nativeprompt()(“type the device hostname”), not a bespoke modal — CSP-safe (nohx-on/eval), consistent with the nativeconfirm()used for re-enable. The paused-guard is agent-side and authoritative:echo.purge_sourcerefuses (source_not_paused) if the source is currently enabled, or (config_unreadable) if the enable flag cannot be read so the agent cannot confirm the source is paused (nothing is purged either way), which closes the scan→purge TOCTOU. The server does not enforce aSOURCE_NOT_PAUSEDcheck (§3.5) because the retention-paused frame is an ephemeral live scan with no persisted paused-state to check. Dispatch is fire-and-forget, sorows_deletedis computed agent-side and returned in the response record — it is not in the immediate dispatch audit row.A1 REST parity + generic-dispatch hardening (2026-07-01). Automation/MCP callers use the structured
POST /api/v1/echo/retention-paused/purge(JSON body{device_id, source},Infrastructure:Deleteper-device-scoped, A4 error envelope,202+command_id; audited fail-closed — no dispatch without a durable audit row). Both the fragment and the REST route confirm the guard is agent-side authoritative. The genericPOST /api/commandescape hatch now also elevates destructive actions:echo.purge_sourcethere additionally requiresInfrastructure:Deleteand is confined to the caller’s visible agents with untargeted broadcast/scope fan-out refused, so it can’t be a weaker path than the dedicated route. The atomic agent-side guard (check + delete under one lock) closes the agent-local check→delete race in addition to the scan→purge TOCTOU.
3.5 Permissions and safety
- View-only access requires
Infrastructure:Read. - Scan fleet requires
Execution:Execute(it dispatches a fleet-wide command — same tier asrun-instructionandecho-executesiblings). - Re-enable requires
Execution:Executeand the operator’s RBAC visibility on the target device. Out-of-scopedevice_idvalues collapse to the same 404 response as not-connected agents (no enumeration oracle); the audit log records the real reason server-side. - Purge requires
Infrastructure:Deleteon the target device (a higher tier than re-enable’sExecution:Execute), plus the operator’s RBAC visibility on the device (same 404-collapse as re-enable). The paused-state guard is agent-side and authoritative:echo.purge_sourcerefuses (source_not_paused) unless the source currently reports disabled, or (config_unreadable) when the enable flag cannot be read so the agent cannot confirm it (nothing is purged either way), which closes the scan→purge TOCTOU and prevents purging an actively-collecting source. The server does not enforce aSOURCE_NOT_PAUSEDcheck — the retention-paused frame is an ephemeral live scan with no persisted paused-state to check, so the authoritative guard lives on the agent that owns the data (see §3.4). The agent action validatessourceagainst the full capture-source registry; the server route additionally restricts it to the four retention-frame sources{process, tcp, service, user}.
3.6 Tests
- Server-side: unit tests for the aggregation/render path against synthetic
echo.statusresponses (paused-only, all-paused, none-paused, partial response). - Agent-side: extends
tests/unit/test_echo_aggregator.cpp(the suite added for #539) with a config-roundtrip test for<source>_paused_atwrite-on-disable / clear-on-enable. - Integration: stand up the UAT stack, disable a source on a synthetic agent, confirm the row appears in the page within one refresh tick, click re-enable, confirm the row drops.
4. PR-D — Echo SQL frame, scope-walking-aware
As built: the frame was not kept on
/echo. The Echo page carries a short note linking to Result sets, and the same query (parent scope, SQL, name, include-empty) is run from Result sets > From Echo query, behind a confirm that shows the SQL. The text below is the original design.
The frame body is largely the existing /fragments/echo-sql route (server/core/src/dashboard_routes.cpp:500). PR-D moves it onto the new page and wires it into the result-set primitive (docs/scope-walking-design.md):
- Scope chip in the frame header — operator picks an active result set (or a standard scope) before submitting. The dispatch resolves
from_result_set:<id>to the device-ID set perscope-walking-design.md§4. - “Save as result set” button on the results pane — after a query returns, operator clicks “Save as result set,” names it, and the server creates a new result set (
source_kind=echo_query) with member IDs equal to the union of agents that returned ≥1 row. Default name isecho-<first-five-chars-of-sql-hash>so the operator can save without typing. - Lineage breadcrumb above the frame mirrors the active scope chip’s lineage (
scope-walking-design.md§8.2).
No changes to the echo.sql agent action; the change is purely server-side scope resolution + result-set persistence.
5. PR-H — Process tree viewer (as-built)
Deviation from the original seed-based design below (kept for history). What shipped reconstructs the tree from the agent’s existing local Echo warehouse only (
$Process_Live+$TCP_Live, queried via the read-onlyecho.sqlaction) — there is no agent-side seed action, noaction='seed'rows, no__checkpoint__rows, and (until #4027) no/api/v1/echo/process-treeREST surface at all. #4027 addedGET /api/v1/echo/process-treeas the frame’s device-PICKER list twin only (§5.6) — the per-device reconstructed-tree JSON surface this paragraph is about remains deferred. This was a deliberate scope choice (local-Echo-data-only); the consequence is an honest completeness limit (see “Honesty” below). Modules: server engineserver/core/src/echo_process_tree.{hpp,cpp}(pure, unit-tested), routesserver/core/src/echo_tree_routes.{hpp,cpp}, pageecho_page_ui.cppFrame 3.
5.1 Reconstruction model (as-built)
- The viewer dispatches two canned, read-only
echo.sqlqueries to ONE selected live host —SELECT … FROM $Process_Liveand… FROM $TCP_Live— through the same dispatch-and-poll seam the device-page “Get live info” uses (untracked dispatch,execution_id="", so it never enters the executions drawer). The agent runs them on its read-only authorizer connection (#760/#631). - The agent forbids recursive CTEs, so the tree is reconstructed server-side in
C++ from the flat event rows: per-pid alive-intervals are built from the
started/stoppedstream, incarnations whose lifetime overlaps the chosen window[from, to]are kept (PID-reuse-safe — each lifetime is its own node), and each is linked to the parent incarnation that owned itsppidwhen it started. Orphans (parent not present /ppid0 / start event aged out) become roots. Cycle- and 50k-node-cap guarded. - Timescale controls. Preset chips — On boot · On agent install · Last minute
· Last 10m · Last hour · Last day — plus a custom From/To (UTC) box.
Point-in-time is the
from == tocase.tois the upper bound (running-vs-exited is decided atto); the SQL fetch lower bound stays oldest-retained so a long-running process started before the window still appears.On bootandOn agent installare Echo-derived proxies (Echo has no boot/install-time column): install =MIN(ts)over retained rows; boot = the most-recent start of a root process (ppid==0), falling back to install.
Honesty in framing. Banner reads e.g. Window <from> → <to> · observed since <oldest-retained> · 411 nodes (281 running / 130 exited) · 0 flagged, with a note
that — because there is no seed — a process whose started event has aged out of
the 100k $Process_Live cap (or that predates the oldest retained row) may not
appear, and that the boot/install anchors are proxies. On Windows the feeder is
ETW (names-only) so per-process path and command line are blank (stated inline);
they are populated on Linux/macOS.
5.2 Filtering, grouping, network (client-side display)
Reconstruction always returns the full tree; display filtering is client-side
(instant, no re-dispatch — each row carries data-state/data-anom):
- State filter — All / Running / Exited chips.
- Anomalies only — toggle (the sole heuristic is a suspicious parent→child name-pair denylist, e.g. an office app or browser spawning a shell/LOLBin; flagged server-side, name-based so it works on Windows).
- Text filter — matches name / PID / remote IP.
- All three combine, reveal+expand the ancestor branches of matches, and are
re-applied after every tree swap (
htmx:afterSettle). - Same-name grouping — 4+ identical-name siblings collapse into one
name ×N (R running · E exited)row (e.g.svchost.exe ×106), expandable to the individual PIDs (each still drill-able). Filters auto-expand groups with matches. - Inline network — each row shows its
$TCP_Liveconnections joined by pid: a count + remoteIP:portendpoints (public/routable egress highlighted amber; listeners shown as:port listen). Full connection table is in the detail panel.
5.3 Layout & detail panel
Two-column: a scrollable tree on the left and a sticky, always-visible detail
panel on the right (stacks below on narrow viewports). Clicking any process row
hx-gets /fragments/echo/process-tree/detail into the right panel — name, PID, parent
PID, user, running/exited + start time, path + command line (blank-with-note on
Windows), the process’s connections, and anomaly evidence. The detail renders from a
server-side reconstruction cache (bounded LRU, 180 s TTL, keyed by a CSPRNG token —
secure_random::random_hex, never mt19937) so row clicks need no further agent
round-trip. The cache entry is bound to the operator who created it: detail
fails closed unless the requesting session matches, so a predicted or leaked token
can’t be replayed by a different operator (transparent to the normal same-session
flow). If secure token generation fails (entropy exhaustion), result returns an
in-panel error and caches nothing rather than minting a weak token.
5.4 Routes
| Route | Purpose |
|---|---|
GET /fragments/echo/process-tree | Frame body: host picker (operator-scoped) + timescale + filter bar + tree/detail targets |
GET /fragments/echo/process-tree/run?device=&preset=&from=&to= | Resolve window, dispatch the two echo.sql, return the polling fragment |
GET /fragments/echo/process-tree/result?…&pcmd=&tcmd=&n= | Poll both commands, reconstruct, cache, render the tree |
GET /fragments/echo/process-tree/detail?token=&node= | Render one node’s detail from the cache (re-checks scope + Execute + originating principal) |
5.5 Permissions & audit
- Viewing the frame:
Infrastructure:Read. - Reconstructing dispatches a live
echo.sql, sorun/resultalso requireExecution:Executeand the per-device management scope (require_scoped_permission) — same posture as the Echo SQL frame and the device live-info probe. (This tightens the original “Infrastructure:Readto view” framing, which assumed a stored, non-dispatching tree.) detailholds the same tier as the reconstruction — it re-checksInfrastructure:ReadandExecution:Executescoped to the cached device, and binds to the originating principal. So a leaked/predicted token can neither cross management scope, downgrade the Execute tier (read behavioral detail an operator couldn’t reconstruct themselves), nor be replayed under a different session.$Process_Live.cmdlineis already redaction-applied at capture by the agent, so no server-side re-redaction is needed; all agent-controlled fields are HTML-escaped.- Agent output is byte-capped before parse (16 MiB process / 4 MiB TCP, defense in
depth over the gRPC message bound); the
echo.sqlquery interpolates only anint64upper-bound timestamp (no string concat — see the load-bearing comment at the query site). - Audit
echo.process_tree.readfires twice (parity withdevice.live.*): a dispatch row when the live query is sent (recorded even if the device is offline or the poll never completes) and a success row after the tree renders; a failure row (csprng_unavailable) if secure token generation fails. The device is thetarget_id(not indetail); the successdetailispreset=… from=… to=… nodes=… anomalies=… os=… conns=…(presetcanonicalized to a fixed allowlist andosnormalized to{windows,linux,macos,?}so neither can forge an audit field;conns=1flags that per-process connection data was shown). Eachdetaildrilldown emits anecho.process_tree.detailrow (node=… os=…). Seedocs/user-manual/audit-log.md.
5.6 Deferred
- Agentic-first REST/MCP parity for the reconstruction itself — a JSON
surface returning the PER-DEVICE reconstructed tree (
{device_id}in the path, the/result+/detailfragments’ data) — remains deferred. #4027 (API-parity programme) shipped REST+MCP twins for the OTHER three Echo fragment reads — the process-tree and capture-sources device PICKER lists (GET /api/v1/echo/process-tree/GET /api/v1/echo/capture-sources, no{device_id}segment — distinct routes from the still-deferred one above, no path collision) and the retention-paused source list (GET /api/v1/echo/retention-paused) — plus MCP toolslist_echo_process_tree_devices/list_echo_capture_sources_devices/list_echo_retention_paused. #4027 deliberately did NOT twin/fragments/echo/process-tree/resultor.../detail— deferred as scope, not impossibility./detail’s cachetokenis a CSPRNG value minted and principal-bound only inside the/resulthandler itself, so a/detailtwin genuinely has no usable input today./result’spcmd/tcmdpair is an ordinaryecho sqldispatch result (Infrastructure:Read, no execute gate) reachable in principle through the already-twinned generic dispatch surface (execute_instruction/POST /api/command) by reproducing the two canned$Process_Live/$TCP_Livequeries verbatim — not a dedicated API path, and a real twin would still need a new async polling contract (no htmx auto-reissue on REST/MCP). Both are otherwise minted only by the dashboard-only/runroute, itself excluded as dispatch-shaped (batched with #3994). Revisit once a/run(and/result) twin lands — seescripts/ci/api-parity/echo.json’sexception:rows on those two paths. - Loaded modules / libraries — not shown by the viewer: the
modulesource records image loads on Windows (ETW) into the$Module_*tables, whichecho.sqlreads. - Seed snapshot (the original §5.1 below) — intentionally not built; revisit only if the no-seed completeness limit proves insufficient in the field.
5.7 Timeline renderers (building blocks, not yet wired)
server/core/src/echo_timeline_view.hpp defines the model of a per-device timeline
read, and echo_timeline_render.{hpp,cpp} holds the pure renderers that turn it into
the device bar, the “what is kept” chart, the lanes, the moment and selected panels
and the “What this record cannot tell you” list. No page, route or API uses them yet,
so nothing is operator-visible.
- What the model must say. Each lane covers its window with typed regions (exact, rolled-up, pruned, paused, not readable with a reason, capped with the point it reaches back to). A stretch with no region renders as “Coverage not stated”, never as exact. Offline, failed and too-old-agent reads render as not readable, never as empty.
- Retention. An expiry is stated only from the configured policy or a row limit. Measured coverage (the oldest row actually seen) is never turned into an expiry, and a source whose measurement could not be made says so instead of “Gone”.
- Wording follows the read. Polling interval, process capture method, operating system and unknown values change the wording; an unknown is worded as unknown, and DNS names are never tied to a process.
- Bounds and escaping. Output is bounded per lane and for the selected panel. Every dynamic value is HTML-escaped; links are supplied by the caller as server-built relative paths with ids percent-encoded, and no route path is fixed in the renderers.
- Time. Every stated time is UTC. Windows outside the years 1970 to 9999 are not drawn.
The data that fills the model (the read, its permissions, audit and the API twins) is separate work; this section records the contract it must meet.
5.8 Timeline page (hidden until the timeline fragments land)
GET /echo/timeline?id=<agent_id>[&at=<epoch seconds>] is the page that will host the
5.7 renderers, with static/yuzu-echo-timeline.js beside it. Both routes are registered only
when kEchoTimelineUi (echo_timeline_page_ui.hpp, copied into page::Deps::echo_timeline_ui)
is true. It ships false: the paths do not exist, nothing links to them, and nothing is
operator-visible. A signed-out request is redirected to sign in; a missing id is a 400.
- What the shell holds. The page (
echo_timeline_page_ui.cpp, shellactive_key“echo”) carries no CSS or script of its own beyond theyuzu.cssrules and the one script. Slots:#tl-run(an htmx load of/fragments/echo/timeline/run?id=<id>[&at=<n>], withdata-moment-urlanddata-selected-urlnaming the other two fragments),#tl-moment,#tl-selected, the UTC clock, Play and Speed, and Copy link to this moment. The id is percent-encoded then HTML-escaped;atis used only when it is 1 to 19 digits. - What the fragments must carry. The script reads numbers, never guesses them:
.tl-body[data-from][data-to](the window),.tl-ph[data-ts](the resolved moment, emitted only for a valid window, which is the script’s bind signal), moment-panel Previous/Next anchorsa[data-ts], block buttonsbutton[data-ts](with adata-refwhen the block is one item), and the selected panel’s linked-item anchorsa[data-ref][data-ts]. Every number is plain digits (at most 15):0x10,1e3or a missingdata-tsmakes the window invalid. The renderers emit all of these. The run fragment is expected to poll its own result withhx-target="this"(as the process tree does). A load inside#tl-runthat is refused, never reaches the server or times out ends the run view with a note (“Could not load the record (HTTP n).” or “Could not reach the server.”): the poll does not retry, so the page needs a reload. The run slot’s own 401 is handled by htmx, not by the script: a signed-outHX-Requestgets 401 withHX-Redirect: /home, which htmx follows. - What the script does. It binds once per run instance (a repeated or unrelated
settle changes nothing; a new instance rebinds from its own moment). An invalid window,
or one without a playhead, gets no controls and no requests. Drag, click and the
arrow, Page, Home and End keys move the moment line (UTC). Every committed time
change requests the moment panel and, while an item is selected, the selected panel at
the same time; the newest request per panel wins and a superseded one is aborted. The
panels show whatever the server returns, including offline, not-readable and capped
states; a non-2xx is “Could not load this panel (HTTP n).” and an unreachable server
“Could not reach the server.” (failures are announced as alerts, a landed panel is not).
Every panel request sends
HX-Request: true, so the server answers a signed-out one with 401; a 401 or a redirected answer ends the session once: playback stops, both panel requests are aborted and the browser goes to/login. - Playback. Play/Pause and a 1x / 10x / 60x speed move the moment line through the window already loaded; they do not show live data. Each step advances the line by speed times elapsed time, clamped to the window; a stalled timer (a throttled or suspended tab) counts at most one second of elapsed time per step, so the line never leaps. It stops at the window end (no loop), on Pause, on any manual move, on a failed panel request, when the tab is hidden and when the run instance is replaced. Repeated time changes (playback and a held key) are limited to one panel refresh a second, the newest time wins, and the timer waits for the previous answer; an answer still missing after ten seconds is abandoned and a fresh request made. A click on a block or on Previous / Next, a scrub release, Play restart and the first move after a quiet second refresh the panels at once. A click on what is already shown (the same time and item) asks for nothing, unless its panel failed, in which case it asks again. Stopping on Pause, the window end or a hidden tab commits the exact stop time once so the panels agree with the line. A click on a linked item in the Selected panel refreshes that panel and stops playback; Play at the window end restarts from the window start. Play is disabled with a note when the window is a day or longer or not valid.
The fragments themselves, their permissions and audit are separate work. What the page assumes of them:
- Requests are
run?id[&at],moment?id&atandselected?id&ref&atunder/fragments/echo/timeline/: stateless, keyed byid, no token. The run poll ends at its result.atparses fail-closed (digits only, no overflow,at=0invalid),refhas a bounded length, and responses carryCache-Control: no-store. - Per-device authorization is theirs:
require_scoped_permission, the same posture as the Echo SQL frame (5.5). One usage-class audit verb throughemit_behavioral_audit, emitted once per run open (not per moment or selected request), with an explicit choice of fail-closed or proceed. - An A5 / ADR-1005 parity exception row (issue and revisit date) lands before the flag flips.
- The renderers’ default
hrefs are not page deep links; the script intercepts them. - Keyboard and ARIA limits: the arrow, Page, Home and End keys move the line while the timeline has focus; the range reads the moment as a UTC time; failures are announced, routine refreshes are not. No accessibility conformance is claimed.
Original seed-based design (superseded — kept for history)
5.1 Reconstruction model
Per the architectural direction set in the parent conversation:
- The agent runs from boot or first install, hardened against tampering (a separate roadmap track — see
docs/yuzu-guardian-windows-implementation-plan.mdand the open Guardian PR ladder). - On first start, the agent runs
echo.process_treeto produce a seed snapshot — every PID currently running, with PPID, name, cmdline, user, start_time. Walks/proc(Linux),CreateToolhelp32Snapshot+Process32FirstW/NextW(Windows),proc_listallpids+proc_pidinfo(PROC_PIDTBSDINFO|PIDPATHINFO)(macOS). Stored inprocess_livewith a taggedaction='seed'row per PID. - From the seed onward, regular
collect_fastcycles emitaction='started'/action='stopped'events. These are appended toprocess_liveand are the canonical mutation stream for the tree. - At render time, the server queries
process_livefor one device’s full history (or a time-bounded slice), replays the seed + events, and produces a tree JSON.
The dashboard rendered the seed timestamp prominently (Tree as observed since … (agent install)). Time slicing was via ?as_of=<ts>. Mitigations for replay cost were a per-device LRU cache (5-min TTL), 24 h __checkpoint__ rows, and a 50K-node render cap. A GET /api/v1/echo/process-tree/{device_id} JSON surface was planned alongside the fragment, and the renderer was to apply the Echo redaction patterns before serialisation.
6. Permissions matrix
| Surface | Required | Notes |
|---|---|---|
/echo page load | Infrastructure:Read | Same as Devices/Inventory pages |
| Retention-paused list view | Infrastructure:Read | |
| Scan fleet (Phase 15.A) | Execution:Execute | Dispatches echo.status fleet-wide; same tier as run-instruction/echo-execute |
| Re-enable | Execution:Execute | Per-device RBAC visibility check; out-of-scope collapses to same 404 as not-connected (no enumeration oracle) |
| Purge | Infrastructure:Delete | Per-device RBAC visibility check + typed-hostname confirmation (native prompt(), CSP-safe). Agent-side source_not_paused / config_unreadable guard is authoritative (no server-side SOURCE_NOT_PAUSED check) |
| Echo SQL submit | Infrastructure:Read (today) → may tighten later | The current grant is read-only because Echo SQL is bounded to the agent’s own DB and runs SELECT-only on a read-only connection behind a SQLite authorizer that allows only registry-known warehouse tables (#760/#631) |
| Save SQL result as result set | Infrastructure:Read + result-set creation quota | Per scope-walking-design §3.3 |
| Process tree view (frame only) | Infrastructure:Read | Frame, host picker, filter bar |
| Process tree reconstruct | Infrastructure:Read + Execution:Execute | Dispatches two read-only echo.sql queries to one device; per-device management scope required; emits echo.process_tree.read |
7. Observability
Per docs/observability-conventions.md:
| Metric | Type | Labels | Status |
|---|---|---|---|
yuzu_echo_dashboard_view_total | counter | frame (retention/sql/tree), result | shipped (PR-A.A) |
yuzu_echo_retention_paused_devices | gauge | source | shipped (PR-A.A). Set from the rows of the page’s own scan read. A cut scan (result_truncated_by_cap, see the bounded-read note in §3.1) does not update it, because its rows would under-count; the gauge keeps the last value from an uncut scan, so it can be stale. It is a per-render view, not a fleet total. |
yuzu_echo_source_purge_total | counter | result | shipped (Phase 15.A — dashboard fragment + POST /api/v1/echo/retention-paused/purge) |
yuzu_echo_source_reenable_total | counter | result | shipped (PR-A.A — dashboard fragment only, no REST twin) |
yuzu_echo_scan_dispatched_total | counter | result | shipped (PR-A.A) |
yuzu_echo_process_tree_render_seconds | histogram | node_count_bucket | planned — not yet emitted (SRE follow-up) |
Do not sum
yuzu_echo_source_purge_totalacross surfaces yet. The dashboard fragment and the REST twin write the same series with differentresulttokens: the fragment distinguishesscope_violationfromdenied, while REST folds per-device scope denial, missing wiring, and audit failure all intodeniedand otherwise only ever writesrequested.successandagent_not_connectedare comparable; nothing else is, and there is nosurfacelabel to split them.
The PR-H viewer ships with no process-tree metrics yet (a dashboard read surface); the render-duration histogram + a dispatch counter + a node-count histogram are a tracked SRE follow-up. The seed-age gauge from the original design is removed (there is no seed).
Audit actions: echo.status.scan (Scan fleet — PR-A.A), echo.source.reenable (Re-enable a row — PR-A.A), echo.source.purge (purge — shipped Phase 15.A; also csrf.denied-style cross-origin refusals are audited under echo.source.purge/reenable with detail=csrf_cross_origin), echo.process_tree.read (PR-H — emitted at dispatch and on a successful reconstruction; the device is target_id, never in detail; see docs/user-manual/audit-log.md). The seed-based echo.process_tree.reseed verb is removed (no seed).
8. Cross-references
docs/scope-walking-design.md— the result-set primitive consumed throughout this pagedocs/yuzu-guardian-design-v1.1.md— agent tamper-resistance, foundational to the process tree’s data quality claimdocs/yuzu-guardian-windows-implementation-plan.md— Windows-first hardening PR ladderagents/plugins/echo/src/echo_aggregator.cpp— the #539 retention pause that the retention-paused list surfacesagents/plugins/echo/src/echo_plugin.cpp— the action surface (echo.status,echo.configure,echo.fleet_snapshot, futureecho.process_tree,echo.purge_source)server/core/src/dashboard_routes.cpp— the existing Echo SQL fragment to be relocated and extendeddocs/data-architecture.md— error code taxonomy, audit envelope conventionsdocs/yaml-dsl-spec.md— DSL surface (gainsfromResultSet:per scope-walking-design §7)
9. Sequencing
| PR | Scope | Status |
|---|---|---|
| PR-A | Page shell + retention-paused list (this doc §3) | Shipped — PR-A.A (paused_at extension + dashboard page + retention-paused list) + typed-confirmation Purge action (§3.4, 2026-07-01). |
| PR-B | Result-set store + REST API (scope-walking-design.md PR-B) | Pending |
| PR-C | Scope chip + sidebar + breadcrumb (scope-walking-design.md PR-C) | Pending |
| PR-D | Echo SQL frame migrated and scope-walking-aware (this doc §4) | Pending |
| PR-E | DSL fromResultSet: (scope-walking-design.md PR-E) | Pending |
| PR-F | Reference IR walkthrough integration test (scope-walking-design.md PR-F) | Pending |
| PR-G | Operational hardening (live re-eval, GC, metrics) | Pending |
| PR-H | Process tree viewer (this doc §5) | Shipped (as-built §5 — local-Echo-data-only reconstruction; no seed). REST/MCP parity deferred (§5.6). |
10. Open questions
- Should the retention-paused list also surface
<source>_enabled=truedevices that have zero recent rows (silent collector failure)? Useful for SRE but visually noisy in IR; defer to PR-G. - The “Purge data” action should ideally be replaced by an “Export then purge” flow that puts the rows into a signed evidence bundle before deletion — a SOC 2 CC7.2 evidence requirement. Defer to a follow-up issue once the retention-paused list ships.
- Multi-device re-enable (select-many in the table, single click “Re-enable selected”) is an obvious affordance once the table has rows; ship in PR-A only if the single-row path is solid.