0140 — File units, leases, tours and renewal rows by company, on the existing phone-index
- Status: Accepted; in use. Production backfilled and parity GO on 2026-10-02 (5,073 rows, 0 findings); the staff "All companies" Leasing leg reads units, leases and tours by company (part 5). Renewal rows are filed but still read per building.
- Date: 2026-09-29
- Deciders: Gera (decision b90678988, answered 2026-09-29 in chat: "Yes, add the lookup + backfill", conditional on a stage run first and counts shown before production)
- Related: design A (docs.propflowai.co/a/leasing-company-partition-design-2026-09),
src/lib/data/dynamo/org-row-gsi.ts(the contract),work-order-org-gsi.ts(the precedent), ADR-0138 (the rollout shape)
Context
The staff "All companies" Leasing numbers fold each company separately. Each company leg reads every building one at a time: about 8.5 database calls per building, 598 for the 70-building company on stage, 513 per cold request on prod (p75 2.5 s). Prospects and occupancies already have company partitions. Units, leases, tours and renewal rows do not.
Stage, measured read-only on 2026-09-29: for each of the two 70-building companies, all four row types together come to under 1 MB, each type fitting in a single query page (largest: leases, 188 rows, 165 KB). A company read is therefore one query per row type, not a sequential drain.
Design A proposed a new index. The table has 19 of 20 indexes on prod. The one prod lacks from stage, conv-nontest-recency-index (ADR-0138), is owed the 20th slot.
Decision
No new index. The keys ride the existing phone-index (HASH GSI1PK, RANGE GSI1SK, projection ALL, present on both tables):
GSI1PK |
ORGROW#<organizationId>#<entity>, entity one of unit, lease, tour, renewal |
GSI1SK |
<propertyId>#<rowId> |
- Why it is safe to share.
phone-indexalready holds non-phone families (PROPERTY#<id>inquiry keys, vendor-contact keys). No phone and no other family starts withORGROW#, and queries are by exact partition. None of the four row types carries GSI1 today (stage: 0 of 1,464 units, 0 of 1,114 leases, 0 of 372 tours, 0 of 651 renewal-building rows). - Both keys or neither. The index is composite; a half-stamped row is invisible.
- The company is the building's, read from
PROP#<pid>/METAat write time with a strongly consistent read, never the session's. No building or no company: stamp nothing. A failed building read never fails the row's write: it is logged with the building id, and the row keeps theORGROW#keys it already carries (a full PUT would otherwise silently unfile it). - Both writer trees stamp (src and the Clara Lambda), pinned byte-identical by a mirror-parity test.
- Every script writes only its own family. The backfill stamps a row with no GSI1 and repairs a row whose
ORGROW#keys are wrong, conditioned so it never writes over another family's GSI1. The revert removes only wherebegins_with(GSI1PK, 'ORGROW#'), so it can never erase a phone lookup. - Copied rows are re-keyed.
clone-property.tscopies units, leases and tours whole, so it drops theirORGROW#keys and re-derives them for the TARGET building's company (scripts/clone-property-org-row.ts). The backfill likewise repairs a row whose keys name the wrong company, not only a row with none. - Readers. Only the staff "All companies" leg reads by company: its scope is the company's whole live building set (
wholeCompaniesinload-leasing-stats-cached.ts). A company or building pick keeps its per-building reads. Rows are still cut to scope. - An empty company read means "unknown", not "none" (part 5). It is also what a company whose rows were never filed by company looks like, and showing it would be a fabricated zero. So an empty or failed company read falls back to the same per-building read a company pick uses; a genuinely empty company pays what it pays today. This catches only the all-unfiled case: a partly filed company would read short, which is what the fresh parity GO before part 5 rules out. Refusing was rejected because legitimately empty row types are common (on stage, a 51-building company has no tours).
- Renewal rows are filed but not yet read by company. The renewals leg reads through
getRenewals→renewal-building-read.ts, which splits sagas from snapshots; moving it to the company partition is a change inside the renewals reader, deferred to its own part. - A building that changes company keeps its rows under the old company until re-stamped. The product's one Property writer refuses an org change; parity reports any drift as NO-GO.
Rollout
- Keys stamped by the writers, one row type per PR, plus the reader function, unused.
- Backfill (dry-run by default, ledger), prefix-guarded revert, parity with a NO-GO gate.
- Stage backfill and parity; stage timing. Production dry-run counts go to Gera. The production apply is Gera's act.
- The Leasing fold reads by company only after part 4c (consistent building read, keep-on-failure) is merged AND on a fresh production parity GO. Parity is a read-only check that can be re-run at any time, not a one-time gate: it is re-run right before part 5 merges, and again after any backfill, revert or building move. A GO from an earlier day does not count.
Consequences
- One strongly consistent
PROP#<pid>/METAread per distinct building per write batch in the unit, lease and tour writers, and one per renewal saga or snapshot PUT. Saga writes are the most frequent of the four (every state transition and version bump), so this is the largest added read cost; a failed read never fails the write. - The company reads are eventually consistent. Of the per-building reads they replace, only the tours read was strongly consistent (
getTours, #9197); units and leases were already eventually consistent. So a tour saved in the last moment can miss one 30-second cache window of the staff "All companies" numbers. Each company read logsleasing-fold/company readwith its row count and whether it fell back. Read that log per company AND entity: a company that genuinely has none of a type (no tours) falls back on every cold load, by design, at the capped fan-out; a fallback for a type the company does have is the signal worth chasing. phone-indexgrows by roughly the four row counts (prod: a few thousand rows, under 10 MB), with no new index to operate or to count against the 20.