Read-only diagnosis of the durable AppFolio request budget for database situsgroup. Every number was re-derived from production.
2026-10-23 · dock situs-appfolio-budget, row diagnose-budget-pause · all times UTC · environment: propflow-prod (DynamoDB) and /aws/lambda/propflow-appfolio-sync-prod (CloudWatch) · read-only, no prod writes
The headline. The budget was never “pinned empty since 2026-09-29 16:00Z.” That timestamp is throttleCountSince — the birthday of the 429 counter on this account, stamped by the first 429 it ever recorded. That first 429 was real and it was a storm (772 in one hour), but it was over within two hours, and the account then issued zero AppFolio Reports API calls for the next fifteen days because staff had paused its sync. The pressure starving Situs maintenance today began 2026-10-14 20:05Z, when that pause lifted. It is not a stuck pause and not a bucket that fails to refill: it is a single 6-token/15s bucket with no ordering and no per-caller share, refusing roughly 200 calls a day while running at 19% average utilisation.
:00 mechanism is withdrawnWhat was wrong. The first revision of this page said the refusals were caused by an ~78-request hourly batch arriving “within seconds of each hour boundary,” and recommended spreading that batch as the cheapest fix. Both are gone from the page — deleted, not footnoted, because a caveat under a wrong causal claim leaves the wrong claim doing the work.
What replaced it, and by what measurement. A sibling lane on this dock measured dispatch directly rather than inferring it from report volume: 7,135 dispatch lines over 24h, folded onto minute-of-hour.
| cadence class | dispatches / 24h | minute-of-hour |
|---|---|---|
bookkeeping | 28 | :21 (27), :22 (1) — two distinct minutes; ten consecutive hours fire at HH:21:26 |
answer_live | 332 | :01, :06, :11, :26, :41… — 30 minutes |
act_now | 1,631 | all 60, flat (~27 each) |
Nothing dispatches at :00. The hourly batch is already 21 minutes off the boundary, so whatever clusters at :00 — if anything does — the batch is not its cause.
Now open, and nobody should guess at it. Whether refusals cluster at :00–:01 at all is unresolved. The only reading is mine, and it is a 34-event sample over four hours (20 at :00, 2 at :01, 7 at :11, 3 at :16, 1 each at :36 and :41) that nobody has re-derived. It may be real with a cause other than the batch, or it may be small-sample noise. The prod-verify-24h lane is already reading the refusal population for its own baseline and will emit a minute-of-hour histogram as a by-product — that is where the answer comes from, and this page deliberately does not re-derive it, because two lanes computing one number is how one fact becomes two that disagree.
What this does NOT touch. Everything else here is measured per report, per row or per log line, and none of it depends on a clock story:
throttleCountSince — a counter’s birthday, not a condition’s start.pausedUntil in the past, and nothing clears a pause because it expires.paused by staff window and its 2,076-line-per-day positive control.unattributed-appfolio-client share.noteThrottled branch that explains it.It is real, and it is two separate numbers that the write-up merges into one. The budget row carries a counter for AppFolio refusing us; it carries no counter at all for our own budget refusing our own callers — and the second one is what starves work orders.
This is the question the prerequisite PR’s reviewer flagged, and the answer is unambiguous: since-onset. throttleCount is documented in durable-account-budget.ts as cumulative and never reset, and throttleCountSince names the window it covers.
aws dynamodb get-item --table-name propflow-prod --consistent-read \
--key '{"PK":{"S":"CONFIG"},"SK":{"S":"APPFOLIO_BUDGET#appfolio#situsgroup#86fe"}}'
| Field | Value at 2026-10-23 01:21:50Z | What it means |
|---|---|---|
throttleCount | 6,077 | 429s from AppFolio, cumulative, never reset |
throttleCountSince | 1790697652125 = 2026-09-29T16:00:52.125Z | The window the count covers. This is the “onset” in the audit. |
tokens | 5.0 of capacity 6 | Not empty |
pausedUntil | 2026-10-23T01:16:36Z (5.2 min in the past) | Not paused |
exhaustedSince | 2026-10-23T01:16:15Z | An exhaustion episode is open — real refusals are happening |
version | 67,453 | Optimistic-concurrency counter; bumped by a successful consume and by a refusal, by nothing else |
So 6,077 spans 2026-09-29T16:00:52Z → now = 23.4 days, an average of 260/day. The current rate is nothing like that average. Two independent differenced reads:
| Reading | At | throttleCount | version | Implied rate |
|---|---|---|---|---|
| the earlier audit | ~2026-10-23 00:03Z (its own guest-card timestamp) | 6,074 | — | +3 in ~80 min ≈ 54 429s/day |
| mine, read 1 | 2026-10-23 01:21:50Z | 6,077 | 67,453 | |
| mine, read 2 | 2026-10-23 01:28:24Z | 6,077 (+0) | 67,482 (+29) | 29 successful consumes in 6.6 min = 4.4/min against a 24/min capacity. All 29 are consumes, not refusals: a refusal is the only other writer that bumps version, and lastRefusedAt read 01:16:26Z at both samples. |
The bulk of the 6,077 is therefore not a steady drip — it is front-loaded. CloudWatch confirms it, from the one log group that writes these lines:
aws logs start-query --log-group-name /aws/lambda/propflow-appfolio-sync-prod \
--start-time <2026-09-28T00:00Z> --end-time <now> \
--query-string 'fields @timestamp
| filter @message like /situsgroup/ and @message like /noteThrottled/
| stats count() as n by bin(24h) as day | sort day asc'
| UTC day | sync lambda | Vercel runtime | Note |
|---|---|---|---|
| 2026-09-29 | 869 | 25 | The storm. 772 in the 16:00Z hour, 97 in the 17:00Z hour, nothing else that day. |
| 2026-09-30 → 2026-10-13 | 0 (14 days, no bins returned) | 11 total (Oct 8, 12, 13) | Sync connection paused by staff. Positive control below. The handful of Vercel-side 429s are admin/Clara reads, which the pause does not stop. |
| 2026-10-14 | 23 | 4 | Pause lifted 20:05Z; last ~4h of the day only |
| 2026-10-15 … 2026-10-22 | 80, 55, 55, 20, 43, 49, 30, 27 | 38, 39, 34, 31, 37, 27, 24, 34 | Steady state since the pause lifted: 50–120/day across both |
| 2026-10-23 (partial, to 01:21Z) | 3 | 3 | — |
| Sum since 2026-09-28 | 1,254 | 307 | vs throttleCount 6,077 — see §6 |
This is the number that matters for starvation, and it is invisible on the status card. When acquire gives up it calls noteRefusal, which writes lastRefusedAt and exhaustedSince and no tally. The only durable record is a 15-minute-sticky flag — the exact mirror image of the pausedUntil trap the file already warns about, one over-reading and one under-reading. The refusal is visible only in a log line:
aws logs start-query --log-group-name /aws/lambda/propflow-appfolio-sync-prod \
--start-time <now-24h> --end-time <now> \
--query-string 'fields @timestamp, @message
| filter @message like /account budget exhausted url=/ and @message like /situsgroup/
| parse @message /caller=(?<caller>[^ "]+)/
| stats count() as n by bin(24h) as day, caller'
| UTC day | unattributed-appfolio-client | accounting-sync | rent-roll-history | Total |
|---|---|---|---|---|
| 2026-10-16 | 166 | 24 | — | 190 |
| 2026-10-17 | 223 | 57 | — | 280 |
| 2026-10-18 | 134 | 27 | 4 | 165 |
| 2026-10-19 | 597 | 5 | — | 602 |
| 2026-10-20 | 204 | — | — | 204 |
| 2026-10-21 | 185 | 24 | 4 | 213 |
| 2026-10-22 | 172 | 34 | 5 | 211 |
| 2026-10-23 (to 01:21Z) | 13 | — | — | 13 |
Which pulls are losing, last 24h (215 refusals, parsed by report endpoint):
| Report refused | n | Report refused | n |
|---|---|---|---|
twelve_month_income_statement | 34 | unit_vacancy | 15 |
work_order | 27 | rental_applications | 13 |
aged_receivables_detail | 25 | guest_cards | 12 |
receivables_activity | 24 | tenant_directory | 12 |
delinquency | 23 | rent_roll_itemized | 5 |
rent_roll | 22 | tenant_tickler | 3 |
Refusals are spread across every report, not concentrated on one. Hourly over the same 24h they run 1–15/hour with a single 41/hour spike at 19:00Z — continuous all-day pressure, not one nightly batch. Work orders really are being refused (27 in 24h), which is the maintenance-intake starvation the dock was opened for; but the heaviest losses are the accounting reports (income statement, receivables, delinquency, rent roll, itemized rent roll: 133 of 215, 62%).
What I could not re-derive: the audit’s “126 other pulls skipped in the last 24 h.” My equivalent measurement on the same account and window is 215 budget refusals at the AppfolioClient.requestJson choke point. The 126 is presumably a count of sync skip rows (a coarser unit — one skip row can swallow several refused pages) from a different instrument. The two are the same family and the same direction; I did not find the query that produces exactly 126, so treat 215 as the receipt and 126 as unverified.
Read, not inferred. All three live accounts at 2026-10-23 01:21:50Z:
| Account (AppFolio database) | tokens | pausedUntil vs now | throttleCount | counting since |
|---|---|---|---|---|
appfolio#jpco#5cd0 | 4.93 / 6 | 20.5 min in the past | 1,232 | 2026-09-16T01:41Z |
appfolio#situsgroup#86fe | 5.0 / 6 | 5.2 min in the past | 6,077 | 2026-09-29T16:00Z |
appfolio#westernslopepm#17a5 | 5.0 / 6 | 12.2 min in the past | 5,598 | 2026-09-16T00:21Z |
Every one of the three is nearly full and none is paused. version advancing 29 times in 6.6 minutes on situsgroup is independent proof that consumes are succeeding, not CAS-wedged.
| Question | Answer, from the code |
|---|---|
| Capacity and refill | DEFAULT_CAPACITY = 6, DEFAULT_REFILL_WINDOW_MS = 15_000. Refill is continuous, not per-window: computeAvailability adds (elapsed / refillMs) * capacity, i.e. 1 token every 2.5s, clamped to 6. Every account gets the same 6/15s — getAppfolioAccountBudget accepts capacity/refillMs overrides and neither of the two production call sites passes either (client.ts:987, guest-card-conversation.ts:100), so Situs’s book and a one-building account draw from identically sized buckets. |
What sets pausedUntil | Only noteThrottled, on a 429. Retry-After when the header is present and finite, else a random pick in the appfolioPolicy 2s–30s band, then Math.min(…, MAX_PAUSE_MS = 5 min). The write is a lengthen-only ratchet: ConditionExpression: attribute_not_exists(pausedUntil) OR pausedUntil < :pausedUntil. |
| What clears it | Nothing clears it. It expires. No code path lowers pausedUntil; tryConsumeOne’s successful write preserves it with if_not_exists(pausedUntil, :zero). The pause ends only by wall clock passing it, which on this ladder is 2–30s, capped at 5 min. |
| State the row is actually in | Saturation by burst, not a wedge. Tokens 5/6, pause expired, exhaustedSince open from 01:16:15Z — i.e. refusals are happening in bursts and the bucket refills fully between them. |
Worth stating because a future reader will reach for them, and because the fix row must not break their defences:
pausedUntil. The ratchet only lengthens and nothing lowers it, so a malformed Retry-After written before MAX_PAUSE_MS existed would wedge every caller on the account permanently, unfixable without a hand write. Not the case here: pausedUntil is 5 minutes in the past, and the cap is in the code today.version attribute. tryConsumeOne reads version ?? 0 and then conditions on version = :expectedVersion, which fails forever on a row where the attribute is absent — five contention retries, then waitMs: 25, then a refusal, every call. The file’s own comment calls this out. It self-heals, because both recordThrottleEvent and noteRefusal seed version with if_not_exists. Not the case here: version = 67,453 and advancing.lastRefillAt. elapsedMs = Math.max(now - lastRefillAt, 0), so a lastRefillAt written from a fast clock makes elapsed 0 forever; with stored tokens at 0 the bucket would read permanently empty and nothing heals it, since every other writer uses if_not_exists(lastRefillAt, …). This is the only shape that would actually produce “pinned empty.” Not the case here: lastRefillAt is 0.3 min in the past.I widened git log origin/main over src/lib/integrations/appfolio and lambda/appfolio-sync across 2026-09-26…10-02. Nothing landed in either path between 2026-09-29T08:07Z and 2026-09-30T03:50Z — a window that brackets the onset hour by 8 hours before and 12 after. The two obvious suspects are both a day late, as the brief said: #14255 (budget always enforced) merged 2026-09-30T17:27Z, #14072 (arm at connect) 2026-09-30T14:09Z. “No code landed at that hour” is the finding, and the real cause is a config event.
The onset was the AppFolio connection for situsgroup being created, followed immediately by its first full-portfolio walk against an account that was in observe mode, not enforce. Four independent receipts:
| # | Receipt | What it says |
|---|---|---|
| 1 | Hourly CloudWatch, 2026-09-29 15:00–19:00Z, filter situsgroup + noteThrottled | 16:00Z hour: 772. 17:00Z hour: 97. Zero in the 15:00Z hour, zero after 18:00Z. 869 total — a two-hour storm with a hard edge at 16:00Z. |
| 2 | Same window, all situsgroup lines by context | Traffic was reports/rental_applications and reports/guest_cards full-portfolio walks, and 385 lines of AppfolioClient.requestJson “account budget observed (not enforced)” — the budget was watching, not holding. |
| 3 | CONFIG / APPFOLIO_BUDGET_ENFORCE#appfolio#situsgroup#86fe, prod DDB | enforce: true, updatedBy: federicochapa, updatedAt: 2026-09-30T04:23:11Z, and the row’s own reason: “Situs connect 2026-09-29: 429 storm within minutes; Fede ‘fix’ in session 006 (same press as jpco 09-12, westernslopepm 09-13).” The answer was written down in prod three weeks ago. |
| 4 | The handoff’s own AppFolio row | “Connected Sep 29, 9:58 AM (database situsgroup)” = 15:58Z. The handoff states local times without a zone; MDT (UTC−6) is the right reading because the same page’s “armed by hand Sep 29, 10:23 PM” matches the ENFORCE row’s updatedAt of 2026-09-30T04:23:11Z exactly under MDT and under no other plausible zone. throttleCountSince is 16:00:52.125Z. Two minutes and fifty-two seconds after connect. |
So 2026-09-29T16:00:52Z is both things at once, which is exactly how the misreading happened: it is the first 429 this account ever recorded (hence the counter’s start) and a genuine onset of a real two-hour storm. What it is not is the start of a condition that persisted.
The gap in the daily 429 table is not missing data — the log group’s retention is 30 days, so 2026-09-23 onward is retained and 2026-09-29 itself returns 869 rows. Positive control, so a zero proves the sweep ran: querying 2026-10-05 for any line containing situsgroup returns 2,076 matched records, and every one of them is context PMSSyncLambda:
one of 2,076 identical lines on 2026-10-05, once per minute; zero
connection=org_a3376147-…#situsgroup not dispatched — paused by staffAppfolioClientlines that day
| Window | Evidence |
|---|---|
First paused by staff line | 2026-09-29T17:01:57.647Z — 61 minutes after the storm started, while the 17:00Z hour’s 97 in-flight 429s were still landing |
| Daily counts | 1,805–2,076 per day, every day, 2026-09-30 through 2026-10-14 |
Last paused by staff line | 2026-10-14T20:04:57.541Z |
So the true timeline is: connect 15:58Z → storm 16:00:52Z–~18:00Z (869 429s, budget in observe mode) → staff pause 17:01Z, holding 15 days with zero AppFolio traffic → enforcement armed by hand 2026-09-30T04:23Z, then made unconditional in code the next day (#14255) → pause lifted 2026-10-14T20:05Z → continuous, genuine budget pressure from then to now: 20–55 real 429s/day and 165–602 self-refusals/day.
Six commits have touched this budget. Every one of them changed whether the budget is enforced, or how loud it is. Not one changed how the single bucket is shared between callers.
| PR | Date | What it tried | Why it did not hold |
|---|---|---|---|
#7911 | 09-11 | Built the durable DynamoDB token bucket, no callers | Correct and still correct. It solved cross-process pacing. It never claimed to solve inter-caller fairness, and does not. |
#7908 | 09-11 | Portfolio walker + process-local pacer | The walker is what produces the burst the bucket cannot absorb. |
#7924 | 09-12 | Every request draws from the budget, armed off by default | Armed off is why Situs stormed on connect: the Sep 29 traffic logged 385 “observed (not enforced)” lines. |
#8695 | 09-15 | throttleCount/throttleCountSince + the status card, “counted not sampled” | Right instinct, half-built: it counted AppFolio refusing us and left us refusing ourselves uncounted. That gap is the one this diagnosis had to fill from logs, and the one the audit misread. |
#14255 | 09-30 | Always enforced, no switch | Stopped the storm and created the starvation. Correct trade, but it converted an AppFolio-side problem into an internal-fairness problem and nothing then addressed fairness. |
#19741 | 10-14 | One signal per exhaustion episode, not one Sentry error per refusal | Fixed alert spam (25,311 events in one issue) by introducing exhaustedSince — a 15-minute-sticky flag with no counter beside it. It made refusals quiet without making them measurable. Landed the same day the Situs pause lifted. |
The caller-side PRs are the same pattern one layer out: #13008, #13084 (renewals skip on a spent budget), #14937 (rental_applications skips instead of paging Sentry), #15644/#15648/#15656 (renewals un-park). Every one made an individual caller fail quietly when the budget refused it. That is the right local behaviour and it is also how 215 refusals a day became a silent data gap instead of an alarm. A seventh PR that makes one more caller skip gracefully is the bandage this rule exists to refuse.
budget-fair-shareThe measurement that should drive the design: the bucket is 81% idle and still refuses ~215 calls a day.
| Quantity | Value | Source |
|---|---|---|
| Capacity | 6 tokens / 15s = 24/min = 34,560/day | DEFAULT_CAPACITY, DEFAULT_REFILL_WINDOW_MS; no call site overrides either |
Measured demand, situsgroup, last 24h | 6,574 requests = 4.6/min = 19.0% utilisation | CloudWatch, AppfolioClient.requestJson GET lines by report |
| Top three consumers | rental_applications 1,484 · work_order 1,454 · guest_cards 1,431 = 66.5% | same query. Each ≈1,440/day = exactly one page per minute |
| Everything else | 2,205 across 11 reports, 4–5% each | same query |
The surviving measurement: who gets refused, by report. Refusal RATE — refused over (served + refused), one 24h window. It is per report, derived from no clock story, and it is the strongest number on this page:
| Report | served | refused | refusal rate | cadence |
|---|---|---|---|---|
rent_roll_itemized | 6 | 5 | 45.5% | hourly bookkeeping class (dispatches at ~HH:21 — not :00; see the correction note) |
twelve_month_income_statement | 57 | 34 | 37.4% | |
aged_receivables_detail | 305 | 25 | 7.6% | |
delinquency | 307 | 23 | 7.0% | |
receivables_activity | 332 | 24 | 6.7% | |
rent_roll | 309 | 22 | 6.6% | |
unit_vacancy | 274 | 15 | 5.2% | |
tenant_directory | 278 | 12 | 4.1% | |
work_order | 1,454 | 27 | 1.8% | act_now, every minute (tenant_tickler excepted) |
tenant_tickler | 309 | 3 | 1.0% | |
rental_applications | 1,484 | 13 | 0.9% | |
guest_cards | 1,431 | 12 | 0.8% |
The jobs that pull once a minute are refused 0.8–1.8% of the time. The jobs the hourly class pulls in a batch are refused 4–45% of the time — a 5× to 50× difference across a 1,700× range of volume. A report pulled 1,431 times a day is almost never refused; one pulled 11 times a day is refused half the time. That is the fairness defect, and it is the whole case for the fix below. It also settles one thing in the negative: the three 1-minute act_now jobs are the budget’s biggest consumers (66.5% of volume) and are not what is being starved, nor can they be emptying the bucket — at one page per minute each they put 3–4 requests/min against a 24/min capacity.
Two conclusions do survive and do not depend on the trigger: raising capacity is the wrong lever (it moves the refusal to AppFolio’s side, where 6,077 429s say we are already at the ceiling), and slowing the walks is also wrong — utilisation is 19%, so there is no volume problem to solve.
Two cautions on this table. The “served” column counts GET log lines emitted after a token was granted (client.ts:1044), so a request that was retried inside its own ladder is counted more than once — which biases the computed rates down, not up. And tenant_tickler pulls at batch volume but is refused at act_now rates, so its placement is uncertain; it is shown where its refusal rate puts it, not where its volume does.
Four parts as first drafted. Part 3 is withdrawn (see the correction note); three stand, vertically sliceable, each useful alone, in the order I would ship them:
| Part | Change | Why it is first/next |
|---|---|---|
| 1. Name every caller | Thread budgetCaller through every AppfolioClient construction site. Keep UNATTRIBUTED_BUDGET_CALLER as the type-level default but add a drift test that fails on any production construction site that does not pass one, with the current sites as a shrink-only allowlist. The sweep must cover the Vercel app and the renewal worker, not just lambda/ — the Vercel runtime carries 307 of the 1,561 logged 429s and the ECS worker touches situsgroup 1,935 times, so a lambda-only sweep leaves a real share of the traffic nameless. | 82% of refusals are unattributed. Every later part needs a caller identity to allocate against, and this one is observable the moment it lands — the refusal breakdown stops being a single anonymous bar. |
| 2. Count refusals on the row | Add refusalCount / refusalCountSince beside throttleCount, written by noteRefusal as its own unconditional ADD — the same reasoning recordThrottleEvent’s docstring already gives for why the tally cannot ride the conditional write. Surface it on the status card beside the 429 counter, with its own …Since label. | This is the single change that would have prevented the misreading this row exists to correct, and it is the only honest before/after for parts 3–4. Today proving the fix works requires a CloudWatch Insights query; after this it is one get-item. |
| 3. | Do not build this. It was “give the hourly batch an offset inside the hour, or pace it to the bucket’s refill rate.” Withdrawn, not deferred, for three independent reasons: (a) there is no :00 spike to spread — bookkeeping already dispatches at HH:21:26; (b) per-report offsets would reverse #8136, Fede’s own cutover to one EventBridge rule per cadence class, which is a decision this row has no standing to re-open; (c) the pacing half is redundant rather than wrong — request-budget.ts already paces every walk at 6 / 15_000, the durable bucket’s own rate, with no non-test call site overriding it. | Left in place, struck through, because a withdrawn recommendation that is deleted outright reads as a recommendation nobody made — and a Fix lane that read the first revision needs to find the retraction where it looked for the advice. |
| 4. Per-class reservation inside the one bucket | Keep one bucket per account — the account-wide AppFolio limit is real and must stay unreachable by construction. Give each cadence class plus interactive a reserved floor: a caller draws from its own reserve first and from a shared surplus pool above it. Concretely, tokens becomes a small per-class record, computeAvailability takes the caller’s class, and the CAS stays a single conditional write on version. The fail-open contract, the lengthen-only pause and computeAvailability-as-single-source all stay exactly as they are. | The actual fairness fix. It is the part that makes a bookkeeping walk structurally unable to starve a work-order pull, and makes Clara’s 3s interactive call win against a background walk. Ship it last because parts 1–2 are what let anyone prove it worked. |
Two constraints the fix lane must not break. (a) computeAvailability is deliberately the only place the refill arithmetic lives — a per-class version must stay a single function with a third caller, not a second copy; its own docstring records that an earlier duplicate was wrong by a factor of capacity on the one card meant to be trusted mid-incident. (b) Every write in this module fails open and silent; a broken budget must never stop the sync. Both parts 2 and 4 add writes, and both must keep that.
Interaction with incremental-guest-card-window: that row attacks the demand side of one of the three top consumers — guest_cards at 1,431 requests/day, 21.8% of all Situs AppFolio traffic, re-walking the full window once a minute. It reduces the burst; it does not change who wins when a burst happens. The two rows are complementary and neither substitutes for the other.
:00–:01 at all — and therefore the proximate trigger of the refusal-rate asymmetry. My only reading is a 34-event, four-hour sample, and it is superseded as a causal basis by the sibling lane’s dispatch histogram, which shows nothing dispatching at :00. The answer will come from the prod-verify-24h lane’s minute-of-hour histogram, emitted as a by-product of the refusal baseline it is already reading; this page deliberately does not re-derive it. See the correction note at the top.AppfolioAccountBudget.noteThrottled lines since 2026-09-28 total 1,561 — 1,254 in the sync lambda, 307 in the Vercel runtime, 0 in the renewal worker (with a 1,935-line positive control), none elsewhere. throttleCount reads 6,077 over a slightly shorter window. The gap is ~4,500, i.e. 74% of the counter.noteThrottled’s pause write is lengthen-only, and on ConditionalCheckFailedException — a longer pause already standing — it calls recordThrottleEvent (which increments throttleCount) and returns without ever reaching the logInfo('pausing') line. So every 429 that arrives while a longer pause stands is counted and not logged, and that is precisely the case a storm produces — the module’s own comment says so, about the tally rather than about the log. Across the whole window the counter runs 3.89× the log. So throttleCount = 6,077 is the authoritative all-process figure and every log-derived 429 count on this page is a lower bound. What I did not do is prove the decomposition: I cannot show that the ~4,500 are all unlogged condition-losers rather than partly a process I did not find. A Fix lane should treat 6,077 as the number and the per-group split as indicative./aws/lambda/propflow-appfolio-sync-prod. The Vercel app adds measurable traffic (307 logged 429s, ~30/day since Oct 15) that is not in those tables, so they are lower bounds. The minute-of-hour clustering and the 19% utilisation conclusion are unaffected in direction — adding traffic makes the collision worse, not better.unattributed-appfolio-client. Part 1 of the fix would have answered this.sections/appfolio-budget.ts has recorded since 2026-09-15 (asked as q-e03ad74b). If they share a pool, every number here is an undercount of the real pressure. This is a question for AppFolio, not a measurement we can take.paused by staff means Situs has a 15-day hole in work orders, balances and occupancy that no budget fix will backfill.The four commands, in order
# 1 — the row, and all three accounts (read-only)
aws dynamodb query --table-name propflow-prod \
--key-condition-expression "PK = :pk AND begins_with(SK, :sk)" \
--expression-attribute-values '{":pk":{"S":"CONFIG"},":sk":{"S":"APPFOLIO_BUDGET#"}}'
# 2 — 429 rate per day (widen --start-time to 2026-09-28 to see the storm and the gap)
aws logs start-query --log-group-name /aws/lambda/propflow-appfolio-sync-prod \
--start-time $S --end-time $E --query-string 'fields @timestamp
| filter @message like /situsgroup/ and @message like /noteThrottled/
| stats count() as n by bin(24h) as day | sort day asc'
# 3 — our own refusals, by caller and by report
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 /account budget exhausted url=/ and @message like /situsgroup/
| parse @message /reports\/(?<report>[a-z_]+)\.json/
| stats count() as n by report | sort n desc'
# 4 — the config event that is the real onset
aws dynamodb get-item --table-name propflow-prod --key \
'{"PK":{"S":"CONFIG"},"SK":{"S":"APPFOLIO_BUDGET_ENFORCE#appfolio#situsgroup#86fe"}}'
# -> reason: "Situs connect 2026-09-29: 429 storm within minutes; ..."
Scope note, repeated because it is load-bearing: jpco, situsgroup and westernslopepm are separate AppFolio databases behind one login. Every number on this page is situsgroup unless the row says otherwise, and the jpco figures are present only as contrast. 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.