Skip to content

Rebuild the live status page around one chart, and put the causal trace on a dial - #763

Merged
xmap merged 24 commits into
mainfrom
worktree-status-relay-legibility
Sep 1, 2026
Merged

Rebuild the live status page around one chart, and put the causal trace on a dial#763
xmap merged 24 commits into
mainfrom
worktree-status-relay-legibility

Conversation

@xmap

@xmap xmap commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Rebuilds the live status page around one chart. The seven tables under it are
gone; everything they said is now either drawn on the chart or written on the
row's own card.

The chart

Rows are laid out by relation shape, in four zones ordered by precondition
(Governance, Participants, Execution, Judgement). One row per instance with a
lifetime, one flat lane per domain without. A template never gets a row: a Plan
has no lifetime during a shift, so a row per template is a page of empty bars.
The useful direction is the other one, which run is an instance of which
template, and that is an attribute of the run.

Bursts pack into one square per event, merged until non-overlapping, collapsing
to a counted bar past a cap. Panning is translate-only over an overscan buffer.

Two independent channels, kept independent

  • Opacity is "how far along the traced causal chain", on a seven-step ramp
  • A gutter tick is "the record binds this row to the pinned event's row"

Correlation is a flat SET, so it sits on neither: it gets a hollow mark, a
form, because putting it on the tone ramp would make a correlated bystander
read as the pinned event itself.

The causation dial

The trace depth is a control (1 to 6, default 2), and it is causation ONLY. The
two directions are asymmetric because the record is: causation_id is one
scalar column, so upstream is a path and can only lengthen, while downstream is
the inverted multimap and widens. The card names both, and names them
differently for the same reason: a flat ordered list up, an indented tree down.
Flattening the tree would state an order between concurrent siblings that the
record does not carry.

Producer

_status_push gains plan names, procedure rows, dataset subject_id, and
clearance binding ids. Clearance title, last_reviewed_by and
last_status_reason stay OFF the wire: operator free text is the shape that
carries incidental personal data, and nothing on the page can resolve them
anyway. The enclosure timeline ships state transitions only, never reason,
monitor_ref, or any substrate address.

Verification

  • 14707 unit tests pass, 1 skipped
  • 12 browser suites (jsdom for attributes, getBoundingClientRect for
    geometry, elementFromPoint for what is actually clickable through the
    clip), plus 3 mutation harnesses that prove each check falsifiable

Several checks in here were written, found to pass on an empty set or a fixture
that could not produce the positive case, and rewritten. The recurring one:
[].every() is true, so an assertion must first prove it has something to
range over.

🤖 Generated with Claude Code

xmap and others added 24 commits September 1, 2026 09:56
The flowing lanes had two failure modes and no middle: at twelve marker
points or fewer every label drew at its own exact x, so a burst printed
them all on top of each other; past twelve the labels switched off
entirely and left a row of anonymous squares. Either way a busy lane said
only that something had happened.

The gate was on the wrong variable. It tested COUNT, but overprinting is
caused by DENSITY: twelve events across fifteen minutes read perfectly,
twelve inside one second cannot be drawn apart at all. At a 15-minute
window over the 812-unit plot one unit is about 1.1s, so no bucket size
recovers two events a second apart. Collapse on geometry instead, merging
only marks that physically overlap, and carry the count on the badge.

Four rules keep the merge honest. A cluster reads "xN" only when every
member shares the name and "+N" otherwise, because five Adjusted plus one
Resumed is not six resumes. Labels seat by severity before position, so a
flood of routine traffic can never take the slot an Aborted needed. A
lane that is overwhelmingly one event type labels it once rather than
once per cluster. And every mark carries its full contents with real
timestamps on hover, because a count is only honest if what it replaced
is recoverable.

Collapse is deliberately a rendering concern: lane.points keeps every
point, so REWIND's folded readout still walks the primary lane for the
last state-carrying event and reports "active" from a RunStarted that the
render merged into a badge.

Also drops the aggregate prefix the lane already names, which frees
30-40% of label width for nothing, moves the axis to wall clock because a
sliding origin renames the same event on every re-render, and marks the
live edge so a track running to it is not mistaken for one that ended
there.

The dead feeling was never the data rate. The producer ticks at 2s, but
the window re-slid once a minute and every re-slide is a full teardown,
so a quiet page sat still and then jumped a sixtieth of its width. It now
slides twice a second, and pauses while a pointer or keyboard focus is
inside it so hover tooltips survive long enough to appear.

infra/status-relay/design/ is a dev fixture, not a test: this directory
has no bundler, no lint config and no JS test runner, so the harness is
the only way these passes get exercised against controlled density.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The events table has recorded correlation_id (NOT NULL, indexed) and
causation_id since the initial schema, and the status feed has never
shipped either. So when three events land in the same second the page can
show that they coincided but not whether one caused the others, which is
exactly the question an operator asks when a run aborts next to a permit
observation.

causation_id is set only where a subscriber reacts to an event
(caution_drafter, run_debriefer, allocation_sealer, the agent seed and
lease, the conductor). Commands arriving over REST leave it null. That
makes the recorded edges precisely the ones a human cannot infer by
looking, and a null cause a positive fact -- an operator did this
directly -- rather than missing data.

cause_occurred_at is resolved by the query rather than left to the
reader. The browser holds a bounded window, so it cannot resolve a
causation_id older than that window, and without the cause's time it can
only render silence for an event that certainly had a cause. Carrying the
timestamp turns an unresolvable parent into "caused by something at
14:31:30, before this window". The LEFT JOIN rides
events_event_id_unique, so it is one index lookup per row and at most
`limit` of them; LEFT and not INNER because a null causation_id is the
common case and an INNER join would silently drop every operator command.

These three do not weaken the port's no-payload rule. They are opaque
identifiers and one envelope timestamp, so no BC's field names ride out
on them and the guarantee the two PII fitness tests actually make is
unchanged. The port docstring now says so, since it is the place someone
will look before adding a fourth field.

schema_version stays 1: the keys are additive, and the relay and producer
deploy to different hosts, so a page served by an older relay has to keep
working against a newer producer. The relay itself needs no change at all
-- it stores activity events opaquely and reads only occurred_at to prune
-- which is what makes this rollable one host at a time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A press anywhere on the chart used to start a scrub, because the
pointerdown handler sat on the whole SVG. That made every mark
unclickable: nothing on the timeline could receive a press, so a cluster's
contents were reachable only through the native tooltip and there was no
way to pin anything. Selection is the prerequisite for showing a causal
chain, so this had to come before the edges.

Three gestures now, with no overlap. A press on a mark selects it. A press
on empty chart pans the view. Hover moves the fold cursor, which needs no
press at all, so reading the folded state costs nothing and never competes
with the other two.

That makes the bottom slider redundant: it was a proxy for a gesture you
can now perform on the thing itself. Removing it would have removed the
only keyboard route, so the chart takes over both: it is focusable and
carries the slider semantics, announcing the cursor's clock time in
aria-valuetext. Arrows move the cursor because that is the value the
element reports; shift-arrows and the Page keys pan, which is a different
act; comma and period walk real events, which is the only way to reach a
mark without hunting; Enter pins the nearest one and Escape releases it.

The chart is a VIEW onto the domain rather than the whole of it. Absent
`viewSpanSecs` the view is the domain and panning is inert, which is
REWIND's behaviour unchanged. The live page gets a window picker: the
default is still the whole 15-minute buffer, so nothing changes unless
someone asks for a closer look, and picking 2m or 5m makes the buffer
pannable and slows the drag down to something precise. Panning off the
live edge pauses following, or the re-slide would fight the drag, and the
LIVE marker hides itself once the view no longer reaches now.

Two things caught while building it. Clusters are formed with a small
margin outside the view so one straddling an edge merges the same way it
would mid-view, but the marks still have to be clipped to the plot or they
draw over the lane labels. And the window picker first rendered as a blank
box: it lives outside `.cora-scrubber`, where the --cs-* tokens are
declared, so `var(--cs-warn)` never resolved and the button fell back to
the UA default. It uses the :root tokens now.

design/page-preview.html runs the real page against a stubbed socket, so
the page's own wiring (window picker, follow and pause, the domain tables)
can be exercised without a relay, a producer or a database.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pinning an event now lights its correlation group, draws arrows to its
cause and its consequences, and says in words what it found. Nothing is
connected until asked: the resting chart is unchanged, because most of the
time the question is "what is happening" and only sometimes "why did that
happen".

This also fixes a gap in the previous slice. It shipped causation_id
without event_id, so the browser held a pointer with nothing to point at:
it could tell that an event was caused, but not by which of the events it
already had. The equality test on a single row could not catch that, since
resolving a cause needs two. There is a test now that builds a cause and
an effect and matches one to the other.

Design rules the drawing follows, each for a reason:

Only causation gets ink. Correlation is a SET, so its members are
highlighted and never joined: connecting N members takes N-1 edges that
assert an order the record does not claim.

Arrows are always single and always point cause to effect. The parent
commits before the child in an append-only log, so mutual causation cannot
occur and a double head would be a lie. Upstream and downstream differ in
hue and weight, never in direction, because "why did this happen" and
"what did it set off" are different questions about the same edge.

A focused chain un-collapses. An arrow landing on a burst badge would
claim it caused the whole badge when it caused one event inside it, so
chain members separate out while everything else stays merged.

Edges are fanned by x CORRIDOR, not per source. A reacting subscriber
fires within the same second as its cause, so two edges with different
causes routinely share a corridor and were each centred on it
independently. Every offset also steps away from zero, since a zero offset
draws a dead-straight vertical that collides with the next one and reads
as a grid rule.

Both walks are bounded and carry a seen-set. An append-only log cannot
contain a causal cycle, but these ids arrive over a socket, and a
malformed one would spin the ancestor walk forever. Trusting the shape of
remote data is a hope, not a guarantee. The depth cut is reported in the
readout rather than silently trimming the story.

And the chart says nothing about causation where the document carries
none. A REWIND run history has no causation at all, so every point there
is causeless, and ringing one as "an operator acted directly" would state
a fact the document never supplied. Absent data must not read as a
positive finding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three changes the status page asked for, all about the chart reading as one
consistent surface rather than a set of separate devices.

Dragging translates the rendered content instead of rebuilding it. The
rebuild is what made panning feel stepped: every pointermove recomputed
clusters, reseated labels and refolded the readout, so marks and text hopped
between two equally valid layouts many times a second. Content is now drawn
a full view-span either side of what is visible, a drag is one attribute
write, and the scene is rebuilt once after the hand lets go. Release coasts
briefly and settles.

The clip must sit outside the transform. clip-path resolves in the element's
own user space, so a clip on the group carrying the translate slides with the
content it is meant to be windowing: the left of the plot goes blank and every
mark bunches to the right, with the transform and the clip rect both set
correctly. The clock row gets its own wider window, because the strip below
the axis is empty out to the canvas edge and a label on the first tick may
overhang into it rather than arriving as ":32".

One mark family. A single event is a circle and a group is that circle
stretched over the span its members cover, same height, same fill, with the
count inside. The pair this replaces differed in shape AND height, so a group
read as a different kind of thing rather than as more of the same thing.
COLLAPSE_GAP is now derived from the mark height plus the surface gap that
keeps two neighbours legible as two.

The LIVE rule is gone. The right edge of a flowing window is the present by
construction and the clock ticks say which instant that is; panned into the
past it is just wherever the viewer stopped. The rule was either redundant or
wrong, never in between.

Two smaller fixes found on the way: selecting an event wrote the cursor to the
scene its own rebuild had just discarded, and pinning something off screen
dimmed every visible mark with nothing lit to show for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Relations are now read by hovering, the way the lane-workbench prototype
read them: move onto an event and its card appears with the cause, the
effect count and the correlation size, its causal chain lights, everything
else recedes, and the edges are drawn. Nothing has to be clicked to learn
what an event is connected to. Clicking still pins, so a chain can outlive
the pointer, and a pin reports in the panel where there is room for it while
hover stays in the card.

Hover is driven from pointermove and never pointerover. Focusing rebuilds
the scene, which destroys and recreates the element under the pointer, and
the browser fires a fresh pointerover for that; reacting to it would focus
the same event forever. A rebuild generates no pointermove, so the loop
cannot start.

The fold cursor no longer follows the pointer in a flowing window, and a pin
no longer drops it on the pinned event. It marks the present, which is a
fixed place at the right edge; roaming, it was a dashed rule across every
lane standing between the viewer and the event they were reaching for.
REWIND keeps the old behaviour under `cursorFollowsPointer`, because there
moving the cursor to read the state at a time IS the interaction.

Only marks and their hit targets take pointer events now. The cursor rule,
the labels, the edges and the baselines were all hit-testable and all sat
above the marks, so any of them could swallow a click aimed at an event.

The preview fixture never set event_id and never produced a reachable
causation_id, so `hasCausation` was false and the whole relations path went
unexercised while the preview looked healthy. Its first repair still could
not chain anything: a burst is one stream and the next is eight seconds
away, so no candidate was ever both foreign and recent enough. Reactions are
now emitted as their own tail on a different stream a second or two later,
which is the shape the real subscribers make.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… hour

The fold cursor is gone from a flowing window. A live window has no "state
at a time you point at" to read, only what is current, so it gets a quiet
dashed rule at the present and nothing that follows the pointer. A closed
history keeps the cursor, because there, moving it to read the state at a
time IS the interaction. Which of the two a chart gets is now derived from
the document's own `live` flag rather than a caller option: the previous
commit put the choice in an option that page.html passed and the dev harness
did not, so the harness kept the roaming cursor and the fix was invisible
exactly where it was being looked at.

Everything that assumed a movable cursor moved with it. A live window drops
the slider role, because claiming one promises a control that is not there;
its arrows pan; and `,`/`.` step the SELECTION rather than an invisible
caret, so a keyboard user can still see where they are.

Retention goes to 24 hours, relay and page. A re-slide over 8,600 buffered
events measures 10ms median against its 500ms interval, and hover answers in
about 80ms, so the cost is not where the risk is. The risk is that the left
edge of a buffer looks exactly like a quiet beamline, so: the domain starts
at the oldest event actually held rather than a flat 24 hours back, the relay
caps at 40,000 events and reports `replay_truncated` when the cap drops any,
and the subtitle says which of "nothing retained before" and "buffer capped"
applies. The window picker gains 1h and 6h; nothing selects the whole day,
because 812 plot units over 24 hours is 106 seconds each and every burst
would collapse into a single pill. A day is something to drag through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every event is one small square again, and a burst is those squares packed
side by side into a bar whose length is how many happened. No number on the
mark: the count is the shape. The label still names the group and says how
many, so nothing is lost by dropping the digit.

The point of packing rather than merging is that there is no merged mark
left to drill into. Each square keeps its own severity colour, its own hit
target and its own place in the chain, so hovering the third event of five
names the third event of five rather than whatever the group is titled
after. That was the thing a clicked group could not do.

Packing makes a group wider than the span that formed it, so two groups that
did not overlap as points can overlap as bars. They are merged until they do
not, or the overprinting this rendering exists to prevent comes back in a new
shape. Past eight in a group the packing would run one burst across a third
of the plot and shove its neighbours out of true; beyond that it draws as one
bar and takes the count back as a number, which is the one case where the
number is worth more than the shape.

Edges now render into their own layer beneath every mark, so an arrow leaving
a filled square passes behind it instead of laying its tail across it. Edges
also land on the SQUARE rather than the event's true x, because packing moved
it and an arrow pointing at empty chart beside the mark it means is worse than
one pointing slightly off-time.

Two bugs on the way: a group's label was anchored to the time-span centre
rather than to the bar, so it sat off its own mark and, near the plot edge,
escaped the clamp and got clipped; and pinning placed the card before
`reveal` panned the chart, leaving it pointing at where the mark had been.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The status page can draw an event but cannot place it: nothing on the wire
said what a Run belongs to, what it is measuring, or which Run a Procedure
is a phase of. That was never a missing capability, only a missing
selection. `RunSummaryItem` has carried `campaign_id` and `subject_id` all
along (they back `list_runs`'s own `?campaign_id=` filter), and
`ProcedureSummaryItem` has carried `parent_run_id` and `kind`; the snapshot
row builders simply never read them.

Run rows gain `campaign_id`, `subject_id` and `started_at`. Subject rows
gain `created_at`. Both absences arrive as explicit nulls rather than
missing keys: a consumer that saw no `campaign_id` could not tell a
standalone Run from a producer too old to send one. `started_at` falls back
to `created_at` where a Run has not started, so a span always has a left
edge rather than drawing from wherever the window happens to open.

Procedures are a new snapshot section, drained per open Run through
`parent_run_id` and bounded by the open-run count exactly as datasets are.
This is the level a consumer cannot reconstruct from the activity stream at
all: that stream carries a Procedure's `stream_id` and can tell one from
another, but nothing in it says which Run a Procedure belongs to.

`last_status_reason` is deliberately not sent. It is operator free text,
which is the shape that carries incidental personal data, and nothing on a
status page needs it; the fixture sets it to a recognisable string so the
test cannot pass by the field merely being empty.

`_OperationUnauthorizedError` joins `_UNAUTHORIZED_ERRORS`. Without it a
missing grant on `list_procedures` would drop the beamline's whole feed
instead of blinding one section, which is the guarantee that tuple exists
to make.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four zones, grouped by the SHAPE of what each carries rather than by name,
because the shape is what decides the mark. Execution is a containment tree,
so campaign holds runs hold procedures and thickness carries depth: the
campaign is visibly the thing containing the runs without a second colour or
another rule. Participants own a lifetime and are many-to-many with runs, so
subjects get bars of their own instead of child rows. Governance is a
condition over a range: drawn as dots you learn a permit was sampled, drawn
as a ribbon you learn whether it held. Judgement is point conclusions with no
lifetime, so those stay flat lanes.

`scrubber.js` gains two render kinds, `zone` and `track`, and rows now stack
at their own heights rather than on a fixed pitch: a caption, a lifetime and
a series of instants are not the same kind of row. Both kinds are generic --
the zone captions, the containment, the permit vocabulary and the tone table
all live in page.html, because which events matter and how a facility nests
is domain vocabulary and this module still knows none of it.

The ribbon and its observations are SEPARATE rows. They are one fact told
twice, the segments being folded from those very events, and on one row the
observations pack into a bar that covers the condition they were folded into.
The ribbon answers whether it held; the row beneath keeps every observation
individually pickable, which is what a causal edge into a permit drop has to
land on.

Nothing is dropped for want of a home. An event whose instance is not on
screen -- a run that finished before the window opened -- lands in an
explicit "(elsewhere)" lane for its domain rather than vanishing, because a
busy window that looked quiet would be the worst possible failure here. The
tree is capped at 26 rows and the subtitle says how many tracks that dropped.

One bug found by the layout: `buildModel` filtered lanes down to markers and
selected series, so every zone and track row was discarded before rendering.
The cap belongs to `series` alone -- that is the only kind whose row count is
driven by how many channels a producer happens to have.

The preview fixture grew the shape this needs, twice over. It had no
`parent_run_id`, no `campaign_id` and no permit history, so the nesting went
unexercised; then its activity events carried stream ids matching no instance,
so every track drew as an empty bar; then those ids ignored lifetimes, so
marks landed outside their own track. Each of those looked healthy on screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rows lose about a fifth of their height: a zone caption is a divider, a track
names itself in the gutter and needs no room above its bar, and only a
markers lane has to seat a label clear of the row above. Sizes are per kind
rather than a common pitch, the containment indent is a nudge, and the kind
column is exactly as wide as the four-character word it holds.

Two layout bugs the tightening exposed. A zone's rule ran from the plot edge,
which is UNDER its own caption for every caption long enough to reach there,
and all four are; it now starts clear of the text, which is the divider shape
the caption wanted in the first place. And a leftover event -- one whose
instance is not on screen -- was filed at the bottom of the page under
Judgement, so a stray Run event was being grouped as a conclusion. Each
domain's flat lane now sits in its OWN zone, which required the claim pass to
finish before any flat lane is emitted rather than interleaved with it.

Comments: forty lines out of `scrubber.js`, and the ones that stayed are
shorter. Several had drifted into describing what the code USED to be -- the
pill that preceded the packed squares, the slider that preceded the focusable
chart, the label gate that tested count instead of density. That is what the
git log is for. A comment earns its place by saying why the CURRENT shape is
what it is, and a reader should not have to reconstruct two dead designs to
reach the live one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Routine events were drawn in `--cs-ink`, the same near-white as a heading.
Marks wear a mark colour and text wears text colours, and breaking that made
every ordinary event as loud as an alarm, leaving the severity tiers nothing
to escalate FROM. They now use `--cs-mark`, and tracks use a dimmer step of
the same hue so a track and the events on it read as one thing.

The hue is measured, not picked. Against everything on screen at once
(validate_palette, dark, surface #10141b): normal-vision floor 18.0 against
the permit green, worst CVD pair 7.5 deutan between that green ribbon and a
red mark, which are a bar and a square on different rows. #3f9dc9, the hue
the prototype used, FAILS that floor at 14.9 against the same green -- close
enough to be hard to separate with full colour vision, on adjacent rows.

Two checks the validator flags stay flagged on purpose. The lightness band
assumes a categorical palette where every series carries equal weight; this
is a status ramp, where routine must be quieter than critical, and the tool
scopes itself to categorical in its own output. The 7.5 deutan pair is red
against green, inherent to those two, and separated here by position, by
shape, and by one being a continuous ribbon.

Squares go 9 to 6 and rows another tenth shorter, which is what makes the
whole execution tree, the participants and the ribbon fit in the space the
tree alone used. An over-cap bar is now taller than a square rather than the
same height, so a summary of ten is tellable from one long event.

The mark-family checks stop pinning the literal 9 and derive the size from
the DOM instead, so the invariant is "every event is the same square" rather
than "every event is nine units". The overlap check had that literal baked in
and reported 36 collisions that were not there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A flat lane's label was 11px sans while every track beside it wore small mono
caps, so the gutter read as two columns from two designs. A flat lane has no
instance to name -- the domain IS its identity -- so one word in the tracks'
own KIND voice is the whole label, and the qualifier that used to be
parenthesised is now a middot the same way the zone captions do it.

Also: the preview's most recent hour is now always busy. The backlog keeps
its diurnal shape, which is right for a day of record, but the live window is
what every check and every glance actually reads, and a fixture that goes
quiet overnight makes those pass or fail by the clock rather than by the
code. Found at 03:49, when the packing check reported "no pack of three to
hover" as a failure of the renderer.

The z-order probe had the same shape of flaw from the other direction:
elementFromPoint returns null outside the viewport, and reading that null as
"nothing covers this edge" turned a scroll position into a reported z-order
bug. It now probes every edge start that is genuinely on screen and says so
when none is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Twelve of the record's forty-two aggregate types were mapped to a domain and
the other thirty fell into "Other". "Other" sat under the last zone heading,
so a Mount, an Allocation and a Visit were all being drawn as JUDGEMENTS,
which is the exact category error the zones were introduced to prevent. All
forty-two are filed now, `other` has no zone at all and renders under no
caption, and Clearance moves out of Judgement: it carries `valid_from` and
`valid_until`, so it is cover over a RANGE and belongs beside the permit as a
ribbon. Drawn as dots you learn that somebody touched a clearance, never
whether it was in force while a run was going.

Mapping a type costs nothing at rest. A domain with no events in the buffer
emits no lane, so the quiet thirty are invisible until the day they are not.

The producer sends `valid_from`, `registered_at` and `status` on a clearance
row to make the bar drawable. `title` and `last_status_reason` stay off the
wire: the reason is free text written at the moment of an incident, which is
the shape that carries incidental personal data, and `template_code` names the
clearance without either.

Two field-drop bugs on the way in, the same class both times. `flowing.snapshot`
was a copy naming four lists, so clearances reached the wire and the tables and
still never reached the chart; the message IS the snapshot and is now kept
whole, which cannot drop a field that is on it. Its pre-first-paint stub named
the same four, so every list is read through one accessor rather than off the
object, and the stub is gone.

Also in this change:

Every row that holds marks gets the same rail. A track drew only its lifetime
bar, so an execution row with a short bar had nothing to read its marks along
while every flat lane did, and the two halves looked like two charts.

Pinning one square in a pack now also haloes the pack. The ring answers "which
event" and a square is six units wide, so on its own it is a speck in the
middle of a bar; the halo answers "out of what". An over-cap bar carries its
whole group rather than its first member, so clicking a bar of ninety
enumerates them in the card instead of reporting one and dropping the rest.

Instance names recede. At 10px near-white they were the loudest thing on the
chart, so a gutter of names outshouted the events they were there to label.

Zone captions lose their explanations. A rule worth reading once is then four
lines of standing text competing with the rows underneath it.

Two bugs the shortened labels surfaced. A lane strips its own noun off the
front of an event type, and it stemmed the LABEL: "Calibs" leaves "Calib",
which matches the front of "CalibrationRecorded" and cut it to
"rationRecorded". The document now carries the aggregate's real noun, and the
strip refuses any cut that does not leave a word. Separately the label-width
estimate ran under the widest real advance on the chart, which clamps a label
to an edge it then overhangs.

A label straddling the plot edge is clamped in rather than left half eaten by
the clip. Wholly outside stays outside.

The readout listed zone captions as rows ("Execution -- no reading yet") and
read a track's event points through the numeric branch, so every run,
procedure, subject and ribbon reported "undefined @".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An operator-action ring and a causal arrow are drawn in the same pass and want
opposite sides of the square. An arrow's tail has to pass BEHIND the mark it
leaves, which is why the edge layer is the first child of the plot; a ring is
a highlight ON a mark, and sharing that layer left every ring chopped by the
squares packed either side of the one it was circling. Rings now go to their
own layer appended after every mark.

Document order is the check, not a pixel probe: pointer-events are off for
decoration, so elementFromPoint can never return a ring, and in SVG document
order IS the z-order. The check is proved by putting the ring back in the edge
layer, which fails it.

The edge z-order probe beside it was flaky, and for a reason worth naming: it
already refuses to read an off-screen null as a z-order failure, so depending
on where the pin landed it reported either a real result or "nothing to
probe". It now scrolls the pinned mark into view first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pinning an event dropped 250 squares to a whisper and left every lifetime bar
at full strength, so the loudest thing on screen became the part that is not
the story: a permit ribbon is saturated green and runs the whole width of the
chart. The comment beside the label dimming already said why this is wrong --
"a label must recede with its own mark" -- and the bars were the same
oversight one level up.

A bar stays lit when its lane holds something in the traced chain or in the
pinned event's correlation, which also gives the tree its answer to "where did
this happen" for free: what is left bright is the row the story is on.

One carve-out. A standing bad or unsettled condition never recedes. Every
other bar is context you can put down while you read something else; "the
hutch is not permitted" is not, and it is the one dim that could cost
something.

Bars recede less far than marks (0.24 against 0.14): at a mark's opacity a
lifetime spanning the whole chart erases the skeleton, and the shape of the
tree is what tells you where the lit part sits.

The carve-out has its own check, because it needed one. Removing it -- dimming
a NotPermitted ribbon along with everything else -- passed every other
assertion in the file, including the one that counts how many bars dimmed and
the one that demands a lit bar have earned it.

The z-order probe in the same file was still deciding by scroll position, so
its window is now tall enough to hold the whole chart. elementFromPoint only
answers inside the viewport, and a chart taller than the window turns "is this
edge behind its mark" into "was this row above the fold".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Opacity already means one thing on this chart: something on this row is in the
causal chain you are tracing. Lighting a row because the projection binds it
to the pinned event's row would make opacity mean two things with no way left
to tell which, so kinship gets a channel of its own -- a tick in the gutter at
the row's left edge, and a line in the card naming the row and the relation.
A bound row keeps whatever brightness the chain gave it.

Which relations, and why not the others. Only what the read models actually
carry is drawn:

  run <-> subject       `subject_id`, already on the wire, and the only
                        cross-zone relation the chart had no way to show
  dataset <-> run       `producing_run_id`, already on the wire
  dataset <-> subject   `subject_id`, added here, and the one that answers
                        what a dataset came from when its run has closed
  clearance <-> run,
    procedure, subject  the binding ids, added here. A clearance drawn as a
                        bar over a range says only that cover existed; these
                        say whether it reaches the run on screen, which is the
                        question anyone looking at one is actually asking

Three a viewer would reasonably expect are absent because the record does not
hold them, and inferring one from co-occurrence in time would be the chart
asserting a fact CORA never recorded. `EnclosureSummaryItem` has no run and a
run has no enclosure, so a permit and a run share a clock and nothing else.
`DecisionSummaryItem` carries `decided_by` and `parent_id`, neither of which
is a run or a subject. Cautions are not in the snapshot at all.

Containment is absent too, deliberately: campaign holds run holds procedure is
already drawn by nesting and by bar thickness, and a tick repeating it would
spend the channel on the one thing the layout already answers.

Kinship is keyed by STREAM, not by lane, and a point carries its own where it
has one. A run has a row of its own and a dataset does not, so a dataset's
bindings have to hang off its single event on a lane it shares with hundreds
of others -- marking the whole DATASETS lane because one member is bound would
be a claim about the row that is not true. The two directions are recorded
independently for the same reason: a dataset event can tick the run that wrote
it, and that run cannot tick back at a row which does not exist.

`asset_binding_ids` stays off the wire. Nothing on this page draws an asset,
so those ids would resolve to nothing.

The new suite pins one event of each shape and reads back both the ticks and
the card, including the two negatives: a caution and an unknown stream type
tick nothing, because the record binds them to nothing. Absent data must not
read as a positive finding. Proved by removing the run/subject bind, which
fails three of its assertions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Governance goes to the top and the four zones now read as a sequence rather
than a set: cover and a permit have to hold before a sample can be mounted, a
sample has to be mounted before a run can measure it, and a run has to have
run before there is anything to conclude. Reading down the chart is reading
the order in which things have to become true.

It also puts the two short, slow-changing blocks above the tall one. The
permit ribbon used to sit below a tree that can reach twenty-six rows, so the
one thing an operator glances at was the one thing they had to scroll for --
and because the row cap is spent in zone order, a busy tree could crowd the
ribbon off the chart entirely. The order is one array now, so changing it is
one line rather than moving four blocks of code.

The good tone drops from 70% to 32%. Measured, not eyeballed: at 70% a good
bar composited to luminance 0.193 where a plain bar of the same depth renders
at 0.083, so the quietest state on the page carried more than twice the weight
of everything around it. Colour spent on "nothing is wrong" is colour the
alarm tiers no longer have anything to escalate from. At 32% it lands on 0.056
and sits inside the family of plain bars, still unmistakably green, while a
bad segment stays at 0.281.

Chroma turned out not to be the problem: at these alphas the green's OKLab
chroma is 6.8 against a plain bar's 5.9. It was luminance the whole way, and
one asymmetry -- a depth-0 bar is additionally reduced to 70%, and the tone
classes override `fill` outright, so a good bar was never getting that
reduction its plain neighbours all had.

Marks on a bar get a surface-coloured ring. Blue on the old green was a
contrast ratio of 1.15, which is not a mark on a bar, it is one colour: every
event on a subject, a clearance or a ribbon row was invisible. The ring makes
the ratio a property of mark-against-ring, so it holds whatever the bar
underneath is doing, and it is only applied on tracks -- on open chart it
would just thicken every square against its own background.

The new checks read both fills back out of the DOM and compare them, so the
invariant is "good must not outweigh plain" rather than a hex value anyone can
retune out from under it. One of them was green for the wrong reason first:
getComputedStyle returns `rgb(51, 85, 111)` in 0-255 for a plain fill and
`color(srgb 0.24 0.72 0.52 / 0.32)` in 0-1 for anything built with color-mix,
and reading both as 0-255 computed the good bar at 0.005 and passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gutter is the one part of the chart that names things without explaining
them. A KIND is a four-letter abbreviation, a zone caption is one word, and a
flat lane's label says nothing about which of the record's forty-two stream
types land on it. Hovering any row in it now opens a card.

The same card as an event's, deliberately. A page with two tooltip designs has
the reader learning two. What differs is that this one describes a ROW rather
than an instant, so it leads with a sentence about the shape and never carries
a clock.

  a zone       what the shape means, why it sits at that height ("1 of 4,
               each a precondition for the next"), and the domains it groups
  a track      the KIND spelled out, its own facts (a run's status, a
               procedure's phase kind and iteration count, a clearance's risk
               band and expiry), when the bar begins and ends, how many events
               are on it, and what the record binds it to
  a flat lane  the stream types it collects, read off the routing table
               rather than written out again so the two cannot drift, plus
               why it has no row per instance

The hit target is the whole gutter cell, not the text: a 9px label is a poor
thing to have to aim at. Hovering it opens the card and nothing else -- no
trace, no rebuild, no pin -- and a pin still wins, because releasing someone's
pin to explain a row heading would be the tooltip taking work away from them.

Two things that had to be got right. The gutter target is created BEFORE the
zone branch returns, or the one row whose label is a single word would be the
one row with no explanation. And a flat lane's "records" list is inverted from
`STREAM_TYPE_TO_DOMAIN` at load, so "what counts as Equipment" is answered
with the list the page actually routes by.

The new suite hovers every row in the gutter and reads back what each card
says, then asserts the properties that make them worth having rather than
their wording: every row has one, every card carries a sentence and at least
three facts, no card echoes a four-letter code back at the reader, every zone
defends its position, and none of them prints an undefined. Proved by putting
the zone captions back behind the early return, which fails it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rewind is a way IN to a second view, not a section of this one, so it belongs
beside the title rather than at the bottom of a stack of tables. Its stage
moves up with it: picking a run from a control at the top used to change
something eight tables below, off screen. The stage collapses to nothing when
empty, so the page looks exactly as it did until someone picks.

The preview had never rendered this view once. The picker fills from a
`run_history_index` message the stub never sent, and the history itself comes
from a fetch the stub answered with `{runs: []}`, so the second of the page's
two timelines was a permanently empty box that looked like an empty box is
supposed to. Its control could have been moved anywhere, or the view broken
outright, with nothing to see. The stub now sends the index and answers
`/run-history/<id>` with a closed run: five lifecycle events and two
observation channels, which is the shape REWIND exists for -- a fold cursor
rather than a live edge, and something to fold.

That makes the new suite possible at all. It checks the control is on the
title's row and to its right, that the picker offers runs rather than only its
placeholder (the state this view was stuck in, and indistinguishable from a
working one), that picking mounts a history above the live chart, and that
back-to-live empties it. It also pins what makes a closed history different
from a live window, since both go through one renderer: a slider role rather
than a group, a fold cursor and no LIVE rule, and a readout that says folded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All seven tables go. Four were pure duplication of rows the chart already
draws, and one of those four was worse than duplication: the Enclosures table
said "no active enclosures" while the chart drew a permit ribbon for the same
hutch, because the two read different messages and only one of them was ever
populated. Two surfaces answering "which hutches are there" from two sources
is the kind of disagreement that gets believed selectively.

The other three carried something the chart did not, so it does now:

  run progress    on the run's card, one line per channel, "812 / 1500, 54%".
                  Not the bar the table drew: every bar on this chart is a
                  span of TIME, and a second bar meaning a fraction of work
                  would be read as the first.
  datasets        the instance list on the DATASETS lane's card
  decisions       the same, on DECISIONS

Both lists are bounded by something the producer already caps -- datasets by
the open-run count, decisions by the tail's own ring -- so neither can run
away. No other domain ships instances, and inventing a list for one that does
not would be worse than the marks alone.

TEMPLATES. A Plan has no lifetime during a shift, so it can never be a row: a
row per template is a page of empty bars, which is why Plan, Recipe, Method,
Practice and Capability were left out of the zones in the first place. The
useful direction is the other one, which run is an instance of which template,
and that is an attribute of the run. The producer now drains plan names once
per tick (small, static, and `ListPlans` has no id filter, so N lookups for a
handful of open runs would be more queries for the same rows) and a run's card
says what it is executing. Absent reads as unknown, never as a run with no
plan, because every run has one.

Dropping the Enclosures table took the only way into an enclosure's rewind
view with it, so the picker now offers both subjects in two groups. Its values
are prefixed `run:` / `enclosure:` rather than guessed from the id's shape:
both are uuids, and choosing which of two views to open by inspecting an
opaque identifier works right up until one collides.

Three fixture gaps closed, all the same shape. The preview had `enclosures:
[]` next to an enclosure timeline, so the contradiction above was invisible
here; no decisions at all; and no `plan_name` on any run. It now carries both
runs with a plan and one without.

Two checks were passing on an empty set. Every assertion in the gutter-card
suite ranges over the cards it found, and `[].every()` is true, so on a page
that rendered no gutter at all they were green -- which is exactly what
happened when `datasetLines` read `snap.datasets` on the first paint, before
any snapshot has arrived, and threw.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The depth was a constant of 4, applied in both directions and quoted at the
viewer in a sentence nothing could change. It is now a control in the chrome,
1 to 6, defaulting to 2: one hop answers "why did this happen and what did it
set off", which is the question a pin usually is, and everything past that is
a follow-up worth asking for rather than paying for on every click.

The dial is CAUSATION ONLY, and the label says so. Correlation is a flat set:
every member is one originating command from every other by construction, so
there is no depth in it to walk. A single control over both would have put a
shape on the record that the record does not have.

The two directions are not symmetric, and the asymmetry is the record's.
`causation_id` is one scalar column, so an event has exactly one cause and
upstream can only ever be a thread; downstream is the inverted multimap, where
one event waking three subscribers gives one node three children. Raising the
dial lengthens the story backwards and widens it forwards. Nothing in the walk
changed for this: it was already a frontier, not a pointer chase.

TONE NOW CARRIES DISTANCE. Marks were binary, in the chain at full or out of
it at 0.14, which threw away the one thing a multi-hop trace has to say. They
ramp 1 / 0.84 / 0.7 / 0.58 / 0.48, with edges taking the same ramp on
`stroke-opacity` so an arrow and the square it lands on agree about how far
out they are. The floor sits more than three times above the dimmed level on
purpose: the far end of a chain and an event outside it entirely are opposite
answers, and a ramp running down into the dim level would make the deepest hop
unreadable exactly when the dial is turned up to look at it.

Which forced correlation off the tone channel. Both channels used to sit at
full opacity, ambiguous but harmless; once tone means hop distance, a
correlated bystander left at full reads as the pinned event itself. It gets a
hollow mark instead: a form, which the ramp cannot collide with.

Lit marks are also per CELL now rather than per cluster. A pack of six can
hold one member of the chain and five bystanders, and lighting all six because
one qualifies overstated the trace by five events.

Two things the readout now says that it could not before: a cut names the
depth actually walked, not the ceiling ("beyond 6 steps" under a dial set to 1
is not a rounding error, it is false), and a fork is called out where one
happened. Only where it happened: "branches: no" on every pin would train the
reader to skip the row on the one pin where a single event woke four
subscribers, which is the entire reason to raise the dial.

THE FIXTURE COULD NOT PRODUCE A FORK. The reaction tail reassigned `cause = re`
on every hop, so it could only ever emit a thread. No node in it has ever had
two children, which means the branching walk -- the reason downstream is a
frontier at all -- had never once been exercised by the fixture written to
exercise it, and every branch assertion made against it would have passed on
nothing. It now deepens or hangs a sibling off the same cause at each hop, and
both shapes have to occur: a fixture that only forks would test depth no
better than one that only threaded tested branching. 39 forking nodes, widest
3.

New depth.mjs, 16 checks, and every one of them mutation-proved. Two did not
survive that: "each hop has its own tone" passed with every mark collapsed
onto one hop class (a set of one distinct tone has size one and equals a ramp
of length one), and the cut-sentence check read whichever of the readout and
the card came first in the DOM, so quoting the ceiling in `chainRows` left the
tooltip correct and the check green. Both surfaces write that sentence from
separate functions and are now asserted separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The card drew one hop up and a bare count down, while the chart behind it drew
four hops of both. A count says a chain is long; it does not say what
happened, and "set off 5 events" is the same shape of answer as the "685
events" a lane label already refuses to be.

Both halves are now named, and they are named DIFFERENTLY, because they are
different shapes:

  up    a flat, ordered list, oldest first, so the card reads forwards the
        way the story happened. `causation_id` is one scalar column, so this
        walk cannot branch and there is no structure to show. Indenting it
        would be decoration, and decoration that looks like the tree's real
        indentation is worse than none.

  down  an indented tree. Flattening it to a list would put two siblings in an
        order the record does not state -- the same claim already refused for
        the correlation set, made in text instead of in edges and no more true
        for being quieter. Two events woken by one cause are concurrent, and
        the only honest rendering of that is that neither is drawn under the
        other. Rows at one indent came out of one cause; that is the whole
        content of the indentation.

Both are bounded by the dial, since both walk `focus.dist`. The tree is
additionally capped at twelve rendered rows and declares what it dropped: a
fan of three at six hops can reach several hundred descendants, and a card is
a hover surface. Rows carry the same two hues as the arrows, so a line in the
card and the edge it stands for are recognisably the same relation.

THE FIXTURE COULD NOT REACH THE CAP. Its tails run two to five reactions, so a
cap of twelve was unreachable and "12 + 0 = 12" reconciled trivially forever.
Lengthening the cascade does not fix it either: at a 42% sibling rate a long
tail is still a near-thread. What was missing is the WIDE shape -- one event
waking a whole rank of subscribers, each waking one or two more -- which is
both the ordinary shape of a subscriber graph and the only one that overflows
a row cap. With it the check runs against 18 descendants, 12 listed, 6
declared.

Nine mutants, all caught, two only after a repair. The card-height check was
too blunt to notice the block losing its `max-height`: the card grew by about
eighty pixels and still cleared the bar, so the assertion moved onto the
block's own clip box and its scroll extent, which is what the cap is actually
for. And two detail strings indexed into an empty list, so a real regression
crashed the suite at the first check that found it and hid every check below.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tone ramp collapsed hops 4, 5 and 6 onto one value, so past hop 3 the
whole tail said "far" and nothing else while the dial went on offering six.
That passed until the wide fan landed: nothing in the fixture had ever reached
hop 4, so a check reading "each hop has its own tone" was ranging over four
hops and agreeing with itself.

Seven steps now, spaced by PERCEPTION and not by even alpha. Composited over
the surface they land 5.9, 5.1, 4.7, 4.3, 4.4 L* apart, where even alpha would
not: the same alpha delta is worth far more L* near the top of the range than
near the bottom. The floor moves 0.48 -> 0.40, which is L* 30 against the
dimmed level's 13.

The check that missed this now measures the composited pixel rather than the
declared alpha. Distinct opacity NUMBERS are not a distinct ramp -- 0.481 and
0.480 differ and are the same colour -- and the claim being made is that a
reader can see which hop a mark is on. It also asserts the ramp is evenly
spaced rather than steep then flat, which even-alpha spacing is.

THE FIXTURE'S WIDE FAN MOVED WITH THE WALL CLOCK. At a 10% chance per burst,
whether one landed inside the window the flowing chart is looking at depended
on the time of day: the card's row-cap check found an 18-node subtree in the
afternoon and reported "none found" an hour later. The fixture already carries
this exact lesson for the burst RATE, in a comment about 3am. It applies to
any shape a check requires, not just to the rate, so there is one guaranteed
fan four minutes before now and the probabilistic one stays for variety.

And the mutation harnesses can no longer corrupt what they test. A harness
edits tracked files in place and puts them back, so two running at once leaves
one file mutated -- which happened, twice, from my own overlapping runs. The
second-order failure is the dangerous one: the next run reads the MUTATED file
as its baseline and restores to that, cementing the leak silently, with the
code wrong and every check still written against the right code. Both
harnesses now refuse to start against a tree that differs from HEAD, and
verify each restore before moving on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  apps/api/src/cora/api
  _status_push.py
  apps/api/src/cora/infrastructure/adapters
  in_memory_event_activity_trail.py
  postgres_event_activity_trail.py
  apps/api/src/cora/infrastructure/ports
  event_activity_trail.py
Project Total  

This report was generated by python-coverage-comment-action

@xmap
xmap merged commit 14c6949 into main Sep 1, 2026
19 checks passed
@xmap
xmap deleted the worktree-status-relay-legibility branch September 1, 2026 16:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant