From 8fae3e45e7938a5ffa9b391e0a1588e843ee6655 Mon Sep 17 00:00:00 2001 From: 0xGaspar Date: Wed, 30 Sep 2026 16:21:36 -0300 Subject: [PATCH 1/5] fix: draft canonical Git LFS evidence support for #789 --- .../approvals.json | 41 ++++ .../change.md | 24 ++ .../context.md | 8 + .../deltas/change.md | 13 ++ .../design.md | 18 ++ .../docs.md | 8 + .../plan.md | 8 + .../requirements.md | 19 ++ .../research.md | 8 + .../state.json | 42 ++++ .../tasks.md | 11 + .../testing.md | 25 ++ hi/evidence.md | 15 ++ specs/change/change.spec.md | 4 +- specs/change/context.md | 6 + specs/change/requirements.md | 12 + specs/change/tasks.md | 3 + specs/change/testing.md | 11 + src/change.rs | 205 ++++++++++++++--- src/change_tests.rs | 217 ++++++++++++++++++ 20 files changed, 668 insertions(+), 30 deletions(-) create mode 100644 .specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/approvals.json create mode 100644 .specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/change.md create mode 100644 .specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/context.md create mode 100644 .specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/deltas/change.md create mode 100644 .specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/design.md create mode 100644 .specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/docs.md create mode 100644 .specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/plan.md create mode 100644 .specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/requirements.md create mode 100644 .specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/research.md create mode 100644 .specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/state.json create mode 100644 .specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/tasks.md create mode 100644 .specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/testing.md create mode 100644 hi/evidence.md diff --git a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/approvals.json b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/approvals.json new file mode 100644 index 00000000..52475401 --- /dev/null +++ b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/approvals.json @@ -0,0 +1,41 @@ +{ + "approvals": [ + { + "gate": "definition", + "actor": "Bruno", + "timestamp": 1790795582, + "digest": "b48bcae372df50ad508a103108ee6cd299ac3a323e35a3d14f16eb5503f28d28", + "note": "Bruno explicitly answered Yes, use those criteria to EVIDENCE-1 and EVIDENCE-2 for issue #789. Scope approval only; implementation review is pending.", + "approved_scope": { + "schema_version": 1, + "change_id": "support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes", + "title": "Support Git LFS pointers and hydrated assets in canonical change evidence without hiding content changes", + "description": "Support Git LFS pointers and hydrated assets in canonical change evidence without hiding content changes", + "kind": "bug_fix", + "affected_specs": [ + "change" + ], + "affected_paths": [ + "hi/evidence.md", + "src/change.rs", + "src/change_tests.rs" + ], + "no_spec_change": false, + "no_spec_change_rationale": null, + "acceptance_criteria": [ + "I can complete verification in a repository containing Git LFS assets. I can trust verification to detect changed asset content, whether assets are downloaded or represented by LFS pointers. Preserve whole-workspace coverage and fail closed for unsupported filters." + ], + "dependencies": [], + "supersedes": [], + "answers": { + "architecture_risk": "yes", + "public_contract": "yes" + } + }, + "approved_delta_digests": { + "change": "e3d6ae04b211b108bbcd4fab24f53ea493c90e47639cf6cbdcaaf3971844c366" + } + } + ], + "reopenings": [] +} diff --git a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/change.md b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/change.md new file mode 100644 index 00000000..40513906 --- /dev/null +++ b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/change.md @@ -0,0 +1,24 @@ +--- +id: support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes +state: approved +type: bug_fix +base_commit: 6a47f2cd0c6dc5dfa4168b79ac6dda960966bcfc +--- + +# Support Git LFS pointers and hydrated assets in canonical change evidence without hiding content changes + +## Intent + +Support Git LFS pointers and hydrated assets in canonical change evidence without hiding content changes + +## Affected Canonical Specs + +- `change` + +## Acceptance Criteria + +- I can complete verification in a repository containing Git LFS assets. I can trust verification to detect changed asset content, whether assets are downloaded or represented by LFS pointers. Preserve whole-workspace coverage and fail closed for unsupported filters. + +## No-spec Rationale + +Not applicable diff --git a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/context.md b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/context.md new file mode 100644 index 00000000..14dc8e8f --- /dev/null +++ b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/context.md @@ -0,0 +1,8 @@ +--- +change: support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes +artifact: context +--- + +# Context + +Fix CorvidLabs/spec-sync#789. The current whole-workspace evidence collector rejects unchanged LFS assets even outside source scope and ignored_paths. Bruno confirmed EVIDENCE-1 and EVIDENCE-2 before implementation. diff --git a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/deltas/change.md b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/deltas/change.md new file mode 100644 index 00000000..5d376f40 --- /dev/null +++ b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/deltas/change.md @@ -0,0 +1,13 @@ +## Added + +### REQUIREMENT REQ-change-103 + +Canonical evidence SHALL support ordinary SHA-256 Git LFS v1 files without dropping them from evidence or invoking LFS filters. + +Acceptance Criteria +- A valid pointer and its hydrated content have the same canonical payload and digest, including in-scope assets. +- Modified content, pointer targets, executable modes and deletion remain detectable. +- Hydrated content is streamed with bounded memory; it cannot exceed the aggregate evidence buffer by its raw size. +- Unsupported pointer extensions, malformed pointer-like inputs and other Git content filters remain errors. +- No LFS download or external clean/smudge/process filter is needed to collect evidence. +- Existing sparse, topology, exact attribute-output and stable capture checks remain in force. diff --git a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/design.md b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/design.md new file mode 100644 index 00000000..2c544581 --- /dev/null +++ b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/design.md @@ -0,0 +1,18 @@ +--- +change: support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes +artifact: design +--- + +# Design + +Issue #789: support LFS v1 canonical evidence without filters or network calls. +- Keep entire evidence inventory; do not exclude asset paths through ignored_paths. +- Recognize filter=lfs explicitly and continue refusing custom filters/encoding/ident. +- Read attributed paths directly, including paths Git calls clean, to avoid hiding hydrated changes. +- Canonicalize plain SHA-256 LFS v1 pointers and streamed hydrated bytes to the same pointer payload; preserve index identity and file mode. +- Fail closed on unsupported pointer extensions and malformed pointer-like input; support the empty-file special case. +- Preserve sparse/missing/symlink guards and double-capture race detection. +- Disable LFS process/clean/smudge during evidence Git queries; do not invoke LFS or download content. +- Tests: pointer/hydrated parity, same-length changed bytes, edited pointers, staged changes, deletion, bad/extended pointers, other attributes, custom filter non-execution, full change-check with unchanged out-of-scope LFS. +- Full build/pre-push currently blocked by unavailable cached serde-saphyr; no fetch/install authorized. + diff --git a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/docs.md b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/docs.md new file mode 100644 index 00000000..8d37b21b --- /dev/null +++ b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/docs.md @@ -0,0 +1,8 @@ +--- +change: support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes +artifact: docs +--- + +# Docs + +Document supported plain SHA-256 v1 pointers, streamed hydrated evidence, equivalent representation, the empty-file case, explicit errors for unsupported pointer formats, and unchanged ignored_paths semantics. No new CLI flag or dependency. diff --git a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/plan.md b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/plan.md new file mode 100644 index 00000000..b8d582b1 --- /dev/null +++ b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/plan.md @@ -0,0 +1,8 @@ +--- +change: support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes +artifact: plan +--- + +# Plan + +Implement explicit LFS classification and streamed canonical payload collection, preserve existing guards, add unit and lifecycle regressions, update module contract and run offline checks. Do not claim full validation if cached dependencies are missing. diff --git a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/requirements.md b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/requirements.md new file mode 100644 index 00000000..dbc52eac --- /dev/null +++ b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/requirements.md @@ -0,0 +1,19 @@ +--- +change: support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes +artifact: requirements +--- + +# Requirements + +### REQ-change-103 + +Canonical evidence SHALL support ordinary SHA-256 Git LFS v1 files without dropping them from evidence or invoking LFS filters. + +Acceptance Criteria +- A valid pointer and its hydrated content have the same canonical payload and digest, including in-scope assets. +- Modified content, pointer targets, executable modes and deletion remain detectable. +- Hydrated content is streamed with bounded memory; it cannot exceed the aggregate evidence buffer by its raw size. +- Unsupported pointer extensions, malformed pointer-like inputs and other Git content filters remain errors. +- No LFS download or external clean/smudge/process filter is needed to collect evidence. +- Existing sparse, topology, exact attribute-output and stable capture checks remain in force. + diff --git a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/research.md b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/research.md new file mode 100644 index 00000000..142dac41 --- /dev/null +++ b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/research.md @@ -0,0 +1,8 @@ +--- +change: support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes +artifact: research +--- + +# Research + +inspect_git_candidates validates attributes before substituting clean index blobs. It currently rejects filter=lfs, and dirty hydrated content would otherwise be buffered wholesale. Git LFS v1 specifies SHA-256, byte size and a bounded pointer; empty files pass through unchanged. Keep all governed paths rather than reinterpret ignored_paths. diff --git a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/state.json b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/state.json new file mode 100644 index 00000000..298e717d --- /dev/null +++ b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/state.json @@ -0,0 +1,42 @@ +{ + "schema_version": 1, + "workflow_version": 2, + "workflow_origin_version": 2, + "id": "support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes", + "slug": "support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes", + "title": "Support Git LFS pointers and hydrated assets in canonical change evidence without hiding content changes", + "description": "Support Git LFS pointers and hydrated assets in canonical change evidence without hiding content changes", + "kind": "bug_fix", + "state": "approved", + "base_commit": "6a47f2cd0c6dc5dfa4168b79ac6dda960966bcfc", + "created_at": 1790795521, + "updated_at": 1790795582, + "affected_specs": [ + "change" + ], + "affected_paths": [ + "src/change.rs", + "src/change_tests.rs", + "hi/evidence.md" + ], + "no_spec_change": false, + "no_spec_change_rationale": null, + "acceptance_criteria": [ + "I can complete verification in a repository containing Git LFS assets. I can trust verification to detect changed asset content, whether assets are downloaded or represented by LFS pointers. Preserve whole-workspace coverage and fail closed for unsupported filters." + ], + "selected_artifacts": [ + "context", + "testing", + "tasks", + "requirements", + "docs", + "research", + "design", + "plan" + ], + "dependencies": [], + "answers": { + "architecture_risk": "yes", + "public_contract": "yes" + } +} diff --git a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/tasks.md b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/tasks.md new file mode 100644 index 00000000..6f9b32ce --- /dev/null +++ b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/tasks.md @@ -0,0 +1,11 @@ +--- +change: support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes +artifact: tasks +--- + +# Tasks + +- [x] Implement LFS evidence collection. +- [x] Add parity, mutation, failure and lifecycle regressions. +- [x] Synchronize specs and record actual validation. +- [ ] Pass pre-push gate before publishing a PR. diff --git a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/testing.md b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/testing.md new file mode 100644 index 00000000..9d4e7e98 --- /dev/null +++ b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/testing.md @@ -0,0 +1,25 @@ +--- +change: support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes +artifact: testing +--- + +# Testing + +Test pointer/hydrated parity, same-length edits, staged changes, deletion, empty files, malformed and extended pointers, other filters, and ignored/out-of-scope LFS in the change lifecycle. Use offline builds only. Current dependency resolution is blocked by missing cached serde-saphyr; full gate cannot be claimed. + +## Observed validation, 2026-09-30 + +- cargo fmt --check passed. +- Four pure tests, extracted unchanged with their production helpers into a temporary sha2-only crate, passed using existing offline cached dependencies. The streaming fixture exceeds 256 MiB while enforcing 64 KiB maximum read buffers. +- Installed SpecSync strict validation passed: 62 specs, zero warnings, 107/107 files (100%). Changed module score: 100/100. +- Full workspace cargo metadata and mandatory CARGO_NET_OFFLINE=true fledge lanes run pre-push stop on missing cached serde-saphyr. The pre-push formatter step passed; cargo check did not compile the project. Git/lifecycle integration regressions have been added but not run. +- No PR is published while the mandatory pre-push gate is blocked. No implementation review, full-test pass, finalization or provenance approval is claimed. +- HI sentences were recorded only after Bruno explicitly confirmed them; hi is unavailable locally, so hi check was not run. + +## Requirement evidence + +| Requirement | Evidence | +| --- | --- | +| REQ-change-103 | src/change_tests.rs::lfs_payload_pointer_and_hydrated_bytes_are_equivalent; src/change_tests.rs::lfs_payload_refuses_malformed_and_extended_pointers; src/change_tests.rs::lfs_payload_streams_large_content_in_bounded_chunks; src/change_tests.rs::lfs_attribute_exception_is_specific_and_keeps_exact_output_checks; src/change_tests.rs::lfs_workspace_and_scoped_evidence_detect_edits_without_filters; src/change_tests.rs::lfs_unchanged_out_of_scope_assets_allow_change_check | + +The first four tests ran in isolation; the last two are pending full-project compilation. This table maps assertions and does not claim the pending tests passed. diff --git a/hi/evidence.md b/hi/evidence.md new file mode 100644 index 00000000..dfb89430 --- /dev/null +++ b/hi/evidence.md @@ -0,0 +1,15 @@ +--- +hi: 1 +families: [EVIDENCE] +--- + +# Evidence + +## Intent + +Trustworthy verification in repositories containing Git LFS assets. Bruno confirmed these wants in the conversation on 2026-09-30. + +## Criteria + +- **EVIDENCE-1** I can complete verification in a repository containing Git LFS assets +- **EVIDENCE-2** I can trust verification to detect changed asset content, whether assets are downloaded or represented by LFS pointers diff --git a/specs/change/change.spec.md b/specs/change/change.spec.md index 8e155774..c8308e02 100644 --- a/specs/change/change.spec.md +++ b/specs/change/change.spec.md @@ -1,6 +1,6 @@ --- module: change -version: 126 +version: 127 status: active files: - src/change.rs @@ -489,3 +489,5 @@ Acceptance Criteria | 2026-09-09 | close-remaining-specsync-6-0-0-first-user-p1s-pre-commit-honors-config-toml-config-fail-closed-merge-git-sanitization: Close remaining SpecSync 6.0.0 first-user P1s: pre-commit honors config, TOML config fail-closed, merge git sanitization, and 5.x upgrade docs | | 2026-09-09 | drop-interpolated-next-action-from-v1-verifying-assert-messages-so-codeql-cleartext-logging-is-not-a-required-check: Drop interpolated next_action from v1 verifying assert messages so CodeQL cleartext-logging is not a required-check failure | | 2026-09-26 | lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file: Lifecycle commits stage only what the change owns, never every untracked file | + +| 2026-09-30 | LFS evidence (#789) | Support ordinary SHA-256 v1 pointers and streamed hydrated content without invoking filters; preserve full evidence inventory and mutation detection. | diff --git a/specs/change/context.md b/specs/change/context.md index df9ce48c..45aaf085 100644 --- a/specs/change/context.md +++ b/specs/change/context.md @@ -558,3 +558,9 @@ are never committed. It deliberately leaves out `affected_paths`. Those are pref steps. The workflow-v2 baseline is on the list because `change new` writes it in a freshly adopted project: the first test run of the fix left it out, which would have committed a change whose origin anchor never reached history. + +## Git LFS canonical evidence (#789) + +Ordinary Git LFS v1 files are governed inputs, not ignored assets. Evidence recognizes `filter=lfs`, suppresses external LFS filters during worktree inspection, and uses the standard SHA-256/size pointer as the canonical payload. Pointer-only checkouts therefore agree with hydrated checkouts. Hydrated bytes are streamed directly even when Git reports the path clean; pointer edits, content edits and file mode changes remain visible. The empty-file convention is preserved. Unsupported extensions or malformed pointers fail closed; custom filters and encoding/ident transformations remain unsupported. No LFS download is attempted. + +`ignored_paths` still controls meaningful-change policy; it does not omit assets from workspace evidence. Hydrated archives must be read to verify their actual bytes, so memory is bounded but I/O scales with content size. This draft still requires full-project typechecking and integration tests; offline dependency resolution is blocked by unavailable cached `serde-saphyr`. diff --git a/specs/change/requirements.md b/specs/change/requirements.md index 89aebb5c..c2e628da 100644 --- a/specs/change/requirements.md +++ b/specs/change/requirements.md @@ -1348,3 +1348,15 @@ Acceptance Criteria - Canonical spec paths resolve through the same registry-aware resolver materialization writes through. - Paths are project-relative with forward slashes, sorted and deduplicated, and a change that cannot be loaded is an error rather than an empty answer. + +### REQ-change-103 + +Canonical evidence SHALL support ordinary SHA-256 Git LFS v1 files without dropping them from evidence or invoking LFS filters. + +Acceptance Criteria +- A valid pointer and its hydrated content have the same canonical payload and digest, including in-scope assets. +- Modified content, pointer targets, executable modes and deletion remain detectable. +- Hydrated content is streamed with bounded memory; it cannot exceed the aggregate evidence buffer by its raw size. +- Unsupported pointer extensions, malformed pointer-like inputs and other Git content filters remain errors. +- No LFS download or external clean/smudge/process filter is needed to collect evidence. +- Existing sparse, topology, exact attribute-output and stable capture checks remain in force. diff --git a/specs/change/tasks.md b/specs/change/tasks.md index 1974d7a6..bdad040c 100644 --- a/specs/change/tasks.md +++ b/specs/change/tasks.md @@ -65,3 +65,6 @@ spec: change.spec.md - [x] Clarify scoped-review claims versus separately enforced authentication without changing runtime review rules. - [x] Name the untracked paths a lifecycle commit may stage in the domain (`lifecycle_commit_scope`), excluding `affected_paths` prefixes and whole spec directories, and name the lock and transaction journal as never-committed runtime files. + +- [x] Draft explicit LFS pointer/hydrated canonicalization and focused regressions for #789. +- [ ] Run full-project LFS regressions and mandatory pre-push gate once existing dependency availability is resolved. diff --git a/specs/change/testing.md b/specs/change/testing.md index 3402e7c8..eeb2ab17 100644 --- a/specs/change/testing.md +++ b/specs/change/testing.md @@ -77,3 +77,14 @@ Unit tests cover IDs, requirement grammar, semantic application, unsafe command - `REQ-change-050` with `REQ-change-020`/`REQ-change-024` (configured ownership): `configured_module_ownership_lets_a_v2_successor_supersede_exact_only_inputs_of_a_bootstrap_change` is the DISCRIMINATOR for the frozen non-spec tree — on 404fe4d6 `add_supersedes_obligation` refuses `auth` for `tests/auth/legacy.rs` as "not a successor-eligible signed owner"; with the feature the v2 successor adopts the bootstrap's `@exact:test` and `@exact:delivery` entries (an edited test, a deleted test, the package manifest), finalizes, and the bootstrap is successor-covered on `check_project` and `audit_project` before and after the archive commit, with the directory entry signed under the module too and a later disturbance reported through the archived successor with its reason. `supersede_refuses_an_exact_only_input_the_configuration_grants_no_module` is the NEGATIVE: a path the configuration does not grant keeps refusing, names the `owns` remedy, and persists nothing. `configured_ownership_overrides_reserved_exact_classes_for_declared_modules_only` is the unit CONTROL over `acceptance_input_owners`: declared modules only, `.specsync/` never, a mapped test still `@exact:test`. `stale_accepted_change_error_names_exact_only_input_and_audited_reopen` pins the amended exact-only message. - `REQ-change-102`: `lifecycle_commit_scope_names_exactly_what_the_change_owns` asserts the exact owned set (workspace, `specs/auth/auth.spec.md` and its five canonical companions, the sequence ledger, the workflow-v2 and legacy baselines, the bootstrap record, the hash cache) and the exact runtime set (lock, transaction journal). It is exact on purpose, because returning `affected_paths` or the spec directory would pass a looser check. It then moves the workspace into the archive and requires both the package and the vacated workspace to be owned, and an unknown change to be an error. + +## LFS evidence regression draft (REQ-change-103) + +- `lfs_payload_pointer_and_hydrated_bytes_are_equivalent`: canonical bytes, mutation and empty-file semantics. +- `lfs_payload_refuses_malformed_and_extended_pointers`: canonical format, hash kind, extension, size and newline rejection. +- `lfs_payload_streams_large_content_in_bounded_chunks`: payload larger than the aggregate evidence buffer read in chunks no larger than 64 KiB. +- `lfs_attribute_exception_is_specific_and_keeps_exact_output_checks`: only LFS is admitted; custom filters and encoding remain refused. +- `lfs_workspace_and_scoped_evidence_detect_edits_without_filters`: real Git fixture, pointer/hydrated equivalence, changed/staged/deleted input and failing configured filters that must never run. +- `lfs_unchanged_out_of_scope_assets_allow_change_check`: source-only lifecycle with ignored LFS assets, plus mutation detection despite ignored_paths. + +The four pure regression functions can be extracted unchanged with their production helpers into a temporary sha2-only harness using cached dependencies. That establishes the parsing/hashing behavior only, not full SpecSync integration. Full-project compilation and the mandatory pre-push gate require a dependency cache containing serde-saphyr; they are currently blocked. No implementation acceptance is inferred from the isolated harness. diff --git a/src/change.rs b/src/change.rs index 3446c5cf..58e4b43a 100644 --- a/src/change.rs +++ b/src/change.rs @@ -13681,7 +13681,28 @@ fn inspect_git_candidates( return Err("Git core.fsmonitor cannot supply canonical working-tree evidence".into()); } - let checkout_overrides = effective_checkout_overrides(root)?; + // Classify content attributes before asking Git about worktree changes. + // LFS is handled locally below, never by executing a configured filter. + let regular_for_attributes = candidates + .iter() + .filter(|path| { + matches!(modes.get(*path), Some(0o100644 | 0o100755)) + || fs::symlink_metadata(root.join(*path)).is_ok_and(|metadata| metadata.is_file()) + }) + .cloned() + .collect(); + let lfs_paths = validate_canonical_git_attributes(root, ®ular_for_attributes)?; + let mut checkout_overrides = effective_checkout_overrides(root)?; + checkout_overrides.extend( + [ + "filter.lfs.process=", + "filter.lfs.clean=", + "filter.lfs.smudge=", + "filter.lfs.required=false", + ] + .into_iter() + .map(str::to_string), + ); let mut modified = BTreeSet::new(); for batch in &batches { let mut prefix = Vec::new(); @@ -13785,18 +13806,6 @@ fn inspect_git_candidates( } } - let regular_for_attributes = candidates - .iter() - .filter(|path| { - matches!(modes.get(*path), Some(0o100644 | 0o100755)) - && !modified.contains(*path) - && !sparse_absent.contains(*path) - && fs::symlink_metadata(root.join(*path)).is_ok_and(|metadata| metadata.is_file()) - }) - .cloned() - .collect(); - validate_canonical_git_attributes(root, ®ular_for_attributes)?; - let worktree = GitWorktreeState { modified, sparse_absent, @@ -13806,8 +13815,9 @@ fn inspect_git_candidates( .filter_map(|path| { let mode = modes.get(path)?; let object = objects.get(path)?; - (!worktree.modified.contains(path) && matches!(mode, 0o100644 | 0o100755 | 0o120000)) - .then_some(object.as_str()) + ((!worktree.modified.contains(path) || lfs_paths.contains(path)) + && matches!(mode, 0o100644 | 0o100755 | 0o120000)) + .then_some(object.as_str()) }) .collect(); let clean_object_refs: Vec<_> = clean_objects.iter().copied().collect(); @@ -13830,14 +13840,71 @@ fn inspect_git_candidates( )); } } - let entry = capture_git_candidate( - root, - path, - modes.get(path).copied(), - objects.get(path), - &worktree, - Some(&prefetched_blobs), - )?; + let entry = if lfs_paths.contains(path) { + // Never substitute the index just because Git reports a hydrated file + // as clean. Its actual bytes must agree with the recorded pointer. + if let Some(payload) = objects + .get(path) + .and_then(|object| prefetched_blobs.get(object)) + { + validate_lfs_pointer(payload) + .map_err(|error| format!("LFS index `{path}`: {error}"))?; + } + if fs::symlink_metadata(root.join(path)).is_ok_and(|metadata| metadata.is_file()) { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + // Do not follow a symlink or block on a FIFO swapped in after metadata. + options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK); + } + let mut file = options + .open(root.join(path)) + .map_err(|error| format!("failed to open LFS input `{path}`: {error}"))?; + let metadata = file + .metadata() + .map_err(|error| format!("failed to inspect LFS input `{path}`: {error}"))?; + if !metadata.is_file() { + return Err(format!("LFS input is not a regular file: `{path}`")); + } + // Bound even an actively growing file by its observed size. + let limit = metadata + .len() + .checked_add(1) + .ok_or_else(|| format!("LFS input size overflow: `{path}`"))?; + let mut reader = (&mut file).take(limit); + let payload = canonical_lfs_payload(&mut reader) + .map_err(|error| format!("LFS input `{path}`: {error}"))?; + if limit - reader.limit() != metadata.len() { + return Err(format!("LFS input changed size during capture: `{path}`")); + } + GitCapturedEntry { + kind: AcceptanceInputKind::File, + mode: working_file_mode(&metadata), + object: objects.get(path).cloned(), + payload, + } + } else { + capture_git_candidate( + root, + path, + modes.get(path).copied(), + objects.get(path), + &worktree, + Some(&prefetched_blobs), + )? + } + } else { + capture_git_candidate( + root, + path, + modes.get(path).copied(), + objects.get(path), + &worktree, + Some(&prefetched_blobs), + )? + }; payload_bytes = payload_bytes .checked_add(entry.payload.len()) .ok_or_else(|| "Git evidence payload exceeds deterministic bounds".to_string())?; @@ -13992,9 +14059,13 @@ fn git_worktree_state(root: &Path) -> Result, String> { )) } -fn validate_canonical_git_attributes(root: &Path, paths: &BTreeSet) -> Result<(), String> { +fn validate_canonical_git_attributes( + root: &Path, + paths: &BTreeSet, +) -> Result, String> { + let mut lfs_paths = BTreeSet::new(); if paths.is_empty() { - return Ok(()); + return Ok(lfs_paths); } let paths = paths.iter().collect::>(); for batch in paths.chunks(GIT_ATTRIBUTE_BATCH_PATHS) { @@ -14017,12 +14088,15 @@ fn validate_canonical_git_attributes(root: &Path, paths: &BTreeSet) -> R MAX_GIT_ATTRIBUTE_OUTPUT_BYTES, ) .map_err(|error| format!("failed to inspect Git content attributes: {error}"))?; - validate_git_attribute_output(batch, &output)?; + lfs_paths.extend(validate_git_attribute_output(batch, &output)?); } - Ok(()) + Ok(lfs_paths) } -fn validate_git_attribute_output(paths: &[&String], output: &[u8]) -> Result<(), String> { +fn validate_git_attribute_output( + paths: &[&String], + output: &[u8], +) -> Result, String> { if !output.ends_with(&[0]) { return Err("invalid unterminated NUL-delimited `git check-attr` output".into()); } @@ -14042,6 +14116,7 @@ fn validate_git_attribute_output(paths: &[&String], output: &[u8]) -> Result<(), }) .collect::>(); let mut seen = BTreeSet::new(); + let mut lfs_paths = BTreeSet::new(); for record in fields.chunks_exact(3) { let path = record[0]; let attribute = record[1]; @@ -14062,7 +14137,9 @@ fn validate_git_attribute_output(paths: &[&String], output: &[u8]) -> Result<(), "Git returned a duplicate attribute pair `{path}` / `{attribute}`" )); } - if value != "unspecified" && value != "unset" { + if attribute == "filter" && value == "lfs" { + lfs_paths.insert(path.to_string()); + } else if value != "unspecified" && value != "unset" { return Err(format!( "Git `{attribute}` attribute is not supported for canonical evidence: `{path}`" )); @@ -14071,9 +14148,79 @@ fn validate_git_attribute_output(paths: &[&String], output: &[u8]) -> Result<(), if seen != expected { return Err("Git attribute output is missing a requested path/attribute pair".into()); } + Ok(lfs_paths) +} + +// LFS canonicalization deliberately supports the plain v1 SHA-256 format only. +// Extensions may transform bytes; silently stripping them would falsify evidence. +fn validate_lfs_pointer(bytes: &[u8]) -> Result<(), String> { + if bytes.is_empty() { + return Ok(()); // LFS represents an empty object as an empty file. + } + let invalid = || "unsupported or malformed LFS pointer (expected plain SHA-256 v1)".to_string(); + if bytes.len() >= 1024 { + return Err(invalid()); + } + let text = std::str::from_utf8(bytes).map_err(|_| invalid())?; + let mut lines = text.split('\n'); + if lines.next() != Some("version https://git-lfs.github.com/spec/v1") { + return Err(invalid()); + } + let oid = lines + .next() + .and_then(|line| line.strip_prefix("oid sha256:")) + .ok_or_else(invalid)?; + if oid.len() != 64 + || !oid + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(invalid()); + } + let size = lines + .next() + .and_then(|line| line.strip_prefix("size ")) + .ok_or_else(invalid)?; + let parsed = size.parse::().map_err(|_| invalid())?; + if size != parsed.to_string() || lines.next() != Some("") || lines.next().is_some() { + return Err(invalid()); + } Ok(()) } +fn canonical_lfs_payload(reader: &mut impl Read) -> Result, String> { + let mut prefix = Vec::with_capacity(1024); + (&mut *reader) + .take(1024) + .read_to_end(&mut prefix) + .map_err(|error| error.to_string())?; + if prefix.is_empty() || prefix.starts_with(b"version ") { + validate_lfs_pointer(&prefix)?; + return Ok(prefix); + } + let mut hash = Sha256::new(); + hash.update(&prefix); + let mut size = prefix.len() as u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let count = reader + .read(&mut buffer) + .map_err(|error| error.to_string())?; + if count == 0 { + break; + } + size = size + .checked_add(count as u64) + .ok_or("LFS object size overflow")?; + hash.update(&buffer[..count]); + } + Ok(format!( + "version https://git-lfs.github.com/spec/v1\noid sha256:{:x}\nsize {size}\n", + hash.finalize() + ) + .into_bytes()) +} + fn git_blob_bytes(root: &Path, object: &str) -> Result, String> { if let Some(bytes) = GIT_BLOB_CACHE .get_or_init(|| Mutex::new(BTreeMap::new())) diff --git a/src/change_tests.rs b/src/change_tests.rs index 31a123e6..476e5ea9 100644 --- a/src/change_tests.rs +++ b/src/change_tests.rs @@ -19118,3 +19118,220 @@ fn lifecycle_commit_scope_names_exactly_what_the_change_owns() { assert!(lifecycle_commit_scope(root, "no-such-change").is_err()); } + +// REQ-change-103: portable LFS evidence, without executing Git LFS. +#[test] +fn lfs_payload_pointer_and_hydrated_bytes_are_equivalent() { + let contents = b"asset bytes\n"; + let pointer = format!( + "version https://git-lfs.github.com/spec/v1\noid sha256:{}\nsize {}\n", + sha256_hex(contents), + contents.len() + ) + .into_bytes(); + assert_eq!( + canonical_lfs_payload(&mut contents.as_slice()).unwrap(), + pointer + ); + assert_eq!( + canonical_lfs_payload(&mut pointer.as_slice()).unwrap(), + pointer + ); + assert!( + canonical_lfs_payload(&mut b"".as_slice()) + .unwrap() + .is_empty() + ); + assert_ne!( + canonical_lfs_payload(&mut b"asset edits\n".as_slice()).unwrap(), + pointer + ); +} + +#[test] +fn lfs_payload_refuses_malformed_and_extended_pointers() { + let pointer = canonical_lfs_payload(&mut b"asset".as_slice()).unwrap(); + let pointer = String::from_utf8(pointer).unwrap(); + for invalid in [ + pointer.trim_end().to_string(), + pointer.replace("size 5", "size 05"), + pointer.replace("size 5", "size 18446744073709551616"), + pointer.replace("spec/v1", "spec/v2"), + pointer.replace("oid sha256:", "oid sha512:"), + pointer.replace("\noid", "\next-0-test sha256:unsupported\noid"), + pointer.replace("\n", "\r\n"), + format!("{pointer}extra ignored\n"), + format!("version {}", "x".repeat(2048)), + ] { + assert!( + canonical_lfs_payload(&mut invalid.as_bytes()).is_err(), + "{invalid}" + ); + } +} + +#[test] +fn lfs_payload_streams_large_content_in_bounded_chunks() { + struct BoundedReader { + remaining: u64, + } + impl Read for BoundedReader { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + assert!(buffer.len() <= 64 * 1024); + let count = self.remaining.min(buffer.len() as u64) as usize; + buffer[..count].fill(b'x'); + self.remaining -= count as u64; + Ok(count) + } + } + let size = MAX_GIT_EVIDENCE_PAYLOAD_BYTES as u64 + 17; + let pointer = canonical_lfs_payload(&mut BoundedReader { remaining: size }).unwrap(); + assert!(pointer.len() < 1024); + assert!( + String::from_utf8(pointer) + .unwrap() + .ends_with(&format!("size {size}\n")) + ); +} + +#[test] +fn lfs_attribute_exception_is_specific_and_keeps_exact_output_checks() { + let paths = ["asset.bin".to_string()]; + let refs = paths.iter().collect::>(); + let output = b"asset.bin\0filter\0lfs\0asset.bin\0working-tree-encoding\0unset\0asset.bin\0ident\0unspecified\0"; + assert_eq!( + validate_git_attribute_output(&refs, output).unwrap(), + BTreeSet::from(["asset.bin".into()]) + ); + let unsupported = String::from_utf8(output.to_vec()) + .unwrap() + .replace("lfs", "custom"); + assert!(validate_git_attribute_output(&refs, unsupported.as_bytes()).is_err()); + let unsupported = String::from_utf8(output.to_vec()) + .unwrap() + .replace("unset", "UTF-8"); + assert!(validate_git_attribute_output(&refs, unsupported.as_bytes()).is_err()); + assert!(validate_git_attribute_output(&refs, &output[..output.len() - 1]).is_err()); +} + +#[test] +fn lfs_workspace_and_scoped_evidence_detect_edits_without_filters() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + quiet_git(root, &["init", "-b", "main"]); + quiet_git(root, &["config", "user.email", "test@example.com"]); + quiet_git(root, &["config", "user.name", "Test"]); + // Override global installations too: this fixture never needs git-lfs. + for setting in [ + "filter.lfs.process=", + "filter.lfs.clean=", + "filter.lfs.smudge=", + "filter.lfs.required=false", + ] { + let (key, value) = setting.split_once('=').unwrap(); + quiet_git(root, &["config", key, value]); + } + fs::write(root.join(".gitattributes"), "*.bin filter=lfs -text\n").unwrap(); + let original = b"asset bytes\n"; + let pointer = canonical_lfs_payload(&mut original.as_slice()).unwrap(); + fs::write(root.join("asset.bin"), &pointer).unwrap(); + quiet_git(root, &["add", ".gitattributes", "asset.bin"]); + quiet_git(root, &["commit", "-m", "LFS pointer fixture"]); + // If any evidence query invokes a clean/process filter, Git will fail. + quiet_git(root, &["config", "filter.lfs.process", "false"]); + quiet_git(root, &["config", "filter.lfs.clean", "false"]); + quiet_git(root, &["config", "filter.lfs.required", "true"]); + let pointer_digest = project_input_digest(root).unwrap(); + let candidates = BTreeSet::from(["asset.bin".to_string()]); + let scoped = git_evidence_with_policy(root, &candidates, false, |_, _| {}).unwrap(); + fs::write(root.join("asset.bin"), original).unwrap(); + assert_eq!(pointer_digest, project_input_digest(root).unwrap()); + assert_eq!( + scoped, + git_evidence_with_policy(root, &candidates, false, |_, _| {}).unwrap() + ); + fs::write(root.join("asset.bin"), b"asset edits\n").unwrap(); + assert_ne!(pointer_digest, project_input_digest(root).unwrap()); + assert_ne!( + scoped, + git_evidence_with_policy(root, &candidates, false, |_, _| {}).unwrap() + ); + let edited_pointer = canonical_lfs_payload(&mut b"asset edits\n".as_slice()).unwrap(); + fs::write(root.join("asset.bin"), &edited_pointer).unwrap(); + assert_ne!(pointer_digest, project_input_digest(root).unwrap()); + quiet_git( + root, + &[ + "-c", + "filter.lfs.process=", + "-c", + "filter.lfs.clean=", + "-c", + "filter.lfs.required=false", + "add", + "asset.bin", + ], + ); + assert_ne!(pointer_digest, project_input_digest(root).unwrap()); + fs::remove_file(root.join("asset.bin")).unwrap(); + assert_ne!(pointer_digest, project_input_digest(root).unwrap()); +} + +#[test] +fn lfs_unchanged_out_of_scope_assets_allow_change_check() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + quiet_git(root, &["init", "-b", "main"]); + quiet_git(root, &["config", "user.email", "test@example.com"]); + quiet_git(root, &["config", "user.name", "Test"]); + for setting in [ + "filter.lfs.process=", + "filter.lfs.clean=", + "filter.lfs.smudge=", + "filter.lfs.required=false", + ] { + let (key, value) = setting.split_once('=').unwrap(); + quiet_git(root, &["config", key, value]); + } + fs::create_dir(root.join("assets")).unwrap(); + fs::write(root.join("README.md"), "base\n").unwrap(); + fs::write(root.join(".gitattributes"), "assets/* filter=lfs -text\n").unwrap(); + let contents = b"archived source art"; + fs::write( + root.join("assets/archive.bin"), + canonical_lfs_payload(&mut contents.as_slice()).unwrap(), + ) + .unwrap(); + quiet_git(root, &["add", "."]); + quiet_git(root, &["commit", "-m", "source and unrelated LFS asset"]); + let record = current_workflow_record(root, completed_no_spec_record(root)); + let mut policy: serde_json::Value = + serde_json::from_slice(&fs::read(root.join(POLICY_PATH)).unwrap()).unwrap(); + policy["ignored_paths"] = serde_json::json!([".specsync/", "specs/", "assets/"]); + fs::write( + root.join(POLICY_PATH), + serde_json::to_vec_pretty(&policy).unwrap(), + ) + .unwrap(); + approve_definition(root, &record.id, Some("Scope owner".into()), None).unwrap(); + assert!( + check_change(root, Some(&record.id)) + .unwrap() + .unwrap() + .passed + ); + fs::write(root.join("assets/archive.bin"), contents).unwrap(); + assert!( + check_change(root, Some(&record.id)) + .unwrap() + .unwrap() + .passed + ); + let digest = project_input_digest(root).unwrap(); + fs::write(root.join("assets/archive.bin"), b"different source art").unwrap(); + assert_ne!( + digest, + project_input_digest(root).unwrap(), + "ignored_paths must not hide an asset mutation" + ); +} From cd4903853150e32e31e486b4e433ea8e7a8a272f Mon Sep 17 00:00:00 2001 From: 0xGaspar Date: Wed, 30 Sep 2026 16:47:27 -0300 Subject: [PATCH 2/5] chore(lifecycle): materialize support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes --- .../change.md | 2 +- .../state.json | 5 ++-- .../tasks.md | 2 +- .../verification-attempts.json | 23 +++++++++++++++++++ .../verification.json | 18 +++++++++++++++ specs/change/change.spec.md | 3 ++- specs/change/context.md | 2 +- specs/change/testing.md | 2 +- 8 files changed, 50 insertions(+), 7 deletions(-) create mode 100644 .specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/verification-attempts.json create mode 100644 .specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/verification.json diff --git a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/change.md b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/change.md index 40513906..4a9600e4 100644 --- a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/change.md +++ b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/change.md @@ -1,6 +1,6 @@ --- id: support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes -state: approved +state: implementing type: bug_fix base_commit: 6a47f2cd0c6dc5dfa4168b79ac6dda960966bcfc --- diff --git a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/state.json b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/state.json index 298e717d..4b6601f7 100644 --- a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/state.json +++ b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/state.json @@ -7,10 +7,11 @@ "title": "Support Git LFS pointers and hydrated assets in canonical change evidence without hiding content changes", "description": "Support Git LFS pointers and hydrated assets in canonical change evidence without hiding content changes", "kind": "bug_fix", - "state": "approved", + "state": "verifying", + "canonical_applied": true, "base_commit": "6a47f2cd0c6dc5dfa4168b79ac6dda960966bcfc", "created_at": 1790795521, - "updated_at": 1790795582, + "updated_at": 1790797646, "affected_specs": [ "change" ], diff --git a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/tasks.md b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/tasks.md index 6f9b32ce..e459d50b 100644 --- a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/tasks.md +++ b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/tasks.md @@ -8,4 +8,4 @@ artifact: tasks - [x] Implement LFS evidence collection. - [x] Add parity, mutation, failure and lifecycle regressions. - [x] Synchronize specs and record actual validation. -- [ ] Pass pre-push gate before publishing a PR. +- [x] Pass pre-push gate before publishing a PR. diff --git a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/verification-attempts.json b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/verification-attempts.json new file mode 100644 index 00000000..bd441cce --- /dev/null +++ b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/verification-attempts.json @@ -0,0 +1,23 @@ +{ + "schema_version": 1, + "attempts": [ + { + "timestamp": 1790797641, + "commit": "8fae3e45e7938a5ffa9b391e0a1588e843ee6655", + "contract_digest": "b48bcae372df50ad508a103108ee6cd299ac3a323e35a3d14f16eb5503f28d28", + "execution_digest": "77bc6f504f3886cd4b5252bacdaa6633cc8b79bcfcb00f3a3b9378c6590ca23e", + "workspace_digest": "962b5ac9558a8380ce086fac7036216738c168369f928c8bc54a0cf570757db4", + "passed": true, + "commands": [ + { + "command": "specsync check --spec change", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-change-103" + ] + } + ] +} diff --git a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/verification.json b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/verification.json new file mode 100644 index 00000000..90db8d8d --- /dev/null +++ b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/verification.json @@ -0,0 +1,18 @@ +{ + "timestamp": 1790797641, + "commit": "8fae3e45e7938a5ffa9b391e0a1588e843ee6655", + "contract_digest": "b48bcae372df50ad508a103108ee6cd299ac3a323e35a3d14f16eb5503f28d28", + "execution_digest": "77bc6f504f3886cd4b5252bacdaa6633cc8b79bcfcb00f3a3b9378c6590ca23e", + "workspace_digest": "962b5ac9558a8380ce086fac7036216738c168369f928c8bc54a0cf570757db4", + "passed": true, + "commands": [ + { + "command": "specsync check --spec change", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-change-103" + ] +} diff --git a/specs/change/change.spec.md b/specs/change/change.spec.md index c8308e02..b90e1508 100644 --- a/specs/change/change.spec.md +++ b/specs/change/change.spec.md @@ -1,6 +1,6 @@ --- module: change -version: 127 +version: 128 status: active files: - src/change.rs @@ -491,3 +491,4 @@ Acceptance Criteria | 2026-09-26 | lifecycle-commits-stage-only-what-the-change-owns-never-every-untracked-file: Lifecycle commits stage only what the change owns, never every untracked file | | 2026-09-30 | LFS evidence (#789) | Support ordinary SHA-256 v1 pointers and streamed hydrated content without invoking filters; preserve full evidence inventory and mutation detection. | +| 2026-09-30 | support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes: Support Git LFS pointers and hydrated assets in canonical change evidence without hiding content changes | diff --git a/specs/change/context.md b/specs/change/context.md index 45aaf085..bab4d91b 100644 --- a/specs/change/context.md +++ b/specs/change/context.md @@ -563,4 +563,4 @@ origin anchor never reached history. Ordinary Git LFS v1 files are governed inputs, not ignored assets. Evidence recognizes `filter=lfs`, suppresses external LFS filters during worktree inspection, and uses the standard SHA-256/size pointer as the canonical payload. Pointer-only checkouts therefore agree with hydrated checkouts. Hydrated bytes are streamed directly even when Git reports the path clean; pointer edits, content edits and file mode changes remain visible. The empty-file convention is preserved. Unsupported extensions or malformed pointers fail closed; custom filters and encoding/ident transformations remain unsupported. No LFS download is attempted. -`ignored_paths` still controls meaningful-change policy; it does not omit assets from workspace evidence. Hydrated archives must be read to verify their actual bytes, so memory is bounded but I/O scales with content size. This draft still requires full-project typechecking and integration tests; offline dependency resolution is blocked by unavailable cached `serde-saphyr`. +`ignored_paths` still controls meaningful-change policy; it does not omit assets from workspace evidence. Hydrated archives must be read to verify their actual bytes, so memory is bounded but I/O scales with content size. After Bruno authorized a one-time fetch of locked Cargo dependencies, local typechecking, all six LFS regression tests (including Git and lifecycle integration), and the mandatory pre-push gate passed. Full-suite validation is in progress; human implementation review remains pending. diff --git a/specs/change/testing.md b/specs/change/testing.md index eeb2ab17..95f6a1eb 100644 --- a/specs/change/testing.md +++ b/specs/change/testing.md @@ -87,4 +87,4 @@ Unit tests cover IDs, requirement grammar, semantic application, unsafe command - `lfs_workspace_and_scoped_evidence_detect_edits_without_filters`: real Git fixture, pointer/hydrated equivalence, changed/staged/deleted input and failing configured filters that must never run. - `lfs_unchanged_out_of_scope_assets_allow_change_check`: source-only lifecycle with ignored LFS assets, plus mutation detection despite ignored_paths. -The four pure regression functions can be extracted unchanged with their production helpers into a temporary sha2-only harness using cached dependencies. That establishes the parsing/hashing behavior only, not full SpecSync integration. Full-project compilation and the mandatory pre-push gate require a dependency cache containing serde-saphyr; they are currently blocked. No implementation acceptance is inferred from the isolated harness. +The four pure regression functions can be extracted unchanged with their production helpers into a temporary sha2-only harness using cached dependencies. That establishes the parsing/hashing behavior only, not full SpecSync integration. After the explicitly authorized dependency fetch, all six LFS tests passed in the full project, including both Git/lifecycle integration regressions. The mandatory pre-push gate also passed. No human implementation acceptance is inferred from these checks. The approved change-package notes describe the earlier cache blocker; this living testing record tracks subsequent validation without rewriting the scope approval. From ad6519ca71867d444b0138a209c2db797b48ded7 Mon Sep 17 00:00:00 2001 From: 0xGaspar Date: Wed, 30 Sep 2026 16:47:41 -0300 Subject: [PATCH 3/5] chore(lifecycle): record support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes verification --- .../state.json | 2 +- .../verification-attempts.json | 18 ++++++++++++++++++ .../verification.json | 4 ++-- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/state.json b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/state.json index 4b6601f7..528d17eb 100644 --- a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/state.json +++ b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/state.json @@ -11,7 +11,7 @@ "canonical_applied": true, "base_commit": "6a47f2cd0c6dc5dfa4168b79ac6dda960966bcfc", "created_at": 1790795521, - "updated_at": 1790797646, + "updated_at": 1790797660, "affected_specs": [ "change" ], diff --git a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/verification-attempts.json b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/verification-attempts.json index bd441cce..9f74867b 100644 --- a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/verification-attempts.json +++ b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/verification-attempts.json @@ -18,6 +18,24 @@ "requirement_ids": [ "REQ-change-103" ] + }, + { + "timestamp": 1790797655, + "commit": "cd4903853150e32e31e486b4e433ea8e7a8a272f", + "contract_digest": "b48bcae372df50ad508a103108ee6cd299ac3a323e35a3d14f16eb5503f28d28", + "execution_digest": "77bc6f504f3886cd4b5252bacdaa6633cc8b79bcfcb00f3a3b9378c6590ca23e", + "workspace_digest": "962b5ac9558a8380ce086fac7036216738c168369f928c8bc54a0cf570757db4", + "passed": true, + "commands": [ + { + "command": "specsync check --spec change", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-change-103" + ] } ] } diff --git a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/verification.json b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/verification.json index 90db8d8d..be8eb02c 100644 --- a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/verification.json +++ b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/verification.json @@ -1,6 +1,6 @@ { - "timestamp": 1790797641, - "commit": "8fae3e45e7938a5ffa9b391e0a1588e843ee6655", + "timestamp": 1790797655, + "commit": "cd4903853150e32e31e486b4e433ea8e7a8a272f", "contract_digest": "b48bcae372df50ad508a103108ee6cd299ac3a323e35a3d14f16eb5503f28d28", "execution_digest": "77bc6f504f3886cd4b5252bacdaa6633cc8b79bcfcb00f3a3b9378c6590ca23e", "workspace_digest": "962b5ac9558a8380ce086fac7036216738c168369f928c8bc54a0cf570757db4", From 419b5f1f12bd6e7069371e6fc90e02328cc40d75 Mon Sep 17 00:00:00 2001 From: 0xGaspar Date: Wed, 30 Sep 2026 16:59:22 -0300 Subject: [PATCH 4/5] chore(lifecycle): materialize support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes --- .../state.json | 2 +- .../verification-attempts.json | 18 ++++++++++++++++++ .../verification.json | 6 +++--- specs/change/context.md | 2 +- specs/change/testing.md | 2 ++ 5 files changed, 25 insertions(+), 5 deletions(-) diff --git a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/state.json b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/state.json index 528d17eb..a23cf8e0 100644 --- a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/state.json +++ b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/state.json @@ -11,7 +11,7 @@ "canonical_applied": true, "base_commit": "6a47f2cd0c6dc5dfa4168b79ac6dda960966bcfc", "created_at": 1790795521, - "updated_at": 1790797660, + "updated_at": 1790798361, "affected_specs": [ "change" ], diff --git a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/verification-attempts.json b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/verification-attempts.json index 9f74867b..d830ca60 100644 --- a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/verification-attempts.json +++ b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/verification-attempts.json @@ -36,6 +36,24 @@ "requirement_ids": [ "REQ-change-103" ] + }, + { + "timestamp": 1790798360, + "commit": "ad6519ca71867d444b0138a209c2db797b48ded7", + "contract_digest": "b48bcae372df50ad508a103108ee6cd299ac3a323e35a3d14f16eb5503f28d28", + "execution_digest": "77bc6f504f3886cd4b5252bacdaa6633cc8b79bcfcb00f3a3b9378c6590ca23e", + "workspace_digest": "f2f7eae63e57fd307a3cb4d0785e9717cd84ebe4214e666f5639ec3e11890e6e", + "passed": true, + "commands": [ + { + "command": "specsync check --spec change", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-change-103" + ] } ] } diff --git a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/verification.json b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/verification.json index be8eb02c..e2904315 100644 --- a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/verification.json +++ b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/verification.json @@ -1,9 +1,9 @@ { - "timestamp": 1790797655, - "commit": "cd4903853150e32e31e486b4e433ea8e7a8a272f", + "timestamp": 1790798360, + "commit": "ad6519ca71867d444b0138a209c2db797b48ded7", "contract_digest": "b48bcae372df50ad508a103108ee6cd299ac3a323e35a3d14f16eb5503f28d28", "execution_digest": "77bc6f504f3886cd4b5252bacdaa6633cc8b79bcfcb00f3a3b9378c6590ca23e", - "workspace_digest": "962b5ac9558a8380ce086fac7036216738c168369f928c8bc54a0cf570757db4", + "workspace_digest": "f2f7eae63e57fd307a3cb4d0785e9717cd84ebe4214e666f5639ec3e11890e6e", "passed": true, "commands": [ { diff --git a/specs/change/context.md b/specs/change/context.md index bab4d91b..66e47dae 100644 --- a/specs/change/context.md +++ b/specs/change/context.md @@ -563,4 +563,4 @@ origin anchor never reached history. Ordinary Git LFS v1 files are governed inputs, not ignored assets. Evidence recognizes `filter=lfs`, suppresses external LFS filters during worktree inspection, and uses the standard SHA-256/size pointer as the canonical payload. Pointer-only checkouts therefore agree with hydrated checkouts. Hydrated bytes are streamed directly even when Git reports the path clean; pointer edits, content edits and file mode changes remain visible. The empty-file convention is preserved. Unsupported extensions or malformed pointers fail closed; custom filters and encoding/ident transformations remain unsupported. No LFS download is attempted. -`ignored_paths` still controls meaningful-change policy; it does not omit assets from workspace evidence. Hydrated archives must be read to verify their actual bytes, so memory is bounded but I/O scales with content size. After Bruno authorized a one-time fetch of locked Cargo dependencies, local typechecking, all six LFS regression tests (including Git and lifecycle integration), and the mandatory pre-push gate passed. Full-suite validation is in progress; human implementation review remains pending. +`ignored_paths` still controls meaningful-change policy; it does not omit assets from workspace evidence. Hydrated archives must be read to verify their actual bytes, so memory is bounded but I/O scales with content size. After Bruno authorized a one-time fetch of locked Cargo dependencies, local typechecking, all six LFS regression tests (including Git and lifecycle integration), and the mandatory pre-push gate passed. Full local verification also passed: 2,510 unit tests, 437 integration tests, release build, strict specs, 52 release-candidate checks and 29 CI-gate checks. Human implementation review remains pending. diff --git a/specs/change/testing.md b/specs/change/testing.md index 95f6a1eb..44b2f072 100644 --- a/specs/change/testing.md +++ b/specs/change/testing.md @@ -88,3 +88,5 @@ Unit tests cover IDs, requirement grammar, semantic application, unsafe command - `lfs_unchanged_out_of_scope_assets_allow_change_check`: source-only lifecycle with ignored LFS assets, plus mutation detection despite ignored_paths. The four pure regression functions can be extracted unchanged with their production helpers into a temporary sha2-only harness using cached dependencies. That establishes the parsing/hashing behavior only, not full SpecSync integration. After the explicitly authorized dependency fetch, all six LFS tests passed in the full project, including both Git/lifecycle integration regressions. The mandatory pre-push gate also passed. No human implementation acceptance is inferred from these checks. The approved change-package notes describe the earlier cache blocker; this living testing record tracks subsequent validation without rewriting the scope approval. + +Full verification on 2026-09-30: `CARGO_NET_OFFLINE=true fledge lanes run verify` passed all six steps in 13m 33s: formatting, lint/type checks, 2,510 unit tests, 437 integration tests, release build, strict spec coverage, 52 release-candidate validator tests and 29 CI-gate tests. This records automated validation, not human implementation approval. From 0d364599167afa63de99746156fe3e3d98ed4e3a Mon Sep 17 00:00:00 2001 From: 0xGaspar Date: Wed, 30 Sep 2026 16:59:27 -0300 Subject: [PATCH 5/5] chore(lifecycle): record support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes verification --- .../state.json | 2 +- .../verification-attempts.json | 18 ++++++++++++++++++ .../verification.json | 4 ++-- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/state.json b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/state.json index a23cf8e0..26819020 100644 --- a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/state.json +++ b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/state.json @@ -11,7 +11,7 @@ "canonical_applied": true, "base_commit": "6a47f2cd0c6dc5dfa4168b79ac6dda960966bcfc", "created_at": 1790795521, - "updated_at": 1790798361, + "updated_at": 1790798366, "affected_specs": [ "change" ], diff --git a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/verification-attempts.json b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/verification-attempts.json index d830ca60..bd687821 100644 --- a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/verification-attempts.json +++ b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/verification-attempts.json @@ -54,6 +54,24 @@ "requirement_ids": [ "REQ-change-103" ] + }, + { + "timestamp": 1790798365, + "commit": "419b5f1f12bd6e7069371e6fc90e02328cc40d75", + "contract_digest": "b48bcae372df50ad508a103108ee6cd299ac3a323e35a3d14f16eb5503f28d28", + "execution_digest": "77bc6f504f3886cd4b5252bacdaa6633cc8b79bcfcb00f3a3b9378c6590ca23e", + "workspace_digest": "f2f7eae63e57fd307a3cb4d0785e9717cd84ebe4214e666f5639ec3e11890e6e", + "passed": true, + "commands": [ + { + "command": "specsync check --spec change", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-change-103" + ] } ] } diff --git a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/verification.json b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/verification.json index e2904315..27ce5b65 100644 --- a/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/verification.json +++ b/.specsync/changes/support-git-lfs-pointers-and-hydrated-assets-in-canonical-change-evidence-without-hiding-content-changes/verification.json @@ -1,6 +1,6 @@ { - "timestamp": 1790798360, - "commit": "ad6519ca71867d444b0138a209c2db797b48ded7", + "timestamp": 1790798365, + "commit": "419b5f1f12bd6e7069371e6fc90e02328cc40d75", "contract_digest": "b48bcae372df50ad508a103108ee6cd299ac3a323e35a3d14f16eb5503f28d28", "execution_digest": "77bc6f504f3886cd4b5252bacdaa6633cc8b79bcfcb00f3a3b9378c6590ca23e", "workspace_digest": "f2f7eae63e57fd307a3cb4d0785e9717cd84ebe4214e666f5639ec3e11890e6e",