Source drift: hash the ingested body, skip an unchanged re-ingest, flag a changed one #105
Closed
opened 2026-08-22 11:59:04 +00:00 by mathias
·
3 comments
No Branch/Tag Specified
main
fix/pipeline-writes-158
fix/edit-path-io-192
fix/create-regular-188
fix/guard-special-179
fix/linkcheck-keeps-write-178
fix/write-dir-redirect-173
fix/tunnel-detect-174
fix/pipeline-root-157
w3/154-mcp-auth-wiring
fix/tunnel-traversal
test/in-brain-targets-171
fix/brain-scan-root-157
fix/graph-wiki-root-157
fix/vectorstore-root-157
fix/notes-root-157
fix/search-root-157
fix/brainwide-root-157
fix/raw-symlinks-153
feat/symlink-tripwire-157
fix/rest-auth-149
fix/123-brain-edit-envelope-check
feat/106-page-aware-extraction
fix/105-source-drift-on-main
fix/104-surface-merge-conflicts-on-main
agent/b1-compile-on-ingest
agent/104-surface-merge-conflicts
agent/105-source-drift
agent/107-loud-unconfigured-alerting
feat/brain-read-edit
fix/capture-i1-refusal-names-target
renovate/configure
feat/consolidate-single-harness
icebox/cmd-routing
feat/brain-pending-promote
fix/close-session-classification-refresh
fix/capture-summarywriter
v0.24.0
v0.23.1
v0.23.0
v0.22.0
v0.21.1
v0.21.0
v0.20.1
v0.20.0
v0.19.1
v0.19.0
v0.18.4
v0.18.3
v0.18.2
v0.18.1
v0.18.0
v0.17.0
v0.16.1
v0.16.0
v0.15.0
v0.14.0
v0.13.2
v0.13.1
v0.13.0
v0.12.1
v0.12.0
v0.11.2
icebox/cmd-routing-2026-07-01
v0.11.1
v0.11.0
v0.10.0
v0.9.0
v0.8.0
v0.7.0
v0.6.0
v0.5.0
v0.4.1
v0.4.0
v0.3.0
v0.2.0
v0.1.0
Labels
Clear labels
agent/ready
blocked/client
blocked/supplier
blocked/upstream
risk/high
risk/low
risk/medium
size/l
size/m
size/s
size/xs
status:dispatch
status:dispatched
status:review
status:reviewing
type/chore
type/decision
type/docs
type/feat
type/fix
type/ops
type/research
type/test
Meets the agent-ready contract; authorizes unattended execution
Waiting on the client
Waiting on a supplier
Waiting on an upstream project or vendor
Irreversible, security-relevant, or control plane
Reversible, local, test-covered
Touches shared state, deploys or data
Too big - split it
Under two days
Under half a day
Under an hour
Maintenance, no behaviour change
Human decision required; never agent/ready
Documentation only
New capability
Corrects broken behaviour
Infrastructure, deploy, runtime
Investigate; output is a finding, not code
Test coverage only
Milestone
No items
No Milestone
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: mathias/hyperguild#105
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Sprint B item 3. Filed so it stops living only in a conversation.
What
On ingest, hash the source body. Re-ingesting the same URL:
content_hashalready exists in the codebase (brain_get/brain_readreturn it,brain_updatestamps prior-hash on supersede) — reuse the concept rather than inventing a second hashing scheme.Why it is now an optimisation, not a correctness fix
Worth stating plainly, because the plan had this the other way round.
The reason to want a skip-if-unchanged guard was that re-ingesting grew pages without bound. #103 fixed that in the write itself: the merge is idempotent, and re-ingesting unchanged content is byte-identical by test. So this issue no longer carries correctness — it carries cost and signal:
half_life(brain#21) — avolatilenote whose source has changed is the highest-value re-verification candidate in the corpus.Building it before #103 would have put idempotency in the guard rather than the write, which is the wrong dependency direction — the write must be safe on its own.
Scope
Interaction to decide
Where does the hash live if the on-disk frontmatter wins on merge (#103)? If ingest writes the hash into frontmatter, the merge must let that specific field be updated by the generator while everything else stays human-owned — i.e. one field the generator owns. Cleanest is probably a generator-owned block rather than a loose key, so the ownership boundary is explicit.
Risk: LOW — additive, and the failure mode of a wrong hash is an unnecessary re-extraction, not data loss.
Refs: #103, brain#21 (
half_life), #43.PR #126 review: BLOCKED, not merged.
refreshOwnediningestion/internal/wiki/merge.goonly replaces an existing generator-owned frontmatter line in the on-disk page; it cannot append one that is not already present. Everywiki/sources/*.mdpage that existed before this PR ships has nosource_hashkey at all. On its next re-ingest,Merge(existing, fresh)callsrefreshOwned(fmA, fmB):fmAhas nosource_hashline to replace, sobVals["source_hash"]is silently discarded and the field never lands on disk.storedSourceHashthen keeps reading""forever, sobodyHash == storedSourceHashnever matches for any pre-existing source page — the skip-on-unchanged feature this issue asks for never engages for the entire existing corpus, permanently, with no warning (fails safe — always re-extracts — but silently defeats the issue's stated purpose for everything except brand-new sources ingested after this PR lands).Verified directly: a standalone
refreshOwnedtest withalackingsource_hashandbsupplying it returnsaunchanged (nosource_hashline added). The PR's ownTestMerge_GeneratorOwnedFrontmatterFieldsAreRefreshednever covers this — itsexistingfixture already carriessource_hash: aaaa, so the append-vs-replace gap is untested.pipeline_test.go's skip test only exercises a page created fresh within the test's own tempdir, same gap.This is exactly the interaction the issue itself flagged as the hard part ("the merge must let that specific field be updated by the generator") and the implementation gets wrong for the pre-existing corpus. Fix:
refreshOwnedmust append a missing generator-owned key, not only replace an existing one.CI was green (run 7838,
CI / Lint / Test / Vetsuccess) — green because no test exercises the missing-key case. Not merged; PR #126 branchfix/105-source-drift-on-mainleft open. #127 should not stack until this is fixed.PR #126 review (round 3): BLOCKED, not merged.
Rounds 1 and 2 are fixed (verified directly, not just via the suite):
refreshOwnednow appends a missing generator-owned key (round 1) and never splices unfenced text into the body (round 2) — the rewrite onto a parsedfmLinelist plus a canonicalrenderFrontmatterBlockgenuinely retires the fence-position-index bug class; confirmed by mutatingparseFrontmatterBlock'sstrings.Index(rest, "\n---\n")fence search, which a real test inmerge_test.gocatches and turns red.A 4th bug, in the same function, introduced by this rewrite:
parseFrontmatterBlock's line classifier (merge.go) doeskey, val, found := strings.Cut(line, ":")with no guard against the line being a non-key raw line that merely contains a colon. The sibling it claims to mirror —internal/api/frontmatter.go'sparseFrontmatter— explicitly guards this:!ok || key == "" || strings.HasPrefix(strings.TrimSpace(line), "#"). The new code dropped that clause. Two concrete, verified repros:#see also: https://x/y) gets misparsed as key=# see also, value=https://x/y, then re-rendered with collapsed whitespace — byte drift on every subsequent ingest, which is exactly the invariantTestRunRaw_ReIngestingUnchangedContentIsByteIdenticalexists to guard, just not for this fixture shape.aliases:\n - 'Chapter 3: The Reckoning', an entirely plausible value for this corpus (book/chapter titles) — gets misparsed as key=- 'Chapter 3, value=The Reckoning', and re-renders without its 2-space indentation:- 'Chapter 3: The Reckoning'instead of- 'Chapter 3: The Reckoning'. That's not cosmetic — it changes the line's YAML structural meaning (de-indents it out of the list) and reproduces the same class of damage round 2 already found ("corrupted the page"), just via a different code path (parse-side misclassification instead of fence-position tracking).Both repros verified locally against
874206b:go teston a hand-built fixture round-trips to a different byte string than the input, and for the list-item case the structural indentation is lost. Neither is covered bymerge_test.go's existing fixtures (none contain a colon inside a comment or a list-item value) or by the pipeline suite.This triggers on the normal path, not an obscure one:
refreshOwnedre-renders the whole frontmatter block throughrenderFrontmatterBlockwheneversource_hash/last_updatedchange, i.e. on effectively every non-skipped ingest — this PR's own stated purpose. Any existing page with a#-comment or a colon-bearing list item in its frontmatter will silently degrade on its next ingest.Fix: reproduce the sibling's
#-comment guard (and more generally, don't treat a colon anywhere in a non-key raw line as a key separator) inparseFrontmatterBlock.Separately, confirmed not a blocker but worth recording: the empty-value render branch (the
aliases:no-trailing-space fix) has no direct unit test ininternal/wikiitself — disabling that branch entirely (case l.value == "":mutated to always-false) leavesgo test ./internal/wiki/...green; it is only caught byTestRunRaw_ReIngestingUnchangedContentIsByteIdenticalininternal/pipeline. Same pattern as round 2: the wiki package's own tests are not self-sufficient proof for this function.CI on head
874206bis green (run 7842) and the other three "done when" clauses of #105 (hash post-frontmatter body, skip+log+visible, changed→proceed+re-stamp hash, one-extraction test) are genuinely met — this block is narrowly about the new frontmatter parser's colon-handling, not the drift-skip feature itself.Not merged. Branch
fix/105-source-drift-on-mainleft open. #127 should continue to wait, per its own stacking note.PR #126 merged (round 4, final): merge commit
b340f86.Round 4 fixed the last bug:
parseFrontmatterBlockdropped the siblingapi/frontmatter.gopackage's comment guard AND misparsed an indented YAML block-list item containing a colon as a top-level key. Commit43f43e4restored the comment guard verbatim and added an indentation check (any line starting with space/tab is a raw continuation line, checked beforestrings.Cutruns at all), with two new exact-match regression tests.Verification performed before merge:
43f43e4againstmain.#(title: '#1 in the series'— confirmed the comment guard checks the whole trimmed line, not just the post-colon value, so this is correctly NOT treated as a comment); a whitespace-only line (single tab, no crash online[0]); a multi-byte-first-rune line (日本語: value, correctly falls through to the key/value path with no panic). All five held.TestRefreshOwned_IndentedListItemsWithColonsAreNotMisparsedAsKeysand a scratch test went red; (2) narrowed the comment guard to check only the post-colon value instead of the whole line —TestRefreshOwned_CommentLinesSurviveUnmangledwent red. Both mutations were caught.ingestion/:go build ./...,go vet ./...,go test ./... -race -count=1— all green, checked by exit code not grep.internal/api/frontmatter.go'sparseFrontmatterstill lacks the indentation guard and has the identical list-item-colon bug, pre-existing and correctly out of scope for this PR.43f43e4(Gitea Actions run 7843, conclusionsuccess), PR wasmergeable: truewith no conflicts.Scope note (not blocking): four review rounds all landed inside
refreshOwned/frontmatter parsing, which is orthogonal to this issue's actual feature (skip-extraction-when-unchanged). In hindsight that mechanism earned its own PR, reviewed independently of the skip-on-unchanged feature it happened to be built to support — the feature diff itself was never what needed four passes. Not acted on here since the code is now correct and tested; noting it for next time a similarly load-bearing helper rides in on a feature PR.Done-when clauses verified: hash of raw post-frontmatter body computed at ingest; stored in a generator-owned frontmatter field (
source_hash, alongsidelast_updated) that survives append/replace correctly for both pre-existing and fresh pages; unchanged source skips extraction (logged, visible per #105's own scope item) and changed source proceeds;TestRunRaw_ReIngestingUnchangedContentIsByteIdentical-class tests cover byte-stability. PR #127 is stacked on top and correctly retargeted ontomain.