Skip to content

CRW-862: one definition of the state backup, its integrity gate and an operator command - #787

Merged
thisisjun786 merged 7 commits into
devfrom
codex/crw-862-state-backup-artifact
Oct 8, 2026
Merged

thisisjun786 merged 7 commits into
devfrom
codex/crw-862-state-backup-artifact

Conversation

@thisisjun786

@thisisjun786 thisisjun786 commented Oct 6, 2026 •

Copy link
Copy Markdown
Owner

What this changes

The state backup routine (internal/runtime/install/zone.go) had three post-merge P1s from the state-backup sidecar work, and it could only be taken inside an install that happens to introduce the additive DAG zone or an ordinary index. This PR fixes the three, gives the routine one definition of the store sidecars with a PRAGMA integrity_check gate, and adds an operator command that takes the same artifact outside an install.

The three P1s

  1. A retargeted WAL link escaped the comparison. The two-listing comparison dropped the store's -wal entry wholesale, so a WAL that is a symlink and is retargeted between the two listings was not noticed, and the copy read bytes from a file the state directory no longer named. The WAL is now compared by its identity (its kind, its link target and the file its bytes come from); only its size and its coming and going are left to the sidecar rules. A log whose link target or resolved source moved refuses with "the store's write-ahead log changed".

  2. A linked store's sidecars were recognised by top-level name only. isStoreShm/isStoreSidecar matched the strings relay.sqlite3-shm/relay.sqlite3-wal. When relay.sqlite3 is a link to another file (inside the state directory, e.g. data/real.sqlite3, or outside it), that file's -shm and -wal were walked as ordinary files: the -shm vanishing refused, and the -wal took the ordinary-file rule. The sidecars are now identified by the path store.InPlaceRead resolves the database to: the -shm is never listed, and the -wal is listed under the restore-compatible name relay.sqlite3-wal and follows the sidecar rules.

  3. A WAL that vanished during the walk still stopped the backup. listState returned any d.Info() error, so a relay.sqlite3-wal removed between ReadDir and Info failed the whole backup. An ENOENT from Info on the store's -wal/-shm is now recorded as gone before its copy and passed; every other file's ENOENT, and every other error, still refuses.

One definition, one integrity gate

The store's sidecars are now computed once (sidecarsFor) as resolved absolute paths, and every place that needs to know "is this the store's sidecar" (the walk, the copy loop, the second listing's frame check, the verification digest loop and the comparison) uses that identity rather than a bare name.

Before the manifest is written, the copy passes an integrity gate. A scratch duplicate of the copied store (its relay.sqlite3 and, when copied, its -wal) is written beside the copy, opened with SQLite and asked PRAGMA integrity_check. The duplicate is what SQLite touches, never the copy, so the backup's bytes are unchanged by the check; the duplicate is removed afterwards. The manifest records integrityCheck (SQLite's answer) and restoreCandidate (true only when the check passed), and a copy that fails the check is not a restore candidate and the command refuses. The install route runs the same backupState, so it gets the same gate. The manifest fields are additive: no existing field changes.

The operator command

crw install backup-state --to <dir> takes the same stopped byte copy outside any install. It calls the same backupState routine, so the copy, its manifest and its integrity fields are the same, and the destination rules (refuseDestination, enoughRoom) are the same. It is routed before the generic install options, like features, config and migrate-state: it takes its own --to flag and prints its own JSON report, and the frozen crw install usage line still names only the commands it named before.

It refuses while the relay service runs, reading the relay the same way the swap gate does (the selected relay's own service status); a reading that could not be taken also refuses. While it copies it holds the store's write gate (<state>/write-gate.lock) exclusively, so no relay writer reaches the store under the copy; a writer that already holds it refuses the command rather than waiting. It takes no other lock, and it takes no backup while the service is unknown or running.

Out of scope

The online sqlite3_backup snapshot and its schedule, restore, reconciliation and relay store code are not touched. The online snapshot schedule is a separate decision and is not built here; the doc says so.

Expected behavior and acceptance criteria

Criterion Evidence in this PR
c1: the parent decisions hold zone.go sidecarsFor, listingDiff (WAL identity), the walk's ENOENT branch, sqliteIntegrityCheck, backup_cmd.go
c2: red before, green after red baseline run below; go test -count=1 ./internal/runtime/install/... green
c3: CI, contract and docs CI on this head; no contract file changes; docs/runtime-install.md updated
c4: one non-draft PR into dev this PR
c5: scope only the declared regions plus this PR's own test file

Commands run, with results

go test -count=1 ./internal/runtime/install/    ok  (71.253s)
go test -count=1 ./internal/runtime/install/... ok  install, install/configguard, install/migrate
go test -count=1 ./internal/runtime/swapgate/   ok  (9.040s)
go test -count=1 ./internal/runtime/doctor/     ok  (6.316s)
gofmt -l internal/runtime/install/              (no output)
go vet ./internal/runtime/install/              (clean)
GOOS=darwin go vet ./internal/runtime/install/  (clean)
make lint                                       exit 0
git diff --check                                (no output)

Red before, green after

The new tests were run against the baseline commit (749d3a0, origin/dev at dispatch) in a throwaway worktree with the test files and the test seams only. They fail there and pass on this head:

# baseline 749d3a0, new tests only
--- FAIL: TestTheBackupRefusesAWALLinkRetargetedBetweenTheListings
--- FAIL: TestTheBackupRecognisesTheSidecarsOfAStoreLinkedInsideTheStateDirectory (the index goes; the log goes)
--- FAIL: TestTheBackupRecordsALogThatGoesDuringTheWalk (the store log)
--- FAIL: TestTheBackupRecordsIntegrityAndRefusesACorruptedCopy (corrupted copy; healthy copy)
--- FAIL: install.BackupState undefined (the operator command)
# this head
ok  github.com/thisisjun786/codex-relay-workflow/internal/runtime/install

Files edited outside the declared regions

None. The declared regions are internal/runtime/install/zone.go, zone_sidecar_test.go, export_test.go, cli.go, the new backup_cmd.go and backup_cmd_test.go, and docs/runtime-install.md; every edited file is one of those.

Contract and golden impact

No contract file, refusal reason, output field outside the backup's own manifest, CLI surface, golden or shipped SQL changes. The two new manifest fields (integrityCheck, restoreCandidate) are additive, and the operator command's result is a new JSON report of its own. No zone statement was appended: the store's schema is untouched.

D-19 contract line-citation drift

docs/port/decisions.md section 83 cites internal/runtime/install/zone.go:110-115 (backupState), :374-375 (the sidecar naming), :473-500 (listingDiff), cli.go:112-114 (--backup-state-to) and internal/runtime/swapgate/swapgate.go:55-59. At this head those line numbers have moved (this PR itself moves some of them, and siblings moved others): backupState is at zone.go:182, the sidecar naming at zone.go:103-137, listingDiff at zone.go:616, and the --backup-state-to registration at cli.go:115-120. The citations name the same functions and flags; only the offsets moved. The decision document is not edited here.

Base currency

git merge-tree HEAD origin/dev reports no conflict. This head was built and vetted on a temporary merge of itself with the latest origin/dev (ffee50d5 tree): go build ./... and go vet of install, swapgate, store and doctor are clean there. dev has moved since the baseline (as expected for this run); the coordinator makes the PR current in its merge lane.


Devin Review

codex added 2 commits October 7, 2026 05:02
…n operator command

Fix the three post-merge P1s of PR #735 in the state backup routine: the store
WAL is compared by its identity in the two-listing comparison, the store sidecars
are recognised by the resolved database path rather than the top-level name, and
an ENOENT from the walk Info on the store log is recorded as gone before its copy.

Add the integrity_check gate: the copied store is duplicated into a scratch file
beside the copy, PRAGMA integrity_check runs on the duplicate, and the manifest
records integrityCheck and restoreCandidate (a failure refuses). The copy bytes
are never opened.

Add the operator command crw install backup-state --to <dir>, which takes the
same artifact outside an install, refuses while the relay service runs, and holds
the store write gate exclusively during the copy.

Red-first tests for each behaviour; docs/runtime-install.md updated.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for security reviews. Please try again later.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 6 potential issues.

Devin Review

Comment thread internal/runtime/install/zone.go Outdated
Comment thread internal/runtime/install/zone.go Outdated
Comment thread internal/runtime/install/backup_cmd.go Outdated
Comment thread internal/runtime/install/zone.go
Comment thread internal/runtime/install/zone.go
Comment thread internal/runtime/install/backup_cmd.go Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b44eb34983

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/runtime/install/backup_cmd.go Outdated
Comment thread internal/runtime/install/zone.go Outdated
Comment thread internal/runtime/install/zone.go Outdated
Comment thread internal/runtime/install/zone.go Outdated
Comment thread internal/runtime/install/zone.go Outdated
Comment thread internal/runtime/install/backup_cmd.go Outdated
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-06T20:14:01.212856Z b44eb34 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

codex added 2 commits October 7, 2026 05:24
…y duplicate

Review fixes for the operator command and the sidecar identity:

- sidecarsFor resolves the database path even when store.InPlaceRead refuses it (a log
  with frames and no usable index after an unclean shutdown), so a linked store log is
  carried under the restore-compatible name rather than walked as an ordinary file;
- listingDiff treats a log that went during a listing walk as absence, not a moved
  identity, so the same checkpoint race no longer refuses on timing;
- the integrity duplicate is streamed with a fixed buffer instead of os.ReadFile, and
  the free-space preflight charges for it, so a store of any size is checked without
  holding it in memory and a destination that fits only the backup refuses up front;
- the operator command locks the write gate beside the resolved database (holdGate),
  not the state directory root, and validates every path flag for non-UTF-8.
…t took the backup

The standalone backup now records its own route: the manifest and the result say the copy was
taken by crw install backup-state outside an install (takenBy: operator), with the words the
operator command's checks support, rather than the swap gate's. PRAGMA integrity_check runs
through QueryContext, so an interrupted backup stops the check instead of holding the write gate
until a large scan finishes.
@thisisjun786 thisisjun786 added the crw-lane Merge lane holds this pull request: CI runs every test leg (temporary light PR CI mode, CRW-790) label Oct 6, 2026
@thisisjun786

Copy link
Copy Markdown
Owner Author

CI on head c05b2c2 (full legs, crw-lane)

Run 37527036257 — pull_request, head c05b2c212836efa3419a08ef4e8a3620ca065147:

Job Result
validate pass
secrets pass
go-product (lint) pass
go-product (test-1) pass
go-product (test-2) pass
go-product (test-3) pass
go-product (test-4) pass
go-product (test-rest) pass
go-product (dist) pass
skill-scripts-node pass
dev-gate pass

The earlier run on the same head (37526799002) ran the five test legs in light mode, where they skip their tests; the crw-lane label made the labeled event rerun them in full, and this run executed them.

Review resolution on this head

Devin and the GitHub Codex review each ran once. All twelve threads (six Devin, six Codex) were fixed with a red-first test each and resolved:

  • resolved write gate beside the resolved database (TestTheOperatorCommandLocksTheGateBesideTheResolvedStore);
  • sidecars resolved even when the WAL index is unavailable (TestTheSidecarsAreResolvedEvenWhenTheLogCannotBeReadInPlace);
  • integrity duplicate streamed, not read whole (TestTheOperatorCommandDuplicatesALargeStoreWithoutHoldingItInMemory);
  • scratch duplicate charged in the space preflight (TestTheSpacePreflightCoversTheIntegrityScratchDuplicate);
  • a log that goes during a listing walk is absence, not a change (TestTheBackupDoesNotRefuseALogThatGoesDuringTheSecondListing);
  • non-UTF-8 path flags refused (TestTheOperatorCommandRefusesANonUTF8State);
  • the integrity query honors cancellation;
  • the standalone artifact records its own route (TestTheOperatorCommandRecordsItsOwnRoute).

The Codex security review was skipped by its usage limit ("You have reached your Codex usage limits for security reviews"), so it is not awaited.

…d string

The two-listing comparison joined its key fields with "|", a byte a file name and a link
target may legally hold, so two different entries could join to one string and a retargeted
link pass. The keys are now comparable structs used as map keys and compared with ==, and
the "gone or changed" entry is reported from the struct Path rather than by cutting a
string. Nothing else in listingDiff changes.

Red-first tests reproduce the independent review pair with real files: the store log
retargeted to a path that collides under the delimiter, and the same for an ordinary
symlinked file in the state directory.
@thisisjun786

Copy link
Copy Markdown
Owner Author

Correction generation 2 — head 771512da1

The independent GLM review found one blocking P1 (failure class 6): listingDiff joined its comparison keys with |, a byte a file name and a link target may legally hold, so two different entries could join to one string and a retargeted link pass. Fixed in 771512da1: the keys are now comparable structs used as map keys and compared with ==, and the "gone or changed" entry is reported from the struct Path rather than by cutting a string. Nothing else in listingDiff changed.

Red-first: TestTheBackupRefusesALogRetargetedToAPathThatCollidesUnderTheDelimiter and TestTheBackupRefusesAnOrdinaryLinkRetargetedToAPathThatCollidesUnderTheDelimiter reproduce the review pair with real files under a temporary state directory; both report exit 0, made: true, restoreCandidate: true on c05b2c2 and refuse on 771512da1.

CI on head 771512da1

Run 37537579176 — pull_request, head 771512da1186333509f42843765dd21c8982984a, full legs (crw-lane):

Job Result
validate pass
secrets pass
go-product (lint) pass
go-product (test-1) pass
go-product (test-2) pass
go-product (test-3) pass
go-product (test-4) pass
go-product (test-rest) pass
go-product (dist) pass
skill-scripts-node pass
dev-gate pass

Local: go test -count=1 ./internal/runtime/install/... ok; gofmt, go vet (linux and darwin), make lint, git diff --check clean.

@thisisjun786 thisisjun786 removed the crw-lane Merge lane holds this pull request: CI runs every test leg (temporary light PR CI mode, CRW-790) label Oct 7, 2026
The listing of a store whose relay.sqlite3 resolves to another path probed that
file's -wal with os.Lstat and read every error as "there is no log". A probe
that failed for any other reason therefore copied the database without its log,
passed the integrity gate on the database alone and recorded a restore
candidate although committed frames may have been left out.

Only fs.ErrNotExist means the log is absent now; any other error refuses the
backup, naming the log's path and the error, before anything is reported as
made.
The integrity gate copied into its scratch directory every backed-up entry whose
path starts with "relay.sqlite3" and flattened it to its base name. A healthy
store beside a directory named relay.sqlite3-archive that holds a file named
relay.sqlite3 therefore collided in the scratch directory (EEXIST), and the
backup was recorded as not a restore candidate. A leftover journal that only
starts with the same name was duplicated for nothing.

The gate now duplicates exactly relay.sqlite3 and relay.sqlite3-wal, the only
files it opens. Red tests on 1bfd207 cover both cases.
@thisisjun786
thisisjun786 merged commit 2791404 into dev Oct 8, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant