1. Experimental Methodology & Controls
Most coding benchmarks score a model on a self-contained function. Infrastructure work is a different problem: it has state, ordering, partial failure, credentials, and a definition of done that only a running machine can confirm. The interesting question is not whether a model can write Terraform — it is whether the thing it writes converges, and whether it survives a node dying.
Eliminating the order effect
Building one engine first teaches patterns that make the second cheaper. That transfer is larger than the difference between the engines, so a sequential A-then-B run mostly measures "second is easier." Three controls removed it:
- Two cold concurrent subagents. Both launched in the same instant with no shared context. The MySQL agent was forbidden from reading
postgres-ha*; the PostgreSQL agent was forbidden from readingmysql-ha*. Neither could see the other's design. - One prompt template. Both prompts were rendered from a single canonical file with an eight-row substitution table covering only engine-specific nouns — hostnames, bucket names, credential names. No example, hint, or named tool appears in one that is absent from the other.
- Pre-written symmetric desired state. Each
colors.ymlstarted at 102 lines fixing provider, region, instance size, node budget, storage targets and the destroy guard. A diff confirmed the two files differed only in name, host, zone and bucket.
Deliberately not fixed: the replication topology, the failover orchestrator, the client endpoint mechanism, the backup tool, and the PITR mechanism. Choosing those is the work being measured, and pre-specifying them would have deleted the finding.
2. Architectural Divergence: How Claude Opus 5 Solved Each Stack
Given identical budgets — three droplets maximum, s-2vcpu-4gb, ubuntu-24-04-x64, DigitalOcean ams3, VPC discovered at runtime — the two agents produced engine-native architectures that disagree in one specific and instructive place.
PostgreSQL arm (postgres-ha & postgres-ha-digitalocean)
- Quorum & consensus: a 3-member etcd 3.5.33 cluster colocated on the database nodes, pinned by release tag and by the SHA-256 of its tarball, driving Patroni 4.1.5 with quorum synchronous commit (
ANY 1). Patroni 4 removed theraftDCS, so an external store was not optional. - Client routing: HAProxy 2.8 on all three nodes, port
5432to the leader and5433to replicas, health-checked against Patroni's REST API, behind three Cloudflare A records. A failover changes nothing in DNS and calls no cloud API. - Backup & PITR: pgBackRest 2.59 to R2, with
archive_commandset in Patroni's DCS rather than on a node — so a promoted standby keeps archiving. WAL-G was evaluated and rejected for lackingcheckand alternate-path restore. - Verified restore: restore the newest backup, replay every archived segment, start it on a spare port, and require a leader-written heartbeat newer than 900s. A
recovery_target=immediatedrill would pass with archiving completely broken.
MySQL arm (mysql-ha & mysql-ha-digitalocean)
- Quorum & consensus: native MySQL 8.0 Group Replication in single-primary mode. The three mysqld processes are the Paxos group, so the quorum store is colocated by construction and there is no orchestrator — detection, quorum and election are one mechanism instead of three that must agree.
- Client routing: a DigitalOcean reserved IP claimed by whichever member reports PRIMARY, polled on a 10s timer. The Cloudflare record's content never changes, so OpenTofu keeps owning DNS while the cluster owns the assignment.
- Backup & PITR:
mysqldump --single-transaction --set-gtid-purged=ONwith zstd, plusmysqlbinlog --read-from-remote-server --stop-never --rawrunning on every member and uploading each minute. Because every ONLINE member logs every transaction under the same GTID, any one prefix is a complete source and the archiver needs no leader election. XtraBackup was rejected for coupling to the distribution's MySQL build. - Verified restore: a scratch
mysqlddaily, assertingreplayed_beats > 0— the only check that distinguishes a restore from a point-in-time restore.
The disagreement worth reading twice
Each arm explicitly rejected what the other chose. The MySQL agent took a reserved IP to get one stable address, accepting a cloud API call in the failover path. The PostgreSQL agent rejected exactly that design on the grounds that it "puts a DO token on every DB node and needs an API call while degraded," and paid for the alternative in client-side complexity instead.
Neither is wrong. They weighted dependency during degradation against client simplicity differently, and both wrote down why.
3. Objective Metrics & Scorecard
Recovered from the filesystem, git history, and live cloud probes by one script run identically against both packages — so no number below is an artefact of how the two arms were counted.
| Category / Metric | PostgreSQL arm | MySQL arm | Combined |
|---|---|---|---|
| Convergence status | ✔ Converged | ✔ Converged | 2 / 2 |
| Package size | 108 files (8,800 lines) | 91 files (8,987 lines) | 199 files (17,787 lines) |
| Source / test split | 2,623 / 4,033 | 2,584 / 4,471 | 5,207 / 8,504 |
| Clojure | 1,558 lines | 1,000 lines | 2,558 lines |
| OpenTofu | 568 lines | 537 lines | 1,105 lines |
| Ansible YAML | 618 lines | 1,079 lines | 1,697 lines |
Design rationale (plans/) | 291 lines | 223 lines | 514 lines |
| Golden fixture files | 54 | 46 | 100 |
| Git commits | 8 | 8 | 16 |
| Deployment tracked files | 14 | 14 | 28 |
| Desired-state keys | 57 | 38 | 95 |
| Reused ONCE | no — green only | no — green only | 0 / 2 |
| Launcher byte-identical to payload | ✔ | ✔ | 2 / 2 |
| Secrets in tracked files | none | none | clean |
| Self-reported failed checks | 7 | 6 | 13 |
Source volume came out within 40 lines across the two arms, and both landed on exactly 8 commits. What differs is where the work went: PostgreSQL put its logic in Clojure, MySQL put its in Ansible. Same total, different layer.
4. Verified Failover & Recovery
Both packages were required to survive losing a machine for real. Neither result is a simulation or a dry run.
| Observation | PostgreSQL arm | MySQL arm |
|---|---|---|
| Fault injected | leader droplet powered off via cloud API | SIGKILL on the primary's mysqld |
| Promotion | ~9s (TTL 30s) | 23s |
| Endpoint serving new primary | no DNS write required | 43s (reserved IP moved at 36s) |
| Data loss | none — pre-failover marker row survived | none |
| Old node rejoin | automatic, missed WAL fetched from R2 | automatic on systemctl start |
| Archive continuity | 358 WAL segments unbroken from segment 1 across both timelines | binlogs from all three members, restore check replayed_beats: 8 |
The PostgreSQL deployment also re-ran ./green create resolved straight from the pushed SHA on GitHub, with no working-tree override — converging all six stages and proving the pin and the launcher copy work on their own rather than only on the machine that wrote them.
5. Friction You Only Find by Running It
Each arm hit exactly one engine-specific wall, and both were invisible to every offline check.
- MySQL caps replication-channel passwords at 32 characters. The supplied credential was 40 (
ERROR 3056/3972, probed on a live server). The agent neither truncated the secret nor asked for a new one: the replication account carriesSHA-256(password)[:32]in hex — one deterministic expression read by both consumers, 128 bits retained, documented in four places. - A powered-off node black-holes the SYN. PostgreSQL's endpoint resolves to three addresses, and
libpqwithoutconnect_timeoutwaits out the OS TCP retry — about two minutes — before trying the next one. The fix became validated desired state rather than a README note:client-connect-timeout-seconds, exercised by acceptance nine times over, withbb goldenfailing if it disappears. Measured atconnect_timeout=5: 6/10 probes at ~80ms, 4/10 at ~5.1s, zero failures.
The most expensive single mistake was not infrastructure at all. The PostgreSQL convergence gate demanded state == running from all three members, but a healthy standby reports streaming, and under synchronous mode its role is sync_standby. The cluster was correct on the first attempt; the gate spent 7.5 minutes failing to notice and skipped every task behind it. Five of that arm's seven failures were its own checks being wrong rather than the system under test.
6. What This Means for Platform & AI Engineering
Three things are worth carrying out of this run.
- The reuse path lost to a blank page. Every previously existing package in this workspace builds on ONCE's provider registry and compute stages. Both agents independently declined it and wrote their own DigitalOcean and Cloudflare templates, arriving at 568 and 537 lines separately. Two isolated agents rejecting the same abstraction on the same task is a signal about that abstraction's fit for multi-node topologies — not about either database.
- "Deployed" is a weaker claim than "survived." Both packages passed every offline check before either had a failover bug. The 32-character cap and the black-holed SYN appeared only under a real fault. A benchmark that stops at
--dry-runwould have scored both arms as finished and shipped both flaws. - Determinism is the deliverable. The output that matters is not the running cluster — it is a SHA-pinned Package Skill whose launcher is byte-identical to its payload, whose golden fixtures fail if a provider template drifts, and which converges the same way from a clean checkout. Both arms produced that.
Every package and deployment from this run is public.