Skip to content

Origin decoding: show which frontend action started each chain - #43

Open
robnewton wants to merge 18 commits into
mainfrom
feat/origin-id-codec
Open

Origin decoding: show which frontend action started each chain#43
robnewton wants to merge 18 commits into
mainfrom
feat/origin-id-codec

Conversation

@robnewton

@robnewton robnewton commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

What this adds

A way to label each chain of invocations with the frontend action that started it, so observability and the flow console can show "a dealer created a move" next to the database write instead of just the write.

Two parts:

  • The originDecoder plugin, which reads an id the frontend sends and turns it into a small object for display.
  • Console rendering: a node on the flow canvas showing that action, who did it, and when, plus grouping of chains that came from the same click.

How it works

A frontend sends a short id for the action a user took (say, "dealer creates a move") as the x-b3-traceid header on its Hasura mutation. With Hasura's OpenTelemetry config on, that id arrives in the event payload at event.trace_context.trace_id, and the Hasura source puts it on meta.sourceTraceId.

That id is only for display. It is never the chain's id. Every chain gets its own correlation id (the id that ties a chain of invocations together) minted fresh at the start, whether or not an id came in. This matters for two reasons:

  • Frontends send the same id for the same action every time, which is what makes it worth decoding. If that id became the chain id, every unrelated run of that action would collapse into one chain.
  • A client should not get to pick a chain's id. It can send any id it wants.

Using it

You write a decode function that turns the incoming id into an object to show, and register the plugin with it. decode is required; eventkit does not ship one, because the meaning of your ids is yours.

The usual way to write decode is a list of the actions your frontends can start, kept in your own code and shared by the frontend that sends the id and the function that decodes it:

export const ORIGIN_ACTIONS = [
  { id: 'act-move-create', action: 'move.create', site: 'dealer-portal', purpose: 'dealer creates a move' },
  // add new ones to the end; never reuse or change an existing id
] as const;
const BY_ID = new Map(ORIGIN_ACTIONS.map(a => [a.id, a]));
export const decodeOrigin = (id: string) => BY_ID.get(id) ?? null;
const kit = createEventKit(hasuraEvent())
  .use(originDecoder, { decode: decodeOrigin })
  .registerEvents([/* ... */]);

decode returns any JSON object to show, or null for an id it does not know (then the plugin does nothing). The object lands at context_data.origin on the first invocation of the chain. Only the first invocation gets one: later steps carry a Hasura-generated trace id, not your action id, so decode returns null for them.

The docs cover the list pattern, a pack-it-into-the-id alternative, sending the header from the frontend, and the safety notes.

The console

When the first invocation of a chain has a context_data.origin, the flow canvas draws a node to its left showing the action, the person who did it (name and role), and when. It carries the ORIGIN marker, since the click started the chain, not the row write. Click it for a drawer with the full decoded object and a link back to the invocation it started. If you give the console a userResolver(email) that returns a name and avatar, it shows those; otherwise it shows the email and initials.

If the decoded object has a clickId (an id the frontend can send that is the same for every chain one click set off), the console groups those chains: one action node stands in front of all the chains that click started, with an edge into each, a small "N chains" count on the card, and a drawer section listing them so you can jump between them. Chains without a clickId each get their own node. The clickId is display-only grouping, like the rest of the object, and each chain keeps its own correlation id.

Options

  • originDecoder({ decode }): decode is required. Your function from the incoming id to an object, or null.
  • correlationFromTraceId on the hasuraEvent source, default off. Off keeps the trace id out of the chain id (the safe default above). Turn it on only for a trusted caller that sends a real, unique trace id per request that you want to be the chain id.

Try it locally

  1. In console/, run node db/local-setup.mjs then npm run seed. The seed puts an origin on some root invocations.
  2. Run npm run dev, open a root invocation's flow, and the action node sits to its left. When two roots share a clickId, one node fans into both chains and its drawer lists them.

Checks

Tests, typecheck, typecheck:contracts, build, bundle check, and docs compile all pass, plus the console app build and library build.

Related

Works with sdk PR #662 (Hasura sends the x-b3-traceid through to trace_context.trace_id) and sdk PRs #663 and #664 (the frontend sending the header, and the shared list of actions).

Chain usability round

A second scope on this branch makes the CHAIN (all invocations sharing a correlation id) the primary object in observability and the console. Origin is optional decoration on top: header-less and non-frontend chains get the same chain and health treatment.

Writer and schema

Sources expose meta.correlationMinted, true when an invocation minted its correlation id and false when it inherited one (loop-guard clears it when it adopts an inbound token's correlation). The observability writer reads the settled flag to set is_chain_root truthfully. Root is never inferred from timestamps and does not depend on a decoded origin.

The writer also promotes context_data.origin.action to an indexed origin_action, and on every member write rolls a summary onto the chain's root row: chain_last_activity_at (greatest-wins) and chain_status (flips to errored when any member errored, never back). The rollup is best-effort and never blocks event handling, so a chain's root can read errored even though the root invocation itself completed, because a downstream member failed.

New invocations columns (origin_action, is_chain_root, chain_status, chain_last_activity_at) and their indexes are in console/db/schema.sql. Existing databases apply console/db/schema-delta-chain-rollup.sql before deploying the writer (it lists the columns in its invocation upsert).

Console

  • Chains index: the Invocations screen defaults to one row per chain, queried as plain root rows with no group-by, sorted by last activity. Rows show the origin chip when present (or the root event otherwise), user, chain status (row color follows the chain, so a root that itself succeeded under an errored chain reads errored), last activity, and per-page invocation count and span. A persistent errors-only toggle (?errors=1) and a per-action filter (?action=) are URL params, and a view toggle keeps the flat all-invocations table.
  • Permalink: the flow page accepts ?correlationId= alongside ?invocationId=, resolving the chain root and rendering the same diagram. This is the stable deep link external tools point at.
  • Search: resolves a full correlation id, a bare invocation UUID, or a fuzzy fragment to the right flow, plus an origin-action typeahead group.
  • Dashboard: leads with an errored-chain count and trend against the previous window, a needs-attention feed of recent errored chains (each one click from its flow), and a top-origin-actions table with per-action errored rate; the existing charts move below.

Docs: docs/chain-observability.md. The new GraphQL operations are hand-scoped into console/src/types/generated.ts (the console schema drifts, so a blind regen is unsafe).

Verified

Root typecheck and 333 backend tests pass. Console builds. Verified headless (puppeteer + local Chrome) against the seeded local obs DB: chains default view, errors-only view, per-action URL filter, reordered dashboard, and a ?correlationId= permalink that renders a chain whose root completed but whose child failed as errored.

Origin node generalized

A decoded origin is now any registered entry point, not only a human click. The console derives the origin node's kind label from the decoded object's optional trigger field (user → "User action", webhook → "Webhook", schedule → "Scheduled", system → "System", absent/unknown → a neutral "Origin"), through one shared helper used by the node, the detail drawer, and the breadcrumb. The avatar/name row renders only for origins that carry a person; webhook and system origins have no identity, so that row is omitted, and the drawer shows trigger as a fact. Everything still derives from the decoded object. The seed gains a webhook-triggered chain so the non-user card renders in dev. Verified headless: the webhook chain renders a "Webhook" origin card with no avatar row.

What it looks like

Chains index, the default view: one row per chain, with the origin action chip when present, chain status, last activity, and per-chain invocation count and span.

Chains index default view

Errors-only toggle: the persistent, URL-addressable filter to chains that errored anywhere.

Chains index, errors-only

Search resolves origins too: typing an origin action shows an origin-action suggestion group over the indexed column.

Search with the origin-action suggestion group

The dashboard leads with health: an errored-chain count with trend, then the needs-attention feed of recent errored chains.

Health-led dashboard

The flow permalink: opening ?correlationId= resolves the chain root and renders the whole chain. Here the root completed but a downstream member failed, so the chain reads errored.

Flow via correlation permalink

Origins are entry points generally, not only clicks: a webhook-triggered chain renders a "Webhook" origin card with no user row.

Webhook origin card

Expected-flow overlay (per-invocation)

The flow canvas can now overlay what a run was supposed to do on top of what it did do. The host wrapper injects an expectedFlowResolver (same pattern as userResolver); it returns an ExpectedFlowGraph, which is toFlowGraph()'s output plus optional metadata, so a producer (the registry codegen) can emit it as plain JSON with no console-specific fields. Node ids stay name-derived so observed and expected line up.

Toggle it with "Expected flow" in the toolbar or the X hotkey; the choice is remembered in localStorage, and the toggle only appears when a resolver is wired. It is display only, per invocation, and never a pass/fail gate. An invocation with no resolved flow (no resolver, null, or error) renders exactly as today.

  • Expected but not observed steps render as dashed, muted ghost nodes, same shape as the real ones.
  • Observed but not expected steps keep their node and get a small off-contract badge.
  • Matched steps are unchanged.

The user-action drawer also gains an "Expected events" section: when a decoded origin carries expectedEvents, each name is checked against the events observed anywhere in the loaded chain (a check when seen, a muted dashed marker when not).

Contract type and semantics are in docs/console-expected-flow-overlay.md.

Overlay on: the root's observed move.create.requested sits above a dashed ghost for the expected-but-missing move.audit.recorded, and downstream steps the flow doesn't declare carry an off-contract badge.

Expected-flow overlay on

Overlay off: the same chain, identical to today.

Expected-flow overlay off

The user-action drawer's expected-events section: two observed (check), one never observed (dashed miss).

Expected events in the user-action drawer

🤖 Generated with Claude Code

https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW

robnewton and others added 2 commits July 24, 2026 01:29
Adds a pure core codec for a structured 32-hex trace/correlation id and a plugin
that decodes it into observability context_data.

The codec (src/core/origin-id.ts) packs a magic marker, a version, an opaque
minting-surface number (originId, 0-255), an env, and 84 random bits into a 32-hex
string. A frontend mints one and sends it as x-b3-traceid; with Hasura's OTel config
on it becomes the invocation's correlation id. Names for originId numbers are consumer
config, not baked into the id. No timestamp and no user identity on purpose, and the
id is display-only (spoofable).

The originDecoder plugin (src/plugins/origin-decoder) decodes the chain's final
correlation id in configureInvocation (after loop-guard/correlation-resolver recover
it) and injects an origin object into request meta, which observability persists as
context_data.origin. Supports a custom decode function and an originNames map. No-op
on any id it can't decode; inert until registered.

Includes tests for the codec and plugin, a docs page (docs/origin-id.md), README
entries, and a minor changeset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
…node

The Hasura DB-event source no longer adopts the inbound
event.trace_context.trace_id as the chain's correlation id by default. A
client controls that trace id (a browser sends it as x-b3-traceid), so using
it as chain identity let a client dictate the correlation id and, worse,
merged every unrelated chain that reused a static action id into one. The
correlation id is now minted fresh at the root, and the raw trace id is
surfaced on meta.sourceTraceId as a conveyance channel. Set
correlationFromTraceId: true on the source to restore the old behavior.

The origin-decoder plugin now reads meta.sourceTraceId (not the correlation
id) and runs a required, consumer-supplied decode function over it, injecting
the returned object verbatim into request.meta.origin (observability persists
it as context_data.origin). eventkit ships no codec anymore: the packed
origin-id codec in core is removed, and decoding policy (a registry lookup or
a packed-format decoder) lives in the consumer. Only chain roots decode;
downstream hops carry Hasura-minted trace ids the decoder returns null for.

Console: when a chain root has context_data.origin, the flow canvas draws a
synthetic node to the left of the root with an edge into it. The node type is
"userAction" to avoid colliding with the InvocationNode ORIGIN badge (that
marks the chain root itself; this marks the client action that started it). It
renders the conventional action/site/purpose keys prominently, any other keys
as compact key-value lines, plus the root's user/role and event time, and
never crashes on a malformed origin. The seed puts an origin on a slice of
roots (always the first) so the node is demoable without a live frontend.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
@robnewton robnewton changed the title Origin-id codec and origin-decoder plugin Convey the client trace id for origin decoding (not correlation), add a console origin node Jul 24, 2026
robnewton and others added 4 commits July 24, 2026 16:54
The card had only a min-width, and NodeShell's meta line is nowrap, so a long
purpose sentence stretched the node past the 450px column spacing and under
the invocation node. Fixed width at 300px, site alone on the meta line, and
the purpose wraps (clamped to 3 lines) in the body instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
Three upgrades to the user-action node, per review of the live demo:

- Who acted, as a person: an optional host-injected userResolver(email) on the
  console config turns the root's source_user_email into a display profile
  (name + avatar). The console stays schema-agnostic about identity; without a
  resolver it falls back to the raw email and an initials avatar. Resolved
  lazily per distinct email and cached; the dev harness shows the reference
  wiring against a users table on the same endpoint.
- Click-through details: the node now opens a drawer like every other node,
  with the profile card, site/role/when/action-id facts, the purpose, any
  extra decoded keys, the raw origin JSON, and cross-navigation to the chain
  root. NodeRow only renders a status chip when a status is actually passed.
- Correct ORIGIN semantics: when a user action exists, it carries the ORIGIN
  badge instead of the root invocation (the click started the chain, not the
  row write), and the flow breadcrumb marks and labels it accordingly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
The card was a box full of words (purpose sentence, action id, extra keys).
The canvas version now shows only what orients you at a glance: the action
slug as the title (its site prefix says where) and one slim who/when row
(avatar, name, relative time, full timestamp on hover). Purpose, action id,
role, and additional decoded keys live in the detail drawer only. Width drops
to 240px, matching the visual weight of the other nodes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
…node

HopDrive's trace id is becoming PREFIX+SEED, so the decoded origin now carries a
clickId that is identical across every chain one user click caused. Each chain
still keeps its own server-minted correlation id; clickId is a separate,
display-only grouping key (client-supplied, so never chain identity).

When the focused chain's root has an origin clickId, the flow view fetches the
other root chains that share it (SiblingChainsByClick, a jsonb containment query
bounded by created_at and a small limit) and merges them onto the canvas. The
layout now groups roots by clickId and draws ONE user-action node that fans an
edge into every root in the group. Roots without a clickId keep the existing
one-node-per-root behavior.

The node card gains a tiny "N chains" count chip next to the ORIGIN badge when a
click started more than one chain; the drawer carries the detail as a "This click
started N chains" section that lists the sibling roots as cross-navigable rows,
with a note that the grouping is display only.

The generated hook for the new query was hand-scoped into generated.ts (the
console's schema has heavy drift, so a blind regen is not safe); the additions are
exactly what codegen produces for the query, nothing else.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
@robnewton robnewton changed the title Convey the client trace id for origin decoding (not correlation), add a console origin node Origin decoding: show which frontend action started each chain Jul 25, 2026
robnewton and others added 12 commits July 25, 2026 08:32
Describe what origin decoding is and how to use it, without the design history.
Same content, plainer words, no em-dashes: the docs page, the changeset, and the
two README mentions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
…rollup

The unit of debugging is the chain (all invocations sharing a correlation_id).
This makes the chain root and its rollup first-class on the invocation record so
the console can query chains as plain root rows.

Writer side:
- Sources expose meta.correlationMinted: true when the invocation minted its
  correlation id, false when it inherited one. loopGuard clears it when it adopts
  an inbound token's correlation, so token-chained hops read as non-root. Root is
  never inferred from timestamps and is independent of any decoded origin.
- The observability writer stamps is_chain_root from that flag, promotes
  context_data.origin.action to origin_action (string only), and on root rows
  seeds chain_status='ok' and chain_last_activity_at=started_at.
- The graphql sink rolls each member write up onto the chain's root row: one
  indexed UPDATE bumps chain_last_activity_at (greatest-wins), and an errored
  member flips chain_status to 'errored' (never back). Both are best-effort and
  swallow all failures so telemetry never blocks event handling.

Tests cover minted-vs-inherited root flagging, origin_action promotion (and the
non-string skip), the last-activity bump, the error flip, and the best-effort
swallow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
Add origin_action, is_chain_root, chain_status, and chain_last_activity_at to the
canonical schema, with partial indexes on root rows (default order by last
activity, plus a smaller errored-roots index) and an origin_action index. A
schema-delta file carries the same change for existing observability databases,
with a note that it must be applied before deploying the writer that sets these
columns.

The seeder populates all four columns and rolls the chain summary up onto root
rows in a post-COPY pass (last activity = latest member start; chain_status
flips to 'errored' when any member failed). It also generates one deterministic
showcase chain: a completed root carrying an origin_action whose non-root child
errored, so the errors-only chains view, the dashboard needs-attention feed, and
the "root succeeded but chain errored" case always have content.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
Make the chain the primary object across the console.

Chains index (InvocationsTable / new ChainsTable): the default view is now
"Chains" — one row per chain, queried as plain root rows (is_chain_root = true,
no group-by), sorted by last activity. Each row leads with the origin action chip
when present (amber, matching the flow canvas' user-action node) or the root's
event/function otherwise, and shows user, chain status (row color follows the
chain, so a root that itself succeeded under an errored chain reads errored), last
activity, and per-chain invocation count and span fetched for the visible page
only. A persistent errors-only toggle and an origin-action filter are URL params
(?errors=1, ?action=), and a view toggle keeps the flat all-invocations table.
Rows open the chain permalink.

Permalinks: the flow page accepts ?correlationId= alongside ?invocationId=,
resolving the chain root (or earliest member) and rendering the same diagram —
the stable deep-link contract for external links. The search box resolves a full
correlation id, a bare invocation UUID, or a fuzzy fragment straight to the right
flow, and gains an origin-action typeahead group over the indexed column.

Dashboard: leads with health, not volume — an errored-chain count with trend vs
the previous window, a "needs attention" feed of recent errored chains (each one
click from its flow), and a top-origin-actions table with per-action errored
rate; the existing charts move below.

New/extended GraphQL operations are hand-scoped into generated.ts (the console's
schema has heavy drift, so a blind regen is unsafe); the additions are exactly
what codegen emits for the new queries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
Document the chain root definition (minted correlation id), the rollup columns
and the writer invariant, the flow?correlationId= permalink contract, and the
chains index behavior. Note explicitly that origin is optional decoration:
header-less and non-frontend chains get the same chain and health treatment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
A decoded origin is any registered entry point, not only a user action: inbound
webhooks, schedules, and system triggers are origins too. The console now derives
the origin node's kind label from the decoded object's optional `trigger` field
(user → "User action", webhook → "Webhook", schedule → "Scheduled", system →
"System", absent/unknown → a neutral "Origin"), via one shared helper so the node,
the detail drawer, and the breadcrumb all agree. Everything still derives from the
decoded object; eventkit never assumes a specific consumer's shape.

The origin node and drawer only render the avatar/name row when the origin carries
a person (a signed-in user); webhook and system origins have no identity, so that
row is omitted. The drawer shows `trigger` as a fact when present, and keeps the
trust note. The amber tone and layout are unchanged.

The seed gains a webhook-triggered chain (origin with trigger:'webhook', an
integration source, no user) so the non-user card renders in dev, and the existing
user origins now carry trigger:'user'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
Add the chains index, errors-only view, origin-action search, health-led
dashboard, correlation permalink flow, and webhook-origin card screenshots under
docs/images/console/ so the PR can embed them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
Adds a host-injection point so the console can overlay the expected flow for
each invocation without knowing where flows come from (same pattern as
userResolver). The host wrapper injects `expectedFlowResolver({ sourceFunction,
at })` and returns an ExpectedFlowGraph.

The contract type is `toFlowGraph()`'s exact output ({ nodes, edges } in the
FlowNode/FlowEdge vocabulary) plus optional metadata (title, generatedAt, hash),
so a producer like the registry codegen can emit it as plain JSON with no
console-specific fields. Exported from the package barrel.

- useExpectedFlows: resolves one flow per distinct sourceFunction, cached for the
  life of the page like useOriginUser. `at` is passed through to the resolver but
  does not split the cache today.
- expectedFlowToGraph: adapts the wire contract into the console's internal
  FlowGraph (adds the display label; ids carry through unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
… section

Adds a resolver-driven overlay on the flow canvas. For each invocation, its
source_function resolves to an expected graph, compared against that
invocation's own observed records with the existing compareFlow matcher and the
shared name-derived node ids:

- expected-but-missing steps render as ghost nodes (dashed, muted, same shell as
  the real nodes) attached under the invocation, tooltip "expected by the flow
  definition, not observed".
- observed-but-unexpected steps get a small off-contract badge on the existing
  node (no new node).
- matched steps render unchanged.
- an invocation with no resolved flow (no resolver, null, or error) renders
  exactly as today. Absence of a flow is never a judgement.

A new "Expected flow" toggle (checkbox + the X hotkey) shows/hides it and is
remembered in localStorage. The toggle only appears when a resolver is wired, so
the generic console is unchanged when none is. Ghost ids are prefixed ghost-flow
so replay hides them like the other overlays' ghosts.

The user-action drawer gains an "Expected events" section: when the decoded
origin carries expectedEvents (string names the cause expects somewhere in the
chain), each is listed with a check (observed anywhere in the loaded chain) or a
muted dashed miss marker. The origin card is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
Wires an example expectedFlowResolver into the standalone dev entry (the same
file the create-eventkit-console template copies). It returns one static flow for
source_function 'event-handlers', derived from the seeded showcase chain's shape
(move.create.requested -> requestVehicleMove) plus one step the seed never
records (move.audit.recorded -> writeMoveAuditLog), so the overlay always has an
expected-but-missing ghost to show in dev.

Adds expectedEvents to the showcase root's decoded origin: two of them are
observed in the chain and one (driver.assigned) never fires, so the user-action
drawer demonstrates both a check and a miss.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
Documents the expectedFlowResolver contract (ExpectedFlowGraph = toFlowGraph
output + optional metadata, name-derived node ids), the per-invocation overlay
semantics (display only, ghost nodes for missing, off-contract badge for
unexpected, no flow means no judgement), and the expectedEvents drawer section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
Overlay on (ghost expected-but-missing node + off-contract badges), overlay off
(identical to today), and the user-action drawer's expected-events section
(check + miss). Captured against the seeded showcase chain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DWPVMqEudqB86CxtHWWVW
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