Operations architecture · Barcelona
A music school that runs on software it owns.
Three businesses share one platform: a school, a rehearsal-room rental, and an instrument rental. One control panel is the situation room; a phone app is the key to the buildings; the public sites, the forms, the payments and the door hardware all hang off the same core. This is how the pieces connect, and what is being taken out next.
Access is key and the key is access. The app's only load-bearing job is opening a door. Bookings, profiles, packs and languages are all decoration on top of it. Every architectural decision below is downstream of that sentence.
The shopfronts
Four public faces, one platform behind them
Three businesses and a shop, each with its own site and its own customers, all pricing from the same catalogue and all writing into the same records. Someone who rents a room, hires a cello and enrols a child is one account, not three strangers who share an email address.
Shine Music School
The school: lessons, enrolment, the trial class, gift vouchers. Its registration form is served by the platform now, not rented from a form vendor.
themusicroombcn.comThe Music Room
Rehearsal and practice rooms, by the hour or by monthly pack, in four languages. A booking here ends in a door that opens.
shinemusicrental.comShine Music Rental
Instrument rental across Spain, from a weekend hire at the counter to a contract billed monthly for years.
shinemusicshop.comShine Music Shop
Instrument sales, new and second hand. Live, and the one still being built out: folding it into the platform sits on the planned layer of the map.
Also live, deliberately quiet: a frozen static host still serving the internal teacher form, and a South African site that stays on the old form vendor until it is rebuilt.
The map 01
Everything, on one canvas
Drag to pan, scroll or pinch to zoom, click any box to read what it is. The Planned layer ghosts in the parts that do not exist yet, so you can see the shape the system is growing into rather than just where it stands.
Access 02
The door is the product. Everything else is a feature.
Teachers and renters get into the buildings with their phone. If that fails, nothing else about the app matters, so the access path is engineered to keep working when the network, the office and the cloud are all having a bad day.
A door opening, as it happens
This runs on a loop below. It is the real sequence, at roughly real speed: a teacher taps Open, the phone reaches the controller directly over Bluetooth, the relay fires, the strike releases, and the door relocks itself a few seconds later without anyone doing anything. No part of this touches the internet.
Reachable
The controller checked in recently over the building network.
Can open
The controller says it is discoverable right now. This is the one that matters.
Both, separately
Inside three days the panel showed both. They are independent facts and are now measured as two.
Illustrative, not a live feed: this page reads nothing from the school.
A booking becomes a key
The other half of access is not a person the school knows. Someone books a room from the website at midnight, and the building has to let them in on Saturday morning without anybody meeting them there.
Books and pays
Website or app, priced by the server.Booking recorded
Lands on the office board as a card.Filed to a room
A human picks the building and the room.Grant minted
Their account may open that door, for that slot only.The door opens
They tap Open. Nobody had to be there.Grant expires
Move the booking and it is torn down and re-issued.The shape of the network underneath
Bluetooth is the real path. The phone talks straight to the controller at the door, so an unlock needs no internet, no office, and no working WiFi at the building. The panel can also queue a remote open, which the controller collects on its next outbound check-in.
What the design has to survive
A door with no internet still opens. Unlocks queue in the controller's memory and flush when it reconnects, so the log catches up even though entry never depended on it.
"The server can reach this door" and "a teacher can get in" are separate facts and are now measured separately. The controller reports whether it is actually discoverable, not just whether it is online.
A failed unlock says which thing failed and what to do next, in the reader's own language. A generic "could not open" turned a two-second diagnosis into a two-day one, once.
Someone who cannot log in cannot open a door, so sign-in is held to the same standard as the lock itself. Session state is shared, so either server can answer.
- Sign-in
- Six-digit email codeno password, long-lived session
- Authorisation
- Time-bounded grantsseparate from the login itself
- Hardware
- Custom controllersown firmware, no lock vendor
- Buildings
- Two independent networksplus one site never fitted
- App
- iOS and Androidone codebase, five languages
- Booking link
- Filing a booking grants the doorrevoked when it moves
A room booking is not finished when it is paid. It is finished when the customer can physically get in — so the booking pipeline ends at the door, not at the receipt.
Control panel 03
The situation room
One web app that the whole staff lives in. Its design brief is momentum: show the one thing to do next, then the next, and never leave someone staring at a card with no obvious move. It reshapes itself around whoever signed in.
- Backend
- 47 routers · 394 endpointsPython, async, one process
- Frontend
- 21 pages · 54 componentsserved from the same origin
- Roles
- Seven, fenced in middlewarea role cannot widen by accident
- Tests
- 200 test files, three enginesrun against the real stack too
What the staff actually work
Panel
Signups, bookings, the lifecycle lane, follow-ups and each person's own queue.
Reservas de sala
Booking cards. File one and the door grant fires for that building.
Formularios
Every public form submission, as a sortable table with charts.
Domiciliaciones
Direct-debit mandates and the monthly run that becomes the bank file.
Profesores
Roster, instruments, availability, and each teacher's private form link.
Tareas
The task board: threads, attachments, hand-offs, snoozes.
Puertas
Who may open what, when, and the audit of every entry.
Alquiler Exprés
Walk-in instrument rentals, priced from the shop catalogue.
Alquileres largo plazo
Long-term contracts, month by month, off the spreadsheet.
Tareas de Alquiler
Delivery and collection agenda, run one-handed from a van.
Calculador de Entregas
Route, tolls and live diesel price for a delivery quote.
Cobros
The debt ladder, with invoice drafts and one-tap messaging.
Gestión
Pauses, withdrawals, teacher changes, and the return-to-lessons chase.
Combos y Grupos
Group classes and combos with fill and gap signals.
Exámenes
External music-exam candidates, tracked step by step.
Catálogo · Tarifas
Courses, instruments and room rates: the one place a price lives.
Estadísticas
Intake, conversion, retention and where students are lost.
Sincronización
Imports, reconciliations and the manual levers behind them.
Running while nobody is looking
Intake sync
Pulls signups, vouchers, bookings and payments in, and retries any confirmation email that did not land.
Lifecycle crons
Resurfaces pauses due back, chases returns, ages exam records, files what has gone quiet.
Fuel prices
Caches the national diesel price so a delivery quote never waits on a government server.
Off-site backup
A full database dump leaves the platform every night and is verified on arrival.
What it actually looks like
These are the real screens, photographed from a running copy of the panel. Every person, family, mandate and booking in them was invented for this page.
None of this is real data. The screenshots come from a throwaway copy of the system, seeded with invented people and cut off from the network, so no student, teacher, family or bank detail could reach this page even by accident. The interface is exactly the production one.
Two servers run at once, but only one may run the background work. They settle it with a lock held in the database, so a deploy hands over cleanly and split-brain is structurally impossible.
Data 04
Append, derive, never clobber
The database is one schema, built by 130 numbered migrations that are only ever added to. The shape of it is deliberate: durable identities at the bottom, an immutable event log above them, and current state calculated rather than stored.
Rules that hold the schema together
A change is a new numbered step, never an edit to an old one. A machine-generated copy of the resulting schema is committed alongside, and the build fails if the two ever disagree.
The suite runs on a lightweight engine for speed, then twice more on the real database — once through the exact connection stack production uses. Behaviour that only works on one of them is caught before it ships.
The connection layer counts commit attempts before sending them, so a request that might already have committed is never retried automatically. Ambiguity surfaces as an error instead of a double charge.
Documents, photos and generated PDFs live in object storage, not in the database. Only the metadata is a row.
Network 05
Where every piece physically sits
Four separate environments, joined only by requests that cross them deliberately. The school buildings are their own island: nothing on the internet can reach into them.
Under the hood
The stack, in detail
Everything below is measured from the running system rather than described from memory. The numbers are the actual constants, timeouts and byte counts in the code. If you only read one thing, read the scars at the end: most of these decisions are somebody's bad night, written down.
The door controller
Custom firmware on a relay board at each door. It has one job, and the whole boot order is arranged around it: the Bluetooth radio comes up before the network, because Bluetooth is the only thing that physically opens the door and it must never wait on a router.
- Boot order
- serial → random boot id → relay driven locked → BLE up → WiFi → HTTP → OTA
- WiFi wait
- bounded at ~15s, then continues without a network. It never reboots on failure
- Reconnect
- backoff 5s doubling to a 60s ceiling · 12 scans an hour instead of 720
- Check-in
- outbound every 3,000 ms, 4,000 ms timeout, carrying telemetry on the query string
- Relay
- duration clamped to 300–15,000 ms; auto-relock is a hardware one-shot timer and a redundant check in the main loop
- Offline log
- 16-entry ring buffer, 12 bytes each — 192 bytes of RAM — guarded by a spinlock because the radio callback runs on another task
- Image
- 1,324,768 bytes, 67.4% of one of two OTA slots · 54,708 bytes static RAM, 16.7% of budget
- Reports
- 13 fields including ble_advertising — the field that distinguishes "reachable" from "can actually let someone in"
Two details worth the detour. The device has no clock, so a buffered unlock is sent as an age in milliseconds rather than a timestamp, computed with unsigned arithmetic so it survives the counter wrapping, and clamped to an hour so a wrapped value can never claim to be more recent than it is. And the flush is by sequence number, not by clearing the buffer: events queued while the request was in flight survive it, and the server de-duplicates on boot id plus sequence, so a lost acknowledgement replays harmlessly.
The backend
One uvicorn process per machine, deliberately with no worker flag — which is what makes the rest of the design coherent, because it means exactly one event loop, one connection pool and exactly two leader candidates cluster-wide.
- Machines
- 2, always on, 1 vCPU / 2 GB each · one region, chosen to sit beside the database
- Health
- every 10s, 5s timeout. It reads a real row from a real table, and publishes the age of the last successful backup
- Leader
- a Postgres session advisory lock on a dedicated connection outside the pool, because pool recycling would silently drop it
- Failover
- instant on deploy; on a crash, bounded by TCP keepalive at 30 + 3×10 = 60s. No lease, no clock comparison, no split brain
- Boot
- pool → advisory lock → migrate → unlock, so two machines starting together cannot race the schema
- Loops
- five, staggered 10 / 20 / 30 / 45s at startup, four of them leader-gated
- Connections
- floor 8, ceiling 22, every session capped at a 5s statement timeout and a 2s lock timeout
A bare SELECT 1 needs no table and can be answered from a constant. On one June morning that let a dead database report itself healthy for about forty minutes while every real query failed. The probe now touches storage, and a saturated pool fails it inside the platform's own timeout rather than queueing behind real traffic.
The data layer
A connection pool with a lease taken per request, and an unusual constraint: the entire codebase was written against sqlite's implicit transaction model, so the layer reproduces those semantics on Postgres rather than rewriting a thousand call sites. Reads run autocommit; the first write opens a transaction.
- Pool
- min 3, max 10 · 5s checkout · 30 min max lifetime · 10 min max idle
- Money safety
- commit attempts are counted before the commit is sent, so a request that might have committed is never auto-retried. Ambiguity surfaces as an error, never as a double charge
- Migrations
- append-only, never edited once applied. A machine-generated copy of the resulting schema is committed beside them and CI fails on drift
- Tests
- 200 files across three engines — fast sqlite, real Postgres, and Postgres through the exact production connection stack
The panel
21 pages, of which 9 are lazily loaded — and the split is drawn on role, not on file size. A secretary, a rental driver or a debt chaser should not download code for a page their token can never open.
- Payload
- 12 chunks, 1.97 MB raw. The charting library alone is 32% of all shipped JavaScript and never reaches a non-admin login
- Layouts
- resolved from the union of roles, not one primary role, so a second grant can never silently narrow somebody's access
- Server fence
- 200 role dependencies across the routers, plus three middlewares that restrict by HTTP method and by path before any route runs
- Allowlists
- matched on path segments, not string prefixes, so granting one path cannot accidentally grant a longer one that starts the same way
- Timeouts
- 30s globally, 120s for uploads, with three endpoints deliberately exempt because their server work cannot be cancelled
About fifty call sites in the app catch errors with a bare catch that throws the message away and prints a flat "could not save". So a timed-out write does two things: it throws a message saying it may have saved, and it fires a global event — because a catch block can swallow an error, but it cannot swallow an event.
The app
- Languages
- 229 keys × 5 languages, typed so that a key missing any one of them fails the build. Not a lint rule, not a runtime fallback
- Bluetooth
- one radio manager for the process lifetime, a 6s wait for the radio to settle, an 8s scan, a 5s connect
- Failure
- seven distinct reason codes, each translated into all five languages, plus a diagnostic line a maintainer can read and a teacher can ignore
- Releases
- a version-code ledger kept by hand, because the store burns a number permanently the moment a build is uploaded
Naming an icon package the SDK already bundles hoisted it, its open-ended peer range pulled a far-future version of the font module, and the platform's autolinker constructs every module during startup — so it crashed before a single line of JavaScript ran. No red box, no stack trace, no error. The fix is one pinned line, and the reasoning is written above it so nobody unpins it.
Money
There is no payment SDK in the repository. The webhook signature is verified by hand, and the rule after that is absolute: once a signature verifies, the handler may never answer with an error, because a non-2xx makes the payment processor retry and eventually drop a booking somebody genuinely paid for. Anything wrong becomes a flag a human reads, not a rejection.
- Pricing
- computed in exactly one place. The edge function that takes the payment holds no rates at all and asks the server for the total
- Underpayment
- recomputed from the signed payload; if it is short, the booking is flagged for a secretary, never refused
- Idempotency
- keyed on the payment session, enforced twice: a check before insert, and a database constraint that catches a concurrent retry racing past it
- Bank details
- AES-256-GCM, with the row's own identity bound into the encryption, so a ciphertext cannot be moved to another row or another table
- Refunds
- exactly one module in the whole repository may issue one, for exactly one case, gated by an allowlist every caller re-checks
- Bank file
- a byte-level contract reverse-engineered from nine files the bank actually accepted — encoding, line endings, delimiter, decimal comma, quoting
Names on the bank sheet come from a public form anybody can fill in. Written the default way, a name beginning with = stops being text and becomes a live formula in the file that goes to the bank — capable of pulling in another sheet, or of recalculating an amount. Every write is now forced to raw text. It is one parameter, and it is the difference between a spreadsheet and an execution surface.
A pattern runs through all of it: prefer the failure that a human will notice. Flag the underpaid booking rather than reject it, report "this may have saved" rather than a clean lie, let the audit log lose a row rather than block a door, and never let a green tick mean anything other than the thing actually happened.
Money 06
Two ways in, and the server picks every price
Cards for anything immediate, direct debit for anything monthly. Nothing a browser or a phone says about an amount is trusted: the price is recomputed on the server at the moment of payment, every time.
Card: a room booking, end to end
Rates
Edited once in the panel.Site & app
Show the price, cannot set it.Re-quote
Server prices it again at pay time.Edge worker
The only holder of the payment key.Booking card
Recorded, customer and office emailed.Door grant
Access opens for that slot.Direct debit, the Spanish way
Most tuition is collected by domiciliación. A customer signs a mandate on a form the platform serves itself; the bank details are encrypted the moment they arrive and are never stored or displayed in the clear. Each month a remesa is prepared, frozen, and handed to the bank as a file in the exact format it accepts.
Every run is frozen at the moment it is stamped, with the whole book snapshotted beside it, so any past charge can be reconstructed exactly as it went out.
A returned debit is a named adjustment line with a reason, not a silently edited amount — and it expires by itself the following month.
Two payment accounts, on purpose
The school and the rental business bank separately, so the platform talks to two payment accounts and never mixes them. Registration fees and gift vouchers land on the school side; room packs and instrument rentals land on the rental side.
Cancel a room 48 hours ahead and the refund is automatic. Inside 48 hours it can be moved but not cancelled. Pack sessions are never refundable — the room was held either way.
A customer asks; a human answers. The office places the new slot in the real calendar, and the door grant is torn down and rebuilt to match. The clock is measured when they asked, never when the office got round to it.
Every price in the school — a lesson, a room, an instrument, a pack — comes from one catalogue. Change it once and the website, the app and the calculator all follow on the next load, with nothing redeployed anywhere.
Forms & paper 07
Every form the public can reach, and the paper each one leaves
These were rented from a form vendor until 2026. They are now first-party: the platform serves the sensitive ones itself, on its own locked-down pages, so a signature and a bank detail never pass through a third party.
Student registration
The main intake. Pays the enrolment fee by card, transfer or instant mobile payment.
Gift voucher
Buy lessons as a present. Pays first, then the voucher PDF is generated and emailed.
Voucher redemption
Turns a paid voucher into a real student record, once, in a single transaction.
Direct-debit mandate
A private link, not on any website. Signature is a ticked box and a typed name, recorded with the exact terms shown.
Instrument rental agreement
The full contract, with ID photos and either a card subscription or a debit mandate.
Room booking & packs
Hourly bookings and monthly practice-room packs, priced live from the panel.
Teacher availability
Reached only by a teacher's own private link — no shared code exists any more.
The journal
Every public submission writes one row to a single journal, inside the same transaction as the thing it created. If the journal write fails, the submission fails: there is no such thing as an action with no record.
What goes into that row is a whitelist, not a copy. Bank details, identity documents, addresses and network fingerprints are never journalled, and a column holding a fragment of one can never be charted — a bar chart of account fragments is still a leak with a bar next to it.
Paper the system produces
Generated on the server: gift vouchers on the branded background, invoices for the debt ladder, payroll sheets, rental contracts. Rebuilt from the record each time and never stored half-made.
Sent from the school's own mailbox. Most go out as a draft for a human to read and send; only receipts, codes and confirmations send themselves. Drafts are idempotent, so pressing the button twice does not send twice.
WhatsApp is deep links, not a bot. The panel writes the message, in the recipient's language, and a person presses send. Nothing auto-messages a customer.
7,469 historical submissions and 1,425 PDFs were pulled out of the old form vendor and kept, so nothing was lost when the subscription ended.
Students 08
From a form to a first lesson, and everything after it
The oldest part of the platform and still the busiest. A signup arrives, a teacher is found, the class is placed and confirmed, and from then on the system's job is noticing what quietly stopped.
Signup
From the website, the office, or a voucher.Matching
Ranks teachers on instrument, level, style, availability and history.Offer
Messaged in their own language; accept or decline is recorded.First class
Date set, confirmation drafted, contact created.Follow-ups
Trial check-ins and a conversion check weeks later.Pause · return · leave
Each a tracked chase, never a dead end.Coming back is a designed flow, not a hope
A pause is not an ending. It carries an expected return date, chases the family up to three times, and resurfaces on the board the day it is due. If a chase dead-ends it is flagged rather than silently converted into a departure — a person decides that.
Returning to the same teacher
Resuming a whole family's lessons used to be a checklist that took most of a minute and frequently ended with no date set. It is now a single action that places and finalises in one move.
Intake, conversion, retention
Where signups come from, how many convert after a trial, how long students stay, which instruments hold people and which lose them, and how much is outstanding right now.
Chasing, as a ladder
Each family's outstanding balance moves through defined steps with a drafted message at each one. It replaced a spreadsheet that nobody could tell you the state of.
Rentals 09
The other two businesses
Rehearsal rooms and instrument rental run on the same platform as the school, sharing its accounts, its payments and its door grants — but with their own catalogue, their own crew and their own vehicle.
Rehearsal rooms
Booked by the hour from the website or the app, or as a monthly pack. Every booking, however it arrives — site, app, office, or cash at the desk — lands in the same place, and ends in a door grant.
Express rental
Same-day instrument hire at the counter, priced straight from the shop's public catalogue so the desk and the website never disagree.
Long-term rental
244 contracts across 3,709 billed months lifted out of a spreadsheet. The rule that makes it honest: a month is paid, failed, or not recorded yet — never two of those at once. Payments are appended as events, so a bounce can never erase a payment that happened.
Deliveries
The crew works a phone-shaped agenda: tap to call, tap to message, tap to navigate. A delivery is quoted from real routing, real tolls and the live national diesel price rather than a guess.
The rental crew's accounts can reach their own work and nothing else. The restriction is enforced in the database query itself, not just hidden in the menu — a hand-crafted request for someone else's data comes back empty, not forbidden.
Switched off 10
What the platform replaced
Each of these was a subscription doing one job badly across a boundary the school could not see into. The point was never only the money — it was owning the data — but the money is real and it is worth stating plainly.
Roughly a quarter of that is already out. The remainder is not a licence cancellation — it is a calendar and an invoicing system that have to be built and proven first.
Where it goes 11
The half that is not built yet
Turn on the Planned layer in the map above to see these in place. The order is deliberate: the money has to be right before the calendar can be trusted, and the catalogue has to be shared before the shop and the rental can be one business.
Billing that bills the contract
Invoicing, balances and receipts move in-house. The principle behind it: a family is billed for the enrolment they agreed to, not for a calendar teachers keep imperfectly. The current tool conflates the two, which is the root of most billing disputes. One balance per family, lines attributed per person, integer cents, append-only, prices frozen at the moment of enrolment.
A calendar, but only as far as money needs it
A lean attendance record, touching money in exactly two places: depleting a pack, and paying a teacher. Everything else about a lesson stays descriptive. This is what finally retires the scheduling subscription and the automation glue with it.
Every instrument in one database
Rental and the shop currently keep separate ideas of what an instrument is. They become one record with one history: bought, listed, sold, or rented out, to whom and for how long. A serial number stops being three different rows in three different places.
The shop joins the platform
Instrument sales come onto the same catalogue and the same customer records as rentals, so a person who rents, then buys, then books a room is one customer with one history — not three strangers who happen to share an email address.
Lessons and rentals in your pocket
The app already opens doors and books rooms. Next it carries your lessons, your rental, your packs and your payments. The thing everyone installed to get through a door becomes the thing they manage everything in — which is exactly why access came first.
Teachers log in as themselves
Teachers get their own side: the students they actually teach, marking who turned up, their availability, and their own public profile. Today every one of those reaches the school through the office or a form, which means the school's picture of its own teaching is always slightly out of date.
Families sign themselves up
Students and parents enrol in lessons, see what they are booked into, pay, and request a change without any of it being a message a secretary retypes. The office stops being the only path and becomes the exception handler — which is the point at which the platform scales past the number of people answering the phone.
Noticing before a human does
Nightly rules that spot a student who quietly stopped coming, or a rental month nobody recorded, and raise it as a card with a drafted message — before the school bills someone for lessons that are not happening.
The through-line, from the first line of code to everything above: always show the next action, never a dead end — and never, ever break the door.