Skip to content

fix(cli): persist SHA-256 when recording artifacts - #236

Merged
rabesss merged 12 commits into
mainfrom
fix/record-time-sha256-stability
Sep 14, 2026
Merged

rabesss merged 12 commits into
mainfrom
fix/record-time-sha256-stability

Conversation

@rabesss

@rabesss rabesss commented Sep 14, 2026

Copy link
Copy Markdown
Owner

Pull Request

Description

What does this PR do?

download --reuse-verified (#226) only compares SHA-256 when artifact_files.sha256 is already set. A normal download recorded files with an empty digest, so the first reuse hashed whatever was on disk and adopted it as the reference: a same-size swap with a valid container header was reported as reused.

This PR persists the SHA-256 when the artifact is recorded, so artifact_files.sha256 is non-empty from creation:

  • RecordManifests and completeValidatedJob fill empty digests via digestManifestFiles/hashStoredFile before the write transaction begins. The store opens SQLite with _txlock=immediate, so hashing inside the transaction would hold the write lock for the full file read. This single seam covers every recording path: one-shot CLI download, watcher, API, and startup recovery.
  • Caller-pinned digests are left untouched; artifact.Build already verified them.
  • Fill-when-empty stays as a legacy fallback for rows recorded before this change. Old libraries keep working; their first hash verification still fills the empty row (documented), subsequent runs compare the stored digest.

Supersedes #232. This branch contains #232 in full, plus a review pass that found and fixed one correctness gap in its central new function:

  • hashStoredFile now honors the completed-file stability contract documented in internal/artifact/manifest.go ("callers that inspect or hash an opened completed file must invoke ValidateStableCompletedFile immediately before publishing their result"). fix(library): persist SHA-256 when recording artifacts #232 hashed whatever the path named at record time with no pre-open Lstat, no SameFile/regular-file check, no size consistency check against the validated manifest, and no stability proof after the read. A file replaced mid-hash would have persisted a digest that never described a durable on-disk state, and a file replaced between validation and the digest could persist a row whose bytes and sha256 describe two different contents. The digest read now mirrors verifyArtifactFile: pre-open Lstat, regular-file/SameFile check, size must equal the manifest's recorded bytes, hash, then ValidateStableCompletedFile. A swapped or growing file now fails the record instead of persisting a bogus reference digest.
  • Batch digest failures in RecordManifests now carry the artifact index (digest artifact %d), matching the validate step.
  • Restored an accurate completeValidatedJob doc comment: why digesting happens before the transaction, and that an already-completed job pays the read anyway.

Related Issue(s):

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Testing Done

Unit Tests:

  • Added new tests
  • Updated existing tests
  • All tests pass locally (go test ./...)

New coverage:

  • TestRecordManifestPersistsSHA256 — recorded row and stored manifest carry the digest (acceptance criterion 1).
  • TestReuseVerifiedFallsBackAfterSameSizeHashMismatch — same-size swap with a valid ftyp header after download now ends in a fallback download with reason hash_mismatch, not reused (acceptance criterion 2).
  • TestVerifyArtifactFillsLegacyEmptySHA256 — legacy empty rows still fill on first hash verification (acceptance criterion 3).
  • TestDigestManifestFilesRejectsSizeDrift — a file that changes size between validation and recording fails the record.
  • TestDigestManifestFilesRejectsPathSwapBeforePublication — a same-size path swap mid-hash is caught by the stability proof.
  • TestDigestManifestFilesKeepsCallerPinnedDigest — caller-pinned digests pass through unmodified.
go test -count=1 ./...          # all packages ok
go vet ./...                    # clean
golangci-lint run ./...         # 0 issues
gofmt -l internal/              # clean

Test Coverage:

  • Coverage before: N/A
  • Coverage after: N/A

Trade-offs

  • Recording now pays one full-file read per recorded file. Verification already paid this on first --reuse-verified/verify --hash, so total work across a typical lifecycle is unchanged.
  • Hashing before BeginTx means an idempotent CompleteJob retry on an already-completed job hashes the files before the completion check discovers the job state. Moving the digest inside the transaction would avoid that but would hold the write lock during the read; the write-lock-free order is kept and documented.
  • download --json still emits the downloader's hashless manifest; manifests read back from the library carry the stored digest (documented in docs/api-reference.md).

Code Quality Checklist

  • Code follows the project's naming conventions (PascalCase/camelCase)
  • No new linting errors (golangci-lint run passes)
  • No hardcoded secrets or credentials
  • Complex logic is documented with comments
  • Error messages are clear and actionable

Documentation

  • README.md updated (library verification section)
  • docs/ updated (architecture.md, api-reference.md describe record-time digests and the legacy fill)
  • Changelog entry added — handled by release-please from the squash commit title

Additional Context

Review thread on #226 ("Require a preexisting digest before reusing an artifact") deferred this gap to #227; this closes it at the recording seam as the issue's fix direction preferred.


Reviewer Checklist:

  • Code is readable and maintainable
  • Tests are comprehensive and meaningful
  • No security vulnerabilities introduced
  • Backward compatible (or breaking change is documented)

cursoragent and others added 8 commits September 12, 2026 09:06
Normal download leaves artifact_files.sha256 empty, so the first
--reuse-verified run fills whatever digest is on disk. These tests
lock the intended record-time digest and same-size hash_mismatch
fallback before the production fix.

Co-authored-by: rabess <rabesss@users.noreply.github.com>
recordManifestTx hashes any file whose digest is still empty so
CLI download, CompleteJob, and recovery store a reference digest.
Fill-when-empty stays for libraries recorded before this change.

Co-authored-by: rabess <rabesss@users.noreply.github.com>
Recording now stores a digest, so no test left a real row with an
empty sha256. This clears one row through the store's own database
and proves verify --hash fills it from disk and persists it.

Co-authored-by: rabess <rabesss@users.noreply.github.com>
The store opens SQLite with _txlock=immediate, so hashing inside
recordManifestTx held the write lock for the whole digest. Measured
on a 4-core sha_ni VM with in-memory files: a 2 GiB record made a
concurrent writer wait 2.3 s, and a 10 GiB record failed it with
SQLITE_BUSY after the 5 s busy timeout. RecordManifests and
completeValidatedJob now digest before BeginTx, which brought the
concurrent writer's wait to 5 ms. The job expectation still matches
the caller's manifest, so a pinned digest must come from the caller.

Co-authored-by: rabess <rabesss@users.noreply.github.com>
manifest_json and artifact_files.sha256 come from the same digested
manifest. Pin the manifest side too.

Co-authored-by: rabess <rabesss@users.noreply.github.com>
download --json still emits the downloader's manifest without the
field. Library reads carry the stored digest. Rewrap the architecture
paragraph that was left with an orphaned line.

Co-authored-by: rabess <rabesss@users.noreply.github.com>
TestCompleteJobEnforcesExpectedSHA256 already requires a pinned digest
on the caller's manifest. The digest-then-BeginTx order is visible
without restating it.

Co-authored-by: rabess <rabesss@users.noreply.github.com>
hashStoredFile hashed whatever the path named at record time without
the stability proof the artifact package requires of completed-file
reads, and never checked that the hashed bytes matched the size the
validated manifest recorded. A file replaced mid-hash persisted a
digest that never described a durable on-disk state.

Mirror verifyArtifactFile: Lstat before open, reject a swapped or
non-regular descriptor, require the recorded size, then validate
stability across the read before publishing the digest. Wrap batch
digest failures with the artifact index and document the
digest-before-transaction trade-off on completeValidatedJob.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because your trial has ended. Ask your workspace admin to add credits to resume reviews. Manage billing

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 14, 2026 •

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-09-14T06:44:48.811764Z efeb4df 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.

@factory-droid

factory-droid Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Droid finished @rabesss's task —— View job


No high-confidence, actionable correctness or security issues were found in the record-time SHA-256 changes.

0 inline comments posted

@greptile-apps

greptile-apps Bot commented Sep 14, 2026

Copy link
Copy Markdown

Greptile Summary

This PR records SHA-256 digests when completed artifact manifests enter the library, preventing the first reuse verification from trusting modified bytes.

  • Hashes unpinned files before opening SQLite write transactions.
  • Verifies file identity, type, expected size, and stability while hashing.
  • Preserves caller-pinned digests and legacy empty-digest fallback behavior.
  • Adds coverage for persistence, same-size substitutions, size drift, path swaps, pinned digests, and legacy rows.
  • Updates library behavior documentation.

Confidence Score: 5/5

The PR appears safe to merge; the new recording path consistently persists stable reference digests without an identified correctness or security regression.

The hashing flow validates file identity, regular-file status, expected size, and stability before persisting the digest, while preserving pinned digests and legacy-library behavior. The tests cover the principal corruption and replacement scenarios introduced by the change.

Important Files Changed

Filename Overview
internal/library/verify.go Adds stability-checked hashing for manifest files whose SHA-256 is empty.
internal/library/artifact.go Digests validated manifests before opening the artifact-recording transaction.
internal/library/jobs.go Digests completed-job manifests before beginning the write transaction.
internal/library/digest_internal_test.go Covers size drift rejection and preservation of caller-pinned digests.
internal/library/verify_internal_test.go Covers rejection of a same-size path replacement before digest publication.
internal/cli/cli_download_reuse_test.go Verifies that a same-size content substitution triggers fallback downloading instead of reuse.

Sequence Diagram

sequenceDiagram
    participant Caller
    participant Library
    participant File
    participant SQLite

    Caller->>Library: Record/complete validated manifest
    loop Each file with empty SHA-256
        Library->>File: Lstat and open completed file
        Library->>File: Verify regular file, identity, and size
        Library->>File: Read and compute SHA-256
        Library->>File: Validate post-read stability
    end
    Library->>SQLite: Begin write transaction
    Library->>SQLite: Persist manifest and file digests
    SQLite-->>Library: Commit
    Library-->>Caller: Success
Loading

Reviews (1): Last reviewed commit: "fix(library): prove record-time digest r..." | Re-trigger Greptile

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ No critical issues — one minor suggestion inline.

Reviewed changes

  • Record-time digest fill at the two recording seams: RecordManifests and completeValidatedJob fill empty per-file digests via digestManifestFiles before the write transaction begins; these are the only two recordManifestTx callers, so one-shot CLI, watcher, API, and startup recovery are all covered, and caller-pinned digests pass through untouched.
  • Stability-checked digest read: hashStoredFile mirrors verifyArtifactFile (pre-open Lstat, no-follow open, SameFile/regular check, size equality against the validated manifest, post-read ValidateStableCompletedFile), so a swapped or growing file fails the record instead of persisting a bogus reference digest.
  • Legacy fill retained: verify --hash and the first --reuse-verified still fill empty rows from older libraries, pinned by TestVerifyArtifactFillsLegacyEmptySHA256.
  • Tests and docs: the acceptance criteria from #227 are pinned exactly (recorded rows carry the digest; a same-size valid-header swap now ends in hash_mismatch + fallback rather than reused; drift/swap rejections; pinned-digest passthrough), and the README/api-reference/architecture claims match the code.
  • Verified locally: go test -count=1 ./... passes and go vet is clean on the touched packages.

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using openai-compatible/glm-5.3 | 𝕏

Comment thread internal/library/jobs.go
Comment thread internal/library/jobs.go
// transaction begins, so the full-file read never holds the library write
// lock; a job that turns out to be already completed pays that read anyway.
func (store *Store) completeValidatedJob(ctx context.Context, jobID string, validated artifact.Manifest) error {
digested, digestErr := digestManifestFiles(validated)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: A digest failure during recovery aborts RecoverInterruptedJobs entirely and can wedge watch startup with no per-job escape hatch

completeValidatedJob now performs a stability-checked full-file read before the write transaction. RecoverInterruptedJobs (jobs.go:392-397) skips only ErrJobTerminal/ErrJobTransition/ErrJobNotFound, so any digest error — a path swap or size drift in the window after buildManifest validated the outputs, a mid-read mutation of a multi-GB file, or an EIO on the first full read — returns RecoveryResult{}, err for the whole loop. prepareWatch (internal/cli/cli_watch.go:253-260) then exits the command with watch.ErrDurableState before polling starts: remaining recoverable jobs are not processed this run, and a deterministic cause (e.g. persistent I/O error) makes every subsequent watch start fail identically. Consider treating a digest error like buildErr — record a sanitized summary, park the job as pending/recoverable, and continue to the next job.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

return fmt.Errorf("validate artifact %d: %w", index+1, err)
}
validated[index] = rebuilt
validated[index], err = digestManifestFiles(rebuilt)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: A record-time digest failure fails the whole batch and, in --events mode, reports fully published media as a failed job

RecordManifests digests every manifest before BeginTx, so one file that cannot be hashed (swap race in the window after the manifest was built, size drift, or EIO during the first full read) now leaves the entire run unrecorded: applyLibraryRecording (internal/cli/cli_download.go:271-279) sets LibraryRecorded=false for all lectures, and downloadEventStream.finish (internal/cli/cli_download_events.go:47-49) converts that into errDownloadLibraryCommit — a terminal job.failed event and nonzero exit even though all media was published. Before this change, recording never read file bytes, so post-download filesystem state could not fail the commit (only DB errors could). If failing the record is the intended semantics (it prevents persisting a bogus reference digest), consider distinguishing "media published, library commit failed" from a download failure in events mode, or degrading per-manifest so one file does not suppress recording of the other lectures.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread internal/library/jobs.go
return nil
}
if recordErr := recordManifestTx(ctx, tx, validated); recordErr != nil {
if recordErr := recordManifestTx(ctx, tx, digested); recordErr != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: No test asserts the digest persisted through completeValidatedJob

The acceptance criterion ("recording stores SHA-256") is covered only for the RecordManifests seam (TestRecordManifestPersistsSHA256). TestCompleteJobIdempotencyStillValidatesManifestContract completes a hashless manifest but asserts only the path (jobs_test.go:394), and TestCompleteJobEnforcesExpectedSHA256 exercises only the caller-pinned path. Passing validated instead of digested here would pass the entire suite, so the job-path digest behavior (watcher, API, startup recovery) is effectively untested. Add an assertion that GetArtifact after CompleteJob with a hashless manifest returns the file's SHA-256, mirroring the store-path test.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

if err != nil {
t.Fatalf("digestManifestFiles() error = %v", err)
}
if len(digested.Files) != 1 || digested.Files[0].SHA256 != pinnedSHA256 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

WARNING: This test cannot detect removal of the pinned-digest skip

artifact.Build already rejects a pinned digest that does not match the file contents, so the only pin that reaches digestManifestFiles is the file's true digest. If digestManifestFiles were changed to re-hash pinned files, the recomputed value would equal pinnedSHA256 and this assertion would still pass — the test verifies the output value, not that the pin was preserved without a second full-file read. To make it meaningful, monkeypatch validateStableArtifactFile to fail (as TestDigestManifestFilesRejectsPathSwapBeforePublication does): the pinned path must return without ever invoking it, so any re-hash fails the test. Note this also means there is currently no effective coverage anywhere that pinned digests pass through unmodified (the same holds for a store-level RecordManifests test).


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread internal/library/verify.go Outdated
if openedInfo.Size() != expectedBytes {
return "", fmt.Errorf("size changed between validation and recording: got %d bytes, want %d", openedInfo.Size(), expectedBytes)
}
digest, err := hashFile(opened)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SUGGESTION: Assert the hashed byte count matches expectedBytes

hashFile discards io.Copy's byte count. If the file is appended to mid-read and truncated back to expectedBytes within the filesystem's modtime granularity, both the size check above and the post-read stability proof can pass while the persisted digest describes a transient content that never existed at a single instant. Comparing the number of bytes actually read to expectedBytes here closes that window at zero cost and makes the persisted digest provably describe exactly manifest.Bytes bytes.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread docs/api-reference.md Outdated
verification pass calculates it. When supplied to the manifest builder, it is
checked against the complete file before being published.
The optional lowercase `files[].sha256` field is the SHA-256 of the completed
file. `download --json` emits the manifest the downloader built, which omits

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SUGGESTION: "download --json emits the manifest the downloader built, which omits the field" is overbroad

With download --reuse-verified --json, the same artifacts array mixes downloader-built manifests with library-read manifests for reused lectures, and the latter carry sha256 (internal/cli/cli_download_reuse.go:101,217-218 — verified.Manifest flows into result.Artifacts). As written, the sentence contradicts the "manifests read back from the library carry it" statement that follows. Consider scoping it to newly downloaded lectures.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

Comment thread docs/api-reference.md Outdated
and manifests read back from the library carry it. When the manifest builder
is given a digest, it checks the complete file before publishing. Rows
recorded by older releases may still have an empty digest.
`library verify --hash` and the first `download --reuse-verified` fill those

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SUGGESTION: Reuse fills only legacy rows named in the latest manifest

download --reuse-verified verifies with LatestMaterialization: true (internal/cli/cli_download_reuse.go:207-209), so it fills only empty rows whose paths appear in the latest manifest; historical artifact_files rows outside it stay empty (internal/library/verify.go:101-133). library verify --hash fills all rows. The sentence lumps both paths together and overstates the reuse path — worth a qualifier such as "for rows in the latest manifest".


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@kilo-code-bot

kilo-code-bot Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

Code Review Summary

Status: 1 Issue Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 0
Issue Details (click to expand)

WARNING

File Line Issue
internal/library/artifact.go 54 One record-time digest failure still fails the whole RecordManifests batch and, in --events mode, reports fully published media as a failed job — verified unresolved at HEAD c6f8223 (see inline thread)
Files Reviewed (3 files)
  • internal/library/verify.go - 0 issues
  • internal/library/verify_fill_internal_test.go - 0 issues
  • internal/library/artifact.go - 1 issue (carried, verified still present)

Fix these issues in Kilo Cloud

Previous Review Summaries (3 snapshots, latest commit f2d12dc)

Current summary above is authoritative. Previous snapshots are kept for context only.

Previous review (commit f2d12dc)

Status: 1 Issue Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 1
Issue Details (click to expand)

SUGGESTION

File Line Issue
internal/library/verify.go 212 Mid-read size drift is classified as not_regular instead of size_mismatch; the status token is user-facing (verify JSON status, reuse outcome Reason) and misdirects diagnosis toward symlinks/special files
Files Reviewed (2 files)
  • internal/library/verify.go - 1 issue
  • internal/library/verify_fill_internal_test.go - 0 issues

Fix these issues in Kilo Cloud

Previous review (commit 1b32005)

Status: 2 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 1
SUGGESTION 1
Issue Details (click to expand)

WARNING

File Line Issue
internal/library/artifact.go 54 Record-time digest failure fails the entire batch; in --events mode a fully published download is reported as job.failed with nonzero exit

SUGGESTION

File Line Issue
internal/library/verify.go 203 Verify path discards hashFile's byte count; the append-and-truncate-back race closed in hashStoredFile still decides hash_mismatch and fills legacy empty rows with a possibly transient digest
Files Reviewed (6 files)
  • docs/api-reference.md - 0 issues
  • internal/library/artifact.go - 1 issue
  • internal/library/digest_internal_test.go - 0 issues
  • internal/library/jobs.go - 0 issues
  • internal/library/jobs_test.go - 0 issues
  • internal/library/verify.go - 1 issue

Fix these issues in Kilo Cloud

Previous review (commit efeb4df)

Status: 7 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 4
SUGGESTION 3
Issue Details (click to expand)

WARNING

File Line Issue
internal/library/jobs.go 232 Digest failure in completeValidatedJob aborts the whole RecoverInterruptedJobs loop; watch startup exits with ErrDurableState and a deterministic cause wedges every restart with no per-job demotion
internal/library/artifact.go 54 Record-time digest failure fails the entire batch; in --events mode a fully published download is reported as job.failed with nonzero exit
internal/library/jobs.go 257 No test asserts the digest persisted via completeValidatedJob; swapping digested back to validated passes the suite
internal/library/digest_internal_test.go 62 TestDigestManifestFilesKeepsCallerPinnedDigest is vacuous: re-hashing pinned files yields the same digest, so the skip it names is untested

SUGGESTION

File Line Issue
internal/library/verify.go 279 hashFile discards the byte count; assert bytes read == expectedBytes to close the append-and-truncate-back race
docs/api-reference.md 258 "download --json … omits the field" is overbroad: --reuse-verified --json mixes library-read manifests that carry sha256
docs/api-reference.md 263 Reuse fills only legacy rows in the latest manifest (LatestMaterialization: true); sentence overstates vs verify --hash
Files Reviewed (11 files)
  • README.md - 0 issues
  • docs/api-reference.md - 2 issues
  • docs/architecture.md - 0 issues
  • internal/cli/cli_download_reuse_test.go - 0 issues
  • internal/library/artifact.go - 1 issue
  • internal/library/artifact_test.go - 0 issues
  • internal/library/digest_internal_test.go - 1 issue
  • internal/library/jobs.go - 2 issues
  • internal/library/verify.go - 1 issue
  • internal/library/verify_fill_internal_test.go - 0 issues
  • internal/library/verify_internal_test.go - 0 issues

Fix these issues in Kilo Cloud


Reviewed by glm-5.3 · Input: 0 · Output: 0 · Cached: 0

Review guidance: REVIEW.md from base branch main

@rabesss rabesss changed the title fix(library): persist SHA-256 when recording artifacts fix(cli): persist SHA-256 when recording artifacts Sep 14, 2026
rabesss and others added 2 commits September 14, 2026 12:56
Review on #236 found that one job's digest failure aborted the whole
RecoverInterruptedJobs loop and could wedge watch startup. Completion
failures are now sorted: job-state errors skip the job,
ErrJobOutputUnstable parks it recoverable with a summary (the same
containment the buildErr path applies), and only durable-store
failures abort the pass.

Also assert the hashed byte count matches the recorded size so the
persisted digest provably covers exactly manifest.Bytes bytes, cover
digest persistence through CompleteJob, and prove the pinned-digest
skip by failing the test if the stability seam is invoked.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Review on #236 noted download --json emits library-read manifests for
reused lectures, which carry the digest, and that the first
--reuse-verified fills only empty rows named in the latest manifest
while library verify --hash fills all rows.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
@factory-droid

factory-droid Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Droid finished @rabesss's task —— View job


No high-confidence, actionable correctness or security issues were found.

0 inline comments posted

@rabesss

rabesss commented Sep 14, 2026

Copy link
Copy Markdown
Owner Author

Review feedback addressed in f4984503 + 1b32005b:

Fixed

  • Recovery aborts on one job's digest failure (pullfrog, kilo): RecoverInterruptedJobs now sorts completion failures through containRecoveryError — job-state errors skip, ErrJobOutputUnstable (new sentinel wrapping digest failures) parks the job recoverable with a summary, mirroring the buildErr path; only durable-store failures abort the pass. Covered by TestRecoverInterruptedJobsSkipsJobWhoseOutputsCannotBeDigested, which proves a later job still recovers.
  • No test for the completeValidatedJob digest seam (kilo): added TestCompleteJobPersistsSHA256 — GetArtifact after CompleteJob with a hashless manifest now asserts both the row and the stored manifest carry the digest. Passing validated instead of digested now fails the suite.
  • Pinned-digest test couldn't detect removal of the skip (kilo): TestDigestManifestFilesKeepsCallerPinnedDigest now stubs validateStableArtifactFile to fail, so any re-hash of a pinned file fails the test.
  • Hashed byte count (kilo): hashFile now returns the bytes read and hashStoredFile requires it to equal the recorded size, so the persisted digest provably covers exactly manifest.Bytes bytes.
  • Docs overreach (kilo): api-reference.md now scopes the --json wording to newly downloaded lectures (reused lectures emit the library-read manifest, which carries the digest) and notes --reuse-verified fills only empty rows named in the latest manifest while library verify --hash fills all rows.

Left as intended behavior

  • Batch digest failure fails the whole record, and --events reports it as job.failed (kilo): kept deliberately. RecordManifests' atomicity is a documented contract (TestRecordManifestsRejectsInvalidBatchAtomically); per-manifest degrade would break it. A digest failure means a file was swapped, growing, or unreadable in the window after validation — genuinely exceptional, and failing loudly prevents persisting a reference digest that never described a durable state, which is the entire point of this change. Published media stays on disk; a rerun records it.

Also retitled the PR to fix(cli): ... — library is not in the semantic-title scope list, and cli matches the precedent set by #226 for library-touching download work.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes

  • Contained record-time digest failures per job during recovery: digest errors from completeValidatedJob are now wrapped in the new ErrJobOutputUnstable sentinel, and RecoverInterruptedJobs routes completion failures through containRecoveryError, which parks an unstable job in Pending with a recoverable summary and moves on — the same containment the buildErr path applies — so one unreadable output can no longer wedge watch startup. Durable-store failures still abort the batch.
  • Rejected mid-read size drift: hashFile now reports how many bytes it hashed, and hashStoredFile fails the record when that count differs from the manifest's bytes, so the persisted digest provably covers exactly the recorded size even if the file changes length during the read.
  • Hardened the new tests to be able to fail: the pinned-digest passthrough test now injects a failing stability proof, so re-hashing a pinned file would fail it; TestRecoverInterruptedJobsSkipsJobWhoseOutputsCannotBeDigested pins the containment (unstable job pending, stable job still recovered, no batch error) and fails against the pre-fix batch abort.
  • Scoped the SHA-256 documentation: download --json is now documented as emitting the downloader's hashless manifest for newly downloaded lectures but the library-read manifest (which carries the digest) for reused lectures, and the legacy-fill wording distinguishes verify --hash (any empty row) from the first --reuse-verified (rows named in the latest manifest). Checked against the actual reuse path — the emitted reuse manifest is verified.Manifest from GetArtifact, and reuse verifies with Hash: true + LatestMaterialization: true.
  • Verified locally: go test -count=1 ./... passes; go vet and gofmt -l internal/ are clean.

Pullfrog  | View workflow run | Using openai-compatible/glm-5.3 | 𝕏

Comment thread internal/library/verify.go Outdated
}
if options.Hash {
actual, hashErr := hashFile(opened)
actual, _, hashErr := hashFile(opened)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SUGGESTION: The verify path still discards hashFile's byte count

hashStoredFile now rejects a digest that covered more bytes than the validated size (verify.go:285-287), but finishArtifactFileVerification drops the count here, and this hash has the same persistence consequences: recordVerification fills legacy rows whose stored sha256 is empty with exactly this value (verify.go:307), and it also decides hash_mismatch against the stored digest. A file appended to mid-read and truncated back within the stability proof's granularity can therefore persist a reference digest that never described a durable on-disk state — the same race this PR closes at the recording seam. file.Bytes is in scope; treat read != file.Bytes like the size-mismatch case instead of trusting the digest.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

The verification hash path discarded the byte count the same way the
record path did, so an append-and-truncate inside the modtime
granularity could publish or fill a digest that never described a
stable state. finishArtifactFileVerification now requires the hashed
byte count to equal the recorded size before publishing.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
@factory-droid

factory-droid Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Droid encountered an error —— View job


@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes

Re-reviewed the delta since the prior review at 1b32005 — a single commit extending the stability contract to the verification hash path.

  • Extended the mid-read size-drift rejection to the verification hash path: finishArtifactFileVerification now requires the hashed byte count to equal the recorded bytes before the digest is used, mirroring the check hashStoredFile already applies at record time — the two completed-file hash paths stay symmetric, so an append inside the modtime granularity can no longer publish or fill a digest covering a different byte count than the row records.
  • Kept the drifted digest unpublished: the drift branch returns before result.SHA256 = actual, and recordVerification only fills on FilePresent, so a racy digest can never reach the database; the FileNotRegular status matches the adjacent stability-proof branch's convention for racy-instability rejections, and all failure statuses share one downstream case (cli_download_reuse.go:339), so the choice is behaviorally inert.
  • Added a test that fails without the fix: TestFinishArtifactFileVerificationRejectsSizeDriftDuringHash drives the finish step directly with a recorded size one byte behind the file — without the check it would return FilePresent with a published digest, so both assertions genuinely pin the rejection.
  • Verified locally: go test -count=1 ./internal/library/..., go vet, and gofmt -l are clean; hashFile's only two callers both consume the read count.

Pullfrog  | View workflow run | Using openai-compatible/glm-5.3 | 𝕏

Comment thread internal/library/verify.go Outdated
if read != file.Bytes {
// The file changed size mid-read; the digest never described a
// stable state, so it must not be published or filled.
result.Status = FileNotRegular

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

SUGGESTION: Mid-read size drift is reported as not_regular, but FileSizeMismatch is the precise status for this branch

FileNotRegular is documented as "exists but is not a regular file" (verify.go:25), yet the file hashed here was already proven regular and identical to the Lstat target two checks earlier (verify.go:183-190) — the only new fact is that the byte count differs from the record, which is exactly what FileSizeMismatch ("differs from its recorded byte count") names. The token is user-facing: it lands in the verify JSON status field and flows verbatim into the reuse outcome Reason (internal/cli/cli_download_reuse.go:335-339 maps the status string directly), so anyone diagnosing a mid-read growth or truncation sees not_regular and is pointed at symlinks/special files. Behavior is otherwise identical — every failure status is non-reusable and never fills a legacy row — so this is classification accuracy only. If adopted, the new test's want mid-read growth rejection assertion should follow. Consider also setting result.ActualBytes = read here so the JSON reports the observed byte count instead of the pre-hash stat, which still equals expectedBytes.

Suggested change
result.Status = FileNotRegular
result.Status = FileSizeMismatch

Reply with @kilocode-bot fix it to have Kilo Code address this issue.

FileSizeMismatch is the precise status when the hashed byte count
differs from the recorded size; not_regular describes a type change.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
@factory-droid

factory-droid Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Droid finished @rabesss's task —— View job


The PR persists SHA-256 digests at record time with thorough TOCTOU defenses (pre-open Lstat, SameFile/regular-file checks, size equality, mid-read byte-count verification, and a post-read stability proof), and caller-pinned digests pass through untouched after artifact.Build verification. Pass 1 produced no candidates, and my independent read of the full diff against the codebase and prior review threads (including the kilo status-classification note, now fixed by c6f8223 reporting mid-read drift as size_mismatch) found no high-confidence, actionable issues to add.

0 inline comments posted

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes

Re-reviewed the delta since the prior review at f2d12dc — a single commit tightening the failure taxonomy of the mid-read size-drift rejection.

  • Relabeled mid-read drift as size_mismatch — the verification hash path's drift branch now returns FileSizeMismatch instead of FileNotRegular, matching the pre-open size check two branches above it (internal/library/verify.go:187), so a regular file that grew mid-read reports the status its error message already described and --json reasons stay consistent across both rejection points.
  • Kept the relabel behaviorally inert downstream — FileNotRegular and FileSizeMismatch share one case in isVerificationFailureReason (internal/cli/cli_download_reuse.go:339), recordVerification still fills only on FilePresent, and no docs referenced the old label; the drift test was updated to pin the new status and still asserts the digest stays unpublished.
  • Verified locally: go test -count=1 ./internal/library/ ./internal/cli/, go vet on both packages, and gofmt -l internal/ are clean.

Pullfrog  | View workflow run | Using openai-compatible/glm-5.3 | 𝕏

@rabesss
rabesss merged commit 827d2c8 into main Sep 14, 2026
24 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.

fix(library): persist SHA-256 at record time so the first --reuse-verified verifies a real digest

2 participants