Why 2026-10-01 lost 57 work orders: the same defect, arriving in a monthly batch

The single-day cliff inside Situs’s continuous work-order shortfall, explained by measurement. AppFolio database situsgroup (never jpco). No AppFolio call was placed for this page — it replays an already-authorised read plus free local data.

2026-10-23 · dock situs-appfolio-budget, row situs-work-orders-missing-now · all times UTC · inputs: the saved non-PII projection of the authorised work_order read (196 rows) and the WOORG#<org> partition of propflow-prod · read-only, no prod writes, no AppFolio requests

It is the same defect, not a second one. On 2026-10-01, 51 of the 51 missing in-scope work orders carry no UnitId, and all 6 of the in-scope work orders that do carry a unit are present. The cliff is an AppFolio-side month-start batch of building-level work orders — 38 of them flagged Recurring: Yes — and because none of them names a unit, every one hits the single drop already established at work-order.ts:1829-1836. The 29.5% figure survives unchanged; what changes is that it should be read as what we lost over that window, not as a flat daily rate.

What was already known, and what is new here

Established earlier by the backfill-the-staff-pause-hole lane and not re-derived: the whole-window identity split (119 present / 77 missing of 196), the unit-less discriminator over the whole window, the named cause, and that all 38 Recurring: Yes rows are unit-less and missing.

New here: the per-day decomposition — which is the question the row actually asked — the result that every in-scope unit-bearing row born 2026-10-01 was retained, the test of whether the batch recurs, and the rule-out of a month-boundary artifact.

1 · The measurement that settles it

Two inputs, both free of any AppFolio request:

Scope is split before anything is counted: 13 of the 196 sit on AppFolio PropertyIds 27, 28 and 39, which are not on our roster and therefore cannot be present locally. All 13 are absent, as they must be. In-scope = 183.

In-scope, whole 16-day window (183 rows)

MeasureCountWhat a non-zero would mean
present119—
missing64 (35.0%)—
missing AND unit-bearing0a different defect from the unit-less drop
present AND unit-less0the unit-less drop is not what it looks like

The unit-less discriminator is perfect at 183 of 183.

2026-10-01, in-scope (57 rows) — the answer

MeasureCount
missing51
of which unit-less51 of 51
unit-bearing rows created that day6
of which present6 of 6

The dock’s 57 = these 51, plus 6 rows on off-roster buildings 27 and 28.

Per-day table, all 196 rows, with the missing split by unit-bearing

day         af   pf  miss  unitless  miss&unitless  miss&unit-bearing
2026-09-29  14   13     1         0              0                  1*
2026-09-30  12   11     1         0              0                  1*
2026-10-01  63    6    57        52             52                  5*
2026-10-02  20   18     2         2              2                  0
2026-10-03   4    4     0         0              0                  0
2026-10-04   2    2     0         0              0                  0
2026-10-05   9    4     5         5              5                  0
2026-10-06  11   10     1         1              1                  0
2026-10-07  16   14     2         2              2                  0
2026-10-08   8    6     2         1              1                  1*
2026-10-09  10    7     3         2              2                  1*
2026-10-10   2    2     0         0              0                  0
2026-10-11   1    1     0         0              0                  0
2026-10-12  12   12     0         0              0                  0
2026-10-13   5    5     0         0              0                  0
2026-10-14   7    4     3         1              1                  2*
TOTAL      196  119    77        66             66                 11*

* — every one of the 11 missing unit-bearing rows is on off-roster building 27 or 28: work orders 20785, 20797, 20844, 20867, 20944 and 20972 on PropertyId 27; 20849, 20850, 20851, 20940 and 20973 on PropertyId 28. Residual after the scope split: zero.

2 · Why the cliff: a month-start batch of building-level orders

The day’s 52 unit-less rows split two ways, and the split matters:

Either way the mechanism is identical: no UnitId → the drop. SubmittedByTenant is No on all 63 rows that day, so this is not a tenant surge. 63 rows against a shoulder rate of about 7 a day is the bulk creation event, and 83% of it is unit-less, so the day’s loss rate is 89.5% instead of about 10%.

This does not contradict the earlier rejection of a Recurring correlation. That rejection was about our code: no Recurring reference exists anywhere in the sync path, and that is still true, with its positive control still passing. Recurring is not a cause in our code — it is AppFolio’s marker for why so many unit-less rows were born on one day.

3 · The month-start batch recurs — tested locally, with controls

AppFolio WorkOrderIds are sequential, so a dropped batch leaves a contiguous hole in the local book’s id sequence. Over the dense era (ids ≥ 12950, i.e. 2024-09-30 onward: 3,954 local ids, 3,953 consecutive pairs, median gap 0, mean 1.05):

Pair classnMedian gapMeanShare ≥ 20
crosses a MONTH boundary291521.341%
control — crosses the 15th/16th260.55.54%
control — all same-month pairs3,9240.00.91%

Recent month boundaries, where the gap is the count of AppFolio ids we do not hold:

2026-07-31 id 19867 -> 2026-08-01 id 19915   gap 47
2026-08-31 id 20379 -> 2026-09-01 id 20424   gap 44
2026-09-30 id 20805 -> 2026-10-01 id 20845   gap 39   <- this row's cliff
2025-07-31 id 16063 -> 2025-08-01 id 16115   gap 51
2025-08-31 id 16572 -> 2025-09-01 id 16621   gap 48
2025-09-30 id 17011 -> 2025-10-01 id 17053   gap 41

The proxy is calibrated against ground truth, not assumed. In the 2026-10-01 batch’s id range 20806–20868 there are 63 ids, 57 of them absent locally, and all 57 are rows the authorised AppFolio read actually returned (51 in-scope unit-less + 6 off-roster). Zero phantom ids. Over the whole 200-id sample span the proxy overstates by 4, about 2%.

Also true, and worth saying because it cuts against a tidier story: of the 56 gap runs of 20 or more in the dense era, only 9 land on the 1st. Month boundaries are strongly enriched for big gaps (41% against 1%), but most big gaps are not at month boundaries, simply because most pairs are not.

4 · A month-boundary measurement artifact — ruled out three ways

A cliff on the 1st of a month is a classic off-by-one in the measurement rather than the data, so it was tested explicitly rather than waved off:

  1. 2026-10-01 is interior to the sampled window (2026-09-29 → 2026-10-14). An off-by-one at a window edge would pile rows on 09-29 or 10-14, not on the 1st.
  2. There is no cross-system date comparison to be off by one. Present/absent is AppFolio-WorkOrderId membership in the whole local book; the day label buckets only the AppFolio side, using AppFolio’s own CreatedAt.
  3. The date-only timezone trap does not bite. CreatedAt is date-only and the writer anchors it at property midnight — observed locally as 2026-10-01T06:00:00.000Z, which is MDT midnight. Every US zone puts local midnight at 04:00–10:00Z, so the UTC date never crosses the day line.
5 · Does the 29.5% survive? Yes — with a caution about how to use it

Nothing is subtracted, because the cliff is part of the same loss rather than a separate defect that was inflating the total. 349 of 1,184 = 29.5% stands as measured. What changes is that it is a window total, not a daily rate:

6 · What this sharpens for the open product decision

The loss is entirely building-level work orders — prod confirms it at full-book scale, not just in the sample: on every tick, unitlessSkipped equals unmatchedUnits exactly, so the resolvable mirror-gap component at Situs is zero (§8). About a third of it arrives as AppFolio’s month-start batch of building-level maintenance — scheduled work with no resident attached, 38 of 52 of it flagged Recurring: Yes. That is the concrete thing the deferred decision in work-order.ts is actually about, and it is what a founder should be asked: should Clara see the building’s scheduled maintenance? Mirroring it changes what Clara sees and may act on, so it stays a founder’s call, raised by the driver and never by a lane.

7 · How to reproduce

Five short offline Python scripts in the lane directory — read-only, no credential, no AppFolio call. Each prints its own positive and negative controls: the id-extraction control on known-present ids, the roster-membership control in both directions (27/28/39 absent, 8/14/41 present), and an impossible id that must not be found.

One small discrepancy, recorded rather than smoothed: this page finds 119 present and 77 missing where the dock recorded 118 and 78. The difference is the instrument — id membership in the whole book rather than a calendar-day join, which is the stricter test. It moves no conclusion.

8 · Does the counter count ticks rather than rows? No — tested two ways

A fair challenge to the new instrument: unmatchedUnits increments once per skipped draft per pass, and a property-level draft that joins no local row is re-seen on every pass. If anything summed those passes, the number would inflate without bound — and unitlessSkipped, shipped in #21988, would report tick-events rather than lost rows. It does not.

The code reading

So the quantity is distinct drafts skipped within one pass’s fetch. Because the fetch is the same rolling 90-day window every pass, one tick’s number is a count of distinct rows in that window.

The empirical disproof, which is the stronger evidence

/aws/lambda/propflow-appfolio-sync-prod, 2026-10-23 03:55–05:56Z, filtered to situsgroup. The read is complete, not truncated: recordsMatched 4,214 equals 4,214 rows returned. A first query matched 6,397 against a limit 2000 and was discarded as a truncated read rather than reasoned from. Parser positive control: 4,214 of 4,214 lines parsed, 52 properties, 129 distinct lambda request ids.

unmatchedUnits reads exactly 349 on 101 of the 129 ticks (the other 28 are narrower fetch shapes covering fewer properties). The decisive bound: the largest value on ANY of the 129 ticks is 351, and the smallest is 282 — the whole two-hour range is 282–351. At roughly one tick a minute an accumulating counter would read about 42,000 by the end of that window.

03:55:08 props=28 rows=1184 unmatchedUnits=349 (29.5%)
03:56:04 props=28 rows=1184 unmatchedUnits=349 (29.5%)
   ...
05:53:08 props=28 rows=1184 unmatchedUnits=349 (29.5%)
05:54:08 props=28 rows=1184 unmatchedUnits=349 (29.5%)

Per-property growth test over all 52 properties: monotonically growing, 0 properties; flat every tick, 31; “varies”, 21 — and that variation is two values only, the full value and 0, tracking two fetch shapes. property-14’s rows is either 104 (then unmatchedUnits=45) or 15 (then 0): a narrower fetch with no unit-less rows in it, not an accumulation.

A third finding, and it is a retraction of my own shipped comment

The counter is sound, but a claim about it that is now in production is wrong, and I am the one who wrote it. #21988 shipped a docblock stating the cadence authoritatively: “the next identical tick emits nothing at all. The DROP is continuous; the WARNING is per changed book” — on the reasoning that the quiet pass returns skipped without running the writer on unchanged input.

Production falsifies it on this Lambda. Same window, same log group, situsgroup:

My first query filtered on /unmatchedUnits=/, which matches the INFO summary only — the warn text does not contain that string, so its absence from that result set was evidence about my filter, not about the warning. I ran the warning its own query rather than assert the absence: matched 2,046 equals 2,046 returned, complete and untruncated, with the filter’s positive control passing.

So the warning is per minute here, not per changed book, and the deployed docblock actively tells a reader it is rare. Either the quiet pass is not on this Lambda’s entry path or it keys on something other than what that comment assumed; this page does not settle which, and reading the runner to find out was out of scope. Two copies, both in src/lib/domain/pms/writers/work-order.ts — 250–256 (the authoritative statement) and 2125–2127 (the pointer that restates it) — so one commit fixes both. None of this moves the counter’s trustworthiness: what makes the number safe is the per-pass reset, not the cadence.

What is calibrated and what is extrapolated

Only the 16-day authorised segment (ids 20779–20978) has ground truth: 81 absent ids, 77 of them rows the authorised read actually returned, 4 phantom. Everything about the other ~74 days of the 90-day sweep is extrapolated from that segment’s error rates, and the composition of those absent ids — unit-less against off-roster against deleted — is unmeasured. The headline is carried by the prod counter’s 349, which is ground truth; the sweep’s only job was to show that 450 absent ids is consistent with 349, which is what kills the 226.

PropFlow Docs