0136 — Launch criteria: org-scoped reads everywhere, attachment resolves person → property → org, old paths kept through the bake, one Settings page
- Status: Accepted
- Date: 2026-09-14
- Deciders: Gera (chat with the driver-aid, ~20:00 CT), from the founders' standup the same day (Fede, Gera, Sean; Slack
C0BE1NFTA0Kthread1789405138.416749)
Context
The multi-org architecture is switched on for two bought test lines with a one-week bake (row
p5-bake-signals: every old-path and head-path read emits a signal and increments a durable counter).
Three customer companies onboard within four to seven weeks. The standup changed some product shapes —
most visibly, a tour is assigned to a leasing person's calendar rather than a building's — and the same
evening an engineer, filtered to one property, opened the Clara Conversations tab and saw several
properties. Gera's instruction, verbatim: "everything open could be driven to completion … we don't want
to delete the old architecture just yet … every page needs to be clearly aware of the new org strategy,
so we don't do table scans and pulls; everything should be pulled strictly from its org … the design
changed, but that means the architecture should still support the old one, just in case — per person
calendar or per org or per property, we're that flexible." Companion ruling: ADR-0135.
Decision
Every read is scoped to the selection, everywhere. No page, route, tab or job reads outside the org(s) and building(s) the viewer has selected; no table scans on user-facing paths. "The selection is the read scope" (PR #8474, #8502) is the rule for the whole app, not for the routes that happen to have been swept. A drift guard fails when a route reads outside the selection. The Conversations tab report is the first named consumer to fix (row
ui-s-d, the consumer sweep). 1a. At the database — the filtering, the window, the order, and the page. Decision 1 ruled where a read may reach: only the selection. This clause rules where the narrowing happens, and it is the same place — in the query. Gera, 2026-09-21 ~04:20Z, verbatim: "we have to do an audit … every page that pulls from the database should do all the filtering and all the adjustments there for two reasons. One is security and two is latency. It's a lot faster if we do it there and we just pull the necessary information. And we could possibly even do pagination to be even faster … things along those lines need to be practiced throughout. So we need to make it clear that each page needs this behavior." A read satisfies 1a when all four are true of the query itself, not of the code after it:- SCOPE. The company, the selection and the property bound are key conditions or filters in the query. A bound applied to an array after the read does not satisfy this at any latency: the rows left the database before the check, in a process that had no business holding them. That is the security half, and it is the half that does not improve when the table is small.
- SHAPE. The sort and the window — a date range, latest N — are key conditions with a
Limit, so cost is proportional to rows shown rather than rows stored. A.sort()or a.slice()after the read is not a window. - PAGINATION. A list reads one page: a
Limitplus a cursor derived from DynamoDB'sLastEvaluatedKey, returned to the caller opaque. Never an offset over a fully-read set. The first page is rendered on the server, the next page is prefetched on the client after paint, and a poll re-reads page one only. - JOINS. No per-row read after the main read. Columns a list renders are carried on the row, stamped at write time or projected onto the index the list reads, not fetched per row.
Staff and "All companies" are not an exemption. A caller with no narrowing selection — a
platform_admin, or an empty selection — reads a bounded page, not the whole estate. "No selection" means the first page of everything you are entitled to, never every row in the table. This is written out because the shared scope helper currently does the opposite (a null scope falls through to the unscoped reader), and because staff accounts take that branch on every page they open — so the rule reads as satisfied in exactly the test that matters most.Background work is bound differently and is not excused. Synchronization, ingestion and durable workflows take their own authorized tenant scope and pinned operation context rather than a browser selection, per Decision 1's existing carve-out. That changes which bound applies; it does not license an unbounded read. A job that walks every property partition still crosses every tenant boundary.
A deliberate whole-estate read is allowed, and must say so. Reconciliation oracles, spine sweeps and archive readers legitimately need every row. Those call a reader named for it and carry a one-line comment giving the reason. The defect this clause names is not breadth; it is breadth arrived at by omission — an optional scope argument left off, so the read silently takes its widest branch.
The measurement that occasioned this clause. A read-only audit on 2026-09-21 measured the properties list against the stage mirror through its own loader functions: five reads issued with no scope argument, 10,791 child rows read on every paint, against 48 rows returned for one company — and four of six companies discarding 85–100% of what was read. The page re-runs that read every five seconds while the tab is open. The prospects list is the same defect measured in production at roughly six seconds per paint. Board standard row:
standard-every-page-filters-and-paginates-at-the-database-not-in-memory-for-security-and-latency.Attachments resolve person → property → org, and all three levels stay supported. The standup moved tour booking onto the leasing person's calendar. That is a default resolution order, not a replacement: a calendar (and the same shape for phone lines, mailboxes, knowledge) may be attached to a person, a property, or an org, and the resolver walks person, then property, then org. The previous building- and company-level attachment work is not scratched; it is the lower rungs of the same ladder. A future customer with one shared calendar per building, or one per company, is served by the same code path with nothing new built.
The old architecture stays through the bake. No deletion row (
p9-deletions, the config-file retirement inp5-config-dumps/v6-config-dumps, the catch-all mailbox inp5-catchall) executes its deletion until the bake readout shows the flipped lines' old-path reads at zero and Gera says the new architecture is running smoothly. Building the dumps and the guards proceeds; the delete waits.One Settings entry, four sub-navs. The main navigation carries a single Settings; inside it a sub-navigation with Admin, Property, Organization, Profile. Every existing settings page is placed under one of those four — placed, not redesigned. Organization and Property may later gain a nested sub-nav of their own. Per ADR-0135 every page under Settings is visible and editable to every signed-in person in the company; Admin alone stays behind
platform_admin. Gera's designs for the page's inner UI follow later and are out of scope here.Drive everything open to completion; Gera tests after. The dock is driven to the finisher's count with the criteria above as acceptance; polishing (the settings redesign among it) starts only when the open rows are closed and the founders have tested the result.
What a future reader should check code against
- A user-facing read that touches rows outside the current selection, or scans a table where a selection exists, violates (1).
- A loader that reads with no scope argument and then filters the result in memory violates (1a), including when the surrounding route is company-gated. The gate decides who may ask; it does not decide what is read.
- A list route that returns every matching row, with no
Limitand no cursor, violates (1a) even when its scope is correct. - A
Limitapplied after an in-memory sort is not a window and does not satisfy 1a's SHAPE clause. - A per-row read inside a loop over the result of a list read violates 1a's JOINS clause; carry the column or project it.
- A null read scope reaching a repository read as an absent argument is the staff bypass and violates 1a by name.
- Code that assumes a calendar (or line, mailbox, knowledge) must hang off a person — or must hang off a building — violates (2); the resolver, not the caller, decides the level.
- A deletion of an old-path map, config file or mailbox before the bake readout and Gera's word violates (3).
- A settings page reachable outside the single Settings entry and its four sub-navs violates (4).
Consequences
ui-s-dgains a concrete acceptance: the Conversations tab honours a one-property selection, and a guard proves every consumer does.- 1a's SCOPE clause has a sanctioned primitive already in the tree (
readOverScope); the work is adoption and closing its null branch, not a new abstraction. - 1a's PAGINATION clause is a new capability, not a sweep: no route pages at the database today.
It lands vertically, one list at a time, starting where the wire contract already exists — the
conversations list already emits an opaque cursor over an in-memory walk. The instructive
precedent is
listPmsRecords, which already implements a correct cursor-paginated, company-keyed read. The one caller of it that a route reaches —listAllGuestCards, behind the prospects list and search — drains every page into a single array in ado … while (cursor)loop, stopped only by a 200-call safety cap. So the page read exists and its one reachable consumer asks for all of it regardless. The clause's work is therefore adoption at the call site, not a new reader: one of that function's unreached siblings, the CRM record adapter'sasync *pull(), already yields per record and stops when its consumer stops, which is the shape 1a asks for. - The cross-property readers keyed by entity type bound time but not tenant; they are re-keyed to the company, which gives them scope, window and a cursor in one move.
- 1a closes on two proofs: read counts that fall with the selection (the bar
bug-atlas-and-dashboard-reads-scan-the-tablealready owns, via the read meter), and read counts that do not rise with the table — a page's reads stay flat as rows are added, which is what "at the database, with pagination" means. p9-calendar("an assignment per building and person, and the round-robin two leasing people need") is the per-person rung and stays;v9-tour-calendar-resolved("building's own, else the company's") is the lower rungs and stays; the resolver is one function with a fixed order.p9-deletionsis gated on the bake readout plus a founder's word, recorded on the row.- A new row carries the Settings placement (one entry, four sub-navs, everything placed under them).
- Rows that presumed a single attachment level are re-read against (2) before more is built on them.
Amendment 1 (2026-09-24) — the Settings redesign starts now
- Status: Accepted — Gera, 2026-09-24, in his request for the redesign (carried by the
Settings redesign driver, task
sess-propflow-24). - Supersedes: the "placed, not redesigned" limit and the "inner UI … out of scope" sentence in Decision 4, and the settings-redesign half of Decision 5's "polishing … starts only when the open rows are closed and the founders have tested the result". Decisions 1, 1a, 2 and 3 are unchanged, and so is the rest of Decision 5: the dock is still driven to the finisher's count.
- Design: Settings Redesign — the Design, which traces every rule below to who said it and when, and places all 212 settings that exist today.
The ask. Gera, 2026-09-24: Settings should be "a clear pretty home… clean and standard" for each level, with the knowledge base included. The redesign is no longer polish that waits for the bake; it is the next piece of work on Settings.
The layout.
- One Settings entry, four levels: You (profile, sign-in and security, my calendar, my
notifications), Company, Building, and Admin, which only PropFlow staff
(
platform_admin) see. This renames Decision 4's four sub-navs (Profile → You, Organization → Company, Property → Building); it does not add or remove one. The page follows the building picker: no building picked shows Company, a building picked shows that building. - Company and Building have the same three sections: Knowledge (facts about the place), Policies (written rules Clara follows) and Settings (switches and numbers, grouped by topic such as leasing and tours, maintenance, connections, notifications, billing). The knowledge base moves here as its own section at both levels.
- The company sets a rule; a building may set its own. A building that has not set its own value keeps the company's value. Superseded by decision E1 (2026-09-30): this item used to say the company may LOCK a rule so a building cannot override it ("Ask the company to change"). A prod audit found zero live locks at any real company, and Gera ruled "just allow it to be set anywhere. No one can restrict it." The lock was removed in #14928 (switch, writes, resolver) and 5a-2 (dead code).
- Every value shows its source in words: "Set by your company", "Set here", "From your company", "PropFlow default · your company hasn't set this", or "Not provided". A money fact with no value at either level is never invented; Clara says it is not on file.
- Who edits. Everyone signed in to the company can see and edit the Company and Building
sections in version 1, per ADR-0135, which this
amendment does not change. You is yours alone. Two things are set up by PropFlow only: the
property-system connection and the sending email address, because changing either can
break the whole integration. Both are edited on the Admin level, behind the
platform_admingate ADR-0135 already names, and only mirrored read-only on the Company page. No new role gate is added anywhere. - Search uses the existing global search, not a second box inside Settings. Opened from a
Settings page, it starts with an
@settingstag already added (and, the same way, other pages will add their own tag, such as@tenants); removing the tag searches everything. A result lands on the exact setting at the level you are on.
Build order: tier → organize → pretty → search (Gera, 2026-09-16: "First phase, determine what goes in what tier. Second phase, organize… third phase, make it pretty… then… search.").
Open: the Policies section is pending decision b90269585 (should written rules such as
pet, renewal and follow-up policies get their own section?). Recommended: yes. Nothing
else in this amendment waits on it.
What a future reader should check code against (this amendment)
- A Building page that refuses, greys out or hides a building's own value because of a company setting violates (3) as amended by E1 (2026-09-30). (It used to read: a page that lets a building change a value the company has locked.)
- A value shown with no words saying where it came from violates (4).
- A Company or Building settings page gated on a role violates (5) until ADR-0135 is superseded. Editing the property-system connection or the sending email address anywhere but Admin also violates (5).
- A second search box inside Settings violates (6).
Amendment 2 (2026-10-13) — "All companies" is the union, not "pick a company"
- Status: Accepted — Gera, 2026-10-13, testing the top-left picker on every page.
- Supersedes: rulings
f535d2eb9(staff on "All companies" are asked to pick a company before any read),f6d0aeb16(a platform admin who picked nothing is refused before any business read) andf5d6f4343(no walk over every company's buildings for the all-companies read), and, in 1a, the "Staff and 'All companies' are not an exemption" paragraph as those rulings applied it: plain "All companies" no longer means "a bounded first page, else refuse". It means the union of every company, read one leg per company. That union is unpaged on a surface whose list is not paged yet, exactly as a several-company pick already is (Consequences: no route pages at the database today); it becomes paged when that list's own pagination lands. The rest of 1a is unchanged: every read is still bounded per company in the query, never one wide read, and a page with its own all-companies index keeps it.
The ruling, verbatim. Gera, 2026-10-13: "for every page, it should always be the combination of everything selected, whether it's all companies and all properties." His test, per page: pick all companies but one and all buildings but one, then plain "All companies"; the left-out rows must be missing and everything else must be there.
What it means in code.
- Plain "All companies" is the union of every company the viewer may see, never wider. Only
platform staff can pick it (a customer's
org=*falls to their own company), so it is every company in the staff grant. - It is answered one leg per company, the path a several-company pick already takes
(
companyLegsOrAllCompanies→resolveOrgLegs→ the surface's per-company loader and fold). A total is each company's own number folded by the payload's rule file, never one wide read. A number that has no honest combined value stays per company, as for any several-company pick. - A surface with a dedicated all-companies reader (the work-orders fleet partition, the conversations recency index, the tenants and turnovers first pages) keeps it.
- Sandbox companies are in the union exactly as they are when staff tick them in the picker: each surface's own test-building rule applies per leg.
- Load: production on 2026-10-13 has 6 companies and 158 buildings behind "All companies". The list caches front the several-company read on their own key, so a poll inside the fresh window reads nothing past the scope.
What a future reader should check code against (this amendment)
- A picker page or route that answers plain "All companies" with "pick a company" violates (1),
unless
picker-one-company-takes-the-legs.drift.test.tsnames it with a reason. - A union computed as one read over every company's buildings, rather than one leg per company, violates (2) and 1a.