fix: enforce NOT NULL on 20 server-defaulted timestamp columns

Audit of models vs fresh DB vs production (all at 0019): 20 columns are
NOT NULL in the models but nullable in the database, all timestamps with
DEFAULT now() created by 0011/0012/0014/0017 without nullable=False.

Classified DB_CONSTRAINT_MISSING: no application path writes NULL to
them (inserts omit them, updates set now; no raw SQL inserts), and there
were zero NULLs in production and in the development database (246,682
rows checked). The upgrade aborts without changing anything if any NULL
is present, instead of inventing a value.

After this revision a fresh database matches the models on nullability,
column types, PKs, FKs, uniqueness and checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
gti
2026-09-23 07:20:04 -03:00
co-authored by Claude Opus 5.5
parent b6b45c703b
commit 6bcfac6497
@@ -0,0 +1,63 @@
"""Enforce NOT NULL on server-defaulted timestamps the models already require.
0011, 0012, 0014 and 0017 created these columns with ``server_default=now()``
but without ``nullable=False``, so the database accepted NULL while the models
declare them non-optional. No application path writes NULL to them (inserts
omit them and rely on the default; updates set ``now``), and before this
revision was written no NULL existed in production or in the development
database.
The upgrade refuses to run — changing nothing — if any NULL is found, instead
of guessing a value for it.
Revision ID: 0020_not_null_timestamps
Revises: 0019_fix_timestamp_defaults
"""
from alembic import op
import sqlalchemy as sa
revision = "0020_not_null_timestamps"
down_revision = "0019_fix_timestamp_defaults"
branch_labels = None
depends_on = None
COLUMNS = (
("admin_users", "created_at"),
("agent_events", "occurred_at"),
("agent_snapshots", "updated_at"),
("ai_eval_cache", "created_at"),
("ai_runs", "started_at"),
("ai_work_items", "created_at"),
("ai_work_items", "updated_at"),
("audit_events", "occurred_at"),
("fiscal_context_entries", "created_at"),
("fiscal_context_entries", "updated_at"),
("installations", "created_at"),
("installations", "updated_at"),
("kb_knowledge", "created_at"),
("kb_observations", "observed_at"),
("kb_products", "created_at"),
("kb_products", "updated_at"),
("tenant_product_context", "created_at"),
("tenant_product_context", "updated_at"),
("web_sessions", "created_at"),
("web_sessions", "last_seen_at"),
)
def upgrade():
bind = op.get_bind()
offenders = []
for table, column in COLUMNS:
nulls = bind.execute(sa.text(f'SELECT count(*) FROM "{table}" WHERE "{column}" IS NULL')).scalar()
if nulls:
offenders.append(f"{table}.{column}={nulls}")
if offenders:
raise RuntimeError("0020 aborted, NULL values present (fix them explicitly first): " + ", ".join(offenders))
for table, column in COLUMNS:
op.alter_column(table, column, nullable=False, existing_type=sa.DateTime(timezone=True), existing_server_default=sa.func.now())
def downgrade():
for table, column in reversed(COLUMNS):
op.alter_column(table, column, nullable=True, existing_type=sa.DateTime(timezone=True), existing_server_default=sa.func.now())