Rebuild the live status page around one chart, and put the causal trace on a dial - #763
Merged
Conversation
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>
Coverage reportClick to see where and how coverage changed
This report was generated by python-coverage-comment-action |
||||||||||||||||||||||||||||||||||||||||||||||||||||||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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_idis onescalar 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_pushgains plan names, procedure rows, datasetsubject_id, andclearance binding ids. Clearance
title,last_reviewed_byandlast_status_reasonstay OFF the wire: operator free text is the shape thatcarries 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
getBoundingClientRectforgeometry,
elementFromPointfor what is actually clickable through theclip), 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 torange over.
🤖 Generated with Claude Code