Skip to Content
RunbooksRunbook: Onboard a Family Manager Household

Runbook: Onboard a Family Manager Household

Owner: Family Manager / Platform Team Backup owner: On-call engineer Last validated: 2026-06-14 Validation method: Live dogfood on the Barash household (org_2c2ebbe574c0990c53c11400) — email → pending approval E2E Severity trigger: SEV3 (a household cannot capture/forward; no platform outage, no data loss) Customer impact: Without onboarding, the household’s mobile surfaces return 404 feature_not_enabled and forwarded email is never ingested Required access: SSH (VPS 178.105.8.25), docker exec on curateme-backend-b2b, MongoDB (B2B db). M365 admin center only for the optional premium path. Related services: curateme-backend-b2b, Microsoft Graph API (premium path only), Redis Time to complete: ~5 minutes (automatic at-scale path); ~15 minutes (one-time premium M365 path)


A Family Manager “household” is just a consumer-household org on the platform. Onboarding it means: (1) marking the org as a consumer_household tenant, (2) turning on the FM_* feature flags for that org, (3) giving it a forwarding alias and an allow-list of approved senders, and (4) populating its members, children, and memory. The premium per-org Microsoft 365 identity is an optional add-on, not a requirement.

There are two ingest lanes, and you should know which one you are on before you start:

  • Automatic, at-scale (default): every household shares ONE license-free M365 mailbox (assistant@curate-me.ai) via Exchange plus-addressing (assistant+<tag>@curate-me.ai). No per-org M365 provisioning, no per-household license. This is what M365_CONSUMER_INGEST_USER_ID + M365_CONSUMER_INGEST_ADDRESS arm.
  • One-time premium (optional): a household adopts its OWN M365 mailbox (e.g. family@curate-me.ai) as a paid AgentIdentity. This is gated behind FM_PREMIUM_IDENTITY, which is pinned OFF in every environment because it costs a real license. Only the wiring script + --force can turn it on.

Prerequisites

Before starting, confirm:

  • VPS accessssh curateme@178.105.8.25, and docker exec rights on curateme-backend-b2b.
  • The household org exists and is a consumer_household tenant. Orgs created by the iOS Sign-in-with-Apple bootstrap (_bootstrap_apple_user in src/api/routes/mobile.py) are marked automatically. Legacy/unmarked orgs are backfilled by scripts/fm_backfill_tenancy.py. The beta-enable and premium-wiring tools refuse a non-consumer_household org without --force.
  • The shared ingest lane is armed (for the automatic path) — both M365_CONSUMER_INGEST_USER_ID and M365_CONSUMER_INGEST_ADDRESS are set in ~/platform/.env.production on the VPS. Unset → the M365 ingest branch never matches and no household mail is ingested. (Show the env var NAMES only; values live on the VPS.)
  • The inbound webhook subscription existsM365_WEBHOOK_CLIENT_STATE is set and the Graph change-notification subscription is live + renewed (the 12h renewal beat task src/tasks/m365_subscription_renewal.py).

For the optional premium path you additionally need: M365 admin center access, an existing manually created mailbox + its Graph user GUID, and MICROSOFT_GRAPH_TENANT_ID / MICROSOFT_GRAPH_CLIENT_ID / MICROSOFT_GRAPH_CLIENT_SECRET set on the VPS.

Worked example used throughout: the Barash household, org_2c2ebbe574c0990c53c11400. Substitute the real target org id.


Step 1: Confirm the org is a consumer household

The FM_* surfaces are consumer-only — a B2B workspace calling them gets a fail-closed 404. Confirm the marker before enabling anything.

ssh curateme@178.105.8.25 docker exec curateme-backend-b2b mongosh "$MONGO_URI" --quiet --eval \ 'db.getSiblingDB("curate_dashboard").organizations.findOne( {_id: "org_2c2ebbe574c0990c53c11400"}, {tenant_profile: 1})'

If tenant_profile is "consumer_household", continue. If it is missing or "b2b_workspace" on an org you KNOW is a household (it has mobile_apple_identities rows), run the backfill — dry-run first, then execute:

docker exec -e PYTHONPATH=/app curateme-backend-b2b \ python /app/scripts/fm_backfill_tenancy.py # dry-run (default — scans, writes nothing) docker exec -e PYTHONPATH=/app curateme-backend-b2b \ python /app/scripts/fm_backfill_tenancy.py --execute # write the markers

Verification: the findOne above returns tenant_profile: "consumer_household". (Note: get_tenant_profile also self-heals a missing marker on first access when an Apple-identity row exists — so a freshly SIWA-bootstrapped org may already be correct without the backfill.)


Step 2: Enable the FM_* flags for the household

Family Manager is EXPLICIT-OFF in production (feature_flags._fm_default): every FM_* flag defaults False in prod. The sanctioned per-household enable path writes org_feature_flag_overrides rows that the member-authed mobile routes honor via the async is_feature_enabled_for_org resolution.

Run scripts/fm_beta_enable.py inside the container. With no --flags, it enables the Phase-2 wedge set: fm_assistant_profile, fm_inbound_email, fm_documents, fm_memory, fm_expenses, fm_ask, fm_llm_extraction.

docker exec -e PYTHONPATH=/app curateme-backend-b2b \ python /app/scripts/fm_beta_enable.py org_2c2ebbe574c0990c53c11400

Enable only a subset, or disable (per-org kill), with:

docker exec -e PYTHONPATH=/app curateme-backend-b2b \ python /app/scripts/fm_beta_enable.py org_2c2ebbe574c0990c53c11400 --flags fm_documents fm_ask docker exec -e PYTHONPATH=/app curateme-backend-b2b \ python /app/scripts/fm_beta_enable.py org_2c2ebbe574c0990c53c11400 --disable

Each override is a row {org_id, flag_name, enabled, updated_at} in org_feature_flag_overrides, filtered on the exact {org_id, flag_name} pair — the tool can never touch another tenant. The tool refuses a non-consumer_household org (use --force only if certain) and refuses fm_premium_identity unless --force is also passed (that is the paid M365 path — see Step 5).

Verification: the script prints a per-org flag table; every enabled flag’s effective column reads True with source=org_override. fm_premium_identity should remain absent/False unless you are doing the premium path.


Step 3: Provision the forwarding alias (random default vs opt-in custom tag)

A household gets ONE active forwarding alias. The default is content-free: a random, non-guessable address. When the shared ingest lane is armed (M365_CONSUMER_INGEST_ADDRESS set), inbound_alias.create_alias builds the address as Exchange plus-addressing on the shared mailbox — assistant+<random-token>@curate-me.ai, provider="m365". When the lane is NOT armed it falls back to the legacy Resend shape <token>@<MOBILE_INBOUND_DOMAIN> (default in.curate-me.ai), provider="resend".

The alias is normally created by the iOS app on the Forwarding Setup screen, which POSTs to the member-authed route (idempotent — ONE active alias per org, re-POST returns created=false + 200, no quota consumed):

POST /api/v1/mobile/inbound/alias # creates the random default GET /api/v1/mobile/inbound/alias # returns the active alias DTO (404 until one exists)

For an opt-in custom tag (the household trades a little privacy — the tag appears in third parties’ inboxes — for a memorable address), the app sends custom_tag in the body:

POST /api/v1/mobile/inbound/alias { "custom_tag": "barash" } # → assistant+barash@curate-me.ai (plus-addressed; old random address goes dark)

A custom tag must be 3-24 lowercase letters/digits/inner-hyphens (_CUSTOM_TAG_RE); impersonation-bait words (admin, assistant, curate, family, security, …) and the reserved h- prefix are rejected (422 alias_tag_invalid). A tag another household already owns returns 409 alias_tag_taken.

Verification: GET /api/v1/mobile/inbound/alias returns the alias DTO with provider: "m365" (armed lane) and the expected address/tag_kind. Structured logs show mobile_inbound_alias_created with ids only — the address itself is a routing secret and is never logged.


Step 4: Seed and verify approved senders

Inbound mail is gated by an allow-list: only verified senders may write into a household (inbound_alias.is_verified_sender, fail-closed). Rows live in mobile_allowed_senders, org-scoped.

The production path is self-serve from the app — the member adds a sender and the platform mails a single-use signed link:

POST /api/v1/mobile/inbound/senders { "email": "grandma@example.com" } # → pending row + signed link emailed GET /api/v1/mobile/inbound/senders # the allow-list (pending + verified + revoked) POST /api/v1/mobile/inbound/senders/{id}/resend DELETE /api/v1/mobile/inbound/senders/{id} # revoke

The recipient clicks the emailed link, which hits the ONE public route GET /api/v1/mobile/inbound/senders/verify?token=…. The token is a 256-bit single-use secret claimed atomically; only its sha256 hash is stored, with a 48h TTL. Every failure renders the same generic page (no org/email oracle).

For dogfood / the operator’s own addresses only, you can skip the signed-link loop by seeding pre-verified rows when you run the premium-wiring script in Step 5 — --seed-sender EMAIL:MEMBER_ID writes a status="verified", verification_method="operator_seed" row directly. (Production senders should always use the add_sender → signed-link flow above.)

Verification: list the org’s senders straight from Mongo and confirm the expected addresses are verified:

docker exec curateme-backend-b2b mongosh "$MONGO_URI" --quiet --eval \ 'db.getSiblingDB("curate_dashboard").mobile_allowed_senders.find( {org_id: "org_2c2ebbe574c0990c53c11400"}, {email: 1, status: 1}).toArray()'

(The wiring script also prints verified senders: [...] at the end of its run.)


Step 5: (Optional) Upgrade to a premium M365 identity

Skip this for the automatic at-scale lane. Do it only when a household should send/receive from its OWN paid M365 mailbox (e.g. family@curate-me.ai) instead of the shared assistant@ mailbox. This is gated by FM_PREMIUM_IDENTITY, pinned OFF everywhere because it provisions a real, paid Microsoft 365 license.

First create the mailbox manually in the M365 admin center and note its Graph user GUID (Object ID). Then run the idempotent wiring script inside the container:

docker exec -e PYTHONPATH=/app curateme-backend-b2b \ python /app/scripts/fm_m365_wire_org.py \ --org org_2c2ebbe574c0990c53c11400 \ --mailbox family@curate-me.ai \ --m365-user-id <graph-user-guid> \ --display-name "Family Manager" \ --seed-sender boris.barash@gmail.com:<member_id>

The script (idempotent, safe to re-run) does five things:

  1. org_feature_flag_overrides — flips fm_premium_identity → enabled for this org.
  2. agent_identities — adopts the manually created mailbox as the org’s ACTIVE primary identity (m365_license_sku="O365_BUSINESS_PREMIUM"). This is adoption, not provisioning — provision_primary short-circuits on the existing identity and never calls Graph create_user.
  3. upgrade_to_premium_identity — flips the assistant profile to identity_mode="b2b_agent_identity".
  4. ensure_inbound_subscription — creates the Graph change-notification subscription (requires M365_WEBHOOK_CLIENT_STATE and the public webhook path deployed).
  5. Prints the org’s verified senders so you can confirm who may email the assistant (plus any --seed-sender rows from Step 4).

Reads its M365 tenant id from MICROSOFT_GRAPH_TENANT_ID on the VPS. The mailbox must already be visible in Graph — a freshly created shared mailbox can take a few minutes to appear.

Verification: the script prints identity adopted: family@curate-me.ai (<guid>) (or identity already active on re-run), upgrade: …, and subscription: …. Confirm the identity row:

docker exec curateme-backend-b2b mongosh "$MONGO_URI" --quiet --eval \ 'db.getSiblingDB("curate_dashboard").agent_identities.findOne( {org_id: "org_2c2ebbe574c0990c53c11400", template_id: null}, {mailbox_address: 1, status: 1, m365_user_id: 1})'

It should show the mailbox and status: "active".


Step 6: Populate members, children, and memory

These are member-authed app surfaces (the iOS app drives them); you rarely call them by hand, but onboarding is not complete until the roster reflects the real household.

  • Adults (the roster): GET /api/v1/mobile/members lists the org’s organization_members; POST /api/v1/mobile/members { "display_name": "…" } adds a non-owner placeholder row (NO key, NO login) into ctx.org_id. A second device becomes a real authenticated member only after a device-join invite + Sign-in-with-Apple bind: POST /api/v1/mobile/members/{member_id}/invite mints a single-use, TTL’d invite that the new device redeems at POST /session/apple (revoke outstanding invites with POST /members/{member_id}/invite/revoke).
  • Children (managed people who do NOT log in): GET/POST /api/v1/mobile/children, PATCH/DELETE /children/{child_id}. Not flag-gated — a child roster is a foundational consumer-household primitive (like /members) — but still consumer-household gated.
  • Household memory: POST /api/v1/mobile/memory/facts records an explicit fact (source_type="explicit_user_entry", highest launch confidence), gated by FM_MEMORY. Most facts accrue automatically from approved calendar proposals (record_memory_candidates_from_decision derives low-risk pickup_location/place/activity_name facts). Medical / identity / payment values are blocked at launch (422 sensitive_fact_blocked) — never stored.

Verification: GET /api/v1/mobile/members returns the expected roster with is_self set for the caller; GET /api/v1/mobile/children lists the kids; GET /api/v1/mobile/memory/facts returns any explicit facts. Each returns 404 feature_not_enabled only if the corresponding flag from Step 2 is off.


Step 7: End-to-end smoke

Send a real email from a verified sender (Step 4) to the household’s forwarding address (Step 3). Within a minute or two it should ride the shared-mailbox plus-tag lane through the Graph inbound webhook and land as a pending approval in the app.

Verification: confirm an inbound message row + the approval landed:

docker exec curateme-backend-b2b mongosh "$MONGO_URI" --quiet --eval \ 'db.getSiblingDB("curate_dashboard").mobile_inbound_messages.find( {org_id: "org_2c2ebbe574c0990c53c11400"}, {status: 1, created_at: 1} ).sort({created_at:-1}).limit(3).toArray()'

In the app, the message appears in GET /api/v1/mobile/inbound/messages (metadata only) and the proposal in the approval queue. Logs carry alias_/sender_/msg_ ids only — never raw addresses, subjects, or bodies.


Rollback / If it goes wrong

  • Disable the household entirely — write enabled=false overrides without deleting anything:
    docker exec -e PYTHONPATH=/app curateme-backend-b2b \ python /app/scripts/fm_beta_enable.py org_2c2ebbe574c0990c53c11400 --disable
    Every FM_* route then returns 404 feature_not_enabled for that org again.
  • 404 feature_not_enabled on every FM route after enabling — either the org is not a consumer_household (Step 1) or the override rows didn’t write. Re-run the flag table: fm_beta_enable.py <org> prints effective + source per flag.
  • Alias created but no mail ingested — confirm both M365_CONSUMER_INGEST_USER_ID and M365_CONSUMER_INGEST_ADDRESS are set in ~/platform/.env.production (BOTH arm the lane), the alias provider is m365 (not resend), and the Graph subscription is live (M365_WEBHOOK_CLIENT_STATE set; the renewal beat task running). A brand-new shared mailbox can take minutes to appear in Graph.
  • Sender link never verifies — the token is single-use + 48h TTL; have the member POST /inbound/senders/{id}/resend. For your OWN dogfood address, re-run the wiring script with --seed-sender EMAIL:MEMBER_ID to skip the link loop.
  • Premium identity wiring failed mid-run — the script is idempotent; re-run it. A 403 Insufficient privileges / 401 Invalid client secret means the Graph app registration env vars on the VPS are missing or rotated (see the Agent Identity Provisioning runbook). FM_PREMIUM_IDENTITY was NOT meant for this household → run fm_beta_enable.py <org> --flags fm_premium_identity --disable --force and delete the M365 mailbox manually.