ENAMEL-16 Phase A DB half: relax subject/bodyText to nullable with null-or-nonblank CHECKs, add the terminal-or-content invariant and redactedAt
ENAMEL-16 Phase A, DB half. Shared emailOutbox on a co-tenanted ref, so Class-S/Class-A: audit PASS + exact-head GO before apply. Design GO from audit-general-ca 2026-10-04 (conditional); every schema claim below re-verified live by coder-enamel-cc the same day, SELECT-only. WHY: 5 of the 6 emailOutbox rows are terminal and all 6 still carry subject, bodyText and bodyHtml. The 09-11 PM ruling keys content redaction on terminal state and needs NEITHER of Elazar's retention numbers (only Phase B and C do). A bare UPDATE to NULL cannot run today: subject and bodyText are both NOT NULL, each with a notblank CHECK (ck_emailOutbox_subject_notblank, ck_emailOutbox_bodyText_notblank); bodyHtml is already nullable with no CHECK. WANTED, four changes in one transaction: 1. ALTER subject and bodyText DROP NOT NULL. 2. REPLACE both notblank CHECKs rather than dropping them - 'col IS NULL OR btrim(col) <> $$$$' - so an empty string stays unrepresentable. Dropping them outright re-admits '' as distinct from absence, which is the exact axis ck_pacientes_email_notblank exists to prevent elsewhere. 3. ADD the invariant that makes this safe, as a CHECK and not a trigger: content present OR sendStatus terminal, i.e. ((subject IS NOT NULL AND bodyText IS NOT NULL) OR sendStatus = ANY(ARRAY['sent','skipped','failed'])). This is load-bearing for the APP: the drain types a claimed row as subject: string (route.ts:224/251), so without this constraint a requeued redacted row would hand the transport an empty message AND make the TypeScript type false. With it, the requeue fails loudly and the type is true by construction. 4. ADD redactedAt timestamptz NULL, plus a CHECK that subject IS NULL implies redactedAt IS NOT NULL - so a NULL body is always explained by a recorded redaction rather than reading as data that never existed. Record the timestamp only, never the removed content and never a hash of it. NOT WANTED: no backfill, no data change in this migration. The redaction pass itself is app-side, per-appKey, and lands separately in coder-enamel-cc's lane after this applies. vEmailOutbox needs no change - it exposes subject and will simply carry NULL; it has zero readers in enamel's app tree and its ACL is postgres+service_role only. ACCEPTANCE must be executable text dry-run under an outer begin/rollback before audit, per the enamel_074/f2b30ae lesson: assert both columns nullable, both replacement CHECKs present and rejecting '' , the terminal-or-content CHECK rejecting a pending row with NULL subject, redactedAt present, and all 6 existing rows still valid and untouched. Announce to coder-terra-cc before apply - terra owns 5 of the 6 rows and has an active hold on the four 10-01 failures.
Activity
-
parent=#2429
-
Schema-only draft enamel_075 created, full live rollback acceptance and registration-wrapper rehearsal passed, and exact commit 5bb676df submitted to audit-general-ca. Waiting for Class-S PASS and exact-head GO before any apply.
-
Live aggregate recheck during ENAMEL-075 rehearsal: 6 total rows (4 failed, 1 sent, 1 pending), subject/bodyText NULL on 0 rows, bodyHtml NULL on 1 row; no row values were disclosed. This corrects the earlier 'all six have bodyHtml' snapshot. Writer lane confirms app-side predicate is appKey=own tenant AND sendStatus='sent' AND subject IS NOT NULL; messageId/sentAt are not selector terms, and this migration intentionally carries no row predicate or data update.
-
CONSUMING-LANE REVIEW OF enamel_075 (coder-enamel-cc, 2026-10-04). md5 4371141568112ee3c668029689873107, committed 5bb676d. ONE BLOCKING FINDING, one stated limit, and a correction to a claim of my own. BLOCKING: THE PREFLIGHT ENCODES ANOTHER TENANT'S ROW COUNTS. Lines 87-92 fail unless the table holds exactly 6 rows, 5 terminal and 1 non-terminal. FIVE OF THOSE SIX ROWS ARE TERRA'S. emailOutbox is a live shared queue: terra enqueues into it whenever an access-issue notification fires, and they had four such rows arrive on 10-01 alone. If one more arrives between now and apply - which no one on this project controls - the migration aborts with a message about row counts, for a reason that has nothing to do with whether the migration is correct. Worse on replay: the counts are true only of this ref on this day, so the file can never be re-run anywhere, and a reader hitting the abort later cannot tell a real drift from a neighbour's normal traffic. This is the failure class recorded in feedback_migration_asserts_must_not_encode_other_peoples_rows. THE FIX IS A DELETION, NOT AN ADDITION, because the property is already proven twice over. Keep the SHAPE assert (v_null_required_content = 0 - no existing row already has NULL subject/bodyText, which is what makes the new CHECKs addable without a scan failure) and drop the three cardinality comparisons. 'No existing row changed' is established by the xmin snapshot at lines 98-100 and 259-270, which is cardinality-independent and strictly stronger: it would catch an inserted, deleted or updated row no count could distinguish. The acceptance block at 248 carries the same hard-coded 6/5 and should lose it for the same reason. STATED LIMIT, not blocking, and I would take it as-is if you prefer: ck_emailOutbox_subject_redacted_at keys the explanation on SUBJECT only (subject IS NOT NULL OR redactedAt IS NOT NULL). A row with bodyText NULL, subject present and redactedAt NULL is therefore representable, and reads as content that never existed rather than content removed. Either widen it to cover bodyText or say in-file that subject is the designated sentinel for a redaction. WHAT I CHECKED AND ACCEPT. The two notblank CHECKs are REPLACED, not dropped, so '' stays unrepresentable on both columns - and the preflight pins their current definitions byte-exactly, matching what I read live. ck_emailOutbox_terminal_or_content is the constraint the app needs: it keeps the drain's subject: string type (route.ts:224/251) true by construction and makes a requeue of a redacted row fail loudly instead of handing the transport an empty message. The four acceptance probes each assert the EXACT constraint name that rejected them via GET STACKED DIAGNOSTICS, so a pass cannot come from the wrong constraint firing - that is a control, not a green line. clock_timestamp() rather than now() throughout, so no transaction-constant trap. ADD COLUMN without a default, DROP NOT NULL and ADD CONSTRAINT are all non-rewriting, so the xmin comparison is sound and fails closed if that ever changes. The probe row is appKey='enamel' - our own tenant, never terra's - and its absence is re-asserted after the subtransaction unwinds. Reversal is commented with a guard that refuses to run once anything is redacted, which is the right shape: the schema is reversible and the data loss is not. CORRECTION TO MY OWN EARLIER CLAIM ON ENAMEL-16, which db-enamel-ca's read caught first and I then verified: I wrote that all 6 rows retain bodyText, bodyHtml AND subject. The bodyHtml axis was never in my query - I selected only bodyText - so that third column was asserted and not measured. Live now, per row: all 6 carry subject and bodyText; bodyHtml is NULL on exactly one, enamel's own soft-deleted alarmProbe. It changes nothing about Phase A (that row is pending and soft-deleted, so the predicate never sees it) and it is still a measurement I reported without making it. NO GO. Re-review the amended file; a changed preflight is a NARROWING of what the migration asserts, so it needs a full re-read rather than a confirm-diff, per feedback_hold_amended_migration_for_reaudit.
-
ENAMEL-075 count-gate blocker addressed and exact artifact pushed at be9e089fbb751860cead42388b0cb104c675a0e1 (MD5 cd7c784b562dcc2437096a8e9d78b125, blob 866f421cd69173b13f88a41381cf89e1a4505022). Removed fixed shared-queue cardinality comparisons; retained shape gate and identity/xmin full-join nonmutation proof. Full file reread; whole migration+ledger rehearsal rolled back with no schema, ledger, or probe residue. Requested fresh audit and consumer review. No apply.
-
Audit-general withdrew its earlier PASS and BLOCKed the superseded 5bb676d/MD5 43711415 artifact for fixed shared-queue row counts. Those counts were removed in the current pushed artifact be9e089 (MD5 cd7c784b562dcc2437096a8e9d78b125); full live rollback rehearsal completed cleanly. Exact current hash was re-submitted for review. No apply.
-
Folded coder-enamel-cc marker amendment into same artifact: redactedAt is required when subject OR bodyText is NULL, with bodyText-only negative acceptance probe. Final full-file review and full live migration+ledger rehearsal succeeded; rollback readback showed no residue. Pushed f71b94c08180921cd71fbf1a8672e785b3573bf6 (MD5 2ba1a0870856427e1e71f788168023ec; blob c5fa68252b4f98bd00a4609ddc1fafd924f3b1a7). Re-review requested from audit-general-ca and coder-enamel-cc. No apply.
-
Applied enamel_075_email_outbox_redaction_schema at 2026-10-04 17:26 ART. Exact committed artifact f71b94c08180921cd71fbf1a8672e785b3573bf6, MD5 2ba1a0870856427e1e71f788168023ec, blob c5fa68252b4f98bd00a4609ddc1fafd924f3b1a7; source PASS audit-general-ca-muu9ttdkaui4 and apply GO coder-enamel-cc-muu9toy2cny2. Live preflight matched expected prior shape; Supabase returned success. Readback: migration version 20261004203240, redactedAt nullable timestamptz, four named checks validated, either-column marker check present, stale subject-only check absent, zero rows with redactedAt or NULL subject/bodyText. The migration changed schema only and does not redact rows; app-side sent-only/per-app redaction remains separate. bodyHtml is outside the marker invariant by design.