Stateflow Observatory Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Ship the first Stateflow Observatory reference experience: a deterministic visual graph and replay timeline that demonstrates FoldKit’s experimental Machine API and typed Port boundary without coupling foldkit-viz to FoldKit, Effect, or Astro.
Architecture: Extract the existing request-diagnostics Machine into an app-owned fixture boundary. Add a small plain-data @opsydyn/foldkit-viz/stateflow subpath that lays out state graphs and preserves semantic records. Build a standalone apps/web Stateflow app that replays validated events through the existing Machine, records transition facts, emits redacted telemetry through a Command-owned outbound Port, and renders the graph/timeline with FoldKit HTML.
Tech Stack: Bun workspaces, TypeScript 6, FoldKit 0.161.0 experimental Machine and Port APIs, Effect 4.0.0-rc.115 Schema/Effect, @opsydyn/foldkit-viz, @opsydyn/astro-foldkit, Astro 7.1.1, Vitest, Vanilla Extract, Oxlint, and Oxfmt.
Spec: docs/superpowers/specs/2026-09-19-stateflow-observatory-design.md
Global Constraints
apps/webowns the Machine definition, replay model, trace history, Port schemas, subscriptions, Commands, and payload redaction.foldkit-vizaccepts plain records only and must not importfoldkit,effect,@opsydyn/astro-foldkit, or application types.astro-foldkitremains a lifecycle and island host bridge; this slice adds no Astro runtime API.- Use
machine.stateTags,machine.edges, andmachine.stepas the Machine source of truth; never parse Mermaid output. - Port traffic is an application boundary; internal replay remains
Message -> update -> Model. - Diagnostics Commands returned by the fixture Machine are recorded as metadata by the replay visualizer and are never executed by the synthetic replay loop.
- Outbound telemetry is emitted only from a Command using
Port.emit; the view never emits directly to a Port. - Layout output must be deterministic for identical graph records and dimensions.
- Keep SSR output meaningful without animation; reduced motion must disable pulse animation without hiding state.
- Preserve unrelated dirty work. Stage only files listed by the task being committed.
- Use named FoldKit exports and the repository’s existing
defineMessageUnion,defineTaggedUnion,Subscription,Port, andsvgRootpatterns.
Review Focus
- Malformed replay input: an invalid encoded Port value must be rejected before it can produce an app Message; cover this in the client bridge scene test.
- Ignored transitions: an event that is outside the current state’s applicable edges must leave Machine state unchanged while adding an
Ignoredtrace with its reason. - Command-producing transitions: replaying a transition that returns
FetchMetrics.Interruptmust show the command name in the inspector without executing HTTP or dispatching a diagnostics result. - Graph integrity: unknown edge endpoints, duplicate edge IDs, and guard metadata must not produce a partial or nondeterministic layout.
- Host and accessibility behavior: the page must render a static graph/table projection, expose controls and selection semantics, and preserve existing Astro app-island behavior at SSR and hydration boundaries.
Task 1: Isolate the app-owned diagnostics Machine
Files:
- Create:
apps/web/src/apps/request-diagnostics/machine.ts - Modify:
apps/web/src/apps/request-diagnostics/update.ts - Modify:
apps/web/src/apps/request-diagnostics/machine.test.ts
Interfaces:
-
Produces
diagnosticsMachine: Machine<ExplorerState, Message>from the newmachine.tsmodule. -
Keeps
update.tsconsumingdiagnosticsMachine.step(...)through the same local name and behavior. -
Does not change the public
request-diagnosticsapp Message or Model shapes. -
Step 1: Add characterization assertions for the exported Machine graph.
Extend
machine.test.tswith the exact state and edge facts the observatory will consume:expect(diagnosticsMachine.stateTags).toEqual(['Idle','Loading','Cancelling','Ready','Selecting','Filtered','Failed',]);expect(diagnosticsMachine.edges).toEqual(expect.arrayContaining([expect.objectContaining({ from: 'Loading', messageTag: 'LoadedMetrics', target: 'Ready' }),expect.objectContaining({ from: 'Loading', messageTag: 'Navigated', target: 'Cancelling' }),expect.objectContaining({from: 'Cancelling',messageTag: 'CompletedCancelFetchMetrics',target: 'Idle',}),]),); -
Step 2: Run the characterization test before the refactor.
Run:
bun test apps/web/src/apps/request-diagnostics/machine.test.tsExpected: PASS, establishing the current Machine graph as the refactor contract.
-
Step 3: Move the Machine definition to
machine.ts.Move
cancelling,interruptMetrics, and theMachine.define(...)value out ofupdate.ts. Export onlydiagnosticsMachineand keep the existingFetchMetrics.Interrupt(...)policy unchanged.machine.tsmay importFetchMetrics,Message, andExplorerState; it must not import chart models or view code. -
Step 4: Rewire
update.tsand existing tests.Import
diagnosticsMachinefrom./machineinupdate.ts, remove the moved Machine-only imports, and import the Machine directly from./machineinmachine.test.ts. Do not retain a second Machine definition or duplicate transition table. -
Step 5: Run the focused diagnostics tests.
Run:
bun test apps/web/src/apps/request-diagnostics/machine.test.ts apps/web/src/apps/request-diagnostics/main.story.test.ts apps/web/src/apps/request-diagnostics/main.scene.test.tsExpected: PASS, including route-exit interruption, retained-island navigation, Port delivery, and the graph assertions.
-
Step 6: Commit the isolated Machine boundary.
Terminal window git add apps/web/src/apps/request-diagnostics/machine.ts \apps/web/src/apps/request-diagnostics/update.ts \apps/web/src/apps/request-diagnostics/machine.test.tsgit commit -m "refactor(web): isolate diagnostics machine"
Task 2: Add the pure foldkit-viz/stateflow layout API
Files:
- Create:
packages/foldkit-viz/src/stateflow/index.ts - Create:
packages/foldkit-viz/test/stateflow.test.ts - Modify:
packages/foldkit-viz/tsdown.config.ts - Modify:
packages/foldkit-viz/package.json - Modify:
packages/foldkit-viz/README.md
Interfaces:
-
Produces the public subpath
@opsydyn/foldkit-viz/stateflow. -
Exports
StateFlowNodeRole,StateFlowGuard,StateFlowNode,StateFlowEdge,StateFlowGraph,StateFlowLayoutNode,StateFlowLayoutEdge,StateFlowLayout,StateFlowLayoutOptions, andlayoutStateFlow(graph, options?). -
layoutStateFlowconsumes onlyStateFlowGraphrecords and returns positioned records; it does not accept a Machine or Message. -
Step 1: Write the failing API and integrity tests.
Create
stateflow.test.tswith tests for the intended public contract:const graph = {nodes: [{ id: 'Loading', label: 'Loading', role: 'transient', visitCount: 1 },{ id: 'Ready', label: 'Ready', role: 'active', visitCount: 2 },],edges: [{id: 'Loading:LoadedMetrics:Ready:Unguarded',source: 'Loading',target: 'Ready',event: 'LoadedMetrics',guard: 'unguarded',transitionCount: 1,},],} as const;const first = layoutStateFlow(graph, { width: 640, height: 360, iterations: 80 });const second = layoutStateFlow(graph, { width: 640, height: 360, iterations: 80 });expect(first).toEqual(second);expect(first.nodes.map(({ id }) => id)).toEqual(['Loading', 'Ready']);expect(first.edges).toHaveLength(1);expect(first.edges[0]).toMatchObject({ source: 'Loading', target: 'Ready' });Add negative tests that an edge referring to an unknown node, or two edges sharing an ID, throw clear errors instead of silently dropping or overwriting graph data. Assert that
guardandguardPositionare preserved on the returned edge. -
Step 2: Run the new test to verify the API is absent.
Run:
bun test packages/foldkit-viz/test/stateflow.test.tsExpected: FAIL because the
stateflowmodule andlayoutStateFlowdo not exist yet. -
Step 3: Implement plain Stateflow records and deterministic layout.
In
src/stateflow/index.ts, define:export type StateFlowNodeRole = 'initial' | 'normal' | 'active' | 'error' | 'transient';export type StateFlowGuard = 'unguarded' | 'when' | 'otherwise';export type StateFlowNode = Readonly<{id: string;label: string;role: StateFlowNodeRole;visitCount: number;}>;export type StateFlowEdge = Readonly<{id: string;source: string;target: string;event: string;guard: StateFlowGuard;guardPosition?: number;transitionCount: number;lastSeenAt?: number;}>;export type StateFlowGraph = Readonly<{nodes: ReadonlyArray<StateFlowNode>;edges: ReadonlyArray<StateFlowEdge>;}>;Use
runForceLayoutinternally after validating that every node ID and edge ID is unique and every edge endpoint exists. Map positions back by stable node ID, preserve input order and semantic fields, and attachx1/y1/x2/y2to every returned edge. Do not import any runtime package outside this package. -
Step 4: Add the build/export surface.
Add
stateflow: 'src/stateflow/index.ts'totsdown.config.ts, add matchingexportsandtypesVersionsentries inpackage.json, and document the subpath in the package README. Do not add FoldKit or Effect as a runtime dependency or peer for this entry. -
Step 5: Run package tests and typecheck.
Run:
bun test packages/foldkit-viz/test/stateflow.test.tsExpected: PASS, including deterministic output and invalid-edge rejection.
Run:
bun run --filter @opsydyn/foldkit-viz typecheckExpected: PASS with no new imports from
foldkit,effect, or Astro in the stateflow module. -
Step 6: Commit the pure package API.
Terminal window git add packages/foldkit-viz/src/stateflow/index.ts \packages/foldkit-viz/test/stateflow.test.ts \packages/foldkit-viz/tsdown.config.ts \packages/foldkit-viz/package.json \packages/foldkit-viz/README.mdgit commit -m "feat(viz): add stateflow layout primitives"
Task 3: Build the app-owned replay, trace, and Port boundary
Files:
- Create:
apps/web/src/apps/stateflow/app.ts - Create:
apps/web/src/apps/stateflow/command.ts - Create:
apps/web/src/apps/stateflow/fixture.ts - Create:
apps/web/src/apps/stateflow/graph.ts - Create:
apps/web/src/apps/stateflow/main.ts - Create:
apps/web/src/apps/stateflow/message.ts - Create:
apps/web/src/apps/stateflow/model.ts - Create:
apps/web/src/apps/stateflow/ports.ts - Create:
apps/web/src/apps/stateflow/subscription.ts - Create:
apps/web/src/apps/stateflow/update.ts - Create:
apps/web/src/apps/stateflow/update.test.ts - Create:
apps/web/src/apps/stateflow/graph.test.ts - Create:
apps/web/src/apps/stateflow/main.scene.test.ts - Consume:
apps/web/src/apps/request-diagnostics/machine.ts,apps/web/src/apps/request-diagnostics/message.ts,apps/web/src/apps/request-diagnostics/model.ts
Interfaces:
-
ReplayEventis a validated subset of request-diagnostics Messages suitable for a bounded visual fixture. -
ReplayEventPortis an inboundPortcarryingReplayEventvalues. -
TransitionRecordedis a redacted outbound record withsequence,messageTag,outcome,from, optionaltarget, optionalreason, andcommandNames. -
TransitionTelemetryPortis an outboundPortcarryingTransitionRecorded. -
diagnosticsMachine.step(...)remains the only evaluator of replay transitions. -
graphFor(model)maps the Machine’sstateTags/edgesand model trace counts into the plainStateFlowGraphconsumed bylayoutStateFlow. -
Step 1: Define the typed Port and replay schemas.
In
ports.ts, defineReplayEventwith the exact variants used by the fixture:LoadedMetrics,StartedSelection,ChangedSelection,ClearedSelection,ClickedReload,CompletedCancelFetchMetrics, andNavigated. DefineTransitionRecordedas a finite Schema record with no raw payload field. CreateReplayEventPort = Port.inbound(ReplayEvent)andTransitionTelemetryPort = Port.outbound(TransitionRecorded).In
message.ts, define control factsClickedPlay,ClickedPause,ClickedStep,ClickedReset,AdvancedReplay,SelectedTrace,SelectedNode,ReceivedReplayEvent, andCompletedReportTransition({ sequence: Schema.Number }). UseSchema.Unknownonly forReceivedReplayEventwith a local type override, then cast at the update boundary as required by the repository convention. -
Step 2: Add the failing transition and Port tests.
Add tests that assert:
const first = update(initModel, Message.ReceivedReplayEvent({ event: fixture[0] }));expect(first.model.trace).toHaveLength(1);expect(first.model.trace[0]).toMatchObject({ outcome: 'transitioned' });const ignored = update({ ...first.model, explorer: { _tag: 'Idle' } },Message.ReceivedReplayEvent({ event: fixture[0] }),);expect(ignored.model.explorer).toEqual({ _tag: 'Idle' });expect(ignored.model.trace.at(-1)).toMatchObject({ outcome: 'ignored' });Add a command-producing replay test for
ClickedReload: the returned command list contains onlyReportTransition, the trace containsFetchMetrics.InterruptincommandNames, and no diagnosticsFetchMetricscommand is returned to the Stateflow runtime.Add
graph.test.tsassertions thatgraphFor(model)returns one node perdiagnosticsMachine.stateTags, one edge perdiagnosticsMachine.edges, and the expectedwhen/otherwiseguard labels and positions. -
Step 3: Run the new app tests to verify the runtime boundary is absent.
Run:
bun test apps/web/src/apps/stateflow/update.test.tsExpected: FAIL because the stateflow app modules do not exist yet.
-
Step 4: Implement the model, fixture, and graph adapter.
In
model.ts, define the app-owned model withexplorer,playback: 'paused' | 'playing',replayIndex,trace,selectedSequence,selectedNode, andlastTelemetrySequence: number | null. Keeptraceas a readonly array of normalized transition facts.In
fixture.ts, usesamplePointsand diagnostic Message constructors to create a bounded sequence that demonstratesLoading -> Ready -> Selecting -> Filtered -> Ready -> Cancelling -> Idle. Include the cancel result as an explicit replay event so the visual can show the cancellation path without starting a network request.In
graph.ts, mapdiagnosticsMachine.stateTagsexactly once to nodes anddiagnosticsMachine.edgesexactly once to edges. MapEdgeGuard._tagtounguarded,when, orotherwise; derivevisitCountandtransitionCountfrom trace records; mark the current state asactive,Failedaserror, andLoading/Cancellingastransientunless the current state overrides that role. Build edge IDs from source, message tag, target, and guard position rather than array index. -
Step 5: Implement the Command-owned outbound telemetry and subscriptions.
In
command.ts, defineReportTransitionwithargs: { record: TransitionRecorded },messages: [Message.CompletedReportTransition], and an Effect that callsPort.emit(TransitionTelemetryPort, record)followed byEffect.as(Message.CompletedReportTransition({ sequence: record.sequence })).In
subscription.ts, combinePort.subscription(ReplayEventPort, event => Message.ReceivedReplayEvent({ event }))with anSubscription.animationFrameentry active only whilemodel.playback === 'playing'. The frame entry dispatchesAdvancedReplay; it must stop when the fixture is exhausted or playback is paused. -
Step 6: Implement update and app exports.
runReplayEventmust calldiagnosticsMachine.step(model.explorer, event), normalizeTransitionedorIgnoredinto one trace record, update the selected sequence and replay index, and return only theReportTransitionCommand. It must not returnresult.commandsfrom the diagnostics Machine.ClickedStepadvances one fixture event while paused;ClickedPlay/ClickedPausecontrol playback;ClickedResetrestoresinitModel; selection Messages change only inspector selection;CompletedReportTransition({ sequence })setslastTelemetrySequence.In
main.ts, exportModel,Message,update,view,ports,subscriptions, and aninitthat returns{ model: initModel }. Inapp.ts, use the existinglazyApp(() => import('./main'))convention. Configure bothports.inbound.replayandports.outbound.transitionTelemetry. -
Step 7: Add the client Port bridge scene test.
Mirror the existing request-diagnostics
Runtime.embedscene test. Send a valid replay event throughhandle.ports.replay.send(...), assert aReceivedReplayEventreaches the app, and wait for the resulting outbound transition record throughhandle.ports.transitionTelemetry. Send a malformed encoded value and assert that neither an app Message nor telemetry record is produced. -
Step 8: Run focused web tests and typecheck.
Run:
bun test apps/web/src/apps/stateflow/update.test.ts apps/web/src/apps/stateflow/main.scene.test.tsExpected: PASS, including ignored transitions, command metadata, invalid Port input, and no replacement diagnostics request.
Run:
bun run --filter @opsydyn/web typecheckExpected: PASS after the viz package build and Astro integration build complete.
-
Step 9: Commit the replay and boundary runtime.
Terminal window git add apps/web/src/apps/stateflowgit commit -m "feat(web): add stateflow replay ports"
Task 4: Render the Observatory and expose the Astro example
Files:
- Create:
apps/web/src/apps/stateflow/stateflow.css.ts - Create:
apps/web/src/apps/stateflow/view.ts - Create:
apps/web/src/apps/stateflow/view.test.ts - Create:
apps/web/src/pages/stateflow.astro - Modify:
apps/web/src/apps/stateflow/main.ts - Modify:
packages/astro-foldkit/README.md
Interfaces:
-
The view consumes only the Stateflow app Model and produces FoldKit
DocumentHTML. -
The view uses
layoutStateFlow(graph, { width, height })andsvgRootfor the graph; it does not call Machine APIs or Ports. -
The page mounts
StateflowAppwithclient:loadand does not add a new Astro integration API. -
Step 1: Add view-level interaction tests before rendering.
Create
view.test.tswith assertions for the rendered Document title, accessible graph label, replay controls, current-state label, event table headers, and the selected trace inspector. Use the existing FoldKit HTML test harness rather than querying browser globals fromview.ts. -
Step 2: Implement the deterministic graph and timeline view.
Render the approved composition:
- header with current state, transition count, ignored count, and playback status;
- left rail with Play, Pause, Step, Reset, replay session, inbound Port status, and outbound Port status;
- center SVG graph with state nodes, guard-labelled edges, active/visited/error classes, and a latest-transition pulse class;
- right inspector with message tag, source/target, guard/reason, structured event data, and command names;
- bottom recent-events table with sequence, event, from, outcome, target, and reason.
Use
h.OnClickMessages for controls and graph/timeline selection. Keep labels inside stable dimensions and use semantic text alongside colour. UsesvgRootwith a title and description. Do not introduce a separate view state or direct DOM mutation. -
Step 3: Add restrained responsive styling.
In
stateflow.css.ts, use the approved dark graphite palette with restrained cyan, indigo, amber, and red accents. Define stable grid tracks, a mobile single-column layout, focus-visible controls, and a pulse animation that is disabled underprefers-reduced-motion: reduce. Avoid gradients, decorative blobs, nested cards, and viewport-scaled typography. -
Step 4: Add the Astro route.
Create
stateflow.astrousing the existingLayoutandStateflowApppattern:---import StateflowApp from '../apps/stateflow/app';import Layout from '../layouts/Layout.astro';import * as layoutStyles from '../layouts/layout.css';---<Layout title="Stateflow Observatory — FoldKit Machine and Ports"><h1 class={layoutStyles.heading}>Stateflow Observatory</h1><StateflowApp client:load /></Layout>Use static package imports only. The existing
lazyAppliteral loader is the only app-loader dynamic import allowed by repository convention. -
Step 5: Document the host boundary.
Add a short
packages/astro-foldkit/README.mdsection showing that Astro supplies the island and lifecycle facts while the app owns Machine/Port semantics. Point to/stateflowand explicitly state that this slice does not add an Astro API or move async work into the integration. -
Step 6: Run the view and app tests.
Run:
bun test apps/web/src/apps/stateflowExpected: PASS with accessible static output and control/message assertions.
-
Step 7: Commit the visual route.
Terminal window git add apps/web/src/apps/stateflow apps/web/src/pages/stateflow.astro packages/astro-foldkit/README.mdgit commit -m "feat(web): add stateflow observatory"
Task 5: Document the reusable remote-visual-load pattern
Files:
- Modify:
docs/roadmap.md - Modify:
packages/foldkit-viz/README.md - Modify:
packages/astro-foldkit/README.md
Interfaces:
-
Documentation names the Stateflow Observatory route and the public
@opsydyn/foldkit-viz/stateflowsubpath. -
Documentation gives one consistent pattern for future remote filter, brush, and zoom loads without adding async responsibilities to
foldkit-viz. -
Step 1: Add the roadmap item and boundary notes.
Add Stateflow Observatory under the next rerelease slice in
docs/roadmap.md, marking the app-owned Machine/Port boundary, pure viz layout API, and Astro host role. Record that future remote visual loads should use an app-owned Command, app-owned AsyncData/Model state, and a typed Port only for host input/output. -
Step 2: Add the package usage examples.
Document this shape in the relevant READMEs:
import { Option } from 'effect';import { revalidateOrLoad, refresh } from 'foldkit/asyncData';const loadOnFilter = refresh({read: (model) => Option.some(model.data),revalidate: revalidateOrLoad,write: (model, data) => ({ ...model, data }),load: LoadFilteredData(),});Explain that
foldkit-vizreceives already-derived records and geometry, while the app owns the remote Command, interruption/cancellation policy, AsyncData state, and Port boundary. Do not imply that a chart primitive can fetch or subscribe. -
Step 3: Commit the documentation slice.
Terminal window git add docs/roadmap.md packages/foldkit-viz/README.md packages/astro-foldkit/README.mdgit commit -m "docs: document stateflow and remote visual load boundaries"
Task 6: Run the complete verification and visual acceptance gates
Files:
-
Repository-wide verification only; no additional source files.
-
Step 1: Run focused package and app tests.
Terminal window bun test packages/foldkit-viz/test/stateflow.test.tsbun test apps/web/src/apps/request-diagnostics apps/web/src/apps/stateflowExpected: PASS, including retained-island navigation and route-exit cancellation tests already in request-diagnostics.
-
Step 2: Run repository lint and formatting checks.
Run:
bun run checkExpected: the new and modified Stateflow files introduce no diagnostics and Oxfmt is clean. The current checkout has pre-existing repo-wide anti-slop/linteffect findings outside this slice; record the baseline comparison and do not broaden the slice to repair unrelated files. Do not invoke Biome or add lint suppressions for the new code.
-
Step 3: Run typecheck and the full test suite.
Terminal window bun typecheckbun testExpected: PASS for
@opsydyn/foldkit-viz,@opsydyn/astro-foldkit, and@opsydyn/web. -
Step 4: Build and inspect the public package entry.
Terminal window bun run --filter @opsydyn/foldkit-viz buildbun pm pack --dry-run --cwd packages/foldkit-vizConfirm
dist/stateflow.mjsanddist/stateflow.d.mtsare included and that the package has no FoldKit, Effect, or Astro runtime import in that entry. -
Step 5: Perform direct browser acceptance.
Start the app with
bun dev, open/stateflow, and verify at desktop and mobile widths:- the static graph, current state, timeline, and table are visible before replay;
- Step advances exactly one trace entry and updates the inspector;
- Play advances the bounded fixture and stops at the end;
- a command-producing transition shows
FetchMetrics.Interruptwithout starting a network replacement; - malformed host input is rejected;
- keyboard focus reaches controls and graph/timeline selections;
- reduced motion removes pulse animation while retaining active-state styling.
-
Step 6: Review the final diff without staging unrelated work.
Run:
git diff --checkandgit status --shortConfirm only the intended commits/files are part of the implementation slice. Preserve the pre-existing dirty files listed by the user and do not amend or reset unrelated commits.
Completion Criteria
/stateflowrenders the approved Stateflow Observatory composition with a deterministic graph, replay controls, Port activity, inspector, and accessible event table.- The graph is derived from
diagnosticsMachine.stateTagsanddiagnosticsMachine.edges; every replay event is evaluated bydiagnosticsMachine.step. - Invalid inbound replay values are rejected at the Port boundary, ignored transitions are recorded without state mutation, and diagnostic Commands are displayed as metadata rather than executed by synthetic replay.
@opsydyn/foldkit-viz/stateflowis plain-data, deterministic, and free of FoldKit, Effect, and Astro dependencies.@opsydyn/astro-foldkithas no runtime API change; its README documents its lifecycle-only role.- The future remote filter/brush/zoom pattern is documented with app-owned Commands and AsyncData/Model state.
- Stateflow-focused Oxlint/Oxfmt,
bun typecheck, workspacebun run test, package build, and direct browser acceptance pass before release work begins; any unchanged repo-wide lint debt is recorded separately from this slice.