Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
5d0e1d8
Collapse a lane's bursts instead of overprinting or silencing them
xmap Aug 31, 2026
8b96779
Send who caused an event, not just that it happened
xmap Aug 31, 2026
fd7edb8
Drag the chart to pan, click an event to pin it, and drop the slider
xmap Aug 31, 2026
d13d31c
Trace what caused an event, on demand and only when asked
xmap Aug 31, 2026
40ecd39
Pan the chart like a carousel, and draw every event as the same pill
xmap Aug 31, 2026
459f21d
Hover an event to see what it caused, and park the fold cursor
xmap Aug 31, 2026
ecad173
Mark the present with one rule, and retain a day instead of a quarter…
xmap Aug 31, 2026
a0189ae
Pack a burst out of squares, so any one event in it can be picked
xmap Aug 31, 2026
c6cda58
Send what places a run among the others, not just that it exists
xmap Aug 31, 2026
bd8576e
Lay the chart out by relation shape, one row per instance
xmap Sep 1, 2026
5f6d61e
Tighten the rhythm, and stop the comments narrating their own history
xmap Sep 1, 2026
d5272ff
Give marks a colour of their own, and shrink them to a mark's size
xmap Sep 1, 2026
172d99f
Put the flat lanes in the same voice as the rest of the gutter
xmap Sep 1, 2026
bf403d6
File the whole record by zone, and give every row a rail
xmap Sep 1, 2026
124af7b
Draw a root ring on top of the marks, not underneath them
xmap Sep 1, 2026
f452092
Let the bars recede with the marks on them
xmap Sep 1, 2026
2ad3ea4
Tick the rows the record binds to the pinned event
xmap Sep 1, 2026
5a9106a
Order the zones by precondition, and stop spending colour on "fine"
xmap Sep 1, 2026
48adbfa
Explain the gutter, in the card the events already use
xmap Sep 1, 2026
08a0867
Move rewind into the page's chrome, and make the preview render it
xmap Sep 1, 2026
d9a8534
Fold the tables into the chart, and name the template a run is running
xmap Sep 1, 2026
2d46d86
Put the causal trace on a dial, and fade each hop
xmap Sep 1, 2026
53e6474
Name the traced chain in the card, a path up and a tree down
xmap Sep 1, 2026
dd7a097
Give every hop its own tone, and stop the wide fan moving with the clock
xmap Sep 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
184 changes: 179 additions & 5 deletions apps/api/src/cora/api/_status_push.py

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions apps/api/src/cora/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1507,7 +1507,9 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
list_subjects=app.state.subject.list_subjects,
list_campaigns=app.state.campaign.list_campaigns,
list_datasets=app.state.data.list_datasets,
list_procedures=app.state.operation.list_procedures,
list_clearances=app.state.safety.list_clearances,
list_plans=app.state.recipe.list_plans,
list_enclosures=app.state.enclosure.list_enclosures,
list_decisions=app.state.decision.list_decisions,
get_run_history=app.state.run.get_run_history,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,34 @@ async def read_since(
self, *, cursor: EventActivityCursor, limit: int
) -> tuple[list[EventActivityRow], EventActivityCursor]:
cursor_key = _cursor_key(cursor)
all_events = self._store.all_events()
newer = sorted(
(e for e in self._store.all_events() if (e.transaction_id, e.position) > cursor_key),
(e for e in all_events if (e.transaction_id, e.position) > cursor_key),
key=lambda e: (e.transaction_id, e.position),
)[:limit]
if not newer:
return [], cursor
# Stands in for the Postgres adapter's LEFT JOIN on `event_id`. Built
# over every event in the store, not just the page being returned: a
# cause is usually older than the page that carries its effect, so
# resolving against `newer` alone would report almost every cause as
# unresolvable and the two adapters would disagree.
occurred_by_event_id = {e.event_id: e.occurred_at for e in all_events}
rows = [
EventActivityRow(
event_id=event.event_id,
stream_type=event.stream_type,
stream_id=event.stream_id,
event_type=event.event_type,
occurred_at=event.occurred_at,
recorded_at=event.recorded_at,
correlation_id=event.correlation_id,
causation_id=event.causation_id,
cause_occurred_at=(
occurred_by_event_id.get(event.causation_id)
if event.causation_id is not None
else None
),
)
for event in newer
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,21 @@
"""

_READ_SINCE_SQL = """
SELECT stream_type, stream_id, event_type, occurred_at, recorded_at,
transaction_id::text AS transaction_id_text, position
FROM events
WHERE (transaction_id, position) > ($1::xid8, $2)
AND transaction_id < pg_snapshot_xmin(pg_current_snapshot())
ORDER BY transaction_id ASC, position ASC
SELECT e.event_id, e.stream_type, e.stream_id, e.event_type, e.occurred_at, e.recorded_at,
e.correlation_id, e.causation_id, cause.occurred_at AS cause_occurred_at,
e.transaction_id::text AS transaction_id_text, e.position
FROM events e
LEFT JOIN events cause ON cause.event_id = e.causation_id
WHERE (e.transaction_id, e.position) > ($1::xid8, $2)
AND e.transaction_id < pg_snapshot_xmin(pg_current_snapshot())
ORDER BY e.transaction_id ASC, e.position ASC
LIMIT $3
"""
"""The LEFT JOIN rides `events_event_id_unique`, so resolving a cause is one
index lookup per row and at most `limit` of them. It is LEFT rather than
INNER because a null `causation_id` is the common case, not an anomaly: every
operator-originated command arrives over REST with no cause, and an INNER
join would silently drop exactly those rows."""

# xid8 0 is Postgres's own invalid-transaction-id sentinel: never assigned
# to a real transaction, so it compares strictly less than any row that
Expand Down Expand Up @@ -65,11 +72,15 @@ async def read_since(
return [], cursor
activity = [
EventActivityRow(
event_id=row["event_id"],
stream_type=row["stream_type"],
stream_id=row["stream_id"],
event_type=row["event_type"],
occurred_at=row["occurred_at"],
recorded_at=row["recorded_at"],
correlation_id=row["correlation_id"],
causation_id=row["causation_id"],
cause_occurred_at=row["cause_occurred_at"],
)
for row in rows
]
Expand Down
37 changes: 30 additions & 7 deletions apps/api/src/cora/infrastructure/ports/event_activity_trail.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,22 @@
promoting a single-consumer port to the shared kernel would be the reverse
of the rule-of-three this codebase applies to new cross-cutting primitives.

Ships `stream_type`, `stream_id`, `event_type`, `occurred_at`, `recorded_at`
only. NEVER `payload`. `test_run_events_carry_no_pii.py` (and its Access-BC
sibling) are the only two fitness tests that guard event field names against
personal data, and they cover exactly two of the twenty-five stream types
this port's data spans; shipping raw payloads across every BC would carry
that guarantee somewhere it does not hold. A lane needs to know THAT
something happened and WHAT KIND, never the values inside it.
Ships `event_id`, `stream_type`, `stream_id`, `event_type`, `occurred_at`,
`recorded_at`, `correlation_id`, `causation_id` and `cause_occurred_at` only.
NEVER `payload`. `test_run_events_carry_no_pii.py` (and its Access-BC sibling) are
the only two fitness tests that guard event field names against personal
data, and they cover exactly two of the twenty-five stream types this port's
data spans; shipping raw payloads across every BC would carry that guarantee
somewhere it does not hold. A lane needs to know THAT something happened and
WHAT KIND, never the values inside it.

The three relationship columns do not weaken that. They are opaque
identifiers and one timestamp drawn from the envelope, never from
`payload`, so no BC's field names ride out on them and the guarantee the two
fitness tests actually make is unchanged. They answer "which events belong to
one operator action" and "which event caused this one", both of which are
structure, not content. Anything requiring a VALUE from inside an event still
has to come from a domain-specific read, not from here.

Cursor discipline mirrors `cora.infrastructure.ports.event_store`'s own
documented rule: `position` alone is unsafe (sequences advance on rollback,
Expand Down Expand Up @@ -51,11 +60,25 @@ class EventActivityCursor:

@dataclass(frozen=True)
class EventActivityRow:
event_id: UUID
"""This event's own identity, and the thing a `causation_id` points AT.
Without it a consumer holds a cause it can never resolve: it can tell that
an event was caused, but not by which of the events it already has."""

stream_type: str
stream_id: UUID
event_type: str
occurred_at: datetime
recorded_at: datetime
correlation_id: UUID
causation_id: UUID | None
cause_occurred_at: datetime | None
"""When the causing event happened, resolved by the query rather than left
to the reader. A consumer holding a bounded window cannot resolve a
`causation_id` older than that window, and without this it can only say
"no cause" for an event that certainly had one. Carrying the time turns an
unresolvable parent into "caused by something at 14:31:30, before this
window" instead of silence. `None` only when `causation_id` is."""


class EventActivityTrail(Protocol):
Expand Down
64 changes: 61 additions & 3 deletions apps/api/tests/integration/test_event_activity_trail_postgres.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ async def _write_event(
stream_type: str = "Run",
stream_id: UUID | None = None,
event_type: str = "RunStarted",
event_id: UUID | None = None,
correlation_id: UUID | None = None,
causation_id: UUID | None = None,
occurred_at: datetime = _NOW,
) -> UUID:
stream_id = stream_id or uuid4()
await store.append(
Expand All @@ -41,12 +45,13 @@ async def _write_event(
0,
[
NewEvent(
event_id=uuid4(),
event_id=event_id or uuid4(),
event_type=event_type,
schema_version=1,
payload={"irrelevant": "never read back by this trail"},
occurred_at=_NOW,
correlation_id=uuid4(),
occurred_at=occurred_at,
correlation_id=correlation_id or uuid4(),
causation_id=causation_id,
principal_id=None,
)
],
Expand Down Expand Up @@ -125,6 +130,59 @@ async def test_read_since_advances_the_cursor_so_a_second_call_sees_nothing_new(
assert second_rows == []


@pytest.mark.integration
async def test_read_since_carries_correlation_and_a_null_cause_for_an_operator_command(
db_pool: asyncpg.Pool,
) -> None:
"""A command arriving over REST has no `causation_id`. That is the common
case, not an anomaly, so the LEFT JOIN must return the row rather than
filter it out the way an INNER join would."""
store = PostgresEventStore(db_pool)
trail = PostgresEventActivityTrail(db_pool)
cursor = await trail.head()
correlation_id = uuid4()

await _write_event(store, correlation_id=correlation_id, causation_id=None)
rows, _cursor = await trail.read_since(cursor=cursor, limit=10)

assert len(rows) == 1
assert rows[0].correlation_id == correlation_id
assert rows[0].causation_id is None
assert rows[0].cause_occurred_at is None


@pytest.mark.integration
async def test_read_since_resolves_the_cause_s_time_even_when_the_cause_is_not_in_the_page(
db_pool: asyncpg.Pool,
) -> None:
"""The join resolves against the whole table, not the page being returned.
A cause is almost always older than the effect that cites it, so a lookup
limited to the current page would report nearly every cause unresolvable
and hand the browser a window it cannot distinguish from "uncaused"."""
store = PostgresEventStore(db_pool)
trail = PostgresEventActivityTrail(db_pool)

cause_event_id = uuid4()
cause_at = datetime(2026, 6, 21, 11, 20, 0, tzinfo=UTC)
await _write_event(store, event_id=cause_event_id, occurred_at=cause_at)

# Baseline AFTER the cause, so the cause is deliberately outside the page.
cursor = await trail.head()
await _write_event(
store,
stream_type="Caution",
event_type="CautionRegistered",
causation_id=cause_event_id,
)

rows, _cursor = await trail.read_since(cursor=cursor, limit=10)

assert len(rows) == 1
assert rows[0].event_type == "CautionRegistered"
assert rows[0].causation_id == cause_event_id
assert rows[0].cause_occurred_at == cause_at


@pytest.mark.integration
async def test_read_since_limit_truncates_and_the_cursor_still_advances(
db_pool: asyncpg.Pool,
Expand Down
Loading
Loading