The template never learns your name

A trainer builds a workout once and runs it for a room of people who are not the same. The data model that survived is three tables with a one-way street between them, and the model it replaced was the obvious one.

4 min readMicroGym · Workout stage

In the gym app a trainer builds a workout once: sets, rounds, the movements in each set with their reps and weights. Then the same workout is run by a room full of people who are not the same. One has a shoulder that rules out overhead work. One gave an intake answer that says she should do the regression of a lift. The template describes the intent. It cannot describe the outcome for each person, because there is a different outcome for each person.

The obvious way to handle that is to copy. Make a per-member version of the workout when the session starts, edit the copy, run the copy. It is obvious because it is what you would do on paper. It is also how the app did it for its first year and a half.

The copy that was deleted

Until 15 October 2026 there were two tables for exactly this: a per-member workout template and a per-member movement-settings row, created at session time, edited by the trainer, reset by a dedicated function. They worked. They were also a second place where the truth about a workout lived, and the second place is where the drift goes. Edit the template after the copy is made and the copy does not know. Fix a movement's defaults and every copy keeps the old ones. The tables were removed as dead code because by then nothing wrote to them, and the reason nothing wrote to them is the design below.

Plan, delta, outcome

What runs now is three tables with a strict direction between them. The template is the plan: workouts, sets, the movements in each set. Nothing in that chain has a member column or a session column, so a per-member version of a template is not something the schema can hold. The substitution is the delta: one row per member per movement, saying use this other movement instead, or use this variation of the same one. The performance is the outcome: what was recorded for that member in that session in that round, with the resolved movement and a pointer back to the original. The three are joined when the grid renders and never merged on disk.

Where the delta comes from

When a session is created the app reads the template, reads the gym's substitution rules, reads each member's most recent completed assessment, and writes a substitution row wherever a rule's answer matches a member's. A rule is narrow: one question, one answer value, one movement, one replacement. There are no injury flags and no strength tiers in the live path. Both existed, both were retired in September 2026 in favour of the single question-and-answer rule, and both are still in the tree as functions nothing calls. The row written at session creation is not scoped to the session. It is the member's default for that movement, and it carries forward. A trainer can write a one-session override by hand, and the override wins over the default when the grid renders.

Resolving at the grid

The tablet in the room shows the template's rows down the side and six members across, paging when there are more. For each cell it asks one question: does this member have a substitution for this movement, preferring a session override, falling back to a default. If so, the cell shows the replacement's name and takes the replacement's properties, timed or counted, per side or not. If the replacement is the same movement with a variation label, the cell says so rather than pretending a swap happened. The movement recorded against the performance is the one the member did, with the original kept as a back-pointer, so the history reads the way the room did.

What it costs

Three views instead of one, and the separation is held by structure, not by a check. No trigger or constraint forbids session code from writing to the template tables, and no policy does either. What forbids it is that the template has nowhere to put a member, and that every write to those tables lives in the workout builder. On 23 October 2026 that was true: fourteen write sites, three files, all in the builder. It stays true only as long as the next person reads this before adding a fifteenth.

The second cost is in the delta. Because the auto-created row is a member default rather than a session row, it is written once and not revisited. A newer assessment answer for the same member and movement does not replace it; the guard that stops duplicates also stops updates. That is a product decision wearing a bug's clothes, and it is written here so it gets decided rather than discovered.

Why it belongs on this page

The anatomy row for this stage said two views. The source says three, and it said the delta was per session when it is per member. Both corrections came from reading the schema, the service that writes it and the component that reads it inside the same hour, which is only possible when the same person owns the migration and the grid. The design is good. The description of it was less careful than the code, and the code is what gets to be right.

More notes
  • The webhook that credits twiceA payment processor delivers every result at least once. The interesting engineering is in what happens when your side fails halfway through.
  • A ledger you can edit from a route handler is not a ledgerWhy every movement of value on the payments platform goes through a Postgres function, and what it costs to keep it that way.
  • Four rows that were not theirsHardening multi-tenant row-level security in four reversible phases, verified by impersonating a member and counting what they could see.
  • The bug inside the fixA payout race, the fix for it, the bug inside that fix, and why it is the best argument I have for one person owning the whole path.
  • The sale that arrives with no referrerA creator shares a link on Instagram, the buyer taps it, installs the app and purchases. Nothing in that chain carries the creator's name across. Here is what does.
  • The minimum that belongs to someone elseHeld commissions are released to creators once a brand's payout minimum is met. Group the money by creator, the obvious way, and one brand's minimum ends up holding another brand's money.
  • The commission that must not mint twiceA buyer pays and the platform owes a creator a commission. Between those two facts sit a colluding pair, a call that arrives twice, and a cart with three items on one payment.
  • The rule that has to be written twiceFirestore security rules do not cascade to subcollections. Forget that in one place and a single query returns every private message on the platform.
  • Thirty days in the ledgerSplitting a payment at charge time is simpler and wrong: a refund after the creator is paid is a clawback nobody enjoys. Holding the money creates a different set of problems, and each piece of machinery around the hold answers one of them.
  • Arbitrary but consistentA gym's assessment answers become rules that swap an exercise for a member before a session. Two rules can disagree about the same movement. The code says who wins, and the comment admits how.
  • The number that ducked every thresholdA deposit arrives from a browser carrying two numbers. The platform scored one of them and charged the other, and every limit it had was measuring the wrong figure.
  • The second one tells you the shapeHigh-risk merchants get dropped by processors, so the platform cannot be welded to one. The interface that makes a processor swappable could not be designed before the second one existed.
  • A key you did not makeA device fingerprint is a hash the browser computes and the server has to take on faith. The useful question is not whether to trust it, but what a server does once it accepts that it cannot.