MagWorksManaged Intelligence

Magpie

Tenancy, records and the transactional substrate

Packages: @magpie/tenancy, @magpie/records, @magpie/unit-of-work, @magpie/records-migrate, @magpie/server-bootstrap, @magpie/in-memory-bootstrap, @magpie/installed-modules, @magpie/aws-cdk.

In plain language

Every piece of data on the platform belongs to exactly one tenant — one client — and the storage layer is built so that reaching across that line is structurally impossible, not merely forbidden. Live state is kept in a deliberately small typed row store that writes atomically and never overwrites silently. When the shape of stored data has to change, an operator runs an explicit, forward-only migration that is recorded in the ledger and that cannot touch the audit history.

The mechanism

Tenant isolation is a storage property. Every domain entity carries tenantId; every query applies a tenant filter; cross-tenant operations require the platform-admin role and write a ledger event; isolation is enforced in the data-access layer, not only in resolvers; and the tenant-isolation test suite blocks deploys, not just merges. The tenancy package also provides identity-provider-driven tenancy resolution and caller construction (the caller is the audit identity, I-27).

Records is a typed row store, not an ORM (ADR-0017). A record is (tenantId, recordType, id) mapping to a frozen nine-field row: id, type, tenantId, data, version, createdAt, updatedAt, createdBy, updatedBy. data is validated against a registered schema. Every update requires expectedVersion — there is no last-write-wins path. Every read resolves to one partition keyed by tenant and record type, so cross-tenant access is structurally impossible at this layer. The query surface is deliberately tiny — primitive-equality filters, a cursor and a limit. No joins, no relationship tracking, no auto-migration, no search index, no event sourcing.

Writes are transactional across packages. Inside a unit of work, record writes are collected and committed in a single DynamoDB TransactWriteItems call; the records adapter never issues a bare put, update or delete. Atomicity is a structural property. Successful commits emit metadata-only lifecycle events to internal subscribers (reconciliation, projection rebuilders) — application code does not subscribe.

Schema evolution is an operator act (ADR-0033). Each row carries a schema version; a read against a mismatched version fails loudly. The migration runner is records-native, explicitly registered, per-tenant, per-record-type, deterministic, idempotent, transactional, resumable, dry-run by default, and forward-only: recovery from a bad migration is a forward fix, never an automatic down-migration. Every application appends a RecordMigration ledger entry.

The runner cannot touch truth, by construction. A migration's target is typed as a registered records type; a ledger entity name or a workflow store cannot type-check as a target. The runner is constructed with only the records adapter, the registry and an append-only lineage writer — it is given no ledger-mutation handle and no run-store handle, so it has nothing with which to migrate history. There is no force, raw or "advanced mode" parameter.

Composition is declared. Every module publishes a manifest (what it publishes, subscribes to, owns in the ledger, contributes as tools, and its record types); cross-manifest validation catches collisions. An AWS-free in-memory bootstrap assembles the whole runtime for tests; the server bootstrap assembles it against DynamoDB. CDK constructs provision the tables, a shared KMS key and least-privilege IAM policies at synthesis time.

Design notes

  • Why a typed row store and not an ORM (ADR-0017). The dominant failure mode after a primitive ships is ORM creep: incremental, consumer-friendly extensions that accumulate until the transactional, tenancy and identity guarantees are watered down by features the doctrine never sanctioned. The ADR freezes the shape and enumerates the forbidden expansions so that a future convenience needs an amendment, not a preference.
  • Why forward-only, operator-invoked migrations (ADR-0033). Replay and ledger truth are immutable and derived state (vectors) is re-derivable, so only live records ever need to evolve. That collapses "migrate without breaking replay" into a clean boundary — the runner never touches truth — and keeps replay simple, which the ADR calls constitutional: "if a migration design pressures replay complexity upward, the design is wrong."
  • Why tenant is in the partition key. One tenant's burst cannot throttle another's reads, and isolation is enforced by storage rather than by caller discipline.

Sources

  • magpie/docs/architecture-constitution.md — INV-TEN-01…05, INV-DAT-01…03.
  • magpie/docs/adr/0017-records-is-not-an-orm.md — INV-REC-01…07.
  • magpie/docs/adr/0033-records-migration-runner.md — INV-MIG-01…12.
  • magpie/packages/records, magpie/packages/unit-of-work, magpie/packages/records-migrate, magpie/packages/tenancy.