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
- 60 routers · 536 endpointsPython, async, one process
- Frontend
- 27 pages · 13 loaded lazilyserved from the same origin
- Roles
- Seven, fenced in middlewarea role cannot widen by accident
- Tests
- 325 test files, 6,058 tests, 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.
Door health
Ages every controller's last check-in, so the panel's reachable dot is a measured fact with a clock on it rather than a remembered one.
Fault ring, pool gauges
Faults are buffered in memory and flushed to a durable table every 30 seconds, on every machine, because the database is by definition misbehaving when this fires. The pool's gauges are logged every 60 seconds.
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 162 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
- about 1,341,500 bytes for the self-updating 1.3.0 build, 68% of a 1,966,080-byte OTA slot; two slots, so a bad image rolls back by itself. The build script refuses anything at or over 1,900,000 bytes · 54,708 bytes static RAM, 16.7% of budget, as last measured
- 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
- seven. Five are staggered 10 / 20 / 30 / 45 / 60s at startup so a fresh machine never fires everything at once, with the mutating ones leader-gated; the two newest (pool telemetry every 60s, a fault-ring flush every 30s) deliberately run on every machine, because the database is by definition misbehaving when they matter
- 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 · 5s connect, 30s reconnect · every session also carries a 60s idle-in-transaction timeout
- 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. Since September every migration must also reapply on Postgres from the previous version
- Watchdog
- one daemon thread per process, waking four times a second: 9.0s for a query, 12.0s for a commit, 3.0s for a pool checkout. It cancels on the server, then shuts the socket down so the blocked caller returns in about 1 ms. A COMMIT is never cancelled. Every fault is a durable row naming its class, route, elapsed time and the pool gauges
- Driver
- psycopg-binary pinned at 3.3.6, which bundles libpq 18.6; 18.0's SSL pending-bytes deadlock was the root cause of the September stalls. Every dependency, transitives included, is pinned by a constraints file
- Tests
- 325 files, 6,058 tests collected, across three engines — fast sqlite, real Postgres, and Postgres through the exact production connection stack. The last full run through that stack, at release 1142 on 19 September 2026: 5,873 passed, 99 skipped, 0 failed
The panel
27 pages, of which 13 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
- 21 chunks, 2,494,830 bytes raw, built from 873 modules in 1.7s. The main chunk is 1,268,623 bytes (338.7 kB gzipped) and is the one to watch. The charting library is 14% of all shipped JavaScript (355,165 bytes) and never reaches a non-admin login; the framework sits in its own immutable 230,379-byte vendor chunk (73.8 kB gzipped) so a deploy no longer re-downloads it
- Layouts
- resolved from the union of roles, not one primary role, so a second grant can never silently narrow somebody's access
- Server fence
- 235 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
- 637 keys × 5 languages (412 in the customer app, 225 on the teacher side), 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.
Decisions —
Decisions, and what each one cost
Eighteen choices that shaped the platform, told the same way each time: the situation, the choice, what it cost, what it bought. The ones marked as scars were somebody's bad night, written down so nobody has to have it twice. Every figure is from the code or the release record, not from memory.
The database moved to managed Postgres
Situation. Production ran on an embedded-replica database with about 850 lines of shim and roughly 1,500 lines of survival machinery around it: a keep-alive that doubled as a restart watchdog, soft and hard resync paths, a compare-and-set lease with a 120-second TTL. The client held the interpreter lock during network I/O, the server evicted idle streams after 10 seconds, and the native client crashed four times in five days.
Choice. Fly Managed Postgres through a psycopg pool, hard cutover on 31 July 2026 after moving both machines to sit beside the cluster. No dual-backend layer: "double surface for a solo maintainer". The old database was frozen as a shadow reference and checked daily for parity until shadow-end on 14 August.
Cost. Five design documents, 403 failures and 158 errors burnt down to zero in one morning, a rehearsed cutover proven by two exports with identical per-table checksums, two weeks of shadow checks.
Bought. Real statement timeouts, real pooling, foreign keys that are actually enforced, and the deletion of the whole survival layer.
One shim reproduces sqlite's semantics on Postgres
Situation. About 1,113 execute call sites and 1,560 tests written against sqlite's contract: question-mark placeholders, rows that answer by name and by position, a last-row id after insert, and the implicit transaction model where the first write opens a transaction.
Choice. One choke point translates the dialect (placeholders, JSON extraction with integer casts for the known keys, the one measured GLOB shape, booleans as integers, RETURNING id appended on identity tables) and reproduces the transaction model: reads run autocommit, the first write issues BEGIN. Timestamps stayed text, money stayed as it was, booleans stayed integers, and the database was created with C collation so string-compared dates keep byte order.
Cost. A permanent compatibility layer that is the hottest file in the repository, and a set of deliberate non-translations that fail loudly rather than return something plausible. The "right" long-term types are deferred to later migrations.
Bought. No per-site edits across a thousand call sites; reads immune to the idle-in-transaction timeout because they never open one; nothing that sorts a string changed meaning on cutover night.
Migrations are appended, generated, and must reapply on Postgres
Situation. Every schema change is a new numbered migration and a Postgres baseline is generated from the same source, with CI failing on drift. Then the deploy that carried schema 130 (3 September) caused a two-minute partial outage: column-adding migrations had silently done nothing on Postgres since the July cutover (a sqlite-only probe raised, the aborted transaction swallowed the fallback, the step was recorded as applied), and 130 was the first to add columns and read them at once. The next day release 999 crash-looped on a sqlite keyword executed verbatim. Doors kept opening throughout, because Bluetooth is local.
Choice. Idempotent column adds, a white-box test of the Postgres path, and a new test class: reapply every migration on Postgres from the previous version, plus a source scan that fails on the sqlite leg where such bugs are written. Migration numbers cannot be reserved; the next build takes the next free one.
Cost. A template rebuild in the test legs on every bump. 32 migrations in September alone, 130 to 162.
Bought. The one path no test leg covered, a runtime migration against live Postgres, now has one.
The suite runs three times
Situation. A suite green on sqlite validates a dialect nobody deploys.
Choice. The same suite on sqlite (the fast leg), on a throwaway Postgres 16 in Docker with a template database cloned per test (about 19 ms to create, 8 ms to drop, tmpfs, fsync off), and through the literal production stack: pool plus per-request lease. Transaction-rollback isolation was disqualified because it fakes commit semantics, the exact class under test.
Cost. CI is opt-in (a marker in the commit message) because the three legs take 35 to 45 minutes. The plan to kill the sqlite leg never happened; it stayed as the fast one.
Bought. 325 files and 6,058 tests. A change that passes only on sqlite cannot ship; the last full run through the production stack, at release 1142 on 19 September 2026, was 5,873 passed, 99 skipped, 0 failed.
Commit attempts are counted before the COMMIT is sent
Situation. A teacher save returned 200 while the write was already gone: the old shim had reconnected mid-request and discarded the uncommitted transaction. Retries on transient errors could also re-run a handler that had already committed.
Choice. The per-request lease increments its attempt counter before COMMIT goes on the wire, and both retry gates refuse any request whose counter is non-zero. A commit that passes its own deadline is a fault class of its own, "the write may have landed", never retried, and the user is told "may have been saved" rather than the engraved "not saved".
Cost. Some genuinely retryable failures are shown to a person instead of healed.
Bought. A money write can never be applied twice by the retry machinery. "Did anything double-apply?" is one query against the fault log.
Two always-on machines, one process each, a lock for the leader
Situation. One machine, a June out-of-memory event, and background loops that would fire twice on two machines: duplicate cards, duplicate emails. The old answer was a lease in a config table with a 120-second TTL, which is clock comparison across machines.
Choice. Two machines, always on, 2 GB each, one uvicorn process and one pool per machine, and a Postgres session advisory lock on a dedicated non-pool connection (pool recycling would silently drop it) with TCP keepalives, so a crashed leader is noticed in 60 to 90 seconds and a clean deploy hands over instantly. Any error on the ping means "not leader this tick", because skipping a tick is strictly safer than two machines doing the work.
Cost. Two machines' rent, and a connection budget of 20 per app against a 100-connection cluster.
Bought. Split-brain is structurally impossible and there is no clock to compare.
A nine-second watchdog the school owns
Situation. Five requests each held a pooled connection for exactly 600 seconds on a healthy pool (size 9, available 8, waiting 0). Not exhaustion, not the server, not a dead socket: a 10-second TCP user timeout was deployed and the next hang came 34 minutes later with the peer still acknowledging. Nothing below the application could bound it.
Choice. One daemon thread per process, waking four times a second, claiming any call past its deadline, cancelling on the server, then shutting the socket down so the blocked caller returns in milliseconds (close alone: 7 ms, or blocked past 8 s when the descriptor was reused; shutdown then close: 1 ms every time). Deadlines per kind: 9.0 s for a query, 12.0 s for a commit, 3.0 s for a pool checkout so a door can never be pushed past its 20-second window. A COMMIT is never cancelled. Every fault lands in a durable row naming its class, the route, the elapsed time and the pool gauges.
Cost. One more thread and a registry of in-flight calls on every pooled connection; no retry, no override, by design.
Bought. The worst fault fell from 600,106 ms to 9,339 ms, and it is diagnosable afterwards.
The stall loop, and the verdict "size was not the cause"
Situation. With hangs capped at 9 seconds they still came about eight times a day, always on the two largest reads (about 1.3 MB of JSON). A ticket went to the hosting vendor. The shared theory was payload size, so both reads were rewritten into 50-row server-cursor batches of about 53 KB.
Choice. A standalone loop outside the application on one machine: the old large read every 2 seconds under a 9-second deadline and, on a stall, a bare TCP connect, a fresh-connection activity snapshot, a full driver connect, a cancel and a socket shutdown, each timed, with a rolling header-only packet capture (2-minute files, twenty kept).
Finding. The stalls continued on small responses. The first capture showed a 545 KB reply fully delivered and acknowledged within 0.67 s, the receive queue empty, and the shutdown returning all 782 rows with no error: the tail of the last 16 KB TLS record sat decrypted inside the TLS library while the driver polled an empty socket. A known client-library defect, fixed upstream on 13 August 2026.
Cost. Ten days, three replies to the vendor, two throwaway scripts, and a batching rewrite that was not the cure (kept anyway: every round trip now sits under the watchdog). Bought. A one-line driver bump, libpq 18.0 to 18.6, deployed on 18 September. The vendor's 660 ms pause was innocent.
Polling, not push
Situation. The spring planning record chose a poll over WebSockets: small dataset, no extra infrastructure. Then a placement made on one laptop did not show on another open page until reload. The owner: "things should load instantly, I don't want to have to refresh".
Choice. One shared hook that refetches on window focus, on the tab becoming visible, and every 20 to 30 seconds while visible; paused while a modal, an inline editor or an unsaved draft owns the screen; every load carries a sequence number so a stale answer never paints over a newer one. Rolled out to twelve more pages in one sweep, and deliberately left off the doors page (Rule 0), stats, catalogue and rates.
Cost. A tick per open tab, which forced the polled reads to be cheap the same evening: an index turned a 314 ms list into 24 ms, a 1.6 MB tick into 240 KB, and a full-table scoreboard read into one query at 19 to 24 ms.
Bought. No socket infrastructure. Server push stays on the list "if the tick ever feels slow".
Fences in middleware, chunks split by role
Situation. Roles were partly enforced by display-name checks scattered through routers and the sidebar, with "keep in sync" comments in both, and prefix matching that a longer path could widen.
Choice. Three middlewares registered narrowest-outermost: writes blocked for the viewer and web-manager roles, the rentals and web-manager roles each locked to an allow-list matched on path segments rather than prefixes. Name gates replaced by role gates; the last two name lists hoisted into one constants file that both the router and the sidebar import. The front end is code-split on role, not on file size: admin-only and office-only pages load lazily so a rentals, chaser or secretary login never downloads them. 13 of 27 pages are lazy.
Cost. 235 role dependencies to keep honest. A test asks the running application for every route under the debit prefixes and compares each live gate against one written table, proven by adding an ungated route and watching it caught.
Bought. A rename or a second admin cannot silently escape a gate.
Login codes live in the database, not in a process
Situation. The one-time code for the phone app lived in a process-local dictionary. With two always-on machines and no session affinity, roughly half of every login had failed since July, for 252 accounts. It hid for two months because sessions last 120 days.
Choice. A durable store keyed by an HMAC of the code (the code itself is never stored, so nobody can answer "just tell me the code"), attempts persisted too, commit before the email is sent. A test refuses to let any module-level dictionary hold login state.
Cost. One table and one test. Bought. Either machine can answer a login, which is part of access and therefore part of Rule 0.
Own door controllers instead of a bought access product
Situation. A third-party building-access app was in use on all four locations, link-only, with no API. A commercial smart lock was brainstormed twice and never reached code.
Choice. An ESP32 board in parallel with each door's strike, a phone app that authenticates to the board over Bluetooth, and a reverse channel in which each board polls the panel every 3 seconds with its firmware version, radio state and buffered unlock events. The doors never ask the panel before opening. Filing a room booking grants the door itself, 30 minutes either side of the slot, stored in UTC after a summer-time lockout in July.
Cost. Hardware, firmware and an app to maintain, and an accepted risk: rotating the door secrets means reflashing every board and an app release.
Bought. Access that works with the internet down, no vendor between a teacher and a door, and about 60 EUR a month. The third-party app was retired on 9 September 2026.
Bluetooth before Wi-Fi, a bounded wait, never a reboot on failure
Situation. The firmware surveyed access points, waited 30 seconds for Wi-Fi and rebooted on failure, forever, with Bluetooth initialised only after that block. A router outage meant the radio never came up: one door sat green in the panel all day and opened for nobody, and the reboot loop pulsed the strike every 30 seconds at night. The owner, after two days locked out: there is no way that functionality should ever break.
Choice. Firmware 1.2.1: Bluetooth first, a Wi-Fi wait bounded at about 15 seconds, then carry on without a network and retry in the background with a backoff from 5 to 60 seconds (12 scans an hour instead of 720, because the two radios share one chip). Six reviewers found the first draft would have made it worse: advertising during a connection lets links accumulate and the stack refuses to advertise at its ceiling, so three leaked links take a door dark. The phone-side root cause was one line: the app's failure path never closed its link.
Cost. A site visit per door, on 6 September. Bought. "Reachable" and "can open" became two separately measured facts.
Doors that update themselves, with a rollback nobody has to watch
Situation. Firmware 1.2.1 can only be updated from inside the building. An update is a site visit per door.
Choice. Firmware 1.3.0: on its ordinary poll a door asks for a new image, downloads it slowly enough to leave the radio free, checks a signature, a hash and an identity marker baked into the image, installs only when idle (relay off, no phone connected, 30 seconds since the last Bluetooth activity), and on the next boot must prove itself within 20 seconds or the bootloader puts the old image back.
Two flash slots make that possible: about 1,341,500 bytes per image, 68% of a 1,966,080-byte slot. The build script refuses an image whose version, key, identity marker or Bluetooth service no longer match; the flashing script asks the door which door it is. Nothing about how a door opens changed: that code is byte for byte 1.2.1.
Cost. One last trip with a cable, because 1.2.1 cannot install 1.3.0 by itself. The panel side is deployed and the images are built; the bench board goes first.
Bought. After that trip, every update is a button in the panel.
Native forms with a journal, instead of a form vendor
Situation. Twelve rented forms in use, 39 in the account. Signups arrived as emails, a script wrote them to a sheet hourly, a 60-second sync imported the sheet. The account held about 2,579 submissions against a plan cap of 500, over-limit accounts get disabled, and about 880 signed direct-debit and rental PDFs were chargeback evidence, so the archive export was the real deadline.
Choice. A native registration form on the school's own site posting to the panel through a guard stack cloned layer for layer from the pack signup (kill switch, body cap, durable lockout, valves, a captcha that fails closed, honeypot and dwell, 24-hour dedup); the sensitive forms served by the panel itself; everything else archived to object storage (39 forms, 7,469 submissions, 1,425 PDFs) and retired. One journal table records one row per submission inside the same transaction as the domain write; a journal failure fails the submission. The payload is whitelist-built and never carries bank details, identity documents, addresses or network fingerprints. Dropdowns submit catalogue codes, not price strings.
Cost. Seven to nine development days in a two-week window, and one lesson recorded on 18 September: an import is not done until its journal row exists.
Bought. No third party between a customer and the panel, prices from one source, and about 42 EUR a month.
A run is a row, membership is computed, only the stamp freezes
Situation. Monthly direct-debit runs were prepared in a spreadsheet as a dated copy of the whole master tab per run, so "what did we charge this family in March" meant opening one of 345 tabs.
Choice. A run is a row. Which orders land in it is computed on every read from each order's charge day (or a one-month override), so a chip is always the truth about what would be charged right now. The stamp freezes the lines into run items with a snapshot of the whole book, bound to the hash of the preview a human actually read; the bank file is generated from what was agreed.
The scar: the first run created after earlier runs had closed showed 182 rows when 43 orders were due that day, because 129 earlier orders had fallen forward (121 of them already charged) and 28 later ones had fallen backward up to 23 days early. Both sweeps are gone: charging late is a delay the school absorbs, charging early takes money on a day nobody agreed to. A run only ever moves forward on its ladder (open, stamped, sent, booked) by compare-and-set, after a review found two tabs could race one run into "cancelled" over "sent".
Cost. Sixteen days from "a run is a row" to the secretaries running the page; two September runs prepared in parallel with the hand method and compared field by field.
Bought. 360 historical runs since 2019 imported, so the March question is one query, and a bank file whose exact bytes are pinned by a golden test.
Mandate numbers are allocated by the panel
Situation. The mandate series was allocated by scanning the master sheet for the highest number. A signup wrote the sheet first and created the panel row after, and a sheet-side trigger and the panel both allocating "highest plus one" was a duplicate race no lock inside the app could prevent. When the owner protected the master tab on 8 September, every push failed and three people who had signed a direct debit sat where no run would look.
Choice. Allocate from the panel's own series, the maximum across the three tables a number can be stamped on, under the same app-wide advisory lock. Google is off the path. A second signature for an account that already holds an active order adopts that order, reuses the number and audits the re-signature once; an inactive row is never adopted.
Cost. Five tests asserting sheet failure modes deleted rather than rewritten; the sheet-bridging code carries dated delete notices.
Bought. The retirement of the master sheet, while the one pipe that feeds the scheduling tool survives on purpose.
Rental money: three sides of a row, and questions instead of guesses
Situation. The instrument-rental sheet writes deposits, extras and sales beside the rent cell while the bank books one amount; read as money, a month shows far more paid than due. 118 contracts were anchored to debit runs only, 104 to the card processor only, 21 to neither, and ten mandate numbers belonged to a different person in the sheet.
Choice. Every ledger row sits on exactly one side: arrived (payment, bounce, refund, failed attempt, signed so "what came in" is one addition), due components (deposit taken, extra, sale), or beside the month (deposit returned, adjustment, insurance). Whatever was charged is what was due; nothing recomputes a proration.
A sheet row that collides with a ledger payment within 14 days, or a same-amount payment 15 to 35 days later, is not written: one review question is raised, refreshed rather than duplicated on re-run, and signed by its writer so a sweep never closes another writer's question. A mandate with no contract is reported and never written. Debit orders gained a kind, school or instrument, with every school-only lookup scoped, because one bank account can pay both.
Cost. Four review rounds and 26 findings on one importer, whose 147 candidate rows became 4 written payments.
Bought. 1,208 of 1,212 debit payments matched to a sheet row, so the sheet hides almost no money the panel lacks, and about 1,000 open questions that are real questions.
Every one of these follows the same line: refuse to guess where guessing moves money or opens a door, and where the system must be wrong, be wrong in the direction a person will notice.
Bought, rented, built —
Bought, rented, built
What the platform rents, what it owns, and why each item sits on the side it does. Each entry says what the thing does, why it is on that side, and what it replaced. Vendor costs are the school's own; nothing here is a customer amount. Items the record does not settle are left out rather than guessed.
Rented
Fly.io
Two always-on shared-CPU machines, 1 vCPU / 2 GB, one EU region, no volumes; a health check every 10 s with a 5 s timeout; restart on failure, ten times.
Why rented. Memory was doubled in July 2026 to take out-of-memory off the table; a second machine has run since 8 July so one wedged machine is not an outage; the region moved on 30 July to sit beside the database, because two known N+1 pages need in-region round trips (about 2,400 sequential reads on one lane).
Replaced. Free community hosting of the dead first version.
Fly Managed Postgres
PostgreSQL 16.14, 100 connections, UTC: the one production database since 31 July 2026, at schema 162.
Why rented. The previous embedded-replica database wedged overnight and crashed; a watchdog and a self-healing shim were built around it and then deleted. The record argues co-location, not managed-versus-self-run.
Replaced. That database, retired on 14 August 2026; before it, sqlite in the first version.
Tigris (S3-compatible)
Chaser PDFs, task files, the encrypted archive of every generated bank file and the bank's confirmation PDF per run, the nightly dump, and the 647 MB archive of the retired form vendor.
Why rented. A per-machine volume pinned that machine to a region and the other machine could not see it.
Replaced. A volume, dropped on 30 July 2026.
Stripe, two accounts
Hourly rooms, packs as subscriptions, refunds, rental agreements, gift vouchers, registration fees. Webhooks land in the panel; a daily read-only reconciliation compares paid sessions against booking rows over a 7-day window and refuses a false all-clear.
Why two. One account is the rehearsal-room business and is shared with the two web shops; the other is the school. Each is blind to the other's sessions, so every webhook is scoped by a metadata marker and the rule is "never consolidate".
Replaced. The form vendor's payment forms and its response sheet as the record.
Sheets, Gmail, Contacts, Apps Script
One token, five scopes, none of them calendar. Sheets is still the bridge to everything not yet in the panel: the intake sheet, the payments mirror, the secretary's run sheet and the payments source-of-truth sheet (both now written by the panel), the instrument-rental sheets the reconciliation reads. Gmail sends every email, mostly as drafts a human reviews. Two importers run on Google's own scheduler.
Why rented. The school already lives in that inbox, and the bank-side and scheduling-side pipes consume sheets. Turned off where the panel took over: the master debit sheet (8 September 2026) and the pack signup sheet (10 August).
Expo EAS, Google Play, App Store
Builds, signing and submission for an Expo SDK 54 / React Native 0.81.5 app; store submissions are the owner's decision only.
Why rented. The native build pipeline is rented rather than maintaining Xcode and Gradle by hand. Trap recorded: a store version code is burned on upload and never reused.
Replaced. Nothing directly; the app itself replaced the door product and the rented booking form.
GitHub
Four repositories; Pages for the frozen static forms site and for this map, each behind its own domain; Actions for the three-leg suite (opt-in, 35 to 45 minutes), a stability watch every 30 minutes sized to the free plan's 2,000 metered minutes, and a type-check on app pushes. Paused since 18 September; releases since then rely on the local three-leg runs.
Why rented. Free static hosting for pages with no secrets, and CI as the enforced gate for schema drift and the three database legs.
Better Stack
An external monitor on the health route (3-minute checks, a phone alert after about 3 minutes down) since 9 June 2026, and a log-shipper app because platform logs vanish within about 12 hours.
Why rented. It costs nothing and removes the laptop and GitHub from the alert path.
Replaced. Nothing; the log shipper was added after the July outage review as "reliability engineering is standard, not optional".
Cloudflare
One Worker creates checkout sessions for the website's booking form: it holds the payment key, relays the panel's quote and never prices anything. Turnstile guards every public form that writes to the panel. One of the two 20-second uptime probers runs here on the free tier; the other runs from a second vantage point.
Why rented. A static page cannot hold a secret; the paid monitor's free plan floors at 3 minutes. Since app 1.8.0 the app mints its own sessions through the authenticated API and no longer uses the Worker.
MyMusicStaff, kept
Lesson scheduling, attendance, the family balance and lesson billing. No API; one Zapier pipe is its only automation.
Why still rented. The money-movement layer had to exist first; switching it off before the panel owned collection would leave every payment channel without a target. Taken from it so far: mandates and monthly runs, pack tracking, teacher availability, the conduct appendix, the account ledger for rooms and packs.
Zapier, one pipe
A new row on the payments sheet becomes a payment in the scheduling tool. The sheet's 10-column format is a frozen contract; the panel appends exactly once per run with sheet-side idempotency so the zap never double-fires.
Why still rented. It leaves with the scheduling tool. The pack-tracking zaps were retired when packs moved into the app.
The bank's web portal
The panel generates the file (five columns, UTF-8 with a byte-order mark, CRLF, decimal comma, minimal quoting) and the owner uploads it by hand, then uploads the confirmation PDF, which is archived with the encrypted file. Results and bounces come back through sheets and the importers.
Why rented. The owner's end state: "the bank consumes a sheet, and that format stays". No XML generation and no bank API anywhere. Bank details are AES-256-GCM encrypted with the row's identity bound in; no plaintext account number in Postgres.
WordPress on Kinsta, WooCommerce
The school site and the rehearsal-room site; two shops share the room-rentals payment account and their prices are mirrored by hand into the panel's express-rental catalogue.
Why rented. It was already there; the May 2026 move was a hosting change, not a platform change. Deploys by manual dispatch only, four paths, staging untouchable. Two in-house plugins ride on it.
Replaced. The form vendor's embeds on both sites.
Anthropic API
Three call sites: extracting pack screenshots, reading rental-task PDFs, classifying bank-import rows. Uploads are capped mid-stream so an oversized file cannot run up a bill.
Why rented. Classification and extraction, nothing else. Not in the door path, the app, the forms or any money write.
HERE, the national fuel feed, Namecheap
Routing, tolls and live diesel for the delivery calculator; a 30-minute loop caches the price because the regulator intermittently blocks the host's egress. The school domain's registrar and DNS; this map and the forms site are records the owner created there.
Built
Firmware and over-the-air updates
An ESP32 board on each of three doors plus a bench board; 1.2.1 in the field, 1.3.0 self-updating built. Arduino toolchain, one sketch folder per door, a build script that refuses an image whose identity marker, version, key, Bluetooth name or service no longer match.
Why built. Rule 0: the door must open with the internet down and never depend on a vendor. The poll handler is fail-open by design.
Replaced. The third-party door product, about 60 EUR a month, retired 9 September 2026.
Backend and office UI
FastAPI on Python 3.12: 60 routers, 536 endpoints, about 80 service modules. React 19 / Vite 7 / Tailwind 4: 27 pages, no state library by design, six view personas, copy in Spanish and English. Nine modules of the first version are copied into the image at deploy time and are live production code.
Why built. The spring 2026 record: lifecycle events stuck in spreadsheets, a dashboard that did not scale, and a target of about 4,460 EUR a year in retired vendors.
Replaced. The first-version dashboard, several working spreadsheets, and piece by piece the vendors opposite.
Native forms and the journal
Registration and vouchers (a plugin on the school site), the debit mandate and the rental agreement (served by the panel with their own CSP), combos interest, pack signup and booking, teacher availability. Captcha, honeypot, dwell, valves, kill switch, dedup. One journal table; an import is done when its journal row exists.
Why built. The vendor's quota (one form died over quota on 27 August 2026), a free-text field that had collected card numbers, and the wish for the panel to be the record.
Replaced. 39 rented forms (twelve in daily use), about 42 EUR a month.
Runs, the bank file, the mandate series
Encrypted bank details and fingerprints, monthly runs frozen at the stamp with a whole-book snapshot, the bank CSV pinned by a golden-byte test, 360 historical runs since 2019, mandate numbers allocated by the panel since 10 September, a second population for instrument rentals since 19 September. A publisher writes the secretary's dated tab and the one append that triggers the scheduling-tool pipe.
Why built. The approved 28 August design: three architect passes and an adversarial critique. The first run born in the panel went to the bank on 5 September and matched.
Replaced. The master sheet's run tabs and two scripts, retired 8 September.
The debt chaser
An eight-step ladder per debt, comment threads, invoice drafts with a generated PDF in object storage, WhatsApp links, a preview-then-commit import of pending payments, a nightly job that creates exam-fee debts. Pack prices read from the catalogue since 16 September.
Why built. The chase lived in emails and a sheet; the ladder makes the next action explicit.
Replaced. A sheet-driven chase.
Booking, packs and the door key flow
Website and app bookings land in one row shape through one webhook; filing grants the door 30 minutes either side of the slot, in UTC; packs run as a debit order or a card subscription with sessions materialised per month; a cancel more than 48 hours ahead refunds itself; teacher pricing is decided from the role on every request; prices reach the sites through two public feeds with 10-minute caches and 30-day last-good fallbacks.
Why built. One record for every booking regardless of channel, and the door grant follows the booking so nobody hands out keys.
Replaced. The rented booking form (July 2026), the door product, a sheets-and-glue pack tracker.
The agreement composer
Since 15 September the rentals role composes the lines (catalogue instruments with tier and coverage, manual lines, conditions the client cannot edit), saving mints a single-use 30-day link, and the client sees a locked table and supplies payment, ID photos, consent and signature. One pricing path serves every agreement.
Why built. A blank public form produced wrong prices and re-typing.
Replaced. A blank native form, itself the replacement for the rented agreement (352 archived submissions).
The long-term ledger and its importers
Since 20 September: every payment and bounce by channel, four report-first idempotent importers keyed by source, a review queue of typed questions, a client history with settlement, an overview beside the month view, and statistics. On go-live about 2,400 ledger rows, about 4,100 contract-months reconciled (about 3,500 of them OK), about 1,000 open questions.
Why built. The owner's rules: whatever was charged is what was due; a month is paid, failed or not recorded and never two states; the sheet is master for contracts "until it falls apart". The first importer's review showed "the GO tab means it went to the bank" was false, which is why every importer reports before it applies.
Replaced. The billing sheet's comments as the only history, and hand reconciliation against the card processor.
Doors, bookings, a teacher side
Expo / React Native: doors over Bluetooth, hourly and pack booking with its own checkout since 1.8.0, my bookings with cancel and reschedule, a teacher side (availability painter, combos, instruments and skills, proposing a student), five languages with 637 keys enforced before any build. Versions 1.6.0 to 1.8.3 shipped between 6 and 14 September.
Why built. Rule 0: the door path is never a ride-along change and is tested on a real door with the build users install.
Replaced. The door product and the rented booking form.
Onboarding and conduct signing
An eight-step card created atomically with the teacher; a versioned code of conduct (twelve sections, four appendices), one signature bound to a hash of the exact bytes on screen; documents typed by magic bytes and stripped of location data, readable only by the owner's own login with an audit row first.
Why built. The old form asked for a signature on five pages and was never finished; the owner wanted the rules contractual and a checklist that drives the office to the next action.
Replaced. An unfinished rented form and paper PDFs from 2017 and 2021.
The tooling around the database
Advisory-lock leader election, a per-request lease, the 9-second watchdog, a fault log with an activity snapshot, a nightly in-app dump with a verify gate and an age on the health route, a generated baseline with a drift check, a constraints file pinning every transitive dependency after an unannounced framework major slipped through, and a stall loop that runs outside the app.
Why built. Each piece is the answer to one recorded incident, listed in the decision log above.
The small pieces
Two WordPress plugins (catalogue prices overlaid on the site's tables at render time, purging the host's caches on change, with one option that restores the typed values byte for byte; native forms carrying no secrets), the checkout Worker, two Apps Script importers, a listener that reads one WhatsApp bug-report group into the task board, and this page.
The line that decides it: the door must never depend on a vendor, so the controllers, the firmware and the app are owned. Money must be auditable in-house, so the ledger, the runs, the bank file and every importer are owned, and every money write leaves a row. Everything else is rented while it is cheaper to rent: the database, object storage, email, sheets as the bridge to the bank and to the scheduling tool, app signing, captcha and paging.
The line has moved in one direction only: booking off the form vendor in July, pack tracking off the glue in August, registration, mandates, vouchers and rental agreements off the form vendor in August, then in September the master debit sheet and the mandate numbers into the panel, the door product off, and instrument-rental money into the ledger. Still rented on purpose: the scheduling tool for lessons, the one pipe into it, and the bank's portal.
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
249 contracts across 4,140 reconciled 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. Since 20 September 2026 every payment and bounce since 2023 sits in one ledger of signed integer cents (about 2,400 rows), each row keyed to its source and marked with how sure the importer was. A bounce can never erase a payment that happened, and a month that does not add up becomes a review question rather than a guess.
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.
Partly there since 20 September 2026: the rental half moved first. Every long-term contract now has a ledger of its payments and bounces since 2023, a client history with settlement, an overview of every contract including the returned and sold ones, statistics by year, channel and instrument family, and a review queue that raises the months that do not reconcile. The shop's side of the catalogue is still ahead.
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.
Partly there since app 1.8.0 (12 September 2026): the app now carries monthly packs (a five-step signup, priced by the server) and a teacher side; lessons, the rental and payments are still to come.
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.
Partly there since September 2026: a teacher logs into the app as herself and paints her own availability, claims instruments and skills, keeps her combos and proposes a student; the code of conduct is signed on a personal link, and a new teacher is onboarded through an eight-step card. Attendance and the list of the students she teaches are still ahead.
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.
Partly there since 20 September 2026: for rental months the reconcile sweep already raises one typed question per month that does not add up, and never asks a person twice. The student side is still a plan.
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.
How work ships 12
How work gets discovered and shipped
One owner, an office of a few people, and AI coding agents doing the building under written rules. The method is the same for a one-field change and for a four-slice feature, and it is written down because two assistants from different vendors share the repository and neither is allowed to guess. The worked example below is one 25-hour stretch, 19 to 20 September 2026: nine production releases, five of them one feature.
Brief
Principles, verbatim where the wording matters.Reconnaissance
Read production as it really is. Write nothing.Findings
Every table, in the browser, before code.Plan and slices
Refusal list at every step.Build
From a complete spec, no conversation.Adversarial review
Numbered findings, replayed on real data.Tests
sqlite, Postgres, Postgres through the lease.Release
One owner per release; both machines read back.Checkpoint
Counts, verifications, what was not built.Spoken, principled, short
The owner states requirements as principles, usually out loud: at bedtime, in a voice session, or dictated while driving. Where the wording matters it is transcribed verbatim into the project memory and the wording becomes the spec; the debit permission split of 7 September is carried in a router docstring as the owner's exact words.
The rental reconciliation brief arrived in three pieces over one day: the outcome at bedtime (every payment and bounce, a client history, an overview, "propose the best view"); the rules while driving ("whatever we charged is what was due"; cash and transfer are under 2% of the money and trusted from the sheet); then deposits, sale arithmetic and a standing instruction: findings, plan, build, deploy, report, and ping when the findings are ready. Two corrections from the same week are rules now: the smallest change that makes the thing work, and check the archive before claiming data is missing.
Read production, write nothing
Nothing is built until the sources have been read as they really are. For the reconciliation: four independent reader agents, one source each (the billing sheet with its cell notes, the debit side, the card processor, the panel's own tables), every database read inside a transaction that is rolled back, the processor read with list calls only, 50 minutes, zero writes. A fifth pass built a month-by-month matrix.
What it found, in numbers: 252 real contracts and 30 empty shells; 2,388 contract-months, 391 not OK of which 217 real disagreements; ten mandate numbers that belonged to a different person in the sheet; 130 bounce rows over 57 mandates; three account numbers and one card number in free-text cells, masked in every file and listed for deletion. And that the panel's own month table was a copy of the sheet, not an independent source, so the report cites it but never decides by it.
A page in the browser before code
The synthesis becomes a report in the repository and a page the owner reads on his phone. His spoken decisions answer the report's open questions, and only then is a plan written, listing "decisions needed from the owner before slice 1" by letter.
The plan is one page: what exists (verified read-only), the model (one new table, no schema churn elsewhere), reconciliation in order with a refusal list at each step, the UX, and the slices. Each slice is a spec by the planning model, a build by a builder, two adversarial reviews, the Postgres legs, a deploy.
Small, ordered, and one that was not in the plan
The reconciliation shipped as five: the ledger, review queue and importers (release 1142, 19 September 16:52); the debit sheet's own run tabs as a second source (1143, 01:18 the next morning, hardened in 1144 at 01:35); the client history; the overview; the statistics.
Slice 1b did not exist in the plan. Slice 1's production run left 474 months reading "unpaid", because the reconnaissance had found 177 collections that existed only in the debit sheet's tabs. The tab importer then proved those 177 were re-listings, crossed-out rows, space rentals and orphan mandates: the sheet hid almost no money, and the unpaid months became genuine questions rather than a missing import.
Report first, apply on purpose
Every importer is a thin command-line wrapper over the application's own service module, "so what this prints and what the app would do can never drift". A bare run writes nothing and prints every row it would touch; apply is "the version you type on purpose"; one transaction, and a single error row refuses the whole thing; every row carries a source key so a re-run recognises its own.
The refusals: a debit or processor month is never written from the sheet, because that is the same money twice; a sale price is never written, only asked; no secret on a command line; the account-number column is replaced with a stub the moment the response is unpacked; the card processor is read with GET only; never in CI, always against a local shadow first.
Thresholds are explicit constants: a bounce matches its payment within 45 days; a sheet row and a ledger row are one collection within 14 days; a run within 31 days before a contract's start books to the start month.
Adversarial, with the real data
A build is never reviewed by its builder. Reviewers are separate agents with the spec, the diff and, where possible, the real data; their job is to refute, and each numbered finding is fixed or rejected with a reason.
The instrument-sheet importer's first version rested on "the GO tab is what went to the bank". The first reviewer replayed it against the real 296 tabs and showed the axiom false: one tab of 27 rows held 18 from one run and 3 from another. Four rounds and 26 findings later, 147 candidate rows became 4 written payments and 18 to 20 questions. The pages went through three rounds and 25 findings, including two card-processor object-id leaks caught before release. Elsewhere that week: 22 findings on the conduct signature, both reviews rejecting the first round of the debit rules. The first round rejects more often than not. That is the point of it.
The suite, three times, with the counts written down
sqlite as the fast full run (5,875 passed at release 1143), a throwaway Postgres 16 with a template cloned per test, and Postgres through the production lease middleware. The counts go into every checkpoint: full lease legs of 5,456, 5,570 and 5,873 passed, 99 skipped, 0 failed across the worked example. The front end adds TypeScript, the Vite build, lint, and a vocabulary check that fails the build if a page label drifts from the service's three words for a ledger row.
Tests never touch a real service: outbound is denied in two layers after a suite once sent about 165 real emails to reserved example addresses. "A per-file stub fixture is not protection. Autouse or nothing." CI runs all three legs but is opt-in (35 to 45 minutes); on 19 and 20 September no CI ran, so every release relied on the local legs, recorded with counts.
The same-minute check and a read-only look at both machines
Standing permission since May 2026: commit, push and deploy when green, run production writes the owner has described, then report; ask first for restarts, bulk deletes, store submissions, customer-facing emails and publishing. One assistant owns a release end to end and re-reads the hosting platform's release list in the same minute as the deploy, a rule that dates from 14 September, when the same runtime was deployed twice four minutes apart. A release with an unfinished slice in the tree ships from a clean worktree of the commit.
After deploy, on both machines: schema version, new tables and constraints, changed routes answering with the expected shape, health, and for a data change a dry simulation (a run dated 1 October takes 39 instrument rows, none unassigned). The stall loop outside the app dies with every deploy and is restarted after each.
The record every session loads first
A dense paragraph per release in the shared instructions file: release number, time, whether the schema changed, what shipped, which rules now live in which service, what was verified on both machines, test counts per leg, whether CI ran, who built and who reviewed, the production data result with counts, and a "not built" line. The file was 1,017 lines on 20 September.
Beside it: a session handoff with a constitution, a playbook and a history, written by a shared end-session command; and a project memory of topic files under an index kept below 150 lines (cleaned from 153 to 126 on 14 September), where the owner's corrections are recorded with the date and the words. The rule that ties them: update the document that owns a fact; never create a second memory.
One file for any AI reading cold
A single file at the repository root describes the five parts (the panel, the doors, the app, the websites and forms, the money spine), how they fit, how the estate is operated, the rules and the open items. Its rules section has ten entries, from Rule 0 to "verify claims against code, not memory". The instructions file points to it first.
An orientation audit by the second assistant on 13 September read the estate cold, ran 101 backend tests, 53 app helper tests, 61 firmware host checks and 194 catalogue checks, wrote two fault-injection tests, touched no runtime and no production data, and concluded "a rewrite is not indicated" with five verified open findings, each listed until proven resolved.
Judgement on one model, typing on another
Since 11 September the planning model does the thinking, planning, review and verification; builder agents execute settled specs from a definition with the house rules baked in: stop on ambiguity, run the legs with counts, no commit, push or deploy, the final message is the report. A builder starts with none of the conversation, so a spec must be complete.
Anything where the next step depends on what the last one revealed stays on the planning model: production diagnosis, design that emerges during the work, money-adjacent decisions, the final check against the served artifact. It is a division of labour, not permission to fan out; the owner pushed back on twenty agents for five small changes. A second assistant from another vendor reads and writes the same instructions, handoff and memory; overlapping work goes to separate worktrees and neither reverts the other's unfinished changes.
The worked example, in numbers
- Releases
- 9 (1136 to 1144) in about 25 hours; schema 160 to 162
- Reconnaissance
- 4 readers plus 1 synthesis · 50 minutes · 0 writes
- Examined
- 252 real contracts, 30 shells · 2,388 contract-months
- Importers
- 4 command-line wrappers over 4 service modules, all report-first
- Importer review
- 4 rounds, 26 findings · 147 candidate rows to 4 written
- Pages review
- 3 rounds, 25 findings · 2 processor-id leaks caught before release
- Ledger
- 2,409 rows after slice 1 · 3,471 OK months · 1,011 open questions · then 1,208 of 1,212 debit payments matched to a sheet row
- Live book
- overview about 200 ms, statistics about 100 ms · batched reads of 50 rows about 53 KB, responses under 65 KB
- Full lease leg
- 5,873 passed / 99 skipped / 0 failed at release 1142
- Records
- instructions file 1,017 lines · memory index under 150 lines · the feature's memory file updated 13 times in 25 hours
The method in one sentence: read before building, report before applying, refute before shipping, and write down the counts so the next session does not have to trust anyone's memory, including its own.
Since 3 September 13
What changed between 3 and 20 September
Seventeen days between the first publication of this page and this update. Grouped by theme, dated, with the numbers as the release record states them.
Firmware that fixes itself, and a panel that knows who opened what
- 1 Sep One door sat green in the panel all day and opened for nobody. "Online" only meant "reaches the server"; the radio side was invisible.
- 3 Sep Firmware 1.2.1 written: Bluetooth before Wi-Fi, no reboot on failure, a backoff from 5 to 60 seconds, advertising re-armed every 10 seconds. Schema 130 adds radio telemetry to the doors table, all nullable so an old door reads "unknown", never "fine", and "can open" as a fact separate from "reachable".
- 3 Sep App 1.4.2 to 1.4.4: the door says why it did not open, in five languages, with a diagnostic line that identified a scan-start failure in seconds after two days of guessing; iPhones stop waiting 3 seconds on a local-network call they can never make. Submitted to both stores.
- 3 Sep Login codes move out of process memory into a durable store keyed by an HMAC of the code. Half of logins had been failing across two machines since July.
- 6 Sep All three doors on 1.2.1, flashed one at a time and verified by standing at each with a phone. App 1.6.0 and schema 139: who opened the door. The phone queues one report per unlock attempt (5-second deadline, at most 20 per flush) and the server matches it to the door's own event within 20 seconds, in its own transaction after the heartbeat has committed, so no SQL error can ever fail a door.
- 7 Sep Firmware 1.3.0 and schema 143: self-update over the air with signature, hash and identity check, install only when idle, automatic rollback if the new image does not prove itself within 20 seconds; a firmware section per door in the panel. The version that enables remote updates cannot itself arrive remotely.
- 7 Sep The door poll leaves the thread pool for its own capacity limiter off the event loop, with one checkout attempt instead of three at 5 s each. Measured: a poll that used to leave the machine unable to answer anything for 14.73 s now returns in 5.02 s with twenty probes answered throughout.
- 9 Sep The third-party door product retired; its line came off the cost table and the handbook's ritual was rewritten around the school's own app.
From ten-minute hangs to a one-line driver bump
- 5 Sep Two independent 20-second probers from two vantage points, because the laptop watcher sampled every 10 minutes and the paid monitor's free plan floors at 3.
- 6 Sep A probe that reads like a person: a student card's reads through the ordinary request path, counts only. The health route had stayed green through two afternoons of timeout toasts. The same afternoon's N+1 (one teacher lookup per contact) became one query.
- 7 Sep Schema 141, a fault table: every fault with its real class, the route, the elapsed time, the machine and the pool state, written through a bounded in-memory ring drained by its own loop. Reads retry once on a transient fault, GET only, never on door paths. Cost to a door poll: 1.6 microseconds.
- 8 Sep The 9-second watchdog. The worst fault fell from 600,106 ms to 9,339 ms. A ticket to the hosting vendor with the evidence.
- 9 Sep The killed connection keeps its deadline class instead of a bare "connection is closed". The students list drops 29 columns nobody reads: 459 KB to 289 KB on the wire.
- 16 to 17 Sep The size experiment: the scoreboard's 750-by-72 read became one query at 19 to 24 ms; the two large reads moved to 50-row server cursors of about 53 KB under one deadline. Schema 157 stores the server's activity view on the fault row, taken from a fresh connection before the cancel, about 40 ms.
- 18 Sep The verdict. Stalls continued on 53 KB responses. A packet capture showed the whole 545 KB reply acknowledged within 0.67 s and the tail of the last TLS record sat decrypted in the library while the driver polled an empty socket: a client-library defect fixed upstream on 13 August. Release 1133 bumped the driver build (libpq 18.0 to 18.6), verified on both machines; full legs of 5,395 and 5,378 passed on the new driver. The vendor's 660 ms pause was innocent.
- 18 Sep From 01:12 UTC the repository's CI was refused on a billing hold, so releases 1132 to 1144 shipped on local three-leg runs, each recorded as "no CI run".
A signed code of conduct, eight-step onboarding, the app's teacher side
- 6 Sep Personal availability links stop leaking: the never-expiring token was in every teacher response; one helper strips it everywhere except the audited mint.
- 7 Sep Schema 142, the code of conduct signed natively. Publishing makes an immutable version with a SHA-256 of the exact bytes; the personal token travels in the URL fragment and is redacted from the access log; documents are typed by magic bytes, stripped of location data, readable only by the owner's login with an audit row first; a storage outage costs the drawn picture, never the signature. Two hostile reviews, 22 findings.
- 8 to 10 Sep The text: twelve sections, four appendices, one signature where the old form asked for five; Spanish and English line-parallel (44 headings, 123 blocks); the owner's edit pass made forty changes, including replacing an illegal sanction with due process citing the collective agreement's own fault grading. Version 1 published after the owner's edit pass of 9 September; a live end-to-end signing test on 10 September was purged afterwards.
- 9 Sep House style: no em dashes and no emojis anywhere a person reads. 145 files swept; language flags drawn as inline SVG, which also settled Catalan.
- 10 to 11 Sep Schema 145 to 147: teachers fix their own combos from the personal link; a runtime migration against live Postgres found to be the one path no test leg covers; availability strings unified from three dialects through one parser.
- 6 to 14 Sep App 1.6.0 to 1.8.3, the teacher side: a profile tab, an availability painter (tap for a 30-minute slot, drag to paint, a 15-minute stepper), combos, instruments and skills, proposing a student (the server caps 30 per teacher per day); 79 plus 56 new strings in five languages.
- 14 Sep Schema 149, eight-step onboarding created atomically with the teacher. Validated with 19 browser scenarios, the real 148-to-149 upgrade and all three CI legs; the 53 existing teachers deliberately not backfilled. On 15 September the teacher card gained a conduct row with the signature line and three document chips.
- 18 Sep Release 1131, an outgoing teacher can be inactive: door access revoked at once and never re-granted by reactivation; nothing about their students or history changes.
One board, four doors, cases that come back
- 14 Sep Releases 1088 and 1089: one service behind four doors (a secretary's add, a teacher's suggestion, a registration whose course is a group, an internal interest form with captcha, dedup and a kill switch that replaced the rented form). A student is resolved by email or phone tail, never by name; every flag change writes an audit row with its source. Ten independent reviewers.
- 14 Sep Schema 150, the case card: contact, snooze to a date that wakes by itself with no cron, retire with a reason, a combo picker where the seat count is information, not a gate. Every write carries the revision it read; a stale one answers 409.
- 15 to 16 Sep Schema 153 and 154: changing a signed-up course needs a reason of at least 10 characters and writes the update, an audit row, a comment and the original in one transaction; a placement brings the student back to the finalise lane with an onboarding kind decided once.
- 16 Sep Schema 155, a group's place is a studio plus a room, refused with 422 otherwise; the teacher is picked from 48 active teachers, not typed.
- 17 Sep Signups-by-course normalised from 52 stored strings to 27 catalogue codes.
- 17 to 18 Sep Create, edit and disband opened to secretaries; leaving a group returns the person to the board and undoes the open return card; the last name-based gate on the page replaced by roles.
- 18 Sep Search by anyone and anything (debounced 250 ms; digits match a partial phone), live probe 16 to 49 ms.
The audit, in-app signup, prices from one catalogue
- 4 Sep 352 archived rental contracts moved to object storage; native agreements get a deterministic PDF built from the very form page the renter ticked.
- 9 Sep A room change is one dropdown, not a reschedule: no notice check, the door grant follows the building only, a failed rebuild is said out loud.
- 10 Sep Mandate numbers into the panel; the master sheet retired and protected on 8 September.
- 12 Sep Schema 148 and app 1.8.0: teacher prices from one catalogue; the app quotes and mints its own checkout server-side with the role from the token; pack signup in the app as a five-step wizard. A review round caught an empty date and a bare start time underpricing; both now 422.
- 12 Sep A seven-lens audit: an app cancellation stayed in the office queue and filing it re-created the door grant (now 409); reschedule approval ignored pack rules; the automatic refund never reached the ledger; the debit-pack dry run still asked Google for a retired sheet; the webhook signature parser kept only the last signature, which would have failed half a secret rotation. All fixed. CI's parity timeout 40 to 90 minutes.
- 15 Sep Schema 152, the rental agreement composer: three designs, two judges, four builders, three reviewers. Every catalogue tier now carries a deposit (30 gaps filled, 87 tiers on the form).
- 16 Sep The chaser reads pack prices from the catalogue instead of four typed constants; the owner moved the primetime supplement in the panel the same afternoon and every surface followed on its next load.
- 18 Sep Schema 160, a cancellation lives on the booking row with its next step and acknowledgement, first in red on the dashboard, struck through on the bookings page. The first real in-app cancellation had produced a board card nobody saw for eight days.
The panel owns the monthly run
- 4 Sep Schema 131, a run is a row: runs, frozen items, one-month overrides; membership computed, only the stamp freezes, bound to the preview's hash. Release 999 crash-looped on this migration on one machine while the other kept serving.
- 4 Sep The bank file re-derived from eleven accepted files, all 370 rows. Schema 132 adds an encrypted envelope per order under its own derived key, bound to the row id, because the panel held fingerprints and not one recoverable account. Two hand-made September runs backfilled from the bank-accepted bytes (124 frozen lines), without which stamping would have re-charged 100 of 117 active orders in silence.
- 5 Sep The sweeps removed: a run is exactly the orders dated that day; the open run went from 182 rows to 41. A sheet column the importer had thrown away (the scheduling-tool family link) imported: 429 linked, zero conflicts.
- 6 Sep Schema 135 and 136: one next-charge date per order, the OK tick per month, and the secretary's ledger: 356 dated tabs with 12 header variants became rows, cross-checked against 159 twin tabs, 149 exact. A runs page: a rail by period, provenance badges, a ceremony page for a new run, a search across every run and year. 24 new test files.
- 7 Sep The page belongs to the secretaries; create, stamp, advance, cancel, overrides and the bank file stay the owner's. A test asks the running application for every route under the debit prefixes and compares each gate against one written table. A run publisher writes the secretary's dated tab and the one append the scheduling-tool pipe consumes.
- 19 Sep Release 1136: pack equals the date; a re-signed mandate adopts its active order and is never a second row. Release 1137, schema 161: instrument rentals as a second population, 44 orders created and 37 items backfilled into the 1 September run, which now reads 198 rows with zero warnings. Release 1141: the next-run announcement, one admin-set date with a 140-character note that may not carry an account number.
A ledger, a review queue, and the answer "the sheet hides almost no money"
- 19 Sep, 12:55 Reconnaissance: four readers and a synthesis, zero writes. Until then the memory entry read "briefed, NOT STARTED".
- 19 Sep, 16:52 Release 1142, schema 162: the ledger with three sides of a row, the review queue signed by writer, three importers. Production run 17:00 to 18:10 in the documented order: 2,409 ledger rows, 1,117 months corrected and 347 added, 3,471 OK months, 1,011 open questions. Two review rounds, three fix rounds, 341 tests in the slice.
- 20 Sep, 01:18 Release 1143: the debit sheet's 296 tabs as a second source (194 run tabs, 2,614 rows in runs), 4 payments written, 1,208 of 1,212 matched; the client history with a running balance and settlement; the overview beside the month view; statistics with every figure a filter or a link, about 200 ms and 100 ms on the live book; card-processor ids redacted from every review answer at every depth. Four review rounds and 26 findings on the importer, three rounds and 25 on the pages.
- 20 Sep, 01:35 Release 1144: one gate hardened (a same-figure collision beside a row is always drift, never a write), re-run on production with 0 written and the questions re-worded to cite the payment beside them.
Smaller fixes with a number on each
- 4 Sep The debit form asks who the lessons are for; every outbound surface enumerates its columns explicitly, one test per surface, so the new column reaches no bank file.
- 5 Sep A read-only households card: 59 clusters where one family is two accounts. The merge button held back after a review returned three blockers and fourteen majors.
- 7 Sep WhatsApp reaches the right number: the child branch, and invisible direction marks stripped from pasted numbers. Across 5,482 values, 608 links changed and none of them was deliverable before.
- 8 Sep Notes during signup, before an account exists. The review reproduced twice that the first version resurrected merged duplicates. Seventeen tests, each confirmed to fail against one of nine mutations.
- 17 Sep Schema 158, a minor's guardian reaches the card: the website form had stored the guardian in the student columns. Both are written now; the seven minors since 20 August backfilled with an audit row each.
- 18 Sep Releases 1125 to 1128, vouchers: a send the mail provider accepted but the call raised on had gone out twice; each leg now stamps "attempted" before the call with a five-minute grace. 153 archived purchases imported as redeemable rows; the forms table pages itself near the bottom instead of "showing 50 of 1,236".
"I don't want to have to refresh"
- 16 Sep The shared live-refresh hook on twelve more pages, reviewed page by page by a second agent (five findings fixed before the ship). The reads made cheap alongside: an index on the action log (216,308 sequential scans reading 1.9 billion tuples against 12 index scans, on 11,700 rows; 314 ms to 24 ms), the chasing page's tick from 1.6 MB to 240 KB, the framework in an immutable vendor chunk (230 KB, 74 KB gzipped), the students list served as raw JSON (360 to 190 ms) with gzip level 9 lowered to 6.
- 17 Sep The uptime Worker had written its state 4,320 times a day against a free tier of 1,000, so the incident log went blind from about 07:40 UTC each day; now at most 144 heartbeats a day.
- still on the list The door-user list at 1.1 s (door code, Rule 0), stats at 0.8 s (admin only, on demand), server push if the 20 to 30 s tick ever feels slow.
The bug inbox, the bank exports, six personas
- 3 Sep The mobile menu's bottom half was never reachable (427 px below the fold on a 412-by-828 viewport); the drawer scrolls as one region.
- 4 Sep Bank transfers filing: four bank exports, one drop zone, every row routed by rule. Of 342 rows, 182 filtered out, 102 already present, 55 new, 3 held for a human tick; apply refuses unless the plan still hashes to what the preview showed. No AI, deterministic rules.
- 4 and 15 Sep A school / rentals switch, then six view personas for the admin. They grant nothing: the token is unchanged and the only direction is narrower.
- 6 Sep Schema 137, a bug-report listener: the school reports app bugs in one WhatsApp group; a linked-device service reads that one group and forwards each message with its media through a durable on-disk queue to an inbox page, one click to a task. 37 listener tests.
- 13 Sep The whole-estate overview written for any AI reading cold; the memory index compacted from 153 to 126 lines.
Seventeen days, and the shape of them: every incident became a rule, and every rule became a test. The page above is the system as those rules left it on 20 September 2026.