Read-only sizing of the paused by staff window on AppFolio database situsgroup. Every number was taken from production for this page. Nothing was written anywhere.
2026-10-23 · dock situs-appfolio-budget, row backfill-the-staff-pause-hole · UTC throughout · propflow-prod DynamoDB (named-key and GSI reads, no scans) + /aws/lambda/propflow-appfolio-sync-prod · code at origin/main 744170de9425 · read-only; no prod writes, no AppFolio call placed
| Rows in the window | Count | Missing locally | Rate |
|---|---|---|---|
unit-LESS (no UnitId) | 66 | 66 | 100.0% |
| unit-bearing | 130 | 11 | 8.5% — and all 11 are on buildings not on our roster |
| present rows, as control | 119 | — | every one unit-set, every one Recurring: No |
The 77 decompose with zero residual: 64 unit-less on buildings we cover (the defect), 2 unit-less off-roster, 11 unit-bearing off-roster. On the 23 covered buildings, 119 of 119 unit-bearing work orders are present and 64 of 64 unit-less ones are absent.
Recurring: Yes → 38 of 38 missing was a correlate, not the cause: there is no Recurring reference anywhere in the sync path (positive control — the string does appear in fixtures and UI files, so the sweep works), and all 38 recurring rows are unit-less building-level maintenance. Status is ruled out too: Completed, Assigned, Work Done and New all appear on both sides.
src/lib/domain/pms/writers/work-order.ts:1829-1836
// A property-level draft that joined no local row is not minted: a mint
// needs a unit, and mirroring AppFolio's own unit-less orders is a separate
// decision. Skipped and counted exactly as it was before property-level
// creates existed.
if (!unit) { unmatchedUnits++; continue; }
Property-level work orders may merge into a row we already hold (allowed 2026-10-10 — “the property is always known, the unit is optional”) but are never minted. A building-level work order PropFlow has never seen is never created. This is a deliberate, documented, unmade product decision — the comment says so in its own words — which makes it a founder’s call, not a lane’s.
pms WorkOrderSync[appfolio] property=appfolio-situsgroup-property-9 rows=61 created=0 updated=0 unchanged=51 unmatchedUnits=10 skipped=0 archived=0 deferred=0 errors=0
Summed over two complete consecutive ticks, 28 buildings each, identical both times: afRows=1184 created=0 updated=1 unchanged=834 unmatchedUnits=349. So 1,184 − 349 = 835 kept, against the 837 counted independently in the local store. The arithmetic closes. No AppFolio call was needed to find this — unmatchedUnits is a per-minute counter nobody reads, and that is the instrumentation finding beside the data one.
Asked whether 1,251 vs 837 was measured over one window, one date field and one property set, it was not, and the mismatch inflated the gap:
WorkOrder.createdAt, which is AppFolio’s own CreatedAt (pms-adapter.ts:2650), not local write time.1,251 is the portfolio walk’s raw row count with no property filter; 837 counts only our buildings. The per-property counters sum to 1,184 — a 67-row difference (5.4%) that no property loop ever consumed, matching the 13 of 196 (6.6%) off-roster rows measured directly (AppFolio ids 27, 28, 39, which hold 13 work orders and can never be stored because we have no building for them).now−90d date string; the only edge effect is that a local instant is property-timezone-anchored while the report filters AppFolio’s calendar day, worth about one day either way (~14 rows against 1,184).| Pair | Arithmetic | Rate | Verdict |
|---|---|---|---|
| portfolio raw vs our store | 414 / 1,251 | 33.1% | inflated — mismatched numerator |
| per-property, one window, one field, one property set | 349 / 1,184 | 29.5% | use this one |
| the 16-day pause window, like-for-like by identity | 64 / 183 | 35.0% | higher because it contains the 10-01 cliff |
So the row should carry 29.5% as the steady-state rate over the live 90-day window, with the honest range: about 29.5% across 90 days and about 35% inside the 16-day pause window, which is inflated by the 2026-10-01 recurring-maintenance cliff. The retained count (835) matching an independent full-window local count (837) is also what shows 1,184 is essentially the complete book for our buildings rather than a partial pass.
Settled from data already in hand — no third AppFolio call was placed. Both reads saved a non-PII projection (ids, property id, created date, status, recurring flag, unit id), deliberately excluding every name, address, email, phone and free-text field, so follow-up analysis needs no further request.
Placed once, read-only, on the Driver’s explicit ruling and under a precondition it set: only with computed availability ≥ 5.0 tokens and no exhaustion episode open, outside the :00–:01 minute. Gate reading at placement: AVAILABLE 6.000/6, exhaustedSince absent, minute 36.
GET https://situsgroup.appfolio.com/api/v1/reports/work_order.json
?from_date=2026-09-29&to_date=2026-10-14&paginate_results=false
placed 2026-10-23T02:36:54.740Z returned 02:36:57 elapsedMs=2416
rows=196 pages=1 truncated=false requests=1
credential: resolved from store, source=organization, database=situsgroup, clientIdLast4=86fe
Hard-bounded to one request by maxPages: 1 plus paginate_results=false, and listAllWithMeta returned truncated=false — so the 196 is a complete answer for the window, not a capped page. The credential came through the org-only door with expectedDatabase: 'situsgroup', which throws rather than answering from another database; the local environment credential is jpco/5cd0 and could not have answered this question.
| CreatedAt day | AppFolio | PropFlow | diff |
|---|---|---|---|
| 2026-09-29 | 14 | 13 | −1 |
| 2026-09-30 | 12 | 11 | −1 |
| 2026-10-01 | 63 | 6 | −57 |
| 2026-10-02 | 20 | 18 | −2 |
| 2026-10-03 | 4 | 4 | 0 |
| 2026-10-04 | 2 | 2 | 0 |
| 2026-10-05 | 9 | 4 | −5 |
| 2026-10-06 | 11 | 8 | −3 |
| 2026-10-07 | 16 | 16 | 0 |
| 2026-10-08 | 8 | 6 | −2 |
| 2026-10-09 | 10 | 6 | −4 |
| 2026-10-10 | 2 | 3 | +1 |
| 2026-10-11 | 1 | 1 | 0 |
| 2026-10-12 | 12 | 12 | 0 |
| 2026-10-13 | 5 | 5 | 0 |
| 2026-10-14 | 7 | 3 | −4 |
| TOTAL | 196 | 118 | −78 |
57 of the 78 land on one day, 2026-10-01 — 63 work orders created in AppFolio, 6 of them in PropFlow. The ±1 jitter elsewhere (including the +1 on 10-10, where PropFlow holds one more than AppFolio’s day filter returned) is a day-boundary effect: PropFlow parses CreatedAt in the property’s timezone while the report filters on AppFolio’s own calendar day, so a work order near midnight files under different days on the two sides. That explains one, not fifty-seven.
The live work_orders portfolio-wide fetch line, read at 02:36:01, 02:37:04 and 02:38:02Z today — three consecutive one-minute ticks, identical:
accountKey=appfolio#situsgroup#86fe propertiesCovered=34 totalRows=1251 requests=1 elapsedMs=3376 / 3393 / 3471
| Window | AppFolio returned | PropFlow holds | Retained |
|---|---|---|---|
| live rolling 90 days (2026-07-25 → 2028-10-23) | 1,251 | 837 | 66.9% |
| the pause window (2026-09-29 → 10-14) | 196 | 118 | 60.2% |
Those two retention rates are the same magnitude. If the pause had left an unhealed hole, the pause window would be markedly worse than the rest of the book; it is not. The pause healed to the same degree the rest of the book is synced — and the rest of the book is missing a third of its work orders.
This also demolishes the “different units” explanation this page previously offered for the 1,331-vs-937 gap in §4(a). That hand-wave said totalRows is a raw row count, not a work-order count. My probe settles it: 196 rows carried 196 distinct WorkOrderIds, every one with a populated CreatedAt. The report returns one row per work order, so a row count is a work-order count, and the gap cannot be absorbed by unit mismatch. The §4(a) fold is left in place as the record of a wrong reading; this paragraph is its retraction.
pmsSource: appfolio and a valid AppFolio property id hold ZERO work orders, ever. Only 1 of the 53 roster buildings is genuinely out of scope (no pmsSource, no AppFolio id). propertiesCovered is 34 against a 53-building roster.ticker (the sync fails closed on a missing ticker, so that would have produced exactly this shape) and 0 are isTest. Both candidate explanations are ruled out by measurement, not argument.What I did NOT establish, and it is the next question: whether the missing 78 sit on the 34 buildings the report covers (a write-side drop) or on the 19 it does not (an intake-scope gap). Distinguishing them needs the PropertyId distribution of those 196 rows, which this probe printed only in aggregate, and the Driver authorised exactly one call. One more read answers it.
The diagnosis read the window off the not dispatched — paused by staff message. I re-derived it from a different marker — whether any AppfolioClient line exists at all, which is the traffic side rather than the dispatcher side — in one Insights query over 2026-09-26 → 2026-10-23T08:00Z (recordsScanned 34,216,797, recordsMatched 4,193,762):
| UTC day | paused by staff lines | AppfolioClient lines | ALL situsgroup lines — the positive control |
|---|---|---|---|
| 09-26 → 09-28 | no rows returned at all — the connection did not exist yet | 0 | |
| 09-29 | 549 | 12,962 | 22,941 |
| 09-30 | 1,884 | 0 | 1,884 |
| 10-01 | 1,805 | 0 | 1,805 |
| 10-02 | 1,900 | 0 | 1,900 |
| 10-03 | 1,908 | 0 | 1,908 |
| 10-04 | 1,816 | 0 | 1,816 |
| 10-05 | 2,076 | 0 | 2,076 |
| 10-06 | 1,872 | 0 | 1,872 |
| 10-07 | 1,949 | 1 | 2,221 |
| 10-08 | 2,027 | 124 — see §2 | 3,169 |
| 10-09 | 2,016 | 0 | 2,016 |
| 10-10 | 1,928 | 0 | 1,928 |
| 10-11 | 1,905 | 0 | 1,905 |
| 10-12 | 1,952 | 0 | 1,952 |
| 10-13 | 1,916 | 0 | 1,916 |
| 10-14 | 1,626 | 1,870 | 86,517 |
| 10-15 → 10-22 | 0 every day | 8,751–10,630/day | 495,354–510,278/day |
Why the zeros are evidence. The third column is 1,805–2,076 on every gap day, so a zero in the second column proves the sweep ran and found situsgroup traffic — just no AppFolio client calls inside it. Without that control the zeros would be a statement about my filter.
Both edges, taken as raw lines rather than as a statistic, each from its own sort + limit query:
paused by staff: 2026-09-29T17:01:57.647Zpaused by staff: 2026-10-14T20:04:57.541Z (my own sort @timestamp desc | limit 1 over 10-14 18:00Z → 10-15 02:00Z, 172 lines matched — not inherited)Raw lines from 2026-10-08T01:16:49Z to 02:46Z are a whole-portfolio pass: AppfolioClient.requestJson GET .../reports/rent_roll.json, then Appfolio OccupanciesSync, AppfolioFutureArrivals, AppfolioContactCoverage, building after building across the roster.
It was the onboarding import, and PR #17548 — feat(pms-sync-pause): import_only mode lets the one-shot onboarding import run while recurring jobs stay paused — merged 2026-10-07T20:37:11Z, 4 hours 39 minutes before that pass started. Its own docstring, in lambda/appfolio-sync/onboarding-import-runner.ts:
The ONE pause that lets the import through is an explicit
pauseBlocksThisImportimport_onlyone (PmsSyncPauseMode), which keeps every recurring job paused — so this returnsfalsefor that org even though it IS paused.
What I cannot tell you is which code was running at 01:16Z, and the distinction matters to a reader, not to the sizing. #17548’s own docstring describes the behaviour it REPLACED — “an armed account kept importing straight through a pause” — which writes byte-identical data. Situs’s pause was set on 09-29, before import_only existed, so it was a FULL pause; after #17548 a full pause blocks the import. So either the fix was live and someone flipped the pause to import_only, or the fix was not yet live and the behaviour it replaced let the import through. The pause row is gone (§9) and I did not read the lambda’s deploy time, so I did not establish which. Either way it was the onboarding import, and either way it is why most of this hole was already gone before the pause lifted.
This is the mechanism, and it is the whole answer. The sibling row found the guest-card pull recomputing a wide window from new Date() with no cursor. Every pull on this row does the same thing, and here that shape is not the bug — it is the cure.
| Data type | Report | Window, from the code | Cursor? |
|---|---|---|---|
| work orders | work_order | workOrderHistoryWindow() = now−90d … now+2y (src/lib/domain/pms/history-window.ts; WORK_ORDER_HISTORY_WINDOW_DAYS, default 90). The portfolio-wide fetch uses the identical call — writers/work-orders-portfolio.ts:82. | None. Recomputed from new Date() on every call. |
| occupancies / lease states (1-minute hot path) | tenant_tickler | wideYearRange() = now−1y … now+2y (pms-adapter.ts:2709) | None. |
| lease states (daily history job) | tenant_tickler | pmsHistoryWindow() = 2000-01-01 … now+2y | None. Whole history, every day. |
| payment receipts | receivables_activity | refreshWindow(now, 2) = 1st of the previous month … today (src/lib/pms/run-payment-history-sync.ts). Its header: “Re-importing a month OVERWRITES its row wholesale.” | None. |
| financial periods | income_statement family | run-financials-sync.ts re-derives 3 closed months for restatements (observed live: window=2026-07..2026-09 on 10-22) | None. |
| balances (current) | rent_roll, aged_receivables_detail, delinquency | No date parameters at all (writers/balances-portfolio.ts:233,238) — these answer as-of-now and only as-of-now. | n/a |
At the resume instant, 2026-10-14T20:05Z, the work-order window was 2026-07-16 → 2028-10-14, the occupancy window 2025-10-14 → 2028-10-14, and the receipts window 2026-09-01 → 2026-10-14. All three strictly contain 2026-09-29 → 2026-10-14. The first post-resume tick re-asked for the whole hole as a matter of course.
Instrument: work-order-org-index (WOORGPK = WOORG#<organizationId>, WOORGSK = <createdAt ISO>#<workOrderId>, projection ALL). WorkOrder.createdAt is AppFolio’s own CreatedAt, not the local write time — pms-adapter.ts:2650, parseAppfolioReceivedAt(row.CreatedAt, timeZone) — which is exactly the axis the question needs.
Work orders PropFlow holds, by AppFolio creation day, straight through the window:
| 09-29 | 09-30 | 10-01 | 10-02 | 10-03 | 10-04 | 10-05 | 10-06 | 10-07 | 10-08 | 10-09 | 10-10 | 10-11 | 10-12 | 10-13 | 10-14 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 13 | 11 | 6 | 18 | 4 | 2 | 4 | 8 | 16 | 6 | 6 | 3 | 1 | 12 | 5 | 3 |
The control is the rate on both shoulders, which is the measurement that survives every objection below:
| Period | Work orders | Per day |
|---|---|---|
| 14 days before the pause (09-15 → 09-28) | 105 | 7.5 |
| 14 full days inside the pause (09-30 → 10-13) | 102 | 7.3 |
| 8 days after resume (10-15 → 10-22) | 58 | 7.3 |
A second, independent instrument says how the heal happened. A PropFlow work-order id carries the epoch-ms at which PropFlow first minted the row (wo_appfolio_<afId>_<ms>), so the mint day is readable off the sort key. Across the 1,218 rows created 2026-06-01 → 2026-10-24:
| Mint day | 10-08 | 10-14 | 10-15 | 10-16 | 10-17 | 10-18 | 10-19 | 10-20 | 10-21 | 10-22 | 10-23 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| rows | 1,120 | 38 | 5 | 3 | 3 | 2 | 10 | 16 | 14 | 6 | 1 |
Of the 105 work orders whose AppFolio CreatedAt falls inside the pause window, 66 were written by the 10-08 onboarding-import pass, 38 by the resume pull on 10-14, and 1 on 10-15. Stated precisely, because the circular version is tempting and wrong: I counted PropFlow’s rows, so I cannot say from this alone that none is missing. What I can say is that every in-window work order PropFlow knows about was written by one of the two catch-up passes — none trickled in late, none is still arriving — and that the in-window daily rate sits within 3% of the rate on both shoulders.
The resume receipt, verbatim:
2026-10-14T20:06:04.106Z PMSSyncLambda work_orders portfolio-wide fetch accountKey=appfolio#situsgroup#86fe propertiesCovered=35 totalRows=1331 requests=1 elapsedMs=3051
67 seconds after the last paused by staff line. One request. The whole 90-day book across 35 buildings.
totalRows=1331 against 937 local work orders in the same window — and what I ruled outQueried without an upper bound, the org’s WOORG partition holds 7,857 work orders, CreatedAt ranging 2018-03-15 → 2026-10-23, across 35 distinct propertyIds. Filtered to the exact window the resume pull asked for (2026-07-16 → 2028-10-14): 937 work orders on 29 buildings. The fetch reported 1,331 rows.
Ruled out — index sparsity. work-order-org-index is the retired dashboard index and its own docblock notes a backfill that counts unstamped rows, so it was the first suspect. Cross-checked against work-order-list-index, which the primary writer stamps, for the three largest buildings: 1,484 / 1,484, 1,043 / 1,043, 731 / 731. Exact agreement; the index is not dropping rows.
Ruled out — buildings AppFolio has and PropFlow does not. propertyIdsCovered=35 in the fetch log equals the 35 distinct propertyIds in the local partition.
Not ruled out, and the code says so itself. totalRows is not a count of work orders: work-orders-portfolio.ts comments that it is the walker’s raw row count and “includes rows a non-numeric PropertyId dropped”, and the per-row mapper additionally skips any row whose Status is not a known AppFolio status. Comparing 1,331 to 937 compares two different units. I did not decompose the 394.
Why this does not touch the verdict. The heal conclusion rests on the shoulder-rate comparison, which is invariant to every explanation above — any row loss that thins the window thins both shoulders equally.
batch-get-item on PROP#<propertyId> / PAYMENTS#{2026-08, 2026-09, 2026-10} for all 53 buildings on the roster — 159 keys, no UnprocessedKeys. Rows found: 27 / 29 / 29. The 24 absences are activity-scoped, not holes: writers/payments-portfolio.ts records that receivables_activity emits a row per receipt, so a building with no receipts in the window legitimately produces nothing.
| Month row | coverage stamped on every row | importedAt |
|---|---|---|
PAYMENTS#2026-09 | 2026-09-01 → 2026-09-30 — the whole month | 2026-10-23 (today) |
PAYMENTS#2026-10 | 2026-10-01 → 2026-10-23 — the whole month to date | 2026-10-23 (today) |
PAYMENTS#2026-08 | 2026-08-01 → 2026-08-31 (26 rows); one row 2026-08-08 → 2026-08-31 | 2026-10-08 (26), 2026-09-29 (1) |
August is frozen at its last import because the 2-month window no longer reaches it — and that last import was the 10-08 onboarding-import pass. September and October are rewritten wholesale on every tick, which is why they carry today’s date.
Counted out of the stored receipts lists, receipts dated inside the pause window:
| 09-29 | 09-30 | 10-01 | 10-02 | 10-03 | 10-04 | 10-05 | 10-06 | 10-07 | 10-08 | 10-09 | 10-10 | 10-11 | 10-12 | 10-13 | 10-14 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 22 | 28 | 88 | 56 | 28 | 9 | 68 | 30 | 10 | 27 | 18 | 4 | 4 | 2 | 5 | 7 |
406 receipts, every one of the sixteen days represented, not a single empty day.
Instrument: entityType-createdAt-index on GSI3PK = OCC#<organizationId>, projection ALL → 562 rows, no pagination. Status split 533 active / 23 former / 6 pending.
Mint days: 09-29: 223 · 10-08: 329 · 10-14: 3 · 10-16: 2 · 10-20: 3 · 10-21: 2. The same two-pass shape — and, as with work orders, no occupancy row was minted between 09-29 and 10-08 or between 10-08 and 10-14.
leaseStart inside it): 6, all present. Two minted 10-08, two minted 09-29 as future-dated rows, one 10-08, one 09-29.leaseEnd inside it): 9, all present — every one leaseEnd = 2026-09-30. Five minted 10-08; two were flipped to former on 2026-10-07, i.e. during the pause.unit_vacancy is a current-state report with no history axis, so there is no occupancy-vacancy time series that a pause could hole in the first place.One thing I am deliberately not claiming: five of those nine still read status: active with a leaseEnd of 2026-09-30 and an updatedAt of 2026-10-08. That is the correct reading for a month-to-month holdover after lease expiry, and I cannot distinguish “correct and unchanged” from “stale” without a live AppFolio read. Named here rather than asserted either way.
The balance stream is the exception, and it is the exception because it is the only one of the four whose source report takes no date.
BAL#{tenantId}#{recordedAt} under PROP#<propertyId> is a change log, not merely a current value — despite TenantBalanceSnapshot’s own docstring calling itself the current balance. Sampled 12 of the 53 buildings (7 carried BAL# rows; 234 rows). recordedAt by day:
| 09-29 | 09-30 → 10-07 | 10-08 | 10-09 → 10-13 | 10-14 | 10-15 | 10-17 | 10-18 | 10-19 | 10-20 | 10-21 | 10-22 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 23 | nothing — 8 days | 163 | nothing — 5 days | 31 | 4 | 1 | 1 | 2 | 4 | 1 | 4 |
Why it is nonetheless close to inert. Every production consumer of getBalanceHistory reduces to the latest row: balance-read.ts’s getCurrentBalance (.at(-1) after dropping future-dated stubs), renewal-eligibility-now.ts:181 (max by recordedAt), leasing.ts:2103 (last element), getLatestBalancePerTenant. The current balance is correct and fresh — rows written 10-22. Two consumers render the intermediate rows, and they are the whole of the visible loss: dashboard/chat/tools.ts:1822 getTenantBalanceHistory, which returns the raw list, and tools.ts:1233, which attaches the newest N dated snapshots to a single-tenant staff lookup. So a PM who asks “show me this tenant’s balance history” sees no observation between 09-29 and 10-08, and none between 10-08 and 10-14. Nothing decides anything off those rows.
Retroactive availability is measured, not inferred, for three of the four streams — measured in the strongest possible way, by the fact that the retroactive reads already happened and the rows are sitting in production. AppFolio served a 90-day-back work_order window on 10-14, a month-and-a-half-back receivables_activity window on every tick since, and a year-back tenant_tickler window every minute. For the fourth, the balance change log, there is no retroactive read in our client at all, and whether AppFolio’s API could serve an as-of-date aged receivable is unmeasured — it is not in the client, not in docs/integrations/appfolio.md, and it is a question for AppFolio rather than a measurement we can take.
Cost is not the obstacle, and it is not close. The receipt in §4(a) is requests=1 for the entire 90-day work-order book across 35 buildings. Occupancies and payments ride the same portfolio-wide walks. Against a bucket of 6 tokens / 15s = 24/min = 34,560/day, running at 19% average utilisation while refusing roughly 215 of our own calls a day, re-running all three walks is a handful of requests — below the noise floor of a single minute’s normal traffic.
archiveDroppedWorkOrders: true reconciles deletions going forward, but a create-then-delete inside the window left nothing to reconcile against.)work_order report has no update column — measured 2026-07-30, UpdatedOn/UpdatedAt both 0 of 565 populated — and filters on CreatedAt, so the batch report structurally cannot catch it. history-window.ts records this as a known accepted gap covered by the daily wo_enrichment sweep, which has had eight days to run. I did not quantify it.work_order read against situsgroup with from_date=2026-09-29&to_date=2026-10-14. It is a read, not a write, and it is one request — but it spends the starving bucket, so it was not mine to take on a read-only dispatch. It is the single cheapest thing anyone could still do on this row.import_only mode from #17548, or the pre-fix behaviour that same PR removed (§2). Needs the lambda’s deploy time at 01:16Z and the pause row’s mode, and the pause row no longer exists.wo_enrichment path). Needs a per-work-order comparison I did not run.totalRows=1331 vs 937 (§4a detail). Two of three candidate explanations ruled out by measurement; the remainder not decomposed.coverage.from is 2026-08-08 on one building, imported 2026-09-29. A pre-existing coverage shortfall, outside my window. Flagged, not chased.active are holdovers or stale (§4c).⚠️ CloudWatch retention on /aws/lambda/propflow-appfolio-sync-prod is 30 days. 2026-09-29 ages out around 2026-10-29, after which queries 1 and 2 return the gap without its left edge. Everything in DynamoDB is permanent.
# 1 — the window, from the traffic side, with its own positive control
aws logs start-query --log-group-name /aws/lambda/propflow-appfolio-sync-prod \
--start-time $S --end-time $E --query-string 'fields @timestamp,
strcontains(@message, "not dispatched") as paused,
strcontains(@message, "AppfolioClient") as client
| filter @message like /situsgroup/
| stats sum(paused) as pausedLines, sum(client) as clientLines,
count() as allLines by bin(24h) as day | sort day asc'
# 2 — the resume receipt: one request, the whole 90-day book
aws logs start-query --log-group-name /aws/lambda/propflow-appfolio-sync-prod \
--start-time $S --end-time $E --query-string 'fields @timestamp, @message
| filter @message like /situsgroup/
and @message like /work_orders portfolio-wide fetch/
| sort @timestamp asc | limit 3'
# 3 — work orders by AppFolio CreatedAt (NOT by local write time)
aws dynamodb query --table-name propflow-prod --index-name work-order-org-index \
--key-condition-expression "WOORGPK = :pk AND WOORGSK BETWEEN :a AND :b" \
--expression-attribute-values '{":pk":{"S":"WOORG#<organizationId>"},
":a":{"S":"2026-06-01"},":b":{"S":"2026-10-24"}}' \
--projection-expression "WOORGSK,createdAt,#st,propertyId,archivedAt" \
--expression-attribute-names '{"#st":"status"}'
# the id tail of WOORGSK is wo_appfolio_<afId>_<epoch_ms> — the MINT time
# 4 — payment receipts and their coverage stamps (batch-get, 53 buildings x 3 months)
# keys: PK=PROP#<propertyId> SK=PAYMENTS#2026-09 | PAYMENTS#2026-10
# 5 — occupancies for the company
aws dynamodb query --table-name propflow-prod --index-name entityType-createdAt-index \
--key-condition-expression "GSI3PK = :pk" \
--expression-attribute-values '{":pk":{"S":"OCC#<organizationId>"}}'
# 6 — the balance change log for one building
aws dynamodb query --table-name propflow-prod \
--key-condition-expression "PK = :pk AND begins_with(SK, :sk)" \
--expression-attribute-values '{":pk":{"S":"PROP#<propertyId>"},":sk":{"S":"BAL#"}}'
# 7 — the pause row itself: ABSENT (removed, not expired)
aws dynamodb get-item --table-name propflow-prod --consistent-read \
--key '{"PK":{"S":"CONFIG"},"SK":{"S":"PMS_SYNC_PAUSE#<organizationId>"}}'
# positive control at the same PK, which DOES return an item:
# SK=APPFOLIO_BUDGET_ENFORCE#appfolio#situsgroup#86fe
The pause row read returns no Item at exit 0. That empty answer only means something because the control at the same partition returns a full item (enforce: true, updatedBy: federicochapa, updatedAt 2026-09-30T04:23:11Z) — so the instrument discriminates, and the absence is a removal rather than a bad key. Key spelling taken from pms-connection-health.ts:313, PAUSE_SK_PREFIX = 'PMS_SYNC_PAUSE#', not guessed.
Scope note, load-bearing: jpco, situsgroup and westernslopepm are separate AppFolio databases behind one login. Every number on this page is situsgroup; no jpco figure appears, and none would say anything about this one. This page covers the Reports API path only — the L4 staff-portal browser path has no budget, no pacer and no counter, so nothing here is evidence about it either way. No AppFolio request was placed to produce this page.