EN

Research & Process Reflection

Six weeks of unattended agents against a schedule that could not slip: the runtime pivot that cost nothing, the migration that silently dropped four predictions, and the dispatch failure that returned 422 and told nobody.

Part of the Paulopus case study. Paulopus ran for six weeks against a fixture list that could not be renegotiated. Kickoffs land when FIFA says they land, and an agent that is late to a match has simply missed it. What follows is not the octopus talking — it is me, the person who built the harness, walking back through the pivots that the decision log actually recorded, and being honest about the one that left a scar in the data.

1. The runtime that billed nothing

Decision. The original plan (2026-06-12) was an Anthropic-API-billed generation harness — scripts/writer/generate.ts plus run.ts, running claude-sonnet-4-6 with the web_search tool, triggered by a GitHub Actions cron.

Approach. Before a single match kicked off, I moved the research and prose generation out of my own code entirely and into a claude.ai scheduled routine on a Max subscription, at $0 API cost. My scripts shrank to two honest jobs: due.ts lists what work is owed as JSON, and ingest.ts becomes the only guarded write path back in.

Artifact.

{
  "topic": "Writer generation runtime",
  "planned": "An Anthropic-API-billed generation harness ... run by a GitHub Actions cron.",
  "shipped": "A claude.ai scheduled routine on a Max subscription ($0 API cost) does the research and prose itself; due.ts lists due work as JSON and ingest.ts becomes its only guarded write path."
}

Result. The routine did the thinking; my repo only decided what was due and let the results back in through one gate. It is the pivot I would make again without hesitation — but pushing generation into a cloud sandbox is exactly what set up the failure in block 4.

2. Locks became drafts — and four predictions vanished

Decision. I first designed pronostics as write-once locks: locked was the only prediction that counted, stamped with an immutable pre-kickoff timestamp. On 2026-06-13 (PR#13) I replaced that with a regenerable drafts[] revision history, appended only on a real change, revisable up to kickoff.

Approach. The drafts model is genuinely better product behaviour — a prediction should be allowed to absorb a confirmed lineup. But the migration ran across matches that had already been graded, and it did not carry their originating predictions across.

Artifact. These four matches still carry a stored verdict and are counted in every aggregate, yet their prediction no longer exists in the data:

matchIdfixturepredictedactualverdict
M001Mexico vs South Africanull2-0Inspired
M002South Korea vs Czech Republicnull2-1RedCard
M007Canada vs Bosnia & Herzegovinanull1-1RedCard
M019USA vs Paraguaynull4-1Missed

Result. This is the change I would most want back. The verdicts are real, the karma is real, and the app's own numbers are internally consistent — but a schema migration silently dropped a field that four already-scored rows depended on, leaving verdicts whose prediction can no longer be shown or re-derived. If I did it again, a migration touching graded rows would fail loudly rather than quietly forget. Trust in a scoreboard is trust in its provenance, and provenance is exactly what leaked out here.

3. Two scoring recalibrations

Decision. Karma changed shape twice. First (2026-06-17, PR#16) the Perfect reward dropped from +2 to +1.5, to narrow the gap over Inspired, while RedCard (-1), Missed (0) and Inspired (+1) stayed put. Then (2026-07-23) grading moved off result.regulation — the 90-minute score — onto the played score (extraTime ?? regulation, penalties never counted as goals) plus the real winner who advanced.

Approach. The second change mattered because knockout football does not stop at 90 minutes. Grading a shoot-out win as a draw was simply wrong.

Artifact. The matches that only make sense under the played-score rule:

matchIdfixturedecidedByverdict
M088Australia vs EgyptpenaltiesPerfect
M099Norway vs Englandextra_timePerfect
M082Belgium vs Senegalextra_timeInspired
M104Spain vs Argentinaextra_timeInspired

Result. Re-grounding the nine knockout matches decided beyond 90' moved the karma total from 15.5 to 25.0, and the final aggregate settles at netKarma 25 across 104 graded matches — 14 Perfect, 39 Inspired, 16 Missed, 35 RedCard, with a longest positive streak of 10 and a longest RedCard streak of 4. A scoring rule that ignores extra time is a scoring rule that will eventually lie to you in the round of 16.

4. Unattended means failures are silent

Decision. Running generation in a cloud sandbox meant the routine could not open a raw TCP connection to Mongo; it wrote back by dispatching a payload into a GitHub Actions workflow. On 2026-06-28 (PR#21) I learned what that costs.

Approach. A full pronostics-plus-briefs run serialised to roughly 150KB of base64, over the workflow_dispatch input ceiling of about 65KB.

Artifact.

{
  "title": "Gzip the ingest dispatch payload",
  "description": "A full pronostics+briefs run ... exceeding the workflow_dispatch input ceiling (~65KB) and silently dropping the run (HTTP 422, no run created); the routine now gzips the payload before base64, and ROUTINE.md now checks the dispatch HTTP status and verifies a new run id."
}

Result. The run did not error in any place I was watching — it returned HTTP 422 and no run was created. That is the defining hazard of six weeks of unattended agents: the schedule keeps advancing whether or not your writes landed. The fix was gzip; the lesson was that the routine must now assert its own success — check the status, confirm a new run id — because nobody is standing there to notice the silence.

5. Cutting the things I designed but never needed

Decision. Two features were planned and then deliberately removed. football-data.org was documented as a fallback score source (2026-06-12) and never wired into sync-scores.ts. An automated FIFA replay-link backfill job (2026-07-01, PR#22) was deleted in favour of a hand-run pnpm replay <matchId> <fifaUrl>.

Approach. I let openfootball stay the sole score source and carried FOOTBALL_DATA_TOKEN as an unused secret rather than build a fallback I might never trigger.

Artifact.

{
  "topic": "Score-sync fallback source",
  "type": "scope-cut",
  "shipped": "Never wired into sync-scores.ts; openfootball remained the sole score source, with FOOTBALL_DATA_TOKEN carried as an unused workflow secret."
}

Result. Both cuts were correct. The surprise was how much planned surface area a fixed deadline lets you shed — a fallback you never reach for and an automation whose output you can hand-curate in seconds are both weight, not safety.

6. Static, then more static than planned

Decision. With the tournament over, the app dropped its live MongoDB Atlas backend (2026-07-23) for a static data/matches.json + data/teams.json snapshot read in-memory through the same lib/db.ts signatures — no database, no runtime secrets, app/api/* deleted. The migration spec then explicitly said not to do a full static export or rework generateStaticParams.

Approach. I broke my own non-goal. Once the data was frozen there was no reason to server-render on demand.

Artifact.

{
  "topic": "Static rendering scope",
  "planned": "Stay a Next.js server app; spec's explicit non-goal is no full static export and no generateStaticParams rework.",
  "shipped": "force-dynamic dropped and generateStaticParams added ...; the whole app now prerenders as static HTML."
}

Result. Dropping force-dynamic and adding generateStaticParams turned the whole app into prerendered HTML, none of it server-rendered on demand. The reflection here is small but real: a spec's non-goals are written before you know what the finished data looks like, and it is fine to overrule your past self once the tournament stops moving — provided, unlike block 2, you do it out loud.

Research & Process Reflection
  • slugresearch-process-reflection-0
  • contentPart of the [Paulopus](/plant/paulopus#reflection) case study. Paulopus ran for six weeks against a fixture list that could not be renegotiated. Kickoffs land when FIFA says they land, and an agent that is late to a match has simply missed it. What follows is not the octopus talking — it is me, the person who built the harness, walking back through the pivots that the decision log actually recorded, and being honest about the one that left a scar in the data. ## 1. The runtime that billed nothing **Decision.** The original plan (2026-06-12) was an Anthropic-API-billed generation harness — `scripts/writer/generate.ts` plus `run.ts`, running `claude-sonnet-4-6` with the `web_search` tool, triggered by a GitHub Actions cron. **Approach.** Before a single match kicked off, I moved the research and prose generation *out* of my own code entirely and into a claude.ai scheduled routine on a Max subscription, at $0 API cost. My scripts shrank to two honest jobs: `due.ts` lists what work is owed as JSON, and `ingest.ts` becomes the only guarded write path back in. **Artifact.** ```json { "topic": "Writer generation runtime", "planned": "An Anthropic-API-billed generation harness ... run by a GitHub Actions cron.", "shipped": "A claude.ai scheduled routine on a Max subscription ($0 API cost) does the research and prose itself; due.ts lists due work as JSON and ingest.ts becomes its only guarded write path." } ``` **Result.** The routine did the thinking; my repo only decided what was due and let the results back in through one gate. It is the pivot I would make again without hesitation — but pushing generation into a cloud sandbox is exactly what set up the failure in block 4. ## 2. Locks became drafts — and four predictions vanished **Decision.** I first designed pronostics as write-once locks: `locked` was the *only* prediction that counted, stamped with an immutable pre-kickoff timestamp. On 2026-06-13 (PR#13) I replaced that with a regenerable `drafts[]` revision history, appended only on a real change, revisable up to kickoff. **Approach.** The drafts model is genuinely better product behaviour — a prediction should be allowed to absorb a confirmed lineup. But the migration ran across matches that had *already* been graded, and it did not carry their originating predictions across. **Artifact.** These four matches still carry a stored verdict and are counted in every aggregate, yet their prediction no longer exists in the data: | matchId | fixture | predicted | actual | verdict | | --- | --- | --- | --- | --- | | M001 | Mexico vs South Africa | null | 2-0 | Inspired | | M002 | South Korea vs Czech Republic | null | 2-1 | RedCard | | M007 | Canada vs Bosnia & Herzegovina | null | 1-1 | RedCard | | M019 | USA vs Paraguay | null | 4-1 | Missed | **Result.** This is the change I would most want back. The verdicts are real, the karma is real, and the app's own numbers are internally consistent — but a schema migration silently dropped a field that four already-scored rows depended on, leaving verdicts whose prediction can no longer be shown or re-derived. If I did it again, a migration touching graded rows would fail loudly rather than quietly forget. Trust in a scoreboard is trust in its provenance, and provenance is exactly what leaked out here. ## 3. Two scoring recalibrations **Decision.** Karma changed shape twice. First (2026-06-17, PR#16) the Perfect reward dropped from +2 to +1.5, to narrow the gap over Inspired, while RedCard (-1), Missed (0) and Inspired (+1) stayed put. Then (2026-07-23) grading moved off `result.regulation` — the 90-minute score — onto the *played* score (`extraTime ?? regulation`, penalties never counted as goals) plus the real winner who advanced. **Approach.** The second change mattered because knockout football does not stop at 90 minutes. Grading a shoot-out win as a draw was simply wrong. **Artifact.** The matches that only make sense under the played-score rule: | matchId | fixture | decidedBy | verdict | | --- | --- | --- | --- | | M088 | Australia vs Egypt | penalties | Perfect | | M099 | Norway vs England | extra_time | Perfect | | M082 | Belgium vs Senegal | extra_time | Inspired | | M104 | Spain vs Argentina | extra_time | Inspired | **Result.** Re-grounding the nine knockout matches decided beyond 90' moved the karma total from 15.5 to 25.0, and the final aggregate settles at **netKarma 25** across **104 graded** matches — 14 Perfect, 39 Inspired, 16 Missed, 35 RedCard, with a longest positive streak of 10 and a longest RedCard streak of 4. A scoring rule that ignores extra time is a scoring rule that will eventually lie to you in the round of 16. ## 4. Unattended means failures are silent **Decision.** Running generation in a cloud sandbox meant the routine could not open a raw TCP connection to Mongo; it wrote back by dispatching a payload into a GitHub Actions workflow. On 2026-06-28 (PR#21) I learned what that costs. **Approach.** A full pronostics-plus-briefs run serialised to roughly 150KB of base64, over the `workflow_dispatch` input ceiling of about 65KB. **Artifact.** ```json { "title": "Gzip the ingest dispatch payload", "description": "A full pronostics+briefs run ... exceeding the workflow_dispatch input ceiling (~65KB) and silently dropping the run (HTTP 422, no run created); the routine now gzips the payload before base64, and ROUTINE.md now checks the dispatch HTTP status and verifies a new run id." } ``` **Result.** The run did not error in any place I was watching — it returned HTTP 422 and *no run was created*. That is the defining hazard of six weeks of unattended agents: the schedule keeps advancing whether or not your writes landed. The fix was gzip; the lesson was that the routine must now assert its own success — check the status, confirm a new run id — because nobody is standing there to notice the silence. ## 5. Cutting the things I designed but never needed **Decision.** Two features were planned and then deliberately removed. `football-data.org` was documented as a fallback score source (2026-06-12) and never wired into `sync-scores.ts`. An automated FIFA replay-link backfill job (2026-07-01, PR#22) was deleted in favour of a hand-run `pnpm replay <matchId> <fifaUrl>`. **Approach.** I let openfootball stay the sole score source and carried `FOOTBALL_DATA_TOKEN` as an unused secret rather than build a fallback I might never trigger. **Artifact.** ```json { "topic": "Score-sync fallback source", "type": "scope-cut", "shipped": "Never wired into sync-scores.ts; openfootball remained the sole score source, with FOOTBALL_DATA_TOKEN carried as an unused workflow secret." } ``` **Result.** Both cuts were correct. The surprise was how much planned surface area a fixed deadline lets you shed — a fallback you never reach for and an automation whose output you can hand-curate in seconds are both weight, not safety. ## 6. Static, then more static than planned **Decision.** With the tournament over, the app dropped its live MongoDB Atlas backend (2026-07-23) for a static `data/matches.json` + `data/teams.json` snapshot read in-memory through the same `lib/db.ts` signatures — no database, no runtime secrets, `app/api/*` deleted. The migration spec then explicitly said *not* to do a full static export or rework `generateStaticParams`. **Approach.** I broke my own non-goal. Once the data was frozen there was no reason to server-render on demand. **Artifact.** ```json { "topic": "Static rendering scope", "planned": "Stay a Next.js server app; spec's explicit non-goal is no full static export and no generateStaticParams rework.", "shipped": "force-dynamic dropped and generateStaticParams added ...; the whole app now prerenders as static HTML." } ``` **Result.** Dropping `force-dynamic` and adding `generateStaticParams` turned the whole app into prerendered HTML, none of it server-rendered on demand. The reflection here is small but real: a spec's non-goals are written before you know what the finished data looks like, and it is fine to overrule your past self once the tournament stops moving — provided, unlike block 2, you do it out loud.
  • date2026-07-24
  • descriptionSix weeks of unattended agents against a schedule that could not slip: the runtime pivot that cost nothing, the migration that silently dropped four predictions, and the dispatch failure that returned 422 and told nobody.
  • nameResearch & Process Reflection
  • typearticle
  • statepublished