Skip to content

fix(trace): write bundle files with LF regardless of platform - #37

Merged
utsab345 merged 2 commits into
utsab345:mainfrom
how2how2how2-arch:fix/bundle-lf-writes
Oct 28, 2026
Merged

utsab345 merged 2 commits into
utsab345:mainfrom
how2how2how2-arch:fix/bundle-lf-writes

Conversation

@how2how2how2-arch

Copy link
Copy Markdown
Contributor

Fixes #36

Summary

Makes bundle files byte-identical on every platform, so the digests recorded in integrity.json mean the same thing everywhere.

Two parts, because there are two ways the bytes can change:

  1. The writers. _write_manifest, _write_events and _write_redactions in trace/storage.py now pass newline="". Without it, text mode on Windows turned \n into \r\n for the same trace.
  2. The checkout. A .gitattributes rule, **/*.sftrace/** text eol=lf. Without it, git's default core.autocrlf=true on Windows rewrites the 35 committed bundle files before any test runs, so the recorded digests are already wrong on disk.

+106/-1 across three files.

Why this matters

Bundle digests are SHA-256 over file bytes — trace/integrity.py::_hash_file, export/receipt.py::bundle_digest/file_sha256. So line endings are not abstracted away; they are part of what is hashed. Measured on the committed legacy_v01.sftrace by rewriting its files as CRLF in a temp copy:

files rewritten to CRLF: 3
digest before: 4cdc805dda5e69e84d8327355d16d9eb8b631b15a3c43d078897511eabb7ec5a
digest after : 2d8f91b50e6266e95de9d6c72103aa6d56bfa5c2db66eb3e82fbb080ec3c9dea

And the user-visible version, on a bundle that carries an integrity.json (8 of the 35 do):

# as committed (LF)
$ stepfork validate --verify-integrity langgraph_failure.sftrace
Validation passed.

# same bundle after a CRLF rewrite, i.e. a Windows checkout
$ stepfork validate --verify-integrity langgraph_failure.sftrace
✗ integrity_mismatch
  SHA-256 mismatch.
  redactions.json
Validation failed: 3 issues.        (exit 1)

Note for anyone reading this as "it still works": events.jsonl is JSONL, so a CRLF file still parses. The breakage is in the digests, not in parsing — verified --verify-integrity is opt-in, which is exactly why Linux CI never noticed.

On the .gitattributes pattern

**/*.sftrace/** is the form that works; *.sftrace/* and *.sftrace/**/* do not match nested files. I checked rather than assumed:

$ git check-attr eol -- tests/golden/legacy_v01.sftrace/manifest.json
# *.sftrace/*            -> eol: unspecified
# **/*.sftrace/**       -> eol: lf
# *.sftrace/**/*        -> eol: unspecified

The rule is deliberately narrow. git status shows no bundle file modified after adding it, so there is no renormalisation commit hiding in here, and non-bundle files keep git's default behaviour.

Tests

Three tests in tests/unit/test_storage.py, and I would rather state what each one can and cannot prove than let the count imply more:

test what it shows
test_saved_bundle_files_contain_no_crlf the assertion that fails on Windows if newline="" is dropped. On POSIX, newline translation is a no-op, so it passes before and after here — a guard for the platform CI can reach but this machine cannot
test_bundle_digest_changes_when_a_file_gains_crlf the stake, not the fix: it shows the digest covers line endings. Passes either way, and exists so the requirement is visible to the next person editing the writers
test_gitattributes_pins_bundle_files_to_lf fails on every platform when the rule is removed — verified by deleting .gitattributes and watching it go red, then restoring it and watching it pass

I deliberately did not fake a stronger writer guard. Simulating Windows by patching os.linesep does not reproduce the translation (CPython decides it elsewhere), so an assertion built that way would pass regardless of the defect — the failure mode where a test certifies something it cannot see.

Testing

uv run pytest                    701 passed in 26.77s
uv run pytest tests/unit/test_storage.py    28 passed
uv run ruff check .              All checks passed!
uv run ruff format --check .     196 files already formatted
uv run mypy                      Success: no issues found in 132 source files

Committed bundles still verify after the writer change, which is the check that this did not disturb existing data:

examples/langgraph_agent/regression/langgraph_failure.sftrace -> Validation passed.
examples/real-llm-case-study/traces/incident-triage.sftrace   -> Validation passed.
tests/golden/successful_run.sftrace                           -> Validation passed.

Checklist

  • Tests added/updated
  • pytest passes
  • ruff check passes
  • ruff format check passes
  • mypy passes
  • docs updated if necessary — the reason for newline="" is commented at each writer, and .gitattributes carries the same explanation; no user-facing docs change
  • no secrets/sensitive traces included

@utsab345

Copy link
Copy Markdown
Owner

Verified

  • 701 passed for the full suite, and 28 passed in tests/unit/test_storage.py.
  • The .gitattributes rule works: all 35 committed bundle files show i/lf w/lf.
  • One gap remains. write_bundle_integrity in src/stepfork/trace/integrity.py:66 still writes integrity.json without newline="".

Why it matters

export/receipt.py::bundle_digest hashes every file in the bundle, including integrity.json. On Windows, that file will still be written with CRLF, so bundle digests will still differ by platform. I reproduced this by rewriting only integrity.json to CRLF: bundle_digest changed, while verify_bundle_integrity still returned verified, because integrity.json is not hashed by itself.

The new test test_saved_bundle_files_contain_no_crlf checks only manifest.json, events.jsonl, and redactions.json, so it does not catch this.

Small changes

  1. Add newline="" to the write_text call in write_bundle_integrity.
  2. Include "integrity.json" in the loop in test_saved_bundle_files_contain_no_crlf.

Also please rebase onto main, since this branch predates #35 and the new macOS and Windows smoke jobs have not run on it.

how2how2how2-arch and others added 2 commits October 27, 2026 10:18
Bundle files are hashed as bytes (trace/integrity.py::_hash_file,
export/receipt.py::bundle_digest), but the three writers in storage.py used
text mode without newline="", so on Windows "\n" became "\r\n" and the same
trace produced different bytes and a different digest per platform.

Pass newline="" in _write_manifest, _write_events and _write_redactions, and
add a .gitattributes rule so a Windows checkout cannot rewrite the committed
bundles either (git's default core.autocrlf would corrupt the recorded digests
before any test runs). The rule is narrow: **/*.sftrace/** matches nested
bundle files, while *.sftrace/* and *.sftrace/**/* do not, and no committed
file changes in the working tree as a result.

Adds three tests, documented for what each one can and cannot show: the
no-CRLF assertion fails on Windows if newline="" is dropped but is a no-op on
POSIX, the digest-sensitivity test describes the stake rather than the fix, and
the gitattributes test fails on every platform when the rule is removed.
@how2how2how2-arch

Copy link
Copy Markdown
Contributor Author

Both done, plus the rebase.

Reproduced your finding first. Before changing anything, I rewrote only integrity.json to CRLF in a saved bundle:

digest before : b2b27c9943ab1d78
verify before : verified
digest after  : da5a5316b5c9ade3
verify after  : verified

So that file sits inside bundle_digest's rglob walk and outside INTEGRITY_FILES = (manifest.json, events.jsonl, redactions.json). That is the whole mechanism: verification keeps saying "verified" because the file it would need to hash to notice is the one being changed.

1. newline="" on the writer — src/stepfork/trace/integrity.py, with a comment naming both facts, since "this file is hashed but does not hash itself" is exactly the non-obvious part.

2. The test — integrity.json is in the loop now. I also made the list assert-complete instead of hand-typed:

names = ("manifest.json", "events.jsonl", "redactions.json", "integrity.json")
written = {path.name for path in destination.iterdir() if path.is_file()}
assert written == set(names)

Your finding is the argument for that: every writer looked guarded because the list was maintained by hand and the bundle grew away from it. Now a new bundle file fails the test until it is covered. Two plants, each red at that assertion and green after restoring:

  • reverting the list to its old three names (the exact gap you found) → 1 failed
  • having write_bundle_integrity write a fifth file → 1 failed

What I could not show red here: removing newline="" and watching the CRLF assertion fail. On POSIX the translation is a no-op, so that mutation stays green on this machine — the limitation the docstring already records, and the reason the completeness assertion is the half I can demonstrate locally.

3. Rebased onto main (580cab3, which includes #35). All eight checks green:

Python 3.11 / 3.12 / 3.13: SUCCESS
Smoke macos-latest  / 3.11, 3.13: SUCCESS
Smoke windows-latest / 3.11, 3.13: SUCCESS

What that green actually buys for this defect: the Windows smoke step runs pytest tests/unit/test_storage.py, so it now checks integrity.json on Windows — previously that file was not in the loop, so the job never looked at it. I did not push a deliberately-broken variant to watch Windows go red, so read it as new coverage rather than a demonstrated mutation.

Local: 701 passed, 28 passed in test_storage.py, ruff check All checks passed, ruff format --check 196 files already formatted, mypy Success: no issues in 132 source files.

@utsab345
utsab345 merged commit c3b5a53 into utsab345:main Oct 28, 2026
8 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.

Bundle files are not byte-stable across platforms: text-mode writes plus autocrlf break recorded digests

3 participants