Changelog

What we shipped, when. Newest first.

Provider delivery retries now preserve unknown outcomes

25 July 2026

What's new

Behind the scenes — uncertain messages cannot be sent twice automatically

  • SMS, email and Telegram delivery attempts now distinguish a confirmed failure

from a provider outcome that is still unknown. An unresolved attempt is held for review rather than automatically contacting the recipient again.

  • Delivery completion now checks the database response and the conditional row

update. A provider acceptance without durable local confirmation is reported as indeterminate, never as successfully sent.

  • Replaying a demo-suppressed owner Telegram notification remains suppressed

instead of being counted as delivered.

  • When an owner notification reaches one recipient but another result is

unknown, the automation records the mixed outcome visibly and does not finalize the step as fully sent.

  • No database migration, environment change, feature-flag change or real

communication is required by this correction.

Behind the scenes — automation and reminder delivery closure

  • Immediate automations now stop before contacting a provider when their

required run or recipient claim cannot be recorded. Mixed recipient outcomes remain truthful, and an unresolved recipient can no longer be hidden by a successful one.

  • Built-in event, trial and class fallback emails now inspect the real provider

result and reuse a stable business-occurrence key. A thrown request is held as unresolved rather than reported as sent or automatically retried.

  • Transactional email outbox rows are claimed before provider delivery. Only a

confirmed provider rejection can return to the retry queue; unknown outcomes are held for review and shown in platform health.

  • Task-reminder digests now have a proposed durable four-outcome authority.

Proven failures retry under their original identity, newly due tasks stay in a separate digest, and unresolved Telegram outcomes cannot be reclaimed by the next cron tick.

  • The delivery caller registry is now generated from individual AST call sites

rather than a hand-maintained file list. It distinguishes keyed and unkeyed wrappers and fails when a sender is added, removed, moved or loses its claim evidence.

  • The task-reminder migration was applied and rehearsed against local Docker

only. Nothing was pushed, no remote SQL was applied, no flags changed and no real communication was sent.

  • The final independent review also brought accident-report attachments through

the shared email boundary with a stable report key, and made Telegram text-plus-attachment sends report partial or unknown outcomes instead of claiming the whole bundle was delivered.

  • Long task-reminder digests are now split into stable Telegram-safe chunks, and

failed digest groups cannot be split by the database claim limit.

  • The caller ratchet now freezes 110 call sites, including Telegram documents

and the direct Resend SDK boundary, and its claim evidence is isolated per callback rather than by display label alone.

  • Closed the final scheduled-email delivery ambiguity: attachment and batch

timeouts now remain unresolved instead of becoming retryable failures, every recipient reservation and terminal write is checked, and only confirmed sends create a sent activity entry.

  • Trial lifecycle emails now use one durable occurrence per club, trial cycle

and stage. A provider timeout can no longer cause the next daily run to send the same nudge again, while a pre-provider claim failure remains safely retryable. Trial expiry and suspension writes are conditionally verified, and an unknown delivery or failed state write turns the cron heartbeat red.

  • The weekly video-review digest now pages every source read, reuses one stable

weekly key per recipient, and records sent, suppressed, failed and unresolved outcomes separately. Database errors, capped reads and an unrecorded audit result now turn the cron heartbeat red instead of producing a believable empty report or allowing a duplicate retry.

  • This digest correction needs no database migration, environment change,

feature-flag change or real communication.

  • Scheduled SMS and Telegram jobs now keep a provider-call exception visibly

unresolved and terminal. Only work proved to have stopped before the provider remains eligible for automatic retry.

  • Owner Telegram fan-out now reports post-provider exceptions as indeterminate

for both keyed and legacy unkeyed callers, rather than presenting an unknown delivery as an ordinary failure.

  • Action Brief email and Telegram delivery now use the same four-outcome model:

delivered, deliberately suppressed, proven failed, or indeterminate. Database errors, thrown settlement writes and zero-row updates cannot produce a false delivered result, and the Action Brief health heartbeat turns red while an outcome is unresolved.

  • Action Brief's expanded ledger statuses require the additive

action-brief-four-outcome-01.sql corrective after action-brief-01.sql. It has been rehearsed on local Docker only; no remote SQL was applied, no feature flag changed and no real communication was sent.

  • An independent Phase 5 code review found no P0 or P1 issue. Its final bounded

P2 bookkeeping family is now closed: late email-outbox and immediate enrolment-invite success prove same-club history/token updates and surface returned errors or zero-row writes without resending an accepted email.

  • Staff now see a warning when an enrolment invitation's provider outcome is

known but its local history needs review. Delivered, queued and suppressed outcomes remain distinct; the public trial response stays deliberately opaque. Phase 5 is code-closed locally but remains undeployed.

Register ownership checks, proven end to end

24 July 2026

What's new

Behind the scenes — every register assignment now has one club owner

  • The proposed database protection for register coaches and volunteers now checks the whole

relationship, not only the person. A class assignment must bind a lesson, its declared club and its member or staff user to the same club, including when an older row is edited by changing only the lesson. This closes an edge where individually valid IDs could otherwise be combined across two clubs.

  • The proposed register snapshot routine independently binds every assignment and displayed person

to the club being requested. It remains an unchanged (club, date) → snapshot interface, but is now an all-or-nothing migration with exact body, role and configuration checks.

  • Deployment order is explicit and enforced: the reviewed Phase 2D function-authority correction

must be applied and verified before the snapshot routine is replaced. Applying in the wrong order stops with no partial change.

  • A new rollback-owned local database rehearsal proves the wrong-order refusal, idempotent reapply,

same-club happy path, historical cross-club omission, insert/update refusal, exact authority and zero residue. The repository's privileged-function baseline now includes the reviewed proposed definition. Nothing was pushed or applied to a remote database.

  • The owner-only Phase 2D database-authority rollout now has a guarded one-command helper. It runs

the complete local checks, confirms the production target, securely prompts once for the database password, applies both reviewed units in order and only reports success after an exact nine-out-of-nine production postflight. A check-only mode cannot connect to the database.

  • Phase 2D has now been applied by the owner and independently checked against the frozen

production manifest: all nine postflight rows passed. Future database functions default to the reviewed restricted authority, existing application function callers retain exactly their declared access, and no function body or customer data changed.

  • The remaining register ownership protections now have the same guarded owner workflow: one

command checks the reviewed files, displays the count-only historical diagnostic, applies the write and read protections in order, and accepts completion only after a unified nine-row postflight. It never repairs or exposes historical assignment data automatically.

  • The owner has now applied those register protections. All nine production checks passed and

there were no historical invalid assignments to repair. The deployed register snapshot remains service-only, has its current body fingerprint recorded, and the next access-control phase can begin.

Behind the scenes — staff actions now follow each club's effective permissions

  • API-token controls, automation run controls and scheduled-report test sends now enforce their

declared permissions on the server. Lower-access staff can no longer reach these operations by calling the route directly.

  • Sensitive grading, event, competition and lesson-plan exports now use explicit view/export

permissions and record an access audit. Competition files and rosters are bound to the active club before their data is loaded, with the same not-found response for missing and other-club records.

  • Custom staff permission grants and denials now carry through older route helpers and secondary

page scopes as well as the main route guard. Payment-provider connection remains owner-only and cannot be delegated; the established admin threshold for changing an event grading target is preserved.

  • Automation control denials retain their clear forbidden response instead of being remapped to a

generic server error, and API-token metadata is restricted to the active club as well as the current staff user.

  • Multi-club staff can again vote on a feature request through their real club-access record; a

stale column name had previously denied that legitimate path.

  • These changes are complete on the local branch but have not been pushed or enabled in

production.

Behind the scenes — safer public links and scoped API credentials

  • Public invite, cancellation, calendar, payment and portal links now share one

non-cacheable, no-referrer and no-index response policy. Missing throttles on event validation/cancellation, external-entry cancellation, portal activation, trial cancellation, calendar feeds and email unsubscribe have been filled before their privileged database reads.

  • Email unsubscribe now distinguishes the authoritative preference write from

its activity log: a failed preference write cannot claim success, while a committed opt-out remains successful if only telemetry needs operator attention.

  • Coach API credentials now carry a 180-day expiry and explicit scopes for the

two real voice-note consumers. Scope mismatch and authority-read failure stop closed; removing a staff member's club access invalidates their token immediately, and plaintext is still returned only once when a credential is minted.

  • Newly issued event waitlist claim links now include a signed issue time while

existing links continue to work. No age limit has been switched on in this compatibility stage.

  • A source-derived registry now covers 22 credential families and 54 route

methods. It corrects stale route classifications and fails the build if a public-token route is omitted, loses its limiter or a raw-token family has no compatibility plan.

  • Five proposed database units have been double-applied and behaviorally

rehearsed on local Docker: one for API-token expiry/scopes, plus additive hash stages for examiner, enrolment, calendar and trial-cancellation tokens. They preserve every existing raw link, reject duplicate source credentials before indexing and repair a drifted hash on reapply; no remote SQL was applied and the later hash-first code/UX cutover remains a separate reviewed step.

  • That compatibility cutover is now authored locally. Examiner, enrolment,

calendar and trial public checks prefer the stored hash while old links and a code-before-migration deploy remain supported. Unexpected database errors stop safely rather than being mistaken for a bad link, and revoking any of the four credentials also clears its hash.

  • Raw-link cleanup is deliberately not claimed complete. Existing examiner and

calendar links can still be re-shown to authorised staff, and trial transaction/recovery work still needs the raw value. Those final rotation and recovery changes remain a separately reviewed phase before plaintext columns can be retired.

  • The independent hardening pass closed the compatibility edges before release:

examiner throttles now use a one-way token hash on every public method, multi-method routes are checked handler by handler, and only an error naming the exact not-yet-deployed hash column may use the legacy lookup.

  • Hash-stage migrations now repair direct hash tampering, reject wrong

same-name indexes, and have bounded lock waits. A count-only production preflight reports duplicate groups, rows to backfill and relation size so the owner can choose a quiet apply window; the postflight verifies each exact trigger and index rather than accepting an object by name alone.

  • The Phase 2D source-caller evidence was regenerated after the route edits.

The inventory now contains 200 calls: the new server-only provider receipt and Telegram token authorities introduce no browser RPC caller.

  • The independent review follow-up tightened the compatibility matcher so a

different missing column whose name merely ends in token_hash cannot enable legacy fallback. The final plaintext-cleanup checklist now also requires the staging hash triggers to be replaced or removed before raw values are cleared, preventing those triggers from clearing the authoritative hash.

Behind the scenes — register deletion remains recoverable

  • A follow-up review found that the already-applied register ownership trigger

could block deletion of a member or staff user if an older malformed assignment linked them to a lesson from another club. The intended ON DELETE SET NULL branch ran after the lesson check, so the cascade could fail before reaching it.

  • An additive corrective is now authored and rehearsed locally. It admits the

all-null cascade only when the assignment's club and lesson stay unchanged, while continuing to reject cross-club people, dual-person rows and attempts to repoint an orphan. It changes no customer data and is not applied remotely.

  • That corrective now refuses to replace an unknown deployed trigger function,

enforces the expected owner, and verifies the trigger's exact function binding and update-column set. Its rehearsal deliberately installs unknown drift first and proves the migration stops before replacement.

  • Standing function-authority checks now use the current reviewed function body

after later migrations, while the original Phase 2D postflight remains immutable deployment evidence.

Behind the scenes — reliable inbound SMS and Telegram

  • Inbound SMS is now designed around each club's own signing key and forwarding

destination rather than one deployment-wide identity. The club in the webhook address is only a selector; the matching club key must prove the request before any message or member record is touched.

  • SMS and Telegram now share a durable provider-receipt model. An immutable

provider event is claimed before business work, redelivery cannot duplicate messages or activity, and a real processing failure asks the provider to retry instead of reporting a false success.

  • A provider event ID reused with different payload content is now rejected

before effects, and SMS distinguishes the webhook event ID from the message ID used for delivery-status correlation.

  • Telegram account linking now consumes the one-time link and creates the chat

link in one database transaction. Competing uses cannot both succeed.

  • Creating replacement Telegram link tokens is atomic at the database boundary

across email invites, staff QR links and the member portal. If that database replacement fails, the previous unused token remains valid. Once it commits, a later email, QR or response failure is reported honestly and does not pretend the previous link still works.

  • Provider-triggered replies and staff forwards have their own idempotency

record. A definite provider rejection can retry; an unknown network outcome is held for review rather than risking a duplicate message.

  • Outbound SMS no longer reports success unless both the provider acceptance

and local message row are durable. An unreadable demo-club state now fails retryably instead of being mislabelled as a successful demo suppression.

  • SMS now supplies its own provider request ID, Telegram validates the complete

success envelope, and both providers have bounded request times. Missing or ambiguous acknowledgements remain visible as unknown rather than being recorded as sent.

  • Telegram setup now verifies its database writes, bot commands and registered

webhook, serialises competing connection attempts, cleans up an ambiguously registered provider webhook, reports outbound/inbound/destination health separately, and refuses to overwrite a working bot through a direct API call.

  • Demo owner Telegram copies are now recorded as suppressed rather than sent or

mislabelled as missing configuration. The release postflight also verifies the exact receipt fingerprint and immediate identity constraints required by webhook deduplication.

  • Request bodies are bounded, delayed SMS status events cannot move a delivered

message backwards, and the platform health page distinguishes processed, duplicate, failed, stuck and unknown inbound work.

  • The database authority, preflight, postflight and two-session concurrency

rehearsal are complete locally. Nothing in this section has been pushed, applied remotely or used to send a real message.

Behind the scenes — an unknown send is no longer treated as a known one

  • A message the provider may already have accepted is never sent twice. When

an SMS or Telegram request timed out, the outcome was recorded as an ordinary failure. Failures are retried, so a message the provider had in fact accepted could be delivered a second time to a real member. A timed-out send is now carried through every step, fan-out and queue as its own explicit "unknown" outcome: the delivery is left open for a human to resolve and is never retried automatically.

  • **A household message that reached one parent but not the other is reported

honestly.** Telegram sends to every linked chat for a member. Previously, if one chat delivered and another timed out, the whole step was recorded as sent — which allowed a retry that would have re-sent to the parent who already had it. Any unknown recipient now makes the whole step unknown.

  • A Telegram account can no longer be disconnected because of timeouts. Three

consecutive delivery failures automatically unlink a chat. A timed-out send was counting towards that total, so a perfectly valid account could be unlinked after three slow responses. Only a genuine, provider-confirmed rejection counts now; an unknown outcome leaves the count untouched.

  • A step is only reported as sent once the record of it is safely stored. The

automation runs view could show a message as sent, or a scheduled send as succeeded, when the final write recording it had not completed. Every such place now checks that write and reports "unknown" instead of claiming success.

  • A send is no longer attempted when its safety record cannot be written. If

the database could not record that a send was about to happen, the message was sent anyway with no duplicate protection. With protection enabled, the send now stops instead. When the feature is switched off, the previous non-blocking behaviour is unchanged.

  • Operator note. Nothing was pushed, applied to a remote database, enabled or

used to send a real message. There is no migration and no flag change: this is application logic plus tests, and behaviour with DELIVERY_IDEMPOTENCY off is deliberately unchanged.

Behind the scenes — the same unknown-send rule everywhere

  • Telegram now reports a timed-out send the same way SMS already did. One adapter still

discarded the "we don't know" signal, so a Telegram timeout was recorded as a plain failure — and failures are retried. It is now carried through like every other channel.

  • A club's own Telegram send can no longer disconnect a parent's chat through timeouts. The

staff "send to one member" path counted an unproven send towards the three-strikes rule that automatically unlinks a chat. Only a genuine, confirmed rejection counts now.

  • **Scheduled sends, the older automation queue and the message-flow worker all follow the same

rule.** Each of them previously turned an unknown outcome into an ordinary failure, which meant the message could be sent again. Each now parks the item for review instead: no delivery timestamp, no automatic retry, and a clear reason recorded.

  • A message that reached one channel but not another is no longer reported as fully sent. If an

email went out and the text message outcome was unknown, the operation stopped being reported as a success. The unknown now wins, on every dispatcher.

  • A deliberately suppressed message is no longer counted as a failure. Demo clubs and

non-production sends are a correct outcome, not an error, and all-suppressed jobs now finish cleanly instead of looking like provider problems.

  • Operator note. No migration, no flag change, nothing pushed or applied remotely, and no real

message sent. Where a database column had no honest "unknown" value, an absent record plus a precise reason is used rather than a misleading "failed". One known limit is recorded in the delivery caller census: unknown deliveries are safe (nothing re-sends) but are not yet listed on an operator screen.

A simpler after-apply database check

23 July 2026

What's new

Behind the scenes — a clearer owner verification

  • The Phase 2C after-apply check is now a single read-only file that can be pasted

directly into Supabase's SQL Editor. It returns one overall verdict and six named function checks, each with a plain-English reason if something is not configured as expected. This changes no data or permissions; it makes the owner-run verification harder to misread before the next phase begins.

  • The owner completed that check successfully, closing the remaining Phase 2C

database permissions work in production.

  • Phase 2D has opened with a read-only production function inventory and a

mechanically generated application caller index. No permissions have changed in this opening step; the evidence is collected first so legitimate features are not accidentally blocked.

  • That production inventory has now been fully classified. The next database

permissions package is prepared and locally rehearsed: it makes future app-owned functions private by default and removes unnecessary direct access from 22 existing internal functions while retaining every reviewed staff workflow. It remains unapplied and has no live effect until the owner runs the two SQL units and receives an all-PASS after-check.

  • A permanent source check now also stops any newly added privileged database

function until its callers and exact role access have been reviewed, so the same broad-access pattern cannot quietly return in a later migration.

Paid event bookings — recoverable by design, and a safer repair

22 July 2026

What's new

Fixes — a successful payment always completes its booking

  • A rotated booking link can no longer strand a paid booking. Reconciliation used to start

by looking the invitation up by its link token. If that link was regenerated between starting a payment and the payment settling, reconciliation gave up — and Stripe could report the payment as done while the booking never completed. Payments now carry the invitation's permanent identity, and every path (the browser confirmation and the background safety net) completes the booking by that identity, not the link. As a side benefit, the one-time booking link is no longer stored at the payment provider at all.

  • A transient database hiccup mid-settle is now retried, not treated as final. A momentary

database error while confirming a booking used to be collapsed into "invalid link" or "event not found" — reasons the payment processor treats as permanent, so it never tried again. Those transient faults are now clearly separated from genuinely-missing records: a real hiccup is retried automatically (the processor redelivers and it self-heals), while a genuinely invalid link or missing event is still handled once and not looped. No customer-facing detail, link or payment reference is ever exposed in logs.

  • Marking the invitation "used" can no longer silently fail. If the final step that marks an

invitation used doesn't confirm, that is now a retryable outcome that heals on the next attempt — the booking confirmation email still fires exactly once and is never lost or duplicated on the retry. In the rare case where the invitation record has genuinely been deleted after the payment, the system records an operator-visible reconciliation note (in the existing payments health surface) rather than retrying forever. Throughout, no money, booking, invoice line or email is ever duplicated by a retry.

Behind the scenes — a safer historical repair and a stricter check

  • The one-off repair for any historical bookings left in the old state was **rewritten to be

safer**: it targets exactly the affected invitations (by permanent identity, one row each even where old data is messy), runs entirely as a single self-checking transaction that rolls itself back on any integrity problem, uses a throwaway in-transaction working set instead of leaving a copy of member data behind, and only ever touches the "used" flag — never a price, booking, status or waitlist. A read-only diagnostic remains the required first step. Authored and rehearsed against a local copy of the platform only; nothing has been run against live data.

  • The Phase 2C database-permission postflight (the after-apply verification for the proposed

service-role-only tightening) is now signature-exact: it checks each of the six functions by its precise definition, so a missing function shows up as a visible failure and an unexpected duplicate can never be mistaken for the real one. No permission change; nothing applied to live.

Final closure — the same path, made retry-proof end to end

  • Starting a booking payment twice can no longer create two payment attempts. The booking's

payment is keyed on the invitation's permanent identity, so a double-tap, a refresh, or an automatic retry while starting checkout resolves to the *same* payment instead of opening a second one.

  • **If your booking link changes while you're answering its questions, you're asked to refresh

rather than paying against a stale link.** The answers you give to a booking's requirements are saved against the invitation's permanent identity; if that link is regenerated at the same moment, checkout now stops and asks you to reopen the up-to-date link instead of quietly losing your answers or taking a payment that can't be matched to your booking.

  • A failed invoice write during settlement is now retried, not reported as done. Completing a

paid booking writes an invoice; if that write doesn't confirm, the booking is no longer marked settled — the payment processor redelivers and it heals, and the retry never duplicates the invoice, the money or the booking.

  • The payments health page names each reconciliation note for what it is. Operator notes used

to all read as "a provider payment missing from the ledger". They're now labelled honestly by kind — a provider payment awaiting its ledger entry, an invoice still queued for automatic line-item repair, an invoice the automatic repair has given up on (flagged for a person to fix by hand), or a paid booking whose invitation record was removed — each with a plain recommended action. Notes that need a person are now visually distinct from the ones that will heal on their own, and any unrecognised note shows as "manual review required". No member personal details are shown.

  • A paid booking payment can only be started by the club the booking belongs to. As a

defence-in-depth tightening, starting a paid event-booking payment now confirms the booking invitation belongs to the club making the request before any card payment is set up, so a payment can never be taken into one club's account for a booking that belongs to another. Free open-to-group bookings are unaffected.

Faster, risk-based development checks

  • Reorganised the permanent developer instructions into a short always-on

contract plus focused frontend, server, money, database, testing and release guides. The underlying tenant, money, database-authority and delivery protections remain in place, while ordinary UI and copy work no longer carries the same process burden as a payment or database-authority change.

  • Added verify:quick, verify:server, verify:critical and verify:release

commands so checks scale with the risk of a change. The full test, lint and production-build battery remains mandatory at phase and release boundaries.

Paid event bookings — final correctness pass

A closing pass over the paid event-booking path to remove the last silent-failure and duplicate-payment gaps.

  • A free event can no longer be paid for by accident. Starting a card payment for an event

that doesn't require payment is now refused outright — no charge, no half-made booking. Free bookings continue to use the normal (no-payment) booking flow.

  • A booking link that isn't yours is rejected the same as an invalid one. A booking link is

checked against the club making the request before anything else, and a link belonging to another club is refused with the exact same message as a bad link — it never reveals whether the place is used, booked or paid.

  • **A momentary database error while starting a booking is treated as an error, not as "no

booking".** Every check made before a card payment is set up now stops safely on a real database fault instead of mistaking it for "link invalid" or "nothing booked" and letting a charge through.

  • A duplicate or mismatched payment is now surfaced for review, never silently accepted. If a

booking that's already paid receives a second, different payment — or its invoice was settled by a different payment, or is in a state that can't be settled, or has gone missing — the system records a clear note on the payments health page for an operator to review and refund by hand. It never auto-refunds and never quietly acknowledges a second charge, and a retry never creates duplicates.

  • An invoice that can't be settled is flagged, not reported as done. Completing a booking only

accepts an invoice actually settled under that payment; a voided, cancelled or wrong-payment invoice is recorded for review rather than treated as complete.

  • A booking's invoice is never left half-made by a database hiccup. If looking up who to bill

fails, the booking is retried later rather than an invoice being created without a billing household.

Paid event bookings — final edge-case closure

  • An incomplete event-payment request can no longer create an untraceable charge. Event

payments now require both the event and its booking-link credential before Stripe is contacted, including when a signed-in staff member makes the request. A malformed request is refused rather than creating a payment that the background confirmation path cannot match back to a booking.

  • A paid event must have a real server-side price. If an event is marked as requiring payment but

its stored price is zero or missing, checkout stops before Stripe and asks the member to contact the club. The browser can never choose an arbitrary amount for a misconfigured event.

  • Two confirmation requests racing each other preserve the winning booking's payment truth. The

losing request now rereads the booking's paid state, invoice and payment reference under the same club before doing anything else. The same payment remains an idempotent retry; a different payment becomes an operator-visible conflict; a cancelled row is never treated as a successful claim.

  • Members are no longer promised an automatic fix when a payment needs human review. Retryable

database interruptions still show "Payment received" and continue automatically. A terminal payment conflict instead shows a separate, calm review message with the payment reference and a clear instruction not to pay again. No provider, invoice or database migration is required for this pass.

Paid event bookings — payment identity closure

  • A cancelled booking with previous payment history can no longer be charged again automatically.

Cancelled rows deliberately retain their invoice, payment reference and paid amount for accounting. Checkout now recognises that history and asks the member to contact the club instead of creating a new charge that could overwrite the earlier payment trail. A clean cancelled attempt that never reached payment can still be retried normally.

  • A pending booking's payment reference can no longer be overwritten by a competing payment. The

final paid transition is conditional on both the booking state and PaymentIntent identity. If two confirmations race, the loser rereads the winner and succeeds only when it proves the same payment; a different payment or changed booking state is recorded for operator review.

  • **Legacy paid bookings without a stored payment identity are no longer silently matched to whichever

payment arrives next.** They are surfaced as a manual reconciliation item so the provider and invoice records can be compared safely. Transient read/write uncertainty remains retryable, and no automatic refund is attempted.

  • Cancelled-row reuse now has the same guard inside the database lock. A proposed, owner-apply

claim_event_slot corrective refuses to reactivate a cancelled row carrying an old invoice, payment reference, paid amount or unknown payment state. It preserves every accounting field, returns a stable refusal code, and has been applied/reapplied and rehearsed on local Docker only. No remote SQL was run.

Register coach selection

  • Selecting a coach or volunteer now responds immediately and survives a response retry. The core

register assignment is returned before the best-effort activity log runs, and an exact duplicate insert is reread as the already-saved assignment instead of being shown as a false error. Coach, volunteer and attendance-state controls now show their own busy state and keep a full mobile tap target, preventing uncertain double taps while the request is in flight.

  • A coach or volunteer can only be added if they really belong to this club and this register.

Adding someone to a class now checks the person against the club before anything is saved, and accepts exactly one person per assignment. A member who has left, or who isn't marked as a coach (or volunteer) for the register, is refused with a clear message instead of being written to the class. A person from another club is refused with exactly the same message as an unknown one, so nothing can be learned by guessing. If the check itself can't complete, the assignment is refused rather than saved on an unverified person, and the class date must be a real calendar date — "31 February", a date with a time stuck on the end, or anything else malformed is rejected. Removing or restatusing an existing entry is unchanged, so historical records stay editable.

Grading-day save feedback

  • Short connection blips no longer flash a worrying pending-save warning at examiners. Notes are

still stored safely and retried immediately, but the warning now waits ten seconds before appearing. If the retry succeeds during that grace period, no banner is shown; a genuinely unsent note remains visible with its existing retry control until the server confirms it.

Member portal Pulse

  • Class feedback now follows the member selected in the household switcher. Pulse prompts are

loaded only for that active member, so changing between siblings cannot show another member's sessions or leave the previous member's prompt on screen. When the selected member has more than one request waiting, a small count badge shows how many there are.

Register — the right people, and only this club's people

  • A class register can no longer show a name from another club. The links between a class and

the coach, volunteer or member assigned to it record only who they point at, not which club they belong to. Adding someone is now checked (see above), but an assignment written before that check existed could still name a person from a different club — and the register would have displayed their name and email. Both ways the register is read now prove every person against the club that owns the class before showing anything: a member must belong to that club, and a coach or volunteer must genuinely have access to it. Anything that cannot be proven is simply left off the register — the record itself is untouched — and a coach who legitimately works across two clubs still appears in both. If the check itself cannot complete, the register refuses to load rather than showing an unverified name.

  • Operator note. A read-only count of any affected historical rows is available at

scripts/check-lesson-staff-person-integrity.sql (counts only — it deliberately returns no names or email addresses), and two reviewed database files are prepared and locally rehearsed: one stops malformed assignments being written by anything at all, the other applies the same club check inside the register's own database routine. Neither is applied and neither changes live behaviour until the owner runs them; the register is already protected without them. Nothing existing is deleted or rewritten by either file.

Member portal Pulse

  • The "waiting" count on the feedback card now stays accurate after a refresh. The badge showing

how many class feedback requests are still outstanding could drop to zero — or hide itself — once the page quietly refreshed its data in the background, because the requests just answered were taken off the total twice. The count now falls as each card is answered and holds steady across the refresh, so it always matches what is genuinely still waiting for the next visit. The three-cards-per-visit limit is unchanged.

Behind the scenes — database access tightening independently re-verified

21 July 2026

What's new

Security — proposed database tightening passed an independent adversarial review

  • As part of the ongoing security hardening programme, a set of nine proposed database

permission changes (tightening which system roles can read or change invite tokens, grading correction records, invoices, portal sessions, sent communications, form responses, event bookings and member records directly) went through a full independent adversarial review before anything is applied.

  • The review re-checked every claimed usage against the current code — important

because recent work moved several event-booking writes behind properly guarded server routes, which actually strengthened the case for the tightening. Two documentation gaps and one subtle rehearsal defect were found and fixed; the changes themselves were confirmed safe.

  • Crucially, the things members and clubs rely on were each proven to keep working with

the tightening in place: public form submissions, logged-out event booking, the member portal login flow, staff calendars and registers, invoice processing, and communications history. These were exercised for real against a local copy of the platform, not assumed.

  • Nothing has changed on the live system yet. The changes remain proposals awaiting a

scheduled maintenance application, with step-by-step pre-checks and post-checks prepared.

  • A follow-up review round the same day closed one further gap the first pass had missed

(a rarely-used second layer of database permissions — per-column grants — is now explicitly cleared and checked, not assumed), and produced a single consolidated apply-day runbook so the whole change can be executed and verified from one document.

  • A second follow-up hardened the proposals further: every one of the nine changes now

refuses to apply at all — rolling back completely — if it finds the database in an unexpected protective state (row-level security switched off), rather than proceeding and quietly leaving club-to-club data separation weaker than intended. The reviews also demonstrated concretely what that refusal prevents, so it is protection with evidence, not caution for its own sake.

  • Now applied to the live system. All nine tightening changes were applied to

production and verified end-to-end (every check passed: the nine tables present, row-level security on across the board, no leftover public or per-column access, and the invite-token and grading-correction rules confirmed exactly as intended). Direct database access for the public and signed-in-browser roles is closed on these tables; the app itself is unaffected because it has always used the privileged server path.

  • The next phase (authored, not yet applied) tightens three internal database routines so

that only the trusted server can run them: the class-pack credit/invoice writer, the member rank/stage change, and three "current stage/programme" lookups. Today a signed-in browser (and, for the lookups, even a logged-out request) can call these directly; afterwards only the server can, closing a same-club staff bypass of the rank-change permission and a cross-club leak of a member's stage. No behaviour changes for anyone using the app normally — the class-pack maths and its no-double-charge protection are proven unchanged.

  • That same phase was closed out with three further safeguards (all authored, none

applied): those routines' internal name-resolution setting is pinned to the safe form; a reviewed "who may run each sensitive routine" record now covers them; and an automated check fails the build if anyone ever re-opens direct access to them, adds an unexpected role, or quietly drops one from the reviewed list. An independent review passed with no issues; the changes are ready for the owner to apply, and nothing has been applied yet.

  • A final hardening pass tightened those same three proposals further (authored, not

applied): each now refuses to apply — rolling back completely and naming the offender — if it finds ANY unreviewed system role already holding direct access, rather than quietly revoking only the roles it expected and leaving an unknown one in place. A real-database rehearsal proves the refusal, and the safety check itself now leaves the shared test database exactly as it found it (the previous version could reopen access it had just closed). The recorded fingerprints of each proposal are now automatically checked against the actual files so they can never silently drift. This latest pass has not yet had its own independent review, and nothing has been applied to the live system.

Fixes — a paid event booking always shows as booked

  • Fixed an issue where a member who paid for an event on their second attempt (after a

first payment method failed) could still appear as "Invited" / "Pending" to staff, even though they held a confirmed, paid booking. Staff could have re-invited or chased someone who had already paid.

  • Booking truth now always wins over a stale invitation across the event manage page and the

calendar event panel: a member with a live booking shows their real state (e.g. Booked · Paid or Waitlisted), and the resend/revoke controls are withdrawn for anyone who already holds a booking. Members marked Late or No-show — and waitlisted members — no longer slip back into the invited list, and the cancel-notification count no longer double-counts them.

  • Behind the scenes, a successful payment now marks its invitation used by the invitation's

permanent identity rather than a link that can be regenerated, so a re-sent link can no longer leave the booking looking unpaid; and if it ever can't, it says so instead of pretending it worked. An operator diagnostic and a rehearsed, owner-run repair are provided to tidy any historical bookings from before this fix. No prices, invoices, refunds or waitlist positions are affected.