Greenfield transactional email stack: dispatcher chokepoint, admission+egress guards, durable outbox
Elazar directive 2026-08-01: every evolutiva app gets a full email-send workflow. Enamel measured NULL - no transactional app mailer (deps, call sites, .env.local absent, 3 Vercel vars all Supabase) AND GoTrue cannot send (external_email_enabled=false, all smtp_* NULL, one true provider). So this is BUILD, not migrate: no sender identity exists to carry. Class-A under Push-to-Done (email-send path): audit design-ping BEFORE implementing, diff review before push, build+runtime logs after. Design requirements settled 2026-08-01 across three sibling specs + pmmaster rulings: (1) TWO separately-named recipient guards, never merged - ADMISSION allowlist (allowedEmailDomains, input-side; on no app's SMTP path) and EGRESS deliverability classifier at the transport chokepoint (venus email-recipient-guard-core is the only live impl; mars+pluto lack it). (2) Outbox-first durable send, enqueue on the caller's transaction. (3) Drain via Supabase pg_cron + pg_net; vercel.json cron CANNOT do sub-daily (Hobby-illegal, fails the build, froze mars prod 31h). (4) Bounded SMTP timeouts every phase, retry only genuinely transient. (5) resolveSiteOrigin fail-closed - pluto src/lib/site-config.ts is the SOLE fleet reference; mars is NOT remediated (5 bare NEXT_PUBLIC_APP_URL fallbacks at 45e1a0b, 4 on the mail path, MARS-599 open). (6) Never ride GoTrue's mailer - satisfied VACUOUSLY today, state as a dated condition not a property (one un-notified dashboard toggle from false). (7) appEvents on send AND fail - pluto's success row fires only on the immediate path, do not copy that. (8) From header must be a verified Gmail send-as or it is silently re-stamped (pluto PLUTO-131).
Sub-tickets — 2/2 done
Activity
-
Email-stack chain closed 2026-09-11, recorded here because a parent inherits nothing from its children and this row would otherwise read as if nothing happened today. ENAMEL-30 done (CRON_SECRET minted, set in Vercel Production, bound by a push-triggered deploy, 200 verified from venus with the real bearer). ENAMEL-32 done at edd9d1a / app 0.9.0 (per-appKey oldest-pending-row age alarm, enabled and firing, deliberately installed BEFORE the invoker so the drain is watched from its first run). ENAMEL-33 done at d7aab4b / app 0.10.0 (evolutiva-emaildrain@enamel.timer live on *:0/5, verified from venus myself - last fire 13:00:00 UTC, Result=success, ExecMainStatus=0 - plus the once-then-suppress config gate with a recovery leg). enamel now has an invoker AND an absence-detector for that invoker; before today it had neither and a queued row was silent mail loss.\n\nSTILL OWED ON THIS PARENT, none of it started: (1) two-worker lease-expiry test against prod - the claim/lease path has never had two concurrent workers race it, and the drain has so far only ever run with zero rows, so the claim loop, the SMTP attempt, the finalize and every outcome branch remain unexercised in production; (2) db-enamel's cardinality and plan review of the drain's claim query, which is gated on real rows existing and therefore cannot be done yet; (3) a retention and purge decision for emailOutbox recipient addresses and message bodies - we are now about to start durably storing personal data with no expiry policy, which is the ENAMEL-1 Ley 25.326 question arriving from the other direction.\n\nUNEXERCISED, stated rather than implied: the appEvents round-trip of the new emailConfigUnavailable/emailConfigRecovered suppression state is proven by unit test only. With SMTP healthy the failure leg never runs. The first real config outage is what tests it.
-
Small known defect in the d7aab4b suppression code, filed so it does not evaporate: on the stateReadFailed path the drain reports the failure (correct - that is the chosen degradation direction) but writes detail {previousAction: null, previousFailureCode: null}, which is byte-identical to a genuine FIRST report. So a repeatedly-failing state read silently returns us to 288 rows/day and the only evidence of WHY is a console.error on Vercel stdout, not in appEvents - the same class as a probe that cannot record its own failure. Fix is one field: put stateReadFailed into detail. Not worth its own deploy; rides the next app push. Not started - venus is under the push-gate lock ruling (see ENAMEL-16 relay) and no gate, build or test may start without holding resource venus-host.push-gate.
-
PUSHED cc6e3b3 (4d65596..cc6e3b3, app/package.json bump + the route file, 15 insertions / 3 deletions). One of this row's owed items, not the row. WHAT SHIPPED: the drain route's emailConfigUnavailable event now carries stateReadFailed in its appEvents detail payload. Before this, both the no-prior-event case and the state-read-threw case wrote detail {previousAction: null, previousFailureCode: null} - byte-identical rows for two different facts, one meaning there was no prior event and the other meaning we could not find out. The comment above that payload claimed a reader could tell a first report from a change; that claim was false in exactly the case the code went out of its way to handle. WHY IT MATTERED MORE THAN ONE FIELD: the route deliberately chooses a degradation direction at the state-read failure - log ANYWAY rather than suppress, because silently dropping the first report of a broken SMTP config is the one loss that matters. That choice is documented in a comment and was invisible in the emitted data, so the one place it gets acted on - a reader deciding whether an alert is new - could not see it. A deliberate degradation that leaves no trace in its own telemetry is indistinguishable from the failure it was chosen to avoid. NO BEHAVIOUR CHANGE: suppression logic, the response body (external callers key on reason=config_unavailable) and the recovery leg are untouched. No test asserted the detail shape, so nothing needed updating - which is itself worth noting, since the shape is now load-bearing for a reader and still has no test pinning it. Gate: venus-host.push-gate taken before the run and the app toolchain executed (lint, tsc, test) because the staged set touched app/.
-
PM RULING on owed item (3), the emailOutbox retention and purge decision. coder-enamel-cc, enamel PM seat, 2026-09-11. The SHAPE is ruled here; the two NUMBERS are Elazar's and are named as such at the end. Nothing is implemented by this event - db-enamel-cc writes the migration once the numbers exist. MEASURED FIRST, so this is a decision about a real table and not a template. emailOutbox holds 2 rows, 1 with sentAt set, both created today - this table started carrying real personal data on 2026-09-11 and had none before. Its columns include recipientsTo (array), recipientsCc (array), replyTo, subject, bodyText and bodyHtml. There are ZERO triggers on it, ZERO functions in the database that touch it, and no purge, retention or cleanup path anywhere in app/src - grep returns only the drain and outbox.ts, and every deletedAt reference in the mail code is a READ filter. The column exists; nothing writes it. So today's policy is retain everything forever by default, arrived at by omission rather than by decision. THE RULING, AND THE LOAD-BEARING PART IS THAT RETENTION IS NOT ONE NUMBER: the PROOF and the CONTENT have different lifetimes and must be separated. Answering a data subject who asks whether we emailed them requires knowing that we did, to whom, and when. It does not require the body. Conflating them is what produces a single retention period that is simultaneously too long for the body and too short for the audit trail. PHASE A - REDACT CONTENT AT TERMINAL STATE. Once a row reaches a terminal outcome, NULL bodyText, bodyHtml and subject. Post-send those three have ZERO operational use: retry is impossible and the send already happened. This is the largest single reduction in stored personal data available here and it costs nothing operationally. TWO CONSTRAINTS ON PHASE A THAT ARE NOT OPTIONAL. First, it keys on TERMINAL STATE, never on age - a row still retrying MUST keep its body or the retry sends an empty message, so an age-based sweep over the whole table is the wrong instrument and would silently break the retry path. Second, it must NOT run inside the send transaction. A bug that mis-marks a row sent would then destroy the only copy of the message in the same breath, converting a status bug into permanent data loss. Redaction is a separate pass that reads sentAt AND messageId as non-null, or a terminal failureCode, and runs behind the send. PHASE B - REDACT IDENTIFIERS AFTER THE DISPUTE WINDOW. recipientsTo, recipientsCc and replyTo are themselves personal data and cannot be kept forever on the argument that they are only an audit trail. After the dispute window, NULL them and keep the row skeleton - id, appKey, emailClass, sendStatus, attempts, sentAt, messageId, failureCode - which still answers how many were sent, of what class, with what outcome, without naming anyone. PHASE C - DELETE THE ROW after the legal retention period. WHY A SOFT deletedAt IS NOT ENOUGH HERE AND THE COLUMN MUST NOT BE USED FOR THIS: a soft delete leaves every byte of the body and every address in the table, readable by anything with SELECT. For a retention obligation the erasure has to be an actual NULL or an actual DELETE. The deletedAt column is for the application's own lifecycle, and using it to satisfy a data-protection requirement would produce a control that passes every check while retaining exactly what it claims to have removed - the same failure class as the ACC1 comment that described an exclusion the code did not make. PRECEDENT ALREADY SET IN THIS PROJECT AND CONSISTENT WITH THIS: audit_terra_cc_ro holds a 16-column grant on emailOutbox that deliberately excludes recipientsTo, recipientsCc, subject, bodyText, bodyHtml and replyTo, on the reasoning that an auditor counting claimed and sent needs none of them. Phase A and Phase B are that same judgement applied over TIME rather than over ROLE. WHAT IS ELAZAR'S AND IS NOT RULED HERE, because neither is a technical question: the DISPUTE WINDOW for Phase B and the LEGAL RETENTION PERIOD for Phase C. Ley 25.326 states the principle - personal data kept no longer than necessary for the purpose it was collected for - and does not supply a number for transactional mail. Guessing one and writing it into a migration would turn an unmade legal decision into an enforced technical fact, which is the failure this project keeps filing under a different name. Surfaced in CLI; it cannot be routed over the hub. ALSO MEASURED, AND IT CORRECTS THIS ROW'S 13:11 NOTE: the drain has NOT only ever run with zero rows. One row carries sentAt, so the claim, lease, SMTP attempt and finalize path have each executed in production at least once. That is a real change to owed items (1) and (2). Stated at its true size: n=2 is not a cardinality sample and does not unblock db-enamel's plan review, and one send is not a concurrency test - the two-worker lease-expiry race remains completely unexercised. What changed is that the path is no longer entirely theoretical.
-
76d870f pushed: buildConfigFailureDetail + ConfigFailureDetail extracted from the drain route into app/src/lib/email/outbox.ts, route repointed, 4 tests added to email-logic.test.ts. Closes the gap I created at cc6e3b3 - the emailConfigUnavailable detail shape was load-bearing for a reader (stateReadFailed discriminates a genuine first report from an unreadable state store; without it the two rows are byte-identical while meaning opposite things) and lived as an inline object literal inside the route handler, where no test could reach it. Gate ran lint+tsc+full suite green. HONESTY CAVEAT: the discriminator test is a real control BY CONSTRUCTION (drop stateReadFailed and expect(firstReport).not.toEqual(couldNotRead) fails), but I did not force it to fail by execution - it is a green line I have argued about, not one I have watched go red.
-
OWED ITEM (1), the two-worker lease-expiry race: ANSWERED BY READING, code written, PUSH BLOCKED BY ANOTHER LANE. Stating the blocker first because the work is done and undeployed, which is not done.\n\nWHAT THE READ SETTLED. Claim atomicity is correct BY CONSTRUCTION and needs no race to prove it: claimOne is a single UPDATE whose subquery carries 'for update skip locked' with limit 1, and every later write is token-checked (finalize matches on id AND claimToken AND sendStatus='claimed'). Two workers cannot claim the same row and a resurrected worker cannot finalize a row another run has taken. So the race I owed a test for cannot occur on that path.\n\nWHAT THE READ FOUND INSTEAD, and it is worse than the thing I was looking for. The lease branch recovered a stale 'claimed' row by folding a second condition into the claim query itself. That restores AVAILABILITY and restores nothing else: attempts is incremented only on a committed finalize, and MAX_ATTEMPTS is evaluated ONLY on the failure finalize, neither of which a worker killed mid-send ever reaches. So a row that reliably kills its worker was re-claimed without bound - the cap was structurally unreachable no matter how many times the row had been taken. Bumping attempts inside the old reclaim would NOT have fixed it, because the claim subquery has no attempts filter and the counter would advance with nothing reading it. AND THE HEAD-OF-LINE EFFECT is what turns it from a leak into an outage: the claim orders by nextAttemptAt, which stays frozen in the past while a row sits 'claimed', so the worker-killing row sorted ahead of every healthy pending row and the mail behind it never went out.\n\nNOT SILENT, stated so the fix is not over-claimed: ops/enamel-outbox-age-alarm.sh counts sendStatus in ('pending','claimed') past nextAttemptAt, so the pile-up was visible to the alarm. The defect is unbounded retry plus starvation, not invisibility. A SECOND thing that WAS invisible: a lease recovery left no trace at all, because staleClaimDropped fires only when the ORIGINAL worker returns to finalize and finds its token stale, which a killed worker by definition never does.\n\nTHE FIX, written and not yet pushed. sweepExpiredLeases() runs once before the claim loop, in one transaction: one UPDATE moves expired-lease rows back to 'pending' with attempts+1 and a provisional park, returning id/attempts/emailClass; then per row, leaseSweepDecision(attemptsAfterSweep, MAX_ATTEMPTS) either retires it as failed/maxAttemptsExhausted or requeues it behind backoffMinutes(attempts) - the SAME backoff a cleanly-failed attempt would get, so a swept attempt and a failed attempt are indistinguishable in scheduling. One leaseExpiredSwept appEvents row per swept id, logged AFTER commit, carrying outboxId/emailClass/attempts/backoffMinutes and no recipient or subject. claimOne's second branch is gone; it is pending-only now. Deliberately no 'for update skip locked' on the sweep: two concurrent sweepers serialise on the row and the second re-evaluates its WHERE after the first commits, by which time sendStatus is 'pending' and claimedAt is null. The decision function is pure and lives in lib/email/outbox.ts with 6 tests covering the cap boundary, backoff parity with the failure path, the 60-minute ceiling, and that no requeue can produce a zero delay.\n\nTHE CHOICE THAT IS LOAD-BEARING: A DIED ATTEMPT IS AN ATTEMPT. Nobody knows whether the SMTP server accepted the message before the worker was killed, so counting the sweep bounds DUPLICATE deliveries at MAX_ATTEMPTS. Sweeping for free optimises for the case where nothing was sent and pays for it with an unbounded case where something was.\n\nHONESTY: this was found by READING, not by racing two workers, and the fix is exactly as unexercised in production as the code it replaces. Owed item (1) is ANSWERED, not EXERCISED, and the row should keep saying so.\n\nPUSH BLOCKED, not by me: .gitpush-pre.sh runs tsc --noEmit over the whole app, so --only scopes the commit and scopes nothing about the gate. 9 TS errors in src/app/(protected)/usuarios/__tests__/usuarios.test.ts - coder02-enamel-cc's uncommitted ENAMEL-22 work, a directory I have not touched. Did not edit their open file. Released venus-host.push-gate rather than holding a host-wide lock while waiting on another agent, DM'd them the exact error list, re-taking on their green.
-
Expired-lease recovery is bounded and pushed: a951f18, app v0.10.11, deploying via GitHub-Vercel. The drain claim query no longer reclaims stale rows inline; sweepExpiredLeases runs first, in its own transaction, increments attempts, and evaluates MAX_ATTEMPTS on the recovering path itself - retire into the failure-finalize terminal shape at or above the cap, requeue behind backoffMinutes(attempts) below it. A died attempt counts as an attempt, which is what bounds duplicate deliveries at the cap. leaseSweepDecision is pure with 6 tests; gate ran lint + tsc + 224 vitest green. Remaining on this row: db-enamel cardinality/plan review (gated on real rows) and the retention migration phases A/B/C (gated on Elazar supplying the dispute window and the legal retention numbers).
-
CORRECTION TO MY OWN 15:13 DECISION EVENT ON THIS ROW. That event said 'one row carries sentAt, so the claim, lease, SMTP attempt and finalize path have each executed in production at least once' and treated it as a change to owed items (1) and (2). Measured now, post-deploy: the sent row is appKey='terra', not enamel. emailOutbox holds exactly 2 rows. The terra row (accessIssue, claimedAt 10:55:00.86, sentAt 10:55:05.05, attempts 1) was drained by the TERRA instance, which the evolutiva-emaildrain@.service template points at a different URL (EMAILDRAIN_TERRA_URL, method GET) from enamel's (EMAILDRAIN_ENAMEL_URL, method POST) - different app, different deployed code. The only enamel row that has ever existed (alarmProbe, created 10:30:15) has attempts=0, claimedAt NULL, sentAt NULL and was soft-deleted at 12:35:57, and the claim query filters deletedAt is null, so it was never claimed and now never can be. SO: app/src/app/api/cron/drain-email-outbox/route.ts has never claimed or sent a row in production. Zero. Every run since the timer was enabled has been a no-op over an empty queue, which is also why a post-deploy appEvents read for this route returns zero rows over the last 25 minutes - that is the quiet state, NOT evidence of health. a951f18 is deployed (dpl_DN2MkPZFX6TifiAezqYYLpmkp1qM READY, production, v0.10.11) and the sweep is therefore live and completely unexercised, exactly as unexercised as the reclaim branch it replaced. Owed item (1) remains ANSWERED BY READING, not EXERCISED. Owed item (2), db-enamel's cardinality and plan review, is gated on real rows and enamel still has none.
-
OUTBOX STATE RE-DERIVED LIVE 2026-09-28 (SELECT-only on eprpywqwzsvrjeaforei + venus user-scope systemd + route source). One non-defect and one real green-line gap. NON-DEFECT, recorded so nobody re-investigates it as a fault (I just did): emailOutbox holds 2 rows total, ever. The terra row (accessIssue, 2026-09-11 10:53) was claimed and sent by TERRA's drain. The enamel row 6b802ee4 (emailClass='alarmProbe', 2026-09-11 10:30) is sendStatus='pending', attempts=0, nextAttemptAt in the past - AND deletedAt='2026-09-11 12:35:57'. It is a soft-deleted probe fixture retired two hours after it was created. The claim query's 'deletedAt is null' correctly excludes it; the ENAMEL-32 age alarm's predicate also carries 'deletedAt is null' and has correctly never fired on it. So: enamel's drain has claimed zero rows because enamel has never enqueued a live email, not because anything is broken. Reading sendStatus/attempts/nextAttemptAt WITHOUT deletedAt makes this row look like a 17-day-stuck delivery. It is not. REAL GAP - the caller cannot see the drain failing. evolutiva-emaildrain@enamel.timer is enabled and firing every 5 minutes (last run 09:30 today, Result=success, ExecMainStatus=0, unbroken for as far back as the journal goes). But the unit asserts nothing about what the run DID. Route line 677 returns HTTP 200 with body {ok:false, reason:'config_unavailable', claimed:0} when the transport gate fails - a 200, deliberately, and the route comment correctly pins that body because external callers key on reason. The unit's ExecStart is curl --config - --fail-with-body --silent --show-error, which fails only on >=400, and the journal carries NO body output at any run: the only lines between Starting and Finished are systemd's own. So the one field that says the run did nothing is discarded, and a config-broken drain reports success indefinitely. This is the exact shape ops/README line 19 warns about, inverted: not an alarm sharing its subject's invoker, but a CALLER that cannot distinguish 'drained' from 'did nothing'. Today it is harmless only because the queue is empty - which is also why it has never been noticed, and why it would first be noticed by an undelivered email rather than by the rail. FIX BELONGS IN THE UNIT, NOT THE ROUTE: ExecStart must assert ok:true (or at minimum exit non-zero on ok:false) so systemd's Result reflects the run's own verdict. evolutiva-emaildrain@.service is the SHARED EVO-94 template serving mars/venus/terra/enamel, so this is a fleet change, not enamel's to make unilaterally. Raised to pmmaster-evolutiva-cc this turn. Instrument coverage: this examined the DB queue, the systemd timer/service state and journal, the unit's ExecStart, the route's return arms, and the alarm script's SQL. NOT exercised: an actual send through the transport (no live row to send), and the mars/venus/terra legs of the same template - I read only enamel's instantiation, though the ExecStart is shared so the gap is structural across all four.
-
RESOLVED 2026-09-28, and the cause is neither the route nor the endpoint. bin-venus-cc reproduced it with the network removed: journald intermittently DROPS a short-lived systemd oneshot's PIPED stdout. Minimal case: systemd-run --user -p Type=oneshot -p StandardOutput=journal /bin/bash -ceu 'printf "MARKER\n" | /bin/cat' - lost once, then captured 12/12 in a later batch. Also lost with '| cat -', '| grep .', and the unit's exact ExecStart shape; captured with a bare echo and with '| tee'. Result=success and ExecMainStatus=0 on every run. Re-queried minutes later the empty runs are still empty, so it is loss, not query lag. Both prior observations were true and both inferences from them were wrong. Mine: 806 journal lines for evolutiva-emaildrain@enamel.service since 09-27 with zero non-systemd lines, which I read as the caller discarding the body. bin-venus-cc's: 4838 runs with no body lines, read as the endpoints returning empty 200s. The measurement that settled it was the unit's exact ExecStart curl run by hand - HTTP 200, content-type application/json, 102 bytes, literal body {"ok":true,"claimed":0,"sent":0,"skipped":0,"failed":0,"retried":0,"sweptRequeued":0,"sweptRetired":0} - followed by starting the service itself (09:34:31, Finished 09:34:32) and seeing no body line for that same run. bin-venus-cc withdrew the empty-200s inference; I had already flagged that my own config_unavailable claim was a SOURCE READ of route L677 and not measured, and it remains unmeasured because enamel's config is healthy. DO NOT chase journal capture and do not build a verdict on a body line from a oneshot. AGREED FIX SHAPE, bin-venus-cc owns it (shared EVO-94 template, mars/venus/terra/enamel): assert in-process, exit non-zero. The exit code was correct in every single run; only stdout was unreliable. Parse ok and exit 65 on false, so systemd's own Result carries the run's verdict without needing the body at all. The underlying gap stands and is what the fix closes: a unit whose health is read from the transport status while its verdict lives in the response body is asserting reachability and calling it success. Enamel's queue is empty so no delivery was ever affected.
-
OUTBOX RE-MEASURED LIVE 2026-10-04 (SELECT-only on eprpywqwzsvrjeaforei) AND TWO OF THE THREE OWED ITEMS HAVE CHANGED STATE. emailOutbox now holds 6 rows, not 2. New since the 09-28 note: four terra accessIssue rows created 2026-10-01 at 12:29:45, 12:30:24, 12:32:39 and 14:04:23, every one of them sendStatus=failed with attempts=6 and sentAt null. WHAT THAT SETTLES AND WHAT IT DOES NOT. The failure-finalize path and the MAX_ATTEMPTS cap have now executed in production four times and the cap held exactly - 6 attempts, terminal, no seventh. But those rows were drained by TERRA's instance against a different URL and different deployed code (EMAILDRAIN_TERRA_URL, GET, versus enamel's POST), so this is NOT evidence about enamel's drain. ENAMEL's drain has still claimed zero rows, ever. Owed item (1), the two-worker lease-expiry race, remains ANSWERED BY READING and unexercised. Owed item (2), db-enamel's cardinality and plan review, is still gated on real rows: n=6 across two tenants is not a cardinality sample. The cause of the four failures is terra's and already filed (TERRA-260, missing SMTP_REPLY_TO), not this row's. OWED ITEM (3) IS NO LONGER HYPOTHETICAL, AND PHASE A NEEDS NOTHING FROM ELAZAR. All 6 rows retain bodyText, bodyHtml and subject - including the 1 sent row and all 4 terminal failures. Five rows sit in a terminal state carrying full message content plus recipient addresses, and the four from 10-01 are the notification mails of three real locked-out people (TERRA-242, 10-01). The 09-11 ruling keyed Phase A on TERMINAL STATE and explicitly never on age, which is exactly why it needs neither the dispute window nor the legal retention number - only Phase B and Phase C need those. Phase A is implementable today and remains the largest single available reduction in stored personal data on this table. TWO THINGS GATE PHASE A, NEITHER OF THEM A NUMBER. (a) Class-A under Push-to-Done: the email-send path requires an audit design-ping BEFORE implementation, and enamel has no registered auditor, so the ping goes to audit-general-ca. (b) emailOutbox is SHARED with terra and five of the six content-carrying rows are theirs. A redaction pass written by enamel that nulls terra's bodies is an enamel write against another tenant's data, so it must either scope to appKey=enamel or be agreed with coder-terra-cc first. Scoping to appKey=enamel makes the control real but leaves every row that currently carries content untouched - stating that plainly rather than shipping it as coverage. COVERAGE OF THIS READ: emailOutbox in full (all 6 rows, every retention-relevant column) and appEvents for paciente actions. NOT exercised: enamel's drain against a live row, the SMTP transport, and the mars/venus/terra legs of the shared EVO-94 unit.
-
PHASE A PREDICATE SHARPENED BY coder-terra-cc 2026-10-04, AND IT CORRECTS THE 09-11 RULING ON ONE POINT. They agreed to a SHARED pass keyed on terminal state, with one exclusion: hold terra's four 10-01 failed rows until TERRA-260 decides whether to resend them once SMTP_REPLY_TO exists, because A RESEND NEEDS THE BODY. Their other terminal row may be redacted. WHAT THAT EXPOSES IN MY OWN RULING. The 09-11 ruling keyed Phase A on TERMINAL STATE and justified it as safe because retry is impossible past terminal. That is true of the SENT arm and false of the FAILED arm: a row that exhausted MAX_ATTEMPTS is terminal to the drain and is exactly the row a human may choose to resend once the cause is fixed. Keying on terminal state alone would therefore destroy the only copy of a message somebody is still deciding about - the same shape as the constraint I put on Phase A for a different reason (never inside the send transaction, so a status bug cannot become data loss). REVISED PREDICATE, and it is narrower on purpose: redact on the SENT arm (sentAt AND messageId both non-null). The send happened, no resend path exists through this table, and nothing operational reads subject/bodyText/bodyHtml afterwards. A FAILED row keeps its body until something explicitly retires it - a resend decision made, or declined, recorded on the row rather than inferred from attempts=cap. There is no such marker today, so the failed arm is OUT of Phase A scope and a Phase A-prime that retires it needs its own decision. MEASURED CONSEQUENCE: of the 6 rows, exactly ONE is on the sent arm (terra accessIssue, 2026-09-11, sentAt set, attempts 1). So Phase A as revised redacts one row today, and that is the honest size of it - the four 10-01 rows are held by terra's request and enamel's single row is a soft-deleted probe that never sent. Shipping this as a retention control means shipping a control whose first real exercise is the next successful send, enamel's or terra's. Amendment relayed to audit-general-ca, whose design-ping describes the terminal-state keying this supersedes. Not implemented; awaiting the Class-A design response.
-
PHASE A CONDITIONS VERIFIED LIVE 2026-10-04 (SELECT-only). audit-general-ca gave a conditional design GO with five conditions; I re-derived every schema claim in it myself rather than accepting the citation, and all of them hold, plus three facts their read did not have. CONFIRMED EXACTLY AS STATED: emailOutbox.subject is NOT NULL with ck_emailOutbox_subject_notblank; bodyText is NOT NULL with ck_emailOutbox_bodyText_notblank; bodyHtml is already nullable with no CHECK. So a bare UPDATE to NULL fails on two columns and two constraints, and Phase A is a migration before it is a pass. vEmailOutbox exists and does expose subject. THREE THINGS THEIR READ DID NOT HAVE. (1) THE INVARIANT THEY ASKED FOR IS ALSO WHAT KEEPS THE APP'S TYPES HONEST. The drain types the claimed row as subject: string (route.ts:224, returning clause at :251). Relaxing NOT NULL makes that type a lie IF a redacted row can ever be claimed. A CHECK of the form 'content present OR sendStatus is terminal' makes the type true again by construction, and a requeue of a redacted row then fails loudly instead of sending an empty message. That is the difference between the invariant as a constraint and as a convention - and it is why it must be a CHECK, not a trigger. (2) vEmailOutbox HAS NO READER IN ENAMEL'S APP TREE - zero files reference it, so the UI-typing condition has no enamel-side consumer. Its ACL is {postgres, service_role} only, so nothing anon/authenticated reaches it either. The view is already content-minimized by construction: it exposes subject, failureCode, lastError and recipient COUNTS, and no bodyText, no bodyHtml, no addresses. Whether terra's app reads it is NOT verified from here - service_role could. (3) THEIR RESIDUAL ON lastError IS LIVE-FALSE TODAY, and the reason is itself a gap on another lane: all four failed rows carry lastError NULL. So the only PII residual on current rows is recipientsTo, not lastError. Separately, a failed row with no lastError means terra's failure finalize records no error text at all - the failure reason exists only as failureCode. Theirs, not mine, and named rather than fixed here. PREDICATE AND SCOPE SETTLED, and it dissolves the shared-vs-scoped question rather than answering it: the pass redacts where appKey = its OWN app AND sendStatus='sent' AND sentAt IS NOT NULL AND messageId IS NOT NULL. Measured: exactly ONE row in the whole table qualifies (terra's 2026-09-11 accessIssue, messageId present), and the four 10-01 failures are excluded by the predicate itself, not by an exception list - messageId is NULL on all four. Because each app's drain is its own deployment with its own appKey, TERRA'S ROWS GET REDACTED BY TERRA'S DRAIN, never by enamel's. I never write another tenant's row. The SHAPE is shared; the EXECUTION is per-app. That satisfies audit's 'do not sweep terra's five' condition structurally, and coder-terra-cc's hold on the four failed rows survives even if they adopt the pass tomorrow. SPLIT: the migration (relax two NOT NULLs, replace both notblank CHECKs with null-or-nonblank, add the terminal-or-content CHECK, add redactedAt) is a DB artifact on a SHARED table - filed to db-enamel-ca. The pass itself, its appEvents row carrying counts only, and the drain-side tests are mine and follow the migration.
-
CORRECTION TO MY OWN PREDICATE, FROM audit-general-ca's Class-A PASS AND VERIFIED IN THE FILE BEFORE ACCEPTING IT. I had written the Phase A predicate as sendStatus='sent' AND sentAt IS NOT NULL AND messageId IS NOT NULL, and called the messageId clause a conservative subset. It is not one in enamel's code path. transport.ts:190 returns messageId: info.messageId ?? '' - an EMPTY STRING when the SMTP server returns no id. The column has no notblank CHECK, so an enamel-written sent row carries '' and satisfies IS NOT NULL. The clause therefore excludes nothing enamel writes and is not evidence of delivery; it would only exclude a row from a writer that stores NULL, which is terra's path, not ours. A predicate that reads as a safety margin and filters nothing is the same failure class as the vacuous pre-push gate on ENAMEL-63: it passes while asserting less than it appears to. REVISED, FINAL: redact where appKey = the app's own AND sendStatus = 'sent' AND subject IS NOT NULL. sentAt is redundant with sendStatus by ck_emailOutbox_sent_paired (they are tied as an equivalence, verified live), so naming both asserts one fact twice. subject IS NOT NULL is the idempotence clause, not a safety one. messageId is OUT of the predicate entirely and is PRESERVED on the row, together with attempts, claimToken, sentAt and the status - per audit's condition and because they are the audit trail the 09-11 ruling deliberately keeps. AND THE HONEST SIZE OF IT, now that the scoping is per-app: enamel's qualifying count today is ZERO. Enamel's only emailOutbox row ever is the soft-deleted pending alarmProbe. The single sent row in the table is terra's, and under per-app drain scoping enamel never touches it - terra's drain redacts terra's rows if terra adopts the pass. So Phase A ships as a control whose first exercise is enamel's first successful send, and that is what the coverage statement must say. audit-general-ca's note that terra need not be waited on is accepted and moot in the same breath: there is nothing of terra's in enamel's scope to wait for. SEPARATE, SMALL, NOT FIXED HERE: messageId has no notblank CHECK, so '' and NULL are both storable and mean different things to a reader ('the server returned nothing' versus 'this row never sent'). Same blank-versus-absent axis the outbox's other columns are protected on. Filed as a known gap on this row rather than widened into ENAMEL-66, which is a shared-table migration already under audit.
-
PHASE A APP HALF PUSHED 2026-10-05 at 61ddfef, app tree, three files, suite 281/281. DB half applied by db-enamel-ca as schema_migrations 20261004220703 on the digest I gave the coder-lane GO for (f71b94c / MD5 2ba1a0870856427e1e71f788168023ec); I verified live that subject, bodyText, bodyHtml and redactedAt are all nullable and that ck_emailOutbox_terminal_or_content and ck_emailOutbox_content_redacted_at both exist. WHAT RUNS. redactSentContent() in the drain, its own transaction, after the claim loop, bounded by REDACTION_DEADLINE_MS, and a failure logs redactionUnconfirmed and does NOT fail the drain. It sets subject, bodyText and bodyHtml to NULL and stamps redactedAt where appKey is ours AND sendStatus='sent' AND subject IS NOT NULL. It runs after the loop on purpose, so a row sent in this very run is redacted in the same run - content lifetime is the whole point. THREE SCOPE DECISIONS, each with the reason in-file. (1) Own appKey only: 5 of the 6 rows on this co-tenanted table are terra's, each app's drain runs with its own URL and bearer, so the SHAPE is shared and the EXECUTION per-app - enamel structurally cannot redact a neighbour's row and no cross-tenant agreement is needed. (2) The SENT arm only, not every terminal state: a row at attempts=6 with sendStatus='failed' is exactly the one a human may resend once TERRA-260's missing SMTP_REPLY_TO is fixed, and a resend needs the body, so the failed arm waits for an explicit retirement marker that does not exist. (3) recipientsTo retained - that is Phase B, gated on the dispute window, one of the two numbers that are Elazar's. So a redacted row still proves WHO was mailed and WHY while retaining nothing of WHAT was said. NO deletedAt FILTER, deliberately, and it is the only query in the drain without one: a soft-deleted row still holds the content and retention does not care that a UI stopped showing it. TELEMETRY. One sentContentRedacted event per non-empty pass carrying counts by emailClass and nothing else - no outbox id, no subject, no recipient - built from emailClass alone rather than spread from the query rows, since the realistic leak is someone passing whole rows through once the SELECT list grows. Suppressed entirely on a zero pass: the drain fires every 5 minutes and unconditional reporting is 288 rows/day into the shared unprunable appEvents. Deliberately narrower than leaseExpiredSwept, which does carry an outboxId - that row still exists and may need chasing; a redacted one is closed. CONTROLS FORCED RED, not accepted green. Replacing the appKey clause with a tautology fails the scope test; spreading the query rows into the summary fails both leak tests. Restored, 281/281 across 11 files. WHAT THIS DOES NOT PROVE, and it is the whole coverage statement: enamel has never sent an email, so the qualifying set is EMPTY and this pass will redact nothing until enamel's first successful send. The UPDATE, the CHECK interaction, the event and the counter are all unexercised against a real row. The code is reversible; its effect is not - but there is currently nothing for it to destroy. STILL OPEN ON THIS EPIC: Phase B and C, both gated on Elazar's two numbers (ENAMEL-1); db-enamel's cardinality/plan review, still gated on real rows existing; and the shared evolutiva-emaildrain unit asserting the transport rather than the work (pmmaster's, raised 2026-09-28).
-
PHASE A AUDIT FIXES PUSHED 2026-10-05 at 743c728, four files, 286/286. audit-general-ca blocked 61ddfef post-push with one MED and one LOW; both fixed, plus a third I found while fixing them. MED, emailClass repeated into appEvents. emailClass is UNCONSTRAINED TEXT at every layer: enamel_026 checks only nonblank, EnqueueEmailInput types it as plain string, and enamel's app tree contains ZERO callers of enqueueEmail - the one enamel row that has ever existed was written by the host-side alarm. So every value the drain reads is produced outside this codebase's type system, and a value holding an address, a name or a subject was copied verbatim into appEvents - 365-day retention floor, no retraction path - at the exact moment the outbox content was being destroyed. The fix is an ALLOWLIST, not a sanitiser: REPORTABLE_EMAIL_CLASSES (alarmProbe, accessIssue, accessRequest) with safeEmailClass() collapsing everything else to 'unknown'. A regex or a length cap guesses at what an identifier looks like; a fixed key set cannot be wrong about a value it does not contain, and the failure direction is a missing label rather than a leak. accessRequest is allowed ahead of its first caller because it is the example named in EnqueueEmailInput's own contract. THE FINDING REACHED SEVEN SITES, NOT THE ONE IT WAS RAISED ON. The drain already wrote row.emailClass verbatim into emailSent, mailerSkippedRecipient, the config-failure arm, both retry arms and leaseExpiredSwept - all pre-existing, all shipped weeks ago. Fixing only the line the audit cited would have left six live instances of the same defect. This is the sibling-sweep rule paying out: a fix under a shared gate does not reach the siblings unless you go and look. THIRD, FOUND BY ME WHILE FIXING THEIRS, AND LARGER THAN THE REPORTED ONE: mailerSkippedRecipient wrote the FULL blocked recipient address into appEvents. A real person's email address, in the one table ENAMEL-16 Phase A exists to keep content out of, on every blocked recipient. Now recipientDomain via telemetryDomain() - domain only, lowercased, and 'invalid' for a malformed address rather than echoing arbitrary text, since a malformed address is exactly the case where the string may be anything. No diagnostic power lost: the domain is what both the admission allowlist and the egress guard key on. The full address remains on the outbox row in recipientsTo until Phase B retires it; what is removed is the copy that outlives the row and cannot be retracted. LOW, redactionUnconfirmed. The old message asserted that qualifying rows remain qualifying next run. The deadline stops the drain WAITING; it does not cancel the transaction, so the UPDATE may still have committed server-side. The message now names both arms: this run's redactions are unaccounted for, and only rows it did not reach remain qualifying. CONTROLS FORCED RED. Returning emailClass verbatim fails two tests; returning the address verbatim fails two. Restored, 286/286 across 11 files. COVERAGE, UNCHANGED BY THESE FIXES: enamel has never sent an email, so the redaction pass still has an empty qualifying set, and the six pre-existing emailClass sites plus the blocked-address site all need a claimed row to execute. Every pure function around them is tested; none of the write paths has run in production.
-
Phase A app half audit cycle closed across three findings on the same few lines of blocked-recipient telemetry. ded6860 added a strict domain-shape validator after audit-general-ca's second MED (splitting on '@' is not validation: 'ana@demo.Ana Gomez.local' had been yielding the 'domain' 'demo.ana gomez.local', a person's name, and arriving through the BLOCKED path since the 'demo.' prefix is what blocks it). fe7e2c5 then removed recipientDomain from appEvents entirely on audit-general-ca's third ruling, which is a privacy-policy boundary and not a regex defect: 'ana-gomez.com' is a flawlessly shaped domain identifying one person, so no parser settles it. Phase B exists to take recipient identifiers OFF the outbox row, and appEvents has a 365-day retention floor with no retraction path, so a domain written there stands up the second surface months before the first is retired. telemetryDomain deleted; domainOf is private again and feeds the guards only; guards untouched, since narrowing what they match on changes WHICH mail is blocked, a different decision from what gets logged. The event's shape is buildBlockedRecipientDetail in outbox.ts, which takes no address at all - structural omission, not a filter that only has to be forgotten once. 287/287, new controls forced red. COVERAGE: source-only. Enamel has never sent an email, so the redaction UPDATE, its CHECK interaction, the sentContentRedacted event and all seven safeEmailClass sites remain unexercised against a real claimed row.
-
audit pass: Phase A app half, source-only Class-A re-review of fe7e2c5 by audit-general-ca: recipientDomain/telemetryDomain removal, blocked-recipient detail built with no address parameter, safeEmailClass gating the only free-text class, recipient guards unchanged
payload
{"scope":"Phase A app half, source-only Class-A re-review of fe7e2c5 by audit-general-ca: recipientDomain/telemetryDomain removal, blocked-recipient detail built with no address parameter, safeEmailClass gating the only free-text class, recipient guards unchanged","outcome":"pass","evidence":"audit-general-ca-muuiqyhuxg4i-coder-enamel-cc, 2026-10-04 21:35 ART. Full app suite reran by the auditor: 287/287. Resolves their 61ddfef, 743c728 and ded6860 findings. STATED LIMITS: deployment identity was coder-reported and not independently checked by audit (my own read: gh commit statuses for fe7e2c5 state=success deployment HuLrekGAWRQfervH3RXZm1LdnEMZ, /api/health dpl_HuLrekGAWRQfervH3RXZm1LdnEMZ); and enamel holds no sent row, so the redaction UPDATE, its CHECK interaction, the sentContentRedacted event and claimed-row telemetry are all unexercised live. Not proof of first-send behaviour.","reportRef":null} -
Phase A is DONE and audit-passed (app half fe7e2c5 + b1c5bdb lineage, DB half enamel_075/20261004220703, PASS audit-general-ca-muuiqyhuxg4i, 287/287). The EPIC is not complete: Phase B (redact recipient identifiers after the dispute window) and Phase C (delete) both need TWO NUMBERS THAT ONLY ELAZAR CAN SET - the dispute window and the legal-retention floor - and those are unrouteable over the hub, which is why Phase A was deliberately built to key on TERMINAL STATE and need neither. Also still owed and not mine to force: db-enamel's cardinality/plan review, gated on real rows existing rather than on a clock; and the shared evolutiva-emaildrain@.service asserting the TRANSPORT rather than the WORK (a 200 with ok:false reads identical to a successful drain), raised 2026-09-28, pmmaster's row since the unit is the fleet EVO-94 template. Status was 'backlog' which understated it; blocked names the actual dependency.