HomeDocs

Engine reference

Setting up domain.config.json

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.

Before you edit

One JSON file at the repo root describes the whole business. Here is what to know before you open it.

  • It lives at domain.config.json in the repo root. Point DOMAIN_CONFIG_PATH somewhere else if you move it.
  • It is read once at startup and cached, then served to the app at GET /config.
  • A bad edit fails at startup with every problem listed at once. There is no such thing as a half-applied config.

Declare only what you change

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:

domain.config.json
{
  "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.

Two conventions that trip people up

  • All money is an integer in minor units. 1200 with currency: "EUR" is €12.00. There are no decimals anywhere in this file.
  • Lists replace, objects merge. Declaring 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

Tenancy: one business, or a marketplace?

Set this first. It changes the shape of the whole app, so every later choice reads differently depending on it.

KeyValuesDefaultWhat it does
modesingle | multimultisingle collapses the app to one implicit business: no Search tab, four tabs instead of five, the root redirects to the business page, and the business-signup link disappears
providerCodestring | nullnullRequired in single mode, it is how the app resolves its one business. Leaving it out is a load error
selfOnboardingbooltrue if mode is multiWhether businesses can sign themselves up. Omit it and it follows the mode; set it explicitly to disagree
tenantVerification{required, credentials[]}{false, []}Licence/KYC gating on the business side
commission{enabled, rateBps, chargedOn}{false, 0, completion}Your platform cut. rateBps is basis points and must be >= 0
a single business
"tenancy": {
  "mode": "single",
  "providerCode": "VISTULA-4471"
}
a marketplace taking 8%
"tenancy": {
  "mode": "multi",
  "selfOnboarding": true,
  "commission": { "enabled": true, "rateBps": 800, "chargedOn": "completion" }
}

Step 2

Capabilities: switch features on

Ten booleans. Off means the UI hides the surface and the API refuses the write.

KeyDefaultKeyDefault
paymentstruerecurrencefalse
reviewstrueprerequisitesfalse
followstrueentitlementsfalse
inventoryfalsecartfalse
waitlistfalsequotesfalse

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

Booking: what one booking actually is

What is being reserved, in what units, for how long, and for how many people.

KeyValuesDefault
unitKindtime_slot staff asset seat room class_capacity stock_item subscription_slot projecttime_slot
granularityminute hour day night week month noneminute
duration.modefixed variable customer_chosen open_endedfixed
duration.minUnitsint >= 11
duration.maxUnitsint >= 1, and >= minUnits1
duration.incrementUnitsint1
party.modeindividual group buyoutindividual
party.minint >= 11
party.maxint >= 1, or nullnull, the resource's own capacity is the ceiling
party.composition[{key, label, priceFactor}] | nullnull
party.matchResourceCapacityboolfalse
sequence{enabled, steps, minGapHours, maxGapHours}{false, 1, 0, null}
subject{enabled, noun, fields[]}{false, "Subject", []}
options[{key, label, type, choices[]}][]

Four businesses, four setups

30-minute appointments, this is the default, write nothing
"booking": { "unitKind": "time_slot", "granularity": "minute" }
a court booked by the hour, 1-4 hours, 2-4 players
"booking": {
  "unitKind": "asset",
  "granularity": "hour",
  "duration": { "mode": "customer_chosen", "minUnits": 1, "maxUnits": 4 },
  "party": { "mode": "group", "min": 2, "max": 4 }
}
a class of up to 12, priced by age band
"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 }
    ]
  }
}
a room booked by the night, about a pet
"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"] }]
  }
}

Add-ons

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

Pricing: what to charge

The block with the most levers, and the one where a mistake costs real money.

The rate

rate.amountMinorUnits is multiplied by a quantity that rate.per chooses:

rate.perMultiplied byUse it for
booking1a flat charge however long or large
slotnumber of slots heldthe classic appointment (this is the default)
personhead countper-seat pricing
unitthe booking's unit_countquantities, pallets, bikes, covers
hourfractional hours90 minutes is genuinely 1.5
daystarted dayshalf a day of storage bills as a day
nightstarted nightsstays
weekstarted weekslong hire
monthstarted monthsstorage, subscriptions

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.

€12.00 an hour for the court, whoever turns up
"pricing": {
  "currency": "EUR",
  "rate": { "per": "hour", "amountMinorUnits": 1200 },
  "chargePerPerson": false
}

Tiers: a different price in some circumstance

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" }
]
appliesWhen clauseShapeMatches on
partySize{min, max}head count (weighted, if you use party.composition)
timeOfDay{from, to}start time; a window may wrap midnight (18:00 → 06:00)
zoneexact stringseat or area zone
bookingIndex{min, max}how many bookings this customer already has
subjectField{key, equals}a field on the pet / vehicle / child
distanceKm{min, max}travel distance

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

Fees are added after the base. Each needs a kind, and the kind decides which other field is required:

kindRequired fieldEffect
flatamountMinorUnitsadded as-is
percentrateBpsbasis points of the subtotal, 250 is 2.5%
distanceBandbands[{maxKm, feeMinorUnits}]non-empty; picks the band the distance falls in
"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 and deposit

  • 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.

The order it all runs in

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 total

Step 5

Payments: when the money moves

Who pays, at what point, and on what cycle.

KeyValuesDefault
flowprepay pay_on_site invoice_after split noneprepay
payercustomer third_partycustomer
schedule[{key, kind: percent|flat, value}][]
billingCyclenone weekly monthly annualnone
noShowFee{enabled, amountMinorUnits}{false, 0}
usageMeteredboolfalse
adapterstringmanual
pay at the counter
"capabilities": { "payments": true },
"payments": { "flow": "pay_on_site" }
no money in the product at all
"capabilities": { "payments": false },
"payments": { "flow": "none" }

Step 6

Timing: the scheduling rules

Slot length, notice, cutoffs, and whether a booking is instant or has to be approved.

KeyValuesDefaultEnforced?
confirmationinstant request_approveinstantYes, the default behind each service's auto-approve toggle
leadTimeMinutesint >= 00Yes, minimum notice on booking
slotDurationMinutesint >= 130Yes
maxBookingsPerSlotint >= 11Yes, via slot capacity
cancellationWindowHoursint >= 024Yes, on change/cancel
bufferMinutesint >= 00Yes, on slot creation
advanceBookingWindowDaysint >= 030No, the demo seed lays slots further out than this allows
approvalWindowHoursint48No, expiring a stale request needs a scheduled job
waitlist{enabled, autoPromote, maxPerSlot}{false, true, 0}No
seasons[{startDate, endDate}][]No, but both dates are required if you declare one
blackouts[{startDate, endDate}][]No, same shape rule
24h notice, owner approves each request
"timing": {
  "confirmation": "request_approve",
  "leadTimeMinutes": 1440,
  "slotDurationMinutes": 60,
  "cancellationWindowHours": 48
}

Step 7

Location: where it happens

Set the timezone even if you change nothing else in this block.

KeyValuesDefault
modesnon-empty list of on_site at_customer remote delivery pickup["on_site"]
defaultone of your own modes, this is checkedon_site
timezonea valid IANA zone, this is checkedUTC
distanceUnitkm mikm
origin{city, lat, lng} | nullnull
serviceArea{radiusKm, travelBufferMinutes, feeBands}{null, 0, []}
remote.meetingLinkModestringnone
fulfilment{windowMinutes, cutoffHoursBefore}{60, 0}
a Warsaw business that also travels to the customer
"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

The optional blocks

Leave these alone unless the matching capability is on.

inventory

KeyValuesDefault
modenone finite rentable consumable serialisednone
reservationWindowMinutesint >= 015
loanPeriodHoursint | nullnull
returnRequiredboolfalse
overdueFeePerDayMinorUnitsint0
restockCyclestringnone
ratioConstraintobject | nullnull

prerequisites

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 | subject

recurrence · entitlements · discovery

KeyValuesDefault
recurrence.patterns[]weekly biweekly monthly[]
recurrence.maxOccurrencesint12
recurrence.term{mode, noticePeriodDays}{fixed, 0}
entitlements.kindnone credits membership passnone
entitlements.plans[]objects, each needs a key[]
discovery.modebrowse reversebrowse
discovery.facets{price, distance, rating, availability, unitKind}all true except unitKind

discovery.facets is the search filter row, the one part of these three that is wired up today.

Step 9

Words: copy, and your own fields

Rename every noun in the product, and add fields the engine has never heard of.

terms: 17 nouns

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"
}

copy: 10 strings

landingTitle, landingSubtitle, confirmTitle, emptyStateSlots, emptyStateBookings, requestPending, waitlistJoined, quoteRequested, depositDue, prerequisiteBlocked. All must be non-empty.

metaFields: your own data

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"] }
  ]
}
RuleDetail
Required on every fieldkey, label, type, each a non-empty string
Allowed typestext number boolean date select file (string is accepted as an alias for text)
A select fieldmust carry a non-empty options list
Undeclared keysalways pass, you can put anything in metadata without declaring it. Declaring a field is how you opt into validation for it

Step 10

When one service needs different rules

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.

POST or PATCH /services
{
  "name": "Guided tour",
  "config": {
    "pricing": { "rate": { "per": "person", "amountMinorUnits": 4500 } },
    "timing":  { "confirmation": "request_approve", "leadTimeMinutes": 2880 }
  }
}

Which blocks you may override

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.

What wins

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 null

Overrides 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

Applying an edit, and reading the errors

Two commands, and a validator that tells you everything wrong at once.

terminal
make reload    # re-read domain.config.json
make reseed    # rebuild the demo catalog to match

make 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.

What a rejected file looks like

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.

The mistakes that actually happen

You wroteWhat happens
"timing": []Rejected: a block must be the type it is declared as. Checked before anything reads it
mode: "single" with no providerCodeRejected, nothing can resolve the business
location.default not in location.modesRejected
minUnits above maxUnits (or party.min above party.max)Rejected
a select option or metaField with no choices/optionsRejected
a fee with kind: "percentage"Rejected, the valid kinds are flat, percent, distanceBand
a misspelled appliesWhen clauseAccepted, and the tier silently never matches. The one failure the validator cannot catch for you

CI runs this same validator on the file, so a broken config fails the build rather than the deploy.

What actually runs today

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 4
  • timing, slotDurationMinutes, maxBookingsPerSlot, cancellationWindowHours, bufferMinutes, leadTimeMinutes, confirmation
  • booking.duration.minUnits / maxUnits
  • location.timezone, origin, distanceUnit
  • metaFields.resources, metaFields.slots, discovery.facets
  • tenancy.mode, providerCode, and terms.admin / terms.slot

Everything 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 complete file

A kayak-hire business: hourly asset rental, deposit, travel fee, approval required.

domain.config.json
{
  "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.