Behavior as Data
Server-Driven UI, grounded in two production systems: SpatialX Orca (a gigapixel medical canvas) and Alphazed (a kids' learning app). • Day 9 pushes the idea one level deeper: not just the screen, but the *flow* and the *rules* become data too. A workflow graph, a job blueprint, and small per-widget config blobs replace hand-written controllers — while stopping deliberately short of a scripting language.
From "UI as data" to "behavior as data"
So far the course has said the screen is data: the server sends a list of typed regions and the client renders them. This day extends that to behavior — the control flow. Behavior as data means the decisions a normal program makes in code (which screen comes next, whether a step runs at all, what counts as a correct answer, which job step is next) are instead written as JSON or database fields that a small fixed engine reads and obeys.
A quick gloss of the words used throughout: a step is one screen or one unit of work; a transition is a rule that says "on this event, go to that step"; a state machine is a set of steps plus the transitions between them; a side-effect is a write the server must persist (an account created, an OTP verified). The point of making these data is the same as making the UI data: you change the flow by editing a value, not by shipping new client code.
The workflow graph IS the flow logic (Alphazed)
In Alphazed, an onboarding or sign-up flow is not an imperative controller with if/goto scattered through it. It is a workflow: a list of steps[], each carrying a transitions map of event → next step. That list is the flow logic — a declarative state machine. There is no per-flow code deciding "after the avatar screen, show the mirror screen"; the graph says so.
The contract is contracts/workflow.schema.json (JSON Schema draft 2020-12, version 2). A workflow is { id, start_step_id, presentation, steps[] }, and each step is { id, screen, presentation, back, transitions }. Here is a single step — one node of the graph above:
{
"id": "positioning_mirror",
"type": "screen",
"screen": "multi_section",
"presentation": "push",
"back": "previous_step",
"transitions": { "complete": "lo_promise", "skip": "lo_promise" }
}
Everything the runtime needs to navigate is here as data: screen names the client builder key, presentation (push|replace|root|modal) says how to show it, back (previous_step|exit_workflow|disabled) says what the back gesture does, and transitions says where each outcome leads.
Reading the transition map
The transitions map is small and strict on purpose, which is what makes it safe to treat as the whole flow. The event keys are exactly complete | skip | fail | error — the schema sets additionalProperties: false, so no other event can sneak in. Each target is either a real step id or the sentinel string "$END", which terminates the flow. A null target is forbidden in this backend contract.
{
"id": "post_sign_up_amal_v6",
"contract_version": 2,
"start_step_id": "avatar_customization",
"presentation": { "stack_owner": "workflow", "exit_route": "main_app" },
"steps": [
{ "id": "avatar_customization", "screen": "multi_section",
"transitions": { "complete": "positioning_mirror" } },
{ "id": "positioning_mirror", "screen": "multi_section",
"transitions": { "complete": "lo_promise", "skip": "lo_promise" } },
{ "id": "lo_promise", "screen": "multi_section",
"transitions": { "complete": "paywall_v2" } },
{ "id": "paywall_v2", "screen": "multi_section",
"transitions": { "complete": "$END", "skip": "$END" } }
]
}
Terminal handling is also declarative. presentation.stack_owner (workflow|host) and exit_route (main_app|welcome|parent_workflow|…) say what happens when the flow hits $END — you read the exit, you do not code it. (Worth a note for anyone comparing contracts: the sibling frontend template on Day 7 allowed null transition targets to mean "terminal"; this backend contract instead requires the explicit $END sentinel. They are two dialects of the same idea.)
Statically validating the graph (min_step reachability)
Because the flow is data, the server can check it before shipping it — something you cannot easily do to imperative branching. src/core/workflow_validation.py's validate_workflow_dict() runs the JSON Schema plus a set of semantic invariants: the start_step_id must resolve to a real step, every transition target must be $END or a real step, every event key must be in {complete, skip, fail, error}, and a min_step reachability walk simulates the graph (carrying a small {signup_complete, signin_complete} state lattice) to confirm the flow can actually reach a terminal state and no step is a dead end.
The failure mode is the interesting part. If a workflow fails validation, the init assembly omits that workflow key entirely — the client simply does not receive it and falls back to its legacy flow. A malformed flow can never reach a user as a broken screen; it just disappears. That is a fail-safe you get for free precisely because the flow is data an engine can inspect.
Submit routing: the side-effect interceptor (Alphazed FE)
Navigating a graph is safe to repeat; writing to the backend is not. So the frontend separates the two. A side-effect here is the real backend POST a step must fire (create the account, verify the OTP). The rule: a step's POST fires only when the emitted event is one the step declares in submitEvents (default ['complete']; an OTP step adds 'resend') and the step's side-effect marker — isSignupSubmit | isSigninSubmit | isOtpRequest | isOtpVerify, supplied by backend config — resolves to a real handler. If either check fails, no write happens; the runner just advances the graph.
Two guards make this robust. Single-flight means a double-tap fires the write once and drops the second (there is also a stale-runner and a stale-step guard, so a write that resolves after the user has moved on is ignored). And the two machine navigation paths — goBack (a history pop) and autoSkip (the auto-advance over an unknown widget from Day 8) — deliberately bypass the interceptor, so moving backward or skipping a broken step can never re-trigger a POST. The declaration of which events submit lives in data (submitEvents + the marker); the enforcement lives in one small interceptor.
Correctness and branching are database fields too
Inside the learning content, the same discipline holds: the rules of a question are data, not code. Whether an answer is correct is the DB column media.is_correct (plus the correct_answers_options JSON answer key); whether a question is multiple-choice (is_multiple_choice) is computed from how many answers are correct, not stored twice. Which bit comes next after a given answer is the foreign key media.next_content_bit_id — answer-driven branching expressed as a row pointer.
{
"id": 8801,
"type": "select-text",
"correct_answers_options": [2],
"answers": [
{ "id": 1, "text": "Cat", "is_correct": false, "next": 8802 },
{ "id": 2, "text": "Lion", "is_correct": true, "next": 8803 }
]
}
Scoring is intentionally thin and derived: scorer.py's compute_top_score is the pure expression max(current, corrects / total) — no bespoke per-game scoring logic. Anything else a widget needs to behave (an on_repeat cooldown, camera_expand_to_width, the parameters of a physics game) rides in the per-widget properties / config JSON blob: the backend validates it and passes it through verbatim, and the client interprets it. The engine stays fixed; the behavior is the data.
The same idea for backend jobs: the Blueprint engine (SpatialX)
SpatialX applies the identical move to backend jobs rather than screens. A blueprint is an ordered list of steps that describe a pipeline — but instead of being a class full of method calls, it is stored as a row in the database (RDS). An engine reads the row, parses it into steps, and runs each one; every step is published to a queue and executed by a subscriber (a small pub/sub loop), so the run is storage-first.
The payoff of being storage-first is resume-from-last-step: because progress is persisted per step, a crashed or restarted run picks up where it left off instead of redoing finished work. Per-lab customization is then just a different row. org_id is the tenant identifier — one organization (a lab) — so a lab's pipeline is a JSON row keyed by its org_id.
{
"org_id": 42,
"blueprint": "wsi_training_pipeline",
"steps": [
{ "step": "ingest_slides" },
{ "step": "color_normalization" },
{ "step": "tile_dzi" },
{ "step": "train_model" },
{ "step": "publish_metrics" }
]
}
Lab 42 needs a color_normalization pass its neighbor does not — so its row simply includes that step, with zero code change. New step types come from a Step Registry + Factory: copy an existing step class, register its name, and any blueprint may now list it. (Build-vs-buy note: the team weighed Temporal and Restate and built their own small engine — a tiny dependency footprint, owned IP, and one uniform pattern to learn.)
The precise boundary: a declarative graph, not a rules DSL
This is the sentence to hold onto, because "behavior as data" is easy to over-claim. In both systems, behavior-as-data means: a declarative workflow/blueprint graph + gates (config.gate, config.enabled, skippable, feature-flag and platform gates, all data) + per-widget properties/config blobs + correctness and branching stored as database fields. That is the entire vocabulary.
What it is not is a general-purpose, Turing-complete rules engine. There is no scripted conditional, no arithmetic-as-JSON, no embedded expression language a payload can use to compute new logic. A step points to another step; a gate flips a flag on or off; a field says an answer is correct. When the flow needs genuinely new logic, that logic lands as a new step class or a new engine capability in code — reviewed like code — not as a clever JSON expression. Keeping this line sharp is what keeps the data debuggable and the engine small.
| Axis | SpatialX Orca | Alphazed |
|---|---|---|
| Where behavior lives | Blueprint row in RDS (ordered steps) | Workflow JSON (steps[] + transitions) + content DB fields |
| What runs it | Engine parses → pub/sub subscribers run each step | Client WorkflowRunner traverses the graph; backend resolves gates |
| Per-instance customization | JSON row keyed by org_id (insert a step) | gates (config.gate / config.enabled), properties blob, branching (next_content_bit_id) |
| Extend the vocabulary | Step Registry + Factory (copy class, register name) | content_bit type enum + the three registries |
| Correctness / resume | storage-first, resume-from-last-step | pure max(current, corrects/total); is_correct is a field |
| Explicitly NOT | a scripted rules engine | a scripted rules engine |
Key takeaways
- Behavior as data extends "UI as data" to the flow and the rules: navigation, gates, correctness, and job steps become JSON/DB values a small fixed engine reads.
- In Alphazed a workflow is
steps[]+ atransitionsmap (event → step id | "$END"); that graph is the flow logic, with no per-flow imperative controller. Event keys are exactlycomplete|skip|fail|errorandnulltargets are forbidden. - Because the flow is data, the backend statically validates it (
min_stepreachability walk); a workflow that fails validation is omitted from/init, so the client falls back to a legacy flow instead of showing a broken screen. - The frontend's submit interceptor fires a backend
POSTonly for a declaredsubmitEventsevent whose side-effect marker resolves, guarded by single-flight and stale checks;goBackandautoSkipbypass it so reverse/machine nav never re-POSTs. - Correctness (
is_correct,correct_answers_options), branching (next_content_bit_id), and per-widget config (properties) are all data; scoring is the pure expressionmax(current, corrects/total). - SpatialX applies the same idea to backend jobs: a Blueprint is an ordered list of steps stored in RDS; the engine parses and runs it, per-lab customization is a JSON row keyed by
org_id, and storage-first design gives resume-from-last-step. A Step Registry + Factory lets anyone add a step by copy-and-register. - The precise boundary: declarative graph + gates + config blobs + correctness/branching as fields — not a Turing-complete rules DSL. New logic lands as code, not as a JSON expression.
Checklist
- [ ] I can explain the difference between "UI as data" and "behavior as data".
- [ ] I can read an Alphazed workflow as a state machine of
steps[]andtransitions, and say what$ENDmeans. - [ ] I can explain how
validate_workflow_dict(with themin_stepwalk) checks a flow and what "omit from init" achieves as a fail-safe. - [ ] I can describe the submit side-effect interceptor:
submitEvents+ side-effect marker, single-flight, and whygoBack/autoSkipbypass it. - [ ] I can point to where correctness, branching, and per-widget behavior live as data (
is_correct,next_content_bit_id,properties). - [ ] I can explain the SpatialX Blueprint engine: steps in an RDS row, pub/sub execution,
org_id-keyed customization, resume-from-last-step, and the Step Registry + Factory. - [ ] I can state precisely why this is a declarative graph and not a general-purpose rules DSL.