[rhodes_upstream_data_map.md](https://github.com/user-attachments/files/27020060/rhodes_upstream_data_map.md)
# Rhodes upstream coverage — REBL3 + ISP + Wrike + Aerie→Rhodes write path
Closes the bulk of the [Rhodes Upstream Data Coverage](https://linear.app/builder-team/project/rhodes-upstream-data-coverage-0f56ae8a5053/overview) project end-to-end:
- [AERIE-184](https://linear.app/builder-team/issue/AERIE-184) sub-issues 1, 3, 4, 6 — REBL3 connector, per-field merger, ISP fetcher + adapter, Wrike LiDAR Vendor extractor.
- [AERIE-195](https://linear.app/builder-team/issue/AERIE-195) — Analytics Worker → Rhodes write path: typed RhodesClient + two orchestrators (merger + Wrike LiDAR backfill), env-gated until [Rhodes PR #56](https://github.com/AI-Builder-Team/Rhodes/pull/56) lands.
- [AERIE-183](https://linear.app/builder-team/issue/AERIE-183) — closed by context_cache/rhodes_upstream_data_map.md (audit artefact, attached on the issue).
AERIE-184 sub-issue 2 (Sindri webhook gap-closure) is Rhodes-repo work; out of this PR's scope.
## Summary
This PR ships the Aerie half of the entire Rhodes upstream coverage initiative, structured as two parallel tracks:
Track A — Ingestion (AERIE-184). Bring up canonical readers for every upstream source the audit identified: REBL3 (full client + connector + per-site enrichment), ISP (DDB+S3 direct fetcher), Wrike (LiDAR Vendor Matterport-URL extractor). Each is a pure, fully-tested unit independent of any write path.
Track B — Write path (AERIE-195). Land the typed RhodesClient that wraps Rhodes' new /sync/aerie/* httpAction surface, then two orchestrators that compose Track A's primitives into actual Rhodes writes:
1. Rhodes merger orchestrator (every 5 min per AERIE-183's MVP target) — reads REBL3 + ISP + Schema-UI-from-current-Rhodes, runs the four-layer per-field precedence merger, diffs vs Rhodes state, sends only changed fields with per-field provenance JSON. Single writer per field set by design — no cross-orchestrator races.
2. Wrike LiDAR Vendor backfill orchestrator (daily — values are populated once per site and near-static; daily preserves Wrike rate-limit headroom for the rest of the platform). Writes ONLY the structured matterportModelId field via a separate Rhodes route. Never clears.
Both orchestrators are env-gated. They short-circuit with a one-time warn until ops sets RHODES_CONVEX_SITE_URL + RHODES_API_KEY after Rhodes PR #56 deploys — same pattern as the REBL3 ingestion env-gate.
## Commits
| SHA | Title | Lines |
|---|---|---:|
| 755c75f | feat(sync/upstream): add REBL3 client + Zod schemas + contract tests | +1,342 |
| 1c41daa | feat(analytics): add rebl3Sites Convex table + sync HTTP endpoint | +572 |
| 1ed1276 | feat(sync/upstream): wire REBL3 connector + worker cadence (slice 2B) | +699 |
| 11938b7 | feat(sync/upstream): add REBL3 per-site enrichment pass (slice 2C) | +927 |
| 48fc3f7 | feat(sync/upstream): add Rhodes per-field source merger (sub-issue 3) | +741 |
| 19617e4 | feat(sync/upstream): add ISP -> field-merge shape adapter (sub-issue 4 prep) | +275 |
| 907c9fd | fix(sync): guard REBL3 sync on missing env + opt out from analytics-worker tests | +68 |
| 527b4b9 | refactor(rebl3): address PR #120 review — high+medium+low+nit issues | +682 |
| 6a03d4e | feat(sync/upstream): add ISP fetcher (DynamoDB + S3) and Wrike LiDAR Vendor extractor | +977 |
| badd2d7 | feat(sync/upstream): add typed RhodesClient for Aerie -> Rhodes writes (AERIE-195) | +766 |
| 814ea46 | feat(sync/upstream): Rhodes merger + Wrike LiDAR orchestrators + 5-min tier (AERIE-184 + AERIE-195) | +2,116 |
| 518001e | feat(sync/upstream): close Rhodes merger follow-ups — REBL3 status, HubSpot, daily REBL3 cadence | +1,274 |
| 2528949 | refactor(sync/upstream): address PR #120 fresh-review fixes — Critical 1+2, all High, blocking Medium + Low | +1,383 |
~33 files, ~11,900 lines net-new in sync/ + chat/, ~300 net-new tests, full sync suite 922/922 green, chat tests 17/17 green.
## Architecture in one diagram
┌─────────────────────────────────────── Aerie analytics worker (this PR) ────────────────────────────────────────┐│ │
│ ── Track A: ingestion primitives (pure, fully tested) ───────────────────────────────────────────────── │
│ │
│ sync/src/upstream/rebl3/ sync/src/upstream/isp/ sync/src/upstream/wrike/ │
│ client.ts (Zod-validated REST) fetcher.ts (DDB scan + lidar-vendor.ts (Matterport │
│ types.ts (passthrough schemas) S3 spillover resolve) model_id regex extractor) │
│ sync.ts (paginated + enriched │
│ ingestion → rebl3Sites) │
│ │
│ ── Track B: Aerie → Rhodes write path (AERIE-195) ───────────────────────────────────────────────────── │
│ │
│ sync/src/upstream/rhodes/client.ts ─── typed RhodesClient ───┐ │
│ upsertSiteMetadata(slug, payload) │ │
│ setMatterportModelId(slug, id|null) │ │
│ listSites() │ │
│ ▼ │
│ sync/src/upstream/rhodes/sync.ts ── refreshRhodesMerger ── /sync/aerie/upsertSiteMetadata │
│ 1. listSites() → baseline + diff state │
│ 2. paginate REBL3 ┐ │
│ 3. fetch HubSpot │ per-field precedence │
│ 4. fetch ISP per scan ├─ via mergeRhodesSiteFields ── diff vs Rhodes ── upsert(only-changed-fields) │
│ 5. SchemaUi from now ┘ │
│ Cadence: every5Minutes (AERIE-183 target) │
│ │
│ sync/src/upstream/wrike/lidar-vendor-sync.ts ── refreshWrikeMatterportBackfill │
│ listSites filter wrikeFolderId → batched /folders fetch → extract → setMatterportModelId(only-on-diff) │
│ Cadence: daily (LiDAR Vendor near-static; preserves Wrike rate-limit headroom) │
│ │
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
│
┌─── X-Api-Key (rh_*) ──── HTTPS ───┐
▼ │
┌── Rhodes (PR #56 ─ new in that PR) ───────────────────────────────┴──────────────────────┐
│ POST /sync/aerie/upsertSiteMetadata │
│ POST /sync/aerie/setMatterportModelId │
│ GET /sync/aerie/listSites │
│ matterportModelId (new structured field on sites) │
└──────────────────────────────────────────────────────────────────────────────────────────┘
## Architectural choices documented inline (full list)
### REBL3 (Track A foundation)
- Two REBL3 site tables coexist intentionally. Existing siteRebl3 is keyed on siteWrikeId for the Buildout Details panel; new rebl3Sites is keyed on rebl3Slug for analytics worker writes. REBL3-only sites without a Wrike folder are now representable. Eric's [Dashboard Consumers → Rhodes Migration](https://linear.app/builder-team/project/dashboard-consumers-rhodes-migration-80c21164a066) owns retiring siteRebl3.
- HTTP route + bearer token, not ConvexHttpClient.mutation(). Required because upsertRebl3Sites and patchRebl3SiteEnrichment are internalMutation (server-only). Mirrors the expense-sync architecture exactly.
- Bulk + enrichment in one weekly cycle. enrichmentAborted surfaces explicitly so the worker only marks the cadence clean on a fully successful run.
- Asymmetric 404 handling. /status returns null on 404 (REBL3 quirk); /site/{slug} treats 404 as a hard error. Documented at every call site.
- Zod .passthrough() everywhere; mapper is intentionally not forward-compat — unknown REBL3 fields silently drop at the storage boundary. Adding a new REBL3 field is a four-step manual workflow (schema column + arg shape + upsert type + mapper assignment) by design.
- 50ms politeness delay between every REBL3 HTTP request — both bulk pagination and per-site enrichment. ~20s of wall time per weekly cycle to be a courteous Vercel tenant.
### Per-field merger (Track A → Track B contract)
- Rule table, not a single global precedence. Different field classes want different rules: REBL3-primary (address, scoring, lease), HubSpot-primary (marketingName, gradeRange), ISP-primary (capacity-derived), Schema-UI-primary (contact info).
- Whitespace-only Schema-UI values do NOT shadow lower-precedence layers (trim before empty check). Prevents accidental "user typed spaces, lost the HubSpot value" failure mode.
- NaN rejection on number rules with fallback to next-precedence layer.
- Zero is a real value — tuition: 0 is a legitimate write, not "missing data".
- occupancyLoad fully wired (was orphaned in early commit; review pass caught + fixed).
### Aerie → Rhodes write path (Track B)
- X-Api-Key header auth, not Bearer. Matches Rhodes' existing verifyAerieApiKey httpAction helper that SHA-256s the key and looks it up in the apiKeys table via api.apiKeys.validateByHash. Key minted by Yibin 2026-04-23 (prefix rh_020a3f2).
- Class-based RhodesClient mirrors RebL3Client. Injectable fetch for tests, AbortSignal.timeout() per request (default 30s), Zod-validated responses, RhodesError preserves status + body for caller inspection.
- Single writer per field set. The merger is the ONLY orchestrator that writes to merger-managed columns. The Wrike LiDAR backfill writes ONLY the disjoint matterportModelId field via a separate route. No cross-orchestrator races by design.
- Diff-before-write. Merger compares its output against listSites current state and skips the HTTP roundtrip entirely when nothing changed. Server is also idempotent (outcome: "no_change") — pre-diffing saves the round-trip and keeps the audit log free of spurious 0 fields changed entries.
- "Empty fields are the feature, not the bug." Merger never sends undefined — missing upstream data leaves Rhodes values untouched. The Wrike LiDAR backfill never clears — empty LiDAR Vendor is treated as "not from Wrike", not "delete what Sindri/manual entry put there".
- Per-cycle caps as circuit breakers. maxIspFetches=500, maxSites=10_000, maxFolders=2_000. Defensive against surprise inventory growth.
- Cadence-advance discipline mirrors REBL3. markRefreshed only fires on a fully clean run (zero errors anywhere in the pipeline). Any degradation holds the tier so the next worker iteration retries within the cadence window.
### Cadence (AERIE-183 target)
AERIE-183 §Deliverable 2 pins the MVP at *"5 minutes where live streaming is not feasible; adjust per source based on data volume."* Per-source assignment:
| Path | Tier | Why |
|---|---|---|
| REBL3 ingestion (rebl3Sites) | weekly | Site inventory changes quarterly; ~150 sites; weekly is well above change rate. |
| Rhodes merger (rhodesMergerSync) | every5Minutes | Pure read/diff/write; ~free on no-op; meets AERIE-183 target. |
| Wrike LiDAR backfill (rhodesWrikeMatterportBackfill) | daily | LiDAR Vendor is near-static; daily preserves Wrike rate-limit headroom. |
A new every5Minutes tier was added to REFRESH_TIERS for this. The shouldRefresh/markRefreshed/cadence-test surfaces all generalise transparently.
## What 2528949 (most recent) closed — fresh-review pass
Addresses every Critical + every High + every blocking Medium + most Low from the post-update internal review.
Critical
- #1 ISP fetcher: ONE Scan per cycle, not N. New fetchAllLatestIspAnalyses() does one DDB Scan filtered to status="completed", in-memory group-by-model_id with deterministic latest-per-model selection, returns Map<model_id, IspFetchResult>. Merger orchestrator calls it once per cycle and looks up per-site in O(1). Per-site fetchLatestIspAnalysisByModelId kept as test-only injectable + one-off lookups.
- #2 Zod runtime validation on ISP-fetched JSON. Both inline and S3 branches now validate via IspAnalyzeResponseSubsetSchema before returning — malformed JSON / wrong types / canonical shape changes throw with a source-label-prefixed error rather than silently propagating garbage.
High
- #3 subset-compat actually catches renames now — replaced one-way assignability check (which was permissive due to optional fields) with per-field-path indexed-access checks (ISPAnalyzeResponse["recommended_capacity"], etc.). Renaming any path on canonical fails compilation.
- #4 pickLatestByCreatedAt deterministic tie-break on lexicographic job_id.
- #5 Worker Rhodes-merger branch tests — 5 new tests cover every gate branch (clean / errCount / upserts.failed / isp.failed best-effort / provisioning gap).
- #6 zoning documented as intentionally not mapped (raw REBL3 string vs Rhodes enum; mapping table owned by AERIE-195 future work).
- #7 Explicit AWS SDK retry config ({ maxAttempts: 5, retryMode: "adaptive" }) on DDB + S3 clients.
- #8 IAM scope + Sergio-signoff requirement documented in fetcher docstring as runbook item.
Medium
- #9 Per-site ISP failures are best-effort in worker gate (bulk ISP failures still gate via __bulk:isp-batch).
- #10 Provisioning-gap detection: notFound > 50% of sites → "PROVISIONING GAP" warning + clock holds.
- #11 Provenance docstring aligned with patch-level reality.
- #12 RhodesClient bounded retry for 502/503/504 + network throws (default 3 retries, exponential backoff). 4xx and 500 NOT retried. 8 new tests.
- #13 hubspot-fetcher.ts header note clarifying it's Redshift-backed, not HubSpot REST.
- #15 Wrike maxFolders truncation surfaces as errors["__truncated"] + visible warn (not silent log).
- #16 HubSpot name-collision drops are now logged.
- #17 AWS SDK version skew investigated — intrinsic to package release cadence (util-dynamodb lags client-dynamodb); current pin is the best alignment achievable.
Low
- Wrike LiDAR regex/comment alignment fixed (removed /show/m/ID mention that didn't match the regex or empirical sample).
- missingFolders counter on Wrike backfill result (folders that don't appear in Wrike's batch response).
- Sites sorted by slug before iteration → deterministic per-cycle log order.
- ISP fetcher console.warn for bucket mismatch swapped to injected log for consistency.
- New every5Minutes cadence test pins the 300_000ms interval.
Nits
- isp-subset.ts docstring explains why subset fields are all optional (matches at-rest persisted JSON; runtime required-field assertion lives in fetcher's Zod validation).
NOT fixed (with rationale in commit body)
- Low #20 / #24 (logging prefix consistency) — existing prefixes already grep-able.
- Med #14 (listSites pagination) — out of scope at current scale; tracked as follow-up.
- Low #21 (no resumption) — idempotent merge-then-diff makes worst case acceptable; cursor state would be meaningful complexity for marginal benefit.
## What 518001e closed
Three follow-ups the merger originally flagged as out-of-scope are now in:
1. REBL3 status enrichment in the merger — loiSignedDate / leaseSignedDate / projectedOpenDate now flow into Rhodes. Implementation reads from Aerie's Convex rebl3Sites.workflowStatuses cache (updated daily by the connector) via a new internalQuery listRebl3SiteEnrichment + GET /sync/analytics/rebl3 httpAction; the merger calls it every cycle, so newly-cached dates propagate to Rhodes within 5 min of the next REBL3 ingestion. Pure parser handles all four signed-status synonyms REBL3 emits (signed/done/completed/complete), ignores the leasing system (post-sign workflow ≠ lease-signed), and reads projected_open_date regardless of due-diligence status.
2. HubSpot layer wiring (real Redshift join) — replaces the v1 empty-map default with a production fetcher that queries staging_education.hubspot_programs_raw (the canonical raw HubSpot mirror — explicitly NOT core_education.dim_school, which is the stale derived table flagged for removal once canonical-sites lands; TEMP comments span analytics/refresh.ts and the related queries). Joins to Rhodes sites by normalised display name (lowercase + trim + collapse-whitespace). Sites with no name match get NO HubSpot layer (the merger handles that correctly). New file sync/src/upstream/rhodes/hubspot-fetcher.ts with full unit tests.
3. REBL3 ingestion cadence: weekly → daily — REFRESH_TIERS.rebl3Sites bumped. This is independent of the Rhodes write path (the merger reads REBL3 directly every 5 min, so Rhodes already gets 5-min freshness on REBL3 data); the cadence change only affects Aerie's own rebl3Sites Convex cache, which JC's dashboards read against. Daily catches every realistic LOI / lease / diligence event without weekly's worth of staleness in front of operators. Cost: ~300 REBL3 requests/day, well under any rate limit.
## Remaining open follow-ups (NOT blocking this PR)
1. HubSpot join: name-based v1 → explicit alias table. Today's join uses normalised display-name matching. An explicit staging_education.rebl3_hubspot_alias table (or extension to the existing map_school_alias) would close the long tail of name mismatches. The fetcher's signature accommodates that swap as a drop-in replacement.
2. Audit doc fix on aerie/sync/src/analytics/capacity.ts (Klair-ISP repo). v1 of rhodes_upstream_data_map.md mis-described that file. v2 corrects + supersedes; doc landing alongside this PR.
3. Sindri webhook health verification (sub-issue 2 — Rhodes repo). Operational state of the existing Sindri webhook is unknown post-freeze. If silent → AERIE-184 #2 becomes unsourced_frozen; if firing → gap-closure is a Rhodes-repo PR (processInboundWebhook extension).
4. Migrate siteRebl3 consumers off the legacy table; remove legacy writes. Owned by Eric's Dashboard Consumers project.
## What this enables for the dashboards
- Master Site Pipeline Dashboard ([AERIE-185](https://linear.app/builder-team/issue/AERIE-185)): site cards with rebl3Slug + name + address + classification land directly. Columns 1–3 (Pre-op search / diligence) read rebl3Sites.workflowStatuses. Compound by_classification_state index supports filtering on both at once.
- Site Detail View ([AERIE-186](https://linear.app/builder-team/issue/AERIE-186)): Panel 1 (Rebel diligence statuses) reads the same workflowStatuses. Panel 3 (School facts) gets address + capacity + REBL3 scoring + agent_results.budget for diligence cost estimates.
- Columns 4–7 of the Kanban (milestone-gated) still depend on Benji's [Unified Sync Pipeline](https://linear.app/builder-team/project/dashboard-consumers-rhodes-migration-80c21164a066). Columns 8–9 + Detail Panel 5 (quality bars) depend on existing Aerie data. Detail Panel 4 (artefact links) depends on Yibin's Schema UI.
## Reviewer guide
The branch is large (11 commits, ~9k lines net) but is structured to review well in chunks if you want to take it one slice at a time. Suggested order:
1. 755c75f (REBL3 client + Zod schemas) — pure foundations, no external deps.
2. 1c41daa (rebl3Sites Convex table + HTTP endpoint) — schema + write surface only.
3. 1ed1276 + 11938b7 (REBL3 connector slices 2B + 2C) — the ingestion orchestrator end-to-end.
4. 48fc3f7 (per-field merger) — pure function. The contract between every Track A primitive and Track B writer.
5. 19617e4 + 6a03d4e (ISP adapter + fetcher, Wrike extractor) — Track A primitives 2 and 3.
6. badd2d7 (RhodesClient) — typed wrapper for Rhodes PR #56's /sync/aerie/* routes.
7. 814ea46 (orchestrators + worker wiring) — composes everything above.
Code-quality passes (907c9fd, 527b4b9) are surgical; safe to skim diff-only.
### Deploy / ops dependencies (no action needed for review, but flagged for context)
The whole AERIE-195 path stays dormant in prod until two things happen:
1. [Rhodes PR #56](https://github.com/AI-Builder-Team/Rhodes/pull/56) merges + deploys. Adds the structured matterportModelId field on Rhodes sites + the three /sync/aerie/* httpAction routes the orchestrators target.
2. Ops sets RHODES_CONVEX_SITE_URL + RHODES_API_KEY env vars on the Aerie analytics worker. API key was minted 2026-04-23 by Yibin.
Until both happen, the orchestrators short-circuit with a one-time warn each (Rhodes merger sync skipped: … / Wrike LiDAR backfill skipped: …) and the rest of the analytics worker continues unchanged.
## Test plan
270+ net-new tests across this PR. Full sync suite: 890/890 green. Chat REBL3 tests: 15/15 green.
- [x] REBL3 client (sync/src/upstream/rebl3/client.test.ts, 15 tests): schema-only contract pin against recorded fixtures, URL building, response parsing, /status 404→null, error throws on non-404, getSite 404 throws asymmetric to status, resolve URL, AbortSignal timeout wiring + timeoutMs=0 disables.
- [x] REBL3 sync (sync/src/upstream/rebl3/sync.test.ts, 37 tests): pure mapper + refreshRebl3Sites orchestrator (pagination, end-of-inventory, first-page-throws-aborts, mid-cycle isolation, push-failure isolation, batchSize splitting, enrichment wiring, stuck-offset duplicate detection, per-batch error key composition, bulk politeness sleep) + full enrichRebl3Sites orchestrator (happy path, /status 404, /site throw, both fail, politeness sleep, batchSize splitting, push isolation, missing agent_results).
- [x] Convex rebl3Sites mutations + read (chat/convex/analyticsRebl3.test.ts, 15 tests): insert, patch, all-optional, bulk, per-row failure isolation, indexes by_classification + by_state, JSON round-trip, identity preservation, listRebl3SiteEnrichment slim shape + null-omission + empty-table.
- [x] Per-field merger (sync/src/upstream/rhodes/field-merge.test.ts, 30 tests): all four-layer precedence chains, edge cases (all-empty, null vs undefined, empty-string-as-null, zero-is-real, partial layers, all-four-layers precedence), whitespace handling (whitespace-only does NOT shadow, padded values trimmed, tab/newline), NaN handling (rejected with fallback), purity check.
- [x] ISP shape adapter (sync/src/upstream/rhodes/isp-adapter.test.ts, 10 tests): happy path, top-level vs summary precedence, optional sub-objects absent, zero passes through, end-to-end into the merger.
- [x] ISP fetcher (sync/src/upstream/isp/fetcher.test.ts, 21 tests): DDB scan + filter + pagination, S3 spillover resolution, malformed s3:// URI, malformed JSON, missing result_json on completed row, no-match returns null, latest-by-created_at picker, empty Items.
- [x] Wrike LiDAR Vendor extractor (sync/src/upstream/wrike/lidar-vendor.test.ts, ~10 tests): both URL shapes (?m=..., /models/{id}), edge cases (null, undefined, empty, whitespace, free-text, double-encoded).
- [x] RhodesClient (sync/src/upstream/rhodes/client.test.ts, 29 tests): every endpoint happy path + 4xx/5xx + shape mismatch + null-value handling + headers (X-Api-Key + Content-Type for POST, X-Api-Key only for GET) + constructor validation + timeout wiring + env helpers (configured/missing/whitespace).
- [x] Rhodes merger orchestrator (sync/src/upstream/rhodes/sync.test.ts, 29 tests): pure helpers (rebl3SiteToLayer, rhodesSiteToSchemaUiLayer, applyRebl3StatusEnrichment with real REBL3 status fixture, diffMergedAgainstRhodes including zero-is-real / unmapped-fields / empty-is-feature cases) + happy paths (REBL3+ISP, REBL3 only, Schema-UI override, HubSpot layer, REBL3 enrichment dates) + failure isolation (bulk REBL3 throws, REBL3 enrichment fetch throws, per-site ISP throws, per-site upsert throws) + counter flow-through + maxIspFetches cap.
- [x] HubSpot fetcher (sync/src/upstream/rhodes/hubspot-fetcher.test.ts, 20 tests): normaliseSchoolName (case + whitespace + null + empty), hubspotProgramToLayer (null→undefined, zero-is-real, empty-string drop), buildHubspotLayerByNormalisedName (first-wins, fallback, no-name drop, empty input), joinHubspotLayersToRhodesSites (re-key to slug, normalise both sides, no-match drop, empty-name drop, multi-site to one HubSpot record), end-to-end fetcher with mocked Redshift module.
- [x] Wrike LiDAR backfill orchestrator (sync/src/upstream/wrike/lidar-vendor-sync.test.ts, 15 tests): pure helper (pickLidarVendorValue) + happy paths for both URL shapes + skip-on-match + update-on-change + never-clears policy (empty + unparseable) + site filtering + batching at batchSize ceiling + shared folderId handling + Wrike 500 per-batch isolation + per-site upsert failure isolation + outcome-counter flow-through.
- [x] Cadence + worker wiring (sync/tests/analytics/refresh-cadence.test.ts, sync/tests/analytics-worker/index.test.ts): every5Minutes tier registered, all new domains in REFRESH_TIERS whitelist, worker tests confirm env-missing path warns once and continues without crashing.
Pre-commit hooks ran on every commit: convex-paths, biome, typecheck-chat, typecheck-sync all green throughout.
### Local steady-state check (recommended before merge)
Once Rhodes PR #56 is in dev, set RHODES_CONVEX_SITE_URL + RHODES_API_KEY (+ WRIKE_API_TOKEN + WRIKE_SPACE_ID + WRIKE_ROOT_FOLDER_ID if exercising the LiDAR backfill) and observe:
[rhodes/merger] refresh starting[rhodes/merger] REBL3 fetched N sites (keyed by slug)
[rhodes/merger] refresh complete: N sites, N updated (M fields), N no-diff, 0 failed; ISP H/T hit, 0 miss, 0 err; layers r=N/h=0/i=H/s=N (Xms)
[rhodes/merger] N sites, N updated (M fields), N no-diff, 0 not-found
[wrike/lidar-backfill] refresh starting
[wrike/lidar-backfill] complete: N sites considered, F folder IDs, F folders fetched, E extracted, … (Xms)
[wrike/lidar-backfill] F folderIds, E extracted, U updated, K unchanged
Verify in the Rhodes Convex dashboard that sites rows are getting their merged fields populated and matterportModelId is being set from Wrike for sites with wrikeFolderId. If env vars are missing, the worker now logs Rhodes merger sync skipped: … / Wrike LiDAR backfill skipped: … exactly once (not per cycle).
🤖 Generated with [Claude Code](https://claude.com/claude-code)