E EidosAGI

Git is a
Type 2 dimension table

Why config-as-code becomes non-negotiable once agents operate your stack.

Astro + Supabase + Railway + GitHub Actions

Manual dashboard edits aren't dumb. They're faster than a pull request, obvious in the moment, and correct for a team of humans with memory of what they've touched. They stop being correct the instant an agent has to reason about the same system — because an agent can only see what's in a file. The question is not whether to manage hosted config in code. It's when the operating model makes that mandatory, and what property of git makes the pattern unusually agent-friendly once you commit to it.

The problem

The trade-off that used to favor clicking

Every hosted service — Supabase, Railway, Fly.io, Cloudflare, Stripe — ships with a web console. For a solo operator with a few settings and good memory, the console is the fastest path. For a two-decade stretch, that was the correct default. The axis along which it held: the operator is a human, inspection is cheap, drift is recoverable in an afternoon. Shops that adopted heavier tooling like Terraform did so at real cost and the payoff showed up only at scale.

Human-only operating model
  • Inspection is cheap. The operator opens the dashboard, sees the value, moves on.
  • Drift is visible in practice. The person who clicked remembers (mostly) what they clicked.
  • Pattern-reuse cost is high. Standing up IaC/CaC takes an afternoon; often overkill for a few fields.
  • Audit is synchronous. "What changed and why" answered verbally or in Slack threads.
Agent-in-the-loop operating model
  • Inspection is gated. An agent cannot read a web console — only files.
  • Drift is invisible. An agent can't know a dashboard field changed six months ago.
  • Pattern-reuse cost is low. One Management API token and a GitHub workflow per service; agents can wire it.
  • Audit is expected. "What was the Site URL on March 1st?" must answer in seconds, from the repo.

The axis has moved. An agent is now in the loop — reading the repo, proposing changes, running the deploy, writing the retrospective. When that agent is asked "what is the current Site URL, and when was it last changed, and by whom?", a dashboard edit made six months ago is not merely inconvenient. It is invisible.

Four seams in the modern auth stack

Consider any greenfield auth shipped on the current stack: Astro on Railway, Supabase for auth, Migadu or a comparable provider for email. Four seams appear independent of team skill:

checkOrigin / edge-proxy host mismatchSSR cookie write into frozen headersurl.origin resolves to container, not public domainsilent 429 from built-in SMTP rate limit

Seam 1 — Astro ↔ Railway edge

Astro's default checkOrigin compares the request host to the browser's Origin header. Railway's proxy rewrites the host. POST forms 403 until the check is disabled or reconfigured.

Cross-site POST form submissions are forbidden

Seam 2 — @supabase/ssr ↔ Astro render

The SSR middleware stages a token-refresh cookie write. Astro has already frozen the response headers by the time the write lands. Every SSR render 500s.

TypeError: immutable (at appendHeader)

Seam 3 — Astro url.origin ↔ proxy

Absolute URLs built from Astro.request.url inherit the container's listen address. Magic-link emails send users to URLs they cannot visit.

http://localhost:8080/login?sent=1

Seam 4 — Supabase SMTP rate limit

Built-in mailer caps at 3 emails/hour on free tier. The endpoint returns 200; the error lives inside the response envelope that naive handlers drop.

over_email_send_rate_limit

Pattern

Three of the four seams live at vendor boundaries. None of the four vendors' docs mentions the others. The gaps are unowned.

What the seams share

Each vendor is the center of its own universe. Each default is reasonable in isolation. The drift between defaults is where the work happens.

The root that produces all four

Patching each seam in isolation costs a PR. Patching the thing that generates seams costs a pipeline. In the case study behind this piece, the Supabase Site URL setting held http://eidosagi.com — http, no path. Set at project creation. Never revisited. Never seen. Nothing in the code said "assume http"; the code assumed https.

The property that makes this non-negotiable in an agent-operated environment

An agent asked "is the auth config right?" can inspect the repo in seconds. It cannot inspect a dashboard without a human in the loop. If the dashboard is the source of truth, the agent is locked out of the system's actual state — not slowed down, locked out. Dashboard-first management treats configuration as a human-only artifact. An agent-operated system cannot afford that constraint.

The approach

The fix has the same shape across every hosted service with a Management API: declarative file in the repo, CI step that reconciles hosted state to match the file on merge, scoped token as a repo secret. The file is the source of truth. The dashboard becomes a read-only picker.

supabase/config.toml
[auth]
site_url = "https://eidosagi.com"
additional_redirect_urls = [
  "https://eidosagi.com",
  "https://eidosagi.com/auth/callback",
  "http://localhost:4321/auth/callback",
]
.github/workflows/config-push.yml
on:
  push:
    branches: [main]
    paths: ['supabase/config.toml']

jobs:
  push:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: supabase/setup-cli@v1
      - env:
          SUPABASE_ACCESS_TOKEN: <secret reference>
        run: supabase config push --project-ref <project-ref> --yes

A Management API Personal Access Token — scoped, expiring, revocable — authenticates the push. Standing this up is one PR. The cost of not standing it up compounds silently across every subsequent config change.

If you want to do this yourself

The minimum viable pipeline is five steps. Each one below is a concrete action the reader can take; none of it is narrative about what we did.

  1. Generate a Supabase Management API token scoped to your account. Copy it once; they don't show it again.
  2. In your repo, add the token as the SUPABASE_ACCESS_TOKEN secret at Settings → Secrets and variables → Actions.
  3. Commit a curated supabase/config.toml containing only the fields you've deliberately chosen — do not include defaults, which invite silent regressions when the vendor changes one.
  4. Add a GitHub Actions workflow that runs supabase config push --project-ref <ref> --yes with the secret in env. Gate its paths: on the config file so unrelated commits don't fire the workflow.
  5. Merge. Verify via the Management API that the hosted settings match the file. If they don't, the workflow's log tells you which field tripped.

Expand the same shape to every hosted service with a Management API. Cloudflare, Stripe, Fly.io, Railway each have equivalents.

The evidence

Versioning schema changes with forward-only migrations is the incumbent. 0001_init.sql, 0002_add_column.sql, each step overwriting the last. To answer "what was this field set to on March 1st?" a migration-based system requires replaying every migration in order up to that date.

Migrations — SCD Type 1
  • State over time — reconstructive; replay every migration.
  • As-of query — run all migrations up to a date, then inspect.
  • Attribution — "who changed X" requires grepping files and cross-referencing commit history manually.
  • Agent orientation cost — O(migrations).
Config-in-git — SCD Type 2
  • State over time — directly queryable from git log.
  • As-of querygit show <sha>:config.toml, one command.
  • Attributiongit log -p -S 'field_name' returns author, timestamp, diff, message.
  • Agent orientation cost — O(1).

Every commit touching the config file is implicitly a row in a Type 2 slowly-changing-dimension history table: field name, new value, valid_from (commit timestamp), valid_to (next commit touching the same field), author, message. The git log command is the SELECT statement.

$ git log -p -S 'site_url' -- supabase/config.toml
commit 96d520d  2026-04-22  Daniel Shanklin
feat: config-as-code pipeline for Supabase

-site_url = "http://localhost:4321"
+site_url = "https://eidosagi.com"

The entire history of a single field, across the life of the project, retrieved by any agent in one shell command. Author, timestamp, exact before-and-after, the commit message explaining why. The declarative part stops the drift. The git-as-history part makes the state queryable across time. Together they remove the entire class of "who changed this, when, and why" questions that otherwise dead-end at a screenshot of a dashboard someone opened two quarters ago.

The result

Dashboard-first was the right default when humans were the only operators and every change was inspected by the person who made it. That defence is gone the moment an agent has to reason about the same system — and at that point the decision flips. Not because the old pattern was stupid, but because the operating model is different.

The rule that falls out: if a dashboard click can change how production behaves, and an agent is going to operate on that production later, the click belongs in a file first. The file earns its place because it's the only artifact the agent can read. The git history earns its place because it's the only way the agent can answer historical questions without a human.

Migrations will lose to version-controlled config for the same reason XML lost to JSON: the thing that makes replay costly is what makes the format resistant to agent inspection. Every hosted service with a Management API is one PAT and one workflow away from this pattern. The work to adopt it is smaller than the work caused by postponing it.