0097 — Email operational signals never create entity records

Context

The operational-signal pipeline (agents/clara/lib/email/extract-operational-signal.ts, introduced 2026-04-09, commit e4533edec) turns an inbound email triaged as "operational" (evictions, vendor invoices, lease admin, verification notices) into one structured OperationalSignal record under the property partition (PK=PROP#{id}, SK=SIGNAL#). The intent from day one: a signal is advisory intelligence attached to existing entities — surfaced on a tenant's page via identity-gated display resolution (src/lib/signals/resolve-signal-tenant.ts, src/lib/signals/tenant-signal-filter.ts) — never a source of new records.

That intent was violated one day after the pipeline landed. PR #32 (2026-04-10, commit 7c4b91ebf) added bridgeSignalToProspect (signal-prospect-bridge.ts): every lease_admin signal got a second Haiku sub-type classification whose prompt folded operational notifications into applications — "application_received — A prospective tenant submitted a rental application, or supporting application documents (income verification, landlord reference) were received" — and create-on-miss identity resolution then minted stage: 'applied' Prospect records from those emails. Because AppFolio notification emails carry only a name (no email/phone/application id), the bridge could neither dedupe nor resolve reliably. Results in production at Camellia:

The stakes rose on 2026-07-16: the team decided pending applications now count toward the dashboard's trending-occupancy figure, so a ghost applied prospect is no longer cosmetic — it directly inflates a headline KPI.

The constraint that would have forbidden PR #32 was never written down. It held before and after purely by construction — no ADR, no doc, and (until PR #3620's narrow drift fence) no test. This ADR writes it down.

Decision

An email-derived operational signal must never create an entity record. Precisely:

  1. extractOperationalSignal persists exactly one OperationalSignal and nothing else. Signals attach to tenants at display time through sender-identity resolution; attachment is a read, not a write.
  2. The only state a signal may drive is a transition on an already-existing entity, behind a deterministic identity gate (today: the renewal bridge, signal-tenant-renewal-bridge.ts, which mutates saga/tenant state only when the sender's identity matches the named tenant, and downgrades to PM-review advisory otherwise). Create-on-miss fallbacks are forbidden on every signal path.
  3. The AppFolio rental-application sync (src/lib/domain/pms/writers/rental-application.ts) is the single owner of the prospect application lifecycle, keyed on appfolio:rentalApplicationId and requiring a real contact handle and matched unit before creating. No email notification path may mint, stage, or dedupe prospects in parallel with it.
  4. The SES/clara@ pipeline stays data-only: a non-data classification is recorded as SKIPPED and routes nowhere (process-inbound-email.ts).

Enforcement

Consequences

Amendment (2026-08-12) — vendor contact points, scoped narrowly

What is carved out. A confident vendor recognition in the inbound vendor-email lane may create a VendorContactPoint — a phone / email / domain row belonging to a VendorCompany, in one client org. Nothing else. Not a Person, not a Prospect, not a vendor company, not a work order, not an identity claim.

Why the rule's purpose survives it. §Decision 1–4 exist to stop email prose from minting business entities that then appear in PM-facing counts — the 4 ghost applied prospects that inflated trending occupancy. A contact point is not a business entity and appears in no count. It is an assertion that an identifier we already received mail from belongs to a company that already exists on the client's own PMS roster. The company is never created; recognition that cannot match a live roster row resolves to nothing. There is no create-on-miss path, because there is no create path at all for the entity the identifier points at.

Why it is not the Haiku-classification failure mode. §Context's objection is to a model reading notification prose and minting a record it cannot dedupe or resolve. Three properties keep this lane clear of that:

  1. Dedupe is structural, not heuristic. The row's key is derived from (company, kind, normalized value), so re-observing an address is a read-modify-write of the same row. Two rows for one address is not a bug that can happen; it is a shape the key forbids.
  2. The model is not the author of the identity on the path that writes. The thread-provenance rung — our own outbound record, on this thread, before this message — always writes. The stated-identity rung writes only at its high tier, where matchVendorCompanyByName({strict: true}) or an adjudication with a trade check and a 0.8 confidence floor resolved a live roster company. The medium tier may still book a calendar entry, which is deletable; it may not teach, because a wrong learned row applies to every future email from that address.
  3. Every row carries its evidence. Provenance, first/last seen, observation count — so a wrong one is findable and removable, unlike a ghost prospect discovered months later on a dashboard.

What this does not touch. The Person spine is untouched: the 2026-08-09 ruling (an unknown inbound contact is a new identity; heuristic attach/merge is always a bug) stands, and attaching an identifier to a company fuses no two humans and records no consent/TCPA state. The fenced modules stay fenced — email-signal-entity-creation-fence.drift.test.ts is unchanged and still forbids every prospect writer under the signal directories. The operational-signal pipeline gains nothing from this amendment; the carve-out is for the vendor-email scheduling lane only.

Scope limit. If a future change wants to create anything else from inbound email — including a VendorCompany for a sender on no roster (12 such senders exist in the Camellia corpus) — that is outside this amendment and needs its own.

Amendment (2026-08-19) — collections case signals, split by direction

What becomes permitted

Three things, and nothing else.

  1. A human-authored link between an eviction case reference and an existing TenantOccupancy. A PM types (or confirms) a case number on a tenancy they already have open. This is not an email-derived write at all — it is a person asserting an identity on a record that already exists — and it is stated here only because everything below depends on it.
  2. STOP-MORE transitions, automatic, on a case that is already linked. A parsed message on a linked case ref may append a COLLACT# chronology row and project a COLLCASE# stage, without a per-message human confirmation, when and only when the transition's effect is that the product does less to that resident.
  3. RELEASE transitions, as a proposal only. A parsed message that would remove a restraint, move the account further along the legal ladder, or overwrite a stop-more stage produces a pending proposal. A person confirms each one individually. The machine never writes the effect. The proposal is a fifth member of the existing ReviewType union (domain/tenant-confirmation/review-rows.ts:22), not a new surface — it lands on /review beside renewal_change, whose capture → decision → released-effect shape (captureRenewalChangeReview / claimRenewalChangeReview) is the one to copy.

An unlinked case ref writes nothing, in either direction. It renders on the signal surface and — this part is load-bearing — it alerts anyway. The alert is deliberately not gated on the link, because the sharpest failure in the corpus (an assistance approval arriving after a writ) is exactly the message most likely to arrive on a case nobody has linked yet. Gating the alarm on the link would make the worst case the silent one.

Why this is not the entity-creation the rule forbids

The COLLCASE# row does not exist for most accounts — production holds zero of them, and zero COLLACT# rows, anywhere. So writing one is, literally, a PutItem where no item was. That is not what §Decision forbids, and the distinction matters enough to state:

No spine entity is created by anything here. Not a Person, not a TenantOccupancy, not a Lease, not a Prospect. Create-on-miss remains forbidden on every path: a case ref that resolves to no linked tenancy resolves to nothing, exactly as an unmatched vendor recognition does.

The three bars from the 2026-08-12 amendment, argued honestly

1. Dedupe is structural, not heuristic — CLEARED. TSM's client id 54784 and a REC/barcode case number appear verbatim in subject lines (Eviction Case Processed - Client 54784, Follow Up for Case - 641732 - <resident>), and the Filing Report's BARCODE is the same number, so one ref threads the whole lifecycle. Two keys follow from it, both forbidding duplicates rather than detecting them: the link is keyed on (property, caseRef), so re-observing a case is a read-modify-write of one row; and the chronology row's id must be derived from the provider message id plus the parser rule that fired, never minted. appendCollectionsAction already writes under attribute_not_exists(SK) and already accepts an id override for callers carrying their own idempotency key — a redelivered webhook surfaces as a ConflictError, not a second entry on a legal chronology.

⚠️ CORRECTION (2026-08-23): the paragraph above cleared this bar naming only HALF the key, and two cuts of the write shipped duplicating rows while satisfying its literal words. actionSK is ACTION#<occurredAt>#<id> — the INSTANT is the other half, and a derived id beside a now() instant still yields a fresh key on every delivery, so attribute_not_exists(SK) can never fire. Cut #1 minted the id and stamped occurredAt: now(). Cut #2 derived the id correctly and passed signal.extractedAt — a new Date() from ten lines above the call — under a comment asserting it was the message's; the bench "replay" cited as proof re-used one in-process signal, so both runs carried the same clock reading and the hold was vacuous. The bar is: BOTH halves of the SK must be derived from the message, and a message that cannot supply either is NOT WRITTEN. The instant comes from the provider's own value, canonicalised, with no now() fallback anywhere on the path — note that emailMeta.date does not qualify, because every caller fills it msg.date || new Date().toISOString() for display and that fallback is applied per delivery. writeStopMore / messageInstantFor (src/lib/domain/collections-write/stop-more-writer.ts) is the implementation; the extractor takes a separate, undefaulted messageInstant input for exactly this reason.

Where that evidence comes from, stated so nobody mistakes it for shipped behaviour. The case-ref finding is a reading of the production mailbox via the read-only sweep scripts (scripts/_collections-mailbox-sweep.ts, whose TERMS vocabulary already carries TSM, Tschetter, writ, rental assistance; scripts/collections-attachment-sweep.ts for the PDF text). The mail already reaches the extractor: isOperationalDataSender (agents/clara/lib/email/auto-reply-guard.ts) routes these senders to { outcome: 'skip', reason: 'operational' } at agents/clara/lib/email/process-inbound-message.ts:741, and that reason is exactly what src/lib/integrations/email/webhook-processors.ts matches to call extractOperationalSignal — awaited, not fire-and-forget, after a 2026-06-10 incident lost 25+ signals to a frozen function. The first work this amendment named was therefore structured extraction on a path that already runs, not teaching the pipeline to read mail it declines to read.

Status of that first work, as of acceptance. It shipped. When this amendment was drafted on 2026-08-19 nothing in application code parsed a case ref — barcode and Filing Report had zero hits in the repo, OperationalSignal had no field a case ref could live in, and these senders produced a generic signal whose case ref, event kind and amounts were dissolved into model prose. PR #5939 closed that gap: the deterministic law-firm and rental-assistance parsers under src/lib/domain/leasing/collections/email/ now claim the message without an LLM and record CollectionsSignalFactscaseRefKind, caseRef, courtDocket, clientId, ruleId, tier, confidence — on the one OperationalSignal the extractor already writes (src/lib/data/types.ts, CollectionsSignalFacts). PR #5993 made a claimed parse settle its own category and its failures observable; PR #5995 added stop-process detection and the premise-rung refusal that reads it.

That changes the starting line, and nothing else. Everything those PRs added is still inside the pre-existing rule: a typed, inert value riding on the one permitted signal, creating no record and driving no transition. A structured caseRef is not a link, and a parsed stop is not a stage. The link, the direction split and the fence additions below remain unbuilt and remain the conditions on anything further.

2. The model is not the author of the identity on the path that writes — CLEARED FOR THE CASE, NOT CLEARED FOR THE RESIDENT. This is the hard one and it is not hand-waved.

3. Every row carries its evidence — CLEARED, and cheaply. The chronology is already append-only, already refuses to overwrite, already stores occurredAt separately from createdAt, and already treats actorEmail: null as system-derived. Every row this amendment permits must additionally carry: the OperationalSignal id, the provider message id, the verbatim matched case ref, the parser rule that fired, and — for a linked write — who authored the link and when. Per ADR-0122 §4 the surface renders these as attributed speech, never as assertion: "TSM's 2026-08-14 notice says the case was dismissed", never "case dismissed", and never as though a PM had typed it. collections.ts already draws that line — "an operator note is a claim, a system event is a receipt" — and a system event sourced from a third party's prose is the weaker of the two, not the stronger.

The direction split, which is the actual decision

Direction is decided by effect on the resident, not by subject matter. The partition must be an exhaustiveness-checked Record<CollectionsStage, …> — the same construct collections-case.ts chose for its guards after COLLECTIONS_STAGE_ORDER (a bare array, which TypeScript does not check for completeness) silently dropped rows.

STOP-MORE — the product does less. payment_plan, assistance_applied, assistance_approved; setting demandServed, openHabitabilityComplaint or tenantDisclosedCashAssistance to true; halting an in-flight chase. A false positive here means we decline to text someone about money — recoverable, and bounded further by ADR-0125: nothing reaches a resident without a person approving it, so a wrongly-restrained account surfaces as a reminder that stopped appearing, in front of the same reviewer. Permitted automatically on a linked case.

RELEASE — the product does more, or a restraint comes off. Clearing or overwriting a stop-more stage; a case marked dismissed, withdrawn or closed; re-enrolling an account into a chase; setting any of those three compliance facts back to false; and every forward move on the legal ladder — initial_engagement, reminder_3day, notice_to_pay_or_quit, flagged_for_eviction. Proposal only, confirmed per instance, on a linked case. There is no automatic path, at any confidence, ever.

Two consequences of that partition are worth spelling out because they look like inconsistencies and are not:

What stays forbidden, explicitly

The failure mode, named plainly

A mis-matched name advances the wrong resident to flagged_for_eviction — a legal accusation, on an innocent tenancy's row, in front of a PM deciding whether to escalate. Or, worse, it clears a real one: a resident whose assistance genuinely was approved has that approval overwritten by a "dismissed" or "case closed" message belonging to somebody else's case, and the product resumes dunning the one person the founders singled out as never to be chased or filed on. Both failures run through the same defect — a model-authored resident name — and both are answered by the same structure: the machine never authors the name, and the release direction never writes without a person.

The third failure is quieter and just as real: nothing happens at all. Zero COLLCASE# rows exist in production, which means no human has ever set a stage. A surface can ship and be used by nobody, and an approval that arrives on an unlinked case in that world reaches no one. That is why the alert is not link-gated, and why the lapse trigger below exists.

What the source fence must gain, in the same PR as any collections writer

src/__tests__/email-signal-entity-creation-fence.drift.test.ts blocks prospect writers only, by literal string. A collections writer imported into a fenced module sails straight past it — the ADR has always forbidden more than the fence enforces, and this amendment is the point where that gap becomes exploitable. The fence must gain, in the same PR:

That list is not decoration. It forces the shape this amendment assumes: the parser under agents/clara/lib/email/ produces a typed, inert value, and a module outside the fence performs the write — which is what §Consequences has required since 2026-07-16 ("build the flow outside the fenced signal modules").

The one existing writer — api/collections/[tenantOccupancyId]/actions/route.ts, today the only caller of putCollectionsCase / appendCollectionsAction — is not that module and must not become it. It stamps actorEmail from the session and rejects the field in the body under a .strict() schema, which is correct and should stay correct: a system-derived write has no session, and routing one through there would either fabricate an operator or force a hole in the schema that guarantees an operator wrote what the column claims. The new writer is a sibling module with actorEmail: null — the value the record type already defines as system-derived.

The writer itself should be made unwritable rather than merely reviewed, using the instrument ADR-0125 §2 already established: a unique symbol declared and not exported, so the stop-more writer cannot be called with a release-direction transition, and the release writer cannot be called with anything but the value produced by the conditional pending → confirmed claim on a proposal row. A boolean cannot express "you cannot write the mistake"; that type can.

Removal and revisit triggers

Scope limit

This amendment covers the eviction-firm and rental-assistance email streams and the two collections row types under an existing tenancy, and nothing else. It authorises no outbound message, no spine write, no compliance fact set to false, no writer for Lease.evictionPending, and no automatic transition on an unlinked case. Anything further needs its own amendment.