Files
pad/internal
xarmian 054b192e5d feat(store): make the NUL invariant a property of the DATABASE (DOC-2823 S2)
Closes BUG-2813's old-binary half. S1 put the rule in the binary, which cannot
help in the window this bug is about: an older binary serving the same SQLite
file has no guard, so a rollback, a staged rollout or a second instance writes
rows the invariant forbids. A trigger is enforced by the FILE.

194 BEFORE INSERT/UPDATE triggers over 97 columns — TASK-2825's 86-column
census plus the second ring Ruling 2 admitted wholesale.

ONE LIST, THREE CONSUMERS, which is the shape TASK-2825 asked for. The
migration is GENERATED from internal/store/nulcolumns.go, a guard test compares
that list against the LIVE migrated schema, and a second test pins the
generated SQL against the list so neither can drift from the other by hand.

The census guard is a BASELINE rather than a demand that all 405 text columns
be classified. The census classified the 86 that can carry caller text and left
ids, timestamps and hashes unenumerated; requiring an entry for each would be
300 lines nobody reads. The 301 known-outside columns are recorded, and ANY
change to that set — added, renamed, removed — fails and asks for a decision.

The predicate is TASK-2824's, measured on the driver Pad actually embeds:
instr for the raw byte (length() C-truncates and cannot be used), and
json_tree for the decoded escape, in values AND keys, with a json_valid guard
because json_tree raises on a non-document. The doubled-backslash case stays
literal, which is the false positive this predicate family exists to avoid.

Trigger aborts are classified into the SAME typed error Layer A produces, so a
caller cannot tell which layer refused and the handler's existing 400 mapping
covers both. That discharges Ruling 2's condition for admitting the second
ring: a header-derived user agent or IP hitting a trigger must not surface as a
500 or a broken login.

THE FOURTH LEG IS LIT. S1 built the differential harness with three legs and
left this one dark; all 17 corpus cases now agree across the HTTP gate, Layer A
at the driver, Layer B via a real UNGUARDED SQLite write, and native Postgres.
Four independent enforcers, one corpus — the property DOC-2823 named as the
actual deliverable.

Two consumers needed changes, both legitimate. The FTS trigger census excludes
the new triggers by prefix, so adding a protected column does not require
editing that test. And a timeline test that INJECTS a NUL to build its fixture
now brackets the injection by dropping and restoring the two triggers — it
needs a row that violates the invariant, because what it tests is the handling
of legacy data, and Layer B exists to stop such a row being written.

SQLite only. Postgres refuses a NUL in text natively and an escape decoding to
one in jsonb, so it already owns the rule there.

Full Go suite green on SQLite and Postgres 17; lint 0 issues.

Claude-Session: https://claude.ai/code/session_01XLtX4dbjBpApbAv3SuBcTm
2026-09-01 21:16:17 +00:00
..
2026-03-26 01:52:36 +00:00