The friction is mine

I'm a competitive-figure-skating mom homeschooling two learners. My state requires 875 hours of instruction every school year, and I have to be able to prove it. The proof lives in four exports — a math-practice platform, a reading log, a calendar feed, and a prose progress summary — and those sources disagree about almost everything that matters: what "a month" is (calendar month vs a rolling thirty days), what a session span means (elapsed time vs attention), which of two files with the same name is current, whether a summary's total matches the rows it claims to summarize.

Every month I reconciled this by hand, rink-side. The dangerous part was never the work. It was that every wrong way of doing it still produces a clean, confident, plausible number. A spreadsheet does not look different when it is lying.

So the system is built around the one rule I already ran by hand:

When the evidence is insufficient, no confident number may exist.

And the rule matters beyond the checkbox. 875 hours is one state's requirement; requirements differ state to state, and they change when a family moves. What the system actually produces is an archive that travels — a record of what was actually learned, every digit tracing to a source row, credible in front of whoever asks next: another state, a school, any future that asks for proof of ability rather than proof of compliance. A confident total that cannot show its evidence stops being worth anything the moment the audience changes. An attested floor does not.

What it does

Unattended, on a schedule: collect the month's evidence files, parse them deterministically, read the prose summary through a leashed model, then decide — per batch and per row — among three exits:

  • ATTESTED FLOOR — a confident minimum. Every digit walks back to a source row. The floor may understate; it may never overstate.
  • REFUSED — coverage is structurally broken. No number is emitted at all, because a partial total would still read as "the total" downstream.
  • HELD — suspicious but not decidable. A person is asked, and the hold names the exact evidence that would settle it.

One trigger walks five months through the whole pipeline in about eight seconds, live model calls included, and every exit fires in one unattended pass. A garbage delivery sits mid-round on purpose: the parser refuses it loudly (a guessed timezone would produce well-formed wrong timestamps), the refusal is filed as that month's outcome, and the months after it still run.

Between the trigger and the summary, no prompt is answered and no confirmation is requested. The person shows up after the round, to read what was held — never during it. That is the Taskmaster shape: the background workflow completes without human intervention, and refusal is one of its autonomous actions, not a request for permission.

Where the models end and the code begins

Two model calls exist in the whole system — the conversational agent (Gemini 3.6 Flash via ADK) and the extraction reader nested inside the attest tool — and neither can put a number into evidence the evidence did not already contain. The reader proposes a pointer: the figure plus the verbatim sentence it came from. Both halves are re-checked against the raw bytes before anything downstream may look. A reader that cannot form a proposal is recorded as a refusal, not a crash.

Everything that decides is ordinary code: structural gates (file-level, no thresholds), semantic holds (row-level, refined only by evidence), and a ledger where the headline number is a floor, never an estimate. The agent's tools are scoped to generate / attest / list — there is no tool that writes an unattested number, so the agent cannot be talked into overriding a refusal (I tried; the capture includes the override it declined). The public demo is hardened the same way it is designed: tool inputs are allow-listed (a month id is an id, never a path), demo storage is pruned to a cap, and the service is pinned to a single instance — a hostile visitor's blast radius is a bounded bill and a refused string.

State is explicit: sessions persist in Cloud SQL via --session_service_uri. An ADK deployment without that flag silently falls back to in-memory sessions and forgets on every container recycle — invisible locally, fatal on Cloud Run with scale-to-zero. I verified persistence the hard way: teach the agent a token, force a container swap, ask again.

The numbers

Three experiments, all reproducible from the repo. Synthetic months are seeded; defects are planted with a manifest, so interception has a denominator.

Gates on vs gates off (deterministic, no model):

confident-wrong totals:  gates on 0/400 — gates off 180/400
                         (overstatement when wrong upward: mean 204 min, max 843)
interception:            10/10 planted defect classes, 20/20 seeds each
clean months:            40/40 attested exactly, zero false friction

The shrink axis — a scenario may leave "ask a person" only by naming the evidence that decides it mechanically:

people asked:        231 -> 40 holds on the same 60 months (83% fewer)
the number moved:    never (floors above truth: 0/240, both configurations)
floor completeness:  90.1% -> 100% of true minutes attested

The model axis — same months, same prompt, five readers: the shipping Gemini 3.6 Flash, three Gemini 2.5 tiers, and an open-weights Gemma 4 (gemma-4-31b-it). The honest headline: this prose barely gets misread (flash-lite 2/48 in the captured run; the other four readers 0/48). Both misreads are the instructive kind — one grabbed the monthly goal (a planned figure) as the total, verbatim quote and all: re-grounding admits it, the corroboration hold downstream names the +210 gap, nothing reaches the floor. The other failed to form a proposal and landed as a recorded refusal, not a crash. A correct read is never punished (48/48 on four readers, 46/46 on the fifth). Wrong figures attested: 0/240 across all five. Swap in whatever reader the budget allows — a different tier, a different family, even a model you could host yourself — and the worst case is being asked, never being lied to.

What broke on the way

  • ADK 2.x is a breaking change from the 1.x most tutorials describe. When an import path disagreed with a blog post, I trusted the version number.
  • The Gemini 3.x line 404s on this project's regional Vertex endpoint — it is served from the global location, which took a night and a wrong turn through an API-key channel to discover. The 2.5 comparison readers stay regional; Gemma alone rides an AI Studio key. The guarantee is designed not to depend on reader quality or channel, and the model axis is the receipt.
  • adk deploy accepts "any SQLAlchemy URL" that is actually async-only: pg8000 was rejected, asyncpg worked.
  • A malformed model output originally crashed the pipeline. It now lands as a recorded extraction refusal — the same honesty the gates apply to evidence, applied to the reader itself. The model axis later caught a real one.

What this is not

  • The interception table proves the mechanism catches what it models, not everything a real platform can emit. The defect classes are the ones my own reconciliation checklist accumulated; the world will have more.
  • The floor is a minimum, not "the real total". Pending items are real activity whose worth is not yet decidable.
  • All demo data is synthetic by construction. That is the privacy boundary, not a shortcut — the real evidence is my children's, and it does not belong in a demo. The planted-defect manifest is what keeps synthetic honest: every defect is known, so interception has a denominator.

What's next

September is the first real month-end this system runs for its original owner. The held-residue is a roadmap, not a defect list: every remaining hold prints the evidence that would retire it, so the next integration is chosen by the residue, not by a feature list. And the archive is the long game: it follows the learners, not the state.


Build story: A Spreadsheet Does Not Look Different When It Is Lying — written for this hackathon.

Built With

Share this project:

Updates

Submission history