Skip to content

Go Backend Migration — Developer Reference

Go Backend Migration — Developer Reference

Section titled “Go Backend Migration — Developer Reference”

sencai-backend is a strangler-fig replacement backend for the Strapi CMS (sencai.space), written in Go. It is an extremely large, actively developed program — read this page for orientation, then sencai-backend/CLAUDE.md and sencai-backend/CUTOVER-PLAN.md for full per-domain detail if you’re working in that repository directly.

Strapi (sencai.space) is the platform’s original backend — a large Node.js/TypeScript codebase (~200 content-types) that has become expensive to iterate on at the platform’s current scale. sencai-backend re-implements the same API surface, content-type by content-type, behind the identical HTTP contract, so that traffic can eventually move over one route at a time (the “strangler fig” pattern) rather than through one large, risky rewrite-and-flip.

The service was originally built in Rust (Axum + Tokio + sqlx + lapin). On 2026-07-05 the language decision was reversed to Go — a business decision (faster iteration, simpler operations, and a match with the two existing Go services in the monorepo, agent-gateway/sencai-agent), not a technical failure of the Rust build, which was CI-green and live-verified at the time of the switch. The Rust implementation was not deleted — it remains reachable in git history and was used as an already-verified reference spec while porting domains that had a Rust equivalent (business logic and edge cases only needed working out once).

The original PHASE-5-PLAN.md scoped this project as 7 modules (FOUNDATION/TENANT/PROVISIONING/AUDIT/METERING/BILLING/DEPRECATE). The real scope, once work started, turned out to be much larger — full parity across roughly 200 Strapi content-types, both of Strapi’s RBAC systems, media/uploads, and the admin panel, a program comparable in size to FÁZE 2’s 36 waves. CUTOVER-PLAN.md tracks this as a wave-by-wave breakdown (WAVE-1 through WAVE-39 at time of writing) and is the living source of truth for exact per-domain status — this page describes the shape of the work, not an exhaustive per-content-type list.

  • Content-type porting: the large majority of Strapi content-types across every domain area (tenant/organisation core, cloud provisioning, cloud networking/IAM/assets/pricing, IaC/blueprints, resilience/chaos, registry/git, secrets/security, FinOps cost tracking, fleet core/ops/opsloop, observability/alerting/incidents, SLA/customer-health, compliance/GDPR/AI-Act governance, workspace directory sync, gamification/forum, marketplace, and most non-content-type custom API modules such as legal, privacy-portal, scim, operations, checkout, analytics, budget, and sponsorships) have been ported, reviewed, and covered by live-database regression tests.
  • AUDIT is split into two parts. Part A (read/verify/evidence/export/anchor surface over the shared, still-Strapi-owned audit_logs hash chain) is done. Part B — the actual hash-chain-writer cutover — is explicitly not started and is human-gated, because two independent writers racing the same hash chain would corrupt it (see “Single-writer invariant” below).
  • DEPRECATE (retiring the corresponding Strapi routes once a domain has fully cut over) has not started for any domain.
  • Database: sencai-backend does not share Strapi’s backend-db. On 2026-07-06 it was split into its own MySQL instance (go-backend-db), forked once from backend-db with an ID-preserving migration. There is no automatic schema-migration tool — go-backend-db’s schema was copied once and evolves through hand-applied, reviewed SQL files per package (documented as schema_*.sql artifacts in each domain package), never a live Strapi/Knex migration.
  • Orchestration: wired into orchestration/ under the go-backend profile (host go-backend.sencai.localhost, port 8080 internally) — shadow-only, deliberately excluded from DEFAULT_PROFILES/all where a task’s own risk profile calls for it (see the dedicated subsystems below).
  • Production deploy: blocked on infra-gcp’s own live cdktf deploy, itself not yet performed. PROD-CUTOVER steps and the eventual Strapi shutdown are tracked in the root manual-steps.md and require a separate, explicit future approval — not something any implementation task can authorize on its own.
  • Single Go binary per entrypoint, several cmd/* binaries sharing one module: cmd/sencai-backend (the main HTTP API), cmd/healthcheck, cmd/dbsync-consumer, cmd/parity-harness, cmd/metering-consumer — all built into the same scratch-based Docker image, selected via an ENTRYPOINT override per Compose service.
  • Package-per-domain, not package-per-layer: each content-type/domain owns its own internal/<domain>/{handler.go,repo.go,types.go}, Go convention over the Rust build’s earlier domain//handlers//repositories split.
  • Shared, business-logic-free internal/ packages: authn (JWT/JWKS validation, hand-rolled TTL+rate-limited JWKS cache), mq (RabbitMQ publisher with reconnect/backoff), orgscope (org-membership/role resolution shared by every domain), tenant (cross-tenant authorization/resolution), auditpub (fire-and-forget audit publish), problem (RFC 7807 error responses), config/logging/db.
  • No separate tenant/auth microservice — tenant/organisation resolution lives directly in this binary’s own packages; a separate service would only duplicate the single-binary structure already established here.
  • Testing convention: plain stdlib testing with table-driven t.Run subtests — no testify. Every non-trivial fix ships with a live-database (and, where relevant, live-broker) regression test, not just a unit test — see “Engineering discipline” below.

internal/dbsync — the ongoing sync mechanism

Section titled “internal/dbsync — the ongoing sync mechanism”

Because go-backend-db and Strapi’s backend-db are two independent databases post-split, something has to keep the Go-side copy current while Strapi remains the only system taking real writes. internal/dbsync (plus the standalone cmd/dbsync-consumer binary) is that mechanism: an event-triggered pull off the existing audit.events/audit.write exchange, backstopped by periodic full-table reconciliation. It reads backend-db through a dedicated, read-only MySQL credential (go_dbsync_readerGRANT SELECT only, enforced at the MySQL grant level, not just in application code) and writes into go-backend-db.

By the most recent count, dbsync-consumer registers adapters for the large majority of the platform’s dbsync-eligible content-types (organisation/cloud-instance/organisation-member and dozens of others across billing, fleet, cloud, workspace, compliance, and audit domains) — coverage is close to complete but not total; a handful of tables remain unwired. Each adapter never trusts a source row’s raw numeric foreign key across the two independently-forked auto-increment sequences — relations are always re-resolved via the target’s own document_id.

A per-resource-type kill switch (DBSYNC_DISABLED_RESOURCE_TYPES) exists specifically so a resource type can be excluded from both the live sync path and the reconciler before any real cutover raises that route’s traffic weight above 0% — this is a documented prerequisite step in the cutover runbook, not automatic.

dbsync-consumer is its own Compose profile, deliberately excluded from DEFAULT_PROFILES/all — new infrastructure reading real production-shaped data should never start as a side effect of a bare up -d.

cmd/parity-harness — Phase 6 continuous shadow-traffic parity

Section titled “cmd/parity-harness — Phase 6 continuous shadow-traffic parity”

A naive “diff Strapi’s response against Go’s response for the same row” approach stopped being viable the moment the database split happened — the two backends no longer share a single underlying row to compare, and Strapi keeps taking real writes Go never sees. parity-harness instead creates its own canary rows independently on each backend from an identical input payload, and asserts each backend’s response conforms to that known input — a methodology that is immune to database drift by construction.

Three frequency tiers, chosen by real-world side-effect cost:

  • fast (default every 10 minutes) — organisation-member CRUD plus organisation/organisation-member reads, self-cleaning, no real side effects.
  • medium (default every 4 hours, plus a separate daily create-probe) — organisation update/description-rotation, and a rare disposable-org create-then-archive probe (since organisation.create() has real side effects: Gitea provisioning, forum webhook, trial-state, funnel telemetry).
  • slow (default every 24 hours) — cloud-instance CRUD, gated behind a double-key safety interlock.

The slow tier’s safety interlock exists specifically because of a real 2026-07-06 incident where a credential-less cloud-instance create silently provisioned a real Hetzner VM against the platform’s real cloud account. Key 1: the harness’s canary payload structurally never includes a credential_id, so cloud-connector’s fail-closed provisioning guard refuses the job outright. Key 2: the entire slow tier is skipped by default unless PARITY_HARNESS_CONFIRM_NO_REAL_PROVISIONING=true is explicitly set — a human attestation this process cannot verify on its own.

Also part of this deliverable: a Traefik-mirroring config that duplicates a sample of real GET-only production traffic to the Go backend asynchronously (never a mutating request — mirroring one would cause Go to perform its own real side effects on traffic it was never meant to touch), purely for a stability/error-rate/latency signal, and a parity-harness Grafana dashboard plus Prometheus alert rules.

parity-harness is its own Compose profile, excluded from DEFAULT_PROFILES/all for the same “never start real canary traffic by accident” reason, and requires a one-time manual Keycloak user bootstrap before its first run.

Metering dual-write burn-in (F5.METERING.04)

Section titled “Metering dual-write burn-in (F5.METERING.04)”

internal/meteringsync + cmd/metering-consumer is a second, independent implementation of billing-adapter’s usage-metering sync-to-Lago responsibility, running read-only against backend-db through its own dedicated go_metering_reader credential. This is a shadow burn-in, not a cutover: billing-adapter remains the only system that actually marks a usage event synced in Strapi. Both producers expose Prometheus metrics under the same series names (distinguished by the job label) so they can be compared on one dashboard before any cutover decision is made. See Billing Adapter for the other side of this comparison.

internal/servicetoken — credential-model redesign (Option B)

Section titled “internal/servicetoken — credential-model redesign (Option B)”

A capability-scoped, DB-backed, rotating token authority (hashed at rest, plaintext revealed once at issuance/rotation) — the Go-native answer to Strapi’s admin-token-per-service-identity model. The first wave closes a real gap for git-connector’s bridge into this backend (a static shared secret previously granted blanket delete rights on three content-types that the original Strapi grant never gave it); a second wave does the equivalent for cloud-connector’s one real call into this backend (cloud-instance status callbacks). Both legs accept the pre-existing static secret and an issued token additively — no existing caller’s code had to change.

internal/useravatar — the first real media/uploads subsystem

Section titled “internal/useravatar — the first real media/uploads subsystem”

A narrow, avatar-only self-service upload/resize/serve subsystem (not a Go port of Strapi’s generic polymorphic files system) — local-disk storage matching what Strapi itself runs today, with three deliberately smaller breakpoints than Strapi’s own five. Notable for the operational gotchas it surfaces for anyone adding a second upload feature: a scratch-based container has no writable temp directory by default (multipart body-size limits must be tuned so the parser never spills to disk), and the router’s blanket request-body-size cap needed a narrow, route-specific exception rather than a global increase.

A few patterns recur across nearly every domain ported into this backend and are worth knowing before reading (or extending) any of it:

  • “DB write ≠ real action.” Before porting any action that looks like it triggers something destructive (a runbook execution, a chaos experiment, a fleet quarantine, a remote command dispatch), each wave traced the entire real chain end to end to determine whether it currently, actually works in the live system. Several genuinely do not (a missing WebSocket message handler, a wrong exchange type, a hardcoded dead port) — for those, this backend deliberately performs the same database write and audit event the real system would, but never attempts the actual dispatch, and discloses this honestly in both the audit event and the HTTP response as dispatch_deferred: true. This is not laziness — reviving a currently-broken destructive trigger without also adding a safeguard would make the newly-working endpoint the highest-blast-radius one in the service.
  • Cross-tenant IDOR review is standard, not exceptional. A very large number of the individual porting waves found and closed real, live cross-tenant access-control gaps in the original Strapi controllers along the way — bare-membership-only role floors hardened, 403-vs-404 existence oracles collapsed, mass-assignment on organisation fields closed. These are documented per-wave in sencai-backend/CLAUDE.md; do not assume Strapi’s existing behavior is the safe default to reproduce without checking.
  • TOCTOU races are closed with SELECT ... FOR UPDATE or a MySQL named lock (GET_LOCK), each backed by a genuine multi-goroutine concurrency test proving the fix under real contention, not just a sequential double-call check.
  • Secrets never round-trip through a generic response DTO. Every credential-bearing content-type (BYOC cloud credentials, SSH keys, service tokens, agent enrollment tokens) uses a response type that structurally cannot carry the secret field, rather than relying on convention to omit it.

Single-writer invariant (audit hash chain)

Section titled “Single-writer invariant (audit hash chain)”

The platform’s append-only, hash-chained audit log (audit_logs) must have exactly one writer, or two processes racing the chain head will corrupt it. Today that writer is the JS audit-consumer inside auth-service-consumer. A Go port of the same writer (audit-consumer, formerly audit-consumer-rs) exists, is CI-green, and is tested — but switching writers (disabling the JS consumer, replaying a backlog of audit.fallback events, enabling the Go one) is a deliberately separate, human-gated cutover step, tracked as AUDIT Part B. Nothing in the content-type porting work above touches this.

This project generally assumes no host Go toolchain — use the repository’s own scripts/go-docker.sh <go subcommand>, which runs go inside a golang:1.25-alpine container with cached module/build volumes and Docker network access to the dev stack. Tests that need live database reachability set EXTRA_NETWORK=sencai_db-go-backend (and sencai_db-backend where a test also needs to read Strapi’s own database, e.g. dbsync/parity-harness/meteringsync tests).

  • sencai-backend/CLAUDE.md — the full, continuously updated per-wave status log (large — this page is a distillation of it)
  • sencai-backend/CUTOVER-PLAN.md — the wave-by-wave content-type breakdown and the per-route cutover readiness table
  • PHASE-5.5.md (repository root) — the live planning document for what remains: AUDIT Part B, metering production cutover, remaining dbsync coverage, the servicetoken redesign’s remaining identities, parity-harness burn-in, and the eventual dual-RBAC/admin-panel replacement work