Review finding (Fix round 1, Task 1): the property getters read their
backing fields without synchronization while RecordSuccess/RecordFailure
write them under _gate, so a concurrent read could observe a torn
DateTimeOffset or a mix of fields from two different writes. Converted the
four auto-properties to explicit properties over private fields, each
getter taking the same lock the writers use. Public interface unchanged.
GrpcSecretSyncService's background sweep now records outcomes on a new
SecretsHubSyncStatus (registered singleton, exposed as public
ISecretsHubSyncStatus) and logs three new events on the outcome-recording
path: EventId 4 (Debug, every in-sync sweep), EventId 5 (Information, the
same outcome throttled to at most once per the new
SecretsGrpcHubClientOptions.InSyncLogInterval, default 1h), and EventId 6
(Information, the first sweep to succeed after one or more failures).
EventIds 1-3 are unchanged; the raw internal SweepAsync still throws on
failure exactly as before. Not yet published to the Gitea feed and
ScadaBridge is not re-pinned — that is a separate, later step.
Replaces the 2026-09-11 remote-exec hold: WinRM exec is allowed only for the on-box steps of a
user-requested deploy; ad-hoc diagnostics/version checks use SMB, HTTP or SQL. SMB file copies and
direct SQL logins remain always allowed. Scope rules live in the winrm skill's Scope section.
CLAUDE.md: ScadaBridge row records main @ 17864d1a (records only, code tip
2b9a7c3c) deployed on vd03 and the vq03/vq04 pair; Theme/Auth/LocalDb rows
lose their "not yet deployed" caveats (QA pair now runs LocalDb replication,
connected); tier note gains the deployed-state line and the winrm access path.
deployment.md: §2.1 access rows replaced by the winrm skill (WinRM 5985 +
SMB 445; servecli/psbridge retired), §7.1 port map gains 5985/445 and marks
6150 retired, live-state table updated (OtOpcUa active flipped to node-a),
new "Deployed builds on QA" paragraph, §9 deployed-state summary.
Claude-Session: https://claude.ai/code/session_01RgthVLVU5CJ8SUuEwruTkt
Row in the sister-project table, a BTDB-edges subsection (Galaxy write contract, ScadaBridge inbound-API SAPID lookup on db-connection BTDB, Historian CLR/INSQL co-location), break-surface note, Windows-only build commands, and the DARS bullet now points at the unversioned WW_ARCH/BatchTracker_DB fork. Full schema/contract notes and the 2026-09-03 gap analysis live in .claude/skills/sister-project-history/btdb.md.
Claude-Session: https://claude.ai/code/session_01Wzg6NUCWHipSexLs2cgRFv
.gitignore ignored all of .claude/, so the per-project history files the root index points at were never committed; the pushed index had lost that history. Un-ignore .claude/skills/ (settings.local.json and worktrees stay ignored) and add the three files.
Claude-Session: https://claude.ai/code/session_01Wzg6NUCWHipSexLs2cgRFv
SnapshotStreamer.SendAsync stamped as_of_seq = MAX(seq), which reads 0 once ack-pruning has
emptied a converged sender's oplog. A receiver whose watermark genuinely is 0 (a rebuilt node —
ScadaBridge's site-a-a since its 2026-08-15 rebuild) was healed by the snapshot but never lifted
by it (the receiver's watermark update is correctly MAX-guarded), so the 0.1.2 empty-oplog
horizon rule saw 0 < last_acked_seq as a gap on every subsequent handshake and re-streamed the
whole database on every reconnect until the sender wrote again. Observed live: the same 127k-row
full snapshot on two consecutive restarts of the pair.
Fix: as_of_seq = max(MAX(seq), last_acked_seq). Every delta pruned below last_acked_seq is
already folded into the rows the snapshot carries, so that is the floor it may claim; the
receiver's MAX guard then lifts the watermark and the next handshake finds no gap. Sender-side
only; wire-compatible; no schema change.
Test WipedPeer_SnapshotLiftsItsWatermark_SoTheNextReconnectSendsNoSecondSnapshot fails on the
unfixed code exactly as the rig does (watermark 0, expected 3) and passes with the fix: the
second episode exchanges only a delta, and a capturing logger on the snapshot streamer proves
no snapshot was sent. Suite 216/216 on 8 of 9 runs; one unnamed single-test failure on the first
run did not recur in eight further runs and could not be attributed.
Three review follow-ups on 122940c. Still 0.3.3 — unpublished, so no further
version bump. No behaviour change to any succeeding path.
- The per-entry poison handler's recovery work (ROLLBACK TO + the dead-letter
INSERT) was unguarded: a wedged connection, an I/O error, or a dead-letter
INSERT that faults would propagate the SECOND exception and silently discard
the FIRST — the one that says why the entry was poison at all. The pair is now
wrapped; a recovery failure throws an AggregateException whose message names
the entry (table + seq) and the recovery fault, and whose InnerException is the
ORIGINAL entry fault, with the recovery fault carried alongside it at
InnerExceptions[1] with its own stack. The outcome is unchanged and still
fail-closed: nothing commits, and disposing the batch transaction unwinds it.
OperationCanceledException is deliberately excluded from the wrap so a
shutdown still surfaces as a cancellation rather than as a fault. No upstream
caller catches SqliteException from the apply path (checked), so widening the
escaping type is safe.
Injected cheaply after all: a RAISE(ABORT) trigger on __localdb_dead_letter,
stacked on the existing row-version poison trigger, produces exactly the
two-fault case with no production test seam. The test asserts both causes are
carried AND that the batch committed nothing — the entry that applied cleanly
before the poison one is gone and the peer watermark never moved.
- Register_SelfReferencingCascade_Throws pins the README's "or from itself"
claim: a table that is its own cascading parent (parent_id REFERENCES
nodes(id) ON DELETE CASCADE) is refused. The guard needed no special case —
it reads every foreign key in the database — but the promise is now tested.
- Comment on why EnterEntryAsync sits outside the try: a SAVEPOINT that failed
opened nothing, so a handler ROLLBACK TO would name a savepoint that does not
exist and fault in turn.
Both new tests were run against 122940c first and fail there: the self-reference
case throws nothing, and the stacked-fault case escapes as a bare
SqliteException("dead-letter write failed") with the original cause gone.
Suite: 215/215 green, build 0 warnings / 0 errors.
Two latent apply-path hazards from the ScadaBridge adversarial architecture
review. Neither has a live trigger in any current consumer — ScadaBridge's ten
replicated tables carry no foreign keys at all (verified against the running rig's
site-localdb.db) and OtOpcUa's LocalDb tables carry none either — but both are the
kind that surface as silent divergence rather than as an error, so they are closed
before a consumer's schema grows into them.
Wire-compatible: no proto change, no lib_schema_version bump, no on-disk schema
change. No consumer code change.
- RegisterReplicated now refuses a table that is the PARENT of a foreign key with
a row-mutating ON DELETE action (CASCADE / SET NULL / SET DEFAULT), naming the
offending child column and action. The apply path writes remote rows with
INSERT OR REPLACE, whose PK-conflict resolution DELETES the existing row first;
connections run PRAGMA foreign_keys=ON, so that implicit delete fires the child's
ON DELETE action while the capture guard is raised. The child rows would change
on the applying node only, produce no oplog entry, and diverge the pair with
nothing able to detect it — LWW compares only rows it was told about. RESTRICT /
NO ACTION stay allowed: they abort the statement instead of mutating rows behind
capture's back, which the apply path already handles as per-entry poison. The
check inspects every foreign key in the database and runs at registration;
SQLite cannot add a foreign key to an existing table, so a child table created
later is caught at the next startup's registration, not at the DDL. Documented
on ILocalDb.RegisterReplicated and in the README's limitations list.
- An entry's row write and its ledger write are now atomic with each other. They
are two statements and SQLite aborts only the failing one, so a statement-level
fault between them — the case the per-entry poison catch exists for — committed
a half-applied entry: a tombstone whose table row was gone while
__localdb_row_version still called it live (SnapshotStreamer can only warn and
skip that, so the peer never learns the row died), or an upsert whose new row
carried the OLD version and therefore lost the next comparison it should have
won. The pair now runs inside a per-entry SAVEPOINT, rolled back (and released,
so nothing nests) before dead-lettering, leaving both writes or neither while
the rest of the batch proceeds and commits.
Every new test was run against the unfixed code first and fails there with exactly
the divergence shape described: the tombstone case reads back null instead of the
live row, the upsert case reads back the remote row against an unchanged ledger,
and the registration cases throw nothing.
Suite: 213/213 green, build 0 warnings / 0 errors.