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 return404 feature_not_enabledand forwarded email is never ingested Required access: SSH (VPS178.105.8.25),docker execoncurateme-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 whatM365_CONSUMER_INGEST_USER_ID+M365_CONSUMER_INGEST_ADDRESSarm. - One-time premium (optional): a household adopts its OWN M365 mailbox (e.g.
family@curate-me.ai) as a paid AgentIdentity. This is gated behindFM_PREMIUM_IDENTITY, which is pinned OFF in every environment because it costs a real license. Only the wiring script +--forcecan turn it on.
Prerequisites
Before starting, confirm:
- VPS access —
ssh curateme@178.105.8.25, anddocker execrights oncurateme-backend-b2b. - The household org exists and is a
consumer_householdtenant. Orgs created by the iOS Sign-in-with-Apple bootstrap (_bootstrap_apple_userinsrc/api/routes/mobile.py) are marked automatically. Legacy/unmarked orgs are backfilled byscripts/fm_backfill_tenancy.py. The beta-enable and premium-wiring tools refuse a non-consumer_householdorg without--force. - The shared ingest lane is armed (for the automatic path) — both
M365_CONSUMER_INGEST_USER_IDandM365_CONSUMER_INGEST_ADDRESSare set in~/platform/.env.productionon 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 exists —
M365_WEBHOOK_CLIENT_STATEis set and the Graph change-notification subscription is live + renewed (the 12h renewal beat tasksrc/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 markersVerification: 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_2c2ebbe574c0990c53c11400Enable 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 --disableEach 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} # revokeThe 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:
org_feature_flag_overrides— flipsfm_premium_identity → enabledfor this org.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_primaryshort-circuits on the existing identity and never calls Graphcreate_user.upgrade_to_premium_identity— flips the assistant profile toidentity_mode="b2b_agent_identity".ensure_inbound_subscription— creates the Graph change-notification subscription (requiresM365_WEBHOOK_CLIENT_STATEand the public webhook path deployed).- Prints the org’s verified senders so you can confirm who may email the assistant (plus any
--seed-senderrows 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/memberslists the org’sorganization_members;POST /api/v1/mobile/members { "display_name": "…" }adds a non-owner placeholder row (NO key, NO login) intoctx.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}/invitemints a single-use, TTL’d invite that the new device redeems atPOST /session/apple(revoke outstanding invites withPOST /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/factsrecords an explicit fact (source_type="explicit_user_entry", highest launch confidence), gated byFM_MEMORY. Most facts accrue automatically from approved calendar proposals (record_memory_candidates_from_decisionderives low-riskpickup_location/place/activity_namefacts). 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=falseoverrides without deleting anything:Everydocker exec -e PYTHONPATH=/app curateme-backend-b2b \ python /app/scripts/fm_beta_enable.py org_2c2ebbe574c0990c53c11400 --disableFM_*route then returns404 feature_not_enabledfor that org again. 404 feature_not_enabledon every FM route after enabling — either the org is not aconsumer_household(Step 1) or the override rows didn’t write. Re-run the flag table:fm_beta_enable.py <org>printseffective+sourceper flag.- Alias created but no mail ingested — confirm both
M365_CONSUMER_INGEST_USER_IDandM365_CONSUMER_INGEST_ADDRESSare set in~/platform/.env.production(BOTH arm the lane), the aliasproviderism365(notresend), and the Graph subscription is live (M365_WEBHOOK_CLIENT_STATEset; 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_IDto skip the link loop. - Premium identity wiring failed mid-run — the script is idempotent; re-run it. A
403 Insufficient privileges/401 Invalid client secretmeans the Graph app registration env vars on the VPS are missing or rotated (see the Agent Identity Provisioning runbook).FM_PREMIUM_IDENTITYwas NOT meant for this household → runfm_beta_enable.py <org> --flags fm_premium_identity --disable --forceand delete the M365 mailbox manually.
Related
- Agent Identity Provisioning — the M365 mailbox + Graph app-registration mechanics the premium path builds on
scripts/fm_m365_wire_org.py— the idempotent premium-adoption wiring scriptscripts/fm_beta_enable.py— per-householdFM_*flag allowlist toolscripts/fm_backfill_tenancy.py—consumer_householdtenant-marker backfillservices/backend/.env.example(M365 consumer-ingest block, ~line 820) — theM365_CONSUMER_INGEST_*andMOBILE_INBOUND_DOMAINenv var names