Safeguarding & Consent¶
Last reviewed: 2026-08-04
This page maps the safeguarding, guardian and consent subsystems as they exist in
code. It was written by reading the services, controllers, routes and the ~30
related tables in database/schema/mysql-schema.sql — this subsystem previously
had no documentation at all, which meant anyone working from the docs concluded it
did not exist. The source code remains authoritative.
Everything here is tenant-scoped unless stated otherwise.
Why this page exists¶
Timebanking frequently involves people who cannot, or should not, transact unsupported: minors, adults with care needs, and members who need a coordinator to act with them. The platform has substantial machinery for this. It is spread across four subsystems with different owners and different maturity levels, and they are easy to mistake for each other.
Guardian relationships¶
🔴 UPDATE 2026-08-07 (guardian redesign, phase 5): the two "guardian" systems are ONE system now. Staff-recorded guardian arrangements live in
account_relationships, marked byproposed_by_user_id NOT NULL, at tier 0 —SupportTiers::resolve()of their empty grant isnoneon every capability, so an arrangement still grants nothing. The historical warning below is preserved because its lesson still applies to copy: an arrangement (a record) and a tier grant (a power) are different things even inside one table, and conflating them in wording is a safeguarding error.
safeguarding_assignmentsis now a read-only archive. Its rows were copied intoaccount_relationshipsby2026_08_07_000001_migrate_safeguarding_assignments_to_relationships(pair conflicts skipped and logged), and its trigger-protected event trail was deliberately never rewritten. Nothing writes to either archive table; the only remaining writer of the OLD table is the supersededSafeguardingService::recordConsent(), which still has no callers.
1. Staff-recorded guardian arrangements (in account_relationships)¶
The general-purpose safeguarding relationship: guardian = parent_user_id,
supported member = child_user_id, proposed_by_user_id = the staff member,
staff_notes, tier-0 permissions, unique per (guardian, member, tenant).
State model (GuardianArrangementService::stateOf()): pending awaiting the
member; active = consented (approved_at); pending + declined_at =
refused; pending + withdrawn_at = withdrawn — a member's "no" is
deliberately NOT status='revoked', which is the staff exit. Every
transition appends to account_relationship_events (append-only,
DB-trigger enforced). Staff-proposed rows are excluded from every
linked-accounts surface and mutation path (SubAccountService filters
whereNull('proposed_by_user_id') in listing, approve, revoke and
updatePermissions) — they are seen and answered only through the
safeguarding screens, and a guardian cannot grant themselves tiers on one.
- Created by staff, not by members.
POST /v2/admin/safeguarding/assignments, gated byAdminSafeguardingController::requireSafeguardingStaff('manage'), which admits admin tiers,broker, and holders of thesafeguarding.managepermission. A member cannot create one. -
Consent belongs to the ward. The ward sees their own arrangements at
GET /v2/safeguarding/my-guardiansand consents atPOST /v2/safeguarding/consent-to-guardian, which is the only writer ofconsent_given_at. A guardian consenting on the ward's behalf is refused, and there are tests for that boundary.Until 2026-08-05 this column had no writer at all —
SafeguardingService::recordConsent()had zero callers — so the admin "consented wards" count was structurally always zero, and the ward was never shown the assignment despite being notified about it. If you add another consent-bearing column, check something can actually write it.🔴 The endpoints alone did not fix it. When they were added, no frontend called them, so a ward still could not see or agree to an arrangement — the same defect one layer up. The UI landed later the same day: a "Guardian arrangements" section in
SafeguardingTab.tsxlists the ward's arrangements and carries the consent action, covered by tests inSafeguardingTab.test.tsx. An API with no caller is not a fix; check the screen exists.🔴 And a screen in ONE frontend is not a fix either. The React section shipped first and the accessible (GOV.UK) frontend had nothing — on the frontend most likely to be used by the very people these arrangements are about. Parity landed the same day:
/{tenantSlug}/accessible/settings/guardians(settings-guardians.blade.php,SettingsAuthParity::settingsGuardians), HTML-first with plain form POSTs and no JavaScript, linked from the settings hub, in the a11y scan's member-page list, and covered byAccessibleGuardianArrangementTest. When you add a member-facing capability, build it in both or record why not. -
Revocation is a soft delete (
revoked_at), so history survives. - Create and revoke both write an
activity_logrow with actor and IP. - It is a record, not a capability. No authorisation path anywhere consults this table — a guardian does not thereby gain the ability to act for, message for, or transact for their ward. It exists to be reported on and to inform staff.
- Surfaced in the broker and admin safeguarding dashboards.
A ward may agree, REFUSE, or WITHDRAW (2026-08-05)¶
The table originally held one ward-facing column, consent_given_at, so the only
action available to the subject of an arrangement was to agree — revoked_at is
staff-only. That is not consent, and withdrawal was impossible.
- Columns:
consent_declined_at,consent_withdrawn_at,ward_response_reason. Mutually exclusive in practice; each transition clears the other two, so the row always states one current position. - All three responses go through
GuardianArrangementService::respond()under a row lock, with one transition table (ALLOWED_FROM). Withdrawal requires a prior agreement. Both frontends call it, so they cannot diverge. - A reason is offered and never required. Requiring somebody to justify refusing a safeguarding arrangement is pressure to consent. Both UIs say so.
safeguarding_assignment_eventsis the append-only trail — action, actor and actor role, reason, IP, user agent — protected by BEFORE UPDATE / BEFORE DELETE triggers raisingSQLSTATE 45000. NoteTRUNCATEbypasses DELETE triggers, so this is immutability against the application and ordinary SQL, not against someone with full database access.- Staff (the assigning member) and the guardian are notified on a refusal or
withdrawal, each in their own language via
LocaleContext. - The guardian has their own view (
GET /v2/safeguarding/my-wards), and a pending decision is surfaced outside Settings byGuardianConsentPrompton the dashboard — previously the only routes in were an email or knowing to dig.
🔴 Do not reuse
SafeguardingService::recordConsent()for any of this. Called without an assignment id it sets consent on every unconsented assignment for that ward, and returnstruewhen zero rows changed.
2. event_guardian_consents — parental consent for minors at events¶
The most rigorous consent implementation in the platform, and the model to copy.
- Guardian email and identity are stored encrypted, with a separate blind hash for lookup.
- The consent artefact is pinned:
consent_text,consent_text_version,consent_text_hash, plus apolicy_binding_hashand the requirement version that was in force. - Grant happens via a single-use, expiring, hashed token
(
token_hash,token_consumed_at,expires_at). The read-only status endpoint is deliberately separate from the grant endpoint so a mail scanner following the link cannot grant consent. - Withdrawal and expiry are recorded with the acting user
(
withdrawn_by_user_id,expired_by_user_id). event_guardian_consent_historyis append-only, enforced at the database level —BEFORE UPDATE/BEFORE DELETEtriggers raiseSQLSTATE 45000. Itsactor_typecolumn carries a CHECK constraint distinguishing a platform user from an external guardian.- Request idempotency is hashed, so a retried request cannot create a second consent.
- An event manager may request or withdraw consent on behalf of a minor; every such action is attributed in the history table.
- Eligibility is genuinely gated —
EventSafetyEligibilityServicedenies participation withevent_safety_guardian_consent_required.
3. vol_guardian_consents — parental consent for volunteering¶
Simpler, and also genuinely enforced. The guardian here is an external person,
not a platform user (guardian_name, guardian_email, guardian_phone,
relationship). The minor requests consent themselves; the token is emailed to the
guardian and never returned to the requester. VolunteerController blocks minors
without active consent from applying, signing up for shifts, or joining a waitlist.
Expiry is swept by a scheduled command.
Linked accounts (account_relationships)¶
A member-to-member relationship, self-service, distinct from all of the above.
relationship_type is one of family, guardian, carer, organization.
- Requested by one member, approved by the other (the child/dependent), with
status
pending → active → revoked. Either party can revoke. - Guarded against self-linking, circularity, nesting in either direction, and a maximum number of children.
- Cross-checked against the safeguarding contact policy in both directions at request time, at approval time, and again whenever permissions are expanded.
- Carries a permission set:
can_view_activity,can_manage_listings,can_transact,can_view_messages(the last is dead — see below).
Enforcement status (updated 2026-08-07). Three booleans are real, and message viewing now exists as a consent-gated tier, never as the fourth boolean:
| Capability | Enforced? | Where |
|---|---|---|
can_view_activity |
✅ | SubAccountService::getChildActivitySummary() |
can_manage_listings |
✅ | SubAccountService::createListingForChild() → POST /v2/users/me/sub-accounts/{childId}/listings |
can_transact |
✅ | SubAccountService::transferForChild() → POST /v2/users/me/sub-accounts/{childId}/transfer |
tiers.messages (assist, ceiling) |
✅ | SupporterMessageViewService → GET /v2/users/me/sub-accounts/{childId}/messages[/{partnerId}] |
can_view_messages (boolean) |
❌ dead forever, by design | see below |
Update 2026-08-07: message viewing is built — as consent, not as a switch. The owner reversed the earlier omission, and the build answers the counterparty-exposure objection recorded below rather than waiving it:
- Consent state machine. A supporter setting
tiers.messages = assistgrants nothing:SubAccountService::updatePermissions()intercepts it into asupport_pending_actionsrow (action_type = 'message_access_grant'). Only the supported member's own yes — in-app, single-use email token, or staff-attested — runsapplyConsentedMessageAccess(), the sole code path allowed to raise the tier. Decline needs no reason; doing nothing expires it; withdrawal (POST /v2/users/me/parent-accounts/{id}/message-access/withdraw) is instant and re-enabling always requires fresh consent. - Read-only viewer with an immutable audit.
SupporterMessageViewServicefetches as the member (their deletes/archives apply), never marks anything read, strips unread counts, excludes federated conversations, re-checks the safeguarding contact policy per read, and requires a stated purpose which is written tosupporter_message_view_audits— DB triggers refuse UPDATE/DELETE — before any data returns. The member sees "last viewed" from that audit. - Counterparty notice.
SubAccountService::messageAccessNoticeFlags()feedsGET /v2/messages/restriction-status?partner_id=two symmetric flags; every frontend folds them into ONE cause-agnostic banner with broker review, so a reader can never tell whose supporter (or whether a coordinator) is involved. The member gets their own standing reminder in conversations. - Ceiling.
SupportTiers::MAX_TIER_BY_CAPABILITYcaps messages atassist(view-only); higher stored values are dropped insanitizeTiers()AND degrade tononeinresolve(). StaffsetTiers()strips the capability entirely — coordinators and brokers can never hold it.
The boolean stays dead forever: SupportTiers has no LEGACY_MAP entry for
it, toLegacyBooleans() hard-writes it false, and the create endpoint strips
it — so a historical can_view_messages: true row (families ticked a checkbox
that never did anything) can never silently activate the real capability. It
remains in SubAccountService::DEFAULT_PERMISSIONS only so historical rows
parse. Regression pins: tests/Laravel/Feature/Safeguarding/SupporterMessageViewTest.php
(the retroactive-grant trap, unread-leak, immutability, purpose-required) and
tests/Laravel/Unit/Support/SupportTiersTest.php (ceiling, staff strip).
Until 2026-08-04 only can_view_activity was enforced — hasPermission() had a
single caller in the whole codebase, while all four toggles were presented to users
in both frontends with labels promising the abilities. Nothing granted a privilege
it shouldn't have, but families could have been told a carer had powers the carer
did not have.
Two rules the proxy endpoints follow, and that anything added here must follow too:
- Attribution is mandatory. The dependent remains the owner (the listing is
theirs, the credits are theirs), and
listings.acting_user_id/transactions.acting_user_idrecord who actually performed the action. A carer's action must never be indistinguishable from the dependent's own. Every proxy action is also written toorg_audit_log, and the dependent is notified in their own language. - Reuse the member's own code path.
transferForChild()delegates toWalletService::transfer()so the carer route inherits the transfer cap, over-spend guard, safeguarding contact check, deterministic lock ordering and idempotency claim unchanged. A parallel money path would be a weaker one. - The safeguarding contact policy is re-asserted at use time, not only at grant
time, and a
pendingrelationship confers nothing.
🔴 The
can_view_messagesboolean must never be wired up — the tier is the only path. The objection that once kept viewing unbuilt (a carer reading a dependent's conversations exposes the other party, who never agreed) is now answered by the notice + consent + audit build above, not waived. What remains permanent is the shape: viewing is granted only through the consent machinery (message_access_grant), only at theassistceiling, and never via the boolean or a plain permission checkbox in any frontend. Do not re-add the key to any permission list "for consistency" with the type or the constant.
Consent records¶
user_consents is the general consent ledger, and it is properly versioned:
consent_type, consent_given, consent_text, consent_version,
consent_hash, ip_address, user_agent, source, given_at, withdrawn_at,
expires_at, is_active. Supporting tables: consent_version_history,
tenant_consent_overrides, tenant_consent_version_history.
consent_types is the platform-global catalogue that per-tenant overrides key
off by slug. It carries category, is_required, legal_basis (the six UK GDPR
lawful bases) and retention_days. It is a data-protection catalogue — it does
not model consent to be represented by another person.
Jurisdiction- and domain-specific consent records also exist:
fadp_consent_records (Swiss FADP), job_gdpr_consents,
caring_research_consents, federation_aggregate_consents.
Caveat: terms acceptance at registration is validated but is not written to the versioned
user_legal_acceptancestable. The only versioned acceptance a member gets is created at first login via the legal gate. Until that is fixed, there is no record of which terms version a brand-new member agreed to.
Raising a safeguarding concern¶
safeguarding_reports is a real case-management workflow, not a content flag.
category:inappropriate_behavior,financial_concern,exploitation,neglect,medical_concern,other.severity:low→critical, driving a review SLA (review_due_at);criticalfans out immediately to staff.status:submitted → triaged → investigating → resolved | dismissed, with an explicit transition table in the service.- Subject can be a user or an organisation. Assignment, escalation and resolution notes are all supported.
safeguarding_report_actionsis an append-only log with a closed action vocabulary (created,triaged,assigned,escalated,status_changed,note_added,resolved,dismissed), actor and notes.- Members submit via the caring-community endpoint and can view their own reports. Triage is deliberately open to non-admin safeguarding officers and brokers.
Related: safeguarding_flagged_messages (message review),
user_safeguarding_preferences and tenant_safeguarding_settings /
tenant_safeguarding_options (which triggers apply, per tenant and per member).
Caveat: there are four independent reporting systems in the platform — generic content
reports,safeguarding_reports, volunteering safeguarding incidents, andmarketplace_disputes— and none of them can reference a time exchange.reports.target_typehas noexchangevalue. A member who believes a completed exchange was recorded wrongly has no in-product way to say so.
Vetting attestations¶
member_vetting_attestations is the best-designed decision surface in the
codebase and worth imitating:
- Evidence is deliberately refused. The controller maintains a list of prohibited input fields (document, file, reference/certificate number, issue and expiry dates) and rejects uploads outright. The platform records that a community attests to having done its checks; it does not become a store of DBS certificates.
- Confirmation requires an explicit acknowledgement plus certification codes, a scope summary and optional private notes. The free-text fields are stored encrypted.
- Revocation uses a closed reason vocabulary, not free text.
member_vetting_attestation_eventsrecordsdecision_before,decision_after,reason_code, actor and policy version — append-only.
Contrast with member suspension and ban, which accept a free-text reason that has no column to live in and survives only inside an audit blob; and with member registration, which has no rejection path at all.
Acting on behalf of a member — current state¶
| Mechanism | Who initiates | Who consents | Can act for them? |
|---|---|---|---|
safeguarding_assignments |
broker / admin | the ward, via POST /v2/safeguarding/consent-to-guardian |
No — record only |
event_guardian_consents |
minor, or an event manager | external guardian, via token | Yes, within events |
vol_guardian_consents |
the minor | external guardian, via token | Gates the minor; no proxy action |
account_relationships |
any member | the dependent | Yes — listings and transfers (attributed + audited); messages not offered |
caring_caregiver_links |
any member (pending) |
— (no activation endpoint) | Blocked in practice |
| Paper onboarding intake | admin | the member, offline on paper | Yes — creates the account |
| Event staff roles | event manager | — | Yes, capability-scoped, fully enforced |
There is no way for a broker to post a listing or record an exchange on behalf of
a supported member. listings has a single user_id with no author/owner split,
and there is no such screen in the broker application. This is the largest gap in
the subsystem, and closing it needs a product decision first: does the broker act
as the member, or record activity attributed to the member? The two have
different consent and audit consequences.
caring_help_requests does support on-behalf creation (is_on_behalf,
requested_by_id) via an active caregiver link — but no endpoint can move a link
to active, so the path is unreachable today.
If you are extending this area¶
- Copy
event_guardian_consentsfor anything involving consent by a third party: versioned consent text, a hash, a single-use expiring token, an append-only history table, and attribution of who acted. - Copy
member_vetting_attestationsfor anything involving a staff decision: closed reason vocabulary, before/after values, actor, policy version. - Never present a permission the backend does not check. See the linked accounts caveat above for why.
- A record of a relationship is not authorisation. If you want a guardian to be able to do something, you must add an explicit check — nothing is implicit.