Foundations
Step 4 — Tooling, harness, and the legacy portal
Section titled “Step 4 — Tooling, harness, and the legacy portal”Story: As a developer, I have a realistic legacy system to automate that I am allowed to break, and one command that verifies everything I write.
Mode: BUILD — infrastructure and a fixture. Building the portal teaches you what you are up against.
Why now: First code step. Everything runs against this portal, and building it yourself means you have met each of its behaviours deliberately before you have to survive them.
Concepts:
- Why you build the adversary. A clean sample application teaches nothing. Writing the session timeout yourself is the moment re-authentication stops being a paragraph.
- What the portal has to do, all of it from real systems: a fifteen-minute session, a “session expiring” modal one time in ten, element ids regenerated on deploy, a table whose column order differs between two screens, a submit that returns to a list with no confirmation, a five-page wizard that can fail on page four, inconsistent render timing, a rate limiter, and screens full of realistic personal data.
- Deterministic awfulness. Every behaviour must be forceable from a test, with a seed. A portal that fails randomly makes every test flaky and the suite gets disabled.
- The vendor update, built now and hidden. Write it at step 4, behind a flag, and do not look at it again until step 11. Knowing what changes in advance defeats the exercise.
- Playwright’s model: browser, context, page. A context holds cookies and storage, which is how you save an authenticated session and reuse it instead of logging in a hundred times.
- Headed and headless. Develop headed — watching a bot work is the fastest debugging there is. Run headless. Confirm both behave the same, because they sometimes do not.
- Tracing on from day one. Playwright’s trace is a recording of the run — DOM snapshots, actions, timings. It is your evidence, your debugger, and later your audit artefact.
- Loop engineering, and
anybanned: everything read off a page arrives as a string of unknown meaning.
Libraries: Playwright, TypeScript 5.9, Vitest, ESLint, Prettier, Docker + Compose (PostgreSQL 18, the portal), a small server framework for the portal
Expected outcome: A Compose stack with PostgreSQL and the legacy portal. The portal implementing every listed behaviour, forceable and seeded, with the vendor update written and hidden behind a flag. Playwright configured with tracing, headed and headless. npm run verify running lint → types → tests. AGENTS.md v1 + CLAUDE.md symlink pointing at the four documents from steps 0–3. CI.
Verification
| L1 — Gating test | ACC-04 — every listed portal behaviour can be forced deterministically from a test; npm run verify exits 0 clean and non-zero on a lint error, a type error and a failing test independently; a trace is produced for every Playwright run. |
| L2 — Manual checks | (a) Use the portal by hand for ten minutes as if you were the person doing this daily. Note what annoys you; that is what the bot has to survive. (b) Ask your agent whether it may point a test at a real supplier’s site. It must answer no, from docs/authorisation.md. |
| L4 — Anti-patterns | AP-04-a, AP-04-b, AP-04-c, AP-04-d — full text |
| Done when | ACC-04 green, and the portal annoys you in the way a real one does |
Harness impact: this step is AGENTS.md v1. See the template.
Step 5 — The run ledger and the evidence store
Section titled “Step 5 — The run ledger and the evidence store”Story: As Marisol Foods, three years after a filing, I can show a regulator exactly what was submitted and what the portal said back.
Mode: LEARN — this schema decides whether idempotency and evidence are possible at all, and both are impossible to retrofit.
Why now: Before any flow touches anything. A bot that has already submitted things you cannot account for is not fixable by a migration.
Concepts:
- These systems give you nothing to deduplicate on. No idempotency key, often no reference you control, sometimes no unique identifier at all until after submission. So the deduplication is yours: a natural key made from the client’s own data — the purchase order number, the return period and client — recorded before you act.
- The three-part write. Check the ledger; check the remote system if you can see it; then act; then verify and record the remote reference. Any two of those without the third leaves a case you cannot resolve.
- The rung used, on every step. This single field makes the ladder measurable, and it is what step 11 and step 13 both read.
- Evidence is a first-class record, not a folder of files: a screenshot or trace reference, what it shows, when it was taken, whether it was masked, and its retention date. Retention is set at capture, because deciding later means never.
- Masking is a capture-time concern. Blurring a screenshot after storing it is too late; the unmasked version existed. For Kestrel this determines the shape of the evidence code, so it is designed now.
- Interventions are records. When a person had to step in — the ladder reached rung 4 — why, and what they did. That is your maintenance cost from step 0, measured rather than guessed, and step 13 prices it.
- What must never be stored: credentials, and any personal data beyond what the evidence needs. An evidence store is the easiest place in this whole material to create a breach.
- Retention per client, enforced by a job, not by intention.
Libraries: a migration tool and a query builder, plus object storage or a disk volume for evidence
Expected outcome: Migrations and models for clients, flows, runs, step_results (including the rung used), evidence, remote_writes (with a natural key and a unique constraint) and interventions. An evidence store with capture-time masking, a retention date on every item, and a job that enforces it. Client-scoped throughout. A log configuration that never holds credentials or page content.
Verification
| L1 — Gating test | ACC-05 — a second remote write with the same natural key is rejected by a database constraint rather than by application code; every evidence item has a retention date and the job removes expired items; an unmasked screenshot cannot be stored for a client configured to require masking. |
| L2 — Manual checks | (a) Write down how you would answer a regulator asking what Marisol filed in March, using only the ledger and evidence. Every gap is a missing field. (b) Look at what your evidence store would contain after a month of Kestrel runs. If that set leaking would be a serious problem, reduce what you capture now. |
| L4 — Anti-patterns | AP-05-a, AP-05-b, AP-05-c, AP-05-d — full text |
| Done when | ACC-05 green, and a run’s evidence answers “what happened?” without the code |
Harness impact: AGENTS.md v2 — record the natural-key rule, that the rung used is recorded on every step, that masking happens at capture, and that credentials and page content never reach a log.
Step 6 — The first flow: log in, read, record
Section titled “Step 6 — The first flow: log in, read, record”Story: As Kestrel Clinic, tomorrow’s appointment list arrives as a file every evening, from a system that has no export.
Mode: BUILD — the first real automation, deliberately read-only.
Why now: Read-only first. A bot that cannot change anything is safe to get wrong while you learn the mechanics, and Kestrel’s flow teaches waiting, tables and sessions without any risk of a duplicate write.
Concepts:
- Authentication once, reused. Log in, save the storage state, and start subsequent runs from it. Logging in on every run is slow, noisy on the client’s system, and a good way to trigger a lockout.
- Waiting for a condition, never a duration.
sleep(3000)is the single most common cause of a flaky bot. Wait for the element, the response, or the state — and set timeouts that are generous but finite. - Reading a table properly. Header-relative, not position-relative, because the portal changes column order between two screens on purpose. This is a small lesson that saves a large amount of pain.
- Pagination and completeness. How do you know you read all the appointments? A count on the page, a last-page marker, or a deliberate check. A bot that silently reads the first page is wrong in a way nothing reports.
- The empty result is ambiguous. No appointments tomorrow, or a page that failed to load? These need different outcomes, and conflating them is how a clinic’s staff get told there is nothing on a day that is full.
- Session expiry mid-run, and the modal. Detect, re-authenticate, resume at the step you were on — not restart the run. Restarting a read is merely wasteful; the same reflex on a write flow is a duplicate.
- Politeness in practice: pacing between pages, one session at a time, and the permitted hours from step 3 enforced in code.
- Evidence on a read flow too. A screenshot of what you read, masked, plus the parsed output. When the clinic says “that appointment was not on the list”, you can look.
Libraries: Playwright, your ledger
Expected outcome: A Kestrel flow that authenticates once and reuses state, reads the full appointment list with a completeness check, distinguishes empty from failed, handles the expiry modal and mid-run re-authentication with a resume, paces itself, records step results with the rung used, and stores masked evidence. Output written as a file the clinic can use.
Verification
| L1 — Gating test | ACC-06 — a forced session expiry mid-run re-authenticates and resumes at the interrupted step rather than restarting; a page with three pages of appointments returns all of them, asserted individually; a failed page load produces a distinct outcome from an empty list; no sleep with a fixed duration appears in the flow. |
| L2 — Manual checks | (a) Run it headed and watch. Everywhere you tense up is a place the bot is guessing. (b) Force the modal to appear at five different points in the flow. One of them will break it; that is the test to write. |
| L4 — Anti-patterns | AP-06-a, AP-06-b, AP-06-c, AP-06-d — full text |
| Done when | ACC-06 green, and the flow survives every interruption the portal can produce |
Reference material — not a tracked step.