## Summary
This PR advances the Rhodes → Aerie migration on two fronts so Aerie can become the source of truth for school-site data without regressing behavior.
1. Runtime write parity. The Aerie MCP write path previously reimplemented Rhodes mutations as thin, direct table patches in chat/convex/rhodes/mcp.ts, bypassing Rhodes' validation, audit logging, notifications, derived-status updates, and safety checks. This PR ports Rhodes' canonical mutation behavior into Aerie as real Convex modules under chat/convex/rhodes/runtime/* and makes approved MCP mutations dispatch to them (matching Rhodes' CONVEX_DISPATCH approach), instead of writing tables directly. Generic CRUD modules under chat/convex/rhodes/* remain only for migration/import plumbing.
2. Migration parity hardening. The Rhodes → Aerie baseline import and reconcile are hardened to validate by data meaning and graph shape, not by matching Convex _ids — provisioning placeholder users for missing Rhodes users, rewriting Rhodes ids embedded in serialized JSON payloads, computing a shared retained graph so import and reconcile agree on what should exist, and producing far better reconcile diagnostics. After these changes, Aerie Dev was reset and reimported from Rhodes Dev and a full reconcile passed with zero gaps (no missing mappings/rows, no stale rows, no content mismatches, no missing users, no outbox failures).
Server-to-server auth for the migration is also consolidated onto a single AERIE_RHODES_SHARED_SECRET, with migration URLs derived from the existing Convex site URLs (explicit URL overrides remain optional).
### Changes
Runtime write parity (canonical Rhodes mutations in Aerie)
- chat/convex/rhodes/runtime/* *(new)* — Canonical Rhodes mutation runtime ported into Aerie: mutationDispatcher.ts, mutationAuthorization.ts, pendingLifecycle.ts, actors.ts, audit.ts, ids.ts, constants.ts, milestones.ts, siteReadModels.ts, documentGapReadModels.ts, and writes/{site,workUnit,workUnitGroup,task,note,document,costBreakdown,changeLog}Writes.ts. These reproduce Rhodes' write behavior (validation, audit, notifications, derived status) rather than patching tables directly.
- chat/convex/rhodes/mcp.ts — Collapse the local write switch (~1.2k lines removed) so approved MCP mutations delegate to the runtime modules above.
- chat/convex/rhodes/pendingMutations.ts — Disable the generic get / list / listByStatus reads (they now throw and direct callers to the rhodes.mcp pending-mutation APIs), keeping only the CRUD writes used by plumbing.
- chat/lib/rhodes-mutation-tools.ts — Minor wiring for the runtime dispatch path.
- chat/convex/rhodesMcpMutationParity.test.ts, chat/convex/rhodesMcpParity.test.ts *(new)* — Parity tests covering the highest-risk MCP mutations against canonical Rhodes behavior.
- chat/convex/_generated/api.d.ts — Regenerated for the new runtime modules.
Migration parity hardening
- chat/convex/rhodes/userRefs.ts, dualWrite.ts, migration.ts — Add getOrCreateRhodesPlaceholderUser (deterministic placeholders keyed on clerkId = rhodes-placeholder:<email>) so dual-write and migration provision missing Rhodes users instead of throwing. Expose ensureUsersByEmail on the migration HTTP API and add includeExists to listMappings (mapping page limit raised to 500) to support reconcile and placeholder preflight.
- chat/convex/migrations/rhodesBackfill.ts — Add serializedJsonFields so embedded Rhodes ids in JSON payloads (auditLog.before/after, notificationDispatchLog.payload, pendingMutations.args, webhookLog.payload) are parsed and rewritten to Aerie ids.
- sync/src/scripts/rhodes-migration.ts, rhodes-migration.test.ts — Add buildRetainedGraph (transitive required-parent pruning) so import and reconcile agree on retained rows; rewrite serialized ids (including typed refs like pendingMutations.args.noteId for deleteNote/updateNote) before comparison; harden reconcile with --compare-sample-size=0, a sample ladder before full parity, includeExists readback, normalized row comparison from a shared snapshot, first-differing field/path/value diffs, HTTP retry/timeout, and examples for each gap category.
- sync/src/scripts/rhodes-bulk-baseline.ts, rhodes-bulk-baseline.test.ts — Build the retained graph after export and filter by retained ids, create placeholder users on execute (report on dry-run), support ignoring missing user emails, add serialized-reference checks and HTTP timeout/retry, and quiet export progress logging — so bulk import agrees with the hardened reconcile.
- chat/convex/migrations/resetRhodesBulkBaselineTables.ts *(new)* — One-shot, per-table reset for the Aerie tables populated by rhodes:bulk-baseline, so a fresh Rhodes → Aerie baseline starts from a clean target graph.
- chat/convex/rhodesDualWrite.test.ts — Cover the placeholder-user path.
Auth / env / docs
- .env.example — Consolidate migration auth onto AERIE_RHODES_SHARED_SECRET; treat RHODES_MIGRATION_EXPORT_URL / RHODES_MIGRATION_STATUS_URL / AERIE_DUAL_WRITE_URL / AERIE_RHODES_MIGRATION_URL as optional overrides (normally derived from RHODES_CONVEX_SITE_URL / CONVEX_SITE_URL).
- features/rhodes-migration/PLAN.md — Update operator/deployment env to the consolidated shared-secret model.
- features/rhodes-migration/RUNTIME_WRITE_PARITY_GAPS.md *(new)* — Audit of MCP write-parity gaps and the recommended canonical-dispatch fix direction (the basis for the runtime port above).
- features/rhodes-migration/RHODES_DEV_TO_AERIE_DEV_GOAL_PROMPT.md *(new)* — Goal/runbook for the Rhodes Dev → Aerie Dev migration.
### Design Decisions
- Dispatch MCP writes to canonical modules, not per-case logic in mcp.ts. A thin switch can't preserve Rhodes' validation, audit, notification, and derived-status behavior. Porting the real mutation modules and dispatching to them keeps Aerie's MCP write behavior in lockstep with Rhodes and is testable via the parity tests.
- Validate the migration by meaning, not by _id. Convex _ids differ between deployments, so reconcile compares normalized retained rows and rewrites embedded Rhodes ids (including serialized JSON and typed refs) before diffing. A shared buildRetainedGraph ensures import and reconcile prune the same unreachable rows (e.g. missing site → prune workUnitGroup → workUnit → task/pendingMutation).
- Placeholder users instead of hard failures. Missing Rhodes users would otherwise block imports or drop rows; deterministic placeholders (rhodes-placeholder:<email>) keep the graph importable and reconcilable.
- Typed serialized-ref detection is intentionally narrow. Detection targets known refs (e.g. pendingMutations.args.noteId) rather than a broad "any key ending in Id" heuristic, which produced false positives against external ids.
## Test Plan
- [x] pnpm --filter @bran/chat typecheck and pnpm --filter @bran/sync typecheck clean
- [x] biome check clean on changed files (pre-commit hook)
- [x] Rhodes dual-write / legacy-id-map / schema tests pass (chat)
- [x] Sync migration + bulk-baseline tests pass (sync)
- [x] MCP write-parity tests pass (rhodesMcpMutationParity, rhodesMcpParity)
- [x] Aerie Dev reset + reimported from Rhodes Dev; full reconcile passed with zero gaps (no missing mappings/rows, no stale rows, no content mismatches, no missing users, outbox clean)
- [ ] Reviewer: spot-check the runtime write modules against Rhodes canonical mutations for any behavior intentionally dropped (e.g. Wrike)
- [ ] Reviewer: run resetRhodesBulkBaselineTables (dry-run) then a fresh rhodes:bulk-baseline + reconcile against real snapshots