Updated 2026-10-08, end of milestone 7

Handrail, as built

A model works a task out once. The run compiles to a typed, reviewable capability. Replay is plain code with no model in the loop. When a check fails, a ladder is climbed, and every rung only proposes.

tests 332source lines 5,417milestones done 0 to 3, 5 to 7 of 8authored once, replayed 100 times, llm_calls 0
How the pieces fit

Architecture

Six layers. A caller hands a capability and inputs to the engine. The engine talks to one surface through six verbs and never sees the application directly. Solid boxes are built and tested. Dashed boxes are planned. Click any box.

Callers

Artifact

Core

Surface protocol

Surfaces

Applications

What is happening, in order

  1. A caller loads a capability file and supplies inputs. Today that is the CLI; later an agent through the MCP server.
  2. The engine validates the inputs, binds the blanks, and opens the surface at the entry URL.
  3. For each step the engine checks the screen, resolves the control, journals, acts, settles, and checks the screen again.
  4. The surface does the actual clicking. The browser surface is built; terminal and Mac are the same six verbs, later.
  5. Everything the run does lands in the evidence directory, hash-chained, secrets masked.
  6. If a check fails, the run ends with a typed result today. From milestone 5, the ladder is climbed first.
  7. Before any of that, a model worked the task out once through the same menu, and the compiler wrote the card the engine now follows.
What a capability looks like

The artifact, one target and one step

From capabilities/plumbline-place-hold.json, written by hand, eleven steps in total. This is the part that picks the right Hold link out of a table with one link per share.

// targets.hold_link
{ "role": "link", "name": { "eq": "Hold" },            1
  "supports": { "invoke": [] },                          2
  "scope": [ { "role": "frame", "name": "work" },
             { "role": "row",   "name": "{{input.share_id}}" } ],  3
  "ladder": [ { "rung": "role_name", "cost": 100 },         4
              { "rung": "native", "cost": 10000000, "surface": "browser",
                "value": "a[href$=\"share={{input.share_id}}\"]" } ] }

// steps[6]
{ "id": "open_hold", "intent": "Open the hold form for this share",
  "screen": "member",                                     5
  "op": { "verb": "invoke" }, "target": "hold_link",
  "effect": { "kind": "navigate" },                       6
  "settle": { "kind": "screen_is", "screen": "hold_form" },  7
  "expect": { "screen_in": ["hold_form"] } }
  1. 1Described the way a screen reader would: a link named Hold. Means the same thing on a browser, a terminal and a Mac.
  2. 2The verbs this control takes. Copied from the browser's closed vocabulary, so authoring and replay cannot disagree.
  3. 3Scope narrows the search: the frame called work, then the table row containing this share id. The blank is filled from a validated input at run time.
  4. 4The ladder, cheapest first. Role and name is tried before a CSS selector. The rung that resolved is reported on every run, so drift shows up early.
  5. 5A step may only run on the screen it names. Screens are recognised by structure, never by text.
  6. 6The risk sticker. A step without one does not load.
  7. 7What must be true before the next step: here, the hold form is showing. The engine polls; the surface never waits.
Workflow 1

A replay, as it actually ran

The demo run on PLUMBLINE: thirteen rows, one per step plus start and finish. Each row says what the engine decided, what went into the ledger, what the browser did, and which screen was seen afterwards. Press Play to watch it in order.

Diagram 1. Engine, ledger, surface and screen, step by step

Result: SUCCESS, confirmation HX-341248, llm_calls=0, classifier_calls=0, 11 of 11 controls on their first-choice rung, 1.8 seconds.

StepEngineLedgerBrowser surfaceScreen seen
startvalidate 5 inputs, register password as secretopen {{env.BASE_URL}}/signonsignon
enter_operator stagescreen is signon? resolve operator_fielddispatched, observedlabel rung: cell left of the input says Operator ID. fillsignon
enter_password stageresolve password_fielddispatched, observedlabel rung. fill (value never logged)signon
sign_on navigateresolve sign_on_button; settle: screen_is deskrole_name rung. click, wait for the frameset to loaddesk
enter_member stageresolve search_field in frame workdispatched, observedlabel rung: cell says Value. fill 400118desk
search navigateexpect inquiry_results or no_matchclick F5=Search, wait for the work frame to navigateinquiry_results
open_member navigateresolve member_link named {{input.member_number}}role_name rung, name bound to 400118. clickmember
open_hold navigateresolve hold_link in row {{input.share_id}}scope narrows to the row; role_name rung finds one Hold. clickhold_form
choose_reason stageresolve reason_listdispatched, observedlabel rung: cell says Reason. select LEGALhold_form
review navigateresolve review_buttonclick F8=Reviewhold_review
post_hold commitnot already done? not in doubt? then resolve post_buttondispatched, observedclick F10=Post Hold; settle when HOLD RESULT text is presentposted
read_result readresolve result_line; readnative css rung. inner text: HOLD POSTED CONFIRMATION HX-…posted
finishfinal screen posted is a SUCCESS outcome; extract confirmationclose the browserRunResult

What is happening, in order

  1. Inputs are validated against the capability before anything is touched. The password is registered as a secret so the recorder masks it.
  2. Every stage step is written to the ledger before and after. Navigate and read steps are not, because redoing them is harmless.
  3. Two targets carry blanks: the member link's name and the Hold link's row. Both are filled from validated inputs.
  4. The one commit, post_hold, is checked against the ledger first: already done means skip, in doubt means refuse.
  5. After post_hold the screen may be posted or already_held. Already held ends the run as a business outcome, not a failure.
  6. The confirmation is extracted from the read step with a regular expression and returned as a typed output.
Workflow 2

Finding one control

What happens inside the engine's "resolve" for the Hold link. The member page has two shares, so two links called Hold. Choose what the page looks like today.

Diagram 2. Walking the ladder, inside one row

Pick a situation.

Scopeframe work → row containing 400118-S0005
Rung 1, cost 100get_by_role("link", name="Hold", exact)
Rung 2, cost 10Ma[href$="share=400118-S0005"]
Fingerprintrole, name, supports, ancestor roles
Resolution

What is happening, in order

  1. Scope first. The search is narrowed to the named frame, then to the one table row whose text contains the share id.
  2. Rungs in cost order. A rung that finds exactly one element wins. Two is ambiguous and stops the run. Zero means try the next rung.
  3. The fingerprint check. A key card opens a door; this looks around the room. Role, name, verbs and container roles must hash the same as when recorded.
  4. The resolution says which rung found it. A run that starts landing on rung 2 is the early warning that the application moved.
Workflow 3

The ledger, across runs

The rule the whole project is built around: a commit written as dispatched and never observed is never pressed again. Try to make the bank post the hold twice.

Diagram 3. A crash in the middle of a commit

The same journal file is reused across runs with handrail replay --journal.

Journal, on disk

At the bank

0times Post Hold was pressed
0runs started

What is happening, in order

  1. The engine writes dispatched post_hold to disk.
  2. Only then does it press Post Hold.
  3. In a clean run it then writes observed post_hold.
  4. If the run dies before that line, every later run refuses to press again and ends with UNSAFE_TO_RETRY. A read-only probe, or a person, decides what happened.
Workflow 4

Authoring, as it actually ran

Figure 6. The evening: a guest, a maître d', a book, a waiter
A maître d' at a stand writes in a book between a seated guest holding a small card and a waiter at a kitchen door

On 2026-10-08, claude-sonnet-5 was seated once in front of PLUMBLINE with a goal, the names of five inputs, and a card with three lines. Nineteen turns and twelve model calls later it said finish. Every turn below is from the real book.

The model never saw a selector, a coordinate or a password. It saw screen text and a numbered menu, and it said numbers.

Reading the picture

  1. Guest with a three-line card is the model. It may only say act, assert_screen or finish.
  2. Maître d' is the guard in middleware.py. Probes, classifies, asks the owner, records.
  3. The book is trace.jsonl, written before the guest speaks again, password masked.
  4. Waiter at the kitchen door is the browser surface. The only one who touches the application.

Diagram 6. Nineteen turns: what the guest said, what the maître d' did

Owner answered "commit, dhawal" up front for every button, so four buttons carry a commit sticker where one would do. The classifier rung in milestone 5 is where that gets smarter.

TurnGuest saidMaître d' didWaiter didBook
1 assert_screenlabel: Operator Sign Onpins the label to the screen's signaturebook line 1
2 act1 set_value {{input.operator_id}}probes: label and native survive; stagefills the Operator ID fieldbook line 2
3 act3 set_value {{input.password}}probes; stage; the value is a blank, never storedfills Passwordbook line 3, masked
4 act7 invoke F5=Sign Onbutton: commit until the owner says; owner says commit, dhawalclicks, waits for the frameset to loadbook line 4
5 assert_screenlabel: Member Inquirypins the labelbook line 5
6 act3 select 'Member Number'stage; a literal that is not an input stays literalselects in the work framebook line 6
7 act5 set_value {{input.member_number}}stagefills Valuebook line 7
8 act7 invoke F5=Searchcommit (owner says commit)clicks, the work frame navigatesbook line 8
9 assert_screenlabel: Member Inquiry Resultspins the labelbook line 9
10 act8 invoke link 400118a link: navigate, nobody asked; the compiler will blank the nameclicksbook line 10
11 assert_screenlabel: Member Recordpins the labelbook line 11
12 act3 invoke link Holdrow 400118-S0001; probed inside that row; navigateclicks the Hold in that rowbook line 12
13 assert_screenlabel: Place Account Holdpins the labelbook line 13
14 act3 select {{input.reason}}stageselects LEGALbook line 14
15 act7 invoke F8=Reviewcommit (owner says commit)clicksbook line 15
16 assert_screenlabel: Hold Reviewpins the labelbook line 16
17 act3 invoke F10=Post Holdcommit (owner says commit)clicks; the hold is postedbook line 17
18 assert_screenlabel: Hold Resultpins the labelbook line 18
19 finishlabel: Hold Resultthe labelled screen is showing; evening overbook line 19

What is happening, in order

  1. The guest labels the screen, then orders by row number. The verb must be the row's own verb; a value is a blank such as {{input.password}}.
  2. Before the waiter moves, the maître d' looks at the control behind the row, tries every rung on the live page and keeps the ones that find exactly this element, takes its fingerprint, and decides how serious the order is.
  3. A button press is a commit until the owner says otherwise. The owner's answer and name go into the book.
  4. Only then does the waiter act. The new screen is observed and the turn is written to disk before the guest may speak again.
  5. finish may only name a screen already labelled, and it must be the one showing.

Diagram 7. The bug the first live run found

The first evening sent the sign-on form seven times and was rejected seven times, with the right values typed each time. The bank's own log showed why: the model sends several tool calls in one message, and the loop ran them concurrently. "Fill, fill, click" fired at once clicked an empty form.

RunTool calls runTurnsModel callsRejected sign-onsCompiled?
First eveningconcurrently41187no, 12 holes
After the fixone at a time, in order19120yes, 11 steps

The fix is a lock in the maître d': one guest speaks at a time, in the order the words came out. A test now fires three calls at once and checks they ran in order without overlap.

Workflow 5

Compile, then the tasting

Figure 7. Three plates, three tables, one honest judge
A judge with a magnifying glass and clipboard inspects the third of three identical plates while a line cook stands back with arms folded

The book is not a recipe. "Number 7, then number 3" has to become "the Search button on the inquiry screen, then the member's link". The compiler does that, and refuses by line if the book has a hole.

Then the card is cooked three times for three different members, and after each plate the judge asks the record store whether the hold really exists. Only then is the card marked verified.

Reading the picture

  1. Line cook is the replay engine, following the draft card from a cold kitchen.
  2. Plate one is member 400118, share S0005.
  3. Plate two is member 400337, share S0001.
  4. Plate three is member 400445, share S0001.
  5. Judge with a magnifying glass is the independent check: it asks PLUMBLINE's record store, never the screen.

Diagram 8. What the compiler did to the book

// from the book, turn 10 and turn 12
act 8 invoke link "400118"          // the member's own number
act 3 invoke link "Hold"  row "400118-S0001"   // the share's own id

// in the card, plumbline-place-hold.authored.json
"link_input_member_number_work": {
  "name": { "eq": "{{input.member_number}}" },            1
  "scope": [ { "role": "frame", "name": "work" } ],
  "ladder": [ role_name, text ],
  "fingerprint": { "over": ["role", "supports", "ancestor_roles"] } }   2

"link_hold_work_input_share_id": {
  "name": { "eq": "Hold" },
  "scope": [ { "role": "frame", "name": "work" },
             { "role": "row",   "name": "{{input.share_id}}" } ],    3
  "ladder": [ role_name, text ],
  "fingerprint": { "over": ["role", "name", "supports", "ancestor_roles"] } }
  1. 1An input value standing alone in a name becomes its blank. One evening with member 400118 became a card for every member.
  2. 2When the name became a blank, the fingerprint was retaken without it. A link named 400337 is the same control: same role, same verbs, same containers.
  3. 3The row the Hold link sat in was recorded by the walker, probed inside during authoring, and written into the scope. It equalled an input, so it became a blank too.

What the compiler refuses

  1. Acting on a screen nobody labelled, or landing on one.
  2. A control no rung can find again.
  3. An input value typed as a literal, or a secret inside any typed value.
  4. A run that never called finish.
  5. Every hole is reported, not just the first.

Diagram 9. The tasting, and the hundred plates after it

PlateMemberShareReasonRunRecord store says
1400118400118-S0005LEGALSUCCESS, llm_calls 0HOLD
2400337400337-S0001LEGALSUCCESS, llm_calls 0HOLD
3400445400445-S0001FRAUD_REVIEWSUCCESS, llm_calls 0HOLD

Card lifecycle: draft → verified, reliability 3 of 3. approved still needs a named person's signature; no file in the repository can grant it.

replays after the gate 100 succeeded 100 llm_calls 0 classifier_calls 0 first-choice rung 11 of 11, every run mean 747 ms, slowest 1,138 ms

What is happening, in order

  1. Each plate starts from a cold kitchen: a fresh browser, a fresh journal, its own evidence directory.
  2. A plate is clean only if the run ended in SUCCESS, both model counters are zero, and the judge says yes.
  3. A commit nobody confirmed cannot leave draft however clean the plates. The schema enforces it; the gate reports it.
  4. The store was reset before each of the hundred replays, so every one placed the hold for real.
Workflow 6

The ladder, rung one, as it actually ran

On 2026-10-08 the mock bank was changed under the verified card in two ways, and the card was replayed with a referee on call. Pick a change and follow what the engine did. Every number is from the live run.

Diagram 10. Recognising a screen that changed

Exact match, then the furniture, then the referee, and the referee's call is re-checked against the furniture before it is believed.

Rung 1. The refereewhich_screen among the card's screens plus none_of_these; accepted at 0.80 with margin 0.20, then re-checked: the chosen screen's stored furniture must overlap the page at 0.5
Tier 2. Similar furnitureJaccard of the page's paths against the stored paths, at or above 0.85
Tier 1. Exactthe hash of the path set equals the stored signature
Result
classifier_calls 0 llm_calls 0 verdicts held 0 of 0

What is happening, in order

  1. The engine names the screen before a step, while settling, and after it. Exact first: the hash of the page's paths equals a stored signature.
  2. If not, similar: one extra banner on the sign-on page gave 6 of 7 paths, 0.857, still the room.
  3. If not, and a referee is on call, the referee is asked a closed question: which of these screens, or none of these. The answer is a probability per option.
  4. The engine checks the call. The chosen screen's stored paths must overlap the page at 0.5 or more. Screen text can steer a referee; furniture cannot be talked into anything.
  5. Every whistle is a row in episodes.db, settled as held or not when the run ends. A referee is asked once per screen per run.
  6. With no referee on call, the ladder stops at rung 0 and the run ends with a typed result. With a scout or a console on call, the climb continues: see Workflow 7.

Diagram 11. The referee's notebook, calibration by confidence

ConfidenceVerdictsHeldRate
0.0 to 0.800-
0.8 to 1.0661.00

Six rows from one banner-drift replay, all at 0.95, all held. Too few to tune a threshold on; enough to show the loop is closed: ask, record, settle, bin. "Held" means the run ended in SUCCESS or a business outcome.

Three backends behind one port

  1. Claude, live: a model with the answer sheet enforced. Option labels are numbered because "Hold Result" is not a valid schema key.
  2. Jev, TypeSafe's hosted referee, pinned to jev-1.13.0: written against the SDK, not yet run, no key.
  3. Laya, on-device, nothing leaves the machine: written against the library, not yet run, the checkpoint did not fit on the disk.
  4. A backend that cannot answer raises; the engine climbs past the rung. A run with no referee reachable finishes by refusing, never by guessing.
Workflow 7

The scout, and the person

Figure 8. The stop button and the telephone
A worker at a stopped conveyor belt holds a wall telephone and points at an object on the belt, beside a red emergency-stop button

When the referee says none of these, two rungs remain. A scout, which is a model seated at the three-line card with commit struck off and a four-turn leash, may go and look. If the scout names nothing the furniture agrees with, the belt stops and the telephone rings: a person takes the baton, does what needs doing in the same live browser, and hands back naming the screen.

Neither is believed on their word alone. The scout's name must be a screen the run was hoping for and the furniture must overlap. The person's name must be one the run was hoping for too, and if the page matches a different declared screen exactly, that match wins.

Reading the picture

  1. Emergency stop is the baton moving to PAUSED: the engine does nothing until it comes back.
  2. Telephone is serve/console.py: take over, hand back naming a screen, or abort. A few dozen lines, localhost only.
  3. The object on the belt is the page nothing could place: here the hold result rewritten entirely.
  4. The worker holds HUMAN_CONTROL. Every exchange of the baton is recorded with a name and a reason.

Diagram 12. Three live runs up the ladder, 2026-10-08

ScenarioRung 1, refereeRung 2, scoutRung 3, personResult
Result line reworded. Laya on-device as referee, Claude scout.none_of_these at 0.93, refusedSaid "Hold Result" in one turn, two model calls. Furniture 0.8. Accepted.SUCCESS, llm_calls 2
Result page rewritten entirely. Claude referee, scout, console.none_of_these at 0.95Wandered eight turns back to the hold form. Named nothing.Handed back "Hold Result" while the hold form was showing.Refused: the furniture said Place Account Hold
Same, no scout.none_of_these at 0.95Read the stuck page and the record store (HOLD), handed back "Hold Result".SUCCESS, escalated_to_human

What is happening, in order

  1. The engine cannot name the screen after Post Hold. The referee is asked and says none of these, confidently. That is a correct answer, and the run climbs.
  2. The scout is seated at the card: act and finish only, commit refused, four turns. It must end by naming an expected screen. The engine then observes the page and checks the furniture overlap.
  3. If the scout names nothing, the baton moves to PAUSED. The console shows the expected screens. A screenshot and the page text are recorded before the person takes over and after they hand back.
  4. The person takes over, drives the same live browser, hands back naming a screen. The engine records who and when, and continues, with escalated_to_human in the result.
  5. The second row is the lesson. An eight-turn scout walked the application backwards; the leash is now four turns. And a person naming a screen the page visibly contradicts is a screen mismatch, not a recovery.
Workflow 8

An agent orders from the counter

The product claim, end to end. An agent that wants a hold placed never sees a screen, a selector or a password. It sees one line on a menu, fills in the card's inputs, and reads a receipt. Both orders below ran on 2026-10-08 through the MCP server, with the mock bank live.

Diagram 13. From an agent's sentence to the record store and back

Agentdeepagents, given exactly the server's tools and told what a receipt means
→
MCP toolplumbline_place_hold(member_number, reason, share_id); no password on the schema
→
Serverfills secrets from HANDRAIL_SECRET_*, runs the engine, no model
→
Receiptcategory, code, outcome, outputs, retryable, run id, evidence
OrderTool callsReceiptWhat the agent said
A LEGAL hold on share 400226-S0002, which is already under hold1BUSINESS_OUTCOME, ALREADY_PROCESSED, retryable false"already has a hold in place; no further retry is warranted since this is a definitive business outcome"
A LEGAL hold on share 400118-S0005, free1SUCCESS, confirmation HX-975249reported the confirmation; the record store says HOLD

What is happening, in order

  1. A verified card is approved by a named person with handrail approve. Nothing else can write that name. Only approved cards are served; the server says on stderr why each other card is off the menu.
  2. handrail serve reads the directory and builds one MCP tool per card. The tool's input schema is the card's public inputs; a secret input is never on it.
  3. The agent calls the tool. The server fills the secret from its own environment, runs the replay engine, and returns the receipt as data: no screen text, no screenshot, no model output.
  4. A BUSINESS_OUTCOME is an answer. The agent reports it and does not call again. Only RECOVERABLE is retryable, and the receipt says so.
  5. Stdout is the wire: the first live run printed a greeting there and the client saw "Connection closed". Everything for a person now goes to stderr.
Not built yet

What remains

Diagram 5. What remains for version 0.1 planned

Milestone 8: macOS accessibilityType and save a note in TextEdit with no coordinates. The input-event tap that flips the baton lives here too.
→
Milestone 4: the terminalpy3270 behind the six verbs. Waits on a 3270 host to demo against.

Also pending: the control rung, which asks the referee "which menu row is this control" and re-checks with the fingerprint; and recording a person's clicks during HUMAN_CONTROL, not just the screen before and after.

Who it is for

Use cases

A clerk at an old desktop computer with a tray of paper forms

Back-office tasks in a bank or credit union built, on the mock bank

Place a hold, release a hold, update an address. Each is a few screens of a system nobody will replace this decade. Someone shows the task once; after that it runs as a reviewed capability with no model, with evidence for the auditor.

A green-screen mainframe terminal on a desk

Green-screen terminals milestone 4

The same engine and the same capability schema drive a 3270 session. A screen is a fixed buffer of fields, so signatures are exact. This is the "computer use, not just browser" claim, and the legacy-banking wedge.

A robot slides a form through a service counter window

Agents as callers built, see Workflow 8

An AI agent should not drive the bank's screens itself. It calls one typed tool per approved capability through an MCP server and gets a typed answer: posted, already held, or not authorised, and whether a retry is safe.

Where each piece lives

Code map

PieceFileLinesStatus
Capability artifact and validatorsschema/capability.py227built
Target, ladder, fingerprint shapeschema/target.py98built
Effect classesschema/effects.py45built
Bindings, including inside targetsschema/bindings.py78built
Errors and resultsschema/errors.py, schema/results.py165built
Replay enginereplay/engine.py288built
Journalreplay/journal.py121built
Input validationreplay/validate.py83built
Evidence recorderkernel/evidence.py130built
Surface protocol, null surfacesurface/base.py, surface/null_surface.py213built
Operations tablesurface/browser/operations.py115built
Locator ladder, probedsurface/browser/locators.py125built
Fingerprintsurface/browser/fingerprint.py75built
Page walk and signaturesurface/browser/walk.py139built
Playwright queriessurface/browser/queries.py47built
Browser surfacesurface/browser/surface.py244built
CLIcli.py128built
Hand-written capability, approvedcapabilities/plumbline-place-hold.json11 stepsbuilt
Model-authored capability, verifiedcapabilities/plumbline-place-hold.authored.json11 stepsbuilt
One line of the bookschema/trace.py39built
Three-line cardauthor/tools.py175built
Maître d'author/middleware.py171built
The guest's eveningauthor/agent.py130built
The book on diskauthor/recorder.py82built
author and verify commandsauthor/run.py197built
Recipe writercompile/compiler.py235built
The tastingcompile/verify.py131built
Terminal surfacesurface/terminal/milestone 4, waits on a 3270 host
Screen signatures, tieredkernel/signature.py86built
The closed cardescalate/questions.py131built
The portescalate/classifier.py52built
Refereesescalate/backends/claude.py, laya.py, jev.py186built, Jev unverified
The notebookkernel/episodes.py127built
The three rungsreplay/rungs/one.py, two.py, three.py293built
The batonkernel/control.py121built
The scout's port and reportescalate/bridge.py44built
The scoutauthor/bridge.py115built
The consoleserve/console.py134built
The signature on the menucompile/approve.py47built
The counter other agents order atserve/mcp.py197built
An agent that reads the receiptexamples/agent_caller.py72built
Naming a screen, preparing a runreplay/screens.py, replay/prepare.py131built

Tests: 332, of which 17 drive a real headless Chromium against PLUMBLINE, and one runs the whole evening with a scripted guest. The invariant tests in tests/invariants/ read the source and refuse any model client in the core.

Companion page

The five pictures

The analogies the code is written in, with annotated illustrations and playable diagrams, live on their own page: Handrail in Five Pictures.