Skip to content

Audit fixes: 16 areas from the security and correctness audit - #989

Merged
NewWorldVenture merged 71 commits into
mainfrom
claude/roadmap-implementation-ld8bon
Oct 10, 2026
Merged

NewWorldVenture merged 71 commits into
mainfrom
claude/roadmap-implementation-ld8bon

Conversation

@NewWorldVenture

@NewWorldVenture NewWorldVenture commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner

Summary

Integrates the audit fix branches produced by the finalaudit.md audit programme, each one the result of a five-lens find, two-skeptic verify, worktree fix and independent adversarial review (with re-fix rounds where the reviewer blocked). Base is main through #995 and #991 (the whole #980–#995 lane merged).

Areas (one merge commit each)

Area Highest severity What changes
X OAuth and scheduled posting medium state/token binding, connection ownership
Alexa assistant keys medium key ownership and rotation
Finance and goal deletes medium role-checked, confirmed deletes
Reminder and to-do deletes medium role-checked deletes; reassign-then-delete bypass closed
AI approvals high dedupe-key forgery, approval PIN flow, parents-only paths
Money writes medium savings-goal and transaction-intent correctness; budget duplicate cleanup lands the requested amount on the canonical row
AI memory and privacy high sensitive-term check, per-member memory scope, fenced recall slice
Inbound email/SMS and urgent texts medium SMS budget counts sends not attempts; sender verification
Notifications and push high no marketing push to minors; per-user reads; push detach auth
Documents and paperwork medium member_id family check; sensitive titles off the feed; family-day expiry; guest capture refused; permanent OCR failures skipped, config failures retried
Autopilot and purchases medium compare-and-set accept; fenced refresh; no purchase-state leak; paged prior reads; deliverable reminder times; fair cron rotation
Account close, crons, import medium closing cancels Stripe billing (reopen resumes); every family cron skips closed families; import idempotency and assignment repair
Family membership high adults cannot assign/remove parents; removed child logins banned and restored on re-add; removed members' sync refused for every caller; onboarding invite cap and resend; adopt takeover closed; #981's kid-identity and PIN-reset provenance guards preserved
Billing and entitlements high no double subscriptions; AAL2 on billing routes; server-side trial lock; 14-day past_due grace; unknown-price cancellations land; webhook tolerates duplicate rows
Family economy high minors cannot deal with other households; party-only pickup completion; invest orders only on own wallet; gift messages held for approval; auction-notice retry; order reads via buyer_family_id with fallback; circle reads via view with fallback
Super-admin console medium AAL2 step-up on every admin surface; admins cannot ban admins; Stripe secrets never overwritten with null; roster revoke really revokes; all config changes audited; non-uuid audit ids no longer dropped
Child safety: Guardian, locator, emergency high emergency escalation actually fires (it was computed and never read); Guardian quiet hours and curfews run on the family's timezone, not New York; emergency texts escalate instead of waiting for quiet hours to end; blocked senders cannot ring through on emergency keywords; a Guardian profile cannot name another family's member and the voice route refuses one; disabled geofences stay silent; removed members take their location with them; trusted contacts are never held by the inbound cap while the LLM scam call stays bounded

Database

Migrations 0477–0485, assembled from the audit's tested proposals and verified on a fresh PG16 replay (460 migrations, 188/188 probes, idempotent second apply, ledger audit next 0486, typecheck clean; CI's Database job green):

# Closes
0477 assistant keys minted and revoked only by the server (client insert/update revoked)
0478 family goals shaped by the household, removed by managers
0479 reminders and to-dos deleted by a manager or their author
0480 approvals pin who filed them; only the server says otherwise
0481 the RLS sweep: ~27 household tables narrowed to their roles (131 probe assertions). kid_progress, the calendar_feeds guest guard and the reward request shape guard are deferred to the held 0496, 0498 and 0500, whose negative-control workflows must keep seeing those defects until the held files land
0482 who may make, unmake and invite a parent (14 forbidden writes refused, 18 allowed). The accept_invite rewrite is deferred to the held 0495; the inviter-still-active re-check is a follow-up once 0495 lands
0483 a live card hold counts; an order names both families; party-only pickups; invest orders on own wallet; leader keeps its price; reserve met by max; buy-now closed by bids; circle blocks and code rotation; circle feed view; gift reads (49 cases)
0484 a trip links a document once (a fail-closed preflight refuses when duplicate links exist, deleting nothing, with a probe proving both rows survive); guests do not file paperwork
0485 push devices found by key

Reserved numbers 0488/0490/0492–0503 are untouched and all eleven held-lane negative-control workflows reproduce as their YAML specifies. Nothing is applied by merging: the production-migrations workflow only verifies on main; applying needs the manual apply=true dispatch. Apply order: the app halves in this PR must be deployed before 0483 is applied (its circle-feed view replaces the dropped read policy); docs/PENDING_PROD_MIGRATIONS.md records this. Two rollout notes: 0482's last-parent rule now also binds the service role; 0483 narrows gift_payments reads for children to completed gifts on their own wallet.

Integration repairs

Confirmed writes the audit branches left unconfirmed; fixture mocks for the merged admin step-up, open-family reads and the ban marker; the recall slice fenced and registered; new copy lifted into the catalogues; e2e fixture source lists; the marketplace rail's Trust Score reads through a server action instead of probing the database from the browser, and the notification-email and sign-out fixture loaders resolve the modules the merged views import (the module-graph guard now catches both drifts). The budget duplicate-cleanup outcome lands the requested amount on the canonical row whatever the delete did. Every member-removal path (family screen, settings, admin) does the membership steps and the location forget. main is merged in through #991 (one conflict in child-login-actions.ts, resolved keeping #981's kid-identity and PIN-reset provenance guards alongside this PR's relink CAS, role restore and ban marker; four in the guardian merge, resolved keeping both sides).

Validation

Each branch passed tsc, eslint --no-ignore and its related vitest files in TZ=UTC, America/Los_Angeles and Asia/Tokyo before integration; the integrated head 8bcf71223 passed the full vitest suite locally in TZ=UTC, and the carve-out commit re-ran the full suite (2,087 files; only the two Node-22-only checks fail locally). After each later merge the merge-sensitive files were re-run in TZ=UTC. CI on the previous head had E2E, Database replay and every other job green except the four held-lane controls and three unit tests, all addressed in 113eb3b90.

Not in this PR

🤖 Generated with Claude Code

https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K

claude added 30 commits October 10, 2026 00:32
…re failed connect receipts

- saveXConnection: accept a stale 'connecting' credential for takeover, run the
  expiry/permission re-checks before the first write, and on any later failure
  restore an existing account's token row and status (revoke a new grant on a
  target that will not connect).
- finishXAuthorization retires its claimed receipt on failure; the callback
  retires an unclaimed receipt on denial/invalid callbacks; createXReceipt
  sweeps stale unclaimed receipts and retires its account if the token insert
  fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
…unlinked, one default list

- resolveAssistantLink/lookupAssistantLink refuse an assistant_links row whose
  created_by is null or differs from user_id: the only minting path writes both
  from the session user, so such a row was written through PostgREST in a
  co-parent's name and would outlive the writer's removal (0419 and the owner
  check follow user_id). The client INSERT/UPDATE grant from 0283 still needs a
  migration to withdraw.
- lookupAssistantLink reports read failures as 'unavailable'; the Alexa route
  speaks ERROR_SPEECH for that instead of the "link your account" prompt.
  resolveAssistantLink keeps its null-on-any-failure contract.
- ensureList creates a missing default list through 0443's serialised
  get-or-create (ensureDefaultGroceryListId / ensureTodoListId), so two first
  captures at once share one list; a list this capture created is removed
  again, while still empty, when the item insert fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
…goal writes, report money deletes lost in transit

- wallet hub: deleteWalletRowAction removes a transaction via
  lib/services/finances deleteTransaction, so the role check, zero-row check
  and household-trail entry match /dashboard/billing.
- goals: deleteGoal requires a parent/adult (or system); create/update/progress
  refuse guest and caregiver. goals-module hides delete from non-managers.
  RLS goals_* still admits every member; the policy change is left for a
  migration.
- billing, budgets and savings delete handlers go through settleAction, which
  toasts a rejected server-action call and re-reads the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
lib/assistant/tools.ts add_goal inserted into `goals` itself, so the role
rule 632ce74 added to createGoal (no guest or caregiver) never ran for chat:
the hand-written tool shadows the registry's goals.create in mergeToolSets,
and RLS on goals admits every member.

- AssistantCtx gains a required `role`; the three call sites (chat route,
  /api/ai listing, assistant-engine) pass the caller's family role.
- add_goal calls createGoal with an 'ai' scope built from that role. An
  unknown role is refused. Return shape and the "Created the goal" summary
  are unchanged; service refusals surface their message, db errors keep the
  toolFailure wording. The goal now also gets the service's date check,
  progress 0 / is_complete false, and the activity entry.
- No other direct writes to `goals` remain outside lib/services/goals (the
  digital-twin page, goals-module and operating-index only read).
- tests/the-assistant-cannot-add-a-goal-for-a-guest.test.ts: guest,
  caregiver and unknown roles are refused with nothing written; parent,
  adult, teen and child still create the goal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
deleteReminderAction and deleteTodoAction took any active member: the
services scope by id + family_id only and the RLS is is_family_member for
all, so a guest, caregiver or child could delete a parent's reminder or any
family task, contradicting the permission matrix (can_delete on reminders is
parent-only) and the view-only guest/caregiver role descriptions.

Both actions now allow managers (parent/adult) to delete anything; a teen or
child only rows they created, are assigned, or (reminders) that are about
them; a guest or caregiver nothing. The matching per-command DELETE RLS
policies are left for a migration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
canDeleteReminder and canDeleteTodo let a teen or child delete a row they
were assigned or (reminders) that was about them. Those columns are not
ownership: updateTodoAction sets assigned_to_id with no role check and the
reminder modal updates family_reminders directly, so a teen could reassign a
parent's task or reminder to themselves and then delete it.

A teen or child may now delete only rows they created: family_reminders by
created_by = auth user id (0014), todo_items by created_by = their
family_members id (0015, which is what that column references). Managers
may still delete any family row; guests and caregivers none.

Tests add the reassign-then-delete case for both tables and turn the
"about them / assigned to them" cases into refusals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
- materializeConciergePlan reserves the concierge_plan_actions row under its
  unique key before creating the record; a 23505 loser creates nothing, a
  failed create releases the reservation.
- skipTrust (and the AI actor scope) now require the service-only
  trust_audit_logs line naming the approval, not the member-writable
  requested_by_kind; gateAiAction writes that line for rows it files. A member
  can no longer approve their own member-filed request.
- foldIntoRun / expireStale release or block only steps whose own approval_id
  is the row; plan_step_ids on the row no longer widen what is touched.
- A recorded decision whose run fold or tool execution failed is reported as
  non-retryable; new resumeSettledRuns sweep returns runs parked on a decided
  or expired approval to the queue. Dependent-step and request updates are
  checked.
- expireStale blocks steps and run before expiring the approval.
- expireStale / remindPendingApprovals / resumeSettledRuns return failure
  counts, and the notifications cron adds them to generationFailures (502).
- connectAccount refuses to move an account already connected for another
  family and refuses an empty external id; both OAuth callbacks fail the
  connect instead of keying the account on the user id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
…quest check

- openApprovalRequest and openApproval share fileOrReusePendingApproval: a
  pending row under the dedupe key is reused only if its requested_by_kind,
  requested_by_member_id, domain, capability and payload match the ask. A row
  planted under the key is never reused (so it cannot collect Bubaly's
  ai_agent audit line); the ask is filed beside it without a key.
- isOwnMemberRequest no longer trusts requested_by_kind: an 'ai' row whose
  asker is the approver counts as their own unless filedByBubaly proves it
  (plan_steps rows excepted: they release only server-parked steps). Closes
  an adult materialising a concierge plan alone.
- Tool approvals whose run fold failed re-read the parked steps: none means
  a standalone approval, executed now; unreadable says so instead of
  promising a sweep that only scans runs.
- connectAccount releases a stale account left in a family the user is no
  longer active in, and raises AccountConnectedElsewhereError otherwise; the
  OAuth callbacks map it to error=connected_elsewhere with its own message.
- materializeConciergePlan releases a failed reservation through the service
  writer, so the member DELETE policy on concierge_plan_actions can go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
…llet money step-up

- createSavingsGoal: a keyed call whose name-matched probe finds a goal with
  different money (target/current) answers ALREADY_SAVED instead of handing an
  older goal back as the approved one.
- Savings contribution modal: in-flight guard (ref + loading/disabled button),
  so a double press posts one delta; a thrown action keeps the modal open.
- updateBudget/budgetVsActual: the oldest row is the category's budget for
  readers and writers; a save that loses the read-then-insert race folds its
  row into the winner; duplicate category rows no longer double-count spend.
- Wallet hub: add/delete on transactions and financial_accounts apply the same
  aal2 money step-up as the billing actions; the hub routes the refusal through
  reportRefusal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
…confirm

- SENSITIVE_TERMS: stems (diagnos/therap/pregnan/bank) take \w* and nouns
  their plurals, so "diagnosis", "therapist", "pregnant", "medications",
  "passwords" and "banking" are classed sensitive.
- Schedule, shopping and travel context slices read facts through a new
  recallFactsForContext helper that checks memory_enabled strictly and
  yields no facts when memory is off or the setting cannot be read.
- The purchase_advisor insight reads memory_enabled strictly before
  querying family_facts and sends none when memory is off.
- confirmFact refuses a non-open (dismissed) card and flips the status
  with .eq('status','suggested'), so a concurrent or replayed confirm
  rolls back its insert instead of leaving a duplicate fact, and cannot
  overwrite a concurrent dismissal.
- Remember/confirm activity titles carry the label, not the value, so
  forgetting a fact does not leave its value in the activity feed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
…nt SMS budget and recovery

- Email auto-reply is reserved once per (family, provider ref) in
  ai_tool_calls (contact_center.email_reply); redeliveries no longer resend
  the reply or write another outbound row.
- Inbound email refs are stored as email:<sha256(familyId, Message-Id)>, so
  one Message-Id addressed to two families files for both and a sender can
  no longer pre-empt another family's message.
- Urgent SMS has a per-family budget (5/hour, 20/day) and collapses repeats
  from one sender within 10 minutes; over budget it stays in-app only
  (contactUrgent.smsBudget).
- Twilio 401/403/404 is 'misconfigured', not 'rejected': the receipt is
  requeued with drain and no spent attempt, and sent once config is fixed.
- A receipt-less inbox row filed non-urgent is queued for escalation when a
  redelivery classifies it urgent; only rows filed urgent are legacy_unknown.
- The post-send receipt write uses its own 5s deadline instead of the shared
  25s signal, so an accepted SMS is not stranded in 'dispatching'.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
…e out

The per-family urgent-SMS budget and the same-sender collapse counted every
receipt with a recent locked_at, including claims whose send came back
misconfigured, retryable or rejected and sent nothing (misconfigured retries
re-claim every 5 minutes, refreshing locked_at). An outage therefore demoted a
same-sender follow-up and any backlog beyond 5/hour to in-app only for good,
and five Twilio rejections blocked the next genuine urgent text.

Both checks now count only receipts whose phase is dispatching, accepted or
unknown, filtered in the query (so the limit applies to sent rows) and
re-checked in code.

Also confirm the email-reply receipt's finishing update (select + row check),
which the unconfirmed-write ratchet flagged as a new member.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
…consented marketing audience

- push/subscribe: before upserting, remove (service role, row + owner named)
  any other account's push_devices row for the same device_key, so a shared
  or handed-down device belongs to one account and is buzzed once.
- sign-out: the browser remembers the device key it registered and, on
  sign-out, POSTs /api/push/unsubscribe with the leaving session's bearer
  token before revoking it; push/unsubscribe accepts that bearer token.
- notify('family') fans out to one row per active member with a login, so
  reading a family notice no longer clears it for everyone; a legacy NULL
  family row still dedupes for all. markRead (service and screen) and
  markAllRead are bounded to the member's own and family-wide rows.
- Marketing push audience requires explicit consent
  (user_preferences.notification_prefs.marketingPush === true) and excludes
  any account with a child or teen membership, read before dispatch.
- Stuck marketing push campaigns: resolveStalePushCampaignAction records a
  stale 'preparing' attempt as preflight_failed (retryable) and a stale
  'dispatching' or 'unknown' attempt as reviewed (deletable, never re-sent).
- APNs GOAWAY retires the session gracefully instead of failing streams the
  provider may already have accepted.

notify() dedupe race and per-user read state for other writers' NULL rows
need schema changes and are not included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
- middleware: the exact POST /api/push/unsubscribe with a well-formed bearer
  joins the bearer-API exception, so the sign-out detach (cookies already
  cleared, credentials 'omit') reaches the route's bearer branch instead of a
  middleware 401. No other /api/push path or method is opened.
- notify('family'): the per-member fan-out rows are written with sent_at
  stamped, the email digest's own "resolved" mark, so a family notice stays
  in-app and push only as the NULL row was. Push (pushed_at) and per-member
  read state are unchanged.
- Tests drive the detach through the middleware into the route, with a
  negative control for other /api/push paths, methods and bad headers, and
  prove a family notice sends no email while named/manager notices still do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
…ff the feed, family-day expiry window, guest capture refusal, permanent OCR failures

- linkToVacation checks a supplied member_id is an active member of the
  family (not_found otherwise); resolveAssigneeId validates assignee_id
  against the family. A 23505 on insert re-reads and returns the existing
  link (created:false).
- readDocument/linkToVacation record "Opened/Attached a private document"
  for sensitive documents instead of the title on agent_activity.
- Document-expiry notifications bound expires_at (DATE) by the family's
  day keys, not UTC instants.
- Guests are refused by /api/paperwork/capture, /api/paperwork/link,
  captureDocument, captureDocumentLink and authorizeDocumentLink.
- extractDocumentText marks refusals, malformed transcriptions and
  auth/config/model errors non-retryable, so an inbound email with such an
  attachment is acknowledged (attachment 'skipped') instead of 503-looping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
…rwork filing

- extractDocumentText: only failures about this document are permanent
  (refusal, malformed/empty transcription, HTTP 400/413/422). Missing or
  rejected keys, unknown models (401/403/404) and everything else stay
  retryable, so the inbound-email webhook answers 503 and the attachment is
  redelivered instead of being skipped while the key or model is wrong. A
  numeric status decides on its own; message wording no longer overrides it.
- addPaperworkAction refuses guests with actionRefusal.notAllowed, the same
  view-only refusal captureDocument/captureDocumentLink give. The other
  paperwork actions do not create paperwork_items.
- resolveAssigneeId logs the member lookup error like the other lookups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
- Feed-imported calendar events (feed_id/external_uid) never auto-prep into
  reminders, groceries or the family-wide "Autopilot handled" notification.
- Accepting a policy suggestion claims the row (open -> approved, fenced on
  status and updated_at) before writing the policy, and stamps executed only
  from that claim: two accepts write one policy, and a concurrent dismiss or
  archive is never overwritten.
- The policy-scan refresh is fenced on status 'open' and the read updated_at.
- resolveSuggestion re-reads the source row (appointment, event, renewal,
  insurance, medication) and returns 'changed' when it is gone, inactive,
  moved, or (appointments) already reminded.
- Birthday gift ideas are chosen from all wishes, regardless of purchase state.
- The scan reads every live dedupe key (paged) and treats a 23505 on insert
  as a prior row instead of failing the whole scan.
- Reminder times are clamped to an instant still ahead (next family 09:00),
  in the scan's auto path and in resolveSuggestion.
- Renewal drafts carry a real `at` three days before the lapse; renewal and
  insurance cards expire at the family's local end of day.
- A purchase approval whose request association fails is cancelled instead of
  left pending and undeliverable.
- The autopilot cron runs families with bounded concurrency inside a time
  budget, rotates its start by day, and reports unreached families.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
…ches everyone

- Event-tied reminders (appointments, calendar events, moment "leave by")
  are never moved to the next family 09:00. The scan drops any draft whose
  card has expired, and any event-tied reminder draft whose time has passed,
  so nothing is created, stored or announced for it. Accepting such a card
  returns 'changed'. Renewal, insurance and refill reminders (and legacy
  cards with no source) keep the next-09:00 rule (reminderIsoFor).
- The cron start moves along a van der Corput sequence instead of one
  family a day. No per-family last-scan signal exists, and this needs no
  estimate of how many families a run reaches: every family is reached
  within 4*N/C runs (40 for 5,000 families at 500 a run, instead of about
  4,500). The start cut-off is now 40 s, and a family is waited on for at
  most 12 s, so the pass returns inside maxDuration 60.
- A clash involving a feed-imported event is pushed without its titles, and
  imported titles go through sanitizeUntrusted before they reach a card.
- A manager's own 'approved' policy claim with no policy behind it, older
  than two minutes, can be accepted again. It is fenced on the claim time,
  so two retries still write one policy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
…p side)

- Roster editor: only a parent may grant/change/remove 'parent'
  (assignableMemberRoles, canRemoveMember); invite form refuses an invite
  role above the sender (canInviteWithRole).
- Removal paths (family-module, settings-module, admin) now write
  REMOVED_MEMBER_PATCH {is_active:false, role:'guest'} so accept_invite's
  reactivation cannot restore an old parent/adult role.
- /api/email/invite: manager-only, pending + unexpired invites only, and no
  parent invite mailed for a non-parent.
- Onboarding finalize: adopt branch requires the progress marker's family,
  a parent role and created_by = caller; invite emails capped at 5 and
  rate-limited per user and per family; child logins cannot claim a family.
- Child logins: sign-in refuses an inactive linked member; ensureActiveFamily
  and onboarding never provision a family for a child login; create refuses
  manager targets; link is CAS on user_id IS NULL and rollback unlinks only
  its own user.
- Provider-sync cron disables connections whose owner is no longer an active
  member instead of syncing them.
- scopeForApprovedWork ignores an inactive asker.

DB-side fixes (fm_update/invites restrictive policies, family_id freeze,
accept_invite ON CONFLICT role, sync/child-login revocation triggers) are
reported separately, not written here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
…nce and whole

- closeAccountAction ends every billable Stripe subscription at period end
  before setting closed_at, refuses the close when a cancel fails or billing
  is not configured, and resumes what it cancelled if the close does not land.
- push-scan, weekly-digest, provider-sync and wallet-allowance skip families
  with closed_at set (inner-joined in the query for the bounded reads).
- commitImport claims a client-minted submission id through rate_limit_hit
  before reading, so concurrent retries of one commit write the file once;
  the wizard holds the id across an unanswered retry.
- A failed chore_assignments insert takes back the chores just created, so
  the re-run recreates them with their owners.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
…eview follow-ups

- Removal: family-module and settings-module now remove through a new
  removeFamilyMemberAction (app/(app)/family/member-actions.ts). The write
  still goes through the caller's session, so fm_update and the
  canRemoveMember check apply. That action and adminRemoveMemberAction then
  call revokeRemovedChildLogin (lib/server/child-account.ts), which bans a
  removed member's child-login account with the service role
  (updateUserById ban_duration). The removal stands if the ban fails; the
  failure is logged and returned, and the screen shows
  familyModule.removedButLoginStillActive. A lookup that cannot be answered
  is reported as failed and bans nobody, because a ban is global.
- Onboarding: finalizeOnboardingAction returns unsentInvites (past the cap,
  refused by a limiter, or rejected or skipped by the mail provider). The
  Done screen lists them with a Send button that posts to /api/email/invite.
  The ONBOARDING_INVITE_EMAIL_CAP comment no longer claims Settings re-sends
  them.
- createChildLoginAction: reactivating a removed local child restores
  role 'child' instead of leaving the 'guest' that REMOVED_MEMBER_PATCH set.
- loadSyncExecutionPolicy refuses (ownerUnavailable) an account whose owner
  is not an active member of its family. That covers every
  runProviderSync/runGoogleSync caller, so the cron comment is now true.
- i18n: the new keys are in en-US and all six base catalogues. en-GB, es-MX,
  es-US and fr-CA are declared empty placeholder overlays
  (PLACEHOLDER_LOCALES, enforced by
  an-empty-locale-says-it-is-a-placeholder.test.ts), so they inherit the keys
  rather than carry them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
- notifications, chore-reminders, calendar-feeds, family-routines (due and
  arming reads), autopilot-scan, model-refresh, library-feeds and
  guardian-learning skip families with closed_at set: .is('closed_at', null)
  on direct families reads, an inner join on families with
  .is('family.closed_at', null) on bounded per-row reads.
- The shared delivery paths the notifications and push-scan crons call do the
  same: push dispatch, the email digest, morning briefs and AI approval
  reminders. So do the contact-center urgent drain, the scheduled social
  publisher, network aggregation (whose prune then drops a closed family's
  contribution) and scheduled marketing automations.
- ai-runs claims through SQL that cannot be changed without a migration, so a
  claimed run of a closed family goes back to the queue a day out instead of
  executing; it is not cancelled, so reopening resumes it.
- autopilot-scan's entitlement check already resolves a closed family to level
  0, but that skips it only while Autopilot needs a paid tier; the filter
  covers a 'free' tier too.
- reopenAccountAction resumes the subscriptions the close cancelled: closing
  did not record them, so a subscription counts when Stripe's canceled_at
  falls within the close. Anything else still set to end, or a Stripe that
  cannot be reached, reopens the account and tells the family to resume it
  from billing.
- A refused close whose rollback could not resume a subscription says so in
  the error the family reads, not only in the log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
- Webhook: an unknown price no longer blocks a status that ends access
  (canceled/unpaid lands on the recorded row, plan kept, admin alerted);
  a second live subscription for a family is flagged instead of
  overwriting the recorded one; multi-row families are read without
  maybeSingle failing every retry.
- Checkout and change-plan share createSubscriptionCheckout: Stripe-side
  live-subscription check (change-plan now has it), the service fee on
  both paths, reuse/expiry of the family's open session, and an
  idempotency key on the create.
- portal/cancel/change-plan/checkout require the AAL2 step-up their
  page enforces; billing-module, upgrade-modal and the trial paywall
  route a stepUp refusal through reportRefusal. The paywall opens the
  portal on a 409 instead of looping into the gated billing page.
- Server-side gates honour the trial lock: resolveFamilyEntitlement,
  resolveFeatureEntitlement 'locked', refuseUnlessEntitled and
  assertAIAccess answer trial_expired / account_closed.
- past_due keeps its paid level while Stripe retries the renewal.
- resolveEntitlement returns the fail-open fallback on any read error
  instead of computing from partial data.
- Fridge Chef carries the Smart Kitchen gate on its page and route and
  records its vision call against the AI allowance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
…ily from context

- revokeRemovedChildLogin now records its ban in the auth user's app_metadata
  (removed_child_ban: true), and leaves alone an account that is already
  banned without that marker (an admin's ban).
- New liftRemovedChildBan lifts a ban (ban_duration 'none') only for this
  member's own child login (child_logins row for family, member and user)
  and only when the marker is set. adminSetUserBanAction clears the marker on
  every ban and unban, so an admin decision is never undone by a re-add.
- createChildLoginAction re-adds a REMOVED, non-manager member who still holds
  this family's child login, instead of refusing with "already has a login".
  It reactivates the row as 'child' (compare-and-set on user_id and
  is_active = false), lifts the removal ban, then sets the new PIN through
  resetChildPinAction. Before this, nothing could bring such a child back,
  because removal leaves user_id in place. No other path reactivates a member:
  the other is_active: true writes on family_members only insert or upsert the
  caller's own parent row.
- removeFamilyMemberAction takes the family from the authenticated context
  rather than from the caller. server-action-authorization.test.ts flagged the
  caller-supplied family id that b108d66 introduced.
- Tests: re-adding lifts only the marked removal ban and restores an active
  child; an admin ban placed before or after the removal is never lifted; an
  account without this member's child login is neither unbanned nor relinked.
  Two guards that already failed at 400dcb6 are fixed: the PRIV-001
  sensitive-reach baseline now records child_logins, read through
  lib/server/child-account.ts with ids only; and an indexOf ordering assert
  uses at().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
…d alerts

- Checkout reuse reads tracked sessions marked 'abandoned' as well as
  'pending' (24h window): the cron marks them after an hour but Stripe
  keeps them payable for 24h, so they were neither reused nor expired.
  Stripe's status === 'open' still decides.
- A tracked session the current Stripe key does not know
  (resource_missing, e.g. after a key switch) is skipped instead of
  failing checkout and change-plan with 500. An expire that fails on a
  session Stripe confirms is no longer open counts as closed. Any other
  failure still refuses: the session may be open and payable, and
  creating another beside it is a double charge.
- An open session is reused only when its line item is the requested
  price, not just the same plan key.
- past_due keeps its paid level for PAST_DUE_GRACE_DAYS (14) after the
  renewal that failed. Stripe advances the period on renewal, so that
  renewal is one plan interval before current_period_end. A lapsed
  family's paywall checkout answers 409 and opens the billing portal.
- Unknown-price alerts are deduped per subscription and status across
  Stripe retries; the revoke path sends the churn alert the normal path
  does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
… findings

- Hand-off: completeHandoffAction goes through loadOrderRole and refuses a
  non-party, so a sibling who reads the family-visible confirm_code can no
  longer complete someone else's pickup or close a loan. A child or teen
  cannot propose or confirm a meeting with another household's member.
- Auctions: bids and Buy-It-Now are for parents and adults only; the
  household that already leads is refused instead of being bid up against
  itself; Buy-It-Now closes at the first bid (action refuses, panel hides it).
- Negotiations and circles: a child or teen cannot accept a cross-family
  offer, or create or join a circle.
- Invest: a child places orders against their own wallet only.
- Add funds: refuses a wallet missing a bucket the split would credit,
  instead of writing completed credits with bucket_id NULL.
- Gifts: the gift-waiting notice goes to managers without the giver's name,
  and /wallet/gift loads pending gifts' name and message for managers only.
- close-auctions cron: a scope or notify throw is a counted failure, not a
  500 mid-batch; notices are sent once per order/listing and auctions settled
  in the last 7 days are re-offered, so a lost winner notice goes out later.

The SQL halves (RPC party and role checks, proxy-bid leader and reserve
rules, BIN in the RPC, buyer_family_id on orders, circle removal and code
rotation, circle column exposure, spendable balance counting processing
holds, invest_orders RLS) need migrations and are not in this change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
… notice re-check reaches every settlement

- Negotiations: makeOfferAction and the counter branch of respondToOfferAction
  refuse a child or teen, like accept. The other household's adult accepting
  either one creates a confirmed order with the child as buyer or seller.
  Every negotiation is between two families (0187's distinct_families check),
  so the accept gate no longer reads the thread for a same-family case that
  cannot exist, and the comment claiming one is corrected. Decline and
  withdraw stay open to every party.
- Circles: shareListingAction is for parents and adults too, as create and
  join already were; it puts the listing in front of other households who can
  bid, buy and arrange a pickup. Unshare and leave stay open.
- close-auctions: the notice re-check pages through the 7-day window oldest
  first (200 per page, at most 25 pages) instead of reading the 50 newest, so
  an older lost notice is reached. A family whose notice scope is null is
  logged and counted as `skipped`, not as a failure, so it no longer makes
  every run return 502 for the whole window; a failed write or a throw still
  does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
…rets, audit config

- Every admin gate (assertSuperAdmin, superAdminGate, requireMarketingAdmin,
  the /admin layout, the /admin action guards, /api/admin exports) now refuses
  a password-only session of a super-admin with an enrolled authenticator,
  and fails closed when the assurance level cannot be read
  (lib/auth/super-admin-assurance.ts).
- adminSetUserBanAction refuses to ban a built-in/env or DB super-admin.
- saveStripeSettingsAction checks its read error and never writes blank
  secrets, so a fee change cannot null the stored keys.
- /admin/admins revoke/deactivate also remove the super_admins grant (same
  immutability/self-lockout rules); every roster change is audited.
- adminCreateUserAction audits the account before the family join.
- Feature-tier set/reset and AI config saves are audited with an actor; the
  tier save is optimistic on updated_at so a concurrent save is not lost.
- Marketplace report resolve refuses a closed report before withdrawing,
  puts the listing back if the report did not move, and audits withdrawals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
… through the circle view, on both schemas

The proposed economy SQL (not applied) adds marketplace_orders.buyer_family_id
and replaces the whole-row circle read on marketplace_listings with the view
marketplace_circle_listings. The app now works before and after it:

- Order reads that were scoped to family_id alone (loadOrderRole, the orders
  page, setOrderStatusAction, leaveReviewAction, the marketplace home and the
  trust score card) read family_id OR buyer_family_id through readFamilyOrders,
  and fall back to the family_id read on 42703/PGRST204. The status update is
  scoped to the order's own family_id; the pickup calendar event goes on the
  confirming party's calendar.
- The auctions board, community feed, auction panel and the placeBid/buyNow
  pre-reads read other families' listings from the view (columns checked
  against what it exposes) and fall back to the table read on PGRST205/42P01.
  Same-family reads stay on the table. Order titles for won listings come from
  the view.
- Missing-object recognition reuses isMissingSchemaObject from
  lib/messages/schema-compat.ts, with one warning per object.
- New RPC refusals: manager_only maps to actions.onlyAParentGuardianCan16,
  already_leading to a new key actions.yourMaxAlreadyCoversThat (all 7 full
  catalogues), and buy-now's not_available/bids_placed to actions.buyItNowIsnT.
  placeBid keeps refusing the leading household until the SQL lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
… for step-up

audit_logs.resource_id is a uuid, so every audit row keyed by a setting
key, an email or 'singleton' was refused by Postgres and only logged to
the console. recordAudit/logAudit now move a non-uuid resourceId into
metadata.resource_key and send null, and the call sites that did this
(tier-features set/reset, AI config, roster invite and grant removal,
super-admin grant/revoke, Stripe settings, feature flags) pass null or
the real row id with the key in metadata.

- The super-admin step-up also gates the feedback status write, the
  onboarding-funnel and journeys dashboards, and /admin/onboarding's
  service-role read.
- The step-up page asks only for the signed-in user on the way into
  /admin, so it never provisions a family for an admin.
- Activating a super_administrator roster entry restores the
  super_admins grant that deactivate removed (audited).
- A listing that cannot be restored after a lost report resolve has its
  withdrawal audited; the tier-save conflict and the step-up refusal of
  tier/AI saves are translated.
- The test fake refuses a non-uuid resource_id the way Postgres does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
…ntact through the hourly cap

Review fixes on 3aa0979 (the Guardian/Locator audit commit).

- lib/location/retention.ts: a failed step is described with
  describeActionError instead of repeating the raw Postgres message; the
  "no raw .message crossing the response boundary" invariant failed on it.
- lib/guardian/sms-processing.ts, app/api/guardian/inbound/whatsapp/route.ts:
  the 10-per-hour per-sender cap held every trust level, so a trusted
  family contact's eleventh text in an hour was written `blocked` and never
  announced, and a retry completed against that. Finding 6 targeted blocked,
  spam and unknown senders and the model call. Past the per-sender cap a
  contact at a ring-through trust level (immediate_family, close_family,
  trusted_friend) now loses only the model call, never the delivery; the
  per-family cap still holds for everyone.
- lib/guardian/sms-processing.ts: the SMS-lane emergency escalation runs
  under an explicit 30 s step budget instead of smsStep's 5 s default (two
  Twilio requests per manager, 15 s deadlines each), so a timeout no longer
  answers 503 while the alerts keep going out detached.
- app/api/guardian/screen/route.ts: the emergency fan-out runs in Next's
  `after()` once the TwiML has gone out (Twilio's 15 s limit), with the
  inline path kept where `after()` throws outside a request scope.

Tests: a trusted contact's eleventh SMS and WhatsApp is still announced for
each of the three levels; a known contact's and an unknown sender's eleventh
is still held; the family cap still holds for a trusted contact; the
screening route answers before the fan-out and the fan-out still runs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
Four held-lane workflows (kid-progress-award, invite-rejoin-role,
guest-household, reward-snapshot) are the owner's negative controls for the
held 0496, 0495, 0498 and 0500: each replays every runnable migration,
requires its held probe to FAIL on the released schema with named breach
lines, applies the held file twice and requires the probe to pass. On this
branch all four died at step 1 ("the held probe failed on the released
schema, but not on: ..."): 0481's kid_progress guard, calendar_feeds guest
guard and reward_redemption_request_shape_guard, and 0482's accept_invite
rewrite, each closed the hole the held file exists to close, so the probe
failed on the wrong error. Decision: the released schema behaves exactly as
main does for those four subjects, each deferred to its held migration.

  0481  the kid_progress section (kid_progress_write_is_a_managers(),
        trg_kid_progress_manager_write, the three kid_progress_manager_*_guard
        policies), the calendar_feeds guest trigger and
        reward_redemption_request_shape_guard() with its trigger are removed;
        the header records the three deferrals (0496, 0498, 0500 and their
        workflows). rls-sweep-check drops those three tables' fixtures and 17
        assertions (148 -> 131, all held). reward-redemption-decision-check's
        trigger inventory goes back to the four on main.
        tests/a-manager-gated-table-is-manager-gated-on-screen.test.ts drops
        the kid_progress pin a399516 added.
  0482  section C (accept_invite) is removed; A1/A2, B1-B3, D1/D2 and the
        self-check stay. 0136's function is the released one; the returning
        member's role is 0495's, and the inviter-still-active re-check is a
        follow-up after 0495 lands. The probe drops C01-C03 (36/15/20 ->
        33/14/18) and moves every fixed uuid into its own 0482000x-...
        namespace, which tests/boundary-probes-are-rerunnable.test.ts asked
        for (a0000000-/c0000000- ids are also seeded by committing probes).
  0484  the DELETE that collapsed duplicate vacation_documents pairs is
        replaced, at the coordinator's request, by a fail-closed preflight:
        vacation_documents_duplicates_refuse() raises 23505 naming the count
        and a sample of the duplicated pairs and deletes nothing; the unique
        index is built only when it passes; a second run on a clean schema
        succeeds and the self-check also pins the function. New probe
        docs/audit/a-trip-links-a-document-once-check.sql proves the refusal
        leaves both seeded duplicates in place and that on the released schema
        the index exists, a second identical link is 23505 and NULL
        document_id rows coexist.
  tests tests/a-client-write-reads-what-it-changed.test.ts pins the reminders
        helper's family-scoped update; tests/reminder-provenance-ui.test.ts's
        fake builder now chains .eq() so the module's .eq('id').eq('family_id')
        resolves. docs/PENDING_PROD_MIGRATIONS.md and docs/audit/README.md
        record the deferrals, the 0484 refusal and the new probe.

Measured on a throwaway PG16 (initdb as ubuntu, port 54621): 460/460
migrations apply; db:audit:migrations passes (next free 0486); probes
188/188 (0 skipped, 0 failed), rls-sweep 131/131, 0482 probe 14 refused /
18 landed, gated-write 173 tables agree; 0481, 0482 and 0484 re-apply
cleanly. All four held workflows reproduced: each probe fails on the
released schema with every line its yml greps for, each held migration
applies twice, each probe then prints its OK line. vitest: the 37 files
touching these probes, migrations and tests (1404 tests) pass, the three
CI failures included; eslint clean on the changed tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K

Copy link
Copy Markdown
Owner Author

Additional scope hold at2f0dd17da1c0ae4c095a5d95f84309c78e824266, Guardian/Locator lane: vercel.json and scripts/cron-dispatch.mjs now schedule /api/cron/location-retention daily03:40. The handler deletes older location_events and clears coordinates. Source inspection confirms this candidate wiring; no activation or execution is asserted.

Bounded next task for this lane's owner: keep the destructive retention sweep unscheduled and outside the deployable candidate pending explicit production-configuration/data-retention authorization. Preserve source/test evidence in a held, non-running lane without removing historical IDs. Do not execute the endpoint, trigger a deployment, run cleanup against real data, or infer authorization from the broader source-audit task. Report exact commit and disposable-test evidence separately. The migration-assembly scope hold6100395136 remains independent and pending.

Copy link
Copy Markdown
Owner Author

Guardian/Locator source review at2f0dd17, separate from retention scope-hold6101009975. Bounded repair for this lane's existing owner, source plus synthetic tests only:

  1. Preserve recoverable emergency delivery state end-to-end. SMS completes its receipt after undelivered/record_failed; WhatsApp logs escalation failures then marks processed/200; screening after() only logs. Released claims alone do not schedule another attempt. Add integration tests for each actual inbound caller with transient total transport failure, then successful retry, without duplicate sends. The separate escalate endpoint's503 test does not cover these callers.
  2. Propagate cancellation/deadlines into escalation transports. Sequential15-second requests across multiple managers can exceed smsStep's30-second budget while its supplied signal is ignored. Test slow multiple-manager fan-out and prove no sends continue after cancellation.
  3. Member-removal cleanup failures must remain visible/recoverable rather than returning unqualified success. Existing location-retention sweep does not repair member_locations. Add cleanup-storage-failure tests without deleting real data.
  4. Treat model-cap read failures and concurrent distinct callbacks explicitly; read-error-as-zero/count-before-insert does not prove a hard cap. Add count-failure/concurrency tests or qualify the guarantee. Recheck active same-family membership in WhatsApp and screening's final destination lookup.

These are static findings, not reports of production incidents. Keep provider acceptance distinct from handset delivery. Do not call real recipients, activate schedules, alter production settings, or apply SQL. Preserve existing task IDs, source evidence and authorization/role checks; report exact commits and synthetic test results.

…retention policy

The child-safety audit fix (3aa0979, merged in b3697b7) added
app/api/cron/location-retention — a sweep that deletes location_events
older than a window and clears coordinates — and scheduled it daily at
03:40 in both vercel.json and scripts/cron-dispatch.mjs. A destructive
retention sweep must not run from the deployable candidate until the
owner has set a retention policy, so this takes it off both schedulers.

- vercel.json: drop the location-retention entry only; the other 28
  entries are byte-identical and the JSON still parses.
- scripts/cron-dispatch.mjs: drop the SCHEDULES row and leave a comment
  saying the route is held, why, and where the hold is pinned.
- The route, its handler (enforceLocationRetention) and lib/location/
  retention.ts are unchanged in behaviour: it stays reachable only with
  CRON_SECRET, by hand.
- tests/cron-schedule-registration.test.ts: the 1:1 disk<->schedule rule
  stays total; it gains an explicit HELD_UNSCHEDULED list naming
  /api/cron/location-retention as "held, unscheduled pending a retention
  decision", and a new test that checks the hold in both directions (the
  route must still exist and must be in neither scheduler, with a reason
  naming the pending decision) so the list cannot go stale or become a
  licence.
- tests/a-removed-member-takes-their-location-with-them.test.ts: the
  "is scheduled, in both places" pin now asserts the hold honestly —
  absent from vercel.json and the dispatcher table, handler still wired.
- lib/location/retention.ts and lib/realtime/published-tables.ts: one
  line each noting the cron is unscheduled pending the owner's retention
  decision (and the JSDoc no longer calls it "the scheduled sweep").

docs/PENDING_PROD_MIGRATIONS.md has no decisions section, so no row was
added there.

Validated: vercel.json parses (28 crons, location-retention absent);
node --check scripts/cron-dispatch.mjs; `--dry-run --at 03:40Z` no
longer lists the route; eslint --no-ignore clean on all changed .ts/.mjs;
vitest 20 files / 224 tests passed (schedule registration, removed-member
retention, every test touching location-retention or lib/location, and
the cron-dispatch/mirror/catch-up/auth/fan-out/failed-run/secrets suites).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K

Copy link
Copy Markdown
Owner Author

Re 6101009975 (retention sweep) and 6101029395 (Guardian/Locator source review):

  • Retention sweep: held unscheduled in 6f2853786. /api/cron/location-retention is removed from both vercel.json and scripts/cron-dispatch.mjs; the route and enforceLocationRetention are untouched and remain reachable only by hand with CRON_SECRET. tests/cron-schedule-registration.test.ts holds it by name with the reason ("pending a retention decision") and checks the hold in both directions, and the removed-member test pins it as unscheduled, so re-enabling requires a deliberate edit once the owner sets a retention window. 20 files / 224 tests green on the schedule and location consumers.
  • Held-lane overlaps: carved out in 113eb3b90. 0481 no longer guards kid_progress, the calendar_feeds guest write or the reward request shape; 0482 no longer rewrites accept_invite. All four negative-control workflows (0495/0496/0498/0500) were reproduced locally exactly as their YAML specifies: probe fails on the named breach lines on the released schema, held file applies twice, probe passes. 0484 now refuses with 23505 when duplicate (vacation_id, document_id) links exist and deletes nothing, with a probe proving both rows survive (docs/audit/a-trip-links-a-document-once-check.sql). 188/188 probes.
  • The four Guardian findings are being verified against the source end to end and fixed where real, on a separate branch with synthetic tests only: recoverable emergency delivery state after a transient total transport failure (per inbound caller, retry without duplicate sends), cancellation/deadline propagation into the escalation transports, qualified results on member-removal cleanup failure, and a fail-closed, concurrency-safe model cap plus active same-family re-checks in the WhatsApp lane and the screening destination lookup. Commit and test evidence will follow on this PR.

Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Source receipt for6f2853786fd00e1651c5f4095fecfc2de83593d9 / ACK6101072439: removal from both automatic schedules satisfies the unscheduled portion of6101009975. The deployment-scope portion remains open: app/api/cron/location-retention/route.ts is unchanged from2f0 and still directly invokes the destructive sweep on an authorized GET. Tests requiring that route to stay wired establish only the weaker unscheduled condition.

Please finish the same bounded hold by excluding the executable endpoint from the deployable candidate and retaining the implementation/evidence in a non-running held source/test location. Preserve history and do not activate, invoke or change production credentials/configuration. Keep the ongoing separate Guardian source/test repair with its current owner; no duplicate assignment is requested. Migration-assembly authorization remains a separate pending question.

6f28537 took the location-retention sweep off both schedulers, but the
executable endpoint app/api/cron/location-retention/route.ts was still in
the deployable candidate and ran the destructive sweep (delete old
location_events, clear coordinates) on any authorized GET. This finishes
the hold: the route file moves, with history (git mv), to
held/api/cron/location-retention/route.ts. Next does not route held/, so
no deployment exposes it. lib/location/retention.ts and every retention
test are unchanged in behaviour.

- held/api/cron/location-retention/route.ts: the handler, byte-for-byte,
  with a header saying why it sits outside app/ and how to restore it
  (move back, re-add the two schedules, drop the hold from the test).
- tests/cron-schedule-registration.test.ts: the disk<->vercel.json 1:1
  rule is exact again (no exception needed: a held route is not under
  app/). HELD_UNSCHEDULED now records where each held route sits and why,
  and the hold test checks both directions: absent from app/api/cron,
  present at the held path (which must be under held/), in neither
  vercel.json nor the dispatcher table, still gated by
  hasCronAuthorization, reason naming the pending decision.
- tests/a-removed-member-takes-their-location-with-them.test.ts: the
  route pin asserts app/api/cron/location-retention/route.ts no longer
  exists and reads the held file, which must still wire
  enforceLocationRetention(createServiceClient()).
- scripts/cron-dispatch.mjs, lib/location/retention.ts,
  lib/realtime/published-tables.ts: the notes from 6f28537 now say the
  route is held under held/ and not deployed.
- package.json: `held` added to the lint dirs. tsconfig already includes
  **/*.ts, so tsc typechecks held/; linting it too keeps the two
  consistent (eslint is clean on the held file).

Scanner tests were checked for held/: hardcoded-locales uses git ls-files
over app/components/lib; shared-secrets walks app+lib and the route reads
no *SECRET itself; .tsx-only walkers skip a .ts route; no catalogue test
requires every key to be used, so the route's locationRetention.* keys
staying in lib/i18n/messages is fine.

Validated: vercel.json parses (28 crons, route absent); node --check
scripts/cron-dispatch.mjs; eslint --no-ignore clean on every changed
.ts/.mjs including the held file; vitest 36 files / 559 tests passed (the
20 from 6f28537 plus both held/-referencing tests and the route-scanning
suites: middleware-public-api-boundary, shared-secrets, cron-auth,
every-key-a-t-call-names, catalogue-key-rendered-through-t,
i18n-catalogue-integrity, notification-write-boundary, the
unconfirmed-write and route-write ratchets, whole-table-reads,
a-closed-family-is-left-alone-by-the-crons, robots coverage,
ai-route-rate-limit, a-filtered-delete, vault, in-filter);
NODE_OPTIONS=--max-old-space-size=6144 npx tsc --noEmit -p . exit 0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K

Copy link
Copy Markdown
Owner Author

Re 6101084415: done in 976c6c8a5. app/api/cron/location-retention/route.ts is moved (git mv, history kept) to held/api/cron/location-retention/route.ts, outside Next's routing, so no deployable endpoint invokes the sweep; lib/location/retention.ts and every retention test stay. tests/cron-schedule-registration.test.ts now pins the hold in both directions (absent from app/api/cron, present under held/, in neither scheduler, auth check still present in the held file, reason names the pending decision) and the disk↔vercel.json 1:1 rule is exact again. 36 files / 559 tests and the typecheck are green.


Generated by Claude Code

@NewWorldVenture
NewWorldVenture marked this pull request as ready for review October 10, 2026 20:05
@NewWorldVenture
NewWorldVenture merged commit 209e5a4 into main Oct 10, 2026
30 checks passed

Copy link
Copy Markdown
Owner Author

Merged as 209e5a48b; production /api/build-info reports it (20:10Z). Two checks are red on that main commit, and both were red before this PR:

  • Verify production and optionally apply reviewed migrations (supabase-production-migrations.yml, verify-only on push): step Verify household module schema fails with HTTP 401 on all 52 checks — the production service-role key the workflow holds is rejected. The same step failed on every run of this workflow on main since 2026-10-02 (d25e39eac, 1b933a19d, d4ec0b65d, 7dfa47db4, 6d9d9207f). No code change fixes it: the SUPABASE_SERVICE_ROLE_KEY secret has to be replaced by the owner, which is also the precondition for the manual apply=true dispatch of 0477–0485.
  • Supabase Preview (the Supabase GitHub App, not a repo workflow): canceling statement due to statement timeout (SQLSTATE 57014) while the preview branch replays the retired-synthetic-users cleanup block. It failed identically on 291e6987c, a9b7e8708 and 6d9d9207f; on this PR it was skipped. Owner-side (preview-branch settings or that block), not this PR's.

The repo's own CI on 209e5a48b is still running and is being watched.


Generated by Claude Code

NewWorldVenture pushed a commit that referenced this pull request Oct 10, 2026
…removal cleanup, model cap)

The four Guardian findings raised on #989's thread, each confirmed by two
skeptics and fixed with negative controls:

- An emergency nobody could reach is retried by a bounded sweep instead of
  being marked complete after a transient transport failure; a family with
  no reachable manager is recorded once as unreachable, not retried forever.
- The step deadline and request cancellation reach the escalation and its
  Twilio sends; managers are reached in parallel.
- A removal whose location forget failed says so on every removal surface.
- The inbound model cap fails closed when its count cannot be read, the
  WhatsApp lane counts after recording, and both lanes and the screening
  lookup re-check active membership.

Conflicts in lib/location/retention.ts and its test resolved keeping main's
hold of the retention sweep alongside the branch's removed-member repair.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
NewWorldVenture pushed a commit that referenced this pull request Oct 10, 2026
#989 fixes the same defects as this branch's sign-in, ensureActiveFamily,
sync-policy, invite-email and onboarding changes, so main's code is kept
for all of them and this branch's versions, the duplicate tests and the
unused locale key are dropped. What stays is the requireUserContext
redirect of a kid login with no family to /kid-login, and its test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
NewWorldVenture pushed a commit that referenced this pull request Oct 11, 2026
…iews

Main's #989 released 0477-0485. 0481 already narrows tax_documents (plus
a teen's or child's own), health_visits and immunizations for caregivers
and guests; 0482 already refuses a kid login's families_insert by a
child_logins row or the synthetic domain. Every held workflow was re-run
against the new replay; three changed and are rebaselined here.

0505 (review 6101040142). Remove no longer deletes the photo before it
knows the outcome: lib/marketplace/remove-listing.ts deletes the row
first, reads back the removed row's photo_url, and deletes that photo
only once the row is gone. On 0505's refusal it always asks 0317's locked
status change to withdraw and reads "Cannot move listing from withdrawn/
completed" as already kept, so a stale screen or another tab's relist is
decided from the row as it is now. Behavioural tests drive refusal,
failed withdrawal, already-kept, relist, filtered delete and failed photo
cleanup; no photo is lost while a listing is kept. The service-role,
null-uid and family-deletion paths are recorded as limits, not a
preservation guarantee.

0506 (review 6101355094). Who may log is separated from whose history is
shown (lib/health/record-scope.ts): anyone still logs a night, check-in
or meal for any member; a manager or the member sees the whole record and
its summaries; anyone else sees only what they logged, labelled, with no
summary built from part of a record. Same before and after release. The
author term is labelled a policy exception needing owner confirmation.
Released baseline: teen and child 8 of 8 tables, caregiver and guest 6.

0507 recut. 0482 closed most of it; what remains is the server's
app_metadata kid-login mark, which 0495 reads and 0482 does not. 0507 now
widens 0482's is_child_login_account() by that test instead of adding a
trigger. The service-role provisioning path is recorded as the
membership lane's (review 6101404893).

0508 (review 6101849884). On top of 0481 the rows lose the teen/child
self-read (the owner's "managers only"), and tax files now answer to the
rows' step-up: an enrolled manager at aal1 is withheld the files too.
The probe adds enrolled-manager aal1/aal2 controls on existing and new
paths, a step-up mutation, and says plainly that no signed URL or
download is exercised. rls-sweep-check (main's 0481 probe) reads which
rule is installed for the child's own tax document.

Checks: all 19 held workflows simulated on the new replay; 188 of 188
released probes; the stack through 0508 applied twice with every held
probe and 188 released probes passing; full vitest passes but for the two
Node-22 environment tests; tsc, eslint and the i18n gate clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KRUgA6hD6QgzmtpSP6TUmP
NewWorldVenture pushed a commit that referenced this pull request Oct 11, 2026
Main (#989) independently added AssistantCtx.role, for the goals service, and
widened isSensitiveMemory's stems. Resolved:
- AssistantCtx keeps main's single role field; its doc now also names
  list_pending_decisions, which reads it here.
- Call sites and test contexts take main's spelling (role: ... ?? null).
- The assistant engine passes the caller's real role (acting?.role ??
  input.role ?? null), not scope.role, which falls back to 'adult' when no role
  is known: a role nobody has is never a manager, and main's own doc for the
  field says an unknown role is refused. The source pin follows.
- isSensitiveMemory keeps main's regex, plus the forms it still missed:
  "prescribed" (prescrib\w*), plural "pins" and "passports".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01776xJyhVe8xJQvTGeNfT9T
NewWorldVenture pushed a commit that referenced this pull request Oct 11, 2026
…mports

CI on 964180c (merged with main) failed tests/medications-fixture-module-
graph.test.ts: tests/e2e/notification-email-preference.spec.ts, added on
main by #989, stubs '@/lib/supabase/errors' with describeDbError and
wroteNoRows only, and components/ui/input.tsx (Field) imports
localizeDbErrorText from it on this branch. The stub now returns the
text unchanged; that fixture is not about language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRY21ZKsFrfB3qtoP972A4
NewWorldVenture pushed a commit that referenced this pull request Oct 11, 2026
#989's money-writes fix landed the same invest-order check this branch
made (a non-manager orders only on their own wallet), answering "child
wallet not found". Main's action code is kept; this branch's test now
pins main's answer, and keeps the Invest page offering Buy / Sell /
Request only on cards the viewer may order on, and the allowance/goal
same-family check, which main does not carry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
NewWorldVenture pushed a commit that referenced this pull request Oct 11, 2026
Main's #989 already skips closed families in every sending cron and at
push and email delivery, so this branch's own closed-family change in
the five senders is dropped for main's code. Of its test, only the
weekly digest case is kept (a-closed-family-gets-no-weekly-digest),
which main's closed-family suite does not drive. That suite's mock of
automation-steps now spreads the real module, since the runners also
read a retried run's log and count sends through it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
NewWorldVenture pushed a commit that referenced this pull request Oct 11, 2026
#989 landed the same own-wallet check for invest orders and answers a
sibling's wallet as not found. The reconciling merge kept main's code but
left this test's expected error behind; it now asserts main's answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
NewWorldVenture pushed a commit that referenced this pull request Oct 12, 2026
#989 landed the same server-side lock for closed and trial-ended
families: resolveFamilyEntitlement, lockedEntitlementCode, and the
refusal in assertAIAccess, resolveFeatureEntitlement and
refuseUnlessEntitled (codes trial_expired / account_closed). Main's
code is kept for all of it; this branch's own version of those gates
is dropped.

What stays from this branch, on main's primitives:
- requireFeature never refuses /dashboard/billing for a locked family
  (no billing-to-billing redirect) and sends a closed family to plain
  billing rather than the upgrade view;
- /api/ai/chat runs the assistant's feature gate and assertAIAccess;
- lib/server/account-standing.ts refuseClosedOrTrialEnded, on the ten
  ungated Free-tier AI/voice routes, and the gift helper on the gift
  link's family. Pantry chef is dropped: main gates it as Smart Kitchen.
Tests are aligned to main's codes; the route tests warm their imports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
NewWorldVenture pushed a commit that referenced this pull request Oct 12, 2026
Main now refuses a closed or trial-ended family in assertAIAccess,
resolveFeatureEntitlement and refuseUnlessEntitled (#989 and later), so
the branch's versions of those, its requireFeature billing redirect and
their two tests are dropped in favour of main's.

Still needed and kept: /api/ai/chat runs refuseUnlessEntitled and
assertAIAccess like /api/ai, and the Free-tier AI helpers (habits,
journal, notes, relationship, voice, the gift page) refuse a closed or
trial-ended family via lib/server/account-standing.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DXw2nu25BjyRfA6Fg3YiMS
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants