Primitive spec

Pirenne

Tenant-as-code. Pirenne turns a tenant’s live configuration into a single portable document — a bundle — and turns a bundle back into a tenant. “What is this business?” and “make a business like this one” become the same artefact.

Status: Live on production since September 3, 2026 — export, plan/diff, transactional apply, review-gated history with rollback, four starter verticals, and scenario runs. A tenant's Arkady rates and limits export with the bundle.

What it owns

An agent that configures a business by choreographing fifty sequenced write calls is unreviewable, unrepeatable and unsafe: a human has to supervise every call, a failure halfway leaves an undescribed half-built tenant, and nothing can be diffed or rolled back. A bundle inverts that. The agent emits one document; the platform turns it into a plan a human approves once, and applies it transactionally.

Pirenne owns the bundle format, the export that produces one, the diff that compares one against a live tenant, the apply that executes it, and the history that lets you go back. It owns no business rules — every section writes through the owning primitive’s normal surface.

Concepts

TenantBundle
Nineteen sections in dependency order — Daneel actions, Radiant repositories and assets, Trantor resource types and resources, the Hardin catalog with pricing and tax, Terminus groups and tiers, Seldon schemes, offers and access rules, Speaker routing, Magnifico audiences and promos. Emitted as canonical YAML with a digest.
Handles, not ids
Every row carries a handle slugged from its natural key, and every reference names a handle, so a bundle applies unchanged to a different tenant. Ids buried in payloads — a booking-calendar block naming its offer — are rewritten to @ref: sentinels, and a final sweep reports any raw id that survived, so an unportable bundle says so instead of looking clean.
Config only, by construction
The catalogue is an allowlist: a column is in a bundle because someone named it. No people, no memberships, no bookings, orders or invoices, no credentials. A redaction pass would have the opposite failure mode — an allowlist’s worst case is a missing field, not a leaked one.
Plan and pin
tenant.plan_bundle produces the minimal diff of bundle-versus-live: creates, field-level updates, deletes, with hard-to-take-back changes flagged and explained. It is a read — nothing is written. Pinning stores it and returns a planId, and apply executes that plan exactly as shown or refuses.
History and rollback
Every apply lands in a history: digest, counts, errors, who did it, the plan it came through, and whether it can be restored. Rollback re-applies an earlier document.
Starter bundles
Four working businesses ship as ordinary bundles — racquet club, gym, salon, studio. Each is shaped differently on purpose because the verticals are: the racquet club books resources by the hour, the gym runs a timetable needing a room and a trainer, the salon has two appointment lengths where the long one takes a deposit, and the studio is capacity-shaped rather than resource-shaped. Applying one walks the same sections and rolls back the same way; nothing about a starter is special once applied.
Scenarios
Each starter ships its own scenario as its acceptance test. A scenario runs against the real configuration with every side effect neutralised and returns a structured verdict — so an agent can prove the business it just built actually works: courts to book, an offer to book them under, a customer record to book them for.

API surface

The reads are scoped to tenant configuration and gated to admin views — a location-scoped operator has no business exporting the tenant’s whole configuration. Every write above export is tier 2, which on the agent surface means an agent can only ask.

RoutePurpose
GET /pirenne/v1/tenant/describeCounts per section plus a shallow list of offers, schemes, resource types and products, each with its handle. The orientation read an agent makes first.
GET /pirenne/v1/tenant/exportThe full bundle as canonical YAML plus its metadata block. Optional ?sections= filter.
POST /pirenne/v1/tenant/planThe bundle-versus-live diff. A read; pin: true stores it and returns a planId.
POST /pirenne/v1/tenant/plans/{planId}/applyExecute a pinned plan exactly as shown, or refuse.
POST /pirenne/v1/tenant/applyCreate or update everything a bundle names. dryRun supported.
GET /pirenne/v1/tenant/historyWhat has been applied, newest first, and whether it can be restored.
POST /pirenne/v1/tenant/history/{id}/rollbackRe-apply an earlier document.
GET /pirenne/v1/tenant/startersThe four shipped verticals and the variables each needs.
POST /pirenne/v1/tenant/starters/{key}/applySet the tenant up from a starter.
POST /pirenne/v1/tenant/scenarioRun a scenario against the real configuration with side effects neutralised; returns a structured verdict.

How it fits with the rest

flowchart TB
  Live[Live tenant] --> Exp[tenant.export]
  Exp --> B[TenantBundle
YAML + digest] B --> Plan[tenant.plan_bundle
diff, no writes] Plan --> Human{Human approves} Human --> Apply[tenant.apply_plan] Apply --> Prims[Seldon / Trantor / Hardin
Terminus / Radiant / Daneel] Apply --> Hist[History + rollback] Start[Starter bundle] --> Plan Apply --> Scen[Scenario run
side effects neutralised]

Pirenne writes through each primitive’s normal surface, so a bundle cannot do anything an operator could not. Gaal is what makes it matter for agents: an outside agent proposes one document instead of fifty writes, and a human approves it once. The neutralised side effects during a scenario run come from the same sandbox posture Demerzel exposes to operators.