ADR-0129 — Collections quiet hours are measured on the RESIDENT's clock, not the property's
- Status: SUPERSEDED by the founder decision of 2026-08-25 (see the banner below). Accepted 2026-08-19 – superseded 2026-08-25.
- Date: 2026-08-19
- Supersedes: ADR-0125 §4 only — specifically the clause "outside 8am–9pm on the property's clock". Everything else in ADR-0125 (the approval gate, the type-not-a-flag instrument, the 48-hour window, the retryable/terminal split, the status vocabulary) stands unchanged and is not reopened here.
- Also corrects: the property-clock rationale on
DEFAULT_QUIET_HOURS_TIMEZONE(src/lib/domain/compliance/quiet-hours/constants.ts), recorded from a 2026-07-29 team decision; a false claim incollections/legality.tsthat the workflow's quiet hold is "resident-local" and "already owns timing"; the matching divergence notes incompliance/collections/README.mdand ADR-0117; and open question Q7 indocs/planning/collections-counsel-brief.md, which asked counsel to resolve exactly this and is now answered. - Related: ADR-0112 (the collections lane), ADR-0117 (the compliance
harness), ADR-0092 (suppression), ADR-0097 (the impossible
+1000stage phone convention).
⚠️ SUPERSEDED 2026-08-25 — the lane is back on the PROPERTY's clock
Gera, 2026-08-25, stated three times and finally as: "there should only be one quiet hours and it's based off the property. Everything else should be updated because it's stale." His reasoning: the property's jurisdiction is the law that governs the tenancy, it is unambiguous, and an area code is a weak proxy for where somebody actually is.
This reverses his own 2026-08-19 answer (decision
collections-three-phasesd4: "Resident clock, resolved from the number. Matches the statute; needs an area-code resolver"), which is what this ADR implemented. Same person, six days apart, opposite answers — recorded plainly so nobody reads this document as still governing.What survives, and what does not. The argument below from
47 CFR § 64.1200(c)(1)— that TCPA measures the called party's local time — is NOT refuted by the reversal; it is a live trade-off that the founder weighed and decided against. Read it as the case for the other side.The refuse-don't-guess rule survives intact and is the part worth keeping: the lane still declines to send rather than fall back to
DEFAULT_QUIET_HOURS_TIMEZONE.propertyClockFact(collections/approved-dun-send-ops.ts) refuses when a property has no timezone or a malformed one, precisely becauseisWithinQuietHourswould have substituted'America/Chicago'and texted on a guessed clock.
resolveRecipientContactWindownow has zero production callers. It andnanp-area-codes.tsare retained, unwired, against the possibility that counsel-brief Q7 is revisited.Shipped in the PR that carries this banner.
Context
TCPA fixes the contactable window at 8am–9pm in the called party's local time (47 CFR § 64.1200(c)(1)). Not the caller's, and not the building's.
PropFlow measured it at the building. decideCollectionsDunPremise took its
verdict from isWithinQuietHours(property.timezone, now), which falls back to a
hardcoded zone when the field is unset:
export const DEFAULT_QUIET_HOURS_TIMEZONE = 'America/Chicago';
Property.timezone has no writer in application code — not onboarding, not
an admin route, not the AppFolio sync. The only assignments in the repo are two
fixture seeders under scripts/. The AppFolio adapter reads the field and
warns when it is missing (pms-adapter.ts:1393), which is the shape of a
value someone else is expected to have supplied.
What is NOT true, stated plainly
The obvious next sentence — "so every property falls through to Central, and a
Denver resident was graded an hour wrong" — is false, and this ADR was
drafted asserting it before anyone checked. A read-only scan of
propflow-prod on 2026-08-19 returned 17 property rows, every one of them
carrying a timezone: eleven America/Denver, two America/New_York, two
America/Phoenix, one America/Los_Angeles. Camellia's is America/Denver.
So the Chicago fallback has not been firing, and no resident is known to have been texted outside their own window. That correction is recorded rather than quietly dropped, because the false version is a better-sounding argument than the true one and would have been the load-bearing sentence in this document.
What IS true, and why it is still worth changing
- The standard is wrong. TCPA measures the called party; a building is at best a proxy for that, and the decision below settled it against the proxy.
- Correctness depends on a field the product cannot maintain. Those 17 values were set out of band. Nothing in the product wrote them and nothing in the product keeps them right, so a newly onboarded property — or a row rebuilt by a future sync — silently becomes Central, and the failure is invisible: the send proceeds, on the wrong clock, and nobody learns.
- Nothing downstream would catch it.
dispatchapplies consent, ADR-0092 suppression and sender policy at the wire; it does not look at the clock. The premise check is the last rung that can stop a badly-timed send.
The defect this fixes is therefore latent rather than observed — a gate whose correctness rests on out-of-band data, sitting on a federal-rule boundary, in front of the first collections text ever sent to a real resident.
Three documents, two standards
The defect had already produced doc rot, which is how it survived review:
| Document | What it said |
|---|---|
compliance/quiet-hours/constants.ts |
Quiet hours are the property's clock — team decision 2026-07-29, argued from number portability. |
compliance/collections/assess.ts |
recipientLocalHour is "Current hour (0–23) in the RECIPIENT's local timezone", and the gate abstains rather than guessing. |
collections/legality.ts |
The workflow's quiet hold is "resolveOutreachQuietHoldActivity, resident-local" and "already owns timing". |
The third was false on both halves, and both errors pointed the reader away from
the gap. The activity is real and collections-chase-workflow.ts:226 does
await it — but (a) it is not resident-local: it reads property?.timezone and
falls back to Central, and its own test is titled "the PROPERTY clock
decides"; and (b) it does not own the timing that matters. Post-ADR-0125 the
chase touch has no outbound path — it writes a pending review row — so
that hold paces when a row appears on a QUEUE, not when a resident's phone
lights up. It is the sentence that read as reassurance, so it is the one that
mattered.
The hold itself is left in place by this ADR: pacing composition to daylight is harmless and mildly useful. It is simply not a TCPA gate, and the comment no longer implies it is.
And ADR-0125 §4 — an Accepted ADR — codified the property clock as the standard. The wrong answer was the written one.
The decision that reopened it
Gera, 2026-08-19, decision page collections-three-phases, question d4
("Quiet hours — property clock or resident clock?"), verbatim:
Resident clock, resolved from the number. Matches the statute; needs an area-code resolver.
He was offered and rejected two alternatives: "property clock, but actually write the field" and "the intersection of both clocks".
This is urgent rather than tidy because collections-first-send is gated on it.
The first collections text ever sent to a real resident should not go out on a
gate whose correctness nobody in the product maintains.
Decision
The collections contact window is computed from the RECIPIENT's own time zone, resolved from their phone number. When the number does not establish a zone, the send is REFUSED. There is no fallback zone on this path.
1. The resolver
src/lib/domain/compliance/quiet-hours/recipient-clock.ts — pure, no I/O, no
ambient clock (now is a required parameter). Given a phone number it returns
either the whole computed window (IANA zone, local HH:MM, the 8–21 bounds, and
the verdict) or a refusal carrying a reason code and one sentence of plain
English.
Its data is nanp-area-codes.ts: a checked-in NPA → IANA table.
2. A gap REFUSES. It does not guess.
This is the load-bearing property, and it is why the union has two arms rather than a boolean plus a default:
- an NPA that is absent from the table → refuse (
unknown_area_code); - an NPA that is non-geographic (toll-free, premium, PCS) → refuse;
- an NPA that is structurally impossible — first digit 0 or 1, or an N11
service code — → refuse (
invalid_area_code). The anonymized-stage+1000convention (ADR-0097) lands here, which is correct: a deliberately impossible number must never resolve to a real clock; - a number that is not readable as a NANP number at all → refuse;
- and an NPA that genuinely straddles a time-zone boundary → refuse
(
ambiguous_area_code).
There is no ?? 'America/Chicago', no nearest-neighbour, no majority-wins. A
refusal returns no timeZone field at all, so there is nothing downstream
can read as a zone even by mistake.
⚠️ The rule that makes this durable: incompleteness is safe by construction. Adding an NPA to the table is a strict improvement; omitting one costs a refusal, never a wrong send. So the table is deliberately conservative — every entry is one a reviewer can check, and anything the author could not check with confidence was left out rather than guessed in.
3. Ambiguous is an answer, not a tie to break
Roughly two dozen NPAs cover territory on both sides of a boundary: 812/930 and 574/219 in Indiana, 850/448 across the Florida panhandle, 701 (all of North Dakota), 605 (all of South Dakota), 907 (all of Alaska), 208/986 (all of Idaho), 906 in Michigan's UP, 620/785 in Kansas, 308 in Nebraska, 606 and 270/364 in Kentucky, 423/729 in Tennessee, 541/458 in Oregon, 775 in Nevada, 915/432 in west Texas, and 928 in Arizona (where the Navajo Nation observes DST and the rest of the state does not).
Each is enumerated with the boundary that straddles it, and each refuses. Picking the majority zone would be right most of the time, and the times it was wrong would be exactly the hour-off federal-rule violations this ADR exists to prevent.
They are enumerated rather than simply omitted so the refusal copy can name the real cause — "area code 812 covers more than one time zone" is a fact a PM can act on; "unknown area code" reads like a bug.
Two of those entries were wrong on the first pass and were corrected before
this shipped: 423 (Tennessee) was assigned Eastern despite also serving the
Central-time Cumberland Plateau counties, and 270/364 (Kentucky) were
assigned Central despite also serving the Eastern-time strip around
Elizabethtown and Campbellsville. Recording that is the point rather than an
embarrassment: a hand-built NPA map is a thing a careful author gets wrong, and
the reason getting it wrong is survivable is §2's refuse-don't-guess rule — not
the author's care. The module header carries the same note so the next person to
add an entry knows the boundary claim is the thing under review.
4. The refusal has its own reason code, and it is retryable
CollectionsDunRefusalReason gains recipient_clock_unresolved, distinct from
quiet_hours. They are different verdicts with different remedies: "it is
23:10 for them" means approve again tomorrow; "we cannot tell what time it is
for them" means fix the number on file, or have a person reach out directly.
Collapsing them would tell a PM to wait for a window that will never open.
It is retryable — the row stays pending — for the same reason
no_recipient is: the fix is a data fix a person can make, and burning a real
reminder to a terminal state over a missing table entry would be the worse
error.
5. Scope: collections only, on purpose
Renewals, prospect outreach, maintenance comms, handyman paging and the
touch-budget window still resolve their window from Property.timezone, and
land on the Central fallback whenever that field is unset — which is not the
case for any property today, and which nothing in the product prevents tomorrow.
They are not changed here. d4 was asked and answered about collections;
migrating five other lanes is a different blast radius and a different decision,
and bundling it would put an unreviewable diff in front of the send that is
actually gated.
DEFAULT_QUIET_HOURS_TIMEZONE therefore survives, with its docblock rewritten
to say what it now is: the property-clock fallback for the lanes that have not
moved, explicitly not the collections standard, and never to be reintroduced
to that path. The two standards coexist deliberately, and the constant says so,
rather than one file quietly implying the other is wrong.
The residual is real and should be named rather than buried. It is the same
LATENT exposure described in §Context — Property.timezone has no writer in
application code, so what those five lanes lack is not correct values but any
guarantee that the values stay correct. A newly onboarded property lands on
Central in five lanes at once, silently, and nothing surfaces it.
Size it as "a gate nobody maintains", never as "we are texting people an hour early in five more places." The second phrasing is the one that would get the follow-up prioritised, and it is false — the same false claim §Context retracts, and the one this document has to avoid restating in the act of scoping around it. If you are reading §5 to decide whether to pick the follow-up up: the reason is unowned correctness on a federal-rule boundary, not an observed mis-send.
6. What the area code does and does not prove
An NPA is where a number was issued, not where its owner stands today. A resident who moved to Denver from New York keeps a 646 number. That is a real limitation of this approach and it is documented at the resolver rather than elided.
It is also the standard the statute's own safe-harbour reasoning describes — absent better information, the area code is the caller's evidence of the called party's location — and the alternative in play was not more accurate about the person, only more convenient for us. If a resident ever tells us their actual zone, that record beats the table and the resolver should be handed it directly. No such field exists today.
Consequences
A Denver resident is contactable 8am–9pm Denver time, whatever the property record says or fails to say — and in particular whether or not anybody keeps
Property.timezoneup to date. Verified by printing the computed window, not by counting green tests:npx tsx scripts/print-recipient-contact-window.ts.At one instant, two residents in different zones now get different verdicts. At
2026-08-20T02:30Za 720 number reads20:30 — INSIDEand a 212 number reads22:30 — OUTSIDE. Under the property clock both were graded on one building's zone.Some approvals that used to send will now refuse, and the rate is measured rather than estimated. Running the resolver read-only over every
phoneIdentityClaim inpropflow-prod(2026-08-19) — 1,329 distinct values, 454 of them in the obviously synthetic555/500/999/000ranges, leaving 875 real-looking numbers:outcome count share resolves to a single zone 851 97.3% refuses — ambiguous_area_code12 1.4% refuses — non_geographic_area_code(toll-free)12 1.4% refuses — unknown_area_code0 0% The ambiguous 12 are
812×2,915×2,423,928,208,270,541,785,620,850— every one a genuine straddle. The toll-free 12 are800×5,844×4,888,866,833— numbers that carry no location by construction. Nothing refuses for a reason that is merely a gap in the table: the first pass had three (863Florida,369California,645Miami-Dade), all found by this measurement and added.So the trade is real but small and entirely intentional: ~2.7% of residents need a person to reach out instead. A refusal costs a manual outreach; a guess costs a federal-rule violation. The refusal names the cause and stays retryable.
⚠️ This is a snapshot, not an invariant. New overlay NPAs are assigned regularly, and each unknown one refuses until somebody adds it. Re-running this measurement is the maintenance signal for the table — a rising
unknown_area_codecount means it has aged, and that count is the one to watch because it is the only refusal reason that is never intentional.The table is a maintained artifact. Adding an NPA is a normal PR. A reviewer's job on such a PR is to check the boundary claim, not the phone formatting — and a straddling NPA must land in the
ambiguousarm, never inzonewith a best guess.Property.timezoneremains writerless. This ADR routes collections around it rather than fixing it, because Gera explicitly rejected "property clock, but actually write the field". Anyone tempted to add a writer should read d4 first.
Alternatives considered
Property clock, but actually write the field. Offered, rejected. It would have fixed the Chicago fallback without fixing the standard: the statute measures the called party, and a building's zone is only a proxy for that. It also has a failure mode this design does not — an unwritten or typo'd field silently becomes a guessed zone again, which is the exact defect being removed.
The intersection of both clocks (send only when inside the window on the property's clock AND the resident's). Offered, rejected. Strictly safer on timing and strictly wrong on doctrine: it keeps a writerless field on the critical path, narrows the window for reasons the statute does not ask for, and makes "why did this refuse?" a two-clock question.
Add a phone-metadata dependency instead of a checked-in table. Checked
first, per the repo rule: package.json carries no phone library at all —
date-fns is installed and has no notion of area codes. A dependency was
therefore a genuine option and was rejected for one reason specific to this
gate: the ambiguity calls in §3 are judgement, and they have to be auditable
line by line by whoever is accountable for the send. A vendored mapper that
resolves a straddling NPA to a single zone makes that call silently, with no
diff to review. If a dependency is adopted later, its multi-zone results must
map to ambiguous, never to zones[0].
Fall back to the property clock when the number cannot be resolved. The tempting middle. Rejected: it reintroduces the guess at exactly the moment we have the least information, and it makes the failure invisible again — the send proceeds and nobody learns the resolver missed. A federal-rule boundary is the wrong place for a fallback that cannot be observed.