0138 — Give the staff "All companies" conversation list its own recency index without test traffic
- Status: Accepted; amended 2026-09-29 (membership also excludes
Property.isTestbuildings — see Amendment) - Date: 2026-09-28
- Deciders: Gera (decision b90641980, answered 2026-09-29 in chat: "New index"); advisor receipt f57425475
- Related: ADR-0049 (the recency index this narrows for one reader), ADR-0136 §1a (database-bounded pages, staff All included),
src/lib/data/dynamo/conv-nontest-recency-gsi.ts(the contract),src/lib/domain/analytics/test-traffic.ts(the predicate)
Context
Platform staff with no company picked read /conversations as one stream: the recency index (GSI5, ADR-0049), which holds every conversation. The page asks for no test traffic, and the route drops test rows only after reading them (route.ts, the canonical isTestTrafficConversation filter). When bench and harness rows dominate the head of the index, the walk spends its five-read budget and returns a short page with a cursor. The table then fires a second request to fill it. The first page takes about 4.9 s against the 2.5 s goal (Phase 11 C2; lane CV audit).
Three options were weighed:
- Merge each real company's own list. Rejected: unassigned and unrouted conversations are in no company's list, so staff All would stop showing them. ADR-0049 keeps assigned and unassigned conversations alike in the global list.
- Keep one stream and accept about 5 s. Rejected: this misses C2 and ADR-0136 §1a.
- Add a new index without test traffic. Chosen.
Decision
Add conv-nontest-recency-index:
| HASH | GSI8PK = the constant CONV_RECENCY_NONTEST (one partition, for ADR-0049's reason) |
| RANGE | the existing GSI5SK. It is reused, not copied. |
| Projection | ALL, like GSI5 |
Membership. A conversation meta row carries GSI8PK exactly when both hold:
- it is in GSI5 (it has a recency sort key), and
isIntrinsicTestTrafficConversation(row)is false.
isIntrinsicTestTrafficConversation is the config-free part of the canonical predicate, and it lives in the same module. It covers:
- the row flags;
- the historical bench property ids;
- stress and test senders;
- harness id prefixes;
- Gauntlet rows.
The canonical predicate is now defined as that, OR the caller's Property.isTest set, OR the configured internal-tester senders. So there is still one predicate, and the index rule is literally part of it.
Invariant: the index is a superset of what the staff read shows. A row the write-time rule calls test is test for every reader. A row it calls real may still be dropped by the read's full predicate, which runs after the read, unchanged. The two configuration markers are left out of the write-time rule on purpose:
- A property's
isTestflag can be removed at go-live. - The tester list can change, or differ between the Lambda and Vercel environments.
Stamping either would leave real conversations missing from the index.
Unassigned conversations are members. Membership never consults the org, and it consults the building only for the historical bench ids.
Why reuse GSI5SK as the sort key:
- The two recency indexes cannot order a thread differently.
- The pointer bump (
bumpConversationRecency), which advancesGSI5SK, moves a member with no writer of its own. - The staff walk's resume logic carries over. That logic handles the few rows stored without milliseconds (7 of 5,922 on prod, 2026-09-25) by widening a resume to the cursor's whole second, which is why the key can stay the raw instant rather than the company list's normalised clock.
Writers:
- The full save (
_unsafe_saveConversation) decidesGSI8PKon the row it PUTs. Because it replaces the whole item, a non-member simply omits the key; that omission is the unstamp. - The merge archive strips every
GSI<n>PKby shape. - The writer census (
conversation-list-index-writers.drift.test.ts) pins the rule that everyGSI5PKwriter decidesGSI8PK. Its one exception is the bump, and the census checks that exception both ways.
Rollout follows the prospect-list precedent (b89979462, b90011572). Each step ends dark, and the read switches only on coverage:
- Writer stamping ships.
- The create/backfill/revert scripts ship.
- Stage rehearsal.
- The index is created on propflow-prod (Gera's go).
- Prod dry-run and a read-only coverage harness. The harness checks:
- G1: no non-test GSI5 row is missing
GSI8PK; - G2: no test row carries it;
- G3: the index's id set equals G1's non-test set;
- G4: the newest 500 rows match row for row under the full predicate;
- G5: the share still dropped at read.
- G1: no non-test GSI5 row is missing
- The prod backfill
--apply(Gera's go). - Only then does staff All with
includeTest=falseread the new index.includeTest=truestays on GSI5.
Consequences
- Staff All's first page can fill from the database in one request, when G5 is small. The read keeps its full filter, so nothing it shows changes.
- One more sparse single-partition index. Its storage is a subset of GSI5's rows, and it adds one attribute write on the full save.
- Residual: conversations at an
isTestproperty outside the historical bench ids remain in the index, and the read drops them. If G5 shows they keep page 1 short, the follow-up is to stamp fromProperty.isTesttoo, together with a property-save writer that re-stamps a property's conversations when its flag flips. That is a separate decision. - A row whose last full save had no recency instant enters GSI5 through the pointer bump and stays out of this index until its next full save or the backfill. The coverage harness (G1) counts such rows.
- A change to the intrinsic predicate (a new stress phone block, a new harness prefix) leaves already-stamped rows with the old answer until their next save. The backfill's repair mode re-evaluates them, and the coverage harness counts any disagreement.
Alternatives considered
- Re-key GSI5's test rows into a second partition. Rejected. It re-keys a live index that the
includeTestreader andgetConversationsMetaread, so it cannot ship dark. - A copied
GSI8SK. Rejected. It is a second sort key that can drift fromGSI5SK, and it needs its own bump writer. - Stamp from
Property.isTestnow. Rejected for this step. Un-flagging a property would strand its real conversations outside the index unless a property-save re-stamper exists. It is left to the G5 measurement.
Amendment 2026-09-29 — membership also excludes Property.isTest buildings
Decision: Option A of the lane-IX design note. The decider is Astra (receipt fb983eb85), and the coordinator verified its claims. This supersedes the Stamp from Property.isTest now rejection above and resolves the Residual under Consequences.
Why. Prod was measured read-only on 2026-09-29 under the original rule:
- The would-be index held 18,746 conversations.
- 17,551 of them sit at the 45
isTestbuildings (the largest building has 3,557; the median 232). - Only 1,195 are shown by staff All's full filter.
- Of the index's newest 500 rows, 476 would be dropped at read (G5). Page 1 could not fill from one read, which misses ADR-0136 §1a: page cost must scale with the rows shown, not the rows stored.
The alternatives, measured on the same data:
- Keeping the rule and looping over pages needs 6–26 reads for page 1.
- Merging the company list index reads every company partition in round one, which also misses §1a.
The rule, as amended: a row carries GSI8PK iff all three hold:
- it is in GSI5;
isIntrinsicTestTrafficConversationis false;- its building is not
Property.isTest.
Unassigned rows have no building, so they are members as before.
The write.
- The full save reads the building flag through
readBuildingIsTest(building-test-flag.ts). It is cached per process for 60 s, because the agent loop saves a thread 8+ times a session (#7743). - It fails toward membership. A missing building or a failed read counts as "not test": a test row in the index is dropped by the read, while a real row missing from it would be invisible.
- The membership function now takes the flag as a required argument.
- The writer census pins that every
GSI8PKwriter reads it.
The invariant still holds: the index is a superset of what the staff read shows. The read's full predicate uses the same Property.isTest set.
Flips. When a building's flag flips, its conversations keep the old answer until they are re-stamped. The flip re-stamper is a separate part: a durable workflow triggered from the one isTest writer, which re-stamps the building after the cache TTL and reports done only when coverage is clean for that building. Until it ships, a flip is corrected by the backfill's repair mode.
Rollout numbers (prod, 2026-09-29):
- The backfill stamps about 1,195 rows and unstamps whatever the live writer already stamped at test buildings (at most 77 on 2026-09-29). That is minutes, not the 43 minutes the old rule needed.
- This is the 20th of 20 GSIs on
propflow-prod. The applied Service Quotas valueL-F7858A77is 20 (default, raisable). Any further index on this table needs a quota increase first.