SRD-078 — The postgres Repository adapter, engine groups and tenant-ready storage¶
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-08-04 |
| Owner | Ruslan Gabitov |
| Implements | ADR-033 v.3 §2.7 (the storage-composition rule, the Migrator capability, the tenant-linkage principle), §2.8 (engine groups, CAS fencing, claim-first wake) |
| Upstream | ADR-002 v.2 §8.3 (optional capability interfaces), §8.4 (adapter conformance testing), ADR-003 v.1 §4.4 (adapter modules, no cross-module imports) |
| Related | SRD-070 (the record this adapter stores), SRD-071 v.2.5 (the wake claim path this group-scopes) |
| Tracking | #276 |
The first second implementation of the Repository port: a durable,
PostgreSQL-backed instance-checkpoint store in its own
adapters/postgres module — plus everything validating a port's
second implementation shakes loose: the engine-group partition on
the port (ADR-033 v.3 §2.8), the tenant linkage in the schema
(§2.7), the ClusterAware/Migrator capability interfaces that so
far existed only in ADR-002's prose, and the Repository conformance
suite that makes the next adapter cheap.
§1 Background (verified)¶
- The port today (
pkg/repository/repository.go:74):Save/Load/Delete/ListInFlightoverInstanceRecord{ID, Payload, Lease, RecVersion, Status}— no group, no tenant.Saveis CAS onRecVersion(mismatch →errs.ConcurrentUpdate,pkg/errs/errors.go:54);ListInFlightreturns non-terminal records with expired leases. - One implementation exists:
memrepo(pkg/repository/memrepo/memrepo.go) — in-memory, value copies, terminal-record eviction cap. Its known defect (auditdocs/audit/remediation-status.mdrow 11, deferred "until persistence lands"): a terminal→Active re-save leaves the id intermSet/termOrder(memrepo.go:87adds, nothing removes on a non-terminal re-save), soevictTerminalLockedcan delete an Active record. - The engine writes records in three places, all stamping lease
owner
t.id/inst.cpOwner: the loop checkpoint (internal/instance/checkpoint_capture.go:98), the recovery claim (pkg/thresher/recovery.go:53), the wake claim (pkg/thresher/wake.go:249). A claim is a CASSaveunder incarnation+1 — the port has no separate claim method, CAS is the fencing. - The engine has an id but no group:
thresher.New(id, …)(pkg/thresher/thresher.go:206); lease TTL viaWithLeaseTTL(pkg/thresher/options.go:387, default 30s). ClusterAwareand the conformance helper exist only on paper: ADR-002 §8.3/§8.4 sketch them;grep -rn ClusterCompatibility --include='*.go'is empty;pkg/renv's doc anticipates the hooks as "deferred" (engineruntime.go:24). TheMigratorconvention (ADR-033 §2.7) likewise has no code.- Adapter-module conventions (from
adapters/dtable,adapters/lua): owngo.modwithreplace github.com/dr-dobermann/gobpm => ../.., toolchaingo1.25.12;Makefileauto-discovers anygo.modintoCORE_MODULES(Makefile:77), so a new adapter module is tidy/lint/build/test/ cover/vuln-gated the day it appears.adapters/sqliteis a scaffold reservation (doc.go + go.mod only) — out of scope here, its own flow. - Docs to sync:
docs/guides/operating/persistence.md,docs/guides/extending/repository.md,CHANGELOG.md[Unreleased].
§2 Requirements¶
§2.1 Functional¶
- FR-1 — the port grows the partitions (
pkg/repository).InstanceRecordgainsGroup string(the creator engine's group, ADR-033 v.3 §2.8 — never empty: an ungrouped engine's group is its own engine id; adapters reject an empty group loud) andTenant string(the owning tenant's id, §2.7;""= the default tenant).ListInFlightbecomes group-scoped:ListInFlight(ctx, group string, now time.Time)— a non-emptygroup, only claimable records of that group return. Claimable is defined by exclusion (non-terminal AND not suspended AND lease-expired), never by enumerating in-flight statuses — the status vocabulary is append-only, and a new non-terminal member (the parallel incidents work, SRD-079 Draft, addsStatusActiveIncidents) must list without every store changing. The port also grows the group registry (ADR-033 v.3 §2.8):RegisterGroup(ctx, group)(idempotent establish) andGroupExists(ctx, group) (bool, error);SaveMUST reject a record whose group is not registered — records reference established groups only;ListInFlightover an unregistered group returns empty, not an error. - FR-2 — the engine carries its group, solo by default. Two
options:
thresher.WithEngineGroup(name string)— join, establishing the group if absent — andthresher.WithExistingEngineGroup(name string)— join only: atRun(before recovery) the engine callsGroupExists, and an absent group is a loud startup failure naming it (the typo-guard: a misspelled group must refuse, not silently mint a fresh partition). Both trim; empty after trim is rejected; setting both (or either twice) is rejected. Unset → the engine's group is its engine id (ADR-033 v.3 §2.8: clustering is explicit opt-in, never accidental), registered idempotently atRun; the engine id is thereby documented as a stable, operator-chosen identity. The group is stamped on every record the engine writes (checkpoint capture, recovery claim, wake claim) and passed toListInFlightat recovery. Every claim path verifies the loaded record's group equals the engine's — a cross-group record reached by id is refused loud (a wiring mistake, not a race). The engine stampsTenant: ""(the default tenant) until the Multi-tenancy ADR wires real assignment. - FR-3 — the capability interfaces (in
pkg/renv— the engine↔extension contract package, whose doc already anticipates the ADR-002 §8.3 hooks,pkg/renv/engineruntime.go:24; a unifiedpkg/extensionwas explicitly rejected by ADR-003 §3.1):ClusterAware(ClusterCompatibility() (bool, string)) andMigrator(Migrate(ctx context.Context) error— "prepare your own objects", ADR-033 §2.7). Adapters satisfy them structurally — no import needed to implement.thresher.Runwalks its wired repository before recovery: implementsMigrator→ call it, an error abortsRunloud (a half-created schema must never look like an empty store).memrepodeclares(false, "in-memory; state is not shared across nodes"). The runtime-level cluster-mode check stays with the Distribution & Scale ADR — this SRD lands the vocabulary and the declarations. - FR-4 — the
adapters/postgresmodule.postgres.New(db *sql.DB, opts ...Option) (*Repo, error)— the handle is user-owned (ADR-033 §2.7; the module importsdatabase/sqlonly, no driver). Options:WithSchema(name)(the namespaced schema, defaultgobpm),WithLogger.Repoimplements the full grown contract: CASSave(INSERT … ON CONFLICT/UPDATE … WHERE rec_version = $expected; zero rows affected →errs.ConcurrentUpdate),Load(copy semantics are free — rows are values),Delete(idempotent), group-scopedListInFlight(status = active AND engine_group = $1 AND (lease_owner = '' OR lease_expiry <= $2), ordered by id for determinism).RepodeclaresClusterCompatibility() (true, "shared PostgreSQL store; CAS+lease fencing per ADR-033 §2.8")and implementsMigrator. - FR-5 — schema & migrations. Embedded, versioned SQL migrations
(an
embed.FSofNNNN_*.sql+ aschema_versiontable in the adapter's schema; each file applies in a transaction, recorded by number; re-running is a no-op). Migration 0001 creates:tenants(tenant_id,engine_group,is_default bool,created_at; partial unique indexUNIQUE (engine_group) WHERE is_default— the database enforces "one default tenant per group") andinstances(idPK,engine_group,tenant_id+ FK totenants,status,payload bytea,rec_version bigint,lease_owner,lease_incarnation bigint,lease_expiry timestamptz,updated_at; index on(engine_group, status, lease_expiry)for the recovery listing; deliberately no CHECK constraint onstatus— the vocabulary is append-only and DDL must not reject a status a newer engine writes). Migration 0001 also createsgroups(group_namePK,created_at) — the FR-1 registry;instances.engine_groupandtenants.engine_groupreference it, so the FR-1 "records reference established groups only" rule is a database guarantee. The listing query follows the FR-1 exclusion rule (status NOT IN (terminal…, suspended)), neverstatus = active. A record arriving withTenant == ""resolves to its group's flag-designated default tenant row, created idempotently on first use (group membership is unknown at migration time). - FR-6 — the conformance suite (new package
pkg/repository/repositorytest— the<pkg>testsibling ADR-003 §4.2 prescribes for it; ADR-002 §8.4'sRepositoryConformancesketch, namedConformancehere since the package already says "repository"):Conformance(t *testing.T, factory func(t *testing.T) repository.Repository)runs the full contract as subtests — create/update CAS accept, stale-version reject (ConcurrentUpdateclassified), load-after-save fidelity (payload, status, lease, group, tenant), payload isolation (mutating a loaded/saved slice never changes the store), delete idempotency,ListInFlightfiltering (terminal excluded, suspended excluded, live-lease excluded, expired-lease included, group-scoped — two groups over one store never cross-list), an empty group rejected loud (onSave,ListInFlight,RegisterGroupandGroupExists), the group registry (RegisterGroupidempotent;GroupExistsbefore/after; aSaveinto an unregistered group rejected; an unregistered group lists empty), deterministic ordering.memrepoand postgres both pass it — one truth, two backends. - FR-7 — zombie-engine fencing (the ADR-033 §2.8 proof). Against
real postgres: engine A owns an instance; its lease lapses; engine B
claims (incarnation+1); A's next save — carrying the stale
RecVersion — is rejected with
ConcurrentUpdate. The lapsed owner can never overwrite the new owner's state. - FR-8 — kill-and-resume e2e. Against real postgres: thresher A
(group
g) runs a process to a timer park, checkpoints, is stopped (the "kill"); thresher B, same database, same group, boots → recovery lists/claims/restores → the timer fires at the recorded deadline → the instance completes; theRecoveredfact observed. A second run variant: an engine in grouphover the same database recovers nothing. - FR-9 — memrepo evict-Active fix (audit row 11 — "no
pre-existing errors"): a
Savethat transitions a tracked terminal id back to non-terminal removes it fromtermSet/termOrder; an Active record can never be evicted. The audit row flips to fixed in this branch. - FR-10 — test infrastructure. Postgres-backed tests read
GOBPM_PG_TEST_DSN; unset →t.Skipwith a pointer tomake pg-up.make pg-up/pg-downrun a disposablepostgres:17-alpinedocker container with a fixed port/password matching the DSN the README documents (a plain docker container by decision — no testcontainers dependency). CI: thecheckjob gains aservices: postgrescontainer and exports the DSN, so the conformance/fencing/e2e tests and the diff-coverage gate run the postgres paths on every PR.
§2.2 Non-functional¶
- NFR-1 — the adapter's runtime dependency set is
database/sql - stdlib only; the postgres driver (
jackc/pgx/v5/stdlib) and testify are test-only dependencies of the module. - NFR-2 — validate-all-params; no
Must*in library paths; loud classified errors (POSTGRES_REPOerror class) per the errs idiom. - NFR-3 — every SQL touch honors
ctx; no connection pooling logic of our own (the pool is*sql.DB's). - NFR-4 —
make cigreen (the new module ridesCORE_MODULESautomatically); diff-coverage ≥95% (aim 100%); touched functions ≥80%.
§3 Models¶
// pkg/repository — the grown record (FR-1)
type InstanceRecord struct {
ID string
Payload []byte
Lease Lease
RecVersion int64
Status Status
Group string // creator engine's group; never empty (solo engine → its id)
Tenant string // owning tenant; "" = the default tenant
}
type Repository interface {
Save(ctx context.Context, rec InstanceRecord) error
Load(ctx context.Context, id string) (InstanceRecord, bool, error)
Delete(ctx context.Context, id string) error
ListInFlight(ctx context.Context, group string, now time.Time) ([]string, error)
RegisterGroup(ctx context.Context, group string) error // idempotent establish
GroupExists(ctx context.Context, group string) (bool, error)
}
// pkg/renv — the capability vocabulary (FR-3)
type ClusterAware interface {
ClusterCompatibility() (compatible bool, reason string)
}
type Migrator interface {
Migrate(ctx context.Context) error
}
// adapters/postgres (FR-4)
func New(db *sql.DB, opts ...Option) (*Repo, error)
func WithSchema(name string) Option
func WithLogger(l observability.Logger) Option
-- migration 0001 (schema-qualified; default schema "gobpm")
CREATE TABLE groups (
group_name text PRIMARY KEY,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE tenants (
tenant_id text PRIMARY KEY,
engine_group text NOT NULL REFERENCES groups (group_name),
is_default boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX tenants_one_default_per_group
ON tenants (engine_group) WHERE is_default;
CREATE TABLE instances (
id text PRIMARY KEY,
engine_group text NOT NULL REFERENCES groups (group_name),
tenant_id text NOT NULL REFERENCES tenants (tenant_id),
status smallint NOT NULL,
payload bytea NOT NULL,
rec_version bigint NOT NULL,
lease_owner text NOT NULL DEFAULT '',
lease_incarnation bigint NOT NULL DEFAULT 0,
lease_expiry timestamptz,
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX instances_recovery
ON instances (engine_group, status, lease_expiry);
CREATE TABLE schema_version (
version integer PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT now()
);
Worked trace (T-8's shape): thresher A (New("engine-a",
WithEngineGroup("billing"), WithRepository(pgRepo))) starts; Run
detects Migrator → schema/tables exist; a process parks on a timer →
the checkpoint INSERTs instances row (engine_group='billing',
tenant_id=<billing's default>, rec_version=1); A stops; thresher B
(same group, same db) boots → Migrate no-ops → ListInFlight(ctx,
"billing", now) returns the id → CAS claim (rec_version 1→2,
lease_incarnation 0→1) → restore → the timer fires at the recorded
deadline → terminal checkpoint (status=Completed). An engine in
any other group over the same database lists nothing throughout — and
an ungrouped thresher C (New("engine-c", …), no WithEngineGroup)
runs in group engine-c, equally blind to billing's instances.
§4 Analysis & decisions¶
- §4.1 The group is a port parameter, not adapter config. Decided
at ADR level (v.3 §2.8): the group is engine identity, so the record
carries it and the listing filters by it. The rejected alternative —
a group-scoped adapter handle (
postgres.WithEngineGroup) — kept the port untouched but hid a partition the contract depends on inside one adapter's wiring; the second implementation exists precisely to surface such contract gaps before 1.0. - §4.2 No
Claimmethod. A claim stays a CASSaveunder incarnation+1 (the shipped SRD-070/071 discipline —recovery.go:53,wake.go:249). A dedicated method would be a second way to do the same thing and another surface every adapter must fence identically. - §4.3 Tenant resolution is storage-side. The engine stamps
""; the adapter maps""→ the group's flag-designated default-tenant row (idempotent ensure on first use — group membership is unknown at migration time). The flag, not a reserved id, designates the default (an operator-chosen id can never collide with it); the partial unique index makes "one default per group" a database guarantee, not a code promise. memrepo carriesTenantas an opaque field — a registry table in a process-lifetime store would be ceremony. - §4.4 Simple embedded migrations. A
schema_versiontable + an orderedembed.FSof SQL files, each applied in a transaction. The rejected alternative (golang-migrate/goose) buys down/redo/CLI tooling this module doesn't need at the price of a heavyweight dependency in a library adapter; two tables don't justify it.Migrateis idempotent and safe to run on every boot. - §4.5 DSN-gated postgres tests, docker not testcontainers (user
decision).
t.SkipwithoutGOBPM_PG_TEST_DSNkeeps a plain checkout'sgo test ./...green with no docker anywhere;make pg-up+ the CIservices:container provide the real database where it matters. The diff-coverage gate runs in CI with the DSN set, so the adapter's changed lines are measured, not skipped — locally,make cidocuments thepg-upprerequisite. - §4.6 The capabilities live in
pkg/renv. ADR-003 §3.1 explicitly rejected a unifiedpkg/extension("becomes a god-package … doesn't match Go-stdlib convention"), andpkg/renvis the engine↔extension contract package that already names the ADR-002 §8.3 hooks as its deferred residents (engineruntime.go:24). Two interfaces land now (Starter/Stopper/HealthCheckerarrive with consumers); adapters implement them structurally, without importingrenv.thresher.RuncallingMigratebefore recovery is the ADR-033 §2.7 "bootstrap walks the wired extensions" rule at its minimal useful size (one wired extension today). - §4.7 Solo by default — no implicit cluster. Three semantics for
an unset group were weighed: (A) all ungrouped engines share one
implicit "default" cluster — rejected: two unrelated applications
wired to one database would silently steal each other's instances,
with deployment parity by luck; (B) a mandatory group whenever a
repository is wired — rejected: pure ceremony for the primary
single-engine embedded audience; (C) chosen — an ungrouped
engine's group is its own engine id: restart recovery works with
zero configuration (the id is stable across restarts), while
cluster membership requires deliberately sharing a group name.
Documented corner: renaming an ungrouped engine strands its old
instances under the old group — production guides recommend an
explicit group. The group registry closes the remaining silent
failure: a misspelled group name would mint a fresh partition, so
membership in an existing group is assertable —
WithExistingEngineGrouprefuses atRunwhen the group is absent. Two options rather than amustExist boolparameter keep call sites self-documenting; "exists" is a registry fact, not an inference from records (an established-but-idle cluster must not read as absent). - §4.8 The e2e "kill" is a stop, not a SIGKILL. The property under test is that nothing in-memory is needed for recovery — a clean-stopped engine and a killed one leave the same durable state by construction (checkpoints are transition-synchronous). A process-level SIGKILL harness would test the OS, not the contract; the zombie test (FR-7) covers the crash-specific hazard (a survivor writing stale state).
§5 API deltas¶
| Surface | Change | Compat |
|---|---|---|
repository.InstanceRecord |
+ Group, Tenant |
additive fields; zero values = defaults |
repository.Repository.ListInFlight |
+ group param |
breaking for implementers/callers (one of each in-repo; the consumer-smoke gate proves embedder impact) |
repository.Repository |
+ RegisterGroup, GroupExists |
breaking for implementers (the group registry, FR-1) |
thresher |
+ WithEngineGroup(string), WithExistingEngineGroup(string) |
additive |
pkg/renv |
+ ClusterAware, Migrator capability interfaces |
additive |
pkg/repository/repositorytest |
new package: Conformance(t, factory) |
new |
adapters/postgres |
new module: New, WithSchema, WithLogger |
new |
memrepo |
group filter, tenant field, evict-Active fix, ClusterAware |
behavioral fix (FR-9) |
§6 Test scenarios¶
| # | Test | Verifies |
|---|---|---|
| T-1 | conformance vs memrepo (pkg/repository/repositorytest driven from memrepo) |
FR-1/FR-6: the full contract incl. group scoping and tenant round-trip on the in-memory reference |
| T-2 | conformance vs postgres (adapters/postgres, DSN-gated) |
FR-4/FR-6: the identical suite against the real schema |
| T-3 | zombie fencing (adapters/postgres, DSN-gated) |
FR-7: lapsed owner's save → ConcurrentUpdate; new owner's state intact |
| T-4 | migrations (adapters/postgres, DSN-gated) |
FR-5: fresh-db Migrate creates all objects; re-run no-ops; schema_version recorded; a second default tenant for one group is rejected by the index; default-tenant ensure is idempotent and group-local |
| T-5 | memrepo evict-Active regression (pkg/repository/memrepo) |
FR-9: terminal→Active re-save untracked; cap pressure never evicts Active |
| T-6 | engine group options (pkg/thresher) |
FR-2: WithEngineGroup/WithExistingEngineGroup validation/trim (empty or both-set rejected); unset → group = engine id (the solo default, §4.7), registered at Run; WithExistingEngineGroup fails Run loud when the group is absent, joins when present; records stamped; recovery lists own group only — a restarted same-id engine recovers, a differently-named neighbor sees nothing; a cross-group record refused loud on claim |
| T-7 | migrator hook (pkg/thresher) |
FR-3: Run calls a wired Migrator before recovery; its error aborts Run; a non-Migrator repository unaffected; memrepo's ClusterCompatibility declaration |
| T-8 | kill-and-resume e2e (adapters/postgres, DSN-gated) |
FR-8: the §3 worked trace — park/stop/recover/fire/complete over real postgres; the other-group engine recovers nothing |
§7 Milestones¶
- M1 — the port growth + the engine group. FR-1/FR-2/FR-9 (+ the
memrepo group/tenant support); conformance suite package (FR-6);
T-1/T-5/T-6. Includes the audit-row flip.
feat(repository): group- and tenant-carrying records, the conformance suite (SRD-078 M1). - M2 — the capability vocabulary. FR-3; T-7.
feat(renv): ClusterAware and Migrator capabilities (SRD-078 M2). - M3 — the adapter. FR-4/FR-5/FR-10 (module, schema, migrations,
CAS/lease SQL, docker targets, CI service); T-2/T-3/T-4.
feat(adapters/postgres): the durable Repository (SRD-078 M3). - M4 — the proof + docs. FR-7/FR-8; T-8 (and T-3 if not landed in
M3); guides (
operating/persistence.md,extending/repository.md), README feature note, CHANGELOG.feat(adapters/postgres): zombie fencing and kill-and-resume e2e (SRD-078 M4).
§8 Cross-doc¶
- Implements ADR-033 v.3 §2.7/§2.8 (the first durable adapter; the
group/tenant partitions on the record;
Migrator; the fencing proof). - Upstream: ADR-002 v.2 §8.3/§8.4 (capabilities, conformance), ADR-003 v.1 §4.4 (adapter-module layout).
- Related: SRD-070 (the checkpoint record and CAS discipline this stores), SRD-071 v.2.5 (the wake claim path FR-2 group-scopes).
- #276: closes all deliverables except
adapters/sqlite(explicitly a separate flow) and the durable DataStore adapter (ADR-030 v.1's deferred workstream — filed as a follow-up issue at the PR handover).
§9 Definition of Done¶
- [x] FR-1…FR-10 implemented; every §6 test exists and passes (the
DSN-gated ones against
make pg-up's container and in CI). - [x]
make cigreen incl. the new module; diff-coverage ≥95% (aim 100%); touched functions ≥80%. - [x] Audit row 11 flipped with the FR-9 fix referenced.
- [x] Guides/README/CHANGELOG synced; the follow-up DataStore issue filed at the PR handover.
- [x] §10 filled.
§10 Implementation summary¶
Landed in four milestones on feat/postgres-repository, each with the
§7-prescribed commit subject:
| Milestone | Commit | Scope |
|---|---|---|
| M1 | a9f0376 |
FR-1/FR-2/FR-9, the conformance suite (FR-6); T-1/T-5/T-6; audit row 11 flipped |
| M2 | e8b5490 |
FR-3 (pkg/renv/capabilities.go, the Run migrate hook, memrepo's declaration); T-7 |
| M3 | c0cfb81 |
FR-4/FR-5/FR-10 (adapters/postgres, migrations, pg-up/pg-down, the CI service); T-2/T-4 |
| M4 | bd7fcbb |
FR-7/FR-8 (T-3/T-8); guides, README, CHANGELOG |
Verified at bd7fcbb: make ci green end to end with
GOBPM_PG_TEST_DSN exported (conformance, migrations, fencing and
both e2e halves against postgres:17-alpine, -race included);
diff-coverage 100.0% of 618 changed coverable lines (min 95);
govulncheck clean; the /check-srd audit PASS (21 🟢 / 3 🟡 / 0 🔴).
Deviations from the §3 sketch, all deliberate:
tenantsis keyed per group — PK(engine_group, tenant_id), not a globaltenant_idPK — and gained a human-readablenamecolumn (review request during M3).instancesreferences the pair with a composite FK, which additionally guarantees a record's tenant belongs to the record's own engine group. Cosmetic deltas:status integer(notsmallint),lease_expiry timestamptz NOT NULL DEFAULT 'epoch',payloaddefaulted, the listing index namedinstances_recovery_listing.- FR-4's
status = activephrasing was a leftover: the landed listing follows the FR-1/FR-5 exclusion rule (status NOT IN (terminal…, suspended)), which is what those requirements mandate. - The diff-coverage gate learned
^\s*t\.Fatal(MakefileCOVER_EXCLUDE, documented in place): the conformance suite is shipped non-test-file library code whose failure diagnostics are structurally unreachable on a green run — the same grounds as the log-call exclusions. A pre-existing gofmt drift inpkg/model/process/process_test.gowas fixed by the style sweep (no-pre-existing-errors rule). - memrepo's record map holds pointers now — the grown record
crossed the linter's copy threshold;
Loadstill returns value copies.
Open questions¶
None — §4 records the resolved design points (group locus, claim shape, tenant resolution, migration tooling, test infrastructure, e2e kill semantics).