0126 — First-contact SMS disclosure is a dispatcher-level invariant

The content below has been re-derived through 2026-08-07 and cites evidence dated after the drafting date — the #5391 catalog correction (2026-08-05), the collections approval gate (ADR-0125, 2026-08-06), and the mint-table narrowing in part 3. The drafting date is kept because it is when the decision was proposed; the revision date is what the citations are current as of.

Context

When PropFlow texts a tenant for the first time, the message arrives from a number they have never seen, signed by a name they have never heard. Today the only lane that introduces itself is the mass-send blast, via CLARA_INTRO_TAGLINE in src/lib/domain/messaging/mass-comms/compose.ts:

This message was sent by your property team via Clara, the property assistant. You can reply to this number if you have questions.

Every other lane — the AppFolio work-order acknowledgement, renewal outreach, tour confirmations, turnover notices — sends its first-ever text to a person with no introduction at all.

Collections is the one near-exception, and it is instructive rather than pedantic. Its copy opens Hi, this is ${propertyName}. (src/lib/domain/leasing/collections/collections-chase-copy.ts:112), so the recipient learns who is speaking — just not that PropFlow or Clara is involved. That is the distinction this ADR draws throughout: the obligation is to disclose an identity the recipient can act on, not to name the vendor. It is why collections needs no second disclosure, and the section on lanes whose copy already identifies the sender works the case through.

The concrete case that prompted this. CAM-2057 (Camellia Unit 308, an "Electrical Outlet" work order a PM created by hand in AppFolio on 2026-08-04) fired notifyTenantOfPmsCreation, which texted the tenant:

Work order CAM-2057 created.
Issue: Electrical Outlet
Our handyman has taken this job.
I'll text you the details as soon as the handyman's timing is confirmed.

That tenant happened to have received tour SMS as a prospect back in May, so the message landed in a thread she recognised. A tenant who only ever existed as an AppFolio row would have received the identical text cold. The AppFolio-originated work-order lane is structurally a first-contact lane — the tenant never texted us, a PM typed a row into the PMS — so it is the lane most likely to introduce PropFlow to someone, and the one with no introduction.

Why this is not a per-sender fix. "Have we texted this person before" is a property of the recipient, not of the sender. Solving it inside notifyTenantOfPmsCreation fixes one lane and leaves the same hole in every other, and the next sender added inherits the hole by default. It also races: two lanes firing in the same minute would each believe they were first and introduce Clara twice.

The dispatcher already carries two invariants of exactly this shape — ADR-0089's language totality and ADR-0119's conversation-record totality — both enforced by wrapping a file-private _unsafe_dispatch in a HOF. That is the seam this belongs on.

Decision

The first SMS PropFlow ever sends to a phone number must introduce PropFlow — and the lane composes that introduction into its own copy. The dispatcher owns the OBLIGATION and the LEDGER; it does not write the words.

The split, and it is a sharpening of the one ADR-0089 uses for language: the dispatcher owns the invariant — this number is introduced exactly once, unforgettably, race-free — and the lane owns the words it renders. A lane cannot decline to introduce itself; a lane that already introduces itself does not get introduced twice. That framing is what makes the exemption below a declaration at the call site rather than an allowlist to keep in sync.

The dispatcher never composes text. It selects a body and stamps a ledger; the words are always the lane's. Part 3 gives the reason, and Alternatives considered records the dispatcher-prepends-a-tagline design and why it lost.

Four parts:

1. The trigger is "no prior SMS in either direction on this E.164." Not "no prior contact" — a voice call does not tell someone who is texting them, so a person with three prior voice conversations and zero SMS does get the disclosure. But prior inbound SMS suppresses it, and the reason is purely a product one: someone who texted us first already has our number, already knows they can reply to it, and introducing ourselves to them is answering a question nobody asked. They initiated; we are the party being contacted.

That product reason is the whole reason. There is no mechanical one: under part 3 the dispatcher applies no prefix, so no lane has a record/carrier timeline that could mismatch.

⚠️ "On this E.164" is the product intent; whether the stored state can actually be keyed that way is OPEN — the ledger is org-scoped (part 2). If the resolution is per-(org, E.164), this sentence changes to match. The two cannot both stand, and Open questions is where that gets closed.

2. The state is a durable stamp on the phone IdentityClaim, not on the Person. smsDisclosureSentAt on the claim makes the steady-state check an O(1) read rather than a getConversationMetasByPhone scan on every send — for a number the ledger has a row for, whether that row records an introduction or a suppression. See What the ledger MEANS in part 4; that section, not this sentence, defines the field.

⚠️ The exception is mostly a FLOW rather than a stock — but this ADR cannot yet claim it is only that. The backfill stamps every number it can identify as having prior SMS in either direction, so the bulk of the permanently-cold stock goes away at cutover. Three residual terms remain, and only the first is small:

  1. the trickle that acquires prior inbound SMS after the backfill and is never introduced — a genuine flow;
  2. the no-claim-row cohort, which cannot be stamped at all (Open question #3);
  3. whatever the backfill mis-classifies — and this term is not bounded. Open question #8 says the available reader answers "was there a prior thread", not "a prior SMS", so a thread-level backfill would stamp voice-only numbers, whom part 1 says must be introduced. That suppresses them permanently, and it fails silently in the direction that produces no send.

So the perf question and the correctness question have the same dependency: until OQ#8 names a reader that sees per-message channel, the backfill's coverage is unknown in both directions. What the ledger MEANS sizes that set and routes it to PR 2 to measure; if that section and this sentence disagree, that section governs. The ledger's case does not rest on the perf claim either way — it is a correctness mechanism, and send-once is what it is for.

The claim, not the Person, because the trigger is per-E.164 and a Person carries N phone claims. A Person-scoped stamp set by texting phone A would suppress the disclosure on phone B, which has never been texted — the stamp and the cold-path scan would be measuring different things by construction. The claim is the E.164, so stamping it keeps both halves on one unit.

Send-once under concurrency — and how part 3 keeps it

The Context rejects the per-sender approach partly on concurrency: two lanes firing in the same minute would each believe they were first. This ADR must therefore not reintroduce that race by the back door, and a naive reading of part 3 does exactly that:

lane A: read ledger (cold) → compose WITH intro → dispatch → stamp ✓
lane B:        read ledger (cold) → compose WITH intro → dispatch → stamp ✗ (lost)

B loses the conditional write correctly, but B's body was composed with an introduction two steps earlier and is already at the carrier. The window is no longer microseconds inside one function — it spans a ledger read, a composition, and a network send in the caller. It also relocates the ledger read out of the dispatcher, so "the dispatcher owns the ledger" would stop being true of the read half.

The resolution: the lane supplies BOTH renderings; the dispatcher still selects. The mint carries the first-contact body as a lazy thunk, invoked only on a cold ledger:

introducedInBody(() => firstContactBody, reason)   // evaluated only if cold

Read → select body → send → stamp stay inside one wrapper behind one conditional write, exactly as before. The lane authors the words (owner direction), the dispatcher owns both halves of the ledger (the invariant), and the atomicity the Context demands is unchanged. The cost is that a first-contact-eligible lane writes two renderings of its own message; that is the price of composing words the dispatcher cannot.

⚠️ This shape is designed here and has never been exercised in code. It is recorded as the ADR's proposal, not as a proven mechanism — see Open questions below, which PR 2 must close before it builds against this.

The cold path, and the instrument it depends on

The cold path (stamp absent) consults conversation history once, so the existing population — every tenant PropFlow has already texted — is not re-introduced. A missing stamp is not evidence of a missing introduction, and treating it as such would blast a disclosure to the whole book on rollout.

Ambiguity resolves to SUPPRESS. Where the history read cannot answer confidently, treat the number as already-introduced. The two failures are not symmetric: blasting the book is unrecoverable, one tenant missing an introduction is not.

⚠️ getConversationMetasByPhone returns messages: [] by construction, so it cannot answer "was there a prior SMS" — only "was there a prior thread." Part 1's trigger is channel-specific, so the implementing PR must name the reader that can actually see per-message channel, or narrow the trigger to what the meta row can answer. This is the instrument the backfill below would be verified with, so getting it wrong makes both the trigger and its verification wrong in the same direction.

The ledger is ORG-SCOPED, and the dispatcher's org is optional

claimPK(organizationId, type, normalizedValue) (dynamo/persons.ts): the claim partition is per-org, and findPersonByClaim requires organizationId as a hard argument. DispatchOptions has no top-level organizationId or propertyId, but it is not org-blind either — DispatchOptions.touch?: OutboundTouchContext (dispatcher.ts:147) carries optional organizationId and propertyId (compliance/touch-budget/send-context.ts). So the accurate statement is that the dispatcher's org is optional and lane-supplied, not absent.

That optionality is the problem: a stamp keyed on an org the dispatcher may or may not have been given is keyed inconsistently. This is tracked in Open questions below rather than settled here.

3. The introduction is BLENDED INTO the message, never bolted onto it. The dispatcher does not compose it.

Stated as a prohibition, because the tempting design is the indefensible one: have the dispatcher PREPEND a fixed CLARA_INTRO_TAGLINE to whatever the lane wrote. Run that against a real worst case:

"This message was sent by your property team via Clara, the property assistant. You can reply to this number if you have questions. Work order CAM-2061 created. Issue: Gas smell reported."

A canned identity paragraph in front of an emergency is worse than no introduction. The right message is one sentence that does both jobs at once — "Hi, it's Clara with your property team. I've got your report of a gas smell and I'm treating it as urgent —" — and no fixed string can produce that, because producing it requires knowing what the message is about and how urgent it is. That knowledge lives in the composing layer, so the composition does too.

So the split is sharper than "dispatcher owns the invariant, lane owns the surface." The dispatcher owns the obligation and the ledgerhas this number been introduced, and did this send introduce it — as a durable, race-free, send-once fact. The composing layer owns the words.

How the obligation stays enforced once the dispatcher stops writing the text. A signal a lane may ignore is not an invariant; every other lane ignoring it is the status quo this ADR exists to end. So it takes the same shape as the two invariants already on this seam: a required, brand-sealed field on DispatchOptions, mintable only by declaring how first contact was handled.

Mint Means A claim about
introducedInBody(…) this lane authored a first-contact rendering of its own copy; the dispatcher picks it only on a cold ledger the copy
neverFirstContact(…) this LANE structurally cannot be first contact, because it only ever replies into an inbound SMS thread the lane
cannedIntro(…) a templated lane that genuinely cannot blend, opting into the fixed tagline explicitly. Sugar over introducedInBody in shape, own BRAND preserved — see below; it still yields two renderings the copy

⚠️ The signatures are written (…) on purpose: two of the three are NOT settled, and PR 2 owns them. (neverFirstContact's (reason) shape IS settled — it takes no body and no version.) Two decisions below change the other two — cannedIntro's shape (it must yield two renderings, not a bare reason) and where the copy VERSION comes from (a required parameter on introducedInBody, supplied by the shared helper for cannedIntro, absent on neverFirstContact). Anywhere else in this document, in ADR-0127, or in the plan that shows a concrete two-argument call is illustrating the SHAPE of a mint, not specifying its arity — those examples predate both decisions and are not the contract. This table is what PR 2 copies, so it names no arguments at all rather than naming a set that is known to be incomplete.

Every mint declares a property of something the CALLER OWNS — the copy it wrote, or the premise of its own lane. None declares a property of the RECIPIENT. That line is the whole enforcement story, and it is a hard boundary on what may be added to this table.

No mint may assert that a number has been texted before. That is a fact about the recipient, and part 2 makes the ledger its sole authority. A caller could only know it by duplicating the dispatcher's claim read and cold-path scan at every call site, racily — and a wrong assertion is silent: no error, no type error, no failing test, and the recipient simply never gets introduced. It is the failure the Alternatives section rejects ("a flag a sender can forget is a hole with extra steps"), wearing a brand: a lane that finds first-contact copy inconvenient reaches for it and the invariant evaporates with the compiler satisfied.

neverFirstContact(reason) is the closest legal thing, and the difference is load-bearing: it asserts the lane's premise, is checkable at the call site, is greppable, is pinned by the drift test, and becomes false only if the lane's premise changes — a code change someone makes deliberately, not a per-recipient guess.

⚠️ The premise must ENTAIL part 1's trigger, not merely correlate with it — and there is exactly ONE premise that does. "Replies into an inbound SMS thread" qualifies because an inbound SMS is part 1's suppression: if the lane's premise holds, the trigger is necessarily already suppressed, so the mint cannot be wrong while the premise is true.

Premises about what the RECIPIENT DID do not qualify, and "confirms an action the recipient just took" is the trap — it reads like a lane property and is one, but an action is not a text. A prospect who books a tour on a web form has taken an action, has a lane whose premise is permanently true, and has never received an SMS from PropFlow; tour confirmations are in this ADR's own un-introduced list in the Context. The reason string would be honest, the drift test asserts the reason, and the lane would still ship un-introduced — which is harder to catch than the recipient-assertion mint this table refuses, because nothing about it looks wrong.

So the rule, stated as a constraint on the mint rather than as a list: a neverFirstContact reason is legal only if it names a prior inbound SMS on this number as the thing that makes it true. Anything else — an action, a form submission, an email, a voice call, a PMS row — is introducedInBody or cannedIntro. The drift test asserts the reason string, so this is the property it is asserting for.

A bare object literal is a compile error, and an omission at an untyped call path throws before the send — mirroring requireRecord. The result: a sender cannot reach the carrier without having reckoned with first contact, and cannedIntro is a declared, greppable choice rather than a silent default.

Who applies cannedIntro's tagline — the lane, via a shared helper. Not the dispatcher. If the dispatcher applied it, "the dispatcher does not compose" would be false for that branch. A shared helper keeps composition on one side of the seam for every mint, and keeps the dispatcher's job exactly "select and stamp."

What the lane may NOT do with a ledger read — and what it may

Stated precisely, because the blanket form of the prohibition above is wrong and would forbid a gate ADR-0127 depends on. The rule is not "a lane may never read the ledger." It is:

A lane's ledger read may never reach its COMPOSITION or its MINT.

Both of this ADR's stated harms are harms of composition, not of reading:

A read that does neither is legal, and ADR-0127's message 2 is the worked example: it reads the ledger to decide whether to send at all, strictly after message 1's stamp, and still mints introducedInBody honestly — so the dispatcher remains the authority on introduction and the lane's read only ever suppresses an optional message. A send/no-send gate is not a composition decision, and it cannot race the thunk because it does not choose a body.

cannedIntro is SUGAR OVER introducedInBody — it must still yield two renderings

Stated explicitly because the obvious signature does not work, and the enumeration above would otherwise hand PR 2 a mint the dispatcher cannot use. introducedInBody carries a thunk and neverFirstContact is never cold, but a cannedIntro(reason) that carries only a reason gives the dispatcher nothing to select between — and a cannedIntro lane can be cold. Both ways out of that are closed by this ADR:

So cannedIntro is a constructor that produces the same PAIR introducedInBody does — while returning its own distinct brand. It takes the lane's default body and returns two renderings, the first-contact one being CLARA_INTRO_TAGLINE + that body, composed lane-side by the shared helper. Same shape to the dispatcher, different type to the compiler (see the brand note below — that difference is load-bearing, not cosmetic). The lane writes no second rendering — which is the ergonomic point of the mint — but the dispatcher still receives two and still selects on the ledger, so every claim elsewhere in this ADR that an eligible lane hands over two renderings stays true of both mints that can carry one. (neverFirstContact hands over one body and is not first-contact-eligible by construction, so the claim was never about it.)

⚠️ This changes the mint's SIGNATURE from the table's (reason) shape, and the drift test pins signatures. PR 2 owns the exact form (cannedIntro(() => body, reason) is the natural one). Recorded here rather than left implicit because a mint that cannot be selected between is not a smaller version of the design — it is a hole in it.

"Sugar" is about AUTHORING, not about identity — the returned value stays DISTINGUISHABLE. cannedIntro spares the lane from writing a second rendering; it does not collapse into introducedInBody at the type level. The value it returns carries its own brand, so cannedIntro-minted and hand-authored first contact remain tellable apart by the compiler, by a grep, and by the drift test.

That distinction is load-bearing rather than fussy: Open question #5's fails-closed candidate is a type from which cannedIntro is not constructible, and a constructor returning the same branded value as introducedInBody would make that type inexpressible — quietly deleting the better of #5's two answers before PR 2 gets to choose, and leaving only the pinned list that fails open on the gas-smell case. Sugar that erases the distinction is not sugar; it is a merge.

cannedIntro is FORBIDDEN on any lane that can carry priority: 'emergency' or 'high'. Without this the ADR asserts both halves of a contradiction: part 3 rejects the prepend design specifically on the canned-paragraph-in-front-of-a- gas-smell case, and then mints a primitive that reproduces it. And the lane that carries the gas-smell case — notifyTenantOfPmsCreation, templated via formatTenantConfirmationSms — is exactly the lane most likely to reach for cannedIntro, because writing a blended rendering is real work. Nothing in the mint table or the drift test would stop it; the compiler would be satisfied and the first emergency work order to a never-texted tenant would send the message this ADR uses as its argument that the design must not exist.

An emergency-capable lane must mint introducedInBody (blending the urgency, as part 3's worked sentence shows) or neverFirstContact.

⚠️ How that is ENFORCED is genuinely unsettled, and this ADR does not get to call it "checkable" without saying by what. For notifyTenantOfPmsCreation the answer is easy — priority: WorkOrderPriority is required (src/lib/data/types.ts:3036) and formatTenantConfirmationSms receives the whole work order, so reading the signature settles it. That does not generalise: "can this lane ever carry an emergency" is a call-graph question, and no grep answers it. The two candidate mechanisms are not equivalent:

PR 2 owns this choice and it is listed in Open questions below. Until it is made, the prohibition is a stated rule with no mechanism behind it — which is worth being explicit about, because a reader who assumes the drift test already covers it will not build the thing that makes it true.

Two messages are allowed, and for the acknowledgement path they are preferred — see ADR-0127, which owns that decision. Touch cost is the wrong axis to decide it on: the acknowledgement is time-critical and must-send, while an insight is optional and best-effort, and fusing them lets a slow or failed insight delay a fact the tenant is waiting for.

4. The stamp is never written when the message provably did not reach the carrier — and IS written whenever it may have. A send skipped by consent revocation, a policy gate, or ADR-0092 suppression must not burn the one-time disclosure, or the tenant's real first text arrives with no introduction and the stamp says they got one.

⚠️ This is deliberately NOT ADR-0119's rule, though it looks like it. ADR-0119 writes the record only on a delivered result; this ledger also writes on an outcome that is unknown at the carrier, because the two invariants want opposite tie-breaks. The subsection below derives that, and it governs — if this numbered decision and that subsection ever disagree, the subsection is the decision.

The converse — introduced-but-unstamped — and why this ledger breaks the tie the OPPOSITE way to ADR-0119

The paragraph above guards one direction only: the stamp firing when no introduction went out. The other direction is not symmetric with it, and this ADR must not inherit ADR-0119's tie-break without re-deriving it.

The dispatcher's own docblock names the path: "A dispatch THROW propagates and applies nothing: the outcome at the carrier is unknown" (agents/clara/lib/messaging/dispatcher.ts:249-250). An introduction can therefore reach the carrier while the ledger stays cold — and the next first-contact-eligible send to that number introduces PropFlow to the same human a second time. That is the same double-introduction the Context rejects per-sender fixes over, arriving through a different door.

The two invariants on this seam want opposite tie-breaks:

erring toward NOT WRITTEN costs erring toward WRITTEN costs
ADR-0119 conversation record a real text missing from the timeline a phantom row asserting a text nobody received
this disclosure ledger a second introduction texted to a real person one tenant never introduced

ADR-0119 breaks toward not written because a phantom row is a lie about history. This ledger breaks the other way: when a message MAY ALREADY BE AT THE CARRIER, STAMP — the boundary is derived immediately below, and it is the carrier, not the exception. It is the same asymmetry part 2's cold path already settles — blasting the book is unrecoverable, one tenant missing an introduction is not — and "unknown" is precisely the ambiguity that section resolves to SUPPRESS. Suppression and stamping are the same choice viewed from the two ends of the ledger, so making them agree is what keeps the rule single.

The dividing line is the CARRIER, not the exception. "Unknown" earns a stamp only where the reason it is unknown is that a message may already be in flight — which is what the docblock actually says: "the outcome at the carrier is unknown." A throw that happens before the carrier call is not ambiguous at all: nothing was sent, and stamping it silently spends the one-time disclosure on a message that never existed.

That distinction is not hypothetical, and part 3 creates an instance of it. The lazy thunk is invoked by the dispatcher on the cold path before the send, so a lane whose first-contact rendering throws produces an exception where no body was ever composed. Stamping there does its damage to a different message: the lane's next send takes the warm branch and ships un-introduced. The same holds for a failed ledger read, a policy-layer bug, or a malformed recipient. It would also sit badly against the paragraph above — a policy layer that throws rather than returning skipped is the same event taking a different exit, and must not get the opposite treatment.

The table is a SECOND gate, not the only one — the first is "did this send introduce anyone"

Precondition, evaluated before any of the four cases below: the dispatcher SELECTED the first-contact rendering. The table decides whether a send that carried an introduction stamps; it does not decide whether a send introduces. Getting this order wrong writes a stamp for a message that introduced nobody.

Concretely, and this is not hypothetical — it is what the mint table already implies:

What the ledger MEANS: "do not introduce this number" — not "this number was introduced"

This is the sentence the rest of the design hangs on, and getting it backwards is what makes the paragraphs above read as a set of exceptions rather than one rule.

The backfill forces the question. Consequences requires stamping every person with prior SMS before first-contact composition ships — people the rollout exists precisely not to introduce. Read as an event log, every one of those rows is a lie: written by a script rather than the dispatcher, naming a copy revision that does not exist, and asserting an introduction that never happened. On day one most rows in the ledger would be of that kind, so "a present stamp means was introduced" would be false for the majority of the table by design.

Read as a suppression ledger, all of it composes:

⚠️ smsDisclosureSentAt is therefore a poor name, and every use of it in these three documents is PROVISIONAL. It reads as an event timestamp for a field whose meaning is do not introduce this number. PR 2 picks the real name and this ADR does not pretend to have picked it — wherever the string appears below, in Entity classification, in the index, or in the plan's migration step, read it as "the field defined here", not as a name to type.

And the SEQUENCING is not free, because this is an attribute on a live IdentityClaim. The rename must land before the backfill, not after: the backfill writes a row for every number with prior SMS, and renaming afterward means rewriting every row it just wrote — on the one table this design has no unwind for. Ordering, therefore: rename → backfill → verify → first-contact composition ships. Deciding to keep the name is also fine; deciding by not deciding, and discovering the ordering after the backfill has run, is not.

And the backfill's target set follows from part 1, not from convenience. Part 1's trigger is no prior SMS in either direction, so the backfill stamps every number with prior SMS either way — not only prior outbound, which is the narrower set the wording invites. That is one conjunct, and it is the difference between a suppression ledger that matches the trigger and one that leaves the inbound-only cohort permanently cold.

⚠️ Which is what happens to the residual cost noted above. With the wider backfill, the permanently-cold set is not "numbers with prior inbound SMS" — it is numbers that acquire prior inbound SMS after the backfill runs and are never introduced, which is a flow rather than a stock. This ADR does not guess its size — the same refusal it applies to the editorial cost, and here a guess that the number is large would make a schema change look necessary. If PR 2 wants it, the cohort is prior inbound and no prior outbound and no stamp, and it is one extra classification on a scan the pre-flight already runs — not the number the pre-flight reports today, which is the would-fire cohort. Whoever adds that classification owns the answer; without it the question stays open rather than being answered by the wrong count.

And the remedy is no longer a schema change. Under the reading this section decides, the stamp is the negative marker: a cold path that computes a suppression can record it using the same two fields the backfill writes — smsDisclosureSentAt set, version absent — so no new field is needed. What it costs instead is a write on the send path and an org to key it by (Open question #2), which are trade-offs rather than a migration. That is the one "whatever established it" the general rule below does not yet follow through on: two things establish a suppression — the backfill and the runtime cold path — and only the first currently writes. PR 2 decides whether the second does; deferring it is legitimate, silently assuming a migration is not.

So, given that a first-contact rendering was selected, the four cases:

outcome stamp? why
skipped (consent, policy gate, ADR-0092) no the send provably did not happen
delivered yes it happened
throw BEFORE the carrier call (thunk raised, ledger read failed, malformed recipient, policy bug) no nothing left the process; identical in substance to skipped
throw AT OR AFTER the carrier call (timeout, transport error, ambiguous provider response) yes a message may be in flight, and a second introduction to a real person is the unrecoverable side

PR 2 must therefore be able to tell those two throws apart — the stamp cannot be implemented as a bare catch. If the dispatcher cannot distinguish them at the point the stamp is written, that is a finding to raise before building, not a detail to approximate: collapsing them in either direction re-creates one of the two failures this table exists to separate.

⚠️ recordOnlyIf narrowing must NOT reach the stamp. The delivered predicate is !result.skipped && (options.recordOnlyIf?.(result) ?? true) (agents/clara/lib/messaging/dispatcher.ts:260-261; the option itself is declared at :102), so a lane can narrow what counts as delivered for the ADR-0119 record.

recordOnlyIf has NO production callers today, and that is the stronger reason to write this down rather than a reason to skip it. Its docblock (:85-101) records that removing the Telegram funnel made the lone caller's recordOnlyIf: (r) => r.provider === 'twilio' in collections-chase.ts a tautology, so it was deleted with the funnel; the only remaining references are in outbound-record-dispatch-entrypoints.test.ts, which pins the AND-ing contract. The option was kept deliberately"the ESCAPE HATCH is the point, not the one caller." A hazard with no current instance and a standing invitation to add one is exactly the kind that gets wired in later by someone who has not read this ADR.

So, the prohibition. Threading that predicate into the disclosure stamp would import ADR-0119's tie-break into an invariant that has just chosen the opposite one, and it would do so invisibly — the lane that narrowed its record would silently make itself eligible to introduce again. The stamp reads the raw dispatch outcome. PR 2 pins this with a test rather than a note, because it is a one-line refactor away from being wrong forever.

There is no wrapper-ordering constraint, and no content-rewrite primitive

Because the dispatcher never rewrites a body, the record and the carrier cannot disagree: the lane composes one body, the record is minted from that body, and the carrier receives that body. withPrefixedContent — a content-rewrite primitive — is explicitly out of scope. Do not build one. A rewrite at dispatch is what would force a wrapper-ordering rule, and there is no rewrite.

This is also why mode: 'none' needs no special case. A lane whose conversation row is written upstream (noConversationRecord) would have been unable to receive a prefix applied at dispatch; with no prefix applied anywhere, whatever body it composed is the body that was recorded and sent.

What the implementing PR still owns:

A lane whose copy already identifies the sender — and there is NO opt-out list

There is exactly ONE mechanism, and it is the mint table. No OutboundMessageKind-keyed exemption list exists, and PR 2 must not build one.

The tempting design carries both: the mint table and a kind-keyed opt-out for lanes that already identify themselves. They answer the same question, so one of them is redundant — and it is the list, for two reasons.

The list cannot hold the property claimed for it. DispatchOptions.kind is kind?: OutboundMessageKindoptional (agents/clara/lib/messaging/dispatcher.ts:153). A list keyed on it is not "greppable and pinned, not a boolean a sender can pass"; omit kind and the exemption silently evaporates. That direction fails safe — an omission means the lane is not exempt, so it gets introduced — so it was never a send hazard. It simply is not the mechanism it was described as being, and making it one means making kind required across all 38 dispatch( call sites, which is a wider change than this ADR scopes and buys nothing the mint does not already give.

The mint table already answers it, with nothing to keep in sync. firstContact is required and brand-sealed, so a lane whose copy already identifies the sender must still mint something. Of the three, only introducedInBody(() => body) fits — and its first-contact rendering is identical to its default, because the identifying sentence is already the opening line.

Collections is the worked example. It opens Hi, this is ${propertyName}. (src/lib/domain/leasing/collections/collections-chase-copy.ts:112, Spanish twin Hola…, le escribimos de ${propertyName}. at :99), so it mints introducedInBody(() => body, 'copy opens with the property name') where the thunk returns the same body the default branch returns. The declaration is at the call site, greppable, and pinned by the drift test on its reason — and if the copy later drops its self-identification, the fix is to change the thunk, in the same file as the copy, rather than to remember a list in another file.

The test is IDENTITY, not opt-out. A lane qualifies for an identical-rendering introducedInBody only when its copy tells the recipient who is speaking. A STOP line is not that: Reply STOP to opt out. says how to make the messages stop, not who is sending them. Had collections carried only the STOP line, its two renderings would have to differ like any other lane's — a message about money owed, from a number the recipient does not recognise, is this ADR's opening scenario with the worst possible payload.

Entity classification

No new entities. TWO fields are added to the existing phone IdentityClaim. Both are written by the dispatcher on a send; the first is ALSO written by the backfill — see What the ledger MEANS in part 4, which defines what they mean.

Field Meaning Nullability
smsDisclosureSentAt do not introduce this number — set either because we introduced it, or because the backfill established it needs no introduction. Not "this number has been introduced": that reading is false for the majority of rows by design. nullable, and the NULL is the signal: STAMP-absent means "may be first contact" — never row-absent (see the ⚠️ below)
the copy VERSION which revision of which introduction this number received nullable, and the null is meaningful. Absent ⇔ no introduction was sent (every backfilled row). The unwind query selects on version present, which is what stops it sweeping the pre-existing book.

⚠️ The unit is the STAMP, never the ROW — and getting this backwards suppresses the entire never-texted book, silently. IdentityClaim (src/lib/data/types.ts:9982-10006) is minted whenever a Person acquires a phone, for identity reasons that have nothing to do with disclosure. So on day one every claim in the book already exists with neither field set. Reading row-presence as do not introduce would therefore suppress everyone, forever — with no send, no error, and no failing test, and the pre-flight would report 0 on a book of 4,000 instead of the 4,000 the rollout section is watching for.

Part 2 already states the correct predicate — "the cold path (stamp absent)" — and row-absence is a different, already-named cohort: the recipient with no claim row at all, Open question #3. The two must not be conflated: stamp-absent means ask the cold path; row-absent means there is nothing to stamp yet.

⚠️ The version is NOT "not optional". The rollout section calls it the only unwind this design has, and that is true of the field's presence in the schema, not of its value on every row. It must exist and it must be written whenever an introduction is; it is null on every suppression. A migration that ships smsDisclosureSentAt alone ships without the unwind; a migration that makes the version NOT NULL cannot represent a suppression at all.

The write rule is part 4's SELECTION precondition followed by its four-case table — not "on a delivered send." That phrasing is ADR-0119's and this ledger deliberately breaks the tie the other way: a throw at or after the carrier call also stamps, because a message may already be in flight. Stated here because this is the section an implementer reads for the schema delta, and a summary that quietly reverts the decision is how the decision gets lost.

Where the VERSION comes from — the MINT carries it

The dispatcher writes the version but cannot know it: part 3 hands it an opaque thunk and a free-text reason, and none of the three mint signatures carries a version. Left unstated, PR 2 ships smsDisclosureSentAt alone — a migration this table calls short — and nobody notices until a bad introduction needs re-firing at exactly the v1 cohort, which is the moment the unwind was for. This gap is a residual of the design reversal: under the rejected prepend design there was exactly ONE introduction, so the version was trivially dispatcher-known.

The version rides the MINT — but it does NOT ride all three the same way, because the three mints do not own the same amount of what they ship.

Mint Where its version comes from Why
introducedInBody the lane, as a required parameter it authored the whole first-contact rendering, so it is the only thing that knows the revision. This is the rule the mint table already states — a mint declares a property of something the CALLER OWNS.
cannedIntro the shared composition helper, not the call site its first-contact rendering is CLARA_INTRO_TAGLINE + the lane's body, and that tagline is one constant with N consumers. A per-call-site literal would be stale the moment the constant is edited — N independently-maintained declarations of one string's revision. The helper that owns the concatenation is the only place the version stays true, and it is the same helper the ADR already puts on the lane side of the seam.
neverFirstContact none — it takes no version it authors no first-contact rendering, so no send through it can introduce anyone and none ever stamps. A required version here would ask a lane to name the revision of copy that does not exist.

And the cannedIntro row is not a detail — it is the unwind's own headline case. The scenario this field exists for is "a bad introduction needs re-firing at exactly the v1 cohort", and for cannedIntro lanes the bad introduction is the shared tagline. Sourcing that version per-call-site would make the one cohort most likely to need re-firing the one whose version is least trustworthy.

Two alternatives for the version's form, both rejected:

⚠️ This changes two of the three mint signatures and what the drift test pins — the same class of change as cannedIntro's shape, and PR 2 owns the exact form of both. Recorded here because a required field with no source is not a smaller version of the design; it is a field that will not exist.

Consequences

Easier. No lane can silently skip first contact any more: the mint is required, so a sender that has not reckoned with it does not compile. The obligation is inherited even though the words are not — which is the half that actually failed before, since the words were never the problem and the forgetting was.

Harder — and this is the real cost of the decision. Composing the introduction is now the sender's job, at every first-contact-eligible lane: one first-contact rendering per lane to author, review, and keep language-correct, and changing how PropFlow introduces itself becomes a survey of those senders rather than a one-line constant edit. A first-contact-eligible lane must also author two renderings of its message (see part 2's lazy thunk), which is more code and more copy per lane.

How many lanes that is has not been derived, and the implementing PR must derive it. What is measured on origin/main is the outer bound: 38 non-test dispatch( call sites across 24 files (git grep -n "await dispatch(" -- src agents lambda, excluding __tests__ / *.test.* / evals). The first-contact-eligible subset is strictly smaller — several of those lanes are vendor- or technician-facing (tech-sms-dispatch.ts, handle-vendor-completion.ts) and several reply into an inbound thread and so mint neverFirstContact. PR 2 enumerates the subset explicitly and that enumeration is what the drift test pins; no number is asserted here, because sizing the editorial cost off a guess is how the cost gets under-planned.

⚠️ The likeliest way this decision fails is therefore editorial, not technical — exactly the risk ADR-0127 names about itself: the catalog stays small because writing copy is nobody's job. If the honest expectation is that most lanes will reach for cannedIntro rather than blend, say so now: that is a materially different rollout from one blended introduction per lane, and it should be predicted here rather than discovered lane by lane. The dispatcher also gains a pre-send concern and one claim read on the cold path.

Segment cost. CLARA_INTRO_TAGLINE is 130 characters (two concatenated sentences, mass-comms/compose.ts). A dispatcher-applied prepend would push essentially every first-contact SMS from one GSM-7 segment to two; under this decision it applies only to cannedIntro lanes, because a blended introduction is a rewritten sentence rather than an added paragraph and may cost nothing at all. And with ADR-0127 sending the ack and the insight as two messages, the two no longer compound inside one body. The segment question is now per-lane and mostly a matter of how tightly each lane writes its first-contact rendering — which is a copy review, not an architectural cost.

The rollout carries one genuinely dangerous failure mode: if the cold path is wrong or skipped, the entire existing tenant book receives an introduction to a service they have used for months. The backfill of smsDisclosureSentAt for every person with prior SMS in either direction — matching part 1's trigger, not the narrower outbound-only set — lands and is verified before first-contact composition is switched on — not in the same PR, and not after.

The rollout needs a settled field NAME, a resume marker, a version, and a dry-run count

The field name is the FIRST gate, and it is an ordering constraint rather than a naming preference. smsDisclosureSentAt is provisional (Open question #6); whichever way that resolves, it must resolve before the backfill runs, since renaming afterward rewrites every row the backfill just wrote on the one table this design has no unwind for. Keeping the name is a fine answer — arriving at the backfill without having answered is not.

"Backfill, verify, then enable" is necessary and not sufficient. Three gaps, all in the irreversibility bucket, and all cheap to close before PR 2:

A partial backfill must have a signature. An interrupted scan leaves an arbitrary prefix stamped with no record of where it stopped, and the next run cannot tell "already stamped" from "stamped by me." The repo already has the right shape — the spine drain scripts (drain-<entity>-orphans.ts: dry-run default, --mode=apply --confirm-table=propflow-prod, audit rows under BACKFILL_AUDIT#<runId>). That shape is required, not "a backfill".

The stamp carries the copy VERSION — when there is one. A row written by the backfill records a suppression, not an introduction, so it carries no version, and that absence is meaningful rather than missing data: the unwind query selects on version present, which is what keeps it from sweeping the whole pre-existing book. See What the ledger MEANS in part 4. Nobody can be un-introduced, and with per-lane copy there are N possible introductions — so "which introduction did this person receive" is otherwise unanswerable. Storing the copy id alongside smsDisclosureSentAt makes a bad rollout a queryable set rather than an archaeological one, and lets a corrected disclosure be re-fired to exactly the cohort that got v1. It costs one field and it is the only unwind this design has.

The pre-flight check is a SCRIPT, not a gate (hot rule 13). A read-only script enumerates the would-fire cohort against prod and reports its size. It runs before first-contact composition ships, with the expected count written down in advance. If it reports 4,000 on a book of 4,000 tenants, the cold path is inverted and that is learned for free instead of by sending. The script is a measurement, not a switch: nothing about the feature is turned on or off by it.

Follow-up implied. ADR-0089 language-aware outbound: the disclosure is tenant-facing copy and needs a Spanish variant. notifyTenantOfPmsCreation currently declares englishByProductDecision(...), so shipping the disclosure without a translation extends an English-only decision to a new surface.

Where each rule is DECIDED

This ADR was revised over several rounds, and its recurring defect was a rule refined in one place while a summary sentence elsewhere kept asserting the superseded version. Summaries are what implementers read, so a stale one is not a cosmetic problem. The fix is structural: each rule below has exactly ONE authoritative home.

Two kinds of duplication, and only one of them is the bug:

So there are TWO consumer documents — the plan (docs/planning/appfolio-wo-ack-first-contact-and-nudge.md) and ADR-0127. The last column lists every site, so changing a row means grepping both before the commit closes. If a cell is ever blank, treat it as no known downstream copy and verify rather than trust it — a wrong blank is the one entry that tells an editor to stop looking.

Rule Decided in Also restated in
When the stamp is written — the SELECTION precondition, then four cases split on the carrier Part 4: The table is a SECOND gate + The converse subsection plan PR 2 step 3 (precondition + table) · plan PR 2 tests · ADR-0127 (message 2's gate)
What the ledger MEANSdo not introduce this number, not this number was introduced; and the unit is the stamp, never the row Part 4: What the ledger MEANS · Entity classification's ⚠️ plan PR 2 rollout list · plan PR 2 step 3
The field NAME is provisional, and the rename must precede the backfill Part 4: the ⚠️ in What the ledger MEANS · Consequences: the rollout section's first gate — Open question #6 plan PR 2 rollout list (its own ordered bullet)
What the claim stores (smsDisclosureSentAt and the copy version, absent on a backfilled row) Entity classification plan PR 2 step 3 · plan PR 2 rollout bullet
The backfill's target set (prior SMS either direction, matching part 1) Consequences, the rollout paragraph plan PR 2 rollout bullet
Which mints exist, and what each may assert Part 3's mint table + No mint may assert… plan PR 2 step 2 · ADR-0127 (message 2's mint)
neverFirstContact's single legal premise Part 3, the ⚠️ under the mint table plan PR 2 step 2 · ADR-0127 (why message 2 cannot use it)
cannedIntro's shape — sugar over introducedInBody, brand preserved cannedIntro is SUGAR OVER… plan PR 2 step 2
Two of the three mint signatures are unsettled (neverFirstContact's is settled) the ⚠️ under the mint table plan PR 2 step 2
No cannedIntro on an emergency-capable lane, and what could enforce it Part 3, cannedIntro is FORBIDDEN…Open question #5 points here plan PR 3 step 4 · ADR-0127 (why message 1 must blend)
Ambiguity resolves to SUPPRESS Part 2, The cold path… plan PR 2 step 5 · ADR-0127 (unconfirmable ledger warmth)
recordOnlyIf must NOT reach the stamp (and it has zero production callers) The converse subsection, the ⚠️ at the end plan PR 2 step 3 — restated in full, cites and docblock quote included
No withPrefixedContent / no content-rewrite primitive There is no wrapper-ordering constraint… plan PR 2 banner
Where the copy VERSION comes from (per-mint: lane / shared helper / none) Entity classification, Where the VERSION comes from plan PR 2 step 2 (the mint bullet) · plan PR 2 step 3 (the field list)
A lane's ledger read may not reach its composition or its mint What the lane may NOT do with a ledger read plan PR 2 step 2 · ADR-0127 (message 2's gate is the legal case)
That there is no kind-keyed opt-out A lane whose copy already identifies the sender plan PR 2 step 6
The trigger (prior SMS either direction) Part 1 — keying is OPEN, see question 1 plan PR 2 tests (incl. the second-phone-claim case)

Open questions — PR 2 closes these before it builds

Collected here rather than left inline so the Decision above contains only what is decided. Each of these is genuinely undecided. None has an owner ruling behind it, and nothing below should be read as one.

  1. Is the state per-E.164 or per-(org, E.164)? claimPK is org-scoped and DispatchOptions.touch.organizationId is optional, so a claim-stamped ledger is keyed per-org whenever an org is supplied and unkeyable when it is not. If per-org is accepted, part 1's "on this E.164" wording changes with it. They cannot both stand.
  2. Where does the dispatcher get its org? touch is the existing carrier and the natural first candidate; making it required on SMS sends is a wider change than this ADR scopes. If instead the lane reads the ledger, the lane has an org and the dispatcher does not — which may itself be the resolution, at the cost of "the dispatcher owns the ledger" no longer being true of the read half.
  3. What happens when the recipient has NO claim row? The cohort most likely to be first contact is a tenant who exists only as a PMS row. Stamping then means minting a Person + claim mid-send — a substantial new side effect on the hot path, needing the org question answered first.
  4. Does the lazy-thunk shape survive contact with an implementation? The send-once-under-concurrency argument in part 2 depends on it entirely, and it has never been built. If it does not survive, the honest alternative is to accept that double-introduction is possible and say so — which contradicts the Context's own rejection of per-sender fixes, so that is a real decision, not a fallback.
  5. What ENFORCES "no cannedIntro on an emergency-capable lane"? The two candidates and why they differ are analysed once, in part 3 under cannedIntro is FORBIDDEN on any lane that can carry…that is the authoritative statement, and this entry deliberately does not reproduce it, so answering the question is one edit there rather than two that can disagree. Read that section for the candidates, their failure directions, and what each costs.
  6. What is the field actually called? smsDisclosureSentAt names an event for a field meaning do not introduce this number, and every use of the string in these documents is provisional. The rename — or the decision to keep it — must be made before the backfill runs, since renaming after it means rewriting every row the backfill wrote on a table with no unwind.
  7. Can the dispatcher distinguish a pre-carrier throw from a post-carrier one at the point the stamp is written? Part 4's table depends on it entirely, and a bare catch cannot tell them apart. If it cannot, one of the two failures that table separates comes back.
  8. Which reader answers "was there a prior SMS on this number"? getConversationMetasByPhone returns messages: [] by construction, so it can only answer "was there a prior thread". Part 1's trigger is channel-specific. Either name a reader that sees per-message channel, or narrow the trigger to what the meta row can answer — and the same reader is the backfill's verification instrument, so a wrong choice makes the trigger and its verification wrong in the same direction.

Alternatives considered

Per-sender opt-in flag. Each sender passes includeDisclosure: true. Rejected — it is the mass-send shape, and it is exactly why every lane but the blast has no disclosure today. A flag a sender can forget is a hole with extra steps, and it does not solve the two-lanes-race case at all.

A separate introductory SMS. Send the disclosure as its own message before the content. Rejected — an introduction that arrives as its own message is a text whose entire content is "hello, we exist", from a number you do not recognise, ahead of any reason to care. It doubles the touch cost against OUTBOUND_KIND_COUNTABILITY for the lanes least able to afford it, and it is strictly worse than the blended sentence part 3 requires, which delivers the identity and the substance in one breath.

Reconciling this with ADR-0127, which does send two messages. The objection is not "never two messages" — it is "never a contentless one". What reads as spam is a text with no content of its own. ADR-0127's second message carries a genuinely useful thing to try; a bare disclosure carries nothing. The blended introduction is what removes the need for one.

Prepend a fixed tagline at dispatch. The dispatcher rewrites every first-contact body to lead with CLARA_INTRO_TAGLINE. Rejected on its worst case (part 3): a canned identity paragraph in front of "Issue: Gas smell reported" is worse than no introduction at all, and no fixed string can be written that isn't — producing the right sentence requires knowing the subject and the urgency, which is knowledge the composing layer has and the dispatcher does not. Recorded here because it is the design most readers reach for first, and because its machinery (a withPrefixedContent content-rewrite primitive, a disclosure wrapper outside withOutboundRecord) is what the Decision explicitly puts out of scope.

Why the emergency carve-out cannot rescue it — the step this entry used to skip. The accepted design mints cannedIntro, which opts into that same fixed tagline, applied lane-side, and rescues the gas-smell case with a restriction (forbidden on emergency-capable lanes) rather than by producing better copy. For every non-emergency lane that takes cannedIntro the tenant reads a byte-identical body under both designs — so "rejected on the gas-smell case" is not, on its own, a reason the prepend design loses. The reason is that the restriction is enforceable lane-side and unenforceable at the dispatcher:

  1. The dispatcher cannot see the predicate. WorkOrderPriority is on the lane's input; DispatchOptions carries no priority field at all. A dispatch-level prepend therefore cannot tell an emergency-capable send from any other, so the carve-out could only be expressed as a per-send flag the caller passes — which is the forgettable-flag hole the first alternative on this list is rejected for. Lane-side, the same restriction is a property of the lane, checkable where the priority already lives and pinnable by the drift test.
  2. A carve-out at dispatch has nothing to fall back to. Lane-side, "you may not use cannedIntro" leaves a real obligation: mint introducedInBody and write the blended sentence. At dispatch the only two branches are prepend and don't — and "don't" means the emergency send goes out un-introduced, which is the hole this ADR exists to close, reappearing on exactly the lane that motivated it.

And the cost the accepted design pays for that is real, not incidental. The prepend design is genuinely cheaper — one dispatcher change against N lane changes. PR 2's scope rests on that trade being made deliberately: the extra lane edits buy an enforceable emergency carve-out and the option of blended copy, not better copy by default.

How much of that cost is wasted depends on how many lanes take cannedIntro, and this ADR does not know. Consequences asks the question rather than answering it — deliberately, on the same grounds it refuses to size the first-contact-eligible subset: sizing the editorial cost off a guess is how the cost gets under-planned. Every cannedIntro lane is one whose tenant reads a body byte-identical to the prepend design's, so if the share is high most of the extra work buys only the carve-out. The enforceability argument above does not depend on the share; only the magnitude of the waste does. PR 2's enumeration answers it with a count, and that is the right instrument.

Apply it in buildOutboundEnvelope. Simpler wrapping, no HOF ordering problem. Rejected — the envelope is built inside _unsafe_dispatch, below the record applier, so the conversation row would hold text the tenant never saw. (Moot under the current decision, which rewrites no bodies at all; retained because it is the natural next idea for anyone who reaches for a prepend.)

Scan conversation history on every send, no stamp. Rejected — an extra query per outbound message on a path that runs on every tenant text, and it is racy: two concurrent sends both read "no history" and both introduce Clara.