| name | replatform-to-infrapad |
| description | Replatform an existing application onto the Infrapad framework (the adiom-data "sample-app" shape: Go + Connect-RPC + buf + Bazel + Goose + a gateway, deployed via Flux/Kubernetes, with a Vite/React/TypeScript SPA). Use when asked to port/migrate/rebuild an existing app onto Infrapad and BOTH the original repo and the target Infrapad repo are already checked out locally. Drives the work proto-first, audits fidelity against the original, and lands a tested, building system.
|
Replatforming an existing app onto Infrapad
You are porting a working application (the source) onto the Infrapad
framework (a checked-out copy of the adiom-data/sample-app scaffold, the
target). Treat the target framework's conventions as primary; reuse the
source's data model, business rules, design system, and seed data as the spec.
Both repos are already checked out. Ask the user for the two paths if not given.
What the Infrapad framework is (orient first)
- Backend: Go. One Connect-RPC service per domain, defined in
proto/,
generated by buf into gen/go/** (and web/src/gen/**). Server assembled
with github.com/adiom-data/framework/httpapp. Raw pgx-via-database/sql,
no ORM; schema via Goose SQL migrations under services/api/migrations/.
- Auth:
github.com/adiom-data/framework/auth/tokenissuer mints EdDSA app
tokens; a gateway (services/gateway/gateway.json) validates tokens via
the issuer JWKS and forwards the verified bearer; services re-verify with
tokenissuer.ConnectAuth (scope check) and read the user via
tokenissuer.AuthValueFromContext. The stock scaffold uses external OIDC.
- Frontend: Vite + React + TypeScript SPA in
web/, calling generated
Connect-web clients.
- Build/deploy: Bazel (gazelle for Go BUILD files; buf for proto, NOT bazel
proto rules), OCI images, three Flux bundles (
//deploy:{infra,migration,app}).
- Read
docs/patterns.md in the target before changing deploy/auth/migrations.
Phase 0 — Understand both repos (parallel, read-only)
Launch ~2 Explore/general-purpose agents IN PARALLEL:
- Source audit: purpose, data model (every table + column), endpoints/routes,
business rules, auth/RBAC, integrations (AI, etc.), frontend pages/components.
Read the source's
PRD.md/docs//AGENTS.md if present — they are the spec.
- Target audit: the framework's service-registration pattern (
internal/api/app.go),
auth wiring (internal/auth/*, the tokenissuer API in the module cache),
migration style (services/api/migrations/0001_*.sql), the sample service
end-to-end (proto → handler → web client), gateway config, Bazel/gazelle/buf setup.
Then read the framework's tokenissuer/httpapp source from the Go module cache
(go mod download github.com/adiom-data/framework then read under $(go env GOMODCACHE))
to confirm the exact Issuer.Mint, JWKSHandler, BearerAuthenticator, and
httpapp.ConnectHandler/ConnectOption signatures before writing code.
Phase 0.5 — Rename the scaffold to the project name
The fresh scaffold still carries the framework's example identity
(sample-app/sample_app, the sample-api/sample-postgres k8s names, the
sample-app-auth secret, the sample-app-{api,gateway,migrate} images, and a
leftover example SampleService). Rename all of it to the project's name/slug.
Do this early (right after Phase 0) so every new file you write already uses
the project's module path — renaming later forces a regen + rebuild of everything.
Confirm the target slug with the user (it drives the Go module path and all k8s
resource names, which are hard to reverse).
- Delete the leftover example service first (its proto + generated Go/TS +
BUILD files) so it isn't dragged through the rename or regenerated.
- Scripted, ordered replace across tracked text files (exclude
.git,
node_modules, web/src/gen, and the bazel-* symlinks). Apply
most-specific first to avoid double-mangling:
- the full module path
github.com/<org>/sample-app → github.com/<org>/<project>
- the compound image names
sample-app-{api,gateway,migrate} → <project>-{api,gateway,migrate}
- the auth secret
sample-app-auth → <project>-auth
- the bare
sample-app / sample_app (Bazel module name, prose, gazelle prefix) → <project>
sample-postgres → <project>-postgres (covers its -app/-superuser/-rw generated-secret suffixes)
sample-api → <project>-api (k8s Service/Deployment, gateway backend url,
AUTH_ISSUER, OTEL_SERVICE_NAME)
Also fix the bazel-sample-app entry in .bazelignore.
- Regenerate, don't hand-edit, the generated trees:
buf generate (the proto
option go_package now points at the new module) rewrites gen/**; then
bazel run //:gazelle rewrites BUILD importpaths/deps; then bazel mod tidy
syncs the renamed Bazel module.
- Verify cross-references stay consistent: the gateway backend host, the
gateway
auth.issuer, the API AUTH_ISSUER, and the K8s Service name must all
be the same <project>-api; the CNPG cluster name must match the
<project>-postgres-* secret names referenced by the app + migration jobs.
- Grep for any residual
sample-app/sample_app/sample-api/sample-postgres/
SampleService and confirm go build ./..., buf generate, web tsc/build,
and bazel build are all green before continuing.
Decision points (ask the user with AskUserQuestion)
- Project name/slug — drives the Go module path and every k8s/image name (see
Phase 0.5). Confirm before renaming; it's destructive to change later.
- Auth model. The framework assumes external OIDC. If the source has its own
email/password login, the usual choice is port password auth into the
framework's issuer: add an
auth.v1.AuthService/Login that verifies bcrypt
and calls Issuer.Mint(ctx, auth.Identity{Subject, Scopes, Attributes}),
carrying role/org/department as token attributes; serve issuer discovery
(/auth/.well-known/openid-configuration, /auth/.well-known/jwks.json) so
the gateway still validates; drop the OIDC browser flow. Alternative: keep
OIDC and map identity via claims.
- Scope: full replatform vs. a vertical slice first.
- Integrations (AI/etc.): port to the target language (e.g. Anthropic Go
SDK, degrade to
Unavailable when unconfigured), defer, or run as a sidecar.
- Frontend: reuse the source UI (port components + rewire transport) vs.
rebuild. Reusing shadcn/ui: bring the real components under
web/src/components/ui/
and expose them via a barrel index.ts under the names pages import, so pages
don't change.
Phase 1 — Foundation (make it compile AND run, end-to-end)
Do this before fanning out. It de-risks every later module.
- Migrations: port the source schema to Goose SQL (
00002_*.sql …). If the
framework owns a users table (app_users), EXTEND it with the source's
identity columns (password_hash, role, org_id, department_assignments, is_active)
rather than adding a parallel table; point domain FKs at it.
- Auth port: implement
Login/Me, the issuer + JWKS routes, demo seed; wire
into internal/api/app.go with a strict authenticator (RequireScopes) for
domain services and an allow-missing one for the auth service. Update
cmd/<bin>/main.go env (drop OIDC-required vars; keep AUTH_ISSUER,
AUTH_PRIVATE_KEY_BASE64; add integration keys).
- One reference domain service end-to-end (e.g. identity): proto → buf
generate →
internal/<svc>/{service.go,db/} → register → a web client + page.
- Run it:
go build ./..., start Postgres, run migrations, go run ./cmd/api,
and smoke-test Login → token → a protected RPC with curl. Verify a 401 without
token and RBAC denial. Only proceed once this works.
Phase 2 — Fan out the domain services (proto-first)
Define ALL remaining protos first (the contract), buf generate once, then
implement. The per-service recipe (repeat for each domain):
proto/<domain>/v1/<domain>.proto — RPCs mirror source routes; messages mirror
source models. Model flexible JSONB columns as JSON string fields
(*_json) for speed; typed sub-messages only where the shape is fixed.
- Goose migration for its tables.
internal/<domain>/db/*.go (pgx queries) + internal/<domain>/service.go
implementing the generated ...Handler (add a compile assertion
var _ <svc>v1connect.<Svc>ServiceHandler = (*Service)(nil)).
- Every handler: read
identity.CurrentUser(ctx); org-scope every query;
department-scope non-managers; enforce RBAC; write an audit row on every
create/update/soft-delete; soft-delete only.
- Register in the single wiring point (
internal/api/app.go / a stubs.go).
- Add the gateway route;
bazel run //:gazelle; go build.
Parallelize the independent per-domain files with subagents AFTER the
reference service is proven — give each the proto, migration, the reference
service, and the conventions; tell them to touch ONLY their package, add the
compile assertion, and go build ./internal/<svc>/.... Then YOU wire the shared
files (app.go, gateway.json, BUILD) and fix integration errors. Keep a shared
audit-writer in a leaf package (e.g. internal/auditlog) so services the
audit service depends on (identity) can also audit without an import cycle.
Phase 3 — Frontend
- Add a small token store + Connect interceptor (
web/src/api/transport.ts):
store the app token from Login (the framework's OIDC AuthTokenManager won't
fit password auth); a Vite dev server.proxy forwards /auth + ^/[a-z]+\.v1\.
to the API.
- Generated clients per service; an auth context; port pages, rewiring the source's
REST/axios calls to typed Connect calls (camelCase methods/fields). For reused
shadcn, keep a
components/ui/index.ts barrel so page imports are stable.
Phase 4 — Build / gateway / deploy / Bazel
- Gateway: a route per service (
/<pkg>.v1.<Service>/), auth service + /auth/
public, the rest verified_bearer.
bazel run //:gazelle after Go changes; add # gazelle:proto disable_global
to the root BUILD (proto is buf-only). After go get/go mod tidy, run
bazel mod tidy to sync MODULE.bazel use_repo.
- Update the app Deployment env + the auth secret keys to the new auth model.
Phase 5 — Seed demo data (and harden ALL startup-time DB work)
Port the source's seed so the app isn't blank — users (bcrypt) and demo data.
Critical porting hazard — deploy-ordering tolerance. The source app was almost
certainly a single process started after its DB, so seed-on-startup (and any
migrate-on-boot, cache warmup, etc.) "just works" locally and synchronously. The
target runs under Kubernetes with multiple replicas, a separate migration
Job, a DB provisioned by another step, and no guaranteed start order — so the
same startup code breaks: the app boots before the DB/schema exist. Audit every
startup-time side effect that touches the DB and make it tolerant:
- Wait-for-ready, don't fail-once. Ping the DB with capped backoff until
reachable, then proceed. The anti-pattern (which will bite): try once, log a
WARN on failure, and continue serving in a broken state (app "up" but unusable —
e.g. every login 401s because users never got seeded). Never silently half-succeed.
- Run it in the background, not blocking startup — readiness must not depend on
Postgres (the framework's own guidance), and the server should come up and self-heal.
- Idempotent + atomic. Wrap the seed in a single transaction so a partial/crashed
run rolls back and the next attempt re-seeds cleanly — not just "skip if a row
exists" (that leaves partial state stuck). Reference data → fold into migrations;
demo data → env-gated, idempotent.
- Single-runner across replicas. Guard with a Postgres transaction advisory lock
(
pg_advisory_xact_lock) so concurrent replicas don't race (no unique-violation
noise, no double seed). One seeds; the others no-op.
- Don't reach for platform ordering (Flux
dependsOn/wait) as the fix — it may not
be the platform's convention, often lives in a tenant-env source you don't control,
and gets pruned. App-tolerance is the durable, convention-aligned fix.
Verify the dashboard/lists populate, then prove the ordering: start the app
against a not-yet-existing DB → it must serve and retry (not crash); create the
DB+schema → it auto-seeds with no restart; re-run → no duplicates; two instances on a
fresh DB → exactly one seeds.
Phase 6 — Fidelity audit (do this; it WILL find regressions)
Run two read-only audits comparing the source's documented intent against your
implementation:
- Invariants & business rules (the source's invariants/workflows vs. your
handlers): scoping, audit-on-mutation, RBAC/separation-of-duties, status
machines, deterministic computations (e.g. expirations), uniqueness, graceful
degradation. Classify each HOLDS/PARTIAL/MISSING/CHANGED with materiality.
- Feature/endpoint/data-model coverage: every source route → new RPC or
MISSING; every table.column present or dropped; each page real or stubbed.
Fix the material regressions, don't just log them. Write a
docs/replatform-gaps.md ledger (FIXED / GAP / PRESERVED) so nothing is lost.
Common real misses to check explicitly: RBAC gates that the per-module subagents
under-implemented, deterministic math simplified to a constant, mutations missing
audit rows, dashboards that dropped an anti-join/window.
Phase 7 — Tests (cover every invariant + core workflow)
Build an integration suite (e.g. internal/apitest/):
TestMain reads TEST_DATABASE_URL; skip when unset so go test ./...
stays green without a DB. Drop/recreate schema, apply Goose migrations (use the
goose library), then per-test create an isolated org + one user per role.
- Inject the authenticated user with
tokenissuer.ContextWithAuthValue(ctx, user)
to call handlers directly (bypasses the token round-trip, still exercises
in-handler RBAC/scoping/audit).
- One test per invariant + the core workflows; assert connect error codes and
deltas (not brittle absolute counts). Put a coverage matrix in
docs/testing.md.
Phase 8 — Docs
Port the stack-agnostic docs (PRD, design system) and rewrite the
stack-specific ones (architecture, data-model, data-flows, workflows, operations,
integrations, testing, invariants) for the Go/Connect/Bazel stack. Add AGENTS.md +
a CLAUDE.md pointer. Keep docs/replatform-gaps.md current.
Phase 9 — GitHub publish workflow (CI)
Add .github/workflows/publish.yml that, on push to main (+ workflow_dispatch),
runs a lightweight unit/CI gate then builds and publishes the Flux/OCI bundles.
Integration tests (DB + running API) do NOT run in CI — they need a database;
keep them manual and document the command in README.md (Phase 7).
Match an existing adiom-data publish workflow in the org for house style; they
range from minimal (just bazel build //deploy:publish_manifest → bazel run //deploy:publish_all) to gated (typecheck + unit tests + bazel build //...
first). Prefer the gated shape so a broken test blocks a release. Key points:
- Publish targets are framework-standard: verify with
//deploy:publish_manifest, publish with //deploy:publish_all.
- Stamping is two-sided — get both right (this bites):
- Enable
build --stamp in .bazelrc (alongside the existing
--workspace_status_command=./tools/status.sh). The deploy bundles expand
{STABLE_REFERENCE_PREFIX} into the image-ref prefix at build time, and
that expansion is a no-op unless the stable-status file is non-empty, which
only happens with --stamp. Without it the bundles ship a literal
{STABLE_REFERENCE_PREFIX}/... image ref. (A bare --workspace_status_command
with no --stamp is the tell that it was forgotten.)
- Do NOT bake
{STABLE_GIT_COMMIT} into push_tags. The flux rule appends
that tag expansion to the runtime publish script, but doesn't put the
stable-status file in the bazel run runfiles — so under bazel run the token
can't resolve and you push an image literally tagged {STABLE_GIT_COMMIT}
(GHCR 404s). Set push_tags = ["latest"] and pass the per-commit tag at
publish time: bazel run //deploy:publish_all -- --tag "sha-${GITHUB_SHA::12}" --tag latest --revision "${GITHUB_SHA}". Runtime --tags override the baked
push_tags; --push-prefix/--source/--compare-tag can be passed too if not
baked. Confirm the run log shows real tags (...:sha-… + ...:latest).
- Host Go is only needed for the non-Bazel gate (
go test/go vet). The
Bazel build itself uses the hermetic rules_go SDK, so a pure build+publish
workflow needs no setup-go.
- The integration suite self-skips without
TEST_DATABASE_URL, so plain
go test ./... in CI runs only fast unit tests — exactly the lightweight gate.
vite build (the web image) does not typecheck — add an explicit
tsc --noEmit step. Use the repo's actual package manager (pnpm vs npm) and
pin Node via .nvmrc.
- Log in to GHCR before the build when image targets pull private base
images (e.g.
ghcr.io/<org>/components/*): rules_oci reads the docker config
that docker/login-action writes. If the bases are public, login-before-publish
is fine.
- Use
bazel-contrib/setup-bazel (cached) over the older setup-bazelisk (no
cache) for faster CI.
Example (gated; adjust package manager / target flags to the repo):
name: Publish
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
packages: write
concurrency:
group: publish-${{ github.ref }}
cancel-in-progress: false
jobs:
publish:
name: Build and publish
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- name: Go build, vet, unit tests
run: go build ./... && go vet ./... && go test ./...
- uses: pnpm/action-setup@v4
with:
version: "<pnpm-version>"
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
cache: pnpm
- name: Web typecheck
run: pnpm install --frozen-lockfile && pnpm -C web exec tsc --noEmit
- uses: bazel-contrib/setup-bazel@0.15.0
with:
bazelisk-cache: true
disk-cache: ${{ github.workflow }}
repository-cache: true
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- run: bazelisk build //...
- run: bazelisk build //deploy:publish_manifest
- name: Publish production bundles
run: |
bazelisk run //deploy:publish_all -- \
--tag "sha-${GITHUB_SHA::12}" \
--tag latest \
--revision "${GITHUB_SHA}"
Gotchas (learned the hard way — check these)
- Postgres
uuid = text has no operator. Optional filters like
($n = '' or col = $n) force $n to text and break uuid comparisons — cast the
column: ($n = '' or col::text = $n). (Bare where id = $1 infers uuid and is fine.)
- proto3 bool defaults to false — a Create that copies a wire entity will set
is_active=false. Force true on create (or let the DB default apply).
int || ' days' fails to encode via pgx; use make_interval(days => $n).
- audit ↔ identity import cycle: the audit service imports identity; put the
Write helper in a separate leaf package.
go mod tidy may drop deps the old stack used (e.g. oauth2 after removing
OIDC) → run bazel mod tidy to fix MODULE.bazel use_repo.
- Vite
@ alias under Bazel: resolve it from process.cwd() (the rule sets
chdir), not import.meta.url (the config runs from runfiles).
- pnpm + TS: set
skipLibCheck: true (pnpm's isolated node_modules trips
library .d.ts react-type resolution); drop deprecated baseUrl (use paths).
- Connect-ES method casing preserves acronyms:
ListQCLogs → listQCLogs.
- Renaming the scaffold (Phase 0.5): replace most-specific-first (full module
path before bare
sample-app); never hand-edit gen/** — buf generate +
bazel run //:gazelle regenerate it; run bazel mod tidy after the Bazel module
name changes; keep gateway-host / AUTH_ISSUER / Service name / CNPG-secret names
mutually consistent.
- Env:
pnpm often isn't on a non-interactive PATH (npm i -g pnpm@<ver>);
the migration tool is pressly/goose (go install github.com/pressly/goose/v3/cmd/goose@latest),
not the similarly-named AI agent that may shadow goose on PATH.
- Publish workflow (Phase 9): the CI gate is unit-only (
go test ./... skips
the DB suite); integration tests stay manual. Log into GHCR before the Bazel
build if base images are private.
- Bazel stamping for publish: need
build --stamp so deploy bundles expand
{STABLE_REFERENCE_PREFIX} at build time — BUT don't bake {STABLE_GIT_COMMIT}
into push_tags (it won't expand under bazel run); pass --tag sha-<short> --tag latest at publish time instead, and verify the run log shows real tags.
- Startup-time DB work assumes order that K8s doesn't give (Phase 5): seed /
migrate-on-boot / warmup that works in the single-process source breaks under
multi-replica + separate migration Job + arbitrary start order. Make it wait-for-
ready (bounded backoff, background, never fail-once-and-continue), idempotent +
atomic (one tx), and single-runner (
pg_advisory_xact_lock). Symptom to catch:
app "Running"/Ready but every request fails because a one-shot startup step
silently lost the boot race.
Verification gate (run before declaring done)
go build ./... && go vet ./...
buf generate clean
cd web && pnpm install && pnpm exec tsc --noEmit && pnpm build
- migrations apply on a fresh DB;
go run ./cmd/api boots, seeds, serves
curl smoke: Login → token → protected RPC; 401 without token; an RBAC denial
TEST_DATABASE_URL=… go test ./internal/apitest/... green; go test ./... green (skips) without it
bazel run //:gazelle && bazel build //cmd/<bin>:<bin> //web:build
(OCI :image / //deploy:* targets need registry creds — note, don't block on them)
Working style
- Proto-first; foundation before fan-out; parallelize independent per-module file
writing with subagents, integrate shared wiring yourself.
- After each phase, build/vet and (for the backend) a live smoke test against a
local Postgres — don't accumulate unverified code.
- Be honest in the gaps ledger; fix material regressions rather than papering over.