Engine reference
The pivot file, field by field: every block, its real default, what each value is allowed to be, and the order to fill them in. You declare only what you change, everything you leave out falls back to a built-in default, so a three-line file boots.
One JSON file at the repo root describes the whole business. Here is what to know before you open it.
domain.config.json in the repo root. Point DOMAIN_CONFIG_PATH somewhere else if you move it.GET /config.Every key has a default in app/config_schema.py. A file that declares three keys is complete and valid, the other ~120 resolve underneath it. This is the smallest useful file:
{
"configVersion": 2,
"domain": "clinic",
"terms": { "provider": "Clinic", "client": "Patient" }
}So the way to configure this engine is not to fill in a big template. Work through the blocks below in order, write down only the lines that differ from the default, and leave the rest out.
1200 with currency: "EUR" is €12.00. There are no decimals anywhere in this file.pricing.tiers means those tiers, not those plus any default. Declaring pricing.rate.per leaves the rest of pricing intact.After any edit, apply it with the two commands in step 11.
Step 1
Set this first. It changes the shape of the whole app, so every later choice reads differently depending on it.
"tenancy": {
"mode": "single",
"providerCode": "VISTULA-4471"
}"tenancy": {
"mode": "multi",
"selfOnboarding": true,
"commission": { "enabled": true, "rateBps": 800, "chargedOn": "completion" }
}Step 2
Ten booleans. Off means the UI hides the surface and the API refuses the write.
Switching a capability on does not configure it, it only unlocks the block. Turning on inventory still leaves inventory.mode at none until you set it.
Step 3
What is being reserved, in what units, for how long, and for how many people.
"booking": { "unitKind": "time_slot", "granularity": "minute" }"booking": {
"unitKind": "asset",
"granularity": "hour",
"duration": { "mode": "customer_chosen", "minUnits": 1, "maxUnits": 4 },
"party": { "mode": "group", "min": 2, "max": 4 }
}"booking": {
"unitKind": "class_capacity",
"party": {
"mode": "group", "min": 1, "max": 12,
"composition": [
{ "key": "adult", "label": "Adult", "priceFactor": 1 },
{ "key": "child", "label": "Child", "priceFactor": 0.5 }
]
}
}"booking": {
"unitKind": "room",
"granularity": "night",
"duration": { "mode": "customer_chosen", "minUnits": 1, "maxUnits": 30 },
"subject": {
"enabled": true, "noun": "Pet",
"fields": [{ "key": "species", "label": "Species", "type": "select",
"options": ["Dog", "Cat"] }]
}
}Each entry in options needs a key, and a type of boolean or select. A select option must carry a non-empty choices list, or the file is rejected.
"options": [
{ "key": "equipment", "label": "Racket hire", "type": "boolean" },
{ "key": "coach", "label": "Coach", "type": "select",
"choices": ["None", "Group", "Private"] }
]Step 4
The block with the most levers, and the one where a mistake costs real money.
rate.amountMinorUnits is multiplied by a quantity that rate.per chooses:
Then chargePerPerson decides whether the party multiplies that:
true (the default), amount × quantity × party. Each person is buying their own thing.false, the party shares one unit. A tennis court costs the same for two players or four.rate.per: "person" already counts heads, so it never double-counts regardless of this flag.
"pricing": {
"currency": "EUR",
"rate": { "per": "hour", "amountMinorUnits": 1200 },
"chargePerPerson": false
}Tiers are checked in the order you write them and the first match wins, so put the most specific first. A matching tier replaces rate.amountMinorUnits. Each needs a key and an amountMinorUnits of 0 or more.
"tiers": [
{ "key": "offpeak", "label": "Off-peak", "amountMinorUnits": 800,
"appliesWhen": { "timeOfDay": { "from": "08:00", "to": "16:00" } } },
{ "key": "group", "label": "Group of 4+", "amountMinorUnits": 1000,
"appliesWhen": { "partySize": { "min": 4 } } },
{ "key": "earlybird", "label": "Early bird", "amountMinorUnits": 900,
"validFrom": "2026-01-01", "validUntil": "2026-03-31" }
]Every clause you write must hold for the tier to apply. Any range accepts {min, max} or a bare value for an exact match. validFrom / validUntil gate when the tier is on sale at all.
Fees are added after the base. Each needs a kind, and the kind decides which other field is required:
"fees": [
{ "key": "clean", "label": "Cleaning", "kind": "flat", "amountMinorUnits": 1500 },
{ "key": "service", "label": "Service fee", "kind": "percent", "rateBps": 250 },
{ "key": "travel", "label": "Travel", "kind": "distanceBand",
"bands": [ { "maxKm": 5, "feeMinorUnits": 0 },
{ "maxKm": 20, "feeMinorUnits": 900 },
{ "maxKm": null, "feeMinorUnits": 2000 } ] }
]caps.perBookingMinorUnits clamps the total. Enforced.caps.perDayMinorUnits is accepted but not enforced, it needs the customer's other bookings that day, which is a database question, not arithmetic. Do not rely on it.deposit is {enabled, kind, value, refundable}, where kind is percent or flat. It is derived from the final total. A non-refundable deposit is a prepayment and is clamped to never exceed the total; a refundable one is a bond and may exceed it.1. a matching tier replaces rate.amountMinorUnits
2. base = amount × quantity(rate.per) × party factor
3. + secondaryRate (an independent axis, ADDED, see below)
4. + fees (flat, percent of subtotal, distance band)
5. clamp to caps.perBookingMinorUnits
6. derive the deposit from the final totalStep 5
Who pays, at what point, and on what cycle.
"capabilities": { "payments": true },
"payments": { "flow": "pay_on_site" }"capabilities": { "payments": false },
"payments": { "flow": "none" }Step 6
Slot length, notice, cutoffs, and whether a booking is instant or has to be approved.
"timing": {
"confirmation": "request_approve",
"leadTimeMinutes": 1440,
"slotDurationMinutes": 60,
"cancellationWindowHours": 48
}Step 7
Set the timezone even if you change nothing else in this block.
"location": {
"modes": ["on_site", "at_customer"],
"default": "on_site",
"timezone": "Europe/Warsaw",
"distanceUnit": "km",
"origin": { "city": "Warsaw", "lat": 52.2297, "lng": 21.0122 }
}Step 8
Leave these alone unless the matching capability is on.
A list. Each entry needs a key and a valid kind; appliesTo defaults to customer.
"prerequisites": [
{ "key": "licence", "kind": "licence", "label": "Driving licence",
"appliesTo": "customer", "required": true, "validityDays": 365,
"blocksConfirmation": true }
]
kind ∈ id_check | licence | intake_form | waiver |
membership | approval | credential
appliesTo ∈ customer | tenant | subjectdiscovery.facets is the search filter row, the one part of these three that is wired up today.
Step 9
Rename every noun in the product, and add fields the engine has never heard of.
Each must be a non-empty string. Singular and plural are separate keys.
"terms": {
"provider": "Clinic", "providers": "Clinics",
"service": "Treatment","services": "Treatments",
"resource": "Room", "resources": "Rooms",
"slot": "Appointment","slots": "Appointments",
"booking": "Visit", "bookings": "Visits",
"client": "Patient", "clients": "Patients",
"admin": "Practice", "admins": "Practices",
"staff": "Clinician","subject": "Patient", "party": "Guests"
}landingTitle, landingSubtitle, confirmTitle, emptyStateSlots, emptyStateBookings, requestPending, waitlistJoined, quoteRequested, depositDue, prerequisiteBlocked. All must be non-empty.
Six entities take custom fields: providers, services, resources, slots, bookings, subjects. This is the extension point that needs no migration, the values live in each table's metadata column.
"metaFields": {
"bookings": [
{ "key": "reason", "label": "Reason for visit", "type": "text",
"required": false, "helpText": null, "visibleTo": "both" },
{ "key": "referral", "label": "Referred by", "type": "select",
"options": ["GP", "Self", "Insurer"] }
]
}Step 10
Do not fork the file. Put the difference on the service.
A service carries its own partial config, deep-merged over the global block. This is what lets two businesses on one deployment price and schedule completely differently.
{
"name": "Guided tour",
"config": {
"pricing": { "rate": { "per": "person", "amountMinorUnits": 4500 } },
"timing": { "confirmation": "request_approve", "leadTimeMinutes": 2880 }
}
}Exactly nine: booking, pricing, payments, inventory, location, timing, recurrence, entitlements, capabilities. Anything else is a 422 naming the allowed list.
tenancy, prerequisites, discovery, terms, copy and metaFields stay global, presentation and platform terms are not a single service's to change.
explicit override in the service's config ← highest
↓ falls back to
the service's own column (price, duration, cutoff …)
↓ falls back to
domain.config.json
↓ falls back to
a built-in default ← never nullOverrides go through the same validator as the global file, against your deployment's resolved config, so a service cannot set payments.flow: "prepay" on a deployment where capabilities.payments is off. An invalid override is rejected on write with the problem list, not silently dropped.
Step 11
Two commands, and a validator that tells you everything wrong at once.
make reload # re-read domain.config.json
make reseed # rebuild the demo catalog to matchmake reload re-reads the file. make reseed is only needed when the data should change too, renaming your nouns does not require it, switching what you sell does.
There is also an owner-gated POST /config/reload, which validates the new file before dropping the cached one: a bad edit comes back as 422 and the running app keeps serving the last good config.
Every problem is listed at once, each with its full path:
domain.config.json is not a usable domain config:
- tenancy.providerCode is required when tenancy.mode is 'single'
(it is how the app resolves its one business)
- pricing.currency must be a 3-letter ISO 4217 code, got 'EURO'
- location.timezone must be a valid IANA zone, got 'Europe/Warsawww'
- capabilities.payments is false, so payments.flow must be 'none'
(got 'prepay'), otherwise the UI hides a step the API still enforces
This is the file a pivot edits, fix the keys above.CI runs this same validator on the file, so a broken config fails the build rather than the deploy.
The file validates far more than the engine currently reads. Check here before you rely on a key.
Every block above is accepted, stored and served. These are the ones with a real reader behind them end to end:
pricing.*, the whole quote pipeline, except the three exceptions called out in step 4timing, slotDurationMinutes, maxBookingsPerSlot, cancellationWindowHours, bufferMinutes, leadTimeMinutes, confirmationbooking.duration.minUnits / maxUnitslocation.timezone, origin, distanceUnitmetaFields.resources, metaFields.slots, discovery.facetstenancy.mode, providerCode, and terms.admin / terms.slotEverything else, inventory, waitlist, prerequisites, recurrence, entitlements, the payment flows, is configurable and validated, and waiting on its reader. Configure it if you want the file to describe your business honestly; do not expect it to gate a booking yet.
A kayak-hire business: hourly asset rental, deposit, travel fee, approval required.
{
"configVersion": 2,
"domain": "kayak-hire",
"tenancy": { "mode": "single", "providerCode": "RIVER-2210" },
"capabilities": {
"payments": true, "reviews": true, "follows": true,
"inventory": true, "prerequisites": true
},
"booking": {
"unitKind": "asset",
"granularity": "hour",
"duration": { "mode": "customer_chosen", "minUnits": 1, "maxUnits": 8 },
"party": { "mode": "group", "min": 1, "max": 2 },
"options": [
{ "key": "wetsuit", "label": "Wetsuit", "type": "boolean" }
]
},
"pricing": {
"currency": "PLN",
"rate": { "per": "hour", "amountMinorUnits": 4000 },
"chargePerPerson": false,
"tiers": [
{ "key": "weekday", "label": "Weekday mornings",
"amountMinorUnits": 3000,
"appliesWhen": { "timeOfDay": { "from": "08:00", "to": "12:00" } } }
],
"fees": [
{ "key": "delivery", "label": "Riverside delivery", "kind": "distanceBand",
"bands": [ { "maxKm": 10, "feeMinorUnits": 0 },
{ "maxKm": null, "feeMinorUnits": 5000 } ] }
],
"deposit": { "enabled": true, "kind": "flat", "value": 10000,
"refundable": true }
},
"payments": { "flow": "pay_on_site" },
"inventory": { "mode": "rentable", "returnRequired": true,
"loanPeriodHours": 8, "overdueFeePerDayMinorUnits": 5000 },
"location": {
"modes": ["pickup", "delivery"],
"default": "pickup",
"timezone": "Europe/Warsaw",
"distanceUnit": "km",
"origin": { "city": "Warsaw", "lat": 52.2297, "lng": 21.0122 }
},
"prerequisites": [
{ "key": "waiver", "kind": "waiver", "label": "Safety waiver",
"appliesTo": "customer", "required": true, "blocksConfirmation": true }
],
"timing": {
"confirmation": "request_approve",
"leadTimeMinutes": 120,
"slotDurationMinutes": 60,
"cancellationWindowHours": 24
},
"terms": {
"provider": "Boathouse", "providers": "Boathouses",
"resource": "Kayak", "resources": "Kayaks",
"slot": "Hire window", "slots": "Hire windows",
"client": "Paddler", "clients": "Paddlers"
},
"metaFields": {
"bookings": [
{ "key": "experience", "label": "Paddling experience", "type": "select",
"options": ["First time", "Some", "Confident"], "required": true }
]
}
}Everything not named here, discovery, recurrence, entitlements, copy, the rest of booking and payments, resolves from the defaults, which is why a file this short is a complete configuration.