0097 — Email operational signals never create entity records
- Status: Accepted
- Date: 2026-07-16
- Deciders: Fede
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:
- 4 ghost
applied-stage prospects minted from income-verification / RentSpree landlord-reference / applicant-authorization emails about existing tenants (record idsprospect_46e57389,prospect_47be4660,prospect_a45a11fd,prospect_c5f7bb6e), each back-dated to the email's date (Jan–Mar 2026) and contactless. Their canonicalINQUIRY#rows were closed tonot_interestedon 2026-07-13; their retiredPROSPECT#rows (which no reader consumes since the ADR-0035 Phase F cutover) still readapplied— a divergence to remember when auditing raw DynamoDB: query the canonical inquiry rows, notPROSPECT#. - A duplicate prospect for a real applicant (incident
prospect_40c4ef8b), which triggered the bridge's removal in PR #3620 (2026-07-12, commit1b04c18c1). - The PM team flagged the phantom "4 pending applications" on the dashboard as early as the 2026-05-05 standup; the ghosts stayed pending for months because the rental-application sync only visits PMS application rows — a prospect with no matching application is never reconciled (the orphan pass below closes that gap structurally).
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:
extractOperationalSignalpersists exactly oneOperationalSignaland nothing else. Signals attach to tenants at display time through sender-identity resolution; attachment is a read, not a write.- 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. - The AppFolio rental-application sync
(
src/lib/domain/pms/writers/rental-application.ts) is the single owner of the prospect application lifecycle, keyed onappfolio:rentalApplicationIdand requiring a real contact handle and matched unit before creating. No email notification path may mint, stage, or dedupe prospects in parallel with it. - The SES/clara@ pipeline stays data-only: a non-data classification is recorded
as
SKIPPEDand routes nowhere (process-inbound-email.ts).
Enforcement
src/__tests__/email-signal-entity-creation-fence.drift.test.ts— repo-wide source fence: no live (non-comment) line in any module underagents/clara/lib/email/,src/lib/integrations/email/, orsrc/lib/signals/may reference a prospect writer (saveProspect,resolveProspectIdentity,generateId('prospect')) or the retired bridge (bridgeSignalToProspect,signal-prospect-bridge). This fails on the pre-#3620 code and on any future reintroduction, regardless of file name.- The narrow extractor-level fence from PR #3620 remains in
src/__tests__/extract-operational-signal.test.ts.
Consequences
- Adding a legitimate new entity-creation flow from email (e.g. a future guest-card-from-email feature) requires amending this ADR first and building it outside the fenced signal modules, on a deterministic PMS key — not a Haiku classification of notification prose.
- The 4 Camellia ghost prospects' canonical rows were already closed
(
not_interested, 2026-07-13) before this ADR landed; no cleanup remains. The rental-application sync's orphan pass (PR #3959) is the structural guard that closes any future unlinkable pending prospect within one poll cycle + its 30-day staleness window, instead of relying on ad-hoc cleanups. - The fence intentionally covers whole directories rather than named files so module renames/moves (the original bridge survived one reorg) cannot dodge it.
Amendment (2026-08-12) — vendor contact points, scoped narrowly
- Status: Accepted — Fede, 2026-08-12, verbally, recorded as Decision 2 of the vendor-identity architecture review (§6). The Consequences above name amending this ADR as the precondition for a new email-derived creation flow; this is that amendment.
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:
- 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.
- 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
hightier, wherematchVendorCompanyByName({strict: true})or an adjudication with a trade check and a 0.8 confidence floor resolved a live roster company. Themediumtier 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. - 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
- Status: Accepted — Gera, 2026-08-21, in session
collections-latest, following the merge of the deterministic parser (#5939), the stop-process lane (#5995) and the fence-list correction (#5999). Answered on decision pagecollections-three-phases(the page that already settled the Phase-1 demand builder, q d3, and the resident-clock rule, q d4). What this amendment permits, it permits only under the conditions stated below — the link, the direction split, and the fence additions are the terms of the acceptance, not aspirations attached to it. - Occasioned by: Phase 1 of the collections work ingests two email streams
the product has never read — the eviction firm's case updates and two
rental-assistance programmes' status mail. Twenty messages in the corpus say
some version of stop the process; four of them landed after a writ was
already with the sheriff. The parser being built in parallel stays strictly
inside the existing rule (it enriches the one permitted
OperationalSignaland creates nothing). This amendment is what a later PR would need in order to go further. - Related: ADR-0122 (a claim binds to observed evidence, never to a phase or an extraction), ADR-0125 (the type-level approval gate), ADR-0129.
What becomes permitted
Three things, and nothing else.
- 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. - 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 aCOLLCASE#stage, without a per-message human confirmation, when and only when the transition's effect is that the product does less to that resident. - 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
ReviewTypeunion (domain/tenant-confirmation/review-rows.ts:22), not a new surface — it lands on/reviewbesiderenewal_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:
- A
COLLCASE#row has no identity of its own. Its key isPK=PROP#<propertyId>, SK=COLLCASE#<tenantOccupancyId>— entirely derived from a tenancy that exists regardless. It is a state field on an existing entity that lives in its own partition only because the rent-roll poller rewritesTenantOccupancyby blind put on every sync tick (dynamo/collections-case.tsheader). Same shape of argument as the vendor amendment's contact point: an attribute of something already on the roster, not a new business entity. - It appears in no count. The ghost-prospect harm was four contactless rows inflating trending occupancy. A stage is a column on a list of delinquent accounts that are on that list because of their ledger, not because of this row. Writing a stage adds no account to any total.
COLLACT#rows are append-only events under the same tenancy key, with the same properties.
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.
actionSKisACTION#<occurredAt>#<id>— the INSTANT is the other half, and a derived id beside anow()instant still yields a fresh key on every delivery, soattribute_not_exists(SK)can never fire. Cut #1 minted the id and stampedoccurredAt: now(). Cut #2 derived the id correctly and passedsignal.extractedAt— anew 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 nonow()fallback anywhere on the path — note thatemailMeta.datedoes not qualify, because every caller fills itmsg.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, undefaultedmessageInstantinput 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 CollectionsSignalFacts — caseRefKind, 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.
Case identity: cleared. The client id and case ref are verbatim regex captures off a subject line. The model authors nothing.
Resident identity: cannot be cleared by any machine we can build.
resolveSignalTenantresolves the sender, and the sender is the law firm, not the resident — so a firm's email is permanentlyname_unit_fallbackorunresolved.renewal-write-gate.tsalready rules on precisely this shape: onlysender_identityauthorises a write; a name+unit match explicitly does not, because "the LLM-extracted tenant NAME + UNIT is NOT proof that the SENDER is that tenant." The name inFollow Up for Case - 641732 - <resident>is model-extracted from a legal caption. And we have already been here: the dashboard'scomputeActiveEvictions(domain/dashboard/stats/compute.ts,computeActiveEvictions) union-findseviction_legalsignals by last name, over the same model-extractedtenantRefs, behind a stoplist that reads like a list of scars —all,other,occupants,unknown,et,al,couple,babies,tenant,resident. Somebody hit caption strings, papered over them one word at a time, and that matcher drives a headline count today. Fede's warning stands: "we gotta be careful with that fuzzy matching as a mousetrap." The difference in stakes is the whole point of this amendment: when that matcher is wrong the dashboard says 6 evictions instead of 7. When a stage write is wrong, a named resident is the one who is wrong.The mitigation is relocation, not relaxation. This amendment does not lower the identity bar; it moves the identity decision off the email path entirely, to a human, once per case. After the link exists, every subsequent message on that ref inherits it deterministically and the model authors nothing on the writing path. That is the same rung the vendor amendment called thread provenance — our own record, on this thread, before this message — with "our own record" replaced by "a person's own link, on this case, before this message." It is a stronger rung, not a weaker one, because a human wrote it.
Where the machine may still guess a resident, it may only propose, and the proposal must show the operator the verbatim caption text it matched on rather than a name it cleaned up. A proposal that renders a tidied name is a proposal that gets rubber-stamped.
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:
- The fact and the stage part company.
demandServed: trueis stop-more (it only ever adds prohibitions to the compliance assessment). The stagenotice_to_pay_or_quitis release (it is a forward claim on a legal ladder shown to a PM). Same email, same underlying event, two different bars — because the partition is over effects, not topics. - A signal may never set a compliance fact to
false. Absence stays the default.buildCollectionsChaseFactscurrently hardcodes five of eight facts tofalse, which is fail-open in two documented places; a signal-derivedtruecloses a real gap, while a signal-derivedfalsewould be an email-derived permission to dun. Only a human clears one.
What stays forbidden, explicitly
- Any spine entity created from email. Unchanged, in full.
- Any
CollectionsDunReviewrow, any claim on one, any outbound. ADR-0125's gate is untouched: the only thing in the codebase that texts a past-due resident is still a person clicking approve. Lease.evictionPendinggets no writer from this amendment. The field's own doc says "Read-only today — no writer yet; a PM eviction-toggle lands here" (types.ts,Lease.evictionPending— re-verified on acceptance: still no writer anywhere in the repo); the spine-redesign handoffs record it as set only by manual DDB edit. Seven read sites across four subsystems gate on it — the renewal cohort walker, renewal retry-prepare, the collections chase activity's tenancy-exit stop, and collections enrollment + approved-dun send — so every one of those "don't dun someone in eviction" guards is vacuous today. That is a real defect and it is not fixed here: an email parser is the wrong first writer for a spine field a PM toggle or the PMS should own, and making it the first writer would silently promote a third party's prose to the thing that stops four subsystems. If the lane wants an eviction restraint, it reads the case row. Fixing the vacuous guard is separate work.- Automatic anything on an unlinked case ref.
- Bulk-confirming proposals. ADR-0125 already ruled on the equivalent: "a button that approves forty at once is the flag again, wearing a different hat."
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:
FENCED_DIRS— the new collections-email parser directory, wherever it lands.FORBIDDEN— the case/chronology writer functions, and only those:putCollectionsCase,appendCollectionsAction,generateId('collact'),generateId("collact"). All four are defined insrc/lib/data/dynamo/, and none occurs on a live line inside any fenced directory today, so forbidding their names there is meaningful.Not the module path
collections-case. It reads like a tighter version of the same rule and is in fact a looser one: it matches the import specifier, so it catches read-only imports too.stage-gate.tsandload-collections-list.tsboth importlistCollectionsCases/getCollectionsCasefrom that module on live lines inside the fence — reads, which this ADR has never forbidden. A fence keyed on the path cannot tell a read from a write; one keyed on the writer's name can.The send path must NOT go on that list, and neither must the stage literal. An earlier draft of this amendment named
claimCollectionsDunForSend,approved-dun-send,assertApprovedForSendandflagged_for_eviction. All four already occur on live lines insidesrc/lib/domain/leasing/collections, which has been aFENCED_DIRsince the fence shipped — measured onorigin/mainwhen the correction landed: 1, 4, 2 and 2 files respectively, nine in total. Accepting that list would have reddened the fence the same day, on files it was never aimed at. Re-measured on acceptance under the fence's ownliveSourcerules (comments stripped,__tests__and*.test.tsexcluded): 1, 1, 2 and 2 files, six in total — the counts moved, the conclusion did not. The four tokens that stay on the list still have zero live occurrences in any fenced directory, re-verified the same way.The error was treating the collections domain directory as though it sat outside the fence. It does not, and that is deliberate: ADR-0125 puts the send path there on purpose, gated by an unexported
unique symbolso that calling the sender without a claim is a compile error. A string match is the weaker instrument and would only shadow the stronger one.flagged_for_evictionis the same case — the lane legitimately names the stage it must not reach, and forbidding the literal would forbid the guard along with the violation.
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
- Automatic removal, not review. The first confirmed instance of a stop-more auto-write landing on a tenancy the message did not concern removes the automatic rung. Everything reverts to proposal-only and the auto-writer is deleted, not flagged.
- Lapse at 60 days. If no human has linked a single case within 60 days of the link surface shipping, this amendment lapses and the parser returns to display-only. The zero-row production scan is the evidence that this is a live possibility rather than a formality.
- Rubber-stamp trigger. If the release-proposal confirm rate exceeds ~95% over a meaningful sample, the proposal surface is not a gate and must be redesigned. A human who confirms nearly everything is ADR-0125's bulk-approve button wearing a third hat.
- Rewrite on a better anchor. If the PMS ever exposes an eviction case id per tenancy, the human link is redundant and this amendment should be rewritten around that deterministic key — the shape §Consequences preferred all along.
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.