Migrations
Every table, column and index in a tenant’s database is derived from that tenant’s entity definitions by the backend dialect. Applying that DDL is a separate, explicit operator action: a definition change on disk changes nothing in any database until you run a migration verb.
The platform rule
Section titled “The platform rule”A service never hand-rolls SQL, a DSN, or a migration. The rule is structural, not a convention:
| Concern | Who owns it |
|---|---|
| DDL text | The backend dialect, from the loaded definition object: CREATE TABLE, CREATE INDEX, ALTER COLUMN, audit/history side tables, CREATE SCHEMA |
| Connection string | The host, built from the tenant’s pool config with the password resolved through Vault |
| Migration | The plan pipeline: generate, review, apply, ledger |
The planner is orchestration only; it never contains a backend-specific string, the only way author-written SQL enters a database is a declared init script or migration script, and both run through the same plan and ledger machinery as generated DDL.
The lifecycle
Section titled “The lifecycle”| Stage | What it does | Produces | Touches the database |
|---|---|---|---|
| Discover | Introspects the live database for the target namespace, excluding the engine’s own tables (data_, or <prefix>data_ on a datastore with a prefix) and, with a prefix, every table that does not carry it | The live schema: tables, columns, indexes, side-table names | Reads only |
| Compare | Diffs the composed definitions against the live schema in physical type space | Diff{Ops, Refusals, Warnings} | No |
| Plan | Lowers each op to SQL through the dialect and assigns 1-based step order | Plan{PlanID, Source, MaxRisk, Warnings, Refusals, Steps}, persisted as one plan row plus one step row per step, all pending | Writes ledger rows |
| Apply | Runs steps in order under a datastore lock, transitioning ledger rows as it goes | ApplyResult{PlanID, Status, StepsApplied, StepsFailed, StepsSkipped, FailedStep, HaltedStep} | Yes. The only stage that does |
| Verify | Validation steps inside the plan run a read query and compare column 1 of row 1 to the step’s expected value | Step pass or fail | Reads only |
Plans are deterministic on the declaration side. Entities sort by name, fields by declared position then name, indexes by name, and step order is op order, so the same definitions against the same live state produce the same additive plan, in the same order, every time. Two environments can be compared plan to plan.
Comparison happens in physical type space, not logical. Several logical types map to one physical
type, so a purely logical comparison would report a freshly created database as full of drift. The
dialect is threaded through the diff so a matched column is compared as “what would CREATE TABLE
emit for this field” against the live column’s raw type, and defaults are compared the same way.
Generate → apply → generate therefore converges on an empty plan.
Automatic versus explicit
Section titled “Automatic versus explicit”| Thing | Automatic | Explicit |
|---|---|---|
| Deriving DDL from definitions | ✅ | |
| Applying DDL | data bootstrap, or data plan generate + data plan apply | |
| Creating the PostgreSQL schema (namespace) | ✅ CREATE SCHEMA IF NOT EXISTS, issued by bootstrap before the plan | |
| Creating the physical database | External: IaC or an operator, never the engine | |
Datastore init.scripts | ✅ on the first engine build per process, per (tenant, datastore) | data init run forces a re-pass |
migrations[].scripts | Appended to the plan, applied by plan apply | |
| Seeds | data seed run | |
| Engine rebuild after a definition change | DELETE /cache, a tenant config refresh, or a restart | |
Foreign keys, CHECK constraints, enum types | Never emitted by the plan pipeline: declare them in an init script | |
| Dropping a table or an index | Never emitted at all; reported as a warning | |
| Dropping a column | Opt in at generate and confirm high risk at apply |
Nothing schema-shaped is ever applied at runtime. The engine build path runs datastore init scripts and nothing else; there is no migrate call anywhere on it. That is deliberate, a definition change reaching a running process must not silently reshape a production database.
Bootstrap: a fresh database
Section titled “Bootstrap: a fresh database”bootstrap is a separate verb because the plan that creates the upgrade ledger cannot be recorded
in that ledger before it runs. It unions the engine’s own system entities into a copy of the composed
schema, generates the plan unpersisted, and applies it under a short-lived advisory lock, neither the
ledger table nor the lock table exists yet. Once its steps have created them it records itself: the
tenant’s ledger starts with the upgrade that created the schema, one data_schema_upgrade row with
trigger bootstrap and status applied, and one data_schema_upgrade_step row per step. The enum tables the
plan created are then filled from their declarations: one seed plan, trigger bootstrap:enum-rows,
with a step per enum. The same rows are re-seeded after every
applied plan (trigger plan:enum-rows), and skipped while a declaration is unchanged.
data.svc -f data.yaml data bootstrap --tenant acme:prod:shop:eu1data.svc -f data.yaml data bootstrap --tenant acme:prod:shop:eu1 --datastore maindata.svc -f data.yaml data bootstrap --tenant acme:prod:shop:eu1 --scope productdata.svc -f data.yaml data bootstrap --tenant acme:prod:shop:eu1 --dry-runIt is idempotent: every generated statement is IF NOT EXISTS, and a re-run against a current
database diffs to zero steps, which is also how a tenant that predates an engine-owned table
gets it: re-run bootstrap and only the missing tables are created. After the first run, the
persisted generate → apply lifecycle takes over.
Seven engine-owned tables are created in the tenant’s own namespace alongside the entity tables:
| Table | Holds |
|---|---|
data_schema_upgrade | One row per upgrade: id, the service, product and datastore it belongs to, the versions it moved between, source, max risk, status, trigger and a meta JSON column for what the deployment records beside it |
data_schema_upgrade_step | The step-level ledger. One row per upgrade step or seed application, with status, timing, checksum and error |
data_locks | Coordination locks, one row per held resource, scoped by the service / product / datastore that holds it. Shared like the upgrade ledger and, like it, unprefixed |
data_archive_handoff | The retention queue between marking a row for archive and the archive writer confirming it |
data_materialized_refresh_tasks | The outbox for cross-entity materialized computed-field refreshes |
data_audit_events | The hash-chained audit stream, one row per event in chain order (seq), written on the mutation’s own transaction when there is one: see Observability and Access rules |
data_event_outbox | The at-least-once delivery queue for an entity’s declared events:. See Event delivery |
Entities carrying scope: shared or scope: superadmin are served by the scope’s own engine and
pool, so their DDL belongs to the deployment rather than to any one tenant. --scope shared and
--scope superadmin migrate those planes once per deployment into the scope’s reserved binding and
namespace; --tenant still names a tenant of the product whose schema declares them. Scoped
entities never appear in a tenant plan.
One ledger, every service
Section titled “One ledger, every service”data_schema_upgrade is not data.svc’s table. Every kis.ai service that migrates a database writes
into it, under that name, in every tenant schema and every installation — and services do share a
database: iam.svc and data.svc commonly live in one tenant schema.
What keeps their rows apart is the columns, not the table name:
| Column | What it answers |
|---|---|
service | Which service ran this upgrade |
product | Which product’s schema it carries |
tenant | Which tenant key it ran for |
datastore | Which datastore within the product |
version_from, version_to | Where it took that datastore |
meta | JSON for what the deployment wants recorded beside it: the binary’s version and commit today, a change ticket if you want one tomorrow |
Every read is scoped the same way. Asking what version a datastore is at gives this service’s answer, and an applied seed of one service never looks applied to another.
There is no separate version table: the newest applied row for a (service, product, datastore) is the version that datastore is at. A history and a version stored apart can disagree, and did, whenever a version row was written and the plan that wrote it was not.
The two tables also keep their names under a datastore
prefix, where every other
engine table takes one. A prefix there would give that datastore a private ledger, which is the
state the shared table exists to end.
Generate, show, apply
Section titled “Generate, show, apply”data.svc -f data.yaml data plan generate --tenant acme:prod:shop:eu1data.svc -f data.yaml data plan show <plan-id> --tenant acme:prod:shop:eu1 --output jsondata.svc -f data.yaml data plan apply <plan-id> --tenant acme:prod:shop:eu1 --confirm-risk mediumdata.svc -f data.yaml data applied list --tenant acme:prod:shop:eu1 --plan-id <plan-id>generate writes a plan row and one row per step, all pending, and executes no DDL. apply
rehydrates that plan from the ledger, so generate and apply may happen in different processes,
by different people, minutes or days apart. Rehydration does not restore warnings or refusals,
a plan only reaches pending when it has none.
Each step carries the exact statement it will run. Read it before applying; --output json prints
the full SQL of every step, which the table renderer truncates.
The change vocabulary
Section titled “The change vocabulary”Every op the diff can emit, what triggers it, the steps it lowers to, and its risk tier:
| Op | Emitted when | Steps | Risk |
|---|---|---|---|
add_table | An entity has no live table | CREATE TABLE, plus one CREATE INDEX per declared index | low |
add_column | A field has no live column and is nullable: true or not required: | ALTER TABLE … ADD COLUMN | low |
add_column | The field is required: and not nullable:, with a default | ALTER TABLE … ADD COLUMN | medium |
add_index | A declared index is missing | CREATE INDEX | low |
add_audit_table / add_audit_index | History strategy typeaudit, <entity>_audit missing | One create each | low |
add_history_table / add_history_index | History strategy type4, <entity>_history missing | One create each | low |
change_column_default | The default drifted from what CREATE TABLE would emit | ALTER COLUMN … SET/DROP DEFAULT | low |
change_column_nullable | Live NOT NULL → declared nullable | DROP NOT NULL | low |
change_column_nullable | Live nullable → declared NOT NULL | A NULL-count validation expecting 0, then SET NOT NULL | medium |
change_column_type | Physical types differ and the change is on the compatible list | Dialect-emitted ALTER COLUMN | medium |
rename_column | Auto-detected 1:1 shape match | ALTER TABLE … RENAME COLUMN | medium |
drop_column | A live column has no field and drops are opted in | An informational row count, then DROP COLUMN | high |
Indexes over fields marked no-index or encrypted are skipped when the table is created, a plaintext index on ciphertext leaks ordering and equality without being useful.
Risk and the apply gate
Section titled “Risk and the apply gate”Risk is low, medium or high. Plan.MaxRisk is the maximum across the plan’s ops, and apply
compares it to what the caller confirmed:
| Confirmed | Applies |
|---|---|
low (the default) | Table, column and index additions, side tables, default changes, nullability loosening |
medium | Also type widening, nullability tightening, renames, NOT NULL columns with a default |
high | Also column drops |
A plan whose risk exceeds the confirmed level is refused outright and no step runs: the run
fails before the lock is even taken. The confirmed value is the operator’s acknowledgement, which is
why it is a parameter and not a config setting: --confirm-risk on the CLI, confirm_max_risk on
the HTTP route.
migrations[].risk in a definition is parsed and stored but never consulted; the gate uses the
planner-computed maximum.
Refusals
Section titled “Refusals”A refusal is the diff declining to guess. Three kinds exist:
| Kind | Raised when | Fix |
|---|---|---|
data_loss | A live column has no matching field and drops are not opted in | Restore the field, or re-generate with drops allowed and confirm high risk |
needs_explicit_migration | An incompatible type change, or a required: non-nullable field added with no default | Declare an expand-contract migration script, or give the field a default |
ambiguous_change | Rename detection matched more than one candidate | Declare the rename explicitly in a migration script |
Only these type changes apply inline. Everything else, including every narrowing, is refused:
| From | To |
|---|---|
string | text |
int | bigint |
float | decimal |
date | datetime |
Rename detection only fires when an entity has both unmatched fields and unmatched live columns, and only pairs them on an exact shape match: same canonical type, same DDL-level nullability, same default presence (not value), same uniqueness. More than one candidate refuses rather than moving data between columns.
What the diff never touches
Section titled “What the diff never touches”| Thing | Behaviour |
|---|---|
| Foreign keys | Never emitted. references: drives joins and eager loading in the compiler; a database provisioned by bootstrap or plan apply has no declared FK constraints |
CHECK constraints | Discovered, never compared |
| Enum types | No CREATE TYPE is emitted by any migration path. A field mapping to a database enum needs an init script |
| Unknown live tables and indexes | Reported as warnings. There is no drop-table or drop-index op; warnings never block apply |
| Audit and history side tables | Created in one shot when missing, then left alone. They are never column-diffed. Their shape is engine-owned |
| System tables | Filtered out of the live scan by the data_ prefix (<prefix>data_ on a prefixed datastore), and injected into the definition set only during bootstrap |
| Another product’s tables | With a datastore prefix, a live table that does not carry it is not this datastore’s: never drift, never a drop candidate |
Entities whose materialization is not table | Skipped entirely. No ops, no warnings. Check this first when a table is unexpectedly missing |
| Purely virtual computed fields | Excluded from the column diff. Array fields and materialized computed fields are always treated as nullable whatever nullable: says. The dialects skip NOT NULL on both |
Steps and execution
Section titled “Steps and execution”| Step kind | Semantics |
|---|---|
ddl | One statement, executed directly. No outer transaction |
dml | Same, for data statements |
validation | Runs a read query and compares the first column of the first row to the expected value: 0, any integer, any, or informational (always passes) |
raw_script | An author-supplied body, split at top-level ;. Runs inside one transaction when the step carries transactional, otherwise each statement runs bare: see the caution below for which paths carry it |
manual_gate | Halts the run for operator confirmation. Honoured by the executor, but no planner path emits one |
seed | Skipped by the executor; seed rows are written by the seed runner |
Statement splitting understands single quotes with '' escapes, double-quoted identifiers, line and
block comments, and dollar-quoting ($$, $tag$). Block comments do not nest. Anything more exotic
belongs in one statement per file.
Failure and partial application
Section titled “Failure and partial application”Steps run in order and the run halts on the first failure. That step flips to failed, the plan
flips to failed, every later step stays pending, and the error comes back with a partial result.
Steps already applied stay applied.
| Plan status | Meaning |
|---|---|
pending | Generated, not yet run |
running | Apply in progress |
applied | Every step succeeded |
failed | A step failed; earlier steps are applied |
halted | Stopped at a manual gate |
| Step status | Meaning |
|---|---|
pending | Not reached |
running | In flight |
applied | Succeeded |
failed | Raised an error, or a validation mismatched |
skipped | Fast-forwarded, or suppressed by a dry run |
There is no rollback. The executor deliberately runs each step without an outer transaction, because
wrapping the plan would roll back steps the ledger has already recorded as applied. Recovery is
fix-and-re-run: generated DDL is IF NOT EXISTS, and the ledger records exactly what ran.
migrations[].down is parsed and stored, and never executed by anything.
--dry-run on apply runs validation steps and records every DDL, DML and raw-script step as
skipped with a dry-run note. It takes no lock.
Ordering and coordination
Section titled “Ordering and coordination”A migration verb targets exactly one (tenant, datastore) pair. There is no fleet sweep, a deployment iterates tenants itself. Per tenant, the order is fixed:
- Provision the physical database and grant the pool’s role rights on it. External.
data bootstrapfor that tenant’s datastore, once. It creates the namespace, the entity tables and the ledger.data plan generate+data plan applyfor every later definition change.- The first request for that (tenant, datastore) pair builds the engine and runs the datastore init scripts once.
Scope planes (shared, superadmin) are bootstrapped once for the deployment, not per tenant.
One coarse lock guards each (tenant, datastore), keyed tenant:<key>:datastore:<name>, held as a
row in data_locks in the database being changed:
| Property | Value |
|---|---|
| Scope | One lock per (tenant, datastore) per service: migration, init scripts, seeds and retention of one service all contend for it. Another service in the same schema holds its own, separated by the service, product and datastore columns rather than by the key — two services commonly name their datastore the same way |
| Default lease | 5 minutes, extended by a heartbeat at one third of the lease when the lease exceeds 30s |
| Crash behaviour | The lease expires and the next caller steals the row |
| Recorded | Holder, host, operation kind, plan id, notes, acquired and expiry timestamps |
| Not taken by | Dry runs, and the bootstrap apply (the table does not exist yet) |
Operation kind is recorded for forensics only. It is not part of the key, so retention cannot run while a migration runs on the same datastore. The init runner treats a held lock as benign: it returns with zero scripts applied and moves on.
The same table also carries the scheduler’s leases — one per job per tenant, keyed
scheduler:<job>:<tenant> — so background delivery, materialized refresh, retention and audit
verification run on one instance at a time. That is why a missing or misaddressed data_locks
stops far more than a migration: the leases fail, and a lease that fails is treated as
not-acquired, so those jobs are skipped for as long as the condition lasts.
Releasing a stuck lock
Section titled “Releasing a stuck lock”DELETE /admin/data/locks/:resource releases an expired lock and answers 200 with
was_live: false. A live lock, one whose holder is still heartbeating, is refused with 409
and the holder named:
{ "resource": "tenant:acme:prod:erp:main:datastore:main", "released": false, "held_by": "data-7f4c", "op_kind": "migrate", "expires_at": "2026-09-17T09:12:44Z", "seconds_to_expiry": 132, "error": "the lock is live: its holder is still heartbeating. Wait for it to expire, or repeat with ?force=true to evict the holder" }Repeating with ?force=true evicts the holder and answers 200 with was_live: true; the evicted
holder fails its next heartbeat. A resource nothing holds answers 404, where it used to report a
successful release of a lock that never existed. Releasing a live lock lets two migrations race on
the same datastore, which is why it takes a second, explicit request and why the deployable service
gates lock release on the superadmin role: see IAM.
The namespace must be set in three places
Section titled “The namespace must be set in three places”Ledger, lock and plan SQL run over the raw database handle, which bypasses the per-checkout
search_path a shared-endpoint pool relies on, the tenant’s physical schema, the pool’s schema:
property, falling back to the composed schema’s name, has to reach all three of: the dialect (so
DDL is emitted qualified), the discovery inputs (so introspection reads the right namespace), and
the ledger and lock manager (so their own rows land in the right namespace). Setting only some of
them is the classic phantom-diff and wrong-namespace-ledger failure. See
Datastores and database pools for how the value is
resolved.
Raw SQL inside a plan
Section titled “Raw SQL inside a plan”migrations[].scripts[] attaches author-written SQL to a version label. The scripts become
raw_script steps appended after the generated DDL, in migrations[] order and then declaration
order. To run SQL before the generated DDL, split the work across two migration entries.
migrations: - name: add-order-audit version: "2026.07.01" description: "trigger for the orders audit log" scripts: - path: sql/triggers/orders_audit.sql kind: ddl - path: sql/indexes/orders_concurrent.sql kind: ddl transactional: false| Key | Type | Default | Notes |
|---|---|---|---|
name | string | — | Migration name |
version | string | — | Version label. Becomes the entity column of every ledger row for these scripts |
description | string | — | Copied to each emitted step’s notes in the generate output. The step ledger has no notes column, so plan show does not replay it |
scripts[].path | string | — | Required. Resolved at load time against the definition assets; a missing file is a hard load error |
scripts[].description | string | — | Operator-facing context |
scripts[].kind | string | ddl | ddl or dml. Anything else is a load error |
scripts[].transactional | bool | true | Parsed and carried onto the step, but dropped when plan apply rehydrates from the ledger: see the caution under Steps and execution. Honoured on the datastore init path |
up, down, risk | — | — | Parsed and stored; it has no effect today |
Script bodies are rendered as templates before execution. {{tenant}} is the tenant key and
{{schema}} is the physical namespace to qualify objects with; conditionals and filters work, not
just variable substitution.
Datastore-level init.scripts[] use the same object shape but run on the first engine build rather
than inside a plan, and are documented with the datastore surface, see
Init scripts. Their plan row is recorded
with a risk of high, but nothing enforces that: the init runner applies its steps with the default
confirmation, so the label is a record, not a gate.
Drift detection
Section titled “Drift detection”Drift is the read-only question “did someone ALTER out of band, or did a migration fail
silently?”, the engine handle exposes Drift(ctx, DriftOptions{…}), which discovers the live
database, compares it to the composed definitions, and returns the diff without executing anything.
| Option | Default | Effect |
|---|---|---|
SystemTablePrefixes | data_, _pg_, _information_, pg_, information_ | Tables excluded from the live scan; data_ becomes <prefix>data_ on a prefixed datastore |
TablePrefix | the datastore’s prefix | Discovery keeps only the live tables that carry it |
IncludeAuditTables | false | Whether _audit / _history side tables take part |
AllowDrops | false | Whether unmatched live columns surface as ops instead of refusals |
An empty diff means the live schema matches the definitions, a backend whose provider exposes no discovery implementation returns an error instead; all in-tree backends implement it.
data.svc has no drift verb. The operator equivalent is data plan generate, which performs the
same discover-and-compare and executes no DDL. It differs only in that it persists a plan row you
can then choose to apply or ignore.
After a definition change
Section titled “After a definition change”Two independent things have to happen, in this order, and neither implies the other.
- Migrate the database:
data plan generatethendata plan apply(ordata bootstrapon a fresh tenant). Skip this and queries against the new shape fail with “relation does not exist” or “column does not exist”. - Rebuild the engine: the process caches a compiled schema, pool and hook chain per (tenant, datastore). Until it rebuilds, writes still use the old shape: a create succeeds and silently omits the new column. Restart the process, refresh the tenant config, or clear the cache in place.
curl -X DELETE http://127.0.0.1:8099/cache \ -H 'Authorization: Bearer <token>' \ -H 'X-Customer: acme' -H 'X-Product: shop' -H 'X-Env: prod' -H 'X-Tenant: eu1'Clearing the cache drops every cached schema, pool, engine and action registry for the tenant and the next request rebuilds from source. It rebuilds the engine; it does not touch the database.
CLI reference
Section titled “CLI reference”Every verb boots the same configuration file as the server, resolves the tenant, and exits. It never opens the listener.
| Command | Flags | Does |
|---|---|---|
data bootstrap | --dry-run, --scope shared|superadmin | Create the namespace, entity tables, indexes and ledger on a fresh database |
data plan generate | --allow-drops | Discover, diff, plan, persist. No DDL |
data plan show <plan-id> | — | Render a persisted plan and its steps |
data plan apply <plan-id> | --confirm-risk low|medium|high, --dry-run | Execute the plan under the datastore lock |
data applied list | --plan-id | Read the step-level ledger |
data init list | — | Declared init scripts and their applied state |
data init run | — | Force an init-script pass now; ledger idempotency still applies |
data seed run | --name, --env, --dry-run, --force, --triggered-by | Apply declared seeds through the engine’s own write path |
data seed list | — | Declared seeds and their applied state |
data locks list | --include-expired | Show held coordination locks |
data locks release <resource> | — | Force-release a stuck lock |
--tenant (required), --datastore (empty selects the schema default) and --output table|json are
shared by every verb. data plan generate --source is accepted and ignored. The source is
always the definitions.
HTTP reference
Section titled “HTTP reference”The admin surface mounts at /admin/data/* and the cross-tenant surface at /superadmin/*; behind
the gateway both sit under /data/. Every route needs a bearer token and the four CEPT headers,
see the Data API reference for the request conventions.
| Route | Body | Gate |
|---|---|---|
POST /admin/data/bootstrap | {"datastore": "", "dry_run": false}. Both optional | Apply |
POST /admin/data/plans | {"datastore": "", "allow_drops": false}. Both optional | Generate |
GET /admin/data/plans/:id | ?datastore= | Read |
POST /admin/data/plans/:id/apply | {"datastore": "", "confirm_max_risk": "low", "dry_run": false} | Apply |
GET /admin/data/applied | ?plan_id= required, ?datastore= optional | Read |
GET /admin/data/upgrades | ?datastore=, ?limit= (default 20) | Read |
POST /admin/data/seeds/runs | {"datastore","names","environment","dry_run","force"}: runs synchronously | Seed |
GET /admin/data/seeds | — | Read |
GET /admin/data/locks | ?datastore=, ?prefix=, ?op_kind=, ?include_expired= | Read |
DELETE /admin/data/locks/:resource | ?force=true to evict a live holder; without it a live lock answers 409 and an absent one 404 | Locks |
DELETE /cache | — | Token only |
Bootstrap is the HTTP form of the data bootstrap verb above, and exists for the same reason: the
plan that creates the ledger cannot be written to the ledger first. It generates the diff-based plan
without persisting it and applies it immediately, so its first steps create the tables the ordinary
generate-and-apply lifecycle then uses. It is gated on Apply rather than Generate, because it
executes DDL.
It returns {plan_id, max_risk, steps, refusals?, status, steps_applied, steps_failed, steps_skipped, failed_step?, error?}, with 200 on success and 207 Multi-Status when some steps
applied before one failed. dry_run returns the plan with status: "planned" and executes nothing,
which is the safe way to ask what a fresh deployment would run.
steps_applied: 0 on an already-migrated database is the success case, and the one to look for:
discovery and the composed schema agree. A non-zero count on a database nobody changed means they
do not.
GET /admin/data/upgrades answers where this service has brought the datastore and what it did to
get there: {tenant, service, product, datastore, version, upgrades[]}, newest first. version is
the version of the newest applied upgrade, and the list is scoped to this service — another service
migrating the same database answers for itself on its own endpoint.
Generate returns {plan_id, source, max_risk, warnings?, refusals?, steps[]}. Apply returns
200 when the status is applied and 207 Multi-Status otherwise, including when a partial result
accompanies an error, with {plan_id, status, steps_applied, steps_failed, steps_skipped, failed_step?, halted_step?, error?}. An invalid confirm_max_risk is a 400. Applying an unknown plan id is a 404; applying a plan
that was refused at generation time is a 409. The id resolves, but the plan can never be
applied, so regenerate it. Errors on this surface are the same errors[] array as the data plane,
always with the constant code admin.
Request field names are snake_case. A camelCase key is not read: it silently leaves drops disallowed
and the confirmed risk at low.
The superadmin mirror repeats the plan, applied, seed and lock routes under
/superadmin/tenant/:tenant/…, plus GET /superadmin/tenant to list the tenants this process holds
resources for and DELETE /superadmin/tenant/:tenant/cache. :tenant is the bare tenant name,
customer, product and environment come from the caller’s own tenancy context, and a value containing
the key separator is a 400. A control plane governs exactly its siblings; a cross-product target is
unrepresentable.
Each operation class carries its own gate. Read, generate, apply, seed, retention, locks,
maintenance, so whether a tenant admin may execute DDL is a per-deployment decision. Any gate left
unset resolves to “admin”, so a partial policy is never accidentally open, and /superadmin/* is
superadmin-only regardless of the policy, the deployable service ships with generate, read and seed
at admin, and apply, retention and lock release raised to superadmin.
Programmatic entry points
Section titled “Programmatic entry points”A host service reaches the pipeline through an admin provider it registers at boot; handler packages never import a host’s boot package. An unregistered host fails with a no-provider-registered error naming the missing boot call, rather than a nil dereference.
| Entry point | Purpose |
|---|---|
migrate.Generate(ctx, GenerateInputs, GenerateOptions) | The whole discover → compare → plan → persist sequence |
migrate.Compare(definitions, live, DiffOptions) | Diff only. DiffOptions.Dialect is required for physical-type comparison |
migrate.PlanFromDiff(diff, PlanOptions) | Lower a diff to SQL steps |
migrate.RehydratePlan(planRecord, stepRecords) | Rebuild an in-memory plan from ledger rows |
migrate.NewExecutor(db, ledger).Apply(ctx, plan, ApplyOptions) | Execute, with MaxRiskConfirmed, DryRun, HaltOnManualGate |
migrate.NewSQLLedger(db).InSchema(ns) | The persisted plan and step ledger |
migrate.NewSQLLockManager(db).InSchema(ns) + migrate.ResourceKey(tenant, datastore) | Coordination locks and their canonical key |
migrate.EnsureNamespace(ctx, db, dialect) | CREATE SCHEMA IF NOT EXISTS, from the dialect. A no-op on schema-less backends |
migrate.SystemEntities() | The six engine-owned entities, in emission order: bootstrap only |
migrate.NewInitRunner(datastore, executor, ledger).Run(ctx) | The init-script pass |
migrate.NewSeeder(schema, writer, ledger).Run(ctx, opts) | Seeds, written through the engine’s hook chain |
migrate.RawScriptBindingsWithSchema(tenant, schema) | The {{tenant}} / {{schema}} bindings for raw scripts |
Drift(ctx, DriftOptions{…}) on the engine handle | Read-only drift comparison |
Migrate(ctx) / MigrateDetailed(ctx, onError) on the engine handle | The zero-config sweep described below |
GenerateInputs carries the definitions, the discovery implementation, the dialect, the database
handle, the ledger, the namespace and the raw-script bindings. GenerateOptions carries the source
label, AllowDrops, the trigger labels and Persist.
The zero-config sweep
Section titled “The zero-config sweep”The engine handle also exposes a blind, non-introspecting migrate: it emits CREATE … IF NOT EXISTS
for every entity and reports per-object failures, continuing past each so one pass shows every
problem. Emission order is the entity map’s iteration order, so it is not stable between runs. It
has no plan, no review, no ledger and no rollback, and it is the only path that emits foreign-key
constraints. Nothing in data.svc calls it. It exists for tests and embedded single-tenant use.
Production DDL goes through the plan pipeline.
Search and vector columns
Section titled “Search and vector columns”A search: block compiles to a stored, generated tsvector column plus a GIN index; a vector
field compiles to a pgvector column plus its ANN index. Both migrate through the ordinary plan:
adding either to a table that already holds rows adds the column (and, for the tsvector, computes
it for every existing row) and creates the index.
Two changes are refused rather than altered in place, and the refusal names what to do instead:
| Change | Refusal |
|---|---|
A vector’s dim | Every stored embedding was produced at the old dimension. New column, backfill from re-embedded values, dual read, cut over, drop |
An entity’s search.document | A generated column’s expression cannot be altered. The plan warns and leaves the column; drop and re-add it in an explicit migration |
The vector and pg_trgm extensions are a prerequisite the migration does not create — see
Search and vectors.