deploy-gate: hash-chain enablement chains the whole backlog - owner decision 'no backlog chaining' (#107) is not implementable on main #139
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found preparing the QA vq03/vq04 upgrade pack for #107 (2026-11-13).
Owner decision (#107, 2026-11-13): "No backlog chaining: only rows written after enablement are chained."
What main does today (
3a59c3f6):AuditLog:HashChain:Enabled=truechains every unchained row, oldest month first.AuditLogHashChainpicks the next row withSELECT TOP (1) OccurredAtUtc FROM dbo.AuditLog WHERE ChainSeq IS NULL ORDER BY OccurredAtUtc(ConfigurationDatabase/Maintenance/AuditLogHashChain.cs:102).AuditLogHashChainOptionsonly hasEnabled,IntervalSeconds,BatchSize,MaxBatchesPerTickandHeadExport, so nothing limits the chain to rows written after enablement. The options' own remarks say "Enabling it on an existing database chains the historical backlog oldest month first".So with today's binaries the decision can't be carried out. Flipping
Enabledon DEV vd03 (owner: "now, after the SourceNode backfill") or on QA (after the #107 window) chains the whole backlog: QA has 3,441 rows from 2026-05-29 on, and DEV is similar. The chained rows'SourceNodeis then frozen. That undoes the decision, and the decision can't be reversed afterwards without a restore.Gate: don't set
AuditLog:HashChain:Enabledon DEV or QA until one of these is done:AuditLog:HashChain:ChainFromUtc(absolute) or a "chain from first enablement" anchor stored inAuditLogChainHead. Rows withOccurredAtUtcbefore the cutoff are never chained.verify-chainandverify-headsmust then treat pre-cutoff rows as out of scope instead ofUnchainedRows, and the filteredIX_AuditLog_Unchainedindex has to stop carrying them for ever. Decide what happens to late site rows whoseOccurredAtUtcis before the cutoff but that arrive after it (probably: chain them, because they were written after enablement; the key would then beIngestedAtUtc, notOccurredAtUtc).The QA migration window itself is not blocked: the chain stays off in that window, as decided.
Pack:
docs/deployment/deploy-prep/2026-11-13-qa-vq03-vq04/README.md§7.Fixed on
mainat52da02a7(docs follow-up3badd1f9). No migration.Design
dbo.AuditLogChainTombstoneat(cutoff month, ChainSeq 0).PurgedAtUtcholds the cutoff, which is the DB'sSYSUTCDATETIME().RowHash=SHA-256("ScadaBridge.AuditLog.ChainCutoff.v1" || YYYY-MM || ticks), andEventIdis the fixed value5cadab1d-c4a1-4c0f-a000-000000000139. The row is written once, inside the chaining transaction under the exclusivesp_getapplock. Both centrals therefore read the same instant regardless of node clocks or config, and it survives restarts. A config timestamp could drift between the two nodes, so I didn't use one. Real links start at 1, so slot 0 is "the link before the first". The existing table and grants cover it: the maintenance login writes it throughscadabridge_audit_purger's INSERT. This is pinned by a test.OccurredAtUtc >= cutoffare chained. The anchor hash is the genesis of the cutoff month (in place of 32 zero bytes), which binds the cutoff into that month's chain and into its exported head.AuditLog:HashChain:ChainBacklog, defaultfalse, which is the owner decision.truerestores chain-everything, but it is only read on the very first enablement. Once an anchor or any chained row exists, the decision is fixed. A legacy chain (heads, no anchor) keeps chaining everything as before. I kept the option because it is the only way to opt into full history coverage, and because it keeps legacy DBs and the existing tests on the old path.OccurredAtUtc(the partition key, indexed).IngestedAtUtcis a non-persisted JSON column that must not be filtered on. A site row dated before the cutoff that arrives after enablement stays out of scope. It is bounded by the site's store-and-forward backlog at that moment and is reported as such. The filteredIX_AuditLog_Unchainedkeeps the backlog rows until retention purges them. The chaining read seeks>= cutoff, so the cost is storage only.Verifier and purges
verify-chainnow splits unchained rows intoUnchainedRows(in scope) andPreCutoffRows(backlog, never a break), plusChainFromUtc. The CLI table printsChain fromandPre-cutoff rows. Months before the cutoff areNotChainedwith their rows counted as pre-cutoff.ChainSeq > 0tombstones, so the anchor survives. The head store tombstone count, head export andverify-headsignore it, so there is noCountMismatchorMissingLink. Per-channel purge across the cutoff tombstones only the chained rows. Archive verify takes the anchor from its own tombstone file (or from the DB) and classifies pre-cutoff rows the same way.Attack analysis
ChainedBeforeCutoff. That holds even if the attacker re-hashes every later link. With the link also cleared, it isMissingRow(orHeadMismatchat the tail). Moved into an earlier month, the source month reportsMissingRowand the target monthChainedBeforeCutoff.PurgedAtUtc, filing the anchor in another month, or adding a second anchor gives the newCutoffAnchorInvalid, reported in every month. Re-hashing the anchor to a later cutoff givesChainedBeforeCutoff. Deleting the anchor givesHashMismatchat ChainSeq 1.Evidence
AuditLogHashChainCutoffTests(7, MSSQL): chain+verify with a cutoff (backlog untouched, Intact, pre-cutoff counts); tamper edit, delete and tail still caught; the eligibility attack in 3 variants; anchor tampering in 5 variants with a positive control; purge across the cutoff (per-channel, heads and export Matches, archive and switch-out, anchor kept, archive verifies, cutoff still honoured afterwards); both nodes agree (two concurrent chainers on the real DB clock give exactly one anchor inside the race window, and a laterChainBacklog=truenode can't override it);ChainBacklogand legacy keep chaining everything; the maintenance login can write the anchor.ZB.MOM.WW.ScadaBridge.slnx: 0 warnings, 0 errors (also after rebasing on #138).Deploy impact: no migration, so nothing joins the QA window's script. The enablement needs a build that contains
52da02a7. QA pack3a59c3f6and DEV vd03424db155would both still chain the backlog. The QA pack §7 enablement steps are updated (keepChainBacklogunset, plus SQL checks for the anchor and for 0 pre-cutoff chained rows). DEV at migration 64 needs only a binary bump beforeEnabled=true.docs/requirements/Component-AuditLog.mdhas been updated. Closing.Live on DEV vd03 (2026-11-13), build
8e32f353. I enabled the chain withAuditLog:HashChain:Enabled=true(Central service env), leftChainBacklogunset, and left head export off.AuditLog hash chain started at 2026-11-13T12:16:53.1143422Z: only rows dated from then on are chained…AuditLogChainTombstonehas exactly oneChainSeq 0row: month 2026-11, cutoff2026-11-13T12:16:53.1143422, EventId5CADAB1D-C4A1-4C0F-A000-000000000139.audit verify-chain --month 2026-11: Intact. Rows verified 1, Unchained 0, Chain from2026-11-13T12:16:53.1143422Z, Pre-cutoff rows 507.--month 2026-10: NotChained, Pre-cutoff rows 667.Record:
docs/deployment/deployment-records/2026-11-13-wonder-app-vd03-8e32f353.md(581e25ca).https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
QA vq03/vq04 now runs
8e32f353(2026-11-13, record8ba5d5d7), which contains the cutoff. The chain stays off on QA per the owner's go:AuditLog__HashChain__*is not set, and 0 rows are chained. QA's later #107 §7 enablement therefore won't chain the 3,441-row backlog.