Skip to content

Safeguarding & Consent

Last reviewed: 2026-09-25

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.

🔴 Read PRODUCT-AUDIENCE.md first if the question is about age. Project NEXUS is a platform for adults aged 18 and over. Since 25 September 2026 that is enforced, not just declared: an account whose recorded date of birth is under 18 cannot sign in, nobody can record a date of birth under 18, and guardian consent for minors is switched off for both volunteering and events. Under-18 participation is removed, not supervised. The two guardian-consent subsystems below are kept in the code and database so the decision is reversible and their records survive, but nothing uses them.


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 by proposed_by_user_id NOT NULL, at tier 0 — SupportTiers::resolve() of their empty grant is none on 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_assignments is now a read-only archive. Its rows were copied into account_relationships by 2026_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 superseded SafeguardingService::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 by AdminSafeguardingController::requireSafeguardingStaff('manage'), which admits admin tiers, broker, and holders of the safeguarding.manage permission. A member cannot create one.
  • Consent belongs to the ward. The ward sees their own arrangements at GET /v2/safeguarding/my-guardians and consents at POST /v2/safeguarding/consent-to-guardian, which is the only writer of consent_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.tsx lists the ward's arrangements and carries the consent action, covered by tests in SafeguardingTab.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 by AccessibleGuardianArrangementTest. 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_log row 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_events is the append-only trail — action, actor and actor role, reason, IP, user agent — protected by BEFORE UPDATE / BEFORE DELETE triggers raising SQLSTATE 45000. Note TRUNCATE bypasses 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 by GuardianConsentPrompt on 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 returns true when zero rows changed.

Switched off on 2026-09-25 (adults-only platform). The request, withdraw and grant endpoints return 410 with error code GUARDIAN_CONSENT_RETIRED. Organisers can no longer set guardian_consent_required or a minor_age_threshold on an event's safety requirements (a minimum age is unchanged). A safety policy published before the switch-off that still says guardian consent is required is treated as if it did not: the flag neither asks for a date of birth nor blocks anyone; the minimum age and code of conduct still apply as published. The tables, their triggers and the append-only history are untouched, and EventGuardianConsentService still works underneath, so re-enabling is a revert rather than a rebuild. The description below is kept because the design remains the model to copy.

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 a policy_binding_hash and 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_history is append-only, enforced at the database level — BEFORE UPDATE / BEFORE DELETE triggers raise SQLSTATE 45000. Its actor_type column 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.
  • While it was switched on, eligibility was genuinely gated — EventSafetyEligibilityService denied participation with event_safety_guardian_consent_required. It no longer does (see the note above).

Switched off on 2026-09-25 (adults-only platform). Every guardian-consent endpoint (the minor's list/request/withdraw, the public verify link, the admin list) returns 410 GUARDIAN_CONSENT_RETIRED. VolunteerService::guardianConsentError() never demands consent or a date of birth, and the tenant setting volunteering.guardian_consent_required always reads as false and cannot be turned on (the admin configuration API refuses it). The table and its rows are kept for GDPR export and retention. The text below describes how it worked.

Simpler, and it was genuinely enforced. The guardian here is an external person, not a platform user (guardian_name, guardian_email, guardian_phone, relationship). The minor requested consent themselves; the token was emailed to the guardian and never returned to the requester. VolunteerController blocked minors without active consent from applying, signing up for shifts, or joining a waitlist. Expiry is still 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 = assist grants nothing: SubAccountService::updatePermissions() intercepts it into a support_pending_actions row (action_type = 'message_access_grant'). Only the supported member's own yes — in-app, single-use email token, or staff-attested — runs applyConsentedMessageAccess(), 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. SupporterMessageViewService fetches 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 to supporter_message_view_audits — DB triggers refuse UPDATE/DELETE — before any data returns. The member sees "last viewed" from that audit.
  • Counterparty notice. SubAccountService::messageAccessNoticeFlags() feeds GET /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_CAPABILITY caps messages at assist (view-only); higher stored values are dropped in sanitizeTiers() AND degrade to none in resolve(). Staff setTiers() 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_id record who actually performed the action. A carer's action must never be indistinguishable from the dependent's own. Every proxy action is also written to org_audit_log, and the dependent is notified in their own language.
  • Reuse the member's own code path. transferForChild() delegates to WalletService::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 pending relationship confers nothing.

🔴 The can_view_messages boolean 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 the assist ceiling, 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.


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_acceptances table. 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); critical fans 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_actions is 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, and marketplace_disputes — and none of them can reference a time exchange. reports.target_type has no exchange value. 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_events records decision_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.


The contact gate: jurisdiction and interaction policy

The enforcement engine that acts on a member's "only vetted people may contact me" choice. It was undocumented here until 2026-08-28, which let a misconfiguration run silently in production for weeks (Sentry 134069538).

Three layers:

  • SafeguardingJurisdictionService holds a hardcoded per-jurisdiction policy table (POLICIES): scheme code, attestation code, policy version base and whether contact gating is available. The tenant's chosen jurisdiction lives in tenant_safeguarding_settings (one row per tenant; no row = unconfigured). Configured via PUT /v2/admin/vetting/policy; the only UI is the broker panel (/{slug}/broker/vetting), not the admin panel. isContactGateUsable() is the single definition of "this policy can operate the gate" — use it, never re-inline the five-field check.
  • SafeguardingInteractionPolicy evaluates sender→recipient contact. It returns ALLOW, DENY (VETTING_REQUIRED — the feature working), or UNAVAILABLE (SAFEGUARDING_POLICY_UNAVAILABLE — the gate cannot run, fails closed). UNAVAILABLE happens when a member has a live vetted-interaction preference but the tenant's jurisdiction is unconfigured or unusable. Matching silently drops such candidates; since 2026-08-28 the matches API reports meta.degraded_reason = safeguarding_policy_unavailable so the member is told, and steady-state unavailability logs once per request at WARNING (transient lookup failures stay at ERROR).
  • Alignment guards. Applying a country onboarding preset also configures the matching jurisdiction when none is set (and warns on a mismatch instead of overwriting an explicit choice), and the daily safeguarding:check-policy-health pager raises one fingerprinted alert naming any tenant with live vetted-interaction selections whose policy cannot operate the gate. The fix for that alert is an admin action — configure the jurisdiction in the broker panel — not code.

History note: ireland had contact_policy_available => false until 2026-08-28, meaning an Irish tenant had no valid configuration and the gate was permanently UNAVAILABLE for its protected members. Garda Vetting gating is now enabled (owner decision); the policy table is pinned by SafeguardingJurisdictionServiceTest, and changing any row of it is a product/legal decision, not a refactor.


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 — switched off 2026-09-25 — No — retired
vol_guardian_consents — switched off 2026-09-25 — No — retired
account_relationships any member the dependent Yes — listings and transfers (attributed + audited); messages not offered
caring_caregiver_links the proposed caregiver (pending) the care recipient confirms, then authorised staff verifies consent Yes, after activation — Caring requests, schedule and cover care only
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 supports on-behalf creation (is_on_behalf, requested_by_id) via an active caregiver link. A proposed caregiver link now stays visibly pending while the care recipient confirms it and authorised staff records consent evidence, re-runs same-tenant and bilateral safeguarding checks, and approves or rejects it. Only an active link enables on-behalf requests, schedule access or cover care; the volunteering guardian-consent label carer does not create, approve or influence this relationship.


If you are extending this area

  1. Copy event_guardian_consents for 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.
  2. Copy member_vetting_attestations for anything involving a staff decision: closed reason vocabulary, before/after values, actor, policy version.
  3. Never present a permission the backend does not check. See the linked accounts caveat above for why.
  4. 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.