Where identity, authorized scope, selected scope, target, freshness, enforcement and outcome actually live in propflowai today — per entry-point family, with the existing test that exercises each and the board row that owns each gap.
Inspected commit 94172a0fad7ba2170f60345521455d98ffcd2d4c (propflowai origin/main, 2026-09-16 16:37:56 −0500) · board read at propflow-docs f4e676628c9489f750f631de51f886275d9c73c9 · deliverable of board row p10-core-contract · this is an inventory, not a completion receipt
The contract exists as code. It is not the contract most entry points go through. Every responsibility PC1 names has a real, well-documented implementation in this repo — and three of the seven have a second, live implementation as well, in one case with three times the importers. The scope machinery that reaches durable work is built and largely dark: exactly one of 36 workflows pins and re-checks its scope, behind a patched() flag, and its only enforcing verdict is armed on two bench buildings. The 40 scheduled HTTP jobs take no scope argument at all.
Every implementation claim below names a file and a line at the inspected sha. Claims are labelled by how they were established:
git grep was run and its count recorded. Everything else.Counts from git grep are ceilings, not measurements of behaviour: a grep proves absence only of the spelling searched, and presence only of a string. Every zero reported on this page was run in the same command as a spelling known to be present, so that a zero caused by a bad path or a shell-glob failure would have shown up as a zero on the control too. Where that control is not stated, treat the zero as unverified.
PC1 asks for a logical envelope carrying verified actor or workflow identity, authorized scope, this request's selected scope, canonical target ids, coverage, the relevant revision basis, and for writes an operation identity and explicit outcome. No new universal context type is needed. TenantContext already carries all but two of those, and four sibling context types exist beside it.
| PC1 responsibility | Field on the existing type | Where |
|---|---|---|
| verified actor / workflow identity | actor?: ActorView (personId, tier, projection, buildings, actingOrg) and job?: JobRef (runId, reason) | src/lib/domain/portfolio/context/mint.ts:181, :224 |
| authorized org / property scope | organizationId, propertyId, roster, reach | mint.ts:200–:220 |
| this request's selected scope | absent from the context. Lives separately in Selection | src/lib/domain/scope/selection.ts:267 |
| canonical target / resource ids | path: ScopePathView, address? | mint.ts:94 |
| revision / freshness basis | mapVersion: number | mint.ts:220; read at MintSource.mapVersion :500 = ORG#<org>/PROFILE.mapVersion |
| boundary enforcement | checkPartition(ctx, pk); WALLED_PREFIXES = ['PROP','ORG','GROUP'] | src/lib/domain/portfolio/context/wall.ts:74, :65 |
| write operation identity + explicit outcome | absent from the context. Exists per-lane (see §6) | — |
The five minted context kinds are TenantContext :200, AdminContext :232, ComplianceContext :241, LifeSafetyContext :251, and the reports context. Their non-test caller census at this sha:
| Minter | Non-test caller files |
|---|---|
mintFromAddress | src/lib/temporal/activities/collections-scope.ts |
mintFromSession | portfolio/actor-measure/delta.ts · portfolio/context/session-view.ts · scope/addressed-write.ts |
mintFromSnapshot | data/dynamo/proporg-roster.ts · context/dynamo-snapshot-source.ts · temporal/activities/collections-scope.ts · temporal/workflows/collections-chase-workflow.ts |
mintFromConversation | data/dynamo/voice-call-scope.ts · domain/voice/pin-call-scope.ts |
mintForJob | domain/vendors/vendor-engagement.ts |
mintAdmin | src/app/api/vendors/route.ts · domain/bench/personas.ts · context/session-view.ts · scope/org-level-guard.ts · domain/vendors/load-vendors-list.ts |
mintLifeSafety (:861) · mintReports (:874) | none — defined, zero non-test callers each |
mintCompliance | does not exist. ComplianceContext (:241) has no minter; it is produced only by the one-way narrowing forCompliance(ctx: TenantContext) at :850, which itself has zero non-test callers |
Controls for those zeros, run in the same commands: mintFooBar → 0 while the six rows above returned non-empty; mintAdmin → 5 files under the identical exclusion filter that gave mintLifeSafety/mintReports zero. The minters are exactly eight — mintFromAddress :528, mintFromSession :592, mintFromSnapshot :663, mintFromConversation :731, mintForJob :790, mintAdmin :833, mintLifeSafety :861, mintReports :874. Files importing portfolio/context/mint at all (non-test): 6. Exactly one route handler touches the context machinery, and through mintAdmin.
| Responsibility | Authoritative implementation | Importers (non-test / total) | Second live implementation |
|---|---|---|---|
| Effective scope (authorized ∩ selected) | scope/selection.ts:404 deriveSelection — "the only constructor of a Selection"; request seams scope/read-scope.ts:184 readScope and scope/get-selection.ts:85 mintSelection, both calling the same resolveGrantsFor + chooseProposal + deriveSelection. The seam most routes actually reach is a third one: dashboard/scope/route-scope.ts:168 readDashboardScope / :195 dashboardScopeOf / :248 computeScopeOf, named in 31 files under src/app — against 4 for readScope | 16 / 34 | yes platform/auth/scope.ts:96 getUserPropertyScope reads the stored User.assignedPropertyIds; the intersection is then open-coded per route with org-scope.ts:248 intersectScopes — 45 calls across 32 files, 10 of them the three-way nested fold, e.g. src/app/api/leasing/renewals/route.ts:144 |
| Memberships & grants | data/dynamo/org-membership.ts:194 recordOrgMembership; property grants via scope/grants.ts:123 grantsFromRoleRows over PersonRole rows | 9 / 27 · 3 / 9 | no — but the org-membership rows are written and not read (the module's own header); resolvePersonHomeOrg is still the runtime answer |
| Settings registry + resolver | portfolio/registry/keys.ts:67 KEYS (61 keys, founder-ratified 2026-09-14) + portfolio/scope/resolve-effective.ts:260 foldEffective / :532 resolveEffective | 41 / 56 · 10 / 13 | yes src/lib/platform/settings-resolver.ts:88 resolveSettings — its own inheritance walk with its own DEFAULTS :36, zero references to resolveEffective or the registry, yet its header cites a key that IS registered (keys.ts:132). 29 / 80 importers — roughly three times the canonical resolver. |
| Scoped repositories | portfolio/context/repository.ts getRepository(ctx) + context/wall.ts:74 checkPartition | 0 / 0 — dark | effectively — the reads happen through data/dynamo/helpers.ts:1643 queryByPK and siblings, 191 non-test call sites of queryByPK( alone, none through the wall |
| Reservations | data/dynamo/calendar-slot-reservation.ts:391 holdSlot (+ submitSlot :521, bookSlot :537, releaseSlot :564); states 'held' | 'submitted' | 'booked' :161 | 5 / 7 | no — leasing/tour/slot-hold.ts wraps it; exactly one caller of holdSlot( outside its definition; wiring drift-pinned by calendar-slot-reservation-wiring.drift.test.ts |
| Durable effect / workflow | src/lib/temporal/ — 36 workflows, barrel workflows/index.ts:18-53; idempotency mint temporal/saga-mint.ts | — | partial — the SQS work-order-create path (ADR-0030) is a second durable-effect mechanism; agents/clara/lib/messaging/inbound-dispatcher.ts:405 is the only code site that spells "durable effect" |
| Platform Admin boundary | not found as specified Today: platform/auth/helpers.ts:730 isActivePlatformAdmin (a role predicate, 76 references) | 76 refs | yes, twice over platform/auth/staff-domain-override.ts:72 grants platform_admin from an email domain suffix, consumed at helpers.ts:312-313 and :377 (staffOverrideApplied ? 'platform_admin' : …) and src/app/api/auth/me/route.ts:244. The suffix rule itself has three spellings: identity/internal-address.ts:54, components/primitives/nav-items.tsx:599, and a raw literal at agents/clara/lib/email/auto-reply-guard.ts:286 |
Each cell names where the stage is decided, or says it is not decided. +/− means a positive and a negative control exist for that stage in that family; + only means no negative control was found; none means neither.
| Stage | A · User APIs (462) | B · List/detail reads (155 pages) | C · Internal tools / AI (151+52+23) | D · Background sync (94) | E · Durable workflows (36) |
|---|---|---|---|---|---|
| Verified principal / workflow identity | auth/helpers.ts:801 requireUser, :739 requireAdmin, :147 enrichAuthenticatedUser. 230 of 462 routes name an enforcing require*; 94 use getCurrentUserUnchecked (:649), which returns null rather than refusing. +/− |
auth/request-user.ts:15 getRequestUser via mintSelection (get-selection.ts:85); older pages take headers() and call getCurrentUserUnchecked inside the loader. +/− |
Ask Clara: ToolContext.user (dashboard/chat/tools.ts:415). Clara conversational: the runtime-supplied conversationId, never the model. Operator run route: authorizeAdminOrBearer. +/− |
none. requireCronOrAdminAuth (platform/security/admin-auth.ts:41) compares a bearer secret at :64-67 and carries no principal. Lambdas take no principal at all. none |
No human principal enters a workflow — correct by design (ALIGN2: a tab must not retarget a workflow). What a run carries instead is a pinned scope, in one of 36: pinCollectionsScopeActivity (activities/collections-scope.ts:91). + only |
| Authorized scope | Three legs: property auth/scope.ts:96, org auth/org-scope.ts:36, roster require-in-roster.ts:427. 281/462 routes import the trio. +/− |
scope/grants.ts:215 resolveGrants from PersonRole rows (new path) vs getUserPropertyScope off the stored list (old path). +/− |
Per-handler, six different spellings: isPropertyInOrgScope, canViewProperty, isInScope, scopeByOrg, assertWorkOrderActorAuthorized, isTurnoverInPmOrgScope. No single choke point a new tool inherits. +/− |
none. git grep -l "domain/scope" -- src/app/api/cron/** → 0; the same call's control requireCronOrAdminAuth → 38. none |
Built, and dark in 35 of 36. collections-chase-workflow.ts:262 pins {organizationId, propertyId, mapVersion} behind patched('collections_scope_pin_v1') and re-checks it at :320. The other 35 carry entity ids only and their activities re-derive org from the row they load. An absent organizationId in a workflow's initial input is not evidence that the run is unscoped. +/− |
| Selected scope | selection.ts:404 deriveSelection from read-scope.ts:97 requestProposal; cookie may fill in only where the address said nothing (chooseProposal :602). +/− |
get-selection.ts:85 mintSelection — zero-arg and react.cached so no component can ask a different question; fails closed via refusal() :48. Used by ~2 of ~15 list pages. +/− |
n/a by design — ALIGN2 says a tool is not a viewer's tab. In practice the Ask Clara ToolContext.scope IS the viewer's selection, so tools inherit it. + only |
n/a by design, and correct: a tab must not retarget a sync. n/a | n/a by design. n/a |
| Canonical target / resource | scope/addressed-write.ts:168 addressOfRow, :258 authorizeAddressedWrite — the write takes its org from the row, not the picker. One production caller: src/app/api/vendors/route.ts:183. +/− |
Detail loaders re-derive: properties/load-property-detail.ts:43-49 canCallerViewProperty; collections via leasing/collections/resolve-account.ts:63 (404-not-403). +/− |
Good where the org is re-derived from the record (Derived from the row where a row exists (inbound-processor/handler.ts:722,729); otherwise the whole estate. none |
Entity id is the only target; activities load the row and re-derive. + only | |
| Revision / freshness basis | No authorization version anywhere. grantVersion/authVersion/membershipVersion → 0/0/0 (control SCOPE_KEY_VERSION → 33). none |
scopeKey (selection.ts:637) = sel2: + resolved ids. Scope is in the key; no auth version, no principal. Detail caches carry neither (propflow:tenant:<id>, TenantDetailClient.tsx:213). none |
None. Tools read live rows per call. none | None. none | mapVersion is pinned at workflow start for the one workflow that mints a context; SnapshotView (mint.ts:161) carries {organizationId, propertyId, mapVersion} and its comment reads "What a workflow pinned at start (§4.6)". + only |
| Boundary enforcement | Property + org legs live; roster leg dark by default — rosterGateModeFor :307 returns off unless PROPFLOW_ROSTER_GATE is set or the building is in config/roster-refuse-properties.json (2 bench buildings). requireInRoster is imported by 0 route files; it reaches 16 routes via helpers.ts:1012 → :1063. +/− |
read-over-scope.ts:98 readOverScope — 17 non-test callers, 13 of them routes. Older list pages instead read wide and filter in memory (load-properties-list.ts:55-64). + only for the old path |
Per-handler. toolFence/scopedTool/assertToolScope → 0 each; orgLeg → 14, all in test files. +/− for three tools, none for five |
A shared bearer secret. Nothing else. none | Two kinds. Duplication:|
| Outcome | HTTP status. ALIGN1: a 200 is not an authorization decision. + only | Rows or an empty list. +/− | A tool result object; refusals are {error} strings or refused: true. +/− |
Handler return value; no receipt contract. none | Six per-domain unions, no shared enum — renewal-workflow.ts:83 DeliveryStatus, :85-93 PMSState (incl. unknown), data/types.ts:5280 VoiceChannelOutcome, pending-notices.ts:86, outreach-send-claim.ts:85, voice/provider/contract.ts:200-217 AdapterErrorCode (incl. uncertain). +/− |
git ls-files 'src/app/**/route.ts' → 462 (459 under src/app/api, plus logout, native-auth, robots.txt).platform/auth/helpers.ts (identity + refusal), auth/scope.ts (property leg), auth/org-scope.ts (org leg), plus require-in-roster.ts (roster leg). There is no module named fence — 736 files match the word and every one is ordinary English or an unrelated fence (markdown, idempotency, import-closure).api/tenants (load-tenants-list.ts), api/properties/[id]/units/[unitId] (load-unit-detail.ts) and api/collections/[tenantOccupancyId]/case-ref (resolve-account.ts:63, which calls requireUser :67, getUserOrgScopeForPropertyScopedRoute :76 and isPropertyScopeDenied :80).src/__tests__/require-in-roster-armed.test.ts, require-in-roster.test.ts, require-in-roster-membership.test.ts, scope.test.ts, rbac-security.test.ts; route level src/app/api/conversations/__tests__/route.org-isolation.test.ts and src/app/api/properties/[id]/__tests__/cross-org-property-id.test.ts.require-in-roster-armed.test.ts:101 is labelled by its author "⚠️ THIS IS THE FAIL-BEFORE" and asserts denied === true; :117 is the dark-default control — the identical request with the gate off is allowed and expect(fetchProperty).not.toHaveBeenCalled(). scope.test.ts:187/214/239 assert an empty scope yields zero rows; :262 asserts an out-of-scope id is refused. Route level: route.org-isolation.test.ts:168 expect(body.items).toEqual([]); cross-org-property-id.test.ts:326/340/356/375 assert 404.vi.mock the scope math itself; 246 mock only auth/helpers, so identity is stubbed and the scope math runs real (101 of those use importOriginal); 36 mock none. The fence's own unit tests (scope.test.ts, rbac-security.test.ts) mock nothing at all.p1-roster-refuse (the gate flip), p7-actor-view (the stored list), ui-s-a / ui-s-d (the consumer sweep).page.tsx, 114 server-renderedmintSelection() (get-selection.ts:85), used by roughly 2 of ~15 list pages (vendors, leasing/prospects). Old: loadPropertiesList(await headers()) → getUserPropertyScope/getUserOrgScope → in-memory filtering at load-properties-list.ts:55-64.mintSelection is a sibling entry point, not a second derivation: it calls the same resolveGrantsFor, chooseProposal, deriveSelection and scopeKey as readScope, and its own comment records why ("This module and read-scope.ts used to hold two separately-written copies of that decision… One function cannot drift from itself"). Its fail-closed claim is real: refusal() at :48 builds {orgs: []} and derives from it, and nothing is caught, deliberately, so a failed read surfaces as an error rather than as "you have no organizations".ConsistentRead: true appears 38 times in 26 files — it is not unused. But the two authorization legs are both eventually consistent: the grant read getPersonRolesAcrossOrgs is a base-table query (person-roles.ts:134-140) that omits the consistentRead argument, and the roster read that defines the effective property set is a GSI query (dynamo/property.ts:168, :213, called from grants.ts:238) which DynamoDB cannot serve consistently at all. One is fixable, one is not.person-roles.ts:211-251 endPersonRole sets active=false, endedAt under attribute_exists(PK) — and is honoured by predicate, not by version: every grant read filters r.active (person-roles.ts:139, :119, grants.ts:216). revokedAt exists (203 hits) but belongs to SMS consent and invites; there are zero occurrences in domain/scope or platform/auth.scope/__tests__/read-over-scope.test.ts — the strongest control in the repo for this stage. Nothing is mocked; the subject is the real function and only the injected reader is a spy. Its negative control asserts both halves: expect(await readOverScope('outside', new Set(['inside']), read, 4)).toEqual([]) and expect(read).not.toHaveBeenCalled(). Positive control at :19. Also domain/vendors/__tests__/vendors-read-the-set.test.ts:240 — expect(orgsRead()).toEqual([]) plus expect(rows).toEqual([]).loadPropertiesList or loadTenantsList — which is precisely the older read-then-filter path.ui-s-a, ui-s-a-r2-tab-local-selection, ui-s-d, p10-core-freshness.| Registry | Where | Count | What it is |
|---|---|---|---|
TOOL_CATALOG | src/lib/tools/index.ts:37 | 151 | Operator catalog; exhaustiveness compile-enforced against the ToolKey union (tools/types.ts:438) |
handleToolCall | agents/clara/lib/agent/tools/index.ts:177 | 52 | The single tool_use → handler boundary for SMS / email / voice; 24-case switch at :256-344, 87 handler files |
dashboardChatTools | domain/dashboard/chat/tools.ts:110, dispatch :554 | 23 | "Ask Clara", read-only, its own ToolContext |
| Tool | What the check actually is | What the read is bounded by | Residual concern |
|---|---|---|---|
:1353 listMaintenanceManuals:1373 listFinancialPeriods | isInScope(propertyId, ctx.scope) against the authorized set, before the read | the same id: getFinancialPeriods filters p.propertyId === propertyId (store.ts:5134); manuals likewise | UNVERIFIED property axis only, no org leg. Matters only if a property id can be reachable across orgs — not established here |
:1363 getTenantBalanceHistory | isInScope(propertyId, ctx.scope) before the read | the authorized property is the partition key: PK=PROP#{propertyId} SK=BAL#{tenantId}# (dynamo/property.ts:1602-1607); the JSON twin filters on both fields (store.ts:3698). A foreign tenantId reads nothing outside the checked building | no escape found the earlier "the tenant record's own property is never consulted" was misleading — it does not need to be |
:1345 listAppliances | const propertyId = unitId.split('-')[0] — the first hyphen — then isInScope | the data layer derives the last hyphen instead: unitId.substring(0, unitId.lastIndexOf('-')) (dynamo/property.ts:1426), and unit ids are `${propertyId}-${unitNumber}` by construction (data/types.ts:116) | UNVERIFIED the two derivations disagree whenever a property id itself contains a hyphen. In the general case the tool's id is narrower, so it fails closed; a cross-property read needs a property id that is a hyphen-prefixed prefix of another and is held by the caller. Whether such a pair exists is not established here |
:1477 appfolioReport | none. if (typeof input.property_id === 'number') params.property_id = input.property_id; reaches the PMS unchecked, and the returned rows are not filtered afterwards — client.listAll(endpoint, params) is returned through truncateNote | only the credential. resolveAppfolioClientForScope (integrations/appfolio/resolve-client.ts:24) collects the unique ownerIds from the already-scoped ctx.properties, appends fallbackUserId (the requesting user, "admins sometimes store creds under their own account for testing", :6-8), races them and takes the first that constructs | UNVERIFIED, and the sharpest of the five The right comparison is the provider-returned property population against the caller's authorized operation scope — not against the org. A per-org credential would not settle this: a caller who holds only property A inside an org that also owns B still requires B to be unreachable, and an AppFolio account credential's reach is the account's portfolio, which is not bounded by the caller's property set. Add that the credential is chosen by whichever owner resolves first, so the account that answers need not be the owner of the property the caller named. Stays UNVERIFIED until a test grants A, forbids B, drives the real dispatch, and asserts on both the query issued and the rows returned |
One cross-cutting fact behind all four: isInScope returns true unconditionally when scope === null (auth/scope.ts:148-152), and getUserPropertyScope returns null only for platform_admin (:96-101). That is the documented Admin exception, not a leak — and it is exactly why ToolContext's own docblock insists the user field is required: "scope alone is the PROPERTY axis and cannot answer the org question".
get_work_order (tools.ts:1048 + :1078 isPropertyInOrgScope), get_conversation (:1127 + :1144), get_prospect (:1208, an org-partitioned fetch), leasing (tools-leasing.ts:1087-1090, anchor derived at :1047 from getProperty(...).organizationId and deliberately not from Conversation.organizationId, :1030), turnover (_turnover-shared.ts:254-257), work-order mutators (assert-work-order-actor.ts:54).src/app/api/work-orders/__tests__/cross-org-derived-work-order-and-chat-ids.test.ts, which drives the real handleDashboardChatTool and the real isPropertyInOrgScope. Negative control at :392 — a row whose stored property belongs to another org is refused, with four writer spies asserted not.toHaveBeenCalled(); no-existence-oracle companion at :406; positive control at :414. Also turnover-tool-dispatcher.test.ts:217/228 and leasing-tools.test.ts:8832 (which adds a no-leak assertion on the other org's name).:581 cannot see the four proposed-id readers. It selects call sites with the regex isInScope\(\s*(wo|conv|prospect)\.propertyId\s*,\s*ctx\.scope\) — a guard handed its own scope, which structurally cannot find a reader that gates on an input id rather than a row-derived one. executed npx vitest run src/app/api/work-orders/__tests__/cross-org-derived-work-order-and-chat-ids.test.ts → 28 passed / 28, rc 0. It is green while those four are outside its census. Note what that does and does not show: the guard's coverage is narrower than its subject. It is not evidence that the four are unsafe — §4C's table is the evidence on that, and it says three of them are bounded by the checked id.vi.mock('@/lib/tools/handlers'), so no handler-side scope ever runs); the org axis on the four proposed-id readers; the appfolio_report passthrough; /api/admin/dev/tools/run refusing a cross-org accountId (route.ts:584-585). Each of those is a UNVERIFIED hypothesis until a forbidden-read control exists — this page does not upgrade any of them to a finding.p5-texting / p8-pickvendor-* for tool fences, p10-core-scope for the proof, p7-domain-override for the operator route.services/renewal-worker/index.ts:239, 18 queue registrations at :193) · 40 Vercel crons (38 /api/cron/* + 2 /api/admin/*) · 28 scheduled CI workflows · the AppFolio sync lambda with 10 EventBridge rules whose payload is {"jobs": [...]} and carries no organizationId (ensure-schedules.sh:201, :405).git grep -l "domain/scope" -- 'src/app/api/cron/**' → 0, and in the same call requireCronOrAdminAuth → 38. Their only gate is a bearer-secret comparison (platform/security/admin-auth.ts:64-67) that carries no scope payload at all.concession-watcher/handler.ts:95, metric-snapshot/handler.ts:502, metrics-alerts-checker/handler.ts:122, outlook-subscription-renewer/handler.ts:99, company-mailbox-poller/handler.ts:83, and appfolio-sync on its scheduled path (candidate set handler.ts:2034, fan-out :2045). One job enumerates orgs explicitly and unconditionally: domain/leasing/org-listings-sync.ts:678 getOrganizations(). Four scheduled CI workflows write production with --apply.operationScope, authorizedOperationScope, pinnedContext, systemActor, serviceAccount and backgroundContext all return 0 repo-wide. Those are names this page proposed, not names the codebase chose, and their absence is not evidence that no mechanism exists — the durable lane proves it, where the same responsibility is spelled SnapshotView and mintFromSnapshot. What the cron measurement does establish is narrower and is the claim that stands: no route-layer scope argument. Controls in the same table: orgId 2,637 and tenantId 3,707 — noting that in this codebase tenantId means the renter, not the tenancy key; TenantOccupancy (data/types.ts:17215) carries personId and organizationId as separate fields, and the multi-tenancy root is organizationId (data/interfaces/organization.ts:5-10).src/app/api/cron/conversation-capture-drift/__tests__/route.test.ts:33); 17 lambda test files import the real handler; agent-runtime has none.lambda/ and it is a count-reporting regression (onboarding-import-runner.test.ts:1615 asserting vendorEngagement === 3), not an isolation assertion. This is a statement about these four families: the durable-workflow family does have one (see E), and an earlier head of this page wrongly folded all five together.route.ts for org references. That establishes there is no route-layer scope argument — the contract-level question this row asks — and does not trace whether an individual downstream sweep re-derives a narrower set internally.src/lib/temporal/workflows/index.ts:18-53, one export per line, matching 36 definitions one-to-one. 42 activity modules on disk, 39 registered, across three registration sites (src/lib/temporal/workers/*, services/renewal-worker/index.ts:197,322, and an in-app eval worker at domain/evals/executor/run-session.ts:609,619). startChild and executeChild → 0 repo-wide: there are no child workflows.collections-chase-workflow.ts:262 calls pinCollectionsScopeActivity behind patched('collections_scope_pin_v1'), storing SnapshotView {organizationId, propertyId, mapVersion}; :320 calls checkpointCollectionsScopeActivity before each notice, which re-mints through the live mintFromSnapshot (collections-scope.ts:136) and returns ok, refused{reason, enforce} or unavailable. The design is documented at length in the activity's own header: a run that sees a map rebind finishes at the new version rather than crashing; a building that changed company yields the named scope_moved, never a 404. The gate state is the finding, not the absence: enforce is true only for scope_moved and only when rosterGateModeFor(propertyId) === 'refuse' (:174-175) — the same switch as requireInRoster, armed today on two bench buildings. Every other refusal, including property_unknown, is recorded and never enforced, deliberately, because the roster GSI it derives from "landed dark and has no recorded prod parity yet". Owning row: p9-workflow-pins.organizationId references across activity modules). That is a real mechanism, not an absence — what is unproven is whether every one of those re-derivations is correct and whether any of them would refuse a revoked grant. orgScope (268 hits) is HTTP request-time only and does not enter a workflow input; an absent organizationId in an initial input proves nothing about the run's scope.domain/voice/provider/contract.ts:246-249 AdapterMutationOptions.operationId, whose docblock states the intent exactly: "the SAME operation key the surrounding write already uses — not a second, adapter-private one." a zero on a coined name proves only that the name is unused effectId, durableEffect, externalEffect, OperationContext, pinnedOrgId and workflowScope all return 0 — but those are spellings this page proposed, not spellings the codebase chose, and the repo's actual vocabulary for the same responsibilities is SnapshotView, mintFromSnapshot, idempotencyKey (1,460), reservation (343) and outbox (275). Read those zeros as "no such identifier", never as "no such mechanism".temporal/renewal-receipts.ts:279 (attribute_not_exists(PK), keyed on (renewalId, activityName) — deliberately not run-scoped, after the 2026-06 incident where separate runs each minted a fresh key), leasing/outreach-send-claim.ts:111, dynamo/pending-notices.ts:305 and :372. The attempt-stable stamp is temporal/activity-stamp.ts:95, reading scheduledTimestampMs and explicitly not currentAttemptScheduledTimestampMs, which "would look correct, compile, and silently defeat the dedup on every retry".renewal-receipts.ts:246 states "AT-MOST-ONCE, not exactly-once… the claim is stranded and that stage's send is permanently suppressed for the cycle." (b) An effect placed whose provider webhook never fires is reconciled by readback: src/app/api/cron/voice-call-reconciler/route.ts, a one-minute cron, terminal write at :346-366.temporal/activities/promise.ts:615-624 ("the REAL fix is an at-most-once claim taken BEFORE the send… not done here… would re-enter the send branch and double-send"); turnover has workflow-level patched() + condition() only, no DB claim (turnover-workflow.ts:403); collections-chase.ts:1224 dispatches without at-most-once protection by design.src/__tests__/workflow-permutations drives the real workflow body but mocks the @temporalio/workflow sandbox wholesale (harness/workflow-mock.ts:29; the README states TestWorkflowEnvironment is deliberately not installed). Seven replay-corpus suites run the real SDK replayer against scrubbed production histories — the strongest real-code harness here. testing-harness/scenarios/run-all.ts drives a live Temporal server but has no npm script and pins @temporalio/*@^1.13.0 against the root's ^1.19.0: treat it as unexercised.workflow-permutations/tour/tour-scenarios.ts:110 reads like a negative control and is in fact a recorded known-bad baseline — harness/types.ts:101 confirms the guard test does not fail on it. The same shape appears in renewal/renewal-adapter.ts:8-16's arm gate.renewal-workflow-retry-letter-guard.test.ts:297-308 (a retry does not duplicate; release control at :364; cold-flag control at :408), renewal-send-guard.test.ts:63-75, renewal-receipts.test.ts:124-130 with an anti-lockout control at :153, renewal-sms-real-dispatch-suppression.test.ts:332-348 with a positive control at :351.src/__tests__/collections-scope-activity.test.ts drives the real pinCollectionsScopeActivity and checkpointCollectionsScopeActivity and the real mintFromSnapshot; only @/lib/data, the snapshot source, the roster-gate module and logging are stubbed. executed npx vitest run src/__tests__/collections-scope-activity.test.ts → rc 0, 10 passed (10). Its negative/positive pair is exactly the gate state: :81 "the building left the company → scope_moved, RECORDED (enforce: false) while the roster gate is off" and :89 "the same verdict is ENFORCED under the roster gate's refuse mode" (enforce: true), with :99 pinning that observe mode records and never enforces.p10-core-outcomes (ST-935) for the effect proof; the authorization absence is a routing question (§7).The row asks for this distinction explicitly, because ALIGN2 turns on it: reading the metadata is not permission to read the rows.
| Authorization metadata (may be read to establish the set) | Where |
|---|---|
| PersonRole rows — the grant | data/store.ts:6531 getPersonRoles, :6545 getPersonRolesAcrossOrgs; consumed by scope/grants.ts:123 |
The organizationId stamp on a Property row | used by org-scope.ts:226 isPropertyInOrgScope and by every tool that re-derives org from a record |
| The roster index | dynamo/property.ts:213 getPropertiesViaRoster via GSI_PROPORG |
| Org membership rows (written, not yet read) | data/dynamo/org-membership.ts |
The legacy stored list User.assignedPropertyIds | data/types.ts; read by auth/scope.ts:96 in 135 non-test files |
| Settings registry keys and their locks | portfolio/registry/keys.ts:67 (61 keys) |
Everything else — work orders, conversations, tenants, leases, prospects, tours, renewals, turnovers, vendors, appliances, financial periods, balance history — is a business record, and ALIGN2's intersection must be computed before the query that returns it, not applied to its result.
| Control | Owning row | Existing test(s) it maps to | Verdict |
|---|---|---|---|
| ST-931 · contract inventory | p10-core-contract (implied only) | this page; scripts/check-raw-store-importers.ts executed as its one instrument | inventory, not a proof |
| ST-932 · scope | p10-core-scope | scope/__tests__/read-over-scope.test.ts · selection-never-wider-than-grants.test.ts (13 cases) · write-addressed-by-the-row.test.ts · vendors/__tests__/vendors-read-the-set.test.ts:240 · work-orders/__tests__/cross-org-derived-work-order-and-chat-ids.test.ts · src/__tests__/scope.test.ts | covered at unit level — what is missing is the per-route trace of what each caller passes |
| ST-933 · freshness / revocation | p10-core-freshness | The nearest existing control is the durable one: src/__tests__/collections-scope-activity.test.ts exercises a real re-resolve against a pinned mapVersion and the recorded-vs-enforced split (:72 map moved 3→4 → ok at the live 4; :81/:89 scope_moved recorded then enforced). On the request path: no test revokes a grant, acknowledges the write and repeats a protected read. Revocation is honoured by the r.active predicate (grants.ts:216); whether any test exercises that filter should be checked in grants-resolved-per-org.test.ts | partial — a version re-resolve exists in one workflow; no revocation race control anywhere |
| ST-934 · resources / capacity | p10-core-resources | src/__tests__/calendar-slot-reservation-wiring.drift.test.ts (wiring pin, not contention); scripts/report-stuck-slot-reservations.ts | partial — no contention test found |
| ST-935 · outcomes / retry | p10-core-outcomes | renewal-workflow-retry-letter-guard.test.ts:297 · renewal-send-guard.test.ts:63 · renewal-receipts.test.ts:124, :153 · renewal-sms-real-dispatch-suppression.test.ts:332, :351 · the 7 replay-corpus suites | strongest existing coverage |
| ST-936 · source identity / metrics | p10-core-data | lambda/appfolio-sync/__tests__/onboarding-import-runner.test.ts:1615 (a count regression, not an isolation control) | gap |
| ST-937 · Atlas & Settings surfaces | p10-core-surfaces | platform/auth/__tests__/is-active-platform-admin.test.ts · admin-emails-is-not-a-provisioner.drift.test.ts · domain/settings/settings-levels.ts:29 pins the four sections | partial — nothing tests the domain-suffix grant's effect |
| ST-938 · replay | p10-core-replay | scripts/portfolio-harness/ (28 registered cases) · 7 replay-corpus suites · evals/replay-harness/ (conversations, not Temporal) | harness exists |
| ST-939 | undefined on the board | — | ambiguity for the driver |
The row says to record the inventory "in this row's dropdown and existing harness registry". Two candidates exist and neither fits cleanly:
docs/OPERATING-CONSTITUTION.md:86 says "The Harness Atlas, the lane page, and this file are the registries." There are 28 references to the Harness Atlas and zero files; nine harness suites cite "Harness Atlas §3.1" for their fixture envelope, citing a document that is not in the tree. This is already recorded twice as unlocated in docs/planning/portfolio-architecture/points-B6.md:53 and :552.scripts/portfolio-harness/cases/index.ts:7 ALL_CASES, generated into fixtures/manifest.json (28 entries, derived by fixtures/manifest.ts:52 buildManifest() "so the manifest can never drift from what actually runs", content-locked by fixtures.lock.json, count-pinned by __tests__/manifest-count.drift.test.ts, CI lane .github/workflows/portfolio-harness.yml). Its ids are slugs such as isolation-fail-open-adopts-callers-company; grep -c "ST-" manifest.json → 1. Provenance is carried as provenance.classRef audit-row strings. If "register by ST id" is meant literally, this registry cannot hold it as-is.docs/planning/portfolio-architecture/how/notes/stress-catalog.md, 1,457 lines, 213 ids — is prose tables, self-described as proposed, and not machine-readable.None of these is a claim that something is broken in production. Each is a path that is unimplemented or unproven, stated as a gap rather than glossed.
| # | Gap | Evidence at the inspected sha | Owning row |
|---|---|---|---|
| G1 | Two authorized-set definitions live inside one request; the intersection is open-coded per route rather than behind the facade | scope/grants.ts:215 vs auth/scope.ts:96; intersectScopes at 45 call sites in 32 files; the divergence is documented in the code itself at dashboard/scope/route-scope.ts:19-27 — an impersonating platform admin is fleet-wide on the unproposed path and narrowed on the proposed one | p7-actor-view, ui-s-d |
| G2 | A second settings resolver with its own inheritance walk, three times as widely imported as the canonical one | platform/settings-resolver.ts:88, 29/80 importers, zero references to resolveEffective | p3-registry-drift / Phase 3 settings rows |
| G3 | Platform admin is still granted by email domain suffix; no enumerable three-identity roster exists | auth/helpers.ts:377; suffix rule spelled three ways; roster search returned 2 test comments | p7-domain-override |
| G4 | The cache key carries the effective scope but no authorization version; a tier demotion over an unchanged property set yields a byte-identical key | selection.ts:637 scopeKey; grantVersion/authVersion/membershipVersion all 0 against a control of 33 | p10-core-freshness, ui-s-a |
| G5 | Detail-page caches are keyed on entity id alone — no scope, no auth version | TenantDetailClient.tsx:213, PropertyDetailClient.tsx:397, ProspectDetailClient.tsx:1324, VendorDetailClient.tsx:85; VendorsClient.tsx:192 spells a key inline against selection.ts:645-650's own rule | ui-s-d |
| G6 | Both authorization legs are eventually consistent — one by omission, one by GSI construction | person-roles.ts:134-140 omits consistentRead; dynamo/property.ts:168/:213 are GSI queries; queryGSI has no consistency parameter | p10-core-freshness |
| G7 | Fail-open in a list loader: a null viewer receives every tenant in the deployment | domain/tenants/load-tenants-list.ts:36 — user ? scopeByProperty(tenants, getUserPropertyScope(user)) : tenants. auth/scope.ts:19-21 documents the opposite contract. Not reachable today: src/middleware.ts covers /api/* and /api/tenants is not in the public allowlist — so this is defence-in-depth, not a live unauthenticated read | ui-s-d or p1-roster-refuse — driver to route |
| G8 | UNVERIFIED appfolio_report passes a model-supplied property_id to the PMS with no PropFlow-side check and no filter on the rows returned. The test is the provider-returned property population against the caller's authorized operation scope — a per-org credential does not settle it, because a caller holding only property A in an org that also owns B still requires B to be unreachable. The other four proposed-id readers check against the authorized set and are bounded by the checked id (§4C); their residual is the missing org axis, also UNVERIFIED | dashboard/chat/tools.ts:1477 (unchecked); credential chosen by first-owner-to-resolve, with a fallback to the requesting user, at integrations/appfolio/resolve-client.ts:24-38; :1345, :1353, :1363, :1373 (checked, property axis only). Needs an A-authorized / B-forbidden control driving the real dispatch and asserting on the query AND the result before any of these is called a defect | p8-pickvendor-* / Phase 8 tool-fence rows |
| G8b | Ask Clara applies authorized scope only — no selected-scope leg on a viewer-initiated read | src/app/api/dashboard/chat/route.ts returns 0 for every selection spelling, against a control of three sibling dashboard routes that use readDashboardScope. Fail-safe in direction (selection cannot widen), but not ALIGN2's intersection. Not to be copied into families D or E, which use their own authorized operation scope | ui-s-d · p10-core-scope |
| G9 | The cross-org census guard's regex cannot see the four proposed-id readers — a coverage gap in the guard, not a verdict on the readers | cross-org-derived-work-order-and-chat-ids.test.ts:581; the suite is green at 28/28 while they sit outside its census | p10-core-scope (ST-932) |
| G10 | No route-layer scope argument on any scheduled HTTP job, and no scope helper imported by one. That is a statement about arguments and imports, not about downstream scoping, which is unproven in either direction | 38 cron routes with 0 domain/scope references against a control of 38 in the same command; six no-arg lambdas. Not claimed: that no mechanism exists — the durable lane shows the repo spells this SnapshotView/mintFromSnapshot. Before anyone prescribes an implementation, the actual connection/operation scope each job already runs under has to be traced — dispatch → activity → authoritative read/effect, per family. That is ST-932/ST-933 work, not this row's, and not a licence to add a scope argument to 38 routes | routing question — see below |
| G11 | No isolation control in the cron, lambda, Fargate or scheduled-CI families (the durable-workflow family does have one). Same distinction as G10: an absent control is not a demonstrated leak — it means nobody has driven an A-authorized / B-forbidden case through these paths | 274 cross-org test files, exactly one under lambda/ and it asserts a count | same routing question |
| G12 | The workflow scope pin exists in 1 of 36 and is dark: only scope_moved enforces, and only under the roster gate's refuse mode. No test revokes a grant and asserts the next effect is refused | collections-chase-workflow.ts:262/:320; collections-scope.ts:174-175; control executed at collections-scope-activity.test.ts:81/:89 → 10/10 | p9-workflow-pins (the pin itself); p1-roster-refuse (the gate arming); p10-core-freshness (the revocation proof) |
| G13 | The scoped repository has no runtime importer and the reads bypass the wall; two of the eight minters and the compliance narrowing have no caller | context/repository.ts has 0 runtime importers — its only two references are import type { ScopedRepository } at relationships/read-relationships.ts:65 and reports/push-report.ts:88 (control: context/wall → 10 files). queryByPK( outside helpers.ts itself: 212 occurrences across 84 non-test files, none through the wall. mintLifeSafety, mintReports and forCompliance → 0 non-test callers (control mintAdmin → 5). check-raw-store-importers.ts executed → 121 + 2 = 123 importers across two raw stores | p5-importers |
| G14 | Declared double-send risks already written into the code and not yet closed | temporal/activities/promise.ts:615-624; turnover-workflow.ts:403; collections-chase.ts:1224 | p10-core-outcomes plus the originating Phase 9 rows |
| G15 | The harness registry named as authoritative does not exist; the one that runs cannot hold an ST id | OPERATING-CONSTITUTION.md:86 vs 0 files; manifest.json grep -c "ST-" → 1 | p10-core-replay (ST-938) and the driver |
| G16 | Two docblocks assert a property the code no longer has | scope/__tests__/fleet-scope.test.ts:276 says authorizeAddressedWrite "HAS NO PRODUCTION CALLER TODAY" — src/app/api/vendors/route.ts:183 is one. The same claim sits in p7-actor-view's own ref #8337 | p7-actor-view |
npx tsx scripts/check-raw-store-importers.ts → rc 0. Output: ok: src/lib/data/dynamo/helpers — 121 pinned importer(s), 0 re-exporters, 421 candidates scanned, 17 primitives watched and ok: agents/clara/lib/data/dynamo/helpers — 2 pinned importer(s). That is the executed basis for "two raw stores, 123 importers" — the board's prose said 122 at an earlier sha.npx vitest run src/app/api/work-orders/__tests__/cross-org-derived-work-order-and-chat-ids.test.ts → rc 0, 28 passed (28). Run to demonstrate G9: it is green while four proposed-id readers sit outside its census regex.npx vitest run src/__tests__/collections-scope-activity.test.ts → rc 0, 10 passed (10). The executed basis for the durable-workflow scope machinery in §4E and G12 — including the recorded-vs-enforced pair at :81 and :89.These deserve more space than the searches, because in both cases the individual measurements were right and the sentence built on them was not.
collections-chase-workflow.ts:262/:320 pins and re-checks {organizationId, propertyId, mapVersion}, and collections-scope-activity.test.ts is a real control over it (10/10, executed). Three bad inferences produced that sentence, each of which this page now refuses on principle: (1) concluding a mechanism is absent from zero imports of domain/scope — a different lane may spell the same responsibility differently, and this one does; (2) treating zeros on identifiers this page invented (operationScope, pinnedContext, systemActor…) as evidence about the codebase rather than about my own vocabulary; (3) reading organizationId's absence from a workflow's initial input as the run being unscoped, when the scope is pinned by an activity on the first tick. The claims that survive are narrower and now sit in §4D and §4E: no route-layer scope argument on scheduled jobs, and a pin that exists in 1 of 36 and is dark except on two bench buildings.ctx.scope is the caller's authorized property set (chat/route.ts:874), not a tab selection, so checking a proposed id against it is exactly the propose-then-check ALIGN2 asks for. The bad inference was treating "the id came from the model" as the defect, when the questions are whether it is checked and what the read is partitioned by — and for getTenantBalanceHistory the partition key is the checked property (dynamo/property.ts:1602). What survives is smaller, concrete and labelled UNVERIFIED in §4C: one true passthrough (:1477), one derivation that disagrees with its own data layer (:1345), and a missing org axis on all four.--include=*.ts — a failed command, not a zero result. Both were caught and re-run quoted. This is why every zero on this page is paired with a present control.require*" is a ceiling: the grep matches prose comments as well as imports. A stricter import-only pass gives 210. Where the two disagree, the smaller number is the safer one.workflow-permutations/tour/tour-scenarios.ts:110 reads as a negative control and pins a known-bad baseline (harness/types.ts:101). renewal/renewal-adapter.ts:8-16 is the same shape.cross-org-derived-work-order-and-chat-ids.test.ts:581 is a census guard handed its own scope by regex, so its coverage is narrower than its subject and it cannot see the four proposed-id readers. That is a gap in the guard; it is not, on its own, evidence about those readers.mintCompliance has zero production callers". There is no mintCompliance. The caller census excluded the defining file from its own file list, so a symbol that does not exist printed the same "none" as a symbol nobody calls — and "unused" and "not defined" are different findings. Caught by grepping the definitions directly; the eight real minters are now enumerated in §1.p10-core-contract. The row's own text says so: "Do not claim the docs PR completes this task."git grep or a file read at one sha. Three commands were executed. Everything else is inspection, and is labelled as such.