-
-
The public merge commit: merged by the policy, not a person, with the checks it ran.
-
Earlier run (2026-10-22, 3 tenants): one human merge, 4:58 later the fix was merged.
-
Nine DevSecOps stages, one merge.
-
Earlier run (2026-10-22, 3 tenants): merge at 12:07:00 UTC, issue closed at 12:11:57 UTC.
-
Run of 2026-10-23, 500 tenants: human merge 11:37:45 UTC, issue closed 11:45:34 UTC (7:49).
-
Duo CLI through the schema-atlas MCP server: old column before the migration, new one after, each citing its map hash.
Inspiration
Every agent that touches a database depends on one document nobody maintains: what the production schema looks like right now. A migration renames a column, the database is right, and the doc, the agents that read it, and the code written against it are quietly wrong until someone finds out in production. The MIT base project, okf-pg-tenant, could describe a multi-tenant Postgres, but it left its verified field empty because nothing verified anything. That empty field is why this exists.
What it does
A migration renames payments.captured_at to funds_captured_at. The database is right. The schema doc every agent reads is not, and neither is the job that still queries the old name. Someone finds out in production.
Schema Atlas keeps the database, the published knowledge about it, and the code that queries it in step, with no human after the merge:
- A human merges a migration.
- CI applies it to a throwaway Postgres, then to Cloud SQL staging (
atlas_staging: migrate, verify,check-queries), then to Cloud SQL prod, re-readspg_catalog, diffs the result against the published map, and publishes the map as a package versioned by its hash. - A GitLab Duo flow, running as its own service account, commits the new verified map and opens an issue saying what changed and which code reads the old name.
- If the change is a rename and the only hits are SQL constants in application code, the flow opens a fix MR.
- CI runs
check-queries: Postgres plans every SQL constant against every tenant schema. Postgres is the judge, not the agent. - A merge policy job on
mainchecks the MR (steward's branch, steward's commits, onlyexamples/changed,check-queriespassed on that exact sha) and merges it. The issue closes. - Staging and prod re-check the merged
main, and a release named for the map summarizes the run and links its package.
A nightly schedule re-reads prod. A hand edit nobody merged shows up as drift, and the steward reports it to a person instead of "fixing" it.
The map is served to humans as a site and to agents over MCP, from the same JSON, with its hash and verification time on every answer.
Who it's for
Teams running a multi-tenant Postgres (schema per tenant) whose services, report jobs and AI agents query the database directly. For them, one renamed column means a broken job, and one hand edit in prod means one tenant quietly differs from the rest.
Before vs after
| Before (manual, fragmented) | After (Schema Atlas on GitLab) |
|---|---|
| Schema docs are edited by hand and go stale within weeks | Every merge re-verifies the map against Cloud SQL prod and publishes it with a hash |
| A renamed column breaks a job; someone finds out in production | The broken query is found, fixed, proven by Postgres on all 500 tenants, and merged in under 8 minutes |
| A hand edit to one tenant in prod goes unnoticed | The nightly check catches it and opens an issue for a person |
| Agents guess at the schema from old docs or code | Agents ask the MCP server and get an answer with the map hash and verification time |
| A person must review and merge every follow-up fix | A merge policy on main merges only steward fixes that pass, and the merge commit says why |
Proof: the hands-off run (2026-10-23, UTC)
Recorded on 2026-10-23, UTC, on 500 tenant schemas. Every link is public.
| Time | What happened | Who | Evidence |
|---|---|---|---|
| 11:37:45 | Human merges migration 0008 (rename orders.subtotal_cents → order_subtotal_cents in every tenant) |
person | !12 |
| 11:38–11:39 | Tests, SAST, secret detection; throwaway Postgres: migrate from zero + verify | CI | pipeline 2980901739 |
| 11:41:38 | Staging (Cloud SQL, database atlas_staging): migrated, verified, check-queries: 2 queries x 500 tenants: 500 failed in 0.7s. Expected: a consumer still reads the old name. Advisory, so prod still runs |
CI | staging job |
| 11:42:01 | Prod (Cloud SQL, database atlas): migrated, pg_catalog re-read, diffed with the published map: breaking, rename suspected. Map packaged by its hash |
CI | prod job, package job |
| 11:43:20 | Steward flow commits the new verified map | Duo flow | 5d30dfd |
| 11:43:42 | Steward opens an issue: what changed, which code reads the old name | Duo flow | #9 |
| 11:44:03 | Steward opens a one-word fix MR for examples/revenue_report.py |
Duo flow | !13, commit 4a5f961 |
| 11:44–11:45 | SAST, secret detection, tests, check-queries on the fix: 0 failed on 500 tenants |
CI | pipeline 2980924446, MR pipeline 2980924804 |
| 11:45:34 | Merge policy checks the MR and merges that exact commit; Closes #9 closes the issue |
CI policy | policy job, merge commit fc00a6f |
| 11:47:11 | Staging re-checks merged main: 2 queries x 500 tenants: 0 failed in 0.7s |
CI | staging job |
| 11:47:35 | Prod re-verifies: map identical to the published one | CI | prod job |
| 11:48:10 | Summary: a release for map acdf7563b34c, linking its package, staging and prod jobs |
CI | release map-acdf7563b34c |
Both are GitLab environments: staging and production. The staging job in the first pipeline shows as failed (allowed): it exits 3 when check-queries fails, which is a warning by design.
Merge to issue closed: 7:49. Merge to summary release: 10:25. Earlier runs: !5 → !6 (2026-10-22, 4:58) and !3 → !4 (5:00), with 3 tenants and before staging existed.
Hands-off, word by word (issue → MRs → scans → fix → merge → staging → validate → prod → summary):
| Word | Where it happened | Link |
|---|---|---|
| issue | Steward opens the impact issue | #9 |
| MRs | The human's migration MR and the steward's fix MR | !12, !13 |
| scans | SAST and secret detection on the fix branch | semgrep-sast, secret_detection |
| fix | The steward's one-word code fix | 4a5f961 |
| merge | The merge policy job merges that exact sha | policy job, fc00a6f |
| staging | Cloud SQL atlas_staging, before prod and again after the fix |
staging (migration), staging (after fix), environments |
| validate | Postgres plans every query on every tenant; throwaway verify | check-queries on the fix, verify |
| prod | Cloud SQL atlas migrated, then re-verified after the fix |
prod (migration), prod (after fix) |
| summary | Release for the map, linking package, staging and prod | release map-acdf7563b34c |
Proof: 9 stages
| Stage | What runs | Evidence |
|---|---|---|
| Plan | Steward opens an issue per map change; the fix MR says Closes #N |
#9, #10 |
| Create | Steward commits the map and writes the code fix | 5d30dfd, !13 |
| Verify | verify and check-queries against real Postgres: throwaway, staging, prod |
check-queries, staging |
| Secure | SAST, secret detection, keyless WIF, pinned proxy checksum | semgrep-sast, secret_detection |
| Package | Generic package schema-atlas-map, one version per map content hash |
packages, package job |
| Release | A release per verified map, linking its package | map-acdf7563b34c, releases |
| Configure | Staging and production environments on Cloud SQL; Pages site and Google Cloud mirror from map.json |
environments, live map, mirror |
| Monitor | Nightly schedule re-reads Cloud SQL and reports drift | pipeline 2980944257, #10 |
| Govern | Merge policy: path, author, sha-pinned proof, protected token | policy job, merge commit fc00a6f, merge_policy.py |
Proof: drift nobody merged, at 500 tenants
Five seeded tenants were picked at random (seed 1792756171) and hand-edited on Cloud SQL, one distinct edit each (three added columns, one added NOT NULL column, one SET NOT NULL). The nightly schedule, run on demand, flagged exactly those five and no others out of 500:
| Time (UTC) | What happened | Evidence |
|---|---|---|
| 11:49:33 | Five hand edits applied outside any migration | (a local script, by design not in the repo) |
| 11:49:39 | Nightly pipeline: verdict drift, 5 new drift entries, one per edited tenant |
pipeline 2980944257, prod job |
| 11:52:56 | Steward publishes the drifted map | commit 9ec82d3 |
| 11:53:12 | Steward opens an issue listing the five tenants; no fix MR | issue #10 |
The five edits, applied at 2026-10-23T11:49:33 UTC (seed 1792756171, pool of 497 seeded tenants):
| Tenant | Table | Edit |
|---|---|---|
a1d40a1f-2895-4e43-89a4-7c70ecac4731 |
orders |
ADD COLUMN gift_note text |
a5dc99b2-4faa-46d8-89c3-5c10b24298f7 |
customers |
ADD COLUMN phone text |
ae06c110-c543-416a-828b-a9a0868e0021 |
payments |
ADD COLUMN refund_ref text |
a41ce52d-e34b-4279-8077-ecac9ec1b36b |
customers |
ALTER COLUMN display_name SET NOT NULL |
a73c4bdf-f32c-47c4-8617-cd5fdfa61f6a |
orders |
ADD COLUMN priority smallint NOT NULL DEFAULT 0 |
Issue #10 lists the same five tenants and no others.
Earlier, with 3 tenants: a hand-added orders.rush_shipping was caught by the nightly run (2976198329) and reported in #6.
Proof: an agent asks, before and after
Duo CLI, through the schema-atlas MCP server, same question before and after migration 0008. Before (local map 1d2121f0b80c): subtotal_cents. After the loop republished (map acdf7563b34c): order_subtotal_cents. Each answer cites the map hash it came from, so the stale one is visibly stale. Shown in the video at 1:45.
Scale
- 500 synthetic tenant schemas (migration
0006). check-queries, 2 queries x 500 tenants: 0.7 s on Cloud SQL staging (job), 0.3 s in CI (job). The loop runs inside Postgres in one call; one round trip perEXPLAINtook 235 s.- Drift: 5 random tenants hand-edited, exactly those 5 flagged, no others (pipeline 2980944257, #10).
- The Google Cloud mirror, the Pages site, the repo and the package all serve map
sha256:663921390538….
How we built it
The base (MIT, vendored unmodified). okf-pg-tenant reads a multi-tenant Postgres catalog, collapses schema-per-tenant into one logical table set, detects per-tenant drift, and renders OKF Markdown. It leaves the verified block empty because nothing in it verifies anything. That is why this entry exists.
Added after 2026-10-05:
map.jsonwith a SHA-256 content hash, and a verdict engine (added, removed, retyped, narrowed, renamed, tenant drift).atlas migrate: append-only migrations, each recorded with its hash; an edited migration is refused.atlas check-queries: extracts SQL constants from Python withast, andEXPLAINs each one per tenant schema.- The Cloud SQL deploy path: keyless Workload Identity Federation from GitLab OIDC, a SHA-pinned Cloud SQL Auth Proxy, no authorized networks; a
stagingdatabase and job beforeprod, both GitLab environments. - Map packages (generic package per content hash) and a summary release per closed loop.
- The merge policy (
src/schema_atlas/merge_policy.py): one pure decision, replay-tested on the real evidence of fix !6 and mutation-checked; it runs only onmainwith a protected token. - A stdio MCP server (five tools), a one-file site, a Google Cloud Storage mirror.
- The Duo flow and the CI loop. 64 tests, lint-clean in CI.
How GitLab Duo Agent Platform is used
- Custom flow
flows/atlas-steward.yml, triggered by a passed pipeline onmain(a merge or the nightly schedule). - Service account
ai-schema-atlas-steward-gitlab-ai-hackathonwrites the map commits, issues, fix branches and fix commits. - Tools:
run_command,read_file,list_dir,create_issue,get_issue,create_branch,create_commit,create_merge_request,get_merge_request,create_merge_request_note. - Judgement: the flow decides whether a verdict is a fixable rename or a decision for a person.
- MCP: the verified map is the agent surface in Duo CLI and IDE.
Challenges we ran into
- A flow can't merge. Its token has the
ai_workflowsscope, and merge returns403 insufficient_scope. We moved merging into a CI policy job onmainwith a protected token, so the policy can't be changed by the branch it judges. - Flow runs can't see protected CI variables, because they run on
refs/workloads/.... That's good for safety, so prod access lives in CI, not in the agent. - Same-project bridge triggers fail (
invalid_bridge_trigger), and trigger-API variables were rejected. The fix MR now asksmainto run a variable-free merge pipeline, which finds steward MRs itself. - Flows don't get MCP servers (
excludeMcpTools: true), so the flow reads the CI artifact and MCP serves the published map. - Windows line endings changed migration hashes. The hash now normalizes
\r\n. check-querieswas too slow at 500 tenants (235 s, one round trip perEXPLAIN). It now runs as one PL/pgSQL call: 0.7 s.- The merge token can merge but not comment on issues (403). The run summary is a release made with the job's own token instead.
Who did what, plainly
The steward service account writes the map commits, issues, fix commits and fix MRs. GitLab lists the flow's owner as the author of MRs a flow opens. The merge is done by the policy job using a protected token that belongs to the project owner. The merge commit message records the policy and its checks. The author check is metadata; the real guarantees are the examples/-only path rule, check-queries on the exact sha, and that the policy and token live only on main. The staging/production deployments and the map-* summary releases use the job's own token, so GitLab shows the account that triggered that pipeline (the merge-token owner, since the policy's merge started it); no person clicked anything.
Accomplishments that we're proud of
- A real one-merge loop through staging and prod on Cloud SQL, 500 tenants, with a public link for every word of the Hands-off definition.
- An outside judge (Postgres
EXPLAINon every tenant) instead of an agent grading itself. - No Google key anywhere; prod access is per-job and keyless.
- Every limit is printed: defaults, keys, constraints, indexes, foreign keys, identity, triggers and RLS are not compared.
What we learned
- An agent should not grade its own work. Postgres planning every query on every tenant is a better judge than any model.
- Platform limits are design inputs: flows can't merge and can't see protected variables, so the merge lives in a policy job on
main, and that made it safer. - Scale shows up in round trips, not rows: 500 tenants made one
EXPLAINper round trip take 235 s; one call inside Postgres takes under a second. - A verified artifact with a hash is a different product from generated documentation: an agent can say which map it read and when it was verified.
What's next
- Compare constraints and indexes.
- Comment the verdict on the migration MR before merge.
- Fix renames in more places than
examples/(ORM models, dbt), each with its own judge.
Built With
- gitlab
- gitlab-ci
- gitlab-duo
- gitlab-duo-cli
- gitlab-environments
- gitlab-package-registry
- gitlab-releases
- google-cloud
- google-cloud-sql
- mcp
- playwright
- postgresql
- psycopg
- python
Log in or sign up for Devpost to join the conversation.