A read-only diagnosis of why the AppFolio guest-card pass re-requests its whole date window every minute — and why the obvious fix is the wrong one.
2026-10-22/23 · dock row diagnose-guest-card-window · read-only, no prod writes · subdomain under test: situsgroup.appfolio.com (account fingerprint appfolio#situsgroup#86fe)
The window costs exactly one AppFolio request per run — measured 1,423 times in 24 hours, never two. Narrowing it saves nothing. The request count is set by the cadence alone. And the window is a defect, in the opposite direction: it reaches one year back, and seven days ago Fede merged #20139 because that same one-year back edge on the sibling report had silently hidden 21 still-pending rental applications at Situs received 2018–2024.
Trinity's 2026-10-22 audit read the symptom right and the cause wrong. The pass does run once a minute, and it does re-request its whole date window every run. Both confirmed. But those are two separate facts with two separate fixes, and only one of them touches the request budget.
| Mechanism | What it is today | Defect? |
|---|---|---|
| Window | wideYearRange() — today−1y … today+2y, recomputed from new Date() on every call | Yes but too NARROW at the back edge, the same shape #20139 fixed for its sibling report. Not a request-budget defect at all. |
| Cadence | act_now EventBridge class rule, rate(1 minute) | This is the cost — it, and only it, sets the 1,423 requests/day. |
| Quiet-pass skip | Post-fetch writer gate (#16738) | No — working 96.4% skip rate. Structurally cannot reduce AppFolio requests. |
src/lib/integrations/appfolio/writers/guest-cards-portfolio.ts:82 calls wideYearRange(), defined at src/lib/integrations/appfolio/pms-adapter.ts:2706:
export function wideYearRange(): [string, string] {
const today = new Date();
const from = new Date(today); from.setUTCFullYear(today.getUTCFullYear() - 1);
const to = new Date(today); to.setUTCFullYear(today.getUTCFullYear() + 2);
return [from.toISOString().slice(0, 10), to.toISOString().slice(0, 10)];
}
Anchored on wall-clock now, and nothing else. No cursor, no stored state, no per-account value. The same function is the window on the per-property fallback path (pms-adapter.ts:942, listGuestCards), so both paths agree.
Trinity's "three-year window" is the parameter span — one year back plus two years forward. The effective data window is about one year back: the forward edge buys nothing on a report filtered by a received date, because no card is received in the future.
No. Nothing records a last-successful-pull for guest cards. What exists and does not serve:
src/lib/domain/pms/quiet-pass.ts) holds inputHash + passAtMs per property and job. It is computed from the already-fetched rows (handler.ts:5129–5163), so it records what the last pass saw — it is not a bound the next fetch could use.PmsRecord.last_seen (src/lib/data/dynamo/pms-records.ts) is per mirrored card, refreshed hourly. Per row, not per pull.src/lib/integrations/appfolio/report-snapshot-cache.ts) lives exactly one Lambda invocation. Its own header says a cross-tick cache "would need its own staleness story this module deliberately does not take on."src/lib/data/dynamo/purchase-orders.ts, PurchaseOrderSyncWatermark.lastSuccessAt) is the closest shape in the repo — but it exists for absence detection, not windowing, and its own window function says outright: "There is no updated_at cursor on the purchase_order report, so every tick is a full refresh of this window."What I could not establish: guest_cards' own filter axis. The repo's AppFolio report catalog (.claude/skills/appfolio/reports-catalog.json) carries only {"id": "guest_cards", "name": "Guest Card Interests"} — no parameter semantics. Settling it needs two read-only Reports API GETs against situsgroup comparing row counts at from_date=2000-01-01 versus from_date=today-1y. I did not spend the budget tokens on an account already being refused. That measurement is the fix lane's first step, and it decides the window half.
Not the expression field in schedules.json. That reads rate(15 minutes) for guest_cards and is dead text. Read live from AWS:
$ aws events list-rules --name-prefix propflow
propflow-appfolio-sync-prod-class-act-now-schedule | rate(1 minute) | ENABLED
$ aws events list-targets-by-rule --rule propflow-appfolio-sync-prod-class-act-now-schedule
Arn: arn:aws:lambda:us-east-1:194925971150:function:propflow-appfolio-sync-prod
Input: {"jobs": ["work_orders", "rental_applications", "guest_cards", "guest_card_messages"]}
There is no propflow-appfolio-sync-prod-guest-cards-schedule rule. Positive control on that absence: the same listing did return …-vendors-schedule and …-wo-enrichment-schedule — per-job rules that still exist, because vendors is individually DISABLED (so not superseded) and wo_enrichment carries excludedFromClasses. The sweep can see per-job rules; guest-cards' is genuinely gone.
So the cadence is schedules.json's classes.act_now = "rate(1 minute)" — a platform default applied to every customer, not anything about situsgroup. ensure-schedules.sh:139–145 retires the per-job rule for any classed, enabled job. docs/integrations/appfolio.md:232 records the change in plain words: "guest_cards 15min→1min", shipped by #8136 on Fede's 2026-09-12 cadence-class ruling.
The brief asked whether the skip should be suppressing these runs and is failing for situsgroup. Two independent answers, both no.
(a) It structurally cannot. handler.ts:5129–5135 drains the fetch into guestCards[], and then :5149 hashes that array. The hash input is the fetch result. The gate at :5429 runs after the AppFolio request has already been paid for. #16738 was always a DynamoDB-side optimisation — its own header says so: "that is ~2 reads per card per minute that can only ever confirm what the last minute already found."
(b) It is firing, and firing well. 30 minutes of pms GuestCardSync[quiet-pass] lines for situsgroup properties, uncapped sample (1,490 lines returned against a 3,000 cap), 52 distinct properties:
| Outcome | Count | Detail |
|---|---|---|
skipped_unchanged | 1,436 | 96.4% of passes |
full_pass | 54 | input_changed 52, last_pass_not_quiet 2 |
| DynamoDB writes across those 54 full passes | 2 | median 0, max 1 |
Re-verified for situsgroup.appfolio.com from the handler's own guest_cards portfolio-wide fetch info line, 24 hours to 2026-10-23T01:26Z:
| Successful fetches | 1,423 | first 2026-10-22T01:27:09Z, last 2026-10-23T01:26:10Z |
requests per fetch | 1 | every single one — distinct values observed: [1] |
totalRows | 1,542–1,547 | cards within today−1y. This matches Trinity's 1,542, but it is the windowed count, not the account's card book — the account may hold more. |
propertiesCovered | 29 | invariant |
elapsedMs | 3,579 / 5,519 / 25,533 | p50 / p90 / max |
| Throttle-skips | 17 | → 1,440 attempts/day = once a minute |
| Per-property fallback | 0 | Negative control: the …fetch failed … falling back to per-property pattern returned 0 events for guest_cards, while the same sweep found 1 for rental_applications — so the pattern matches when the thing exists. There is no hidden 29×-per-tick multiplier. |
All portfolio-wide fetch lines for appfolio#situsgroup#86fe, 24 hours. Denominator caveat: these are the sync Lambda's fetches only. durable-account-budget.ts's own header names other drawers on the same bucket — the availability heartbeat, Clara's live tools, and concurrent invocations — so the account's true draw is higher and is not measured here.
| Job | Ticks | Requests | Share of this table |
|---|---|---|---|
| work_orders | 1,455 | 1,455 | 21.8% |
| rental_applications | 1,423 | 1,423 | 21.3% |
| guest_cards | 1,423 | 1,423 | 21.3% |
| balances | 305 | 1,218 | 18.2% |
| occupancies | 263 | 526 | 7.9% |
| lease_states | 286 | 286 | 4.3% |
| unit_vacancy | 273 | 273 | 4.1% |
| lease_states_history / renewals / payments | 23 / 24 / 24 | 71 | 1.1% |
| Total | 6,675 |
Guest cards are 21% of the sync Lambda's spend, not the whole budget. The three act_now one-minute jobs together are 4,301 requests — 64%. The durable bucket's capacity is 6 tokens per 15s (durable-account-budget.ts: DEFAULT_CAPACITY = 6, DEFAULT_REFILL_WINDOW_MS = 15_000) = 24/min = 34,560/day, so 6,675/day is about 19% of capacity from this Lambda.
diagnose-budget-pause), not for this rowThree things I noticed and deliberately did not chase:
situsgroup in 24h, 118 read "AppFolio account budget exhausted" and only 10 read "AppFolio rate limit (429)". Per job: work_orders 27 (all ours), balances 25 (all ours), occupancies 23+2, guest_cards 12+5, rental_applications 13+3, unit_vacancy 15, lease_states 2, lease_states_history 1. Spread evenly across all 24 UTC hours (min 1, max 11) — not a single episode.work_orders ran 1,455 times in that window, more often than guest cards. "Guest cards are starving the work-order pull" is not supported at the tick level; the starvation claim needs re-deriving at 15-second burst granularity.durable-account-budget.ts's header describes — or at the unmeasured other drawers, rather than at total volume. And the budget counts requests, so no window change moves it.| PR | What it tried | Why it does not solve this |
|---|---|---|
#7935 858c595 | Guest cards: one portfolio-wide call per account instead of one per property. | It held, and it worked. This is why situsgroup costs 1 request/tick instead of 29. There is no per-property fetch left to remove. |
| #16738 | Quiet-pass: skip the writer pass when the input hashes identically and the last pass was quiet. | Sits after the fetch. Saves DynamoDB reads; cannot save one AppFolio request. Working at 96.4%. |
| #8136 (2026-09-13) | Cadence moved from per-job to three platform classes; per-job rules retired. | This is the cause of the one-minute cadence. It took guest_cards from 15min to 1min deliberately, on Fede's 2026-09-12 ruling. Not a bug to fix — a decision to revisit. |
| #15309 / #14341 | work_orders re-enabled, folded into act_now. | Same class, same one-minute cost. Added ~1,455 requests/day to this account. |
#20139 1180980 (2026-10-15) | Widened rental_applications from today−1y to whole history, because 21 still-pending applications at Situs (received 2018–2024) never reached the mirror. Notes "still one request per account". | The direct precedent, and it points the opposite way to the audit. Same report family, same wideYearRange() back edge, same customer. guest_cards was left on the old window. |
work_orders' 90-day windowhistory-window.ts | The one everyday window anyone has narrowed — rolling 90 days, env-tunable. | Narrowed for payload and Lambda cost, not request count, and it shipped with a written caveat that it creates a correctness gap plus a compensating daily wo_enrichment sweep. Its docstring: "do not 'fix' it by silently widening the window back toward pmsHistoryWindow()'s floor." |
Rename the row. "Incremental guest-card window" is the wrong fix, and building it would be a regression. Two independent changes, and they are coupled in ordering.
Correctness. Costs no extra requests.
situsgroup: guest_cards with from_date=2000-01-01 versus from_date=today-1y, status: 'all' on both. Differing row counts prove the back edge is hiding cards and settle the filter axis in one shot. scripts/guest-cards-portfolio-dryrun.ts already exists as the read-only harness.wideYearRange() → pmsHistoryWindow() in both paths (writers/guest-cards-portfolio.ts:82 and pms-adapter.ts:942), and add the writer-side lookback predicate alongside it — the isWithinEverydayApplicationWindow seam from #20139 — so the mirror holds every card while the prospect writer's input is unchanged. #20139's own diff is the template, file for file.requests stays 1 after widening. A bigger payload could cross a page boundary; today's single request is measured on the narrow window, not the wide one.financials shapeCost. Needs a founder's ruling.
The only shipped way to cut the request count without touching the global class cadence is already in this Lambda, from Gera's 2026-09-18 decision b89703990 / #9372: financials ticks hourly but reads AppFolio once per property per day, by consulting a durable cooldown row before the fetch.
src/lib/data/dynamo/financials-sync-cooldown.ts — the row.isFinancialsFetchDue(lastSuccessAtMs, nowMs) — src/lib/pms/run-financials-sync.ts:132.handler.ts:6435, declines with summary: { skipped: 'financials-cooldown' }, stamped at :6538 only on a run that reached the source — a failure leaves the stamp alone, so an outage costs one tick, never a whole interval.Copy it as a per-account (accountFingerprint) cooldown for guest_cards, consulted before fetchGuestCardsPortfolioWide at handler.ts:4737, with a guest-cards-cooldown skip reason added to the existing taxonomy at handler.ts:425. The class rule, its target Input and every other job stay untouched. At a 5-minute interval that is 1,423 → ~288 requests/day for this account; at 15 minutes, ~96.
guest_cards' expression: "rate(15 minutes)" in lambda/appfolio-sync/schedules.json has fired nothing since #8136 and reads as the live cadence to anyone who opens the file — it is what the audit read. Either strip the inert expression from classed jobs, or comment it the way guest_card_messages' own notes block already does.
guest_cards' from_date filters on. Measured for rental_applications (Received, #20139) and work_order (CreatedAt, history-window.ts); inferred, not measured, for guest_cards. Two read-only GETs settle it.situsgroup actually holds guest cards outside today−1y. Same measurement. #20139 found 21 such applications at this customer, which makes it likely, not proven.diagnose-budget-pause.act_now burst, rather than the day's total, is what empties the bucket. 19% average capacity with 118 refusals/day says it must be burst, concurrency or an unmeasured drawer, but this was not measured at 15-second granularity.src/lib/integrations/appfolio/pms-adapter.ts:2706 · src/lib/integrations/appfolio/writers/guest-cards-portfolio.ts:82 · src/lib/integrations/appfolio/pms-adapter.ts:942lambda/appfolio-sync/schedules.json (classes.act_now) · lambda/appfolio-sync/ensure-schedules.sh:139–145 · aws events list-rules / list-targets-by-rule output above · docs/integrations/appfolio.md:137,232lambda/appfolio-sync/handler.ts:5129–5163,5429–5443 · src/lib/domain/pms/quiet-pass.ts · src/lib/data/dynamo/pms-records.ts:122–135 · src/lib/domain/pms/mirror/guest-card-mirror.ts:1307src/lib/integrations/appfolio/durable-account-budget.ts:66–70858c5954857 · #20139 11809804150 · #8136 · src/lib/domain/pms/history-window.tssrc/lib/pms/run-financials-sync.ts:132 · src/lib/data/dynamo/financials-sync-cooldown.ts · lambda/appfolio-sync/handler.ts:6435,6538 · handler.ts:4737,4799,5131–5133/aws/lambda/propflow-appfolio-sync-prod, filtered on appfolio#situsgroup#86fe or on situsgroup property ids. No prod writes, no sync re-run by hand, no budget reset. Numbers read from jpco or westernslopepm are labelled as such and say nothing about situsgroup.