Captures from the branch's own dev server, so the PR carries a before/after a reviewer can actually look at. Actions was down when this was taken; it has since recovered — see the CI note below.
6 Aug 2026 · branch policy-drawer · captured against propflow-stage
Data note. Every name and balance below comes from propflow-stage, which is anonymized by construction (ADR-0097/0110 — phones in the impossible +1000 NPA, emails @example.test). The identical capture against prod would carry real delinquent tenants' names and balances and is not safe to attach anywhere.
The PAYS column sits between PAID and ENDED — how they pay next to when they last paid, because a chase decision reads the two together. Header order asserted in text as well as pixels: … LATE · PAID · PAYS · ENDED · STATUS · STAGE.
Captured with a property selected — the policy is per-property, so with the picker on “All Properties” the editor correctly refuses to render controls and you see the empty state instead of the feature. Opened from the named Collections policy control (top right — replacing an unlabelled three-dot). The page stays readable behind the rail, which is the whole argument for a drawer. Scope is stated out loud: “Applies to Camellia Apartments.” Footer carries Cadence saves as you go and the All policies → escape hatch.
/settings#follow-ups:tenant.collections.delinquent — the link a drawer's All policies → builds. Opens the card, selects the module facet, expands the lane and flashes it, where before it scrolled to a collapsed accordion and stopped.
Updated 6 Aug, after a design pass. The first capture of the drawer was cramped — DockedPanel's bodyPadding defaults to '0', so a body of prose and form controls rendered flush against the card edge. The drawer now passes its own padding, drops the settings-card marker legend (three chips and two wrapped lines above the one lane you opened it to see), and no longer shimmers a skeleton for a load that never runs. Both drawer traps are now written into docs/architecture/dock-drawer.md.
The drawer looked unconfigurable. It wasn't — but the cadence table has five columns and was built for the full-width settings card, so at the dock's 480 default the Send as control ran off the right edge. Drawer now opens at 620 and the whole row is visible.
The default cadence was wrong, and the editor made it obvious. Old: 1h → 3d → 7d, which spent all three of the lane's 3-per-calendar-month messages inside week one and then went silent for three weeks. The 1-hour first touch also rendered the second row's increment as “71 hrs”. New: waits of 21, 7 and 3 days — touches landing on day 21, 28 and 31. Gera, 6 Aug: “we’re gonna play a little relaxed for this initial phase.” The anchor is already a full month past due, so the first reminder goes out around 51 days late and the ladder tightens as it drags rather than front-loading. Day 31 is the ceiling: CADENCE_MAX_DELAY_MS is 31 days, under the outreach workflow’s 33-day run timeout, and that 2-day gap is what lets a final touch at the cap absorb a quiet-hours hold instead of being killed unsent.
Actions recovered and gave the branch its first real run. One red: sensor-registry.drift.test.ts. Every
*.drift.test.ts in this repo has to carry an @sensor pragma declaring when it gets re-decided and what
would make it deletable, and the new module-axis guard shipped without one. Fixed — reviewBy: permanent, because the
guard holds for exactly as long as <PolicyButton> derives its label, lanes and existence from the registry's
module field, and while that derivation holds a wrong module value fails silently: the lane simply
never appears on a page and no other test notices.
The drawer did not open at all. PolicyButton rendered <PolicyDrawer> gated on dock.isOpen. That looks equivalent to mounting it always and is not: the panel's slot-registration effect deletes its own open-state entry on unmount, so the panel opened, mounted, and un-opened itself before the next paint. aria-expanded was back to false after every click. No test caught it; the screenshot did.
Broken copy in the money-rules footnote. The sentence interpolated a noun phrase into “once …” and rendered “once the balance going a month past due.” Now reads “The cadence above counts from the balance going a month past due.”
The reset shown above closes the window the load opens. It does nothing about the one the save opens, and that is the same whole-schedule clobber one layer down.
save captures propertyId at render, so the PATCH always reaches the right property — the URL was never the bug. Everything after the await was unscoped:
1 · Drawer open on A, Save clicked, PATCH in flight. 2 · Picker moves to B; the load effect resets, fields blank, skeleton up. 3 · A’s PATCH resolves first and setPricing(stored) puts A’s full fee schedule back into state — so the form re-renders editable and “Saved”, with blank fields, under B’s name. 4 · The operator types a late fee and Saves, and A’s parking, pet rent, admin fees, renters insurance, customFees and petPolicy land on B.
This one is harder to see than the bug above it, not easier. With the stale-render bug the wrong numbers were on screen, where an operator might have caught them. Here the reset has already blanked the fields — nothing on screen is wrong. Only the payload is.
One generation token now covers all three ways a continuation can go stale: the effect re-running, the component unmounting, and the picker moving under a save. busy joins the reset block because the guard drops the old property’s finally { setBusy(false) } — without that line the guard would convert a wrong-property write into a form stuck under “Saving…” forever.
The reviewer reasoned this finding from source and ran no tests, so the red run is the part that was missing. Both directions were run against the real component.
Control A — remove the guard, keep the test. The rendered DOM is the bug: a green Saved badge and an editable form under Other Property, every field empty, while pricing holds the other property’s schedule.
AssertionError: expected 'Collections policyCollections policyO…' not to contain 'Saved'
Expected: "Saved"
Received: "… Opened from Other Property … Applies to Other Property only.
Late fee Percentage of monthly rent % of rent
Flat late fee Charged instead of, or on top of, the percentage $
Grace period Days after the due date before a fee lands days
Save Saved …"
Test Files 1 failed (1)
Tests 1 failed | 6 skipped (7)
Control B — the decisive assertion, on the wire. The first version of this test could not fail: a bare “click Save, expect no PATCH” passes in both worlds, because the reset blanks graceDays and save bails on !Number.isFinite(grace) before it ever builds a body. It would have passed against the unfixed code and read as proof. The test now types first, as the real failure requires — and with the guard removed it emits the actual clobber:
AssertionError: expected [ Array(1) ] to deeply equal []
+ "{\"pricingDetails\":{\"lateFee\":{\"graceDays\":2,\"percent\":5},
+ \"parking\":{\"monthly\":75},\"petPolicy\":{\"rent\":40}}}"
That is prop-1’s parking and petPolicy being PATCHed onto prop-2. Restored, the suite is green: 137 tests across the policy components, the loader and the module-axis drift guard; npm run typecheck exit 0; ESLint 0 errors.
Three were carry-overs on their second round, and they matter more in this PR than they would anywhere else — its whole thesis is that a stale scope sentence is how the wrong edit gets made.
PolicyDrawer.tsx — propertyId’s docblock pointed at scopeLabel, which does not exist in this repo outside unrelated purchase-order components, and claimed the header names scope, which the very next docblock says it deliberately does not. collections-list-loader.test.ts — the join comment cited af-99/99 while the fixture is OCC_REF = 950, contradicting the paragraph 35 lines above it explaining why af-99 was rejected (it can only ever exercise the miss). FollowupPolicyEditor.tsx — cited the drawer’s “Applies to” line, which now reads “Applies across this property’s account”; repointed at both per-section scope lines. Plus a fragment wrapping a single div in FollowUpsCard, and a 2.5s “Saved” timeout that could fire against a different property (subsumed by the token guard).
The editor’s two load errors had no path back to false, while both load effects retry (they guard on overrides !== null / budgets !== null, so a failure leaves them null). One transient 500 latched the banner; the retry underneath it then succeeded and rendered the operator’s real saved overrides, with “showing defaults, editing disabled” sitting on top of them.
The failure mode is trust, not data. Nothing is lost and nothing is written wrong. The banner simply tells an operator not to believe controls that are correct — so the honest ones get ignored later.
Cleared where the data lands rather than at the top of the effect. Two reasons, and the second is the better one: the synchronous reset trips react-hooks/set-state-in-effect, and while a retry is in flight the last attempt really did fail — saying so until new data actually arrives is the honest render.
AssertionError: expected 'Collections policyCollections policyO…'
not to contain 'showing defaults, editing disabled'
Test Files 1 failed (1)
Tests 1 failed | 7 skipped (8)
The regression test drives the retry with a property switch, not the drawer toggle. The toggle does not re-run the effect under jsdom, and the reason is worth writing down because it is not the obvious one: DockedPanel keeps a stash of the children captured while open (renderedChildren = stashing ? lastOpen.current.children : children) so the panel does not slide out empty. exited only flips on framer-motion’s onAnimationComplete, which jsdom never fires — so the stash never releases and the test would be pinning frozen props rather than the bug. In a browser the animation completes and close/reopen is a real trigger.
The 31-day cadence cap was justified by a 33-day workflow timeout in three separate files. This PR corrected it twice already, and each correction left another copy behind — the third was inside the drift guard this PR added.
So it is now stated once, in the CADENCE_MAX_DELAY_MS docblock, and the guard cites it instead of restating it. A rationale kept in three places is a rationale that will be wrong in two of them.
The corrected version separates what the old one merged. 31d is the generic cap, so it is set by the tightest run budget among the lanes that use it — prospect outreach at 33 days, leaving the 2-day gap that lets a final touch absorb a quiet-hours hold. Collections runs at 45 days, and its shipped 21/7/3 cadence lands the final touch exactly at the cap, which is what makes it read like the justification while actually carrying 14 days of headroom. It is bound by the shared cap, not by its own budget.
exclusiveGroup="right-rail" is now the exported OPERATIONS_RIGHT_RAIL_GROUP. Group membership is plain string equality, so a typo does not fail — it silently drops the panel out of the group, which is the overlap the prop exists to prevent, in the one situation nobody tests (two panels open at once). And the “Saved” badge timer is now tracked and cleared on cleanup; the token check already made a late fire harmless, which made it easy to leave a live timer on a dead component.