How PawDesk was built

The changes, requirements, tests, and decision records on these pages come from the repository and the latest test run. The method section below is written by hand.

Requirements proven
177 of 177
Tests
1061
1018 prove a named requirement
Changes
49
Decision records
19

Generated 10/19/2026, 1:29:18 PM from the latest test results.

Each change starts as a spec

Each piece of work is an OpenSpec change. It holds a proposal, requirements written as when-then scenarios, a design that weighs the options, and tasks that each name the test proving them. A finished change is archived, and its requirements join the current specs.

Decisions get a numbered record

A choice that outlives one change gets a record with its context, the options, the decision, and the consequences. A committed record stays as written. A new record replaces it when the decision changes.

Tests name the requirement they prove

Backend, frontend, and browser tests each name the requirement they verify. Each requirement below links to its tests and their latest result, and the build fails when a requirement has no passing test.

Model steps are scored and traced

Rules with a right answer stay in code. Model steps are scored on labeled synthetic examples, with the dangerous errors counted separately, and each model call is traced with the cost the provider reported.

Changes, in order

  1. October 13, 2026

    Phase 1 · Domain model

    Every later step (compliance checks, capacity checks, agents, approvals, and the dashboard) reads and writes the same core records: owners, pets, vaccine rules, kennels, reservations, and booking requests. A booking request also has to move through a strict lifecycle. A single approved flag can't tell "not checked yet" apart from "rejected" or "crashed halfway," and it can't stop untrusted input from jumping straight to approval. The domain model and lifecycle have to exist, and be enforced by the database and plain code, before any LLM step is added.

    What changed, 25 requirements, 19 of 19 tasks
    • Add a PostgreSQL schema, managed by versioned migrations, for: owners, pets, facilities, vaccine rules, vaccine records, kennels, reservations, booking requests, booking-request transitions, approvals, traces, and spans.
    • Derive a pet's size class from its species and weight using fixed thresholds.
    • Make the database itself reject overlapping reservations for the same kennel.
    • Add a booking-request lifecycle with an explicit table of allowed transitions, including a failed state that records which step failed and why. Retry returns the request to that step.
    • Restrict entry into approved to two paths only: the system, from ready (all deterministic checks clear), or staff, from needs_review.
    • Record every transition in an append-only transition log.
    • Detect concurrent updates to the same booking request and reject the stale one.
    • Add a seed command that loads a synthetic dataset into a development database, and that can safely be run more than once.
    • Add a test convention that links each test to the spec requirement it verifies.

    Requirements

    Tasks

    • ✓Add `sqlalchemy[asyncio]`, `alembic`, and `pytest-asyncio` to the backend. Verify with `uv sync` and `uv run python -c "import sqlalchemy, alembic"`.
    • ✓Add `app/db.py`: an async engine and session factory built from `DATABASE_URL`, rewriting the scheme to `postgresql+psycopg://`. Verify with `tests/test_db.py::test_engine_executes_select_1`.
    • ✓Write [decision record 0003](../../../../docs/decisions/0003-async-database-access.md) (async database access). Verify the file exists and that design D1 links to it.
    • ✓Add the `pawdesk_test` database fixture. It creates the database if missing, runs `alembic downgrade base` then `alembic upgrade head`, and gives each test a session inside a transaction that is rolled back afterward. Verify with `tests/test_isolation.py`: a row committed in one test is absent in the next.
    • ✓Register the `spec(capability, requirement)` pytest marker and add a `conftest.py` hook that writes it into JUnit XML properties. Verify that `uv run pytest --junitxml=…` output contains the marker properties for a marked test.
    • ✓Write [decision record 0004](../../../../docs/decisions/0004-spec-test-traceability.md) (spec-to-test traceability). Verify the file exists and that design D13 links to it.
    • ✓Add `app/enums.py` (Species, Service, SizeClass, plus later enums) and ORM models for facilities, owners, pets, vaccine rules, vaccine records, kennels, and reservations. Verify with `uv run ruff check` and that the models import.
    • ✓Initialize Alembic with the async template and write the first migration: the `btree_gist` extension, all booking-domain tables, the generated `size_class` column, the reservation exclusion constraint, and every CHECK and unique constraint. Verify that a fresh test session runs the downgrade/upgrade round trip.
    • ✓Add the facility time-zone validator. Verify with `tests/test_booking_domain.py`, covering every booking-domain scenario (owner/species/weight rejection, size-class boundaries and recompute, invalid time zone, per-service rules, duplicate rule, vaccine record dates, kennel size class, overlap/back-to-back/different-kennel/zero-night reservations, one reservation per request).
    • ✓Write `tests/test_lifecycle_guard.py`. It enumerates the allowed set from the spec and parametrizes over **every** (from, to, actor) combination, asserting that allowed ones pass and all others raise `IllegalTransition` naming both states. It also covers retry to the recorded failed step versus a different step, `sent` being terminal, and both paths into `approved`. Verify that it collects and fails, since the guard doesn't exist yet.
    • ✓Implement `app/lifecycle.py`: the `State` and `Actor` enums, the allowed-transition table, and the guard function, with no I/O. Verify that `tests/test_lifecycle_guard.py` passes in full.
    • ✓Add models and a migration for booking requests (status, `failed_step`, `last_error`, `version`), the transition log, and approvals, including the failed-state CHECK and the reject-requires-reason CHECK. Verify the round trip and `tests/test_booking_lifecycle.py::test_new_request_starts_received` / `test_unknown_state_rejected`.
    • ✓Implement `app/transitions.py`: `transition()` applies the guard, updates the state, sets or clears the failure fields, appends the log row, and flushes, converting `StaleDataError` into `TransitionConflict`. Verify with `tests/test_booking_lifecycle.py`, covering the logged transition, no log entry on rejection, failure recorded, retry clearing the failure, staff rejecting a failed request, the stale update rejected with exactly one log row, and reject-without-reason rejected.
    • ✓Add models and a migration for traces and spans: kind and status CHECKs, error-requires-message, non-negative tokens and cost, `ended_at >= started_at`, `NUMERIC(14,10)` cost, `JSONB` payloads, and the composite parent foreign key. Verify with `tests/test_run_tracing.py`, covering every run-tracing scenario, including the 8-decimal cost round trip and the cross-trace parent rejection.
    • ✓Implement `scripts/seed.py`: the host allowlist, a single `TRUNCATE … CASCADE`, and the fixed fictional dataset with dates relative to today, all in one transaction. Verify with `tests/test_seed.py`, covering every table populated, all five kennel size classes, the three vaccine edge cases, identical counts after two runs, and a non-local host refused with no changes.
    • ✓Run the seed against the development database. Verify with `uv run python -m scripts.seed`, which prints per-table counts.
    • ✓Make the backend container run `alembic upgrade head` before uvicorn. Verify that `docker compose up --build` reports all services healthy and `/health/db` returns ok.
    • ✓Update `README.md` with the migrate, seed, and test commands. Verify that each documented command runs as written.
    • ✓Run the full suite and lint. Verify that `uv run pytest` and `uv run ruff check . && uv run ruff format --check .` pass, and that every spec requirement in this change has at least one test carrying a matching `spec` marker.
  2. October 14, 2026

    Phase 2 · Deterministic core

    The two questions that decide whether a booking can be accepted, "are this pet's vaccines valid for the whole stay?" and "is a kennel of the right size free every night?", have exact answers. They must be answered by tested code, not by a model. Later LLM steps only supply inputs to these checks (dates, vaccine records). If the checks aren't trusted first, a wrong reply could come from either the model or the rules, with no way to tell which.

    What changed, 13 requirements, 8 of 8 tasks
    • Add a pure compliance check that takes the required vaccines, a pet's vaccine records, and the stay dates. It returns a status for each vaccine (ok, missing, expired, expires_during_stay, not_yet_given, unknown_expiry) and an overall outcome (compliant, not_compliant, needs_review).
    • Fix the date semantics used everywhere:
      • Stay dates are calendar dates in the facility's time zone.
      • A pet is on site every date from check-in through checkout, inclusive.
      • A vaccine's expiry date is the last day it is valid.
    • Add a loader that reads a facility's required vaccines for a service and species, plus a pet's vaccine records, from the database.
    • Add a kennel search that returns a free kennel of an exact size class for every night of a stay, or nothing. When several qualify, the choice is deterministic.
    • Add conversion from an instant in time to a facility's local calendar date.

    Tasks

    • ✓Write [decision record 0005](../../../../docs/decisions/0005-stay-date-semantics.md) (stay date semantics). Verify the file exists and that design D1 links to it.
    • ✓Add `facility_date()` in `app/dates.py`. Verify with `tests/test_dates.py`, covering the Chicago, Tokyo, and naive-datetime scenarios.
    • ✓Write `tests/test_compliance.py`: the boundary table (expiry Mar 5/6/8/9/10 for a Mar 6–9 stay; given Mar 7), day service on the expiry date, booster and unknown-expiry combinations, the precedence order, every outcome rule, extra records ignored, and end-before-start rejected. Verify that it collects and fails against a stub.
    • ✓Implement `app/compliance.py` (value types, `check()`). Verify that `tests/test_compliance.py` passes in full.
    • ✓Add `load_requirements()`, `load_evidence()`, and `check_pet()`. Verify with `tests/test_compliance_db.py`, covering the seeded Pepper (`expired`, `not_compliant`) and Olive (`unknown_expiry`, `needs_review`) scenarios against the seeded test database.
    • ✓Write `tests/test_capacity.py`: partly booked, back-to-back, exact size only, other facility ignored, deterministic choice, zero-night rejected, and the seeded full and free medium windows. Verify that it collects and fails against a stub.
    • ✓Implement `app/capacity.py` (`find_kennel()`, a single `NOT EXISTS` query). Verify that `tests/test_capacity.py` passes in full.
    • ✓Run the full suite, lint, and the spec-coverage check. Verify that `uv run pytest` and ruff pass, and that every requirement in this change has at least one test with a matching `spec` marker.
  3. October 14, 2026

    Phase 3 · LLM tracing

    Every later phase calls a language model: intake, records extraction, the resolver, and the reply. Each of those calls costs money, can fail in several ways, and processes untrusted input. Without one traced client, failures would be handled inconsistently, retries could hammer the provider or double the cost unseen, secrets or raw images could end up in logs, and the dashboard would have nothing to show. Tracing has to exist before the first agent does, so that no model call is ever untraced.

    What changed, 12 requirements, 7 of 7 tasks
    • Add a single async model client that is the only way to call a model. It:
      • refuses to run outside an active trace,
      • applies a timeout to each attempt,
      • retries only transient failures, with exponential backoff and jitter, honoring Retry-After,
      • never retries permanent failures,
      • treats an HTTP 200 that carries an error body as a failure,
      • records exactly one llm span per call.
    • Record the cost reported by the provider as an exact decimal. If the provider doesn't report one, the cost stays empty and is never estimated.
    • Never record secrets. The API key and request headers are never stored, and inline image data in prompts is replaced by a hash and a size.
    • Add tracing helpers: start a trace, and open nested spans for code and tool work. Parent links follow the code structure, including concurrent work.
    • Write spans in their own transaction and commit them immediately, so a span survives even when the work it describes is rolled back. A span is stored as running when it starts and finished when it ends.
    • Add an attributes field to spans for structured metadata (attempts, finish reason, provider, generation id, request parameters), modeled on OpenTelemetry span attributes.
    • Turn the Phase 0 smoke script into a traced call that writes a real span to the development database.

    Tasks

    • ✓Add `spans.attributes` (JSONB, not null, default `{}`) to the model and a migration. Verify with `alembic check` and the attributes default and round-trip tests in `tests/test_run_tracing.py`.
    • ✓Implement `app/tracing.py`: `Tracer` with its own sessionmaker, `start_trace()`, `span()`, and the context variables. Verify with `tests/test_tracing.py`, covering a running span visible from another connection, failing work recorded and re-raised, nested parent, concurrent siblings, and a span surviving a rolled-back business transaction.
    • ✓Write [decision record 0006](../../../../docs/decisions/0006-tracing-and-model-calls.md) (independent span writes; every model call goes through the traced client). Verify the file exists and that design D4 links to it.
    • ✓Add `scrub()` and the failure classification (design D5, D8) with unit tests in `tests/test_llm.py`, covering a data-URL image replaced by its hash and size, and every status in the classification table. Verify that the tests pass.
    • ✓Implement `complete()` in `app/llm.py` (trace required, per-attempt timeout, retry loop with `Retry-After` and full jitter, 200-with-error handling, exact cost, one span per call). Verify with `tests/test_llm.py` against `httpx.MockTransport`, covering every llm-client scenario, including the API-key scan of stored rows.
    • ✓Register a `live` marker excluded by default, add `tests/test_llm_live.py`, and rewrite `scripts/hello_openrouter.py` as a traced call. Verify that `uv run pytest` skips it, that `uv run python -m scripts.hello_openrouter` prints a span id, and that the span row in the dev database has the model, tokens, cost, and status `ok`.
    • ✓Run the full suite, lint, `alembic check`, and the spec-coverage check. Verify that all pass and every requirement in this change has a test with a matching `spec` marker.
  4. October 14, 2026

    Phase 4 · Intake agent

    A booking starts as a free-text message from a pet parent, for example "can biscuit stay fri-mon? he gets anxious at night". Every later step (compliance, capacity, resolution, the reply) needs that turned into exact, typed values: which pet, which service, which dates. Reading messy language is the one job a language model is genuinely good at. It's also where untrusted input enters the system. So the output has to be strictly validated, and the input must be unable to change anything beyond the description of the request.

    What changed, 11 requirements, 7 of 7 tasks
    • Add an intake step that turns a pet-parent message into a validated BookingIntent: pet name, service, start and end dates, special notes, the model's confidence, and anything it found ambiguous.
    • Request structured output from the provider using a strict JSON schema generated from the same Pydantic model used for validation, and route such requests only to endpoints that support them.
    • Always validate the model's output with Pydantic, whatever the provider promised. On a validation failure, make exactly one repair attempt that shows the model its validation errors.
    • Flag instruction-like text in the message with fixed patterns in code, so a detected injection attempt always goes to human review.
    • Run deterministic checks in code after parsing:
      • dates are in order, not in the past, and within a booking horizon;
      • day services fit a single day;
      • the pet name matches one of the owner's pets;
      • low confidence or reported ambiguities send the request to human review.
    • Isolate the untrusted message:
      • it is wrapped as data, never as instructions;
      • oversized messages are refused before any model call;
      • the output schema has no field that could approve, price, or prioritize anything, and unknown fields are rejected.
    • Add a labeled set of 16 messy messages (including 3 prompt-injection attempts) and an evaluation command that runs it against the real model and reports results.

    Tasks

    • ✓In `complete()`, add `provider.require_parameters: true` to the request body only when `response_format` is given. Verify with new tests in `tests/test_llm.py` that inspect the request body for both cases.
    • ✓Add `BookingIntent` (Pydantic, `extra="forbid"`) and `strict_json_schema()` in `app/intake.py`. Verify with `tests/test_intake_schema.py`, covering valid parsing, an extra field rejected, out-of-range confidence rejected, and the strict schema with every field required, no additional properties, and no `title`/`default` keys.
    • ✓Add the versioned prompt in `app/prompts/intake.py` (system rules, today's date and weekday, time zone, message delimited and neutralized). Verify with tests that the system message never contains the customer text and the user message wraps it in the delimiters.
    • ✓Implement `run_intake()`: the oversize refusal, the model call with strict `response_format` and temperature 0, Pydantic validation, one repair carrying the errors, `IntakeInvalidOutput`, date and pet checks in code, the instruction-like-text flag, confidence and ambiguity issues, the outcome, and the `intake` span with prompt version and output. Verify with `tests/test_intake.py` against scripted responses, covering every booking-intake scenario except the labeled set.
    • ✓Add `backend/evals/intake_cases.json` (16 cases, 3 injections) and `scripts/eval_intake.py` with a pure scoring function. Verify with `tests/test_intake_eval.py`, covering the set's shape and scoring per field and in total with scripted answers.
    • ✓Run `uv run python -m scripts.eval_intake` against the real model. Verify that a report is written under `backend/evals/results/` and that the measured totals are recorded (not estimated).
    • ✓Run the full suite, lint, and the spec-coverage check. Verify that all pass and every requirement in this change has a test with a matching `spec` marker.
  5. October 14, 2026

    Phase 5 · Records agent

    Vaccine compliance is only as good as its inputs. Pet parents send photos of vaccine certificates, not structured records, and those photos are blurry, rotated, handwritten, or missing fields. Reading an image is a job for a vision model. But a wrong date silently turns into a wrong compliance decision, and an invented expiry date is the most dangerous error of all, because it can make an expired pet look covered. So extraction needs strict validation, explicit handoff to a person when in doubt, and above all measurement: an accuracy number from a repeatable evaluation, not an impression from a few demos.

    What changed, 12 requirements, 9 of 9 tasks
    • Add a records step that sends a certificate image to the vision model and returns validated vaccine records in the same VaccineEvidence shape the compliance check already uses:

      • each vaccine as it maps to the facility's vocabulary, plus the label as written;
      • the date given;
      • the expiry date, or empty when the certificate doesn't print one.
    • Never infer an expiry date. A missing expiry stays empty, so compliance reports it as unknown_expiry and a person looks.

    • Check the extraction in code:

      • image type and size limits before any model call;
      • dates that are impossible or in the future;
      • vaccine labels that don't map to a known vaccine;
      • a pet name on the certificate that doesn't match the pet;
      • low confidence, or the model reporting the image as hard to read.

      Any issue sends the result to human review.

    • Factor the "validate, then repair once" logic out of intake into a shared structured-extraction helper used by both steps.

    • Add a synthetic certificate generator. It renders HTML templates to PNG in three layouts, with blur, rotation, handwriting-style fonts, and missing fields. Each image is saved with its ground truth and visibly marked as a synthetic test document.

    • Add an evaluation that runs the records step over 36 generated certificates and reports:

      • per-field accuracy;
      • invented expiries;
      • decision agreement: whether compliance computed from the extracted records matches compliance computed from the ground truth.

      Results are broken down by variant.

    Decisions it relied on

    Tasks

    • ✓Add `app/structured.py` (`extract()`, `InvalidModelOutput`, strict schema and response format moved from intake). Switch `app/intake.py` to it, with `IntakeInvalidOutput` subclassing `InvalidModelOutput`. Verify that every existing intake test passes unchanged, and add `tests/test_structured.py` for the helper.
    • ✓Add `CertificateExtraction` / `ExtractedRecord` and the versioned prompt (`app/prompts/records.py`). Verify with schema tests (extra fields rejected, confidence range, strict schema) and prompt tests (no-inference rule, vocabulary, image part present).
    • ✓Implement `run_records()` in `app/records.py`: image type and size checks by magic bytes, the model call through the shared helper, code checks (dates, unmapped labels, pet name, no records), doubt issues, the outcome, conversion to `VaccineEvidence`, and the `records` span with prompt version and image hash. Verify with `tests/test_records.py` against scripted answers, covering every vaccine-records-extraction scenario.
    • ✓Add `playwright` and `pillow` as dev dependencies, install Chromium, vendor OFL fonts with their licenses, and implement the pure ground-truth generator. Verify with `tests/test_certificates.py`, covering deterministic truth, variant coverage, synthetic marking in the rendered HTML, and fictional names.
    • ✓Render the 36-certificate set with `uv run python -m scripts.generate_certificates`. Verify that 36 PNGs and `truth.json` exist, and that the loaded-set test (each image has truth, at least 30) passes.
    • ✓Implement `scripts/eval_records.py` with pure matching, scoring, invented-expiry, and decision-agreement functions. Verify with `tests/test_records_eval.py` against the spec's scoring scenarios, plus a scripted end-to-end run on two certificates.
    • ✓Write [decision record 0007](../../../../docs/decisions/0007-evaluating-llm-steps.md) (how LLM steps are evaluated). Verify the file exists and that design D7 links to it.
    • ✓Run `uv run python -m scripts.eval_records` against the real model. Verify that a report is written under `backend/evals/results/` and that the measured totals are recorded (not estimated).
    • ✓Run the full suite, lint, and the spec-coverage check. Verify that all pass and every requirement in this change has a test with a matching `spec` marker.
  6. October 14, 2026

    Certificate benchmark v2

    The first certificate benchmark (v1) measures field accuracy well, but its decision metric is weak. For the reference stay, the correct compliance outcome is not_compliant for 28 of 36 certificates, compliant for 5, and needs_review for 3, because randomly drawn dates are usually expired. A records step that leaned toward "not compliant" would score almost perfect decision agreement on v1 while being dangerous in practice. Decision agreement only means something when the correct answers are mixed, and when many certificates sit right at the boundary where a one-day misread flips the result.

    What changed, 2 requirements, 5 of 5 tasks
    • Add benchmark v2: 36 certificates, the same layouts and variants, generated by first choosing each certificate's correct decision and then constructing records that produce it. The mix is balanced across compliant, not_compliant, and needs_review, with deliberate boundary cases (an expiry on checkout day versus the day before).
    • The generator checks every v2 certificate's decision against the compliance check before writing anything.
    • Keep v1 unchanged alongside it, so earlier reports stay reproducible. Each truth file names its benchmark version, and evaluation reports name the version they ran on. The evaluation defaults to the newest benchmark.

    Decisions it relied on

    Tasks

    • ✓Add `make_truth_v2()` (targets first, self-verifying against the compliance check) and a version argument to `scripts/generate_certificates.py`, leaving v1 generation byte-for-byte unchanged. Verify with `tests/test_certificates.py`, covering v2 determinism, the decision mix, boundary coverage, and a failed self-check raising.
    • ✓Render v2 with `uv run python -m scripts.generate_certificates v2`. Verify that `tests/test_certificate_set.py` passes for both v1 (unchanged) and v2 (36 images with truth and a version field).
    • ✓Add a version argument to `scripts/eval_records.py` (default `v2`) and a pure `build_report()` that records the benchmark version. Verify with `tests/test_records_eval.py` (report names `v2`; the default is v2).
    • ✓Run `uv run python -m scripts.eval_records` against the real model on v2. Verify that the report is written and that the measured totals are recorded (not estimated).
    • ✓Run the full suite, lint, and the spec-coverage check. Verify that all pass and both new requirements have matching `spec` markers.
  7. October 14, 2026

    Phase 6 · Orchestrator

    PawDesk now has every building block of a booking decision: intake reads the message, records reads the certificate, compliance and capacity decide. But nothing runs them together. Running them together raises the hard questions of real systems. What runs in parallel? What happens when the process dies halfway through a run that has already paid for two model calls? How do we avoid repeating, or doubling, work after a restart? The orchestrator answers these with plain code: a fixed workflow, not an LLM deciding what to do next. That keeps every booking's path predictable, testable, and safe to resume.

    What changed, 10 requirements, 8 of 8 tasks
    • Store a certificate image with a booking request (one per request, with the same type and size limits as the records step).
    • Add an orchestrator that drives a request from received through extraction and checks:
      • Extraction: intake and records run concurrently.
      • Checks:
        • the certificate's pet name is compared with the pet intake matched;
        • compliance combines the pet's records on file with the extracted ones;
        • capacity looks for a kennel of the pet's size class for boarding.
      • Routing: the request moves to drafting when everything is clear, or to resolving with a list of reasons.
    • Make execution durable:
      • each step's result is saved in the same transaction as its state change;
      • a step whose result is already saved is never run again;
      • a request interrupted mid-run resumes from its last saved step.
    • Step failures move the request to failed, keeping the error. A retry resumes at the failed step.
    • On resume, spans left running by a process that died are closed as abandoned.
    • Add command-line entry points: submit a request with a certificate, process one request, and resume interrupted requests.
    • Record two decisions: why PawDesk is a hybrid workflow rather than a fully agentic system, and how durable execution works.

    Tasks

    • ✓Add the `booking_documents` and `booking_step_results` models and a migration, plus `attach_certificate()` with magic-byte and size checks. Verify with `tests/test_orchestration_storage.py`, covering image round-trip with hash, type and size, a second certificate rejected, a PDF refused, and a duplicate step result rejected.
    • ✓Implement `process_request()` in `app/orchestrator.py`: the received → extracting checkpoint, concurrent extraction with no session held during the model calls, checks (pet cross-check, compliance with on-file plus extracted records, capacity for boarding), routing with reasons, and saved results with transitions in one transaction. Verify with `tests/test_orchestrator.py` against scripted model answers, covering concurrency, no certificate, every check scenario, every routing scenario, and tracing.
    • ✓Add resume and failure behavior: skip steps that have saved results, the crash hook, failures moving to `failed`, retrying from the failed step, and finished requests left unchanged. Verify with the crash-after-extraction (no model calls on resume), crash-during-extraction (exactly one result per step), finished-request, failure, and retry tests.
    • ✓Close abandoned spans when processing starts. Verify with tests for a span left running being closed as abandoned, and for finished spans being left untouched.
    • ✓Write [decision record 0008](../../../../docs/decisions/0008-hybrid-workflow-over-agentic.md) (hybrid workflow over agentic) and [decision record 0009](../../../../docs/decisions/0009-durable-execution-with-step-checkpoints.md) (durable execution). Verify both exist and that design D1/D2 link to them.
    • ✓Add `scripts/bookings.py` with `submit`, `process`, and `resume` (with an optional `--crash-after STEP` hard stop). Verify with a test of `submit` storing the request and certificate.
    • ✓Real end-to-end: submit a seeded owner's message with a benchmark v2 certificate, process it, and confirm routing. Then a real kill: submit another, `process --crash-after extracting`, `resume`, and confirm it completes without new extraction model calls (span counts). Verify by recording the observed states and span counts.
    • ✓Run the full suite, lint, `alembic check`, and the spec-coverage check. Verify that all pass and every requirement in this change has a test with a matching `spec` marker.
  8. October 14, 2026

    Phase 7 · Resolver agent

    After checking, a request lands in resolving when something stands in the way: no kennel is free for those nights, or a required vaccine won't cover the stay. Today it simply stops there. A good front desk doesn't just say no. It looks for what would work: the same stay a few days later, a larger kennel, or an updated vaccine record. Finding and combining those options is open-ended enough to be a fit for a small agent: a model that calls tools in a loop. But an agent is the least predictable pattern there is (decision record 0008), so this one is fenced in:

    • it reads no untrusted text;
    • its tools only read;
    • its only output is a proposal that code re-checks;
    • it stops when it's done, stuck, or out of steps.
    What changed, 8 requirements, 8 of 8 tasks
    • Tool calling in the model client: tools can be offered on a model call, and the model's requested calls come back as structured data. They are recorded on the span.
    • A resolver agent with a hand-written loop:
      • call the model, run the tools it asks for, append the results, repeat;
      • stop at a step limit;
      • stop on a stuck guard: the same tool called again with the same arguments.
    • Four read-only tools, bound to the request so the model can only choose dates or sizes:
      • find_alternative_dates: nearby stays of the same length;
      • list_missing_vaccines: what doesn't cover a given window;
      • check_kennel_sizes: free kennels per size class;
      • propose_offer: the only way to finish.
    • Proposals are verified by code: each proposed option is re-checked against capacity and compliance and marked verified or not, with the problems that would remain.
    • Code decides whether the agent runs at all: only problems an alternative can fix (capacity, vaccine coverage) go to the agent. Anything else (an unknown pet, unreadable input, suspected injection) goes straight to a person, with no model call.
    • The orchestrator continues past resolving: the resolution is saved with the move to drafting, durably like the other steps. Failures move the request to failed and are retried as before.
    • A decision record for why the agent is bounded and proposal-only, and why its loop is hand-written.

    Tasks

    • ✓Add tool calling to `complete()`: a `tools` parameter, `ToolCall` and `assistant_message` on `LLMResult`, tool calls in the span output, and `require_parameters` when tools are offered. Verify with `tests/test_llm.py` tests for a scripted tool-call response and for the routing preference. Add an opt-in live test that the configured model calls a trivial tool.
    • ✓Add `resolution_scope()` (eligibility) and the prompt builder in `app/resolver.py` and `app/prompts/resolver.py`. Verify with `tests/test_resolver.py` for: full nights eligible, unknown pet, suspected injection, `unknown_expiry` not attempted; and a test that the customer's message text never appears in the prompt.
    • ✓Implement the four tools with bound context and validated arguments. Verify with database tests: alternative dates three days later, an expired bordetella window, kennel sizes with medium full and large free, an invalid date range, a smaller kennel refused.
    • ✓Implement the loop with the stuck guard, the step limits, unknown-tool and invalid-argument handling, and verification of the proposal. Verify with scripted model turns: proposal after two calls, a repeated call stuck with no further model call, exactly 6 calls at the step limit, no proposal, `approve_booking` answered as unknown, verified and unverified options.
    • ✓Add the `resolution` step (migration for the step constraint) and the resolving step to the orchestrator: saved with the move to `drafting`, failures to `failed`, and retry. Update the existing orchestration tests for the new end state. Verify with `tests/test_orchestrator.py` for: resolution saved with the move, not attempted with no model call, model unavailable then retried.
    • ✓Write [decision record 0010](../../../../docs/decisions/0010-bounded-proposal-only-resolver.md). Verify it exists and that design D1 links to it.
    • ✓Real runs from the command line: a Biscuit boarding request whose nights are full for every medium kennel (fill them with reservations in the dev database), and the existing Moose request left in `resolving` (expired bordetella). Verify by recording each outcome, the proposal and its verification, turns, tool calls, and cost.
    • ✓Run the full suite, lint, `alembic check`, and the spec-coverage check. Verify that all pass and every requirement in this change has a test with a matching `spec` marker.
  9. October 14, 2026

    Phase 8 · Reply approval

    Every request now reaches drafting with a full record of what was checked and, where something stood in the way, a verified proposal. Two things are missing before PawDesk can act:

    • a reply to the pet parent;
    • a decision about who may let that reply go out.

    The reply is human-facing text, which is a job for a model. But a reply is also a promise: a message saying "Biscuit is booked for March 10–13" commits the facility. So the reply must never promise anything the checks didn't confirm, and anything that needed a closer look must wait for a person. That person must be able to approve the reply, fix it, or turn the request down. Their action must resume the run from where it paused.

    What changed, 7 requirements, 6 of 6 tasks
    • A reply agent drafts a warm, clear message (subject and body) from structured facts only: the owner's and pet's names on file, the service and dates, and what the checks and resolver found. Customer text is never sent to it.
    • A deterministic promise check runs on every draft. It flags:
      • dates the checks didn't confirm;
      • commitment words ("booked", "confirmed", "reserved", "approved") in a reply that confirms nothing;
      • any price.
    • Code decides where a draft goes:
      • it goes to needs_review when the request came through resolving or the draft fails the promise check;
      • otherwise it goes to ready, is approved by the system, and is sent.
      • The reasons for a flag are saved.
    • Staff approval API:
      • list the requests waiting for review, with their flag reasons;
      • approve, edit and approve, or reject with a reason.
      • Each action is recorded as an Approval with the staff member's name, and it resumes the run.
    • Sending:
      • the final reply (or a decline for a rejected request) is recorded as the outbound message, once per request;
      • for a confirmed boarding request, the kennel is reserved in the same transaction;
      • a kennel taken in the meantime fails the step instead of double-booking.
    • A decision record for where the human gate sits.

    Tasks

    • ✓Add `promise_issues()`, `reply_facts()`, and the situation rules in `app/reply.py`, with the prompt in `app/prompts/reply.py`. Verify with `tests/test_reply.py` for: unconfirmed date, commitment without confirmation, price, clean reply, only verified options in the facts, `under_review` when nothing is offered.
    • ✓Add the `reply` step result and `outbound_messages` (migration), and the drafting, ready, approved, and rejected steps in the orchestrator. Update the existing orchestration tests. Verify with `tests/test_orchestrator.py` for: clean boarding request to `sent` with one reservation and one message, a resolved request waiting in `needs_review` with a resolution reason, a failed promise check waiting, a kennel taken before sending, a waiting request left alone, and the customer's message text never reaching the reply model.
    • ✓Add `app/approvals.py` and the API routes. Verify with `tests/test_approvals_api.py` for: the queue, approve, edit and approve, reject with and without a reason, and acting twice.
    • ✓Write [decision record 0011](../../../../docs/decisions/0011-human-gate-before-sending.md). Verify it exists and that design D1 links to it.
    • ✓Real runs: a clean request processed to `sent`, and a flagged request approved through the API to `sent`. Verify by recording the states, the drafted replies, the promise-check findings, and cost.
    • ✓Run the full suite, lint, `alembic check`, and the spec-coverage check. Verify that all pass and every requirement in this change has a test with a matching `spec` marker.
  10. October 14, 2026

    Phase 9 · Dashboard

    PawDesk now makes real decisions with models in the loop. An operator can only trust that if they can see, for any request:

    • what happened and in what order;
    • what each model was asked and answered;
    • what it cost, how long it took;
    • why a request was stopped for review.

    Everything needed is already recorded (traces, spans, step results, transitions, approvals). It is only visible through SQL today. The dashboard makes it visible live, and lets someone submit a request (including an injection attempt) and watch the system handle it.

    What changed, 9 requirements, 10 of 10 tasks
    • Observability API (backend):
      • list and inspect requests with their states, step results, transitions, and every span;
      • stream a run's progress as server-sent events;
      • metrics: cost per request and per model step, latency, flag rate, requests per state;
      • saved eval reports;
      • a demo endpoint that submits a sample request and processes it in the background.
    • Dashboard (frontend):
      • Requests home with headline metrics.
      • Live run view:
        • a graph of the workflow whose nodes light up as steps run, colored by kind (model, code, tool, human);
        • a timeline waterfall where parallel steps visibly overlap;
        • a span inspector with the full input, output, tokens, cost, latency, and error.
      • Review queue with flag reasons and approve, edit, or reject.
      • Metrics charts and an Evals page.
      • Demo panel with sample requests, including an injection attempt.
    • The backend address stays server-side: the browser talks only to a Next.js route that forwards an allowlisted set of API paths.
    • Frontend tests are linked to specs too, with a decision record extending the traceability convention.

    Tasks

    • ✓Add `app/observability.py` (request list and detail, metrics, eval reports) and their routes. Verify with `tests/test_observability_api.py`: list with cost and flag, whole-run detail, 404, cost per step, flag rate, reports listed.
    • ✓Add the server-sent events stream. Verify with tests for progress then end, and for a resting request.
    • ✓Add demo samples and background submission with an injectable model client. Verify with tests for submitting the injection sample (processed to review with a scripted model) and an unknown sample (404, nothing stored).
    • ✓Add vitest with JUnit output and the `specTest` helper, `@xyflow/react`, and `recharts`. Write [decision record 0012](../../../../docs/decisions/0012-frontend-test-traceability.md). Verify `pnpm test` writes a JUnit report with spec-tagged names.
    • ✓Add the allowlisted proxy route (`lib/proxy.ts` + `app/api/[...path]/route.ts`). Verify with unit tests for allowed and refused paths, streaming pass-through, and no backend call for refused paths. (The check that no client component reads the backend address lands with the components in 2.4.)
    • ✓Add `lib/graph.ts`, `lib/timeline.ts`, and `lib/format.ts`. Verify with unit tests for: parallel running nodes, skipped resolver, waiting for review, overlapping bars, nesting depth, and inspector formatting.
    • ✓Build the pages: home, live run view (graph, timeline, inspector, SSE), review queue, metrics, evals, demo. Verify that `pnpm lint`, `tsc`, and `pnpm build` pass.
    • ✓Write [decision record 0013](../../../../docs/decisions/0013-live-updates-sse-from-database.md). Verify it exists and that design D1 links to it.
    • ✓Real demo in a browser: submit the clean and injection samples from the demo page, watch both live, approve the flagged one from the review page. Verify with screenshots and by recording the outcomes.
    • ✓Run the backend suite, the frontend tests, lint, type check, build, `alembic check`, and the spec-coverage check over both JUnit reports. Verify all pass and every requirement in this change has a linked test.
  11. October 14, 2026

    Phase 9b · Build walkthrough

    A finished project says little about how it was built. Claims like "spec-driven" or "fully tested" are easy to write and hard to check. The repository already holds the evidence:

    • every change, archived with its proposal, design, and tasks;
    • every requirement, with WHEN/THEN scenarios;
    • every decision record;
    • tests that each name the requirement they verify.

    A reviewer should be able to follow that evidence without reading the repository: from a change to the requirements it added, from a requirement to the tests that verify it and their latest result, and from a change to the decisions it relied on. Generating the page from those files, instead of writing it by hand, keeps it honest.

    The coverage check run at the end of every change has so far been a one-off script. The generator makes it part of the project.

    What changed, 5 requirements, 6 of 6 tasks
    • A generator (backend/scripts/walkthrough.py) reads only the public sources:

      • the archived changes;
      • the current specs;
      • the decision records;
      • the backend and frontend JUnit reports.

      It writes frontend/content/walkthrough.json. Its --check mode fails when a requirement has no passing linked test, or a test names a requirement that doesn't exist.

    • The build walkthrough pages, separate from the runtime dashboard:

      • methodology and change timeline: each archived change with its date, motivation, what changed, tasks, the requirements it added or changed, and the decisions it relied on;
      • spec browser: each requirement with its scenarios, its linked backend and frontend tests, and their latest result;
      • decisions: every decision record, rendered in full.
    • A decision record for generating the page into a committed file.

    Tasks

    • ✓Implement `scripts/walkthrough.py`: parse archived changes, specs, decisions, and both JUnit reports from the listed sources only, and write the JSON. Verify with `tests/test_walkthrough.py` against a synthetic repository: every change in date order with tasks and requirement deltas, private marker absent, verified, failing, and untested requirements, decision links.
    • ✓Add `--check`. Verify with tests for a gap (untested, unknown name) exiting 1 and for complete coverage exiting 0.
    • ✓Add `lib/walkthrough.ts` (anchors and links) with unit tests for stable, distinct anchors and change-to-requirement links. Add `react-markdown`, then build `/build`, `/build/specs`, and `/build/decisions`, plus a nav link. Verify that lint, the type check, the tests, and the build pass.
    • ✓Write [decision record 0014](../../../../docs/decisions/0014-walkthrough-generated-into-a-committed-file.md). Verify it exists and that design D1 links to it.
    • ✓Generate the real `walkthrough.json` from fresh test reports, run `--check`, and verify in a browser that every archived change is on the timeline and that following a requirement reaches its tests and results.
    • ✓Run the backend suite, the frontend tests, lint, type check, build, and `--check` (which now replaces the one-off coverage script). Verify all pass.
  12. October 14, 2026

    Phase 10 · Architecture comparison

    PawDesk is a hybrid workflow: code fixes the order, models only read and write, code decides, and a single fenced agent may only propose (decision records 0008 and 0010). That choice was argued from principles: error compounding, injection risk, testability. It was never measured against the obvious alternative: one autonomous agent given every tool and the whole request, deciding everything itself.

    The comparison is worth measuring because the alternative is simpler to build and might be good enough. The comparison has to be fair:

    • the same cases;
    • the same model;
    • the same deterministic checking tools;
    • the same expected answers;
    • failures, including injection attempts, counted the same way.
    What changed, 4 requirements, 7 of 7 tasks
    • An autonomous agent (app/autonomous.py). One model gets:

      • the customer's message and certificate image;
      • read-only tools: the owner's pets, a vaccine check, a kennel check, alternative dates;
      • a final decision tool: confirm, offer alternatives, or hand to a person.

      It runs in a loop with the same step limits and stuck guard as the resolver. It writes nothing.

    • A comparison set of booking cases on seeded owners and benchmark certificates. It includes clean requests, failing and borderline vaccines, a full-kennel request, an unknown pet, unclear dates, and three injection attempts. Each case's expected outcome is computed by running the deterministic checks on the case's true facts (certificate ground truth, the pet on file), not labeled by hand.

    • A lab runner (scripts/compare_architectures.py):

      • creates and seeds a separate lab database;
      • runs every case through both architectures, stopping the hybrid workflow before anything is sent;
      • writes a dated report with accuracy, unsafe confirmations, injection handling, cost, latency, model calls, and failures per architecture.
    • The dashboard's Evals page shows the comparison side by side.

    • A decision record with the measured conclusion.

    Tasks

    • ✓Implement `app/autonomous.py` and `app/prompts/autonomous.py`: bound read-only tools, the loop with limits and the stuck guard, and the final decision. Verify with `tests/test_autonomous.py` against scripted turns: confirm after four calls with nothing written, a repeated call stopping as `needs_human`, and a vaccine check combining on-file and certificate records.
    • ✓Add `evals/comparison_cases.json` and the pure parts of `scripts/compare_architectures.py`: expected outcomes from true facts, outcome mapping, and scoring. Verify with `tests/test_compare_architectures.py`: confirmable truth, injection never confirmable, unsafe confirmation counted, hybrid outcome mapping, and the lab guard refusing non-local hosts.
    • ✓Add the lab runner (database creation, migration, seeding, both architectures, report) and a scripted end-to-end test on two cases that also shows nothing is sent.
    • ✓Show comparison reports side by side on the dashboard's Evals page. Verify with lint, the type check, the build, and a frontend test of the report shaping.
    • ✓Run the comparison for real over the whole set. Verify by committing the report and recording the numbers.
    • ✓Write [decision record 0015](../../../../docs/decisions/0015-hybrid-over-autonomous-measured.md) from the measured results. Verify it exists and that design D5 links to it.
    • ✓Run both suites, lint, type check, build, `alembic check`, and the walkthrough coverage check. Regenerate the walkthrough. Verify all pass.
  13. October 14, 2026

    Phase 11 · Ci

    Every check so far runs on one developer machine, by hand:

    • the backend suite against a real database;
    • the frontend tests, lint, type check, and build;
    • the spec-coverage check;
    • the browser demo.

    A change that breaks any of them is only caught if someone remembers to run it. Continuous integration runs all of them on every push and pull request.

    Two of the checks need care in CI:

    • The end-to-end demo makes real model calls. In CI those would cost money, need a secret, and give different answers from run to run, so CI must replay recorded responses instead.
    • CI must never be the way a secret leaks into the repository.

    The plan also calls for a README with an architecture diagram and a demo GIF, so a reader sees what the project does before reading any code.

    What changed, 5 requirements, 8 of 8 tasks
    • Recorded model responses. The model client can run in record mode (call the model and save each response under a hash of the exact request) or replay mode (answer only from saved responses and never touch the network). A request with no recording fails loudly.
    • End-to-end tests (Playwright, against the real backend and dashboard on a fresh, seeded database, with replayed responses):
      • the clean demo sample runs live to sent;
      • the injection sample stops for review and is approved from the review queue to sent.
    • A secret check:
      • a test fails if any tracked file contains something shaped like a real API key;
      • CI also runs a dedicated secret scanner over the history.
    • A GitHub Actions pipeline with these jobs:
      • backend: lint, migrations, alembic check, tests against a Postgres service container;
      • frontend: lint, type check, unit tests, build;
      • end-to-end tests with replayed responses;
      • the walkthrough's coverage check over all three test reports;
      • the secret scan.
    • The walkthrough counts end-to-end tests alongside backend and frontend tests.
    • The README gains an architecture diagram, a demo GIF made from the end-to-end run's screenshots, the measured results, and how to run everything.
    • A decision record for testing with recorded model responses.

    Tasks

    • ✓Add `app/cassettes.py` and the cassette settings, and wire them into the default client. Verify with `tests/test_cassettes.py`: record then replay returns the same response with no network call, and a missing recording fails without retry, naming the key.
    • ✓Add `tests/test_secrets.py`. Verify that it fails on a planted key-shaped value in a temporary repository and passes on the real one.
    • ✓Extract `scripts/lab_database.py` from the comparison lab, and add `scripts/e2e_server.py`. Verify that the comparison tests still pass.
    • ✓Add Playwright with two spec-tagged tests and JUnit output. Record the responses once against the real model, then verify the tests pass in replay mode.
    • ✓Teach the walkthrough generator the `e2e` suite. Verify with a generator test where only an end-to-end test verifies a requirement.
    • ✓Add `.github/workflows/ci.yml` and `tests/test_ci_workflow.py`. Verify the structural test, and run each job's commands locally.
    • ✓Write [decision record 0016](../../../../docs/decisions/0016-recorded-model-responses-for-ci.md), add `scripts/make_demo_gif.py`, generate `docs/demo.gif`, and rewrite the README with an architecture diagram and the measured results. Verify that the GIF exists and that the README links resolve.
    • ✓Run every check, including the end-to-end tests and the walkthrough check over three reports. Regenerate the walkthrough.
  14. October 14, 2026

    Fix production images

    The production backend image crashed on startup with No module named 'httpx'. The app imports httpx directly, but it was declared only as a development dependency. It used to arrive indirectly with the model SDK, and the current SDK version no longer brings it. The image installs runtime dependencies only (uv sync --no-dev), so the gap appeared only there.

    Every check passed regardless, because tests, the end-to-end server, and the dev server all run with development dependencies installed. Nothing built or started the production images.

    What changed, 1 requirements, 2 of 2 tasks
    • httpx and pydantic (also imported directly, previously present only through other packages) become declared runtime dependencies.
    • A test fails when app/ imports any third-party package that isn't a declared runtime dependency.
    • The pipeline gains an images job. It builds the backend and frontend production images, and imports the app inside the backend image, which has runtime dependencies only.

    Requirements

    Tasks

    • ✓Declare `httpx` and `pydantic` as runtime dependencies, and add `tests/test_runtime_dependencies.py`. Verify that the test fails on the old dependency list and passes on the new one.
    • ✓Add the `images` job to the pipeline and extend `tests/test_ci_workflow.py`. Verify by building both images locally, importing the app inside the backend image, and checking every dashboard route against the rebuilt container.
  15. October 14, 2026

    Demo generated requests

    The Try page offers four fixed samples, and its vaccine certificates can't be seen: the page says a card "is attached" without showing it. That falls short in two ways:

    • Showing the system. A reader can't check what the records step was supposed to read, so they can't judge whether it read it right.
    • Exploring the system. Four fixed cases show four fixed paths. A request made up on the spot shows the desk handling something it was never tuned on.
    What changed, 7 requirements, 5 of 5 tasks
    • Generate a random request:
      • a seeded owner and one of their pets;
      • a service that pet can have;
      • dates soon after the demo day;
      • a message in a pet parent's words;
      • a vaccine certificate that matches all of it.
    • The card follows one scenario, stated in plain words:
      • every vaccine current;
      • one expired before the stay;
      • one expiring during the stay;
      • one missing from the card.
    • Reproducible: a seed fully determines the request and its card, so the same seed always gives the same request and image.
    • See the card before sending, for generated requests and for the fixed samples.
    • Send a generated request. It runs exactly like a sample, with its card attached.

    Tasks

    • ✓Add `app/demo_generator.py` (request, scenario, and records from a seed) and `app/certificate_card.py` (the card drawn with Pillow), and move Pillow to the runtime dependencies. Verify with `tests/test_demo_generator.py` against a seeded database: same seed gives an identical request and image, seeds vary pets, services, and scenarios, the card names the pet and owner, and scenario dates checked alone give the expected compliance status.
    • ✓Add the preview routes and accept `{seed}` on demo submission. Verify with API tests: a generated certificate PNG equal to the one attached on send, 404 for a sample without a certificate, and sending a seed stores the request with its card and processes it with a scripted model.
    • ✓Extend the proxy allowlist and the Try page (generate, preview message, scenario, and card, generate another, send). Verify with frontend tests for the new proxy paths and the submission body, then lint, type check, build, and the end-to-end demo.
    • ✓Try it for real in a browser: generate requests, check each card against its request, and send one. Verify with screenshots and by recording the outcome.
    • ✓Run every suite and the coverage check, then regenerate the walkthrough.
  16. October 14, 2026

    Path step tooltips

    The run view's path shows nine steps with a status each ("Done", "Skipped", "Working"), but not what a step is for or what it found. A reader has to know the architecture already, or open the inspector and read raw JSON. The path should explain itself at a glance:

    • What the step does, in one sentence, for someone seeing the desk for the first time.
    • What it found in this run, in one line: what intake read, which vaccine failed, which kennel was free, who approved.
    What changed, 1 requirements, 4 of 4 tasks
    • Hovering or focusing a step in the path shows a tooltip with what the step does and, once it has run, a short summary of its result for this request.
    • Steps that haven't run, or were skipped, say so and why, where the request shows it.
    • The summary is built from the request already loaded. No new requests and no new API fields. It updates live with the run.
    • Steps become buttons: keyboard users can tab to a step, read the same tooltip, and open it in the inspector with Enter.

    Requirements

    Tasks

    • ✓Add `lib/step-summary.ts` with `stepSummary(id, status, detail)`. Verify with spec-tagged unit tests: every step's description, results for a clean run, an expired vaccine, no free kennel, a skipped resolver, a review by staff, waiting and running steps, and missing fields falling back to plain text.
    • ✓Give the path a custom step node (a button), the tooltip outside React Flow, and Escape to hide it, and pass the request detail from the run view. Verify with an end-to-end test that hovers and focuses a step on the clean sample's run, then lint, type check, and build.
    • ✓Try it for real in a browser on runs with different outcomes. Verify with screenshots of the tooltip near the graph's edges and at phone width.
    • ✓Run every suite and the coverage check, then regenerate the walkthrough.
  17. October 14, 2026

    Pet profile photos

    The requests list and the run page identify a request by the pet's name in text. A photo makes each row recognizable at a glance and makes the demo read as a pet care desk rather than a log viewer. The photo belongs to the pet, so the same pet looks the same on every request, and it has to match what the pet is: a Beagle shows a Beagle, a cat never shows a dog.

    What changed, 7 requirements, 8 of 8 tasks
    • The request list and request detail report the matched pet's id, species, and breed alongside its name.
    • The dashboard picks a photo for each matched pet: a photo of its breed when the set has one, otherwise a stable photo of its species chosen from the pet's id.
    • A request with no matched pet (before intake, or a pet not on file) shows a neutral placeholder, never a photo.
    • The photo appears as an avatar on each row of the requests list and beside the pet's name on the run page. On a live run, it appears as soon as intake matches the pet.
    • A small set of dog and cat photos is committed with the dashboard. Every photo comes from Wikimedia Commons under CC0 or a public-domain dedication, is recorded in a manifest with its source, author, and license, and is re-encoded without embedded metadata.

    Tasks

    • ✓Add `pet_id`, `pet_species`, and `pet_breed` to `list_requests` and `request_detail` (null when no pet is matched), and to `RequestRow` and `RequestDetail` in `frontend/lib/types.ts`. Verify with API tests tagged `observability-api` / "Requests identify the matched pet" for a matched and an unmatched request, plus the existing API suite and the frontend type check.
    • ✓Add `backend/scripts/fetch_pet_photos.py` (hand-picked Commons titles; license check; 640 px thumbnail, 320 × 320 center crop, JPEG without EXIF or XMP; writes `frontend/public/pets/` and `frontend/lib/pet-photos.json`). Unit-test its license filter and crop on a local image with fake metadata, with no network. Verify with `pytest tests/test_fetch_pet_photos.py`.
    • ✓Choose the titles (one per seeded breed where a CC0 or public-domain photo exists, plus general photos for each species), confirm the file list, source, and total size before downloading, then run the script once. Verify with the manifest tests (2.3) and by looking at every photo.
    • ✓Add `frontend/tests/pet-photos-manifest.test.ts` for "Every committed photo is licensed for reuse and recorded" and "Committed photos carry no embedded metadata". Verify with `pnpm test`.
    • ✓Add `frontend/lib/pet-photo.ts` (breed match within species, else an FNV-1a pick from the pet id, else none) with unit tests for every scenario of "A matched pet shows a photo of its breed" and "Other pets show a stable photo of their species". Verify with `pnpm test`.
    • ✓Add a `PetAvatar` component (photo with alternative text naming the pet, or the inline paw placeholder) and use it on the requests list rows and the run page heading. Verify with lint, type check, build, and a screenshot of both pages with a matched and an unmatched request.
    • ✓Extend the end-to-end demo with checks tagged `pet-photos` / "Photos appear on the requests list and the run page" and "A request without a matched pet shows a placeholder": Biscuit's Beagle photo on the run page and on the list, and the placeholder for the unknown-pet sample. Verify with `pnpm e2e`.
    • ✓Run every suite and the traceability check, regenerate the README demo GIF from the new frames, and regenerate the build walkthrough. Verify with `pytest`, `pnpm test`, `pnpm e2e`, and `uv run python -m scripts.walkthrough --check`.
  18. October 14, 2026

    Plain interface copy

    The dashboard's copy reads as templated. It leans on stock patterns: slogans built on a contrast ("Models are measured, not trusted"), three-part runs ("The same requests, the same model, and the same checking tools"), filler adverbs ("exactly"), and passive sentences that hide who acted ("Every request so far was decided by the checks").

    The passive voice costs more than style here. The desk's main safety property is who approves a booking: the checks or a named person, never a model. A sentence like "A polite decline was sent" leaves the reader to work out who decided.

    Two strings also state things that are false:

    • The build page says "Nothing on these pages is written by hand," but its method section is hand-written.
    • A failed send reads "Stopped while approved," which shows a raw state name.
    What changed, 5 requirements, 7 of 7 tasks
    • Rewrite the dashboard's interface copy in a plain voice:
      • every page intro, heading, label, empty state, and status sentence;
      • the demo samples' titles and descriptions, which the backend serves to the Try page.
    • Name the actor in each sentence that reports a decision: the checks, or the staff member by name.
    • Fix the two false strings.
    • Add a copy check that fails the build on the parts of the rules a program can verify:
      • a shared list of banned words and phrases;
      • no em dash inside a sentence.
    • Add a short public style guide for the rules that need judgment, and a decision record for adopting it.
    • Update the end-to-end test and the demo GIF to the new wording.

    Tasks

    • ✓Add `docs/style/copy-rules.json` (the banned list from design D2), `docs/style/interface-copy.md` (the guide with PawDesk examples and the three exceptions), and decision record `docs/decisions/0018-plain-voice-for-interface-copy.md`. Verify the JSON parses and every link in the guide and the record resolves.
    • ✓Add `frontend/tests/copy.test.ts` (syntax-tree scan, design D3), with fixture tests for each scenario: a banned word reported with file, line, and word; `justify-self-end` ignored; an em dash in a sentence reported; a lone "—" allowed. Leave the "current dashboard passes" test marked as expected to fail until task 2.3. Verify with `pnpm test`, which runs the fixture tests and lists today's findings.
    • ✓Rewrite `outcome()` and `flagText()` in `lib/story.ts` (design D4), after confirming in `backend/app/orchestrator.py` that running a failed request again resumes from `failed_step`. Verify with `lib/story.test.ts`, tagged to "Outcome sentences name who decided" and "Failure sentences name the step in plain words", covering every row of the D4 table and a state missing from the map.
    • ✓Rewrite the demo sample titles and descriptions in `backend/app/demo.py`, and add `tests/test_demo_copy.py`, tagged to "Demo sample descriptions follow the same rules", which reads the shared list. Verify with `uv run pytest`, and with a temporary extra word in the list that makes both suites fail (scenario "One list for both checks").
    • ✓Rewrite the remaining dashboard copy page by page (design D5) and turn on the "current dashboard passes" test. Update `e2e/demo.spec.ts` to the new wording in the same commit. Verify with `pnpm test`, `pnpm lint`, `pnpm typecheck`, `pnpm build`, and `pnpm e2e`.
    • ✓Look at every page in a browser with seeded data and a finished demo run, and read the text on each one against the guide. Verify with screenshots of the home, Try, run, review, metrics, evals, and build pages.
    • ✓Regenerate `docs/demo.gif` from the new end-to-end frames, run every suite and the coverage check, and regenerate the build walkthrough. Verify with `uv run python -m scripts.make_demo_gif`, the full backend and frontend suites, and `--check` on the walkthrough.
  19. October 14, 2026

    Utf8 json responses

    A real run's resolver summary looked garbled: "Mar 9–12" where the model wrote "Mar 9–12". The text in the database is correct UTF-8, and the API sends the correct bytes. The garbling happens in the client reading the API:

    • The API declares content-type: application/json with no charset.
    • JSON is always UTF-8 (RFC 8259), and browsers and httpx read it that way.
    • Some clients don't. Windows PowerShell 5.1's Invoke-RestMethod falls back to ISO-8859-1 when no charset is given, which turns the en dash's three bytes (E2 80 93) into three characters that print as –.

    So model output that contains any non-ASCII character (dashes, accented names, curly quotes) reads wrong in a common operator tool, and looks like a bug in the model client.

    What changed, 2 requirements, 3 of 3 tasks
    • JSON responses declare their encoding: application/json; charset=utf-8. The bytes don't change; only the header does.
    • Regression tests pin the whole path: a model response that carries an en dash as raw UTF-8, with no charset (as the provider sends it), reaches the saved step output, the span, and the API unchanged, and the same holds through a cassette recording and replay.

    Tasks

    • ✓Add regression tests for non-ASCII model text: an orchestration test where the resolver proposes an offer with an en dash sent as raw UTF-8, read back from the saved step and its span, and a cassette test that records and replays an en dash. Verify both pass on the current code, which shows the model client, resolver, and database keep the text.
    • ✓Add a middleware that declares `charset=utf-8` on every JSON response. Verify with API tests: one inspects a request whose resolver summary has an en dash and checks the header and the text (it fails before the change and passes after), and one checks a 404 and a 422.
    • ✓Run every backend suite and lint, then check the real API with Windows PowerShell's `Invoke-RestMethod`.
  20. October 14, 2026

    Demo new pets

    The Try page's generated requests draw their pet from the eight seeded pets, so the same names keep coming back: generate a few requests and Biscuit or Moose shows up again, often a name that already has a request in the list. A demo that is meant to show the desk handling requests it was never tuned on keeps showing it the same few pets.

    What changed, 6 requirements, 5 of 5 tasks
    • A generated request brings a new pet:
      • a seeded owner, as before;
      • a pet invented for that owner: a name from a pool of over 100 dog names and 50 cat names, a breed, and a weight that fits the breed.
    • Sending adds the pet to its owner before the request is stored, so intake matches it like any pet on file. Sending the same seed again reuses that pet.
    • Fresh names. When the server picks the seed, it skips any draw whose pet name is already on file (which covers every earlier request's pet) or was generated recently in this browser.
    • The Try page remembers recent names. It sends the last 10 names it generated in this browser session with each new generation.
    • Still reproducible: a seed alone still fixes the request, so the preview, the card, and the send stay the same.
    • Simpler scenarios. A new pet has no vaccine records on file, so the card is the only evidence and every scenario holds as described.

    Tasks

    • ✓Invent the pet from the seed (species, pool name, breed, weight), show the breed on the card, and add choosing a fresh seed. Verify with tests: pool sizes and clearance, same request before and after sending, names vary, fresh seeds skip names on file and recent names, a given seed is kept, and the scenario checks still pass.
    • ✓Accept `avoid` on `GET /demo/generate` and add the pet on send. Verify with API tests: a name on file and recent names are skipped, sending adds one pet with the generated details, and sending twice reuses it.
    • ✓Keep the last 10 generated names in session storage and send them with each generation. Verify with a frontend test of the request URL, then lint, type check, build, and the end-to-end demo.
    • ✓Try it in a browser: generate several requests, check the names don't repeat and the cards match, and send one. Verify with a screenshot and the run's outcome.
    • ✓Run every suite and the coverage check, then regenerate the walkthrough.
  21. October 15, 2026

    Try page pet photos

    The requests list and the run page show each pet's photo, but the Try page, where a visitor picks a request to send, shows none. The visitor chooses between requests about pets without seeing the pets, and a freshly made-up pet stays a name until it is sent.

    What changed, 2 requirements, 4 of 4 tasks
    • Each sample in the Try page's list shows its pet's photo, or the paw placeholder when the sample's pet is not on file (the unknown-pet sample).
    • A generated request shows its pet's photo on the "Make one up" choice and beside the preview's heading.
    • The preview's photo is the one the run page will show. A breed in the photo set always gets its breed's photo. Other pets get a general photo chosen from the pet's id, so a generated pet's id now comes from its seed, and sending gives the new pet that id.
    • The samples list from the API includes each sample pet's id, species, and breed when the pet is on file.

    Tasks

    • ✓Derive a generated pet's id from its seed and insert the pet with it on send, and add each sample's pet fields to `GET /demo/samples`. Verify with tests: the same pet id across previews and after sending, and Biscuit's fields for the clean sample with none for the unknown-pet sample.
    • ✓Show `PetAvatar` on each choice in the Try page's list and beside the preview's heading. Verify with a frontend test that a generated preview and its sent pet get the same photo, an end-to-end check of the Beagle photo on the clean sample, then lint, type check, and build.
    • ✓Try it in a browser: check the samples' photos, generate a pet whose breed is outside the set, send it, and compare the photos. Verify with screenshots.
    • ✓Run every suite and the coverage check, then regenerate the walkthrough.
  22. October 15, 2026

    Public demo hosting

    The demo runs only on a developer machine. A reviewer who wants to try it has to clone the repo, start Postgres, and bring their own model key. A hosted copy lets anyone open a link and send a request.

    Hosting it publicly changes three things:

    • Anyone can make model calls. Each sent request costs money on the project's model key, and nothing limits how many a stranger sends.
    • The database is not local any more. The seed command refuses every non-local host (on purpose: it deletes everything), so the hosted database can't be reset to the known demo state.
    • Hosted Postgres URLs carry libpq options. Neon's connection string ends in ?sslmode=require&channel_binding=require. psycopg accepts those, but SQLAlchemy passes query options straight to asyncpg as keyword arguments, and asyncpg rejects both, so the backend can't connect.

    The hosting choice itself (Hugging Face Spaces for the app, Neon for Postgres) is recorded in decision record 0019.

    What changed, 5 requirements, 9 of 9 tasks
    • Model use is capped. Optional hourly and daily limits on model calls, counted from the trace spans the system already records. When either is reached, sending a demo request answers 429 with a plain explanation and stores nothing. No limits are set by default, so local development and tests are unchanged.
    • The Try page explains a reached limit with the server's explanation, instead of asking whether the API is running.
    • Hosted Postgres URLs work as given. The async URL translation turns sslmode into asyncpg's ssl and drops channel_binding, which asyncpg doesn't support.
    • The seed command can reset one named remote host. A remote host is still refused unless it is exactly the host named in PAWDESK_SEED_ALLOWED_HOST.
    • A single-container image for the Space. It builds the dashboard and the backend, and on start runs migrations, reseeds the demo data, serves the backend on 127.0.0.1:8000, and serves the dashboard on the Space's public port.
    • A deploy script that uploads only files committed at HEAD, plus the Space's own Dockerfile and README.

    Tasks

    • ✓Translate libpq SSL options in `async_url`. Verify with unit tests for a hosted and a local URL.
    • ✓Let the seed command reset the host named in `PAWDESK_SEED_ALLOWED_HOST`. Verify with tests that a different remote host is still refused and the named host passes the check.
    • ✓Add the hourly and daily model-call limits to `POST /demo/requests`. Verify with API tests for each scenario, including that a refused request stores nothing.
    • ✓Show the server's explanation when sending answers 429. Verify with a unit test.
    • ✓Add the Space's Dockerfile, start script, and README in `deploy/huggingface/`. Verify by building the image locally and running it against a local database.
    • ✓Add the deploy script that builds the bundle from `HEAD` and uploads it. Verify with a test of the bundle's contents.
    • ✓Write decision record 0019 and a README section on the hosted demo.
    • ✓Run every backend and frontend suite, lint, type check, and the walkthrough coverage check.
    • ✓Create the Neon database, set the Space's secrets and variables, deploy, and check the hosted demo in a browser: pages load, a request runs to an outcome, and the limit message appears when a limit is reached.
  23. October 15, 2026

    Home page intro

    The dashboard opens on the requests list, which assumes the visitor already knows what PawDesk is. Most visitors to the demo arrive cold: they want to try a request, and they need a few plain sentences first on what the desk does and why it was built. Today the explanation lives in the README, which a visitor to the hosted demo never sees.

    What changed, 2 requirements, 4 of 4 tasks
    • The home page (/) becomes the Try page with an introduction. It opens with a plain-language description of PawDesk and links to the rest of the dashboard, then shows the Try panel, so a visitor can send a request without leaving the page.
    • Below the panel, the page explains the project:
      • how a request moves through the steps, and which steps are model, code, or a person;
      • the rules that keep a model from approving a booking;
      • the dashboard's other pages and what each one shows;
      • why the project was built, and how its skills map to the code;
      • the technologies used;
      • the method: spec-driven changes, decision records, tests linked to requirements, and an AI coding assistant whose output is reviewed against the spec and the tests.
    • The requests list moves to /requests. The navigation's Requests link and the run page's back link point there.
    • /demo redirects to /, so existing links keep working.

    Tasks

    • ✓Move the requests list to `/requests`, redirect `/demo` to `/`, and point the navigation and the run page's back link at the new addresses. Verify with end-to-end tests for the redirect and the list's address, and update the existing end-to-end tests to the new addresses.
    • ✓Write the home page: introduction, Try panel, how a request moves, the safety rules, the other pages, the project's purpose with the posting's skills, the stack, and the method, with counts read from the walkthrough. Verify with an end-to-end test that the page explains PawDesk and shows the panel, then the copy check, lint, type check, and build.
    • ✓Check it in a browser at desktop and phone widths. Verify with screenshots.
    • ✓Run every suite and the coverage check, then regenerate the walkthrough.
  24. October 15, 2026

    Home page polish

    The first version of the home page leaned on stock landing-page parts: an all-caps label over the headline, numbered markers on a list that isn't a sequence, a grid of identical page cards, a row of big numbers, and a cloud of technology tags. Its copy used slogan endings, three-part lists, and passive sentences. Seven sections, two of which repeated the navigation and the skills table, made the page longer than what a visitor needs.

    What changed, 1 requirements, 4 of 4 tasks
    • The introduction says plainly what PawDesk is, why it was built, and what it has, in a few sentences and a short list of features named in the job posting's terms. The all-caps label above the headline goes.
    • Beside it, one real request: the clean sample's message, with the parts a model reads marked, and the results code checked listed under it. The marks use the run graph's colors for model and code.
    • The page has three parts after the Try panel instead of six:
      • how a request moves, as one ordered timeline, with the guarantees the tests check under it;
      • how the code is written with an AI assistant, as the sequence each change follows, with the live requirement and test counts in the text;
      • last, a table that pairs each of the job posting's skills with the technologies and parts of PawDesk that use it.
    • Headings across the dashboard use Fraunces instead of Bricolage Grotesque.
    • Removed: the page cards (the text links to each page where it mentions it), the big-number row, the technology tags (the table names each technology), and the all-caps label.
    • The copy is rewritten in active voice, naming who acts, following docs/style/interface-copy.md.

    Tasks

    • ✓Rewrite `app/page.tsx`: the worked example in the introduction, the steps timeline with the guarantees, the posting table with the technologies, and the method sequence with generated counts. Verify with the updated end-to-end test, the copy check, lint, type check, and build.
    • ✓Check it in a browser at desktop and phone widths. Verify with screenshots.
    • ✓Switch the heading font to Fraunces across the dashboard, rewrite the introduction (what it is, why it was built, features in the posting's terms), and move the posting table to the end. Verify with the updated end-to-end test, the copy check, lint, type check, build, and screenshots of the home page and two other pages.
    • ✓Run every suite and the coverage check, then regenerate the walkthrough.
  25. October 15, 2026

    Home page agents

    The home page calls PawDesk a multi-agent workflow, but it never says which agents there are or what each one is allowed to do. The steps section mixes the agents with the code around them, so a visitor can't see each agent's input, output, and the code check that limits it. Those limits are the main design point of the project.

    What changed, 1 requirements, 3 of 3 tasks
    • A new section, "The agents", above "How a request moves". It lists the four model-driven parts (intake, records, resolver, reply) with what each reads, what it returns, and what code checks before its output counts.
    • It says how much of each one is a model: intake, records, and reply make one model call each; the resolver runs a short loop with read-only tools and a step limit.
    • The steps section gets shorter, since the agents section now holds the detail. Each step names the agent or the code that runs it.
    • The introduction names the product ("PawDesk is a multi-agent workflow…") instead of starting with "It's".

    Tasks

    • ✓Add "The agents" above "How a request moves", shorten the steps, and name the product in the introduction. Verify with the updated end-to-end test, the copy check, lint, type check, and build.
    • ✓Check it in a browser at desktop and phone widths. Verify with screenshots.
    • ✓Run every suite and the coverage check, archive, and regenerate the walkthrough.
  26. October 15, 2026

    Home page booking rules

    The home page says code checks the vaccines and finds a kennel, but never says what those checks are. A visitor can't tell that grooming is a same-day visit with no kennel, that boarding needs at least one night and a kennel of the pet's size, or which vaccines each service needs. Without the rules, the agents and steps sections describe a system whose logic stays hidden.

    What changed, 1 requirements, 4 of 4 tasks
    • A new section, "Booking rules", between the Try panel and "The agents". It covers:
      • each service (boarding, daycare, grooming): how long a visit lasts, whether it needs a kennel, and which vaccines dogs and cats need;
      • when a vaccine counts for a stay, with a small date strip showing one shot that covers a stay and one that expires during it;
      • how a dog's weight sets its kennel size, and when a kennel counts as free;
      • which dates a request can ask for;
      • what happens when a rule isn't met.
    • The rule data lives in frontend/content/booking-rules.json. The page reads it, and a backend test checks it against the seeded facility's vaccine rules, the size class thresholds, and the booking horizon.

    Tasks

    • ✓Add `frontend/content/booking-rules.json` and a backend test that checks it against the seed's vaccine rules, `SIZE_CLASS_SQL`, and `BOOKING_HORIZON_DAYS`. Verify with the test.
    • ✓Add the "Booking rules" section: the services table, the coverage date strip, kennel sizes, dates, and what happens when a rule isn't met. Verify with the end-to-end test, the copy check, lint, type check, and build.
    • ✓Check it in a browser at 1280, 800, and 375 px. Verify with screenshots.
    • ✓Run every suite and the coverage check, archive, and regenerate the walkthrough.
  27. October 15, 2026

    Distinct sample pets

    The clean boarding sample and the prompt injection sample are both about Biscuit, so the Try page shows the same Beagle photo twice. Each sample should be about a different pet, so the choices are easy to tell apart and the photos differ.

    What changed, 1 requirements, 2 of 2 tasks
    • The prompt injection sample is about Pepper, a Chihuahua owned by Priya Venkataraman, instead of Biscuit. Its message names Pepper.
    • Its certificate names Pepper and is current, so the planted instruction is the only reason the request goes to staff. The card is drawn by code when the sample is requested, because the benchmark images carry no card for Pepper.
    • No two samples share a pet. A test pins this.

    Tasks

    • ✓In `backend/app/demo.py`, point the injection sample at Pepper (Priya's email, a message naming Pepper) and draw its card with `certificate_card.render` (design D1, D2). Verify with `tests/test_observability_api.py` tests tagged to "Each sample is about a different pet", one per scenario.
    • ✓Run the backend suite and the Try page end-to-end test, and look at the Try page in a browser. Verify that the clean and injection samples show different photos.
  28. October 15, 2026

    No tap highlight

    On a phone, tapping a button, a link, or a text box draws a blue box or ring around it. The browser's tap highlight and the focus ring both show up on touch, where the visitor already knows what they tapped, so the page flashes blue on every press.

    What changed, 1 requirements, 3 of 3 tasks
    • No tap highlight: the dashboard turns off the browser's tap highlight on every element.
    • No focus ring on tapped text fields on touch screens: a tapped text box, menu, or radio already shows focus through the caret, the on-screen keyboard, or the picker, so the outline is dropped on devices whose main pointer is a finger.
    • Keyboard focus is unchanged: a visitor who moves with Tab still sees the focus ring on every control.

    Tasks

    • ✓Turn off the tap highlight, and the touch focus ring on text fields, in `app/globals.css`. Verify with a new end-to-end test, lint, type check, and build.
    • ✓Check it in a browser emulating a phone. Verify with computed styles, and confirm the test fails with the rules removed.
    • ✓Run every suite and the coverage check, archive, and regenerate the walkthrough.
  29. October 15, 2026

    Architecture page

    The home page lists the agents and the steps in words, but a visitor can't see how the parts fit: which process runs what, where the database sits between them, which states a request moves through and who may move it, and how a crashed run picks up again. These are the questions a technical reviewer asks first, and each one takes several source files to answer today.

    What changed, 2 requirements, 5 of 5 tasks
    • A new Architecture page at /architecture, linked from the navigation between Evals and How it was built.
    • Six diagrams, each with its explanation beside it:
      • the parts of the system and how they connect (browser, dashboard, proxy, API, orchestrator, Postgres, OpenRouter), with how the hosted demo differs from local development;
      • the path one request takes through the agents, the checks, routing, the resolver, the reply, the promise scan, and the review queue;
      • the lifecycle states, with the actor allowed on each transition, the only two ways into approved, and the states that can fail;
      • the step loop that maps each state to one step function and commits after each step;
      • what a model's output passes through before code uses it;
      • how tracing records each step as a span and how the run view reads them.
    • A short list of the tables, grouped by what they hold.
    • The lifecycle transitions and the state-to-step map live in frontend/content/architecture.json. The page draws from it, and a backend test checks it against the lifecycle rules and the orchestrator's step map.

    Tasks

    • ✓Add `frontend/content/architecture.json` with the transitions, failable states, and state-to-step map, and a backend test that checks it against `ALLOWED`, `CAN_FAIL`, and `STEPS`. Verify with the test.
    • ✓Add `components/diagram.tsx` and `app/architecture/page.tsx` with the six diagrams and the tables list, and a unit test that every transition has one drawn arrow. Verify with the unit test, the copy check, lint, and type check.
    • ✓Add Architecture to the navigation, and an end-to-end test that opens the page from the link, finds the six diagrams by name, and checks phone width. Verify with the end-to-end test.
    • ✓Check the page in a browser at desktop and phone widths. Verify with screenshots.
    • ✓Run every suite and the coverage check, then regenerate the walkthrough.
  30. October 15, 2026

    Scroll to top

    The home page, the build walkthrough, and a run page with many steps are long. A visitor who has read to the bottom has to scroll the whole way back to reach the navigation or the Try panel.

    What changed, 1 requirements, 3 of 3 tasks
    • A "Back to top" button sits in the bottom right corner of every dashboard page once the visitor has scrolled past the first screen, and hides again near the top.
    • A ring around the button fills as the visitor scrolls, so it also shows how far down the page they are.
    • Pressing it scrolls smoothly to the top, or jumps there at once when the visitor's system asks for reduced motion.

    Tasks

    • ✓Add `components/back-to-top.tsx` and place it in the layout. Verify with a new end-to-end test, lint, type check, and build.
    • ✓Check it in a browser at desktop and phone widths. Verify with screenshots.
    • ✓Run every suite and the coverage check, archive, and regenerate the walkthrough.
  31. October 15, 2026

    Nav external links

    The source code is public on GitHub, and PawDesk is one project in a wider portfolio. A visitor to the dashboard has no way to reach either from the page.

    What changed, 1 requirements, 3 of 3 tasks
    • Two links at the right end of the navigation bar: "Source code", to the GitHub repository, and "Portfolio", to https://pohi.me/.
    • Both open in a new tab, so the dashboard (and any live run on it) stays open, and each says so to screen readers.

    Tasks

    • ✓Add the "Source code" and "Portfolio" links to the navigation bar. Verify with a new end-to-end test, lint, type check, and build.
    • ✓Check them in a browser at desktop and phone widths. Verify with screenshots.
    • ✓Run every suite and the coverage check, archive, and regenerate the walkthrough.
  32. October 15, 2026

    Hero vaccine card

    The worked request at the top of the home page says the pet parent sent "a photo attached", and the records agent reads that photo, but the example never shows it. A visitor sees the message the intake agent read and has to imagine the other half of the input.

    What changed, 1 requirements, 3 of 3 tasks
    • The worked example shows the vaccine card that the clean sample sends: Biscuit's certificate, between the message and the facts code checked.
    • The card is marked the same way as the message, as something a model read, so the legend still explains every mark in the example.

    Tasks

    • ✓Show the clean sample's vaccine card in the worked example. Verify with the home page end-to-end test, lint, type check, and build.
    • ✓Check it in a browser at desktop and phone widths. Verify with screenshots.
    • ✓Run every suite and the coverage check, archive, and regenerate the walkthrough.
  33. October 15, 2026

    How it works link

    The hero's "How it works" link jumps to "How a request moves", which sits below the booking rules and the agents. A visitor who follows it skips the rules that decide every outcome and lands halfway down the explanation.

    What changed, 1 requirements, 2 of 2 tasks
    • "How it works" opens the booking rules, the first section below the Try panel, so the explanation reads from the top: rules, then agents, then how a request moves.

    Tasks

    • ✓Point "How it works" at the booking rules section. Verify with the home page end-to-end test, lint, type check, and build.
    • ✓Run every suite and the coverage check, archive, and regenerate the walkthrough.
  34. October 15, 2026

    Fixed injection card

    The end-to-end test for the injection sample passes on Windows and fails on Linux CI. Pepper's vaccine card is drawn by code each time it is requested. Pillow's PNG encoder compresses with zlib-ng on Windows and with the system zlib on Linux, so the same drawing gives different bytes (45,058 vs 45,284). The recorded model responses are keyed by a hash of the request body, which carries the image, so the Linux run asks for a recording that does not exist.

    What changed, 1 requirements, 4 of 4 tasks
    • Pepper's card becomes a committed PNG, read from disk like the other samples' cards. Its bytes are the ones the existing recordings were made with, so no re-recording is needed.
    • A script draws the card from Pepper's record, for the rare case it must change. Its output is committed, not computed at run time.
    • A test checks the committed card against a fresh drawing, pixel for pixel, so the file can't drift from the record it shows.

    Requirements

    Tasks

    • ✓Add the drawing script and commit Pepper's card with the bytes the recordings were made with. Verify the file's sha256 is `77450e23…`.
    • ✓Serve the injection sample's card from the file. Verify with the backend tests for the served bytes and the pixel match.
    • ✓Check on Linux that the served bytes and the pixel match hold. Verify in a Linux container.
    • ✓Run every suite and the coverage check, archive, and regenerate the walkthrough.
  35. October 15, 2026

    Coverage strip example

    The coverage diagram in the booking rules section is labeled as Biscuit's stay, but its expiry dates are made up to show one passing and one failing shot. The worked example at the top of the page now shows Biscuit's real vaccine card, whose dates differ, so a reader comparing the two sees a contradiction.

    What changed, 1 requirements, 2 of 2 tasks
    • The diagram describes a hypothetical dog instead of Biscuit: its accessible name, the on-site lane, and the caption.
    • The dates and verdicts stay the same, since they illustrate the rule, not a pet on file.

    Tasks

    • ✓Relabel the coverage diagram as a hypothetical dog's stay. Verify with the booking rules end-to-end test, lint, type check, and build.
    • ✓Run every suite and the coverage check, archive, and regenerate the walkthrough.
  36. October 15, 2026

    Matching sample cards

    The three Try page samples with a vaccine card use two styles. Pepper's card (injection sample) is drawn by the demo card template; Biscuit's (clean) and Moose's (expired vaccine) are certificate benchmark images in other layouts. Biscuit's benchmark card also prints Moose's owner, J. Whitfield, as the owner.

    What changed, 1 requirements, 4 of 4 tasks
    • Biscuit's and Moose's cards are drawn by the same template as Pepper's, from each pet's own name, owner, breed, clinic, and the same vaccine records the benchmark cards printed, so each sample still ends the same way.
    • Both are committed PNGs next to Pepper's, served as stored, like Pepper's.
    • One script draws all three cards. Pepper's bytes do not change.
    • The end-to-end recordings for the clean and expired samples are recorded again, since the model now reads different images.
    • The benchmark images stay as they are: the records eval and the architecture comparison still read them.

    Requirements

    Tasks

    • ✓Draw Biscuit's and Moose's cards with one script, commit them, and serve them for the clean and expired samples. Verify with the card tests and that `pepper.png` is unchanged.
    • ✓Record the end-to-end responses again and confirm every sample's outcome. Verify with the end-to-end suite in replay mode.
    • ✓Check the cards in a browser on the Try panel and the home page example. Verify with screenshots.
    • ✓Run every suite and the coverage check, archive, and regenerate the walkthrough.
  37. October 15, 2026

    Sample request summaries

    On the Try page, a generated request's preview opens with a summary: the pet, service, and dates as a heading, then the owner, breed, weight, and what the vaccine card shows. The fixed samples show only the message, so a visitor has to read it to learn what the request is, and can't see what the card is meant to show before sending. The page also opens on the first sample, while "Make one up" is the choice that shows something new on each visit.

    What changed, 2 requirements, 4 of 4 tasks
    • Each sample's preview opens with the same summary as a generated request: pet, service, and dates, then the owner, the pet's breed and weight when on file, and what the vaccine card shows.
    • The samples list from the API carries each sample's service, dates, owner name, the pet's weight, and a card summary in plain words.
    • "Make one up" is selected when the Try page opens, and a request is generated right away so the preview isn't empty.

    Tasks

    • ✓Give each sample its service, dates, and card summary, and return them with the owner's name and the pet's weight from `GET /demo/samples`. Verify with an API test of the clean and unknown-pet samples.
    • ✓Render one summary block for samples and generated requests, select "Make one up" on open, and generate a request on mount. Verify with end-to-end checks, then lint, type check, and build.
    • ✓Try it in a browser and check each sample's summary. Verify with a screenshot.
    • ✓Run every suite and the coverage check, then regenerate the walkthrough.
  38. October 15, 2026

    One kennel per pet

    Running the clean boarding sample several times books Biscuit into every medium kennel for the same nights (M1, M2, M3, all Mar 24–27). Two problems show:

    • Nothing stops one pet from holding two kennels on the same night. A repeated or duplicate request is approved and reserves another kennel, as if a second dog were coming.
    • The demo samples stop showing what they promise. Once earlier runs fill the medium kennels, the clean sample is routed to a person with "no free medium kennel for the stay". Generated requests (stays 5 to 40 days after the demo day) fill the same kennels over time on the hosted demo.
    What changed, 4 requirements, 7 of 7 tasks
    • A pet holds at most one kennel per night. The database refuses a reservation that shares a night with another reservation for the same pet, as it already does for the same kennel.
    • The checks notice a pet already booked for the stay. For boarding, the capacity check first looks for a reservation of the same pet that shares a night with the stay. If it finds one, no kennel is searched, and the request goes to a person with the reason "capacity: Biscuit already has a kennel for these nights".
    • Running a boarding sample frees its stay. Before a fixed sample's request is stored, reservations made by earlier requests in the sample pet's size class that share a night with the sample's stay are released. Seeded reservations are kept.

    Tasks

    • ✓Add the pet overlap constraint to `Reservation` and a migration that removes later same-pet overlaps first. Verify with capacity tests for the refused and back-to-back inserts.
    • ✓Add `find_pet_stay` to `app/capacity.py`. Verify with a lookup test.
    • ✓Report `already_booked` from the capacity check before any kennel search, add the routing reason, and send it to a person in the resolver's scope; fail verified options that overlap the pet's other stay. Verify with orchestration and resolver tests.
    • ✓Check the pet again at send time, and fail the step when either constraint refuses the insert. Verify with a send-time test.
    • ✓Show the already-booked sentence on the capacity step. Verify with a step-summary unit test.
    • ✓Release reservations in a boarding sample's way before storing it. Verify with demo API tests for the three scenarios.
    • ✓Migrate the local database, run the clean sample twice live and check both are sent, then run every suite and the coverage check, and regenerate the walkthrough.
  39. October 15, 2026

    Card synthetic marking

    Every demo vaccine card, generated or fixed, prints "PawDesk demo certificate. Not a real record." along its bottom edge. The records model reads the whole card, so it reports that line as an ambiguity ("certificate footer states it is a PawDesk demo certificate, not a real record"), and any ambiguity sends the request to a person. The clean boarding sample, which should pass with no person involved, gets routed to review on some runs, and a recorded end-to-end response already carries the same ambiguity.

    Flagging a card that says it isn't real is the right behavior for the records step, so the fix belongs on the card, not in the prompt.

    What changed, 1 requirements, 3 of 3 tasks
    • Demo cards no longer print the footer. Nothing drawn on a demo card says it is not a real record.
    • The synthetic marking moves into the image's metadata. Every demo card carries a PNG text chunk saying it is a synthetic PawDesk demo certificate with fictional names.
    • The three sample cards are redrawn and their end-to-end model responses recorded again.

    The certificate benchmark's watermark ("SYNTHETIC TEST DOCUMENT") is unchanged: the records eval measures reading those images as they are.

    Tasks

    • ✓Drop the footer from the demo card and add the `Comment` text chunk. Verify with card tests for the blank area and the metadata.
    • ✓Redraw the three sample cards. Verify with the served-as-committed and pixel tests.
    • ✓Re-record the end-to-end responses for the changed cards, run the clean sample live and check its records have no ambiguity, then run every suite and the coverage check, and regenerate the walkthrough.
  40. October 15, 2026

    Example card outcome

    The home page's worked example takes up most of its height with the input: the message and a full-size vaccine card. Its payoff is a "Sent" tag and a sentence describing that a reply went out, so a visitor never sees what PawDesk actually did. Nothing marks the card as an example either, so it can read as a live request. And the clean sample's card says Biscuit got his DHPP shot on Nov 24, 2026. The demo runs on a fixed date (Mar 3, 2027), so that's in the past for the checks, but a visitor reading the page today sees a shot given next month.

    What changed, 3 requirements, 6 of 6 tasks
    • The worked example is labeled as an example at the top.
    • The vaccine card opens as a short preview that expands to the full card, so the example is about as tall as before once the outcome is added.
    • The example ends with what PawDesk did: it booked a medium kennel for the stay and sent a reply. The reply's subject and first paragraph are shown, and the rest opens on demand. The text is the reply from the clean sample's recorded run.
    • No date given on a sample's card is after the real calendar date. Biscuit's DHPP date becomes Nov 24, 2025; his card is redrawn and the clean sample's recorded model responses are recorded again.

    Tasks

    • ✓Change Biscuit's DHPP date to Nov 24, 2025, redraw his card, and check the other cards are unchanged. Add a test that no sample card's date given is after today.
    • ✓Record the clean sample's model responses again, keep the unchanged recordings as committed, and drop any that are no longer used.
    • ✓Label the worked example, show the card as a preview that expands, and end with the kennel booked and the reply from `content/worked-example.json`. Verify with end-to-end checks, then lint, type check, and build.
    • ✓Check in the clean-sample run that its reply matches the worked example.
    • ✓Try it in a browser at desktop and phone width. Verify with a screenshot.
    • ✓Run every suite and the coverage check, then regenerate the walkthrough.
  41. October 15, 2026

    Basic text layout

    The backend test job on GitHub Actions hangs in the test that compares each committed sample card with a fresh drawing. The runner has the Raqm text-shaping library installed, and Pillow uses it for any TrueType font when it is present. Raqm shapes the Caveat signature differently from Pillow's basic layout, so the drawn signature moves by a few pixels. Development machines and the test container have no Raqm, which is why the cards matched there. The failing assertion compares two 2 MB byte strings, and pytest's diff of them runs for longer than the job is worth watching.

    What changed, 1 requirements, 3 of 3 tasks
    • Cards always use Pillow's basic text layout, so a card draws the same pixels with or without Raqm. The committed cards were drawn with basic layout, so they and the recorded responses stay valid.
    • The pixel comparison fails fast, reporting the region that differs instead of diffing the raw pixel bytes.

    Tasks

    • ✓Draw the card's TrueType fonts with basic layout, and test it. Verify on Linux with `libraqm0` installed that every committed card matches a fresh drawing.
    • ✓Make the committed-card comparison report the differing region. Verify a deliberately changed card fails fast with its region.
    • ✓Run every suite and the coverage check, archive, and regenerate the walkthrough.
  42. October 16, 2026

    Keep chosen sample

    The Try panel sometimes ignores a sample the visitor just chose. The page opens on "Make one up" and makes up a request as soon as it is interactive. That first request also selects "Make one up", so a sample chosen in the moment before it starts is switched back. Sending then submits the made-up request instead of the sample. The end-to-end tests choose a sample right after the page loads, and on GitHub Actions they failed this way on most recent runs: the clean sample's checks never passed, because a made-up request with no recorded responses was sent instead. A trace of a local failure shows the click finishing as the first made-up request starts.

    What changed, 1 requirements, 2 of 2 tasks
    • Making up the opening request leaves the choice alone. The page already starts with "Make one up" selected, so the automatic first request only fills the preview. Choosing "Make one up" or "Make another" still selects it.

    Requirements

    Tasks

    • ✓Make the opening request without changing the choice, and add the end-to-end test. Verify with lint, type check, and the demo end-to-end tests repeated (`--repeat-each=5`) several times with no failure.
    • ✓Run every suite and the coverage check, archive, and regenerate the walkthrough.
  43. October 16, 2026

    Space binary files

    On the hosted demo, a made-up request's vaccine card fails to load: /api/demo/generated/<seed>/certificate.png answers 500 with OSError: unknown file format when Pillow opens Caveat.ttf. Hugging Face stores larger binary files in LFS (Xet), and here it stored Caveat.ttf and ReenieBeanie.ttf that way. A Space's build only resolves LFS files that its .gitattributes marks for LFS. The deploy bundle carries the repository's .gitattributes, which has no LFS rules and replaces the Space's own, so the build checks out those fonts as LFS pointer files. Smaller binaries such as the sample cards are stored as plain git and load. The previous deploy already had the same state, so this predates the latest changes.

    What changed, 1 requirements, 3 of 3 tasks
    • The deploy bundle marks its binary file types for LFS. The bundle's .gitattributes keeps the repository's rules and adds an LFS rule for each extension that has a binary file in the bundle (today gif, ico, jpg, png, ttf). Files Hugging Face keeps as plain git are unaffected: git-lfs passes content that isn't a pointer through unchanged.

    Requirements

    Tasks

    • ✓Mark the bundle's binary file types for LFS in its `.gitattributes`, with a bundle test.
    • ✓Redeploy and check that a generated card and the sample cards load from the hosted demo.
    • ✓Run every suite and the coverage check, archive, and regenerate the walkthrough.
  44. October 16, 2026

    Hosted seed without requests

    Every start of the hosted demo runs the seed, and the seed adds five example booking requests (Juniper, Pepper, Tofu, Moose, Biscuit) with their history. On the public demo these look like real activity: the review queue opens with Pepper waiting, and the request list shows runs no visitor made. The demo should open with an empty desk, so the only requests on it are the ones visitors send.

    The rest of the seed is still needed there. The facility, its vaccine rules and kennels are what the checks run against, and the owners and pets are the clients that incoming messages are matched to; the Try page's samples are written about them.

    What changed, 2 requirements, 4 of 4 tasks
    • The seed command can leave out booking requests. python -m scripts.seed --no-requests loads the same facility, rules, kennels, owners, pets, vaccine records, and reservations, and no booking requests, transitions, or approvals.
    • The hosted demo seeds without requests. The Space's start script passes --no-requests, so each start leaves the review queue and request list empty.
    • Local development and the tests keep the full seed, example requests included.

    Tasks

    • ✓Add `--no-requests` to the seed command, leaving out booking requests, transitions, and approvals, with seed tests.
    • ✓Pass `--no-requests` from the Space start script, with a test, and update the hosting notes.
    • ✓Run every suite and the coverage check, archive, and regenerate the walkthrough.
    • ✓Redeploy and check that the hosted review queue and request list are empty and the Try samples load.
  45. October 16, 2026

    Keep generated request

    On the Try page, clicking the "Make one up" choice always makes up a new request, even when one is already in the preview. A visitor who looks at a sample and then goes back to "Make one up" loses the request they were reading, along with any edits to its message, and each click uses up another pet name. The made-up request should stay put until the visitor asks for a new one.

    What changed, 1 requirements, 2 of 2 tasks
    • Choosing "Make one up" shows the request already made up. It only makes one up when none exists yet, for example when the opening request failed.
    • "Make another" is the way to get a new request, and reloading the page still opens on a new one.

    Requirements

    Tasks

    • ✓Make the "Make one up" choice select the existing request, and add the end-to-end test. Verify with lint, type check, and the demo end-to-end tests.
    • ✓Run every suite and the coverage check, archive, and regenerate the walkthrough.
  46. October 16, 2026

    Nav try link

    The navigation's "Try a request" link opens the top of the home page. The Try panel sits below the introduction and the worked example, so a visitor who follows the link still has to scroll to find it.

    What changed, 1 requirements, 3 of 3 tasks
    • The navigation's "Try a request" link goes to /#try, so it scrolls to the Try panel from any page, the home page included.
    • The empty requests list's "Try a request" button goes there too, for the same reason.

    Tasks

    • ✓Point the navigation's and the empty requests list's "Try a request" links at `/#try`. Verify with new end-to-end tests, lint, type check, and build.
    • ✓Check the link in a browser from the home page and from another page.
    • ✓Run every suite and the coverage check, archive, and regenerate the walkthrough.
  47. October 17, 2026

    Records year fidelity

    On the hosted demo, the clean boarding sample was held for staff with "vaccine bordetella: expired", even though its card prints Bordetella valid through Nov 3, 2027. The records step read the card's years wrong: every date printed after 2024 except Rabies's expiry came back one to three years earlier, in 2024 or 2025 (Bordetella read as given 2024-10-12 and expiring 2025-11-03). It still reported confidence 0.95 and no ambiguities, so nothing in the records step flagged the error. The recorded test run of the same card read every date correctly, so the error shows up in some live reads but not others.

    Two things let that one misread change the outcome:

    • The model is not told to keep printed years as they are. The prompt states today's date, and the misread dates all fall closer to the model's own sense of "now". The prompt never says that dates later than today are normal and must be copied unchanged.
    • The seeded records on file drift with the day the seed runs. The seed places every date relative to the real calendar day, while demo requests are processed as if today were the demo day (Mar 3, 2027). Seeded on Oct 16, 2026, Biscuit's Bordetella on file expires Feb 13, 2027, before the sample stay. If it were anchored to the demo day, it would cover the stay. The same drift breaks the expired-vaccine sample once the demo is reseeded on or after Nov 3, 2026: Moose's Bordetella on file would then cover his Mar 10 to 13 stay, and the sample would pass.
    What changed, 4 requirements, 11 of 11 tasks
    • The records prompt tells the model to copy every date exactly as printed, including the year, and says that dates after today are expected and must not be adjusted. The prompt version becomes records-v2.
    • The seed command anchors every date to the demo day instead of the real calendar day, so the hosted demo's data is the same whenever it restarts.
    • Moose's seeded edge cases move off the expired-vaccine sample's nights. Moose's Bordetella on file is expired before Mar 10, and his seeded upcoming stay (where rabies runs out partway through) moves to nights that don't overlap Mar 10 to 13. The edge cases themselves are kept.
    • A deterministic test checks that each sample ends as it promises. From the seeded records and the dates printed on each sample's card, with no model involved, the clean sample is compliant with a free kennel, the injection sample is compliant, and the expired-vaccine sample fails only on Bordetella.
    • A new evaluation reads each sample card many times with the live model and reports how often any printed date is misread and how often a misread changes the sample's compliance outcome. It runs once on records-v1 before the prompt change and once on records-v2 after. The certificate benchmark v2 is also run again on records-v2 to check that the new wording doesn't lower its accuracy.

    Tasks

    • ✓Add the pure scoring to `scripts/eval_sample_cards.py`: misread, decision flip (card records alone, sample stay), silent flip, and per-card and total counts. Verify with `tests/test_eval_sample_cards.py`, tagged `@spec("certificate-evaluation", "Sample cards are read repeatedly to measure misreads")` and covering the shifted-year, exact-read, decision-flip, and silent-flip scenarios.
    • ✓Add the runner: N reads per card (default 20) through `run_records` with `today=DEMO_TODAY`, the sample's pet as the booked pet, and truth from `draw_sample_cards.CARDS`. It is traced like `eval_records`, and the report records the prompt version, model, reads per card, and total cost. Verify the report-building test for the "Report names what it measured" scenario passes, and that `uv run python -m scripts.eval_sample_cards --help` (or a dry import) runs without calling the model.
    • ✓Run the measurement live on `records-v1` and commit the report to `evals/results/` unedited. Verify the report file exists and names `records-v1`.
    • ✓Add the copy-dates-as-printed wording to `app/prompts/records.py` and set `PROMPT_VERSION = "records-v2"` (design D1). Verify with a prompt test tagged `@spec("vaccine-records-extraction", "Printed dates are copied exactly")` for the "Prompt keeps printed years" scenario, plus a span test that the `records` span records `records-v2`.
    • ✓Run the sample-card measurement again on `records-v2` and commit its report. Verify the report names `records-v2`, and compare the misread, flip, and silent-flip counts with the 1.3 report.
    • ✓Run `uv run python -m scripts.eval_records v2` on `records-v2` and commit its report. Verify the report names benchmark `v2` and prompt `records-v2`, and compare given accuracy, expiry accuracy, and silent disagreements with `records-v2-20261014T155439Z.json`.
    • ✓Make the seed command pass `DEMO_TODAY` as the anchor (design D4), and update the module docstring. Verify with tests tagged `@spec("seed-data", "Seeded dates are anchored to the demo day")`: the arguments are read with the demo day as the anchor, and Biscuit's Bordetella on file is at -60/+120 days from it.
    • ✓Move Moose's Bordetella on file to the dates on his card, his seeded large-kennel stay to days +21 to +28, and his rabies expiry to day +24 (design D5). Update `tests/test_compliance_db.py` to check his stay on the new days. Verify the full backend suite passes, including the existing seed edge-case tests.
    • ✓Add the sample-promise test (design D6), tagged `@spec("observability-api", "Samples end as promised from seeded and printed records")`, with one test per scenario: clean, injection, expired-vaccine only on Bordetella, and clean on records on file alone. Verify it passes. Also verify that it fails when the 3.2 Moose edit is reverted locally.
    • ✓Record the end-to-end model responses again (`PAWDESK_E2E_RECORD=1 pnpm e2e`, with that worktree's own test database). Remove recordings that are no longer used. Verify that `pnpm e2e` passes on replay with no network calls to the model.
    • ✓Run the full backend suite, `ruff`, the frontend unit tests, and the end-to-end suite on replay, then regenerate the build walkthrough. Verify everything passes and `scripts.walkthrough --check` reports every requirement verified.
  48. October 17, 2026

    Send ready after back

    Sending a request from the home page's Try panel opens its run. Coming back to the home page, with the browser's Back button or the navigation's "Try a request" link, shows the panel's buttons still disabled and labelled "Sending". The panel stays unusable until the page is reloaded, so a visitor can't try a second request.

    What changed, 1 requirements, 3 of 3 tasks
    • The Try panel is ready again when the visitor comes back from a run. The send button reads "Send and watch it run" and is enabled, and "Make another" is enabled.
    • Sending and making up a request are tracked apart, so clearing one never interrupts the other.

    Tasks

    • ✓Add the end-to-end tests and see them fail.
    • ✓Track sending apart from making up a request, and clear it when the panel is hidden. Verify with lint, type check, and the demo end-to-end tests.
    • ✓Run every suite and the coverage check.
  49. October 19, 2026

    Home page hybrid workflow

    The home page introduction calls PawDesk a multi-agent workflow. It isn't one: plain code fixes the order of the steps, and models only read the messy input and write the reply (decision record 0008 chose a hybrid workflow over a multi-agent supervisor). The Space's short description says the same. A visitor who knows the patterns reads the wrong architecture before seeing the rest of the page.

    What changed, 1 requirements, 3 of 3 tasks
    • The introduction calls PawDesk a hybrid workflow and says what that means: code runs the steps in a fixed order and calls agents only where the input is messy.
    • The Space's short description says "Hybrid workflow booking intake desk for pet care".

    Tasks

    • ✓Add the end-to-end test and see it fail.
    • ✓Reword the introduction and the Space's short description. Verify with lint, type check, and the home end-to-end tests.
    • ✓Run the frontend suites, strict validation, and the coverage check.