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.