Situs guest cards: the window and the cadence

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.

The short version — the table
MechanismWhat it is todayDefect?
WindowwideYearRange() — today−1y … today+2y, recomputed from new Date() on every callYes but too NARROW at the back edge, the same shape #20139 fixed for its sibling report. Not a request-budget defect at all.
Cadenceact_now EventBridge class rule, rate(1 minute)This is the cost — it, and only it, sets the 1,423 requests/day.
Quiet-pass skipPost-fetch writer gate (#16738)No — working 96.4% skip rate. Structurally cannot reduce AppFolio requests.
1 — What sets the window

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.

Is there a watermark it could anchor on instead?

No. Nothing records a last-successful-pull for guest cards. What exists and does not serve:

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.

2 — What sets the cadence

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 quiet-pass skip is not the missing suppressor

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:

OutcomeCountDetail
skipped_unchanged1,43696.4% of passes
full_pass54input_changed 52, last_pass_not_quiet 2
DynamoDB writes across those 54 full passes2median 0, max 1
3 — What is healthy, because it narrows the fix

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 fetches1,423first 2026-10-22T01:27:09Z, last 2026-10-23T01:26:10Z
requests per fetch1every single one — distinct values observed: [1]
totalRows1,542–1,547cards 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.
propertiesCovered29invariant
elapsedMs3,579 / 5,519 / 25,533p50 / p90 / max
Throttle-skips17→ 1,440 attempts/day = once a minute
Per-property fallback0Negative 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.

Where guest cards actually sit in the spend

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.

JobTicksRequestsShare of this table
work_orders1,4551,45521.8%
rental_applications1,4231,42321.3%
guest_cards1,4231,42321.3%
balances3051,21818.2%
occupancies2635267.9%
lease_states2862864.3%
unit_vacancy2732734.1%
lease_states_history / renewals / payments23 / 24 / 24711.1%
Total6,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.

For the sibling lane (diagnose-budget-pause), not for this row

Three things I noticed and deliberately did not chase:

  • The refusals are OUR bucket, not the vendor's. Of 128 throttle-skips for 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.
  • Average spend is 19% of capacity while the bucket refuses 118 times a day. That points at burst alignment inside a single minute plus concurrent invocations — exactly what 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.
4 — Prior attempts: the root, not a bandage
PRWhat it triedWhy it does not solve this
#7935 858c595Guest 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.
#16738Quiet-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 / #14341work_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 window
history-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."
5 — Recommended shape for the fix row

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.

A — the window: WIDEN it, mirroring #20139 exactly

Correctness. Costs no extra requests.

  1. Measure first. Two read-only Reports API GETs on 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.
  2. If they differ: swap 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.
  3. Re-verify 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.
  4. Regression test that fails on the bug: a card received outside today−1y must reach the mirror.

B — the cadence: a pre-fetch per-account cooldown, the financials shape

Cost. 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.

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.

C — one-line doc fix, free

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.

What this diagnosis could NOT establish
  1. Which date column 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.
  2. Whether situsgroup actually holds guest cards outside today−1y. Same measurement. #20139 found 21 such applications at this customer, which makes it likely, not proven.
  3. The 2026-09-29 16:00Z onset hour. #8136 made this one-per-minute on 2026-09-13 — sixteen days before the symptom — so the guest-card cadence alone does not explain the onset either. (Nor do #14255 or #14072: both merged 2026-09-30, a day after.) That belongs to diagnose-budget-pause.
  4. Whether the 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.
Receipts
  • Window: 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:942
  • Cadence: lambda/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,232
  • Quiet-pass: lambda/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:1307
  • Budget: src/lib/integrations/appfolio/durable-account-budget.ts:66–70
  • Prior art: #7935 858c5954857 · #20139 11809804150 · #8136 · src/lib/domain/pms/history-window.ts
  • Cooldown precedent: src/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
  • All figures from CloudWatch /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.
PropFlow Docs