Files
pad/internal/store/nul_trigger_header.go
T
xarmian f5b0062d6e fix(store): NUL Layer B covers columns added after 084 through later generated trigger files (BUG-3108) (#1506)
* fix(store): NUL Layer B covers columns added after 084 through a later generated trigger file (BUG-3108)

The trigger generator had one hardwired target, 084, which runs before every
later migration's columns exist, so every post-084 column could only be
baselined. The trigger files are now an ordered list; each protected column is
assigned to one, 084 by default. The generator writes every file and the pin
test compares every file. The startup re-assertion wants and re-executes only
the files already applied, because it runs after every migration, including the
ones before a later column exists.

094 carries the three post-084 columns that hold caller text: items.lease_holder
(request body, verbatim), item_relation_links.source_field_key (a schema field
key) and item_decisions.model (the admin model pin). 084 is byte-identical.

TestNULTriggerFilesFollowTheirColumns walks the migration chain one file at a
time and fails when a column's trigger file does not run after the migration
that introduced it, naming the fix. The next post-084 column therefore fails
there instead of going quietly into the baseline.

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N

* test(store): a NUL trigger restore does not re-execute a trigger file that is not yet applied (BUG-3108)

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N

* fix(store): item_relation_links.source_field_key stays out of Layer B on migration 088's derivation, now recorded as an exclusion (BUG-3108)

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N

* docs(store): ScanNUL's cost comment counts the two post-084 protected columns (BUG-3108)

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N

* fix(store): later trigger files get their own name prefix so an older binary cannot drop them; the ordering instrument takes a column's latest appearance (BUG-3108)

An older binary's restore reads triggers by GLOB 'pad_nul_*' and drops any it
does not expect. Had 094's triggers used that prefix, a binary predating 094
started with --force would have removed them all. Each later file now uses
pad_nul<NNN>_, and the restore reads one GLOB per file it knows.

columnIntroductions records the last time a column appeared after being
absent, so a column dropped and re-added, or a table renamed and replaced,
counts as introduced again. The instrument is parameterised so a synthetic
chain can show that rule going red.

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N

* docs(store): the source_field_key exclusion names the check that covers it, a decoded value (BUG-3108)

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
2026-09-24 08:13:59 -04:00

58 lines
3.0 KiB
Go

package store
// nulTriggerMigrationHeader is the generated migration's preamble.
//
// It lives here rather than only in the .sql file so the generator and the file
// share one definition — the pin test compares the whole rendered text, header
// included, which is what makes a hand edit to the file detectable.
const nulTriggerMigrationHeader = `-- Layer B of the NUL invariant (DOC-2823 S2): the database owns the rule.
--
-- GENERATED from internal/store/nulcolumns.go. Do not edit by hand — run
-- GEN_NUL_TRIGGERS=1 go test ./internal/store/ -run TestGenerateNULTriggerMigration
-- and commit the result. TestNULTriggersMatchTheList fails if this file and the
-- Go list disagree.
--
-- WHY TRIGGERS AND NOT ONLY THE GO GUARD. S1's Layer A makes the invariant a
-- property of the BINARY. BUG-2813 is the window where that is not enough: an
-- older binary serving the same SQLite file has no such guard, and a rollback,
-- a staged rollout or a second instance writes rows the invariant forbids.
-- A trigger is enforced by the FILE, so it holds for every writer.
--
-- SQLITE ONLY. Postgres refuses a NUL in text natively (SQLSTATE 22021) and an
-- escape decoding to one in jsonb (22P05), so the database already owns the
-- rule there; adding triggers would duplicate an existing guarantee.
--
-- PREDICATE, measured on modernc.org/sqlite v1.57.0 / SQLite 3.53.3 in
-- TASK-2824 rather than assumed:
-- * instr(col, char(0)) finds a real NUL. length() does NOT — it C-truncates
-- at the NUL — so instr is the only builtin to trust here.
-- * json_tree DECODES the six-character escape, exposing a real NUL in both
-- the value and key columns, and a DOUBLED backslash stays literal text
-- with no false positive.
-- * a Go-bound real-NUL parameter reaches the trigger intact, which is what
-- makes BEFORE INSERT the right shape: an old binary's write is seen.
--
-- No DB-side repair is attempted, deliberately: the same measurement shows
-- SQLite's string functions disagree about NUL-bearing text, so transforming
-- such a value in SQL cannot be trusted. Repair is S3, in Go.
`
// nulLaterTriggerMigrationHeader is the preamble of every trigger file after
// the first. The reasoning is 084's and is not repeated; what differs is why
// the file exists at all.
const nulLaterTriggerMigrationHeader = `-- Layer B of the NUL invariant (DOC-2823 S2), for columns added after 084.
--
-- GENERATED from internal/store/nulcolumns.go. Do not edit by hand — run
-- GEN_NUL_TRIGGERS=1 go test ./internal/store/ -run TestGenerateNULTriggerMigration
-- and commit the result. TestNULTriggersMatchTheList fails if this file and the
-- Go list disagree.
--
-- WHY A SEPARATE FILE (BUG-3108). A trigger can only follow its column. 084
-- runs before these columns exist, and SQLite re-parses every trigger during
-- an ALTER TABLE ... RENAME, so a trigger there naming a later column breaks
-- the next table rebuild. The predicate, the marker and the SQLite-only scope
-- are 084's; its header carries the measurements.
`