Join the conversation

Join the community of Machine Learners and AI enthusiasts.

Sign Up
kanaria007 
posted an update Aug 1
Post
123
✅ Article highlight: Benchmark Publication Without Governance Inflation (art-60-274, v0.1)

TL;DR:
This article argues that a benchmark result is not a governance maturity claim.

A score may be real, reproducible, and worth publishing—and still say nothing by itself about safety, deployability, assurance, institutional quality, or platform maturity. 274 treats benchmark publication as a discipline of comparability, disclosure, lifecycle limits, and anti-inflation.

Read:
kanaria007/agi-structural-intelligence-protocols

Why it matters:
• prevents measured results from being inflated into safety or maturity claims
• separates historical results from current comparability
• makes scope, freshness, omissions, and unsupported readings visible
• allows honest publication without requiring full platform assurance
• treats narrower wording as trust discipline, not underselling

What’s inside:
• the publication triad: comparability, disclosure, and anti-inflation
• bounded publication outcomes such as PUBLISHABLE, PUBLISHABLE_WITH_LIMITS, NOT_COMPARABLE, and NOT_PUBLISHABLE
• benchmark publication profiles
• comparability disclosure notes
• public non-claims registers
• inflation checklists for result-to-maturity, comparison-to-assurance, historical-to-current, and wording inflation

Key idea:
Do not say:

“this system scored well, therefore it is mature, safe, or ready to deploy.”

Say:

“this result was observed under this benchmark and comparability frame, remains valid within these lifecycle and disclosure limits, and does not support these broader governance claims.”

Better benchmark publication is not a louder score.

It is a result that is harder to overread.

Your checklists catch inflated wording. The case I keep hitting inflates with no wording to fix.

A dependency-resolution dataset went up today carrying a narrowly worded claim: 90.0% accuracy on a sealed validation set of entirely unseen packages. Nothing in that sentence overreads. It names the split, the holdout condition, the metric.

I paged all 3,432 rows of that split this morning. It is 81.4% failures, so always predicting failure scores 81.4%. The reported number is 8.6 points over a constant predictor. And on the 718-row subset where the question is actually interesting, always predicting pass scores 89.0%, against that 90.0% headline.

None of your four inflation checks fire on it. Not result-to-maturity, not comparison-to-assurance, not historical-to-current, not wording. The sentence is honest. The split composition did the inflating.

So I think the triad is missing a leg, and it is a cheap one: publish what a trivial predictor scores on the same split. Majority class for a classifier, random guesser for multiple choice, empty output for generation. One number, computed from the labels alone, no model needed.

That also gives PUBLISHABLE_WITH_LIMITS something mechanical to key on. A result sitting within a couple of points of its own trivial baseline is not NOT_PUBLISHABLE. It is publishable with the baseline printed beside it, and readers stop overreading on their own.

Harder to overread is the right goal. I would push it one step: hardest to overread is a score that ships with the number it had to beat.

Does 274 have a slot for the trivial baseline, or does that sit under disclosure?

·

You’re right. 274 had an implicit place for this under comparability and disclosure, but it did not make the trivial-baseline publication surface explicit, so your example passed the existing checks too easily.

I’ve updated the article to add baseline-relative legibility: a task-appropriate trivial baseline under the same metric, scoring rule, split, and slice; the same-scope delta; and a PUBLISHABLE_WITH_LIMITS posture when a meaningful trivial baseline is omitted, the same-scope comparison is unavailable, or the result falls within the declared materiality or uncertainty band.

I also separated two cases that are easy to conflate:

  • a split-derived diagnostic baseline, which shows how much of the score is explained by the realized evaluation distribution; and
  • a precommitted operational baseline, which represents a simple alternative that could actually have been run without inspecting the final labels.

The update also covers decision-relevant slices, baseline selection, and post-hoc operating-point selection.

So the answer to your last question is: it previously sat only implicitly under disclosure. It now has an explicit slot.

Thanks, “the number it had to beat” was exactly the missing publication surface.

Your new section caught me before it caught the publisher.

I pulled art-60-274 at the current head and read 4.4. The clause I did not expect is the anti-gaming one: report the strongest materially applicable trivial baseline, and do not select only the weakest because it creates the largest apparent advantage.

I gave you the weakest.

So I recomputed on the same sealed split. All 3,432 rows, same metric, same scope.

trivial baseline role accuracy
always predict failure split-derived 81.4%
per-pair majority split-derived 81.4%
run the resolver, predict fail iff it fails precommitted operational 97.7%

The published result on that split is 90.0%.

The split-derived family is degenerate here. There are exactly four package pairs and all four lean the same way, so knowing which pair a row is in buys 0.0 points over the constant predictor. Worth knowing on its own for a split sold as unseen-package generalization.

The operational one changes the sign. Its rule is fixed without touching a label: run the resolver, believe it. 2,714 of 2,793 true failures caught, zero false alarms, 79 missed. Which means it also reaches the second published number, 100% precision on failures, at 97.2% recall. Both headline figures, no model.

So the reported result sits below its own trivial baseline, and the baseline I handed you in this thread was 16 points under the real one, in the direction that flattered the argument I was already making.

Your two-role split is what made that visible, and I now think it is the load-bearing part of 4.4 rather than the trivial-baseline idea itself. The 81.4% answers how easy the split was. The 97.7% answers whether you beat something I could have run. Only the second can land above the reported score, so only the second can move a posture.

Your slice clause fires too. The decision-relevant slice is the 718 rows that resolved, of which 79 failed later. That 11.0% is exactly what a resolver cannot see, and it is the reason the dataset exists. No same-scope system score is published for it, so PUBLISHABLE_WITH_LIMITS, as written.

One thing 4.4 has no slot for, and I am not sure it should. The strongest baseline here is the incumbent the work exists to replace. Median resolve stage in that data is 0.17s on a populated cache, so it is not even costly. When the trivial baseline is the status quo, "did you beat it" and "was this worth building" collapse into one question, and a posture rule can only answer the first.

Does the baseline family need an eligibility or cost note, or is that deliberately outside publication discipline?

·

This is a very useful follow-up. And yes: I think eligibility belongs inside publication discipline, while cost belongs there only insofar as it materially affects whether the comparator is actually applicable. Whether the work was “worth building” is deliberately outside that boundary.

4.4 already points in that direction by defining an operational baseline as something that could actually have been run under the stated conditions, and by allowing task costs and operational consequences to affect materiality. So I do not think this needs a new posture rule.

The publication question is:

did you beat an eligible operational comparator under the declared conditions?

If latency, compute, data access, or another operating cost makes a comparator materially non-equivalent, that should be disclosed as part of the comparison. But cost should not be used silently to exclude an incumbent that was in fact available.

The next question

was the improvement worth the engineering effort, purchase price, deployment complexity, or opportunity cost?

is a separate value and decision surface. A benchmark can provide evidence to that decision, but it should not supply the utility function.

More generally, my own view is that this is the healthier role for a benchmark: it is a snapshot of where a system currently succeeds and fails, not a verdict on whether the work was worth building. Falling below an incumbent can be a useful result precisely because it tells you what remains to be solved. The publication problem begins when that snapshot is stretched into a commercial or deployment claim that the measurement itself does not support.

Your resolver result also makes the two-role distinction much clearer than my original example did. The 81.4% split-derived baseline tells us how much the realized evaluation distribution explains. The 97.7% operational baseline tells us whether the published system beat something that could actually have been run.

I would make one small distinction from your wording: I do not think only the operational baseline can affect publication posture in principle. A diagnostic baseline can also force a downgrade if the reported result is at or below what the realized split composition already provides. The operational baseline affects it for a different reason here: the system loses to an available comparator.

And with the 0.17s median resolve stage you reported, at least latency does not appear to explain away that comparison.

Thanks again for rerunning this against the revised section. The anti-gaming rule turning back on the original baseline choice is exactly the kind of failure mode I wanted 4.4 to expose.

Taking the correction. A diagnostic baseline can downgrade, and my "only the operational one" was too strong.

But this split has a property I do not think 4.4 has a name for yet. Its diagnostic baseline is not independent of the operational one.

The 81.4% constant-fail number is not a fact about the task. It is the resolver’s failure rate. 2,714 of 3,432 rows fail at resolution, and that alone is what makes FAIL the majority class.

Condition on the incumbent and the majority flips. On the 718 rows the resolver passes, 639 pass. Always-PASS scores 89.0% there. Same split, opposite constant predictor, and it also clears 81.4%.

So the diagnostic baseline here is the operational baseline with its per-row discrimination deleted. Same rule, one bit instead of 3,432. That is the entire 16.3 point gap: 81.4% is assume the resolver’s modal answer, 97.7% is read it.

Which makes the diagnostic number worse than uninformative when it is reported alone. The published 90.0% clears it by 8.6 points, so the comparison reads as due diligence done. It is the incumbent, wearing enough of a disguise to pass.

I doubt this is rare. Any benchmark whose split composition is produced by the pipeline it means to replace inherits it. The label distribution is the comparator’s output distribution.

So does 4.4 need to ask where the split composition came from? If the majority class is an artifact of the comparator, a diagnostic baseline is not a second check. It is the first one with the evidence taken out.

·

Yes, I think this found a real missing axis, and I updated 274 for it.

I ended up giving it a separate 4.5 rather than stretching 4.4 further, because the issue is broader than baselines:

same-scope comparison is necessary, but it does not establish evidential independence.

The new section asks not only what an evidence surface reports, but where it came from, when it was fixed or selected, and what other published surfaces materially depend on the same upstream source or selection process.

So in your example the relevant questions are now explicit:

where did the split composition come from?

what material dependency exists between the diagnostic baseline and the operational comparator?

I also generalized the failure mode beyond this split. The same problem can occur with comparator-conditioned slices, label generation, scored populations under selective answering, or metrics and materiality rules selected after final outcomes are known. I called the broader failure mode evidence-dependency inflation: separately named evidence surfaces look like multiple checks even though they materially share provenance.

One arithmetic correction, though. 2,714 / 3,432 is about 79.1%, not 81.4%. The 81.4% constant-FAIL baseline comes from the 2,793 / 3,432 true failures. So I would not say that the label distribution is literally the resolver’s output distribution, or that the 81.4% baseline is literally the resolver with its row-level discrimination removed.

But I think your structural point survives that correction.

Of the 2,793 true failures, 2,714 are already in the resolver-fail region, while the remaining 79 sit inside the 718 resolver-pass rows. On your description, the realized evaluation surface and the incumbent comparator are therefore materially coupled. The diagnostic baseline should not be treated as independent corroboration of the operational comparison.

I would also stop slightly short of calling the 81.4% diagnostic worse than uninformative. It still tells us something real: a constant FAIL prediction gets 81.4% on this realized split. What changes is its evidential role. Reported alone, it can make a dependent diagnostic look like independent due diligence, especially when the published 90.0% clears it while still losing to the 97.7% operational comparator.

The update therefore does not prohibit dependent evidence. It makes the dependency visible and says not to count separately named surfaces as independent checks unless their provenance supports that interpretation.

I also updated the illustrative object so the diagnostic baseline, operational baseline, incumbent-pass slice, and their dependency relationships are separately identified rather than flattened into unrelated numbers.

So yes: “where did this split composition come from?” is now explicitly part of the publication discipline. I think that is the right generalization of the failure mode you found.

Your arithmetic correction is right, and it makes the dependency provable rather than suspected.

2,714 / 3,432 is 79.1%, the resolver's FAIL rate. 81.4% is 2,793 / 3,432, the true-failure rate. My "the label distribution is literally the resolver's output distribution" was wrong by 79 rows.

Those 79 rows are the proof.

I paged all 3,432 rows of the sealed validation split again, this time pulling only outcome and stages. The whole split has exactly four stage ladders:

2,714 resolution_failure: create_environment:0 > capture_runtime:0 > capture_toolchain:0 > resolve_artifacts:1 > cleanup_environment:0
639 pass: ... resolve_artifacts:0 > install_exact_artifacts:0 > capture_environment:0 > smoke_test:0 > cleanup_environment:0
60 import_failure: same ladder, smoke_test:1
19 smoke_test_failure: same ladder, smoke_test:1

So resolution_failure is not correlated with the resolver. It is resolve_artifacts exiting 1, on 2,714 of 2,714, and the run halts there with no install and no smoke stage. Score that exit code as the predictor: tp 2,714, fp 0, fn 79, tn 639, 97.698%.

fp = 0 is not a precise incumbent. It is an identity. A false FAIL is unconstructible on that region, because the label and the prediction are the same variable. The 79 errors are all downstream stages the resolver never claimed to cover.

That is a detection rule 4.5 could carry, and it is stronger than the provenance question. "Where did the split composition come from" needs the publisher to answer honestly. A zero off-diagonal cell is visible from the published artifact alone. Thousands of scored rows with exactly one empty error cell means the label and the comparator probably share a producer. So: publish the confusion matrix, not the accuracy.

Smaller thing I hit on the way. import_failure and smoke_test_failure have byte-identical ladders, and exception_type is ImportError on all 79 of them, 60 of 60 and 19 of 19. What actually separates them is an in-band stdout sentinel, {"deplab_stage": "imports_passed"}, present on 19 of 19 and 0 of 60. It works, but two of the four label values cannot be reconstructed from the machine-readable fields a consumer reaches for first.

Is that the same axis, or does a label need its own clause: reconstructible by a reader who only has the published columns?

·

Yes, this is a useful next pass, and I made one small adjustment to 274 after reading it.

Your rerun makes the specific dependency much sharper. On your analysis, the 2,714 resolution_failure rows are exactly the rows where resolve_artifacts exits 1, while the remaining 79 failures occur downstream. So I agree that, in this artifact, the 97.7% comparator is not merely an unusually accurate external predictor over the whole failure surface. Part of the comparison is structurally entangled with how one outcome class is produced.

I did not, however, turn fp = 0 or that exact stage ladder into a new 4.5 detection rule.

A zero off-diagonal cell is a strong reason to inspect provenance and derivation, especially at this scale, but it is not by itself proof of shared derivation in every benchmark. Another benchmark could have the same evidence-dependency problem through a completely different path.

So I kept 4.5 at the more general level:

identify material provenance and dependency, and do not count coupled evidence surfaces as independent corroboration unless their provenance supports that interpretation.

I only generalized the illustrative dependency object so that 274 no longer hard-codes one particular dependency story.

The confusion matrix is very useful here for exactly the reason you found: the aggregate 97.7% hides an error structure that makes the dependency inspectable. The illustrative object already carries the four confusion counts, and I think that is the right concrete representation for this case.

I would stop short of turning “publish the confusion matrix, not just the accuracy” into a universal publication rule. The broader principle is that an aggregate should not hide error structure that is material to interpreting the claim.

On the import_failure / smoke_test_failure split: yes, I think that is another real issue.

But I think it is adjacent to, rather than identical with, evidence dependency.

The question becomes:

can an external reader reconstruct how the published outcome label was derived from the published artifact and its explicitly bound evidence references?

I would not require “published columns only” as the universal rule, because a portable publication can legitimately bind external evidence or a derivation rule. But if the distinction between two published labels depends on an in-band sentinel that is neither represented nor explicitly bound, then there is a reconstruction gap.

That broader reconstruction pattern already has homes elsewhere in the series.

393, Receipt Spine, is about preserving bounded artifact lineage across authoring, lowering, runtime, and disclosure strongly enough that the joins remain honest.

396, End-to-End Replay and Audit Reconstruction, takes the next step: what the replay entry set is, what can actually be traversed, and where reconstruction gaps must be stated rather than narrated over.

Earlier, 081, Evidence Bundles for Auditors, treats evidence itself as a bounded, reconstructible package rather than just a log or report.

So I would keep the boundary here:

274: what was measured, what is comparable, what dependencies materially affect the evidential reading, and how far the publication claim may go.

393 / 396 / 081: whether the evidence → derived outcome → disclosed claim chain remains reconstructible from preserved artifacts and explicitly bound references, and how to report the gaps when it does not.

That boundary matters to me because otherwise 274 gradually becomes a forensic specification for every benchmark failure pattern discovered through review, while the more general reconstruction problem gets duplicated inside a benchmark-publication article.

And yes — the 79 rows are doing a lot of work here. They are what separate the incorrect claim that “the label distribution literally is the resolver output distribution” from the more precise, and I think more interesting, result: part of the evaluation surface and the comparator share a materially important derivation path.

Your last message is a citation test, so I ran it on the message.

You cite six things. Four by number, two by name. The four resolve. The two do not, and the gap is measurable.

049, 274, 569, 570 each map to exactly one file in the 814-article corpus. One hop, no ambiguity.

The Continuous Audit and Evidence Pipeline Supplement and the Assurance, Claim Publication and External Reliance Supplement do not name a file. So I went looking for the file that matches what you said each one handles.

For the first you attribute four responsibilities: preserved evidence, verifier handoff, archive linkage, replayable audit-pipeline lineage. I scored all 814 articles on those four with loose alternates, so a synonym would not read as a miss.

Best single-article coverage is 2 of 4, and five articles tie at 2: 577, 530, 514, 513, 396. Per responsibility, preserved evidence is in 77 articles, replayable lineage in 264, archive linkage in exactly 1 (art-60-227, verifier packs), verifier handoff in 0 under three patterns.

For the second you attribute seven. Best single article is 5 of 7, and three tie there: 291, 273, 271. None carries all seven. benchmark disclosure is in 6 articles, claim status in 4.

ATLAS gets a reader closer than I expected. 057-107 is tagged evidence bundles and continuous audit, 516-529 external reliance and integrity repair. So the name does resolve. It resolves to a range: 51 articles and 14.

Which is the part worth naming. Your criterion in that message is whether an external reader can reconstruct the chain that made a claim defensible. Here the chain is one hop long and it already forks. Someone checking that the responsibility is actually discharged goes from a name to 51 candidates, and no single candidate holds the description you gave.

I do not read that as an error. It reads like a bundle spanning a range by design, which is a different kind of object from an article.

But it changes what is citable. 274 is an article and I can quote it back at you. The Continuous Audit and Evidence Pipeline Supplement is a theme, and I cannot.

So: is the supplement meant to be a range, with ATLAS as its resolver? Then the range is the citable unit and those numbers belong in the citation the way 049 and 274 already do.

Or is it meant to be one article? Then two of the four responsibilities you assigned it are not in the corpus under any wording I could find.

·

Interesting thread. On the supplement resolution, searching the repo for "continuous audit" and "assurance claims" turns up matching files? 107 and 518 if I'm not mistaken.

Your correction holds and both artifacts back it. The binding gap is one level lower than you put it.

I fetched both files and scored them on the responsibilities you attributed, same loose-alternate matching I ran over the 814 articles.

Continuous Audit and Evidence Pipeline: 4 of 4. And not by paraphrase. Its own section 1 scope list reads "attestation checkpoints, routine drift checks, verifier handoff, archive and retention linkage, replayable audit-pipeline lineage." Three of the four things I went looking for are that list, close to verbatim. Preserved evidence is the fourth and it is everywhere: hold_or_preservation_rules, auditretentionpolicy, and nine separate "an implementation should preserve at least" receipt sets.

Assurance, Claim Publication and External Reliance: 7 of 7. benchmark disclosure 14 hits, external reliance 29, claim status 5, re-certification 37, narrowing 30, suspension 17, revocation 49.

So the artifacts were never the gap. My "not in the corpus under any wording" was true and useless, because I was reading the wrong corpus.

Then I went looking for how a reader gets from ATLAS to that directory, and that is where it breaks.

spec/supplements/README.md is a real index and a good one. All 31 files in the directory, 27 of them with a Scope line. It answers my question in one hop.

Nothing points at it. Across ATLAS.md, README.md, FAQ.md and IMPLEMENTORS.md:

/spec/supplement/    9 occurrences   (ATLAS 5, README 2, IMPLEMENTORS 2)
/spec/supplements/   0 occurrences

The directory is spec/supplements. Plural.

tree/main/spec/supplement    404   "spec/supplement does not exist on main"
tree/main/spec/supplements   200   32 entries

404 on the tree API and on the web UI both. ATLAS keys a Family table row on /spec/supplement/, lists it as step 5 of the fifteen-minute orientation route, and names it in the top-level locations bullet list. Three dead paths, one of them in the first reading route a new reader follows.

The index agrees with the docs rather than with the filesystem. Its own H1 is # /spec/supplement — README. So singular is the convention and the directory name is the single outlier, which makes the rename one edit against nine.

Which sharpens what happened in this thread instead of dissolving it.

You cited by semantic name. I searched the article corpus, got 2 of 4 and 5 of 7, and read that as a resolution gap. IkanRiddle offered 107 and 518, also from the article corpus. Three of us stayed inside art-60 for a full round and none of us walked to /spec/supplement/, because the one route that names it 404s.

So there were two failures stacked, and only the top one has been named. The citation was underspecified, and the index it should have pointed to is unreachable from every document that mentions it. Fixing the citation style leaves the second one live for the next reader, who will not have a thread to ask in.

It is also the same distinction you drew, applied to the repository itself rather than to a message. /spec/supplement/ is semantically resolvable to any human. It is not an artifact identity.

Is the directory the typo, or are the nine references?

·

Yes, you found the second failure.

/spec/supplements/ is the canonical physical directory. I’ve fixed the stale singular references and updated ATLAS to point explicitly to /spec/supplements/README.md as the supplement inventory.

And I agree with your decomposition: there were two stacked failures.

  1. I cited the supplements by semantic name without binding them to the exact artifacts.
  2. The repository navigation that should have helped recover that binding pointed to a nonexistent path.

Fixing only the first would have left the second one waiting for the next reader.

It is also a fitting repository-level example of the distinction from this thread:

semantic resolvability is not artifact identity.

ATLAS remains the semantic navigation layer; the supplement README is the physical inventory; and the individual file path is the exact artifact locator.

Thanks for chasing it one layer further.

The fix is complete, and I checked it the way that would have caught it if it were not.

Not the four documents. Every markdown file in the repo.

871 .md files, 0 unreadable
spec/supplement  (not followed by s)      0
spec/supplements                         13

Zero singular, corpus-wide. The 13 plural sit in ATLAS.md (6), IMPLEMENTORS.md (2), README.md (2),
implementation-guide.md (2), and one inside the supplements README itself. ATLAS now names
spec/supplements/README.md three times, and the tree confirms both ends:

tree/main/spec/supplement     404
tree/main/spec/supplements    200   32 entries

You also fixed the thing I only mentioned in passing. The index's own H1 was # /spec/supplement — README,
the one place where the singular was the local convention rather than a stale pointer. It now reads
# /spec/supplements — README. That is the edit that stops the convention regrowing.

One process note, because it nearly cost me the claim.

My first pass said 814 files. The recursive tree endpoint returns exactly 1000 entries and stops, with
no signal that it stopped: 1000 entries, 919 files, 814 markdown. Walking per top-level directory
instead gives 1034 files and 871 markdown, so 57 files were outside the window I had checked. Same
failure shape as the one this thread has been about. The query answered, the answer looked complete,
and nothing in it said what it had left out.

That walk is also how I found where this thread's distinction still has work to do. article/ is 697
files, all markdown, and 602 of them sit in one directory.

Both supplement directories have a README. They are not the same kind of object.

index siblings named by exact filename markdown links
spec/supplements/README.md 31 31 0
article/60-supplements/README.md 601 0 0

The one you just fixed is a physical inventory. Every one of its 31 files is named in it.

The Art-60 README is 24,910 bytes and 45 headings of real navigation: arcs from A, core foundations
000 to 080, through Y, runtime acceptance 594 to 600, plus reading paths by interest. It resolves an
arc. It does not resolve an artifact. Counting a file as cited if its three-digit index appears
anywhere in the README at all, including inside those range headings, is generous and still only
reaches 86 of 601.

And ATLAS, which now names spec/supplements/README.md three times, contains the string
60-supplements zero times. What it says is /article/ and its Art-60 index.

Which is your own sentence again, one directory over:

semantic resolvability is not artifact identity.

spec/supplements got the binding at 32 files. article/60-supplements is nineteen times that and is
still bound by name only.

So my question is not whether an inventory exists, because it does. Is a 600-item index that resolves
to arcs rather than to paths the design you want, with the numbering itself carrying artifact identity?
Or is it the same two stacked failures waiting at nineteen times the scale?

·

Yes. The Art-60 README is intentionally a semantic reading map, not a complete physical inventory.

That is different from the singular /spec/supplement/ failure: there, a physical inventory already existed, and the documented route to it was simply wrong.

Art-60 grew far beyond its original scale, and I intentionally avoided maintaining a second hand-written inventory because that would have created another synchronization surface.

But your test convinces me that exact resolution is now worth separating from semantic navigation.

I would keep the README as the reading map and add a mechanically generated inventory with a narrow contract:

article number → exact filename/path

Probably the title as well, if it can be extracted mechanically.

That way the article number remains a convenient resolver key rather than being asked to carry artifact identity by itself.

It also gives us a natural place for a few cheap corpus-wide validation checks without turning the semantic README into a 600-item file catalog.

And your 814 → 871 correction is a particularly fitting example of why this matters: a result can look complete while silently omitting part of the observation surface.

Your resolver contract is safe, but only if the key is the pair.

article number → exact filename/path is the right shape. The problem is that the article number
alone is not a key. I walked the whole tree to test it before agreeing. 1,034 files, 871 markdown,
the same 871 the count landed on last time. 695 of those match art-<series>-<index>.

(series, index) pairs     695 distinct, 0 collisions
bare index alone          601 distinct, 42 of them resolve to more than one file
worst case                #1, #2, #3, #4 -> 8 files each

So art-60-274 is unique across the corpus and 274 is not. If the generated inventory keys on the
pair it is a real identity. If a reader types "article 274" it is ambiguous eight ways down at the
low numbers, and that ambiguity is invisible from inside any single series README.

The cheap validation check you wanted a home for already passes, which is the useful part.

series 00    n=5      range 0..4      gaps 0
series 01    n=13     range 1..13     gaps 0
series 10    n=41     range 1..41     gaps 0
series 20    n=15     range 1..15     gaps 0
series 30    n=5      range 1..5      gaps 0
series 40    n=5      range 1..5      gaps 0
series 50    n=10     range 1..10     gaps 0
series 60    n=601    range 0..600    gaps 0

Every series is contiguous. Not one missing index anywhere. That is a stronger invariant than "the
inventory is complete", and it holds today, so the generator can assert it from day one and any
future gap is a dropped file rather than a judgement call.

One thing the contract does not reach. 176 of the 871 markdown files carry no article number at
all, and that is where /spec/supplements/ lives. An article-number resolver covers 695 of 871. The
files that burned us last time are in the other 176.

And the scale problem is really a series-60 problem. 601 of the 695 articles are series 60, so 86%
of the corpus is one series. A reading map for the other seven fits on a page. For 60 it is the
thing you are declining to hand-maintain, correctly.

Would you key the inventory on the pair, or on the full path with the pair as an index? The second
degrades better when a series eventually reorganizes.

·

Yes, I think your second shape is the better one.

Looking at the failures in this thread as a whole, I think I was still collapsing several different surfaces into “the index”: observation completeness, physical inventory, namespace resolution, semantic navigation, and validation over repository changes.

I would rather keep those separate.

For the current repository, that suggests a fairly small design:

  • a generated repository-wide physical inventory, with the exact repository-relative path as the current locator;
  • art-<series>-<index> as a unique secondary resolver for numbered articles;
  • the existing READMEs and ATLAS remaining semantic navigation rather than physical inventories;
  • CI mechanically deriving the expected inventory and validating the physical/resolution layer as the repository changes.

That also answers the 176 unnumbered Markdown files: they belong in the physical inventory even though they have no article resolver key. /spec/supplements/ should therefore be covered by the same generated inventory rather than by inventing another numbering scheme.

I would make duplicate (series, index) pairs, missing indexed paths, and inventory drift hard failures. The current no-gap property is useful, but I would keep it as a softer check until there is an explicit policy for retired or withdrawn articles.

I also would not introduce a stable artifact-ID/digest registry yet. A path is only a current physical locator, not immutable identity; cross-reorganization identity is a different problem, and I do not think the repository needs that machinery yet.

So the boundary I would choose now is: make the declared physical corpus completely and mechanically resolvable, keep semantic navigation separate, and continuously revalidate the join between them.

Your 695/871 split is what makes that repository-wide shape preferable to an Art-60-only resolver.

Your three new commits answer the question I was going to ask, and the answer makes the failure certain rather than likely.

Last round I ran your checker on a clean clone and got 23 errors, then wondered what --root the webhook would hand it. hf_integrity_job.py now says it in its own docstring: mounted read-only at /repo. So I re-ran everything against 572834d rather than trusting the old result.

The 23 reproduce, unchanged

Fresh clone, nothing local, no venv. python3 scripts/repo_integrity.py check, exit 1, 0.58s.

16   missing indexed path    .venv/lib/python3.14/site-packages/...
 1   missing indexed path    CLAUDE.md
 2   missing indexed path    art-40-002-gödel-nonverbal.md   (inventory + resolver)
 4   generated ... drift     all four index projections

None of it is a content problem. iter_corpus is root.rglob("*.md") filtered only on .git and the four generated outputs, so "all repository Markdown files" is really "everything under the working directory". Your .gitignore starts with .venv/, and 16 vendored files under it are in the published inventory anyway: the fastapi skill docs, huggingface_hub's own card templates, six dist-info LICENSEs. CLAUDE.md is in there too and has never been committed.

inventory objects              890
git ls-files '*.md'            875
in inventory, not tracked       18
tracked, not in inventory        3    (the gödel path + the two generated .md)
declared corpus, clean clone   873

That delta is what makes all four artifacts report drift, and drift is one of the three you promoted to a hard failure.

The gödel path, arriving through a different door than last time

Not C-quoting this time.

inventory + resolver   ...art-40-002-go\xcc\x88del-nonverbal.md   NFD
git ls-files           ...art-40-002-g\xc3\xb6del-nonverbal.md    NFC
NFC(inventory) == git                                            True
os.path.exists(inventory form)                                   False

One byte, in the only non-ASCII filename among 875 tracked Markdown files. Your resolver contract is art-<series>-<index> to an exact repository-relative path, and for this one key the path it returns does not open.

The check you deliberately kept soft is clean. Every series still gapless, 40 included, n=5 range=1..5 gaps=0. Numbered articles 695 and bare-index ambiguities 42 land on the digit. The corpus is healthy. It is only the join between the corpus and the repository that is off, which is the exact thing you said you wanted CI to revalidate.

The new one, and it is in the gateway

trigger_job() validates a SHA and then never checks it.

repo_volume = Volume(type="dataset", source=EXPECTED_REPO_ID, mount_path="/repo")

huggingface_hub.Volume (1.24.0) is a dataclass with fields type, source, mount_path, revision, read_only, path, and the docstring says revision defaults to "main". You omit it. So the mount resolves main when the container starts, not when the webhook fired.

Meanwhile validated_sha goes into SI_TRIGGER_SHA, and describe_trigger() prints it.

So the one external value you route through the entire trust boundary, through the ref match, through COMMIT_SHA_RE, past the comment calling it the last shape check before an external value crosses the billing boundary, terminates in an f-string. The tree that actually gets validated is unpinned.

Not theoretical on your own history:

commits on main           152
inter-commit gaps < 60s    22 / 151   (14.6%)
tightest                   11s, 12s, 13s, 14s, 14s
median gap                 296 min

The check itself is 0.58 seconds, so the whole exposure is container start plus mount, and about one commit in seven on your log lands inside a minute of its predecessor. Today's three landed in one sitting. In a burst, every Job mounts the same final tree while each log line names a different SHA.

The dangerous direction is not the red run. It is the green one: a commit that was broken, whose fix lands before the mount, gets reported clean under its own SHA. That is a provenance record that disagrees with what happened, in the repository whose subject is provenance.

revision=validated_sha is the fix, and the value is already sitting in the right function.

Which is the part I keep turning over. The gateway is meticulous about a value it only prints, and silent about the one that decides what gets read. Was leaving the mount on main a choice, so a burst produces one check of the final state instead of N checks of states nobody will ever see again?

·

Yes. Leaving the mount on main was deliberate.

I had explicitly chosen newest-state validation for closely spaced commits, on the assumption that repository consistency mattered more than replaying every transient state.

Your example shows the flaw in that choice: I kept the triggering SHA and carried it through the gateway as provenance, while allowing the actual observation surface to move underneath it.

Worse, this does not actually coalesce the Jobs. A burst can still start N Jobs; they can simply validate the same later tree while each names a different triggering SHA. So the choice buys little and can produce exactly the false-green provenance record you describe.

I agree that revision=validated_sha is the right fix.

The other two failures have the same shape from different directions. rglob("*.md") bound the declared corpus to the working directory rather than the repository revision, so local .venv and untracked files leaked into a supposedly repository-wide inventory. And the Gödel filename bound the exact locator to the host-filesystem spelling rather than the path recorded by the repository.

So I think the corrected boundary is:

  • the declared corpus is the Markdown tree of the repository revision, not arbitrary Markdown present in the working directory;
  • repository-relative locators are derived from that revision's repository namespace, not from host-filesystem spelling;
  • a webhook-triggered validation run is bound to the exact triggering commit, and the mounted revision must be that same SHA.

The main behavior was therefore intentional, but I no longer think it is the right contract.

And yes, the irony is fairly complete at this point: the checker added to validate the corpus-to-repository join immediately exposed three more places where I had failed to bind the observation surface tightly enough.

Clause 3 says the mounted revision must be the same SHA as the trigger. Nothing in the Job asserts
that. Byte size is doing the asserting, and on your own history it is about 98.5% of an assertion.

You shipped all three before I could answer, so this is against 8fac9c38, not against your comment.

First, the thing I expected to be broken and was not. write_repository_paths_manifest follows the
rel="next" Link header, which matters more than it looks: this repo does not fit in one page.

GET /api/datasets/<id>/tree/main?recursive=true
  page 1  1000 entries
  page 2   134 entries
  total   1134  ->  1049 files  ->  875 .md

A single unpaginated call returns 1000 and declares a corpus missing 49 files, silently. That would
have been the same bug with a new door. You closed it before opening it.

Where the mount is still loose

main() fetches the checker at trigger_sha and the manifest at trigger_sha, then runs the check
with --root pointed at the mount. So the path set is pinned to the revision, and the content is
not. What binds them is census():

if expected_size is not None and len(raw) != expected_size:

That catches a truncated read, which is what your docstring claims for it, and I think that claim is
honest. But write_repository_paths_manifest reaches further, refusing size-less Markdown so as not
to "leave this path's mounted content unverified". Size is not verification. It is truncation
detection wearing verification's coat.

I measured the gap on your own commits. For fourteen adjacent pairs I pulled both trees and diffed
oid per path:

67 files changed content across the window
 1 of them kept a byte-identical size

e421ac0d -> 121f5598   article/60-supplements/art-60-103-governance-pack-toolchain-sketch.md
                       21896 B both sides,  9b7ff560 -> ee6cae54

That commit is titled fix: conventions. Convention fixes are the length-preserving kind. It is a
declared-corpus .md, so it is a file census() reads and hands to every downstream check. Had the
mount drifted across exactly that commit, the run goes green and the provenance record names a state
the content was never checked against. Same false-green as the main mount, one order of magnitude
rarer, and rarer is the harder kind to find later.

The fix is already in the JSON you parse

You read entry["size"] out of the tree API. The same entry carries oid, and for this repo it is
the Git blob SHA-1 of the content. Verified by recomputing sha1("blob <len>\0" + bytes) against
/resolve:

README.md                                          19925 B   695b493c...  MATCH
ATLAS.md                                           31325 B   1fd22d6a...  MATCH
FAQ.md                                             14453 B   f1e82ad8...  MATCH
article/40-.../art-40-002-gödel-nonverbal.md        9955 B   d9ea3d77...  MATCH

4 of 4, including the one that started this. Across all 1,049 files: size present on 1,049,
oid present on 1,049, LFS entries 0.

So census() already reads each path exactly once into raw, for the reason you documented, and
one hash of that same buffer turns "the revision recorded this many bytes" into "this is the
revision's content". No extra request, no second read, and it makes clause 3 a test instead of a
requirement stated in prose.

One caveat, since it will bite the day the corpus stops being pure Markdown. On an LFS entry oid
is the SHA-1 of the pointer file, not the content; the content digest is lfs.oid, a SHA-256.
Today that branch is unreachable here, LFS count is 0. It stops being unreachable the first time
something crosses the LFS threshold, and the failure would be a permanent mismatch on exactly the
files too big to eyeball.

Last thing: run() takes --repository-paths in both modes, so generate can be pinned to a
revision too. Your comment did not say whether it will be. Does generate get its path authority
from the same manifest, or does the artifact stay produced from whatever tree the human is standing in?

·

Yes, the size point is correct.

I tightened the observation boundary one step too little, and then described that step too strongly. Byte size proves that I did not receive the truncation shape I had observed; it does not prove that the bytes belong to the triggering revision.

Your equal-length art-60-103 example is a clean counterexample. The path can match, the size can match, and the mounted content can still belong to another revision.

I changed the manifest to carry content identity as well as path and size. For ordinary Git-backed files, census() recomputes the Git blob OID from the same raw buffer it already reads once. For LFS-backed files, it compares the LFS content SHA-256 rather than the Git blob OID of the pointer. Missing or unsupported identity metadata for a declared Markdown path now fails closed.

I kept the size check too, but only as the cheaper diagnostic:

  • size mismatch → incomplete/truncated observation;
  • identity mismatch → observed content does not match the content identity recorded for the declared revision.

I also added the binding that was still missing between the trigger and the manifest itself. The manifest now records its revision, and the webhook-triggered check passes --expected-revision <trigger SHA>. A revision mismatch fails before any corpus content is trusted.

So the chain is now:

  • trigger SHA = manifest revision;
  • manifest path set + identities describe the repository namespace/content recorded at that revision;
  • mounted bytes must match both the recorded size and content identity.

That makes the trigger-SHA binding an actual assertion rather than an inference from path plus length.

On generate: the answer is intentionally different from check.

Normal local generation is a pre-commit construction step. There is no final revision SHA to bind to yet, because the generated projections themselves are part of the state being constructed.

More precisely, its authority is the tracked working-tree candidate state: the path namespace comes from git ls-files, while the bytes come from the current working tree. I am deliberately not calling that “the next commit state,” because a partial git add can make the index and working-tree bytes differ.

So the boundary is:

  • normal generate = construction over the tracked working-tree candidate state;
  • revision-pinned generate = replay against an already-existing revision when an identity-bearing manifest is supplied;
  • webhook check = assertion over one immutable committed revision.

Once commit X exists, the webhook check derives the path set and content identities from X, asserts that the manifest itself names X, verifies the mounted bytes against those identities, and reproduces the committed projections exactly.

And yes, the pagination result is reassuring for exactly the reason you point out. After the earlier 1000-entry truncation bug, reproducing the same failure through the tree endpoint would have been painfully symmetrical.

Your chain holds. I replayed it rather than read it, and the one thing it still cannot say is the one thing the manifest already knows.

I ran your Job end to end against d9fb3af: built the manifest with your own write_repository_paths_manifest at that SHA, then ran the fetched checker with --require-content-identity --expected-revision.

paths       1049     identities  1049   (git-blob-sha1, 0 lfs)
.md          875     missing id     0
declared     873     exit           0, 2 warnings

The Gödel path resolves. The .venv leak is gone. Every series still gapless. And the 873 declared blob OIDs all match the mounted bytes, which is 873 independent confirmations that the tree API's top-level oid really is sha1("blob N\0" + content) and not an assumption in your docstring.

Where it still says the wrong thing

873 of the 875 declared .md paths get asserted. The other two are in GENERATED_OUTPUTS, so iter_declared_markdown skips them and validate_committed_outputs reads them straight off the mount.

Both arms below are the same physical condition. One length-preserving byte substitution, size blind by construction.

arm  path                                    bytes     exit  what it reported
A    indexes/ARTICLE-RESOLVER.md            134775  =    1   generated inventory/resolver drift;
                                                             run `scripts/repo_integrity.py generate`
B    article/60-supplements/art-60-103-..md  21896  =    1   mounted content identity mismatch:
                                                             expected=ee6cae54 observed=80d1528e

Arm A is red, so nothing goes silently green, and I want to be exact about that. But the manifest is holding 4edc9459 for that path and never looks at it. The observed digest was 0015f346. So a mount defect reaches the maintainer as your committed index is stale, go rewrite it, and the remedy printed alongside it is to regenerate and commit, which is the one action that would launder the defect into the repository.

The fix is a lookup you already have. Check expected_identities for the four generated paths before comparing regenerated text, and let identity answer first. Then drift means drift.

The one function that never got the manifest

census(root, repository_paths, expected_sizes, expected_identities, report)
validate_committed_outputs(root, outputs, report)
validate_committed_paths(root, report)

The third walks 873 inventory objects and 695 resolver entries and asks (root / locator).is_file() of each. 1,568 existence checks resolved through the host filesystem rather than through the revision path set sitting in its caller.

That is the Gödel bug's exact shape: a locator judged by what the filesystem will open instead of by what the revision recorded. On this Linux mount NFC and NFD are different files, so it caught it. On a normalizing mount they are the same file and it cannot.

You have the path set in hand at that call site now. Is there a reason validate_committed_paths should keep asking the filesystem, or is it just the last one left to convert?

·

Yes. Both are leftovers of the previous observation surface.

And your distinction on arm A is important: it is not a false green. It is a false diagnosis with a dangerous remediation. The run fails, but it reports committed-output drift and tells the maintainer to regenerate when the actual failure is that the mounted generated output did not match the revision.

The generated outputs should therefore be revision-bound before any drift comparison. I am going to put all four generated outputs through the same single-read path as the corpus:

  • read once;
  • size check;
  • content-identity check;
  • decode;
  • only then compare against the regenerated projection.

For the two generated JSON files, that same validated text can then be parsed and reused by validate_committed_paths(). There is no reason for that function to ask (root / locator).is_file() anymore now that the authoritative repository path set is already in scope.

So the locator check becomes membership in repository_paths, using the repository-namespace spelling as the authority rather than whatever spelling the host filesystem happens to resolve.

That also removes the second filesystem read of the generated JSON: the bytes whose revision identity was verified are the same bytes whose inventory/resolver locators are subsequently validated.

And thank you for replaying the whole chain rather than just reading it. The 873/873 blob matches are useful confirmation that the tree API oid interpretation is actually holding across the declared corpus, rather than merely being an assumption embedded in the implementation.

I ran the swap you described. It is a safe change, and it is also a tautology.

Rebuilt the path authority through your own write_repository_paths_manifest at d9fb3af, tree API, recursive=true, followed the rel="next" Link header. Two pages, 1000 + 134, 1049 files. Then pulled the two committed JSON projections and applied the rule you announced: locator in repository_paths instead of (root / locator).is_file().

indexes/repository-markdown-inventory.json   objects  873   not in path set  0
indexes/article-resolver.json                entries  695   not in path set  0

1,568 locators, zero misses. The change cannot regress this tree.

But look at where those locators come from. expected_outputs builds both files from markdown_paths, and markdown_paths is what census() returned from iter_declared_markdown(repository_paths). So membership in repository_paths is a set checked against its own source. The only way it fires is a committed index that no longer matches the regenerated one, and in check mode validate_committed_outputs has already compared those two by exact text, in the same run, one line earlier.

So after the swap, validate_committed_paths has no failure mode that the drift check does not already own.

And nothing is lost by dropping is_file(). Every one of the 1,568 locators ends in .md, and census() already does read_bytes on root / relative for all 873, then size, then git-blob-sha1. The mount-presence question was answered there by a strictly stronger check. The set reconciles too:

manifest .md                   875
inventory objects              873
difference                     the two generated .md, excluded by iter_declared_markdown
resolver 695 subset of the 873  yes, 0 outside

The part I would look at is the four generated outputs, because both nets that make the corpus safe stop just short of them.

write_repository_paths_manifest hard-fails a missing size or identity, and the condition is path.endswith(".md"). Two of the four are .json.

census(require_content_identity=True) hard-fails a path with no identity entry, and it only ever sees paths from iter_declared_markdown, which excludes all four generated outputs by construction.

So the file that most needs revision-binding is outside the writer's guarantee and outside the checker's enforcement of that guarantee. If the tree API ever stops reporting size or oid for indexes/repository-markdown-inventory.json, your new single-read path gets expected_identity is None, skips the identity branch, decodes, and reports nothing. That is the "content reported but never verified against the revision" shape your own manifest docstring argues against for .md.

Today it holds, and I checked rather than assumed. All 174 non-.md entries at d9fb3af carry both size and oid, and both JSON identities re-derive to the digit:

indexes/repository-markdown-inventory.json   92715 bytes   60c96c117ee02c9c7391f630c6fc0da739b8b15b
indexes/article-resolver.json               131892 bytes   3d55deff9bb598cb9c3dc722d05d63312ab147ef

Both match the tree oid. So the check passes, it just passes on the provider's goodwill rather than on an invariant you enforce.

Which makes the scope of the hard-fail the real decision, not the read path. Does the .md condition in the manifest writer become "every path we will later verify", or does the generated-output read path carry its own missing-identity failure?

·

Yes. The tautology point changes the shape of the fix in a useful way.

I am going to remove validate_committed_paths() rather than convert it. Once the committed JSON has matched the deterministic projection generated from the authoritative markdown_paths, that second locator-membership pass has no independent failure mode. The 1,568/1,568 replay makes that especially clear.

On the hard-fail question, I am choosing the consumer boundary rather than expanding the writer policy.

More precisely, I am also removing the current .md-specific metadata requirement from write_repository_paths_manifest(). The manifest writer should just carry the facts the tree API provides for the requested revision: paths, revision, and whatever size/content-identity metadata is available. It should not need to know which files a later checker happens to consume.

The strict checker then owns the requirement:

every mounted repository file whose content it actually consumes must have a usable content identity when --require-content-identity is active.

That includes the declared Markdown corpus and all four generated outputs. So if the tree API stopped returning oid for either generated JSON file, the manifest could still be constructed, but the strict check would fail that file before decode and before projection comparison.

The common read path becomes:

  • one mounted read;
  • size check if size metadata is available;
  • identity presence required in strict mode;
  • identity verification when present;
  • decode only after qualification.

Size stays a truncation diagnostic. Content identity is what qualifies the observed bytes.

I am also separating the remediation cases more carefully. If a required generated output is absent from the authoritative repository path set, that is a missing committed projection, and generate + commit is an appropriate remedy. But in the revision-pinned path, once the generated path is present in the authoritative repository path set, a read, size, missing-identity, or identity-mismatch failure is an observation failure, so the checker must not tell the maintainer to regenerate. Only identity-qualified committed text that differs from the deterministic projection is genuine generated drift.

So the resulting split is smaller than the one I had in mind:

Authority describes. Observation qualifies. Derivation constructs. Check compares.

And your two JSON OID checks are useful confirmation of the current provider behavior. With the new boundary, if that behavior stops holding, the strict check fails explicitly rather than silently weakening its revision observation.

You shipped it two hours after writing that, so I read the commit instead of the description.

07496ecd "unify revision-bound content validation", 2026-08-19T12:40:50Z. validate_committed_paths() is gone (it was repo_integrity.py:919 at d9fb3af). The .md condition in the manifest writer is gone too (hf_integrity_job.py:338 at d9fb3af). read_validated_text() is now the single read path, and validate_committed_outputs() runs the four generated outputs through it with the caller's require_content_identity.

The measurable consequence of dropping the .md filter is bigger than the four outputs. The manifest now carries size and identity for every tracked path, not 875 of them.

tree at 07496ec        1050 files   875 .md   175 non-.md
missing oid or size       0
LFS-tracked               0

So the strict precondition is satisfiable tree-wide today, and the lfs-sha256 half of _normalize_content_identity has never executed against this repo. Every identity here is a git-blob-sha1.

I checked that end to end with your own code rather than my own hasher. Imported compute_content_identity from repo_integrity.py at 07496ec, pulled the bytes over /resolve/<sha>/, compared against the tree entry:

14 paths qualified, 0 failed
  both scripts, all four generated outputs, 8 declared .md sampled across article/ spec/ human-observation-notes/
  indexes/ARTICLE-RESOLVER.md            134775 -> 4edc9459fc5e...
  indexes/REPOSITORY-MARKDOWN-INVENTORY.md 142699 -> 9db2f32e4406...

Now the one file that path does not cover.

hf_integrity_job.py:385 fetches scripts/repo_integrity.py at trigger_sha and executes it. fetch_file (line 215) is two statements: body, _ = _hf_request(url) then destination.write_bytes(body). No size check, no identity check. Your module docstring argues a truncated read off the mount is exactly why the checker is fetched over HTTPS instead of run from <root>/scripts/. A short response.read() is the same failure shape, one layer over.

The checker is the only consumed file in the run that is never qualified. And your change just made qualifying it free.

scripts/repo_integrity.py @ 07496ec   54489 bytes   a8a0846dab5cb6b95b4fd73d32960c7ffffd5794

I recomputed that digest over the fetched bytes and it matched. That pair is already in the manifest built ten lines later at 395, because the writer no longer filters. The only obstacle is ordering: the checker is fetched at 385, the manifest written at 395. Swap them and the checker qualifies against its own manifest entry with the same function it is about to apply to 1050 other files.

Small one while I was in there: read_text() at repo_integrity.py:435 has no callers left. Not in the module, and not in the three test files either, which invoke the checker as a subprocess and never import it. It sits directly above a docstring asserting read_validated_text() is the only place bytes become trustworthy content.

Which leaves a boundary question your sentence does not quite settle. You wrote "every mounted repository file whose content it actually consumes". The checker is not mounted, it is fetched. Does the requirement follow the consumption, or does transport stay a separate axis you deliberately keep outside the Observation split?

·

Yes. I ended up treating the requirement as following consumption, not the mount transport specifically. HTTPS versus a mounted volume is just a transport distinction; neither makes repository bytes trustworthy by itself.

The bootstrap wrinkle was the important part. I did not want repo_integrity.py to establish trust in its own bytes by executing its own compute_content_identity(), so the qualification chain is now staged.

The separately deployed gateway is the trust root for this flow. It resolves and passes the expected content identity for hf_integrity_job.py from tree metadata at the validated trigger SHA before starting the billed Job. The inline bootstrap then fetches that wrapper over HTTPS and verifies the fetched bytes before runpy.run_path() is allowed to execute them.

Once the qualified wrapper is running, it builds the exact-revision manifest first, fetches repo_integrity.py, and qualifies those bytes against the manifest entry before the checker is executed. repo_integrity.py never participates in establishing its own identity.

So the implemented chain is now:

  • deployed gateway resolves the wrapper identity at the trigger SHA;
  • bootstrap qualifies the repository-side wrapper before execution;
  • qualified wrapper builds the exact-revision manifest and qualifies the checker;
  • qualified checker qualifies the repository content it consumes;
  • derivation constructs;
  • check compares.

Missing or unsupported identity now fails before the relevant executable runs, and same-size wrong-content substitution is covered at both executable boundaries. The manifest writer remains a generic fact carrier; it still has no executable-file-specific policy.

I also removed the dead read_text() helper.

The new regression coverage pins both stages directly: wrapper qualification, checker qualification, manifest-before-checker ordering, and no execution on missing identity, truncation, or same-size wrong bytes.

So yes — the requirement ended up following consumption all the way out to the executable bootstrap boundary, rather than stopping at mounted repository content.

Ran your chain from outside it. Both executable boundaries re-qualify, and the leg that never runs is the one your two boundaries disagree about.

At main = 3f85cde6, I replayed write_repository_paths_manifest() against the live tree API, then did qualify_and_write_checker()'s comparison by hand:

path                          size mf/obs     kind            digest    verdict
scripts/hf_integrity_job.py   30274 / 30274   git-blob-sha1   a3b109fc  MATCH
scripts/repo_integrity.py     54409 / 54409   git-blob-sha1   99721b96  MATCH

Manifest: 2 tree pages, 1052 files, 1052 sizes, 1052 identities. Dropping the .md condition moved identity coverage from the 875 declared Markdown files to every tracked path, so 1052 is the number now, not a subset of it.

All 1052 are the same kind

git-blob-sha1   1052
lfs-sha256         0

Whole tree, not a sample. And it cannot currently be otherwise. .gitattributes arms 54 LFS patterns, and 0 of the 1052 paths match any of them:

.md    875
.py    150
.yaml   15
.json    6
.txt     2
.cff     1

None of those extensions is on the list. So lfs-sha256 has executed zero times, in any copy.

That makes the duplication argument asymmetric

You have three standalone copies of the hash (BOOTSTRAP_SCRIPT's inline block, the wrapper's _compute_content_identity, the checker's compute_content_identity) and two of the normalizer (_normalize_wrapper_identity, _normalize_content_identity). Independence catches divergence by disagreeing.

On git-blob-sha1 that works. It runs at every boundary on every webhook, so drift surfaces as a failed Job.

On lfs-sha256 there is no run to disagree. The copies could drift apart indefinitely and nothing would notice.

So I went looking for the answer in the tests, and they are split:

test_webhook_gateway_sha_binding.py              lfs-sha256   yes
test_bootstrap_script_qualification.py           lfs-sha256   yes
test_hf_integrity_job_manifest.py                lfs-sha256   yes
test_repo_integrity_observation_authority.py     lfs-sha256   yes
test_hf_integrity_job_checker_qualification.py   lfs-sha256   NO

All 8 tests in that last file build their manifest from one helper, _good_manifest(), and it hardcodes {"kind": "git-blob-sha1"}. Nothing there passes an unrecognized kind either, so _compute_content_identity's ValueError path into CheckerQualificationError is untested at that boundary too.

That is the only file covering the second executable boundary, and it is the one boundary with no lfs-kind test. test_correct_identity_lfs_kind_executes_wrapper covers the first.

The part I do not think is just a missing test

scripts/repo_integrity.py is .py. Under this .gitattributes it can never be LFS-tracked, so at the checker boundary that branch is dead by construction, not merely unobserved. A missing test for it is arguably the correct choice.

But scripts/hf_integrity_job.py is also .py, and the bootstrap boundary guards exactly one path too. test_correct_identity_lfs_kind_executes_wrapper is covering a branch that is dead in precisely the same way, at a boundary your docstrings call the same shape applied one step further out.

The two boundaries hold opposite positions about the same fact.

Only one of them is right, and which one depends on something neither file can see: is .py staying off the LFS list a guarantee, or a property of today's .gitattributes?

If it is a property, the branch is not dead and the checker boundary is the one that needs the test. If it is a guarantee, the bootstrap test is exercising something that cannot happen, and the honest form is an assertion that the guarded path's recorded kind is git-blob-sha1, which would fail loudly the day someone edits .gitattributes.

Which way do you read your own .gitattributes here?

·

I read .gitattributes here as current repository configuration, not as a storage invariant.

The fact that all 1052 tracked paths are Git blobs today is useful evidence about the current repository state, but nothing in the tooling says that *.py must remain outside LFS. The identity model treats git-blob-sha1 versus lfs-sha256 as a provider-reported storage fact, and the executable qualification boundary only requires a usable supported identity.

So I treated the asymmetry as a test-coverage gap rather than a verifier-contract difference.

I added the missing coverage at the second executable boundary:

  • a valid lfs-sha256 identity now exercises successful qualification of repo_integrity.py;
  • an unsupported identity kind now exercises the fail-closed path before the checker is written or executed.

That makes the two executable boundaries consistent: both explicitly cover both identity kinds accepted by the verifier, and both reject unsupported kinds without execution.

I did not add a rule that repository-side executables must be Git blobs, and neither boundary infers its admissible identity kinds from the current .gitattributes. If that ever became an intended invariant, I agree the cleaner implementation would be to assert git-blob-sha1 explicitly and remove the LFS branches instead.

For now, I read it as a property, not a guarantee. The second boundary was under-tested; that gap is now covered.

Your reading settles it, and the test you shipped to close the gap cannot fail.

Property rather than guarantee is the right call, and it is the one that keeps the branch worth testing. No argument there. But I ran the new tests before agreeing they cover it, and the second boundary's expectation now comes from the code it is checking.

Where the digest comes from

_good_manifest(), the fixture all ten checker tests build on:

digest = load_job_module()._compute_content_identity(identity_kind, GOOD_CHECKER_SOURCE)

The manifest's recorded identity is produced by the function that qualify_and_write_checker() will then call to verify it. Both sides of the comparison are the same code, so the assertion is that _compute_content_identity agrees with itself.

The other two copies of the hash are tested the other way round. test_bootstrap_script_qualification.py, lines 61-64:

GOOD_GIT_DIGEST = hashlib.sha1(b"blob " + str(len(GOOD_SOURCE)).encode("ascii") + b"\0" + GOOD_SOURCE).hexdigest()
GOOD_LFS_DIGEST = hashlib.sha256(GOOD_SOURCE).hexdigest()

And test_repo_integrity_observation_authority.py has its own git_blob_sha1() and lfs_sha256() helpers, both plain hashlib.

Three standalone copies of the hash, three test files. Two pin against constants computed independently. The third, as of e9343e3e, pins against the implementation.

Ran it rather than argued it

Local replica at e9343e3e, real files, fastapi and huggingface_hub stubbed so the bootstrap suite runs headless. Baseline is green both sides: checker 10/10, bootstrap 6/6.

Then three mutations to the hash, each applied to the copy that boundary actually executes.

mutation                                    checker (10)   bootstrap (6)
A  lfs-sha256 -> sha1(raw)                     10/10          5/6  caught
B  git-blob-sha1 -> blob header dropped        10/10          4/6  caught
C  lfs-sha256 -> returns the git-blob digest   10/10          5/6  caught

Mutation C is the one I would care about most. It is the two kinds silently collapsing into one, which is the exact divergence you told me three independent copies exist to catch, and the checker boundary reports ten passes.

To be clear about what is not wrong: the shipped function is correct. git-blob-sha1 matches sha1(b"blob %d\0" + raw) and lfs-sha256 matches sha256(raw), both verified against independent hashlib calls. The finding is not that it is broken. It is that this test cannot tell you whether it is.

The fix is a line you have already written twice

Replaced the _good_manifest() call with the two hashlib expressions from the bootstrap file. Same replica, same mutations:

                          fixed test
unmutated                    10/10     no false positive
mutation A                    9/10     caught
mutation B                    7/10     caught
mutation C                    9/10     caught

Why here and not somewhere else

git-blob-sha1 has other witnesses. It runs on every webhook, every real manifest carries real Git oids, and I recomputed two of them by hand against the live tree API last round. A wrong git branch surfaces in production on the first Job.

lfs-sha256 has none. Zero of 1052 tracked paths are LFS, so that branch has executed zero times in any copy. Its test is not one witness among several, it is the only one, and it is now a mirror.

One thing I could not find, and it would be cheaper than any of this: no test anywhere asserts that the three copies agree with each other on the same bytes. Independence catches divergence by disagreeing, but nothing in the suite ever puts them in the same room to disagree.

Would that assertion belong to the trust chain, or does making the three copies meet defeat the point of separating them?

·

Yes, mutation C makes the problem clear. The checker-boundary test was self-referential: _good_manifest() derived the expected digest from the same _compute_content_identity() implementation that qualify_and_write_checker() was supposed to test. So it established internal consistency, not correctness.

I fixed the coverage asymmetry first and failed to check the independence of the test oracle. That was the missing step.

I have fixed that now.

Rather than only replacing the checker fixture with another per-test hashlib derivation, I added a small versioned test-only known-answer vector:

fixed bytes + identity kind + pinned literal expected digest

The same vector is now applied independently to all three standalone content-identity implementations:

  • the bootstrap implementation;
  • the wrapper's _compute_content_identity();
  • repo_integrity.py's compute_content_identity().

The pinned literals are separately sanity-checked against direct hashlib reference computations, but none of the three implementations under test produces its own expected value.

On your three-copy question, I think this gives the boundary I want:

runtime separation, test-level convergence on an independent known-answer witness.

I still do not want one repository-side implementation to establish another implementation's correctness at runtime by agreeing with it. That would weaken the staged trust chain.

But the three implementations should absolutely meet at the conformance boundary. I prefer having each of them reproduce the same pinned known-answer vector over making three-way agreement itself the primary oracle, because A == B == C can still be true when all three are wrong in the same way.

A direct three-way comparison could still be a useful secondary drift check, but it is no longer needed as the common witness: all three now independently converge on the same pinned answer.

So yes: your mutation table exposed a real test-oracle bug. The shipped hashing code was correct, but the checker test could not independently distinguish that correctness from a mirrored bug. It now has an independent witness for that contract.

Your vector is correct against an oracle that is not Python, and it kills five of the eight wrong implementations I threw at it. The three that live are the three your payload is too small and too plain to see.

The literals hold, and not just under hashlib

You sanity-check the pinned digests against hashlib. I checked them against two things that share no code with your repo or with each other:

git hash-object v1.bin   60e7e688abc04f59ca2a9376e05d1516d68e98cc   == VECTOR_V1_GIT_BLOB_SHA1
sha256sum      v1.bin    93f2590cec25b4cfd362e1a05adec39abe0eb58e30194e66503c7d1566c1757c   == VECTOR_V1_LFS_SHA256

Git's own C, and coreutils. So the vector is externally right, not internally consistent. And all three suites do import it, at test_bootstrap_script_qualification.py:52, test_hf_integrity_job_checker_qualification.py:52, test_repo_integrity_observation_authority.py:27. The self-reference is gone.

What the vector can and cannot distinguish

I transcribed the two shipped lines from repo_integrity.py:177, hf_integrity_job.py:330 and app.py:405 (identical in all three), then ran eight wrong versions against V1.

mutant                          killed by V1?   wrong on
M1 sha256 for git-blob-sha1     KILLED          everything
M2 no blob header               KILLED          everything
M3 header missing the NUL       KILLED          everything
M4 sha1 for lfs-sha256          KILLED          everything
M7 strip trailing newline       KILLED          everything
M5 char length, not byte        SURVIVES        any multibyte content
M6 hash first 4096 bytes only   SURVIVES        any file over 4096 bytes
M8 CRLF normalisation           SURVIVES        any file with CRLF

Every survivor survives for the same reason: V1 is 36 bytes of printable ASCII ending in LF. It cannot be wrong at a length boundary, a decode boundary, or a line-ending boundary, because it does not have any.

Only one survivor fails open, and it is the one your own docstring names

M5 and M8 fail closed. A wrong header length or a normalised newline gives a mismatch, the check errors, nothing executes. Noisy, safe.

M6 does not. hf_integrity_job.py says the shape you are defending against is "a same-size wrong-content substitution". An implementation that hashes only a leading chunk accepts exactly that, for every byte past the chunk. Your size check at repo_integrity.py:533 catches an append, so it is not appends. It is a same-size edit past the boundary: size matches, truncated digest matches, content differs, generate returns 0.

How live each survivor is, over all 1054 files

Last round I sampled three files for this and generalised from them. That was wrong on one of the three mutants, so here is the whole corpus instead. Cloned at ae8d69b4, the sha your API reports for main, 1054 tracked paths by git ls-files, zero LFS.

files 1054     total 24,696,833 B     median 25,438 B     max 142,699 B
VECTOR_V1 as a share of the median file        0.14%

mutant                            files that would expose it
M5 char length, not byte                945 / 1054   89.7%
M6 hash first 4096 bytes only           794 / 1054   75.3%
M8 CRLF normalisation                     2 / 1054    0.2%

The correction is M8. I said your corpus does not exercise it at all, on the strength of three files that happened to have no CRLF in them. It does. protocols/AGI.yaml carries 50 CRLF line endings and spec/SPEC-LICENSE.txt carries 11, blob oids 8583547b and e0f1b299 against your live tree, both non-LFS. So all three survivors are live on real content, not two of three. (The path count was also 1052 last round and the right number is 1054.)

M5 is the widest by far. Only 109 of your 1054 files are pure ASCII. The largest gap is 1,402 bytes on art-60-177, and indexes/ARTICLE-RESOLVER.md is 134,775 bytes against 133,381 characters. ATLAS.md, which I quoted last round, is 31,325 against 31,151.

M8 is the narrowest and still worth its line. Both CRLF files are small, 1,914 and 729 bytes, so M6 is not what is hiding them, nothing is. One of the two is a protocol file rather than prose.

The one line I would change

Not a second oracle. A second payload. V2 as multibyte, CRLF-bearing, and longer than any read chunk in the path kills M5, M6 and M8 in one addition, and V1 stays pinned and untouched exactly as your docstring promises.

Which makes the sizing question the real one. Do you size V2 against the read path, so it just has to exceed the largest chunk anything in the chain reads? Or against the corpus, so it exceeds your largest file at 142,699 bytes and the vector has to grow when the repo does?

·

Yes. I agree with the V1 result: the oracle is now independent, but the payload is not discriminating enough. A 36-byte ASCII+LF vector cannot exercise byte-vs-character length, suffix consumption, or CRLF preservation, so M5/M6/M8 surviving it is expected once you look at the input shape.

And thanks for checking the literals against git hash-object and sha256sum. That gives the literals an independent external cross-check in this review, rather than only another Python computation.

I would add V2, but I would not size it against the current corpus maximum.

The corpus size is current repository state, not part of the content-identity contract, and I do not want a versioned KAT to grow every time the largest tracked file grows. I also would not make any current read-chunk size part of the contract.

The contract I want is simpler:

the identity is computed over the complete, exact raw byte sequence, with no decoding, newline normalization, prefix truncation, or character-length substitution.

So I would keep V1 pinned and add an adversarial V2 that crosses the dimensions V1 cannot:

  • multibyte UTF-8 content;
  • CRLF as well as LF;
  • length beyond a deliberately chosen test boundary, explicitly not a runtime or corpus invariant;
  • a distinctive tail marker beyond that boundary.

I would also add a suffix-sensitivity case rather than asking one finite payload to stand in for arbitrary-length consumption: two equal-length byte strings with an identical long prefix and a difference only near the tail, each checked against independently pinned expected digests. That gives us a direct falsifier for prefix-only hashing.

So the test split would be:

V1 -basic algorithm/object-format known answer
V2 -encoding/newline/boundary adversarial known answer
tail sensitivity -prefix-truncation / suffix-consumption falsifier

One point where I read the production consequence differently is M6.

I agree that M6 survives V1, and the conformance suite should kill it. But I do not think a prefix-only implementation fails open in the strict revision-bound chain as it stands.

The expected identity there is the provider-reported full-file Git/LFS identity from revision-bound tree metadata. A prefix-only implementation on a file larger than its cutoff would therefore disagree with the legitimate full-file identity on the unmodified file itself and fail closed.

A same-size suffix edit would preserve the mutant's truncated observed digest, but that truncated digest would still not equal the provider's full-file expected digest. For it to fail open, the expected side would also have to be produced through the same truncated mapping — essentially the self-referential-oracle class we just removed.

So I would classify M6 as:

  • a real conformance gap in V1;
  • a live implementation mutation worth killing;
  • but, under the current revision-bound provider-identity chain, fail-closed rather than fail-open.

If your generate returns 0 M6 run used the provider-derived full-file identity as the expected value, I would be interested in the exact harness, because that would contradict the dataflow above and would be a separate gap worth reproducing.

On your sizing question, then: neither the corpus maximum nor the current chunk size is the invariant. I would make V2 stable and deliberately boundary-crossing, and use the tail-sensitivity case as a separate falsifier for prefix truncation and omitted suffix consumption.

You are right about M6 and I was wrong. I ran it, and it fails closed exactly as your dataflow says.

Here is the exact harness, since you asked for it.

Manifest built with your own write_repository_paths_manifest against the tree API at ae8d69b44c1ff283b9e30aa77ef7c11c6a514841: 1054 paths, 1054 sizes, 1054 identities, all provider-derived. Then check --repository-paths <manifest> --require-content-identity --expected-revision ae8d69b4... on an untouched clone at that sha.

clean checker              exit 0   OK: 2 warning(s)
M6 mutant, same tree      exit 1   764 mounted content identity mismatch

The mutant is one line, raw = raw[:4096] at the top of compute_content_identity. First error is ATLAS.md, expected 1fd22d6a observed 7dedc824. So the observed side disagrees with the provider's full-file identity on the unmodified file, and there is no configuration in which the truncated digest becomes the expected one. Single call site at repo_integrity.py:545, expected always from the manifest's identities. Fail closed.

Where my claim actually came from

generate with no --repository-paths. The git ls-files mode. There are no sizes and no identities there, so compute_content_identity is never called at all.

Which means the fail-open is real but it is not the mutant's. Clean checker, no manifest, a same-size single-byte edit to ATLAS.md at offset 31125:

generate   exit 0   OK: 2 warning(s)   generated outputs unchanged, git status shows only the tampered file

The correct implementation accepts it silently. I attributed to the mutation what belongs to the missing authority metadata. The finding moves rather than dies: every byte-level guarantee in this tool rides on --repository-paths, and without it check and generate verify structure only.

M6's severity, recomputed

It is worse for the implementer and better for your property. On this corpus M6 is not one silent acceptance, it is 764 errors on a clean tree. The consumed set is 762 .md plus the two index projections. The 30 over-4096 paths it never reads are 23 .py, 6 .yaml, 1 .json. Smallest file it breaks is 4,166 bytes, seventy past the boundary.

So a prefix-only implementation is a total outage on first real run, and invisible to V1. That is a better reason to keep it on the kill list than the one I gave.

Sizing, conceded, with one design note

You are right that a KAT anchored to a mutable corpus statistic is not a known answer. Drop that.

But I would make the tail-sensitivity case a ladder rather than a single pair, because a fixture of length L only detects cutoffs below L. On your own consumed set, 4,166 to 142,699 bytes, median 28,250:

cutoff    8,192   breaks 710 of 764
cutoff   65,536   breaks  27 of 764
cutoff  131,072   breaks   3 of 764

A cutoff two orders of magnitude apart is live either way, and one fixture cannot tell you which you have. Four pinned pairs at 1 KiB, 8 KiB, 64 KiB and 160 KiB report the cutoff instead of just its existence, with fixed lengths and no corpus dependency, which is the contract you asked for.

Does that ladder belong inside V2, or is it a third vector class beside tail sensitivity? And what is the largest prefix cutoff you would still call plausible enough to pin against?

·

Thanks for rerunning M6 and for separating the actual no-manifest result from the mutation. Your revised result matches the dataflow I had in mind: with provider-derived full-file identity on the expected side, a prefix-only implementation is a hard outage on the untouched revision-bound tree, not a silent acceptance.

I have implemented the follow-up.

I kept V1 unchanged and added V2 as a second pinned literal known-answer vector. V2 stays deliberately small and is focused on the representation dimensions V1 cannot exercise: multibyte UTF-8, mixed CRLF/LF, and preservation of the exact raw byte sequence.

I did not put the prefix-cutoff ladder inside V2, and I ended up making the distinction stronger than “third vector class.”

The split is now:

V1 -basic algorithm/object-format literal known answer
V2 -exact-byte representation adversarial literal known answer
tail ladder -deterministic metamorphic falsifier for prefix truncation / omitted suffix consumption

The ladder probes:

1 KiB -8 KiB -64 KiB -256 KiB

Each rung generates two equal-length inputs that share the entire prefix through the selected boundary and differ only in a distinctive tail beyond it. The same probes are applied to all three standalone content-identity implementations, under both git-blob-sha1 and lfs-sha256.

I deliberately did not pin a large table of ladder digests. The test-only reference calculator is itself checked against the pinned V1/V2 literals, and the generated tail pairs are then compared against that reference. So V1/V2 remain the concrete known-answer anchors, while the ladder acts as a bounded metamorphic falsifier for prefix truncation / omitted suffix consumption.

I also kept your logarithmic sizing idea but did not define a “largest plausible cutoff.” Every finite prefix-only cutoff is wrong under the contract. The 256 KiB top rung is only a finite diagnostic probe, not a semantic limit, runtime chunk size, or repository-size promise. I chose 256 KiB rather than something derived from the current 142,699-byte corpus maximum specifically so the fixture does not track mutable repository state.

The invariant remains:

identity is computed over the complete, exact raw byte sequence.

The finite probes are bounded falsifiers against known wrong implementation families; they do not define how much input an implementation is allowed to consume.

I also made the no-manifest boundary more explicit in the tooling documentation. Rather than treating assurance as a binary local/strict distinction, the documentation now separates three orthogonal dimensions: path authority, revision binding, and content-identity policy.

Ordinary local git ls-files operation remains a tracked working-tree-candidate check and makes no independent revision-qualified byte-identity claim; the webhook path is the strict revision-qualified profile that combines manifest authority, expected-revision binding, and mandatory content identity.

So I think the two findings now land in separate places:

  • M6 exposed a real conformance-test gap, now covered by the tail falsifier;
  • the no-manifest experiment exposed an assurance-posture boundary, now documented more explicitly rather than being turned into a self-derived local byte-identity check.

Thanks for pushing both far enough to distinguish those two.

Your ladder survived the attack I built for it, and it is stronger than your own docstring says.

I re-derived all four pinned literals independently before touching anything.

V1  36 bytes   blob-sha1  60e7e688...  OK    sha256  93f2590c...  OK
V2  88 bytes   blob-sha1  29819027...  OK    sha256  473015a1...  OK

A fourth confirmation, then. And V2's line endings are escapes inside a bytes literal rather than real CRLF in the source, so autocrlf on checkout cannot move that digest. That was the trap actually available here. The multibyte characters are literal, so the residual exposure is source re-encoding rather than line endings, and test_content_identity_vectors.py catches that one.

Full suite at 016765bf1 on a clean tree: 64/64 across five files (9 vectors, 8 probes, 9 bootstrap, 13 job checker, 25 observation authority).

The attack, and why it failed

The ladder looked blind to one family. make_tail_pair puts the difference deep in the tail, which is optimal against prefix truncation and maximally visible to anything that reads the tail at all. So I mutated all three implementations to a head+tail fingerprint, 64 KiB from each end, true length kept in the blob header, no-op below 128 KiB:

_M7 = lambda b: b if len(b) <= 131072 else b[:65536] + b[-65536:]

It reproduces V1 and V2 exactly. It reads neither the middle nor 67% of the top rung. I expected it through.

M7 vs shipped suite     3 FAIL of 64
caught at               boundary=262144, both identity kinds

Caught. And not by the metamorphic property: digest_a != digest_b holds fine for M7, because the differing bytes sit inside the tail it does read. What killed it is the second half of your test, where each payload is qualified against reference(payload) over the full bytes. That is a known-answer check at four input sizes, not a prefix falsifier, and it catches every non-full-consumption family rather than prefix-only ones.

So the docstring undersells the code. "No prefix-only shortcut observed at these sizes" is weaker than what the suite actually proves.

Where the edge actually is

Not 256 KiB. make_tail_pair builds boundary + boundary // 2, so the top rung feeds 393,216 bytes, and detection tracks payload length rather than the label.

elision engages above 142,699 B    CAUGHT      3 of 64
elision engages above 393,216 B    UNDETECTED  0 of 64
elision engages above 393,217 B    UNDETECTED  0 of 64

The edge is exactly the top rung's payload length. Against your corpus at that revision:

files                                     1,056
largest        142,699   indexes/REPOSITORY-MARKDOWN-INVENTORY.md
> 131,072                                     3
> 262,144                                     0
> 393,216                                     0

Headroom 2.76x, not the 1.5x you would read off the label. Every elision that could touch a byte in this repo has to engage at or below 142,699, and you cover to 393,216. On this corpus the ladder is a complete net for the family, not just a diagnostic.

The one thing still corpus-dependent

The headroom, not the fixture. You cut corpus dependence out of the fixtures deliberately and that was right. But nothing now notices if a path crosses 393,216 bytes and starts sailing over the top rung. Your three largest files are all index projections that grow with the corpus, and REPOSITORY-MARKDOWN-INVENTORY.md is the one nearest the edge.

The check that closes it is not a fixture, it is an assertion about the repo: no manifest path exceeds the largest probe payload. Fixtures stay corpus-free, and the headroom becomes checked rather than coincidental.

Would you take that assertion, or do you read a growing index projection crossing 384 KiB as the point where the ladder earns another rung instead?

·

Thanks, this was a useful attack, especially because it exposed that the fixed ladder was doing more than I had described.

You are right about what killed M7. The metamorphic half alone was not enough: digest_a != digest_b still held because your head+tail implementation consumed the differing tail. The failure came from the independent full-byte reference comparison on each generated payload.

I have updated the implementation and documentation around that distinction.

I kept V1, V2, and the fixed ladder unchanged:

V1 -literal algorithm/object-format KAT
V2 -literal representation-fidelity KAT
fixed ladder -stable bounded conformance/falsification evidence

The ladder is now described more accurately as combining two checks:

  • a metamorphic tail-sensitivity relation;
  • a full-reference comparison for each generated payload.

So it can falsify prefix truncation and other byte-elision strategies that produce a non-reference identity at those bounded probe sizes. I stopped short of describing that as a proof against every possible non-full-consumption implementation, because the fixture set is still finite.

You are also right that the top 256 KiB boundary produces a 393,216-byte payload, so the effective size of that particular fixed probe is 384 KiB rather than 256 KiB.

I did not, however, turn that number into either a repository limit or a permanent “add another rung here” threshold.

Instead I split stable diagnostic/conformance evidence from current live-domain size coverage.

I added a deterministic dynamic live-domain sentinel. The test measures the current maximum byte size among payloads covered by the live qualification surfaces of the three content-identity implementations — declared Markdown, generated projections, and the wrapper/checker source payloads themselves — and generates a synthetic sentinel at the next power of two strictly above that maximum.

That sentinel is then checked against the same independently anchored full-byte reference by all three independently maintained content-identity implementations, under both git-blob-sha1 and lfs-sha256.

This is a size-domain sentinel, not a representative sample of repository content.

So the structure is now:

unbounded contract
-identity is over the complete, exact raw byte sequence

stable evidence
-V1 / V2 / fixed 1-8-64-256 KiB ladder

current-domain size applicability evidence
-dynamically sized sentinel beyond the current live qualified-payload maximum

This means I did not add:

  • manifest path <= 393,216 as an admissibility rule;
  • a repository/runtime content-size ceiling;
  • a manual rule saying that crossing 384 KiB earns another fixed rung.

If the repository grows, the dynamic sentinel grows with it automatically. The fixed ladder remains corpus-independent and useful for stable diagnostics, while the live-domain size evidence follows the size domain whose coverage claim it is actually supporting.

The documentation also now explicitly says that neither the fixed ladder nor the dynamic sentinel proves correctness for arbitrary future input lengths. The contract is unbounded; the evidence is finite and scoped.

So I ended up taking a third route relative to your final question:

do not constrain the repository to the finite falsifier, and do not rely on remembering to extend the ladder when the corpus grows. Let a separate size-applicability probe scale with the live domain instead.

Thanks again — the M7 mutation was what made the distinction between the ladder's metamorphic role and its full-reference role visible enough to separate those two kinds of evidence cleanly.

The sentinel works, and it named itself when I attacked it.

Baseline first, at 5f717e9d: 121/121 across nine files.

Then I mutated all three implementations to prefix-truncate above 64 KiB. Caught, and the new file is one of the things that caught it:

test_content_identity_dynamic_live_domain_sentinel_matches_reference
  kind=git-blob-sha1 size=262144

Live max reads 142,699, so 1 << bit_length() lands on 262,144. The manual rung is gone and the size axis is closed.

The gap that is left is not size

Minimal pair. Same transform, one extra condition in front of it.

C2   replace(CRLF, LF)                          116/121   CAUGHT
M8   replace(CRLF, LF) if len(raw) > 88         121/121   clean

C2 fails in three places, all of them tests you wrote for exactly this: V2 representation fidelity in the bootstrap, in the wrapper, and in the observation authority. M8 is the same two lines with a size gate, and the whole suite passes.

The gate does not have to be 64 KiB. It has to reach 88.

gate     payloads above it    detected by
   0                    11    V2
  87                    10    V2
  88                     9    NOTHING
65536                    5    NOTHING

88 is len(VECTOR_V2_BYTES). Every newline-fidelity and multibyte-fidelity guarantee in the suite lives inside that one payload, and a gate one byte past it steps over all of them.

Second family, same cell

A chunked reader that decodes and re-encodes each chunk. Identity on pure ASCII at any length, identity below one chunk, wrong only where a multibyte sequence straddles a chunk boundary.

chunk B    detected by
      16   V2
      32   NOTHING
    4096   NOTHING
   65536   NOTHING

Both verified rather than argued: gate 88 is 121/121, chunk 4096 is 121/121.

Why they survive

payload             len   non-ASCII   CR   LF   distinct bytes
V1                   36           0    0    1               21
V2                   88          27    2    4               42
ladder 1 KiB       1536           0    0    0               28
ladder 256 KiB   393216           0    0    0               28
dynamic sentinel 262144           0    0    0               16

Every payload above 88 bytes is pure ASCII with no newline of either kind. So the suite exercises the large-input path only on ASCII, and the representation-sensitive path only at 36 and 88 bytes. A defect needing both is in neither cell.

The sentinel is also the narrowest payload in the suite, 16 distinct byte values against V2's 42, because hex. And it is the one that self-scales, so as the corpus grows the top probe gets narrower in byte class, not wider.

Your reason for hex is right about the constraint and maximal about the cost. census() requires valid UTF-8. Valid UTF-8 is not sixteen ASCII symbols.

What I am not claiming

Neither mutant corrupts anything you ship today.

tracked files over 64 KiB                  27
of those, containing CRLF                   0
of those, containing a non-ASCII byte      27
internal 64 KiB boundaries across them     30
continuation-byte density              0.2298%
expected straddles                      0.072
P(zero straddles)                         0.93

So the chunk family is silent right now at 93%. That is a coin, not a property, and it is the same shape as the headroom point from last round: the suite cannot see the transform, and the corpus happens not to trigger it.

The 27 of 27 is the line I would not leave alone. Every large file you ship is non-ASCII. Every large payload you test with is not.

The change

make_dynamic_probe already emits hex blocks. Threading a CRLF and a few 2/3/4-byte sequences through that stream keeps it valid UTF-8, keeps it deterministic, keeps the exact length, and moves the representation axis from 88 bytes to 262,144 and self-scaling from there. One payload, one reference comparison, no new rung.

Would you put the representation axis inside the dynamic probe? Or does folding two properties into one fixture cost you the diagnostic separation you just spent a round building, in which case the honest shape is a second sentinel rather than a wider one?

·

Yes, I think this attack found a real missing interaction cell rather than another weakness in either V2 or the size sentinel individually.

The suite had per-axis coverage on both dimensions:

representation fidelity -> V2
large-input consumption -> fixed ladder + dynamic size sentinel

but it did not have joint coverage of representation-sensitive behavior at large sizes. Your gated-CRLF mutant and chunked decode/re-encode mutant sit exactly in that empty interaction cell.

I kept the existing dynamic size sentinel unchanged and added a second dynamic representation-at-scale sentinel rather than folding the two responsibilities together.

The evidence split is now:

V1 -> literal algorithm/object-format KAT
V2 -> literal representation-fidelity KAT
fixed ladder -> stable bounded consumption/elision falsification
dynamic size sentinel -> current live-domain size applicability
dynamic representation-at-scale sentinel -> bounded representation × size × selected-boundary alignment interaction falsification

Both dynamic sentinels use the same live-domain-derived target size, so there is still only one size authority. At the current live maximum of 142,699 bytes, both are 262,144-byte probes.

The new probe is deterministic, exact-length, and valid UTF-8. It distributes mixed CRLF/bare-LF and recurring 2-, 3-, and 4-byte UTF-8 sequences across the payload rather than concentrating the representation-sensitive material in a small prefix.

It also deliberately places multibyte sequences across a bounded set of selected power-of-two internal boundaries. Those are adversarial test points, not a claim that an implementation uses those chunk sizes, and not a claim of arbitrary chunk-boundary coverage.

The probe generator itself is tested for the properties it claims: exact length, UTF-8 validity, line-ending diversity, 2/3/4-byte sequences, distribution across early/middle/late regions, and actual straddling at each selected boundary.

The new sentinel is then compared against the same independently anchored full-byte reference under all three independently maintained content-identity implementations and both git-blob-sha1 and lfs-sha256.

I also reran the two failure families you demonstrated.

For the gated CRLF transform:

replace(CRLF, LF) if len(raw) > 88

I now get the diagnostic shape I wanted:

V2 -> PASS
dynamic size sentinel -> PASS
representation-at-scale sentinel -> FAIL

The 4096-byte chunked decode/re-encode mutant produces the same separation:

V2 -> PASS
dynamic size sentinel -> PASS
representation-at-scale sentinel -> FAIL

So the new test is not just making the suite red somewhere; it localizes the missing evidence to the representation × scale/alignment interaction.

I deliberately stopped there.

I did not add arbitrary chunk-size enumeration, NFC/NFKC cases, BOM stripping, whitespace normalization, or a Cartesian product of every representation factor. The documentation now says explicitly that the selected boundaries and representation classes are finite adversarial evidence, not a proof over arbitrary future transforms or chunk boundaries.

So the contract remains:

identity is over the complete, exact raw byte sequence.

The evidence remains finite and factorized:

stable literal anchors + stable bounded falsifiers + live-domain size evidence + one independently diagnosable interaction sentinel.

Your 27/27 observation was the part that convinced me this was worth adding: every large tracked file currently contains non-ASCII bytes, while every large synthetic identity probe had been ASCII-only. That was a real mismatch between the live domain and the joint evidence surface, not merely a hypothetical mutant.

Thanks, this one moved the design from per-axis coverage to explicit interaction evidence without turning the size sentinel into a mega-fixture.

Both separations reproduce, and the new probe has exactly one CRLF in it.

I re-ran your two failure families against e8688aee before anything else.

mutant                                 caught by
replace(CRLF, LF) if len(raw) > 88     representation-at-scale sentinel, nothing else
chunked decode/re-encode, 4096         representation-at-scale sentinel, nothing else

So the diagnostic separation is real, and the second sentinel was the right call over a wider one.

The probe as built

make_dynamic_representation_probe(262144)
  exact length            yes
  valid UTF-8             yes
  4-byte straddles        14 of 14 selected boundaries, all present
  2-byte sequences         3
  3-byte sequences         3
  CRLF                     1     at offset 229376

One CRLF, in the last eighth.

place() returns silently on overlap, and the boundary markers go first by design so the region markers yield to them. At size = 262144, size // 8 = 32768 and size // 2 = 131072 are themselves selected boundaries already holding a 4-byte sequence at boundary - 2. So place(32768, b"\r\n") and place(131072, b"\r\n") both overlap and both return.

The live sentinel size is 1 << bit_length(), so it is always a power of two, so size // 8 and size // 2 are always in representation_probe_boundaries(size). This is not an edge case. It is the only case.

Why the generator test cannot see it

has_representation_material() is an OR over CRLF, 2-byte, 3-byte and 4-byte. The 4-byte boundary markers are in every region by construction, so the early and middle assertions pass on those alone and the CRLF skew is invisible.

Per third, at 262144:

region   CRLF   4-byte   2-byte
early       0       13        1
middle      0        1        1
late        1        0        1

The 4-byte axis is skewed the other way for the same reason. Powers of two below N pile up at the bottom, so 13 of 14 straddles are in the first third and none are in the last.

What it costs, verified

mutant                                                       caught by
if len(raw) > 88: normalise CRLF in raw[:65536] only         NOTHING
if len(raw) > 88: normalise CRLF in the first 7/8 of raw     NOTHING
if len(raw) > 88: normalise CRLF in the LAST 1/8 of raw      representation-at-scale sentinel

A prefix-scoped, size-gated newline normaliser is last round's M8 with one more condition in front of it, and the new sentinel does not see it. It sees the suffix-scoped one.

The change is three bytes

Nudge the two colliding relative positions off the powers of two:

for relative_position in (size // 8 + 3, size // 2 + 3, size - size // 8):
              CRLF total   per third    14 straddles   prefix-scoped mutant
as shipped             1   [0, 0, 1]    intact         escapes
with + 3               3   [1, 1, 1]    intact         caught

Exact length and UTF-8 validity unchanged, every boundary straddle still present. I ran it rather than argued it.

I would also make the distribution test per-class instead of an OR, since the OR is what let the skew through, and a per-class version would have caught this before I did.

Your chunk axis holds, and your scope note is honest about where it stops. Chunk 3000 escapes everything, which is exactly what you said it would do.

One question

The overlap guard skips silently. That is why the CRLF vanished, and it is the same shape as the size gate that started this whole line: a condition in front of a transform, doing nothing visible.

Would you rather it raise, so a collision is a build failure instead of a silent coverage loss? Or does "representation coverage across regions, not a promise that every specific marker exists" mean you want it to keep yielding, in which case the honest fix is to assert per-class per-region and let that assertion be the promise?

·

Yes, I ended up taking both.

The overlap is now a construction failure rather than a silent yield, and the fixture tests now assert the representation classes independently per region instead of collapsing them through the old OR predicate.

I also took your +3 adjustment for the two colliding CRLF positions. At the current 262,144-byte live-domain-derived probe size, that gives CRLF in all three regions while preserving all 14 selected boundary straddles.

There was one more version of the same problem hiding underneath that: the regional 4-byte markers and the selected-boundary 4-byte markers were initially the same code point, so a missing regional marker could still be masked by a boundary marker in the same region.

I split those as well:

regional 4-byte marker -> 🧪
selected-boundary straddle marker -> 😀

so the two claims are now independently observable rather than merely independently placed.

The generator contract is now effectively:

required placement collision -> construction error
regional representation -> CRLF / bare LF / 2-byte / 3-byte / 4-byte independently present in early, middle, and late regions
selected-boundary alignment -> 4-byte UTF-8 straddles every selected power-of-two boundary

I added a direct collision regression as well, rather than relying only on a too-small probe failing out of bounds. The probe fixture suite now passes 26/26, and removing only the regional 4-byte placements now correctly breaks the regional-distribution test instead of being masked by the alignment markers.

So I think your diagnosis was right: the problem was not another missing sentinel. It was that the sentinel generator could silently fail to realize part of the evidence surface it claimed to construct, and its own validation was coarser than that claim.

The useful rule I am taking from this round is:

For required test evidence, construction should fail closed, and fixture validation should be at least as fine-grained as the evidence claim it supports.

Thanks, this was a good catch. The second sentinel still keeps the diagnostic separation we wanted, but required evidence can no longer disappear silently during fixture construction.

Fail closed on construction is the right half. The half I would test next is whether the validation is actually as fine-grained as the claim now says it is.

Your contract has three clauses, and the middle one is the one that grew teeth:

regional representation -> CRLF / bare LF / 2-byte / 3-byte / 4-byte
                           independently present in early, middle, late

That is five classes across three regions. Fifteen required placements. You verified one class by removal, the regional 4-byte, and it correctly broke the regional-distribution test rather than being masked by the alignment markers. Splitting 🧪 from 😀 is what made that observable, and it is a better fix than the one I proposed, because it separates the claims rather than just separating the positions.

So the question is coverage of the removal test, not coverage of the fixture. And notice what the removal you ran actually proves. You took out the regional 4-byte placements together, which is three of the fifteen going out at once. That shows the class is load-bearing. It does not yet show that early, middle and late are each independently load-bearing, because one surviving region can carry the assertion for the other two, and that is precisely the failure you just fixed one level down.

The sharper version is one placement at a time. Does each of the fifteen break a test, and does each break a different one? If two can be removed and only one assertion goes red, the contract still reads finer than it verifies, and that is the same shape as the OR predicate you just retired.

I ran the generalized version of this today on an unrelated package, a control-plane evidence release with a four-test suite and a signed hash manifest. Seven mutants, each deleting one guarantee its own docs claim. Four survived with a green suite and every published number unchanged. Two reasons, both structural rather than sloppy: one field was written as a literal False at construction and never computed, so its counter could not move, and the headline metric was a set difference between two arms, so any failure that hit both arms equally cost nothing.

The second one is the one I keep meeting. A differential metric is blind to any fault that moves both arms the same way, by definition. Your per-region assertion is not differential, which is why I think it will hold. But the masking you found underneath it is the same family one level down: a single code point was satisfying two independent claims at once, so a failure of the regional claim got absorbed by the boundary claim. Evidence that is shared between two claims cannot discriminate between them.

Which brings the rule you stated somewhere sharper, if you want it: fixture validation should be at least as fine-grained as the claim, and the evidence that it is should be a red run, not a reading of the assertion.

So: have you run the removals one placement at a time rather than one class at a time, and did any single removal come back green against 26/26?

·

Yes, I took the sharper version and turned it into an explicit first-order mutation test.

The regional claim is now represented as exactly fifteen pinned cells:

5 representation classes × 3 regions

with the exact ordered set itself asserted separately, so the mutation matrix cannot silently shrink from 15/15 to 12/12 if a class disappears from the enumeration.

For each cell, one at a time, the test:

  1. starts from a genuine generated probe;
  2. locates that marker from the actual emitted bytes rather than re-deriving the generator's placement arithmetic;
  3. overwrites only that marker with the corresponding bytes from the plain ASCII dynamic probe;
  4. checks that length and UTF-8 validity are unchanged;
  5. runs the per-class/per-region validator;
  6. requires the validator to report exactly that one cell as missing.

So the acceptance condition is stronger than “some assertion went red”:

remove(cell_i) -> missing == [cell_i]

for all fifteen cells independently.

I also pinned the fifteen-cell inventory itself separately. That closes a smaller self-reference trap I noticed while implementing your suggestion: if the mutation test merely iterated whatever cells the validator currently knew about, deleting a class from both sides could reduce the matrix to 12/12 and still look healthy.

The current result is:

15 mutants
15 detected
15 correctly localized
0 survivors
0 mislocalized
0 impure mutants

The mutation harness does not reuse the generator's placement formulas. It scans the actual generated payload for the claimed marker in the claimed region, then removes that observed marker. That keeps “where the generator says it wrote something” separate from “what the fixture actually contains.”

So this is now red-run evidence at the same resolution as the regional claim, rather than an inference from reading the validator.

I also kept the scope narrow. This is first-order single-placement falsification of the fifteen regional claim cells, not pairwise or higher-order combinatorial mutation coverage. I do not think fifteen separate named test functions are the important property here; detection and localization at the fifteen-cell claim surface are.

Your unrelated-package example helped sharpen why.

A differential metric can be perfectly implemented and still have a common-mode fault direction that leaves the published value unchanged. The earlier shared 😀 witness had the same structural shape at a smaller scale: two claims intended to be independently falsifiable collapsed onto one observation, so one claim could disappear without changing what the validator saw.

The failure mode in both cases is not simply “insufficient test count.” It is an observationally null fault direction: a claim-relevant change exists that the evidence mapping does not distinguish.

Splitting 🧪 from 😀 removed one such ambiguity between the regional and alignment claims. The fifteen one-at-a-time removals now test the next level down: whether each declared regional cell is itself distinguishable under the selected single-removal fault family.

So I think the rule sharpens beyond just matching assertion structure:

A claim is only as fine-grained as the smallest claim-relevant fault its evidence system can distinguish.

Or operationally:

claim granularity, validation granularity, and falsification granularity should agree at the surface being asserted.

That still does not imply exhaustive combinatorial coverage. The fifteen removals establish first-order sensitivity for the fifteen declared cells; they do not prove arbitrary two-cell interactions, substitutions, relocations, or unrelated representation transforms.

Thanks — this was a good next test because it moved the question from “is the validator written at the claimed resolution?” to “can the evidence system actually observe a fault at that resolution?”

That is a much stronger distinction.

I was wrong about the drift, and your own harness is what showed me.

My worry was that multiplicity would creep in as the probe grew, because a one at a time removal has power exactly where a cell has one instance. _locate_regional_cell does region_bytes.find(marker_bytes), and _regional_representation_failures needs the count to reach zero. So detection requires count == 1.

I measured it instead of assuming. Your generator, seven sizes:

size        cells with exactly 1 instance
1,024                 15 / 15
4,096                 15 / 15
16,384                15 / 15
65,536                15 / 15
262,144               15 / 15
1,048,576             15 / 15
4,194,304             15 / 15

Singletons everywhere, and not by luck. make_dynamic_probe emits 0123456789abcdef and nothing else at every size I tried. That alphabet is disjoint from all five marker byte sets, so filler can never accidentally mint a CR, an LF, an é, a 日 or a 🧪. Accidental recurrence is not merely unlikely here, it is impossible by construction. Your 15 of 15 is sound, and it is sound for a better reason than the fixture happening to be small.

The residue is that the property holding it up is asserted in prose. It lives in _make_single_cell_mutant's docstring, "always plain ASCII hex digits (0-9a-f)". The nearest thing to a test is len(set(payload)) > 1, which passes on any alphabet of two or more bytes.

So I ran your suite under a maintenance edit a reasonable person could make: keep the counter-indexed hexdigest construction, wrap it to 64 columns so the fixture diffs nicely in an editor.

exact requested length     still true
valid UTF-8                still true
len(set(payload)) > 1      still true (17 distinct bytes)

28 tests in the module     1 red
impure mutants             none
mislocalized               none
survivors                  bare-LF@early, bare-LF@middle, bare-LF@late

Your purity net, the one built to catch a second fault injected by the harness itself, stays green. Localization stays green. One test goes red, and it reports:

survivors (removed but undetected)

The validator was never undetecting anything. The fixture grew extra bare LFs, the removal took one of many, and the claim became over-witnessed. Opposite cause from what the label names, opposite fix.

So it is fail loud, not fail silent, and I should have said that. The cost is a wrong first hypothesis at 2am, on the one test whose whole job is telling you which cell broke.

The instance count fixes it for one integer per cell. bare-LF@early: removed 1 of 4096 reads as a fixture change instantly. removed 1 of 1 sends you to the validator, correctly.

Does anything currently pin make_dynamic_probe's alphabet, or is the hex property load-bearing for the mutation matrix in a place no test is watching?

·

Thanks, I think your reclassification is exactly right, and the measurement made the reason stronger than either of us had stated before.

The singleton property was not drifting with size. The current filler is plain hex and disjoint from all five representation-marker classes, so its growth cannot accidentally mint another CRLF, bare LF, or selected multibyte sequence.

The remaining issue was one layer higher: that property was load-bearing for the mutation experiment, but the harness was relying on it rather than independently proving that the experiment it was about to interpret was actually a single-cell removal.

I tightened that boundary.

The harness now checks the baseline witness counts before constructing any validator claim: every one of the 15 regional cells must have exactly one witness. For each mutant, it then verifies that the target changed from exactly 1 -> 0 and that every non-target cell count remained unchanged. Only after those checks pass is the validator allowed to produce a survivor or localization result.

I also pinned the 64-column wrapping case directly. A formatting change that introduces additional bare LFs now fails as a fixture precondition failure before the validator is evaluated, rather than surfacing as “removed but undetected.”

So the distinction is now explicit:

removed 1 of N, where N > 1
→ the falsifier itself is not admissible for that claim

removed 1 of 1, target becomes 0, everything else stays fixed
→ now the validator can legitimately be implicated if it misses the removal

I think there is a useful general rule hiding in this one:

Qualify the intervention before attributing the outcome.

A detector can only be blamed after the harness has independently established that the condition it was supposed to detect was actually instantiated. Otherwise a red test may still be useful, but its failure attribution is unsound.

That also sharpens the point you made about this being fail-loud rather than fail-silent. The suite was already telling us that something had gone wrong; what it did not yet know was which layer had gone wrong.

Thanks for going back and measuring the original drift hypothesis rather than carrying it forward. The correction itself exposed the more interesting issue.

Ran it at 31bc717. 31 green, up from 28, and the 64-column edit is now a named test with the presence-check negative control sitting inside it. test_64_column_wrapped_filler_fails_the_singleton_precondition_not_the_validator asserts both halves: the singleton precondition fires, and _regional_representation_failures() stays green on the same bytes. That second assertion is the one that makes it a regression test rather than a passing test.

You did answer the alphabet question, in test_dynamic_probe_filler_is_marker_free. So I went and measured which of its five checks actually does the work, across filler edits a maintainer might plausibly make.

                                isascii  no CR  no LF  no multibyte  hex-subset
today's filler (hex)              pass    pass   pass     pass          pass
wrapped every 64 cols             pass    pass  FIRES     pass         FIRES
tab-separated for readability     pass    pass   pass     pass         FIRES
NUL padding appended              pass    pass   pass     pass         FIRES
swapped to a base64 alphabet      pass    pass   pass     pass         FIRES
swapped to uppercase hex          pass    pass   pass     pass         FIRES
an 'é' slips in                  FIRES    pass   pass    FIRES         FIRES

hex-subset fires on 6 of the 6 changed fillers. The four checks the docstring calls load-bearing fire on 2 of 6 between them.

And the multibyte block cannot fire alone. Every one of those four markers is made of bytes at or above 0x80, so a marker present implies isascii() already False. It is strictly implied by the line above it. There is no filler that trips it and not isascii().

Which inverts the labels. The docstring says the CR/LF/multibyte checks are "the load-bearing property this test exists for" and that hex-subset is "implementation-choice-level. Not the load-bearing property above." Measured the other way round: the named-marker checks catch only classes already in the matrix, which are the ones you already know about. The alphabet check is the only line that catches a class you have not added yet.

The concrete case is tab. Suppose a representation class gets added that is an ASCII control byte, tab being the obvious neighbour to CRLF. A filler that grew tabs stays pure ASCII, has no CR, no LF, no multibyte marker, and passes four of the five checks. Only the one flagged optional fires. Row 3 above is exactly that payload.

The singleton precondition does not cover the gap either, and correctly so. _regional_representation_counts() counts the fifteen cells that exist. A contaminating byte that is not yet a cell is not something it can count.

None of this is a bug. The suite is green and it is right today. It is about which line survives a refactor, and the docstring currently tells a maintainer that the strongest line is the droppable one. Cheapest repair is a comment swap, not code.

Your rule generalizes further than the harness, by the way. Qualify the intervention before attributing the outcome is the same move as: check the fixture before blaming the detector. Both are refusing to read an outcome until you have established the condition was instantiated. That is also what my table is doing to your docstring, on a much smaller scale.

Is an ASCII control class ever a candidate for the matrix, tab in particular? If it is, the filler check and the cell list have to move together, and only the alphabet line would have caught the filler half on its own.

·

Thanks, your table convinced me that the labels were wrong even though the fixture behavior was right.

I changed the documentation around test_dynamic_probe_filler_is_marker_free to separate three different roles more accurately.

The lowercase-hex restriction is now described as the fixture's construction envelope, not as a disposable implementation choice. Its useful property is broader than the current marker list: it keeps the filler vocabulary deliberately narrower than the representation vocabulary, so edits such as TAB-separated formatting, NUL padding, uppercase hex, or a different alphabet are rejected before they can quietly become new fixture semantics.

The CR/LF/current-marker checks are now described as claim-bound checks for the representation classes the matrix models today.

And I corrected the multibyte description as well: for the current selected UTF-8 markers, those explicit checks are not an independent enforcement dimension beyond isascii(). They remain useful because they make the intended representation exclusions local and diagnostic, but I no longer count them as separate assurance.

So the dependency is now stated in the right direction:

lowercase hex -> ASCII -> current selected multibyte markers absent

but absence of those selected markers does not imply ASCII.

On TAB specifically: yes, I think an ASCII-control representation class is a plausible future matrix extension, with TAB being the obvious first candidate. I did not add it now, though. The current matrix is still a bounded falsifier for the failure families it already claims to exercise, and I do not want to broaden that claim surface just because another class is imaginable.

What I did make explicit is the maintenance relationship you pointed out: the filler vocabulary and the modeled representation-class vocabulary are coupled fixture structures. If a new class such as TAB is added later, the filler-envelope assumptions need to be revalidated at the same boundary.

There is another reusable rule here too:

Qualify the guard before attributing assurance.

A guard's assurance role should come from what it independently excludes, which other guards subsume its exclusion set, and which claim boundary it actually protects — not from how important its comment sounds.

Your table was a nice small example of exactly that: it tested the docstring's classification instead of trusting the classification because it was written down.

The gap you say a wider vocabulary could eliminate is 110 bytes wide, and I think that number changes the recommendation.

New docstring, on the relationship between the envelope and the claim-bound checks: "a wider claim vocabulary could narrow, or even eliminate, the gap between what the alphabet check and the current claim-bound checks each exclude." Counted as byte values:

excluded by the lowercase-hex envelope        240 of 256
excluded by isascii() plus the CR and LF checks   130
excluded by the envelope alone                    110

All 110 are ASCII. 31 control, 79 printable. TAB is one of them. So is NUL, so is space, so are A through F.

The gap does narrow, by exactly one byte per single-byte class. Adding TAB takes it 110 to 109. Eliminating it takes 110 classes, which is the whole non-hex ASCII table. The multibyte half is already collapsed: isascii() excludes all 128 bytes at or above 0x80 in one line, so your four named markers add nothing there, and of the seven checks in the test exactly two, CR and LF, exclude anything the envelope does not.

The ordering makes that sharper than it reads. check() raises on the first failure, and the envelope check is the first call in the loop. So for any filler the envelope rejects, which is every filler in my table from last round, the CR, LF, marker and isascii() checks never execute. Their diagnostic locality is real but currently unreachable: a maintainer who wraps the filler at 64 columns now gets "must stay within the lowercase-hex-digest alphabet" and never sees the LF check that would have named the class.

That is a two-line fix in the opposite direction to dropping anything. Put the claim-bound checks first and the envelope last, and every one of them gets to speak for the class it names before the envelope catches the residue. Same coverage, same 110-byte backstop, but the failure message identifies the class when a class is identifiable.

I fetched the file rather than trust the summary: scripts/tests/test_content_identity_probes.py, 55408 bytes, sha256 a53eed073b526063852996da448c79c40ec528e2dc4699079c9faa68cafbcd64, dataset at dd44e433e2, modified 12:14:58Z, two minutes before your reply. The rewrite is there and it is documentation only, which is what you said it was.

Does the ordering matter to you, or is the envelope-first arrangement deliberate because you would rather the strictest constraint be the one that names itself?

·

Thanks. Yes, the ordering matters, and envelope-first was not deliberate.

Your 110-byte count also changes how I would describe the relationship between the two layers.

I had written that a wider claim vocabulary could eventually eliminate the gap between the claim-bound checks and the lowercase-hex envelope. That is technically true, but misleading as a design direction. The measured residual is much larger and more structural than that wording suggests: adding TAB would only move it from 110 to 109. Eliminating it would effectively mean modeling the whole non-hex ASCII space.

So I now think the cleaner distinction is:

  • the named checks describe the representation classes this matrix currently claims to discriminate;
  • isascii() catches the remaining non-ASCII space;
  • the lowercase-hex restriction is intentionally broader and remains a residual fixture-construction backstop.

I did not add TAB or any other new class.

I also changed the fail-fast ordering to:

CR → LF → selected multibyte markers → isascii() → lowercase-hex envelope

so the most specific modeled class gets to name the failure before a broader guard shadows it. A wrapped filler now reaches the LF diagnostic first; selected multibyte contamination reaches its marker-specific diagnostic; unrelated non-ASCII falls to isascii(); TAB/NUL/space/uppercase-style drift falls through to the envelope.

I also pinned that ordering through the actual shared guard path rather than testing a separately duplicated ordering model, so the regression test cannot stay green if the real guard sequence drifts.

I think the reusable rule here is:

In a fail-fast harness, order specific attribution before residual exclusion.

Or more generally: a diagnostic guard only contributes the diagnostic role we attribute to it if that guard is actually reachable as the observed failure.

Thanks for measuring the byte-space rather than leaving the “wider vocabulary” claim qualitative. The 110-byte number made the boundary much clearer.

The reordering is real and the test that pins it has one blind spot. Four of the five guards are pinned. isascii() is not.

Ran the file rather than read it. scripts/tests/test_content_identity_probes.py at main is 63034 bytes, sha256 34483f4d..., up from 55408 and a53eed07.... Stdlib-only, so it runs. 32/32 pass.

The ordering is exactly what you said, lines 368 to 383: CR, LF, the four _FILLER_MARKERS, isascii(), envelope. And test_filler_guard_order_attributes_the_specific_class_first does call _check_dynamic_probe_filler() itself rather than a hand-copied sequence. That was the claim most likely to be only nominally true, and it holds.

Then I mutated the guard and re-ran, because a precedence test is worth what it fails on.

mutation                                         result
envelope moved to the front (the original bug)   FAIL, 31/32
   "a CR-contaminated filler must be attributed to CR,
    got: must stay within the lowercase-hex-digest alphabet"

isascii() and envelope swapped                   32/32 PASS

The second is a real change to the documented order and no assertion notices. Same payloads through both builds:

payload                    real order            envelope-before-isascii
"u+00FC" + filler          must be pure ASCII    must stay within the ... alphabet
"u+03B1" + filler          must be pure ASCII    must stay within the ... alphabet
U+1F9EA   + filler         regional-4byte        regional-4byte
TAB       + filler         ... alphabet          ... alphabet

So isascii() becomes diagnostically unreachable, which is the exact defect you just removed one layer down, and the suite stays green.

The cause is the probe set, not the ordering. The test witnesses CR, LF, one selected marker, TAB, and a clean filler. TAB is ASCII, so it passes straight through isascii() and lands on the envelope under either arrangement. The marker case is caught above isascii(). So no payload in the test ever has its attribution decided by where isascii() sits.

The class that would pin it is the one your own docstring already names at lines 302 to 305: isascii() being false does not imply one of these specific markers is present, since plenty of other multibyte sequences exist. A U+00FC filler is exactly that class. Non-ASCII, not in _FILLER_MARKERS, and the only kind of payload whose attribution differs between the two orders. One more probe and the ordering is pinned end to end.

That is your own rule one level up. A guard only contributes the diagnostic role we attribute to it if it is reachable as the observed failure. A precedence test only pins the positions where it can observe a difference, and right now it observes four of five.

Is the omission deliberate, because isascii() is a backstop whose exact position you would rather not promise, or is it just the class nobody wrote a witness for?

·

Thanks, it was not deliberate. It was the one missing witness class in this precedence test.

Your mutation makes the gap very clear: the existing cases reached CR, LF, a selected marker, and the residual ASCII envelope case, but none of them made the relative position of isascii() and the envelope observable.

I added a U+00FC (ü) case for exactly that reason.

It is:

  • non-ASCII;
  • not one of the current _FILLER_MARKERS;
  • outside the lowercase-hex construction envelope.

With the intended order, it must therefore reach isascii() first and report the isascii() diagnostic. If the envelope moves ahead of isascii(), the observed attribution changes to the alphabet diagnostic.

The new case goes through the same shared _check_dynamic_probe_filler() path as the other precedence witnesses; there is no second hand-copied ordering model.

I am also treating U+00FC strictly as a test-only precedence witness, not as a new regional representation class. The matrix claim surface is unchanged.

I tightened the wording around what the regression actually promises as well. The interesting contract is not an arbitrary exact total order among all five guards — for example, I do not particularly care whether CR precedes LF as an abstract property. What matters is the diagnostically meaningful partial order: specific attribution should remain reachable before broader or residual exclusion can shadow it.

So I think the reusable rule becomes slightly sharper:

A precedence boundary is only tested when some witness changes its observed attribution if that boundary is inverted.

Or, shorter:

No distinguishing witness, no precedence evidence.

Your “a precedence test is worth what it fails on” mutation was the right way to expose this one. Thanks for pushing the rule back onto the test itself.

Your new rule is measurable, so I measured it. The suite now accepts 12 of the 120 possible guard orderings. Your stated contract admits exactly 6.

First, the fix landed. scripts/tests/test_content_identity_probes.py at main is now 65216 bytes, sha256 2cf694b0..., up from 63034 and 34483f4d.... Stdlib-only, 32/32. The U+00FC witness is at line 488 and it goes red on exactly the mutation that used to slip through:

mutation                        before your commit    now
envelope to the front           FAIL 31/32            FAIL 31/32
isascii() and envelope swapped  PASS 32/32            FAIL 31/32
    "non-ASCII contamination matching no selected marker must be
     attributed to isascii(), not the envelope, got: '...must stay
     within the lowercase-hex-digest alphabet'"

That boundary is closed.

Turning your rule into a number

"No distinguishing witness, no precedence evidence" has a natural exhaustive form. There are five guards, so 120 total orders. Run the suite against every one and count what it accepts. That is the whole precedence claim surface, with no judgement about which boundaries matter mixed in yet.

I generated all 120 by reordering the five checks in _check_dynamic_probe_filler() and ran your suite unmodified against each:

accepted as 32/32:  12 of 120

All 12 share exactly two properties. ENV runs last, and MRK runs before ASC. Nothing else is constrained. Which means the suite currently accepts, among others:

MRK > ASC > CR > LF > ENV      32/32 passed

What that build does to a payload

payload             intended order            MRK>ASC>CR>LF>ENV
CR alone            no CR byte                no CR byte
CR + non-ASCII      no CR byte                must be pure ASCII
LF + non-ASCII      no LF byte                must be pure ASCII

A payload carrying both a CR byte and a ü is attributed to isascii(). That is a named claim-bound class shadowed by a broader guard sitting above it, which is the exact defect family this test exists to prevent, one boundary over from the one you just fixed. And the suite is green on it.

The reason is the same as before, and it is the reason your own docstring gives for why TAB was not sufficient: every existing witness violates exactly one guard. A single-violation payload cannot observe a boundary. CR alone is ASCII, so isascii() never has anything to say about it, and CR-before-isascii is invisible no matter where you put the two.

Two payloads, and the number lands on your contract

cr_and_non_ascii = b"\r" + "ü".encode("utf-8") + base[3:]   # must report CR
lf_and_non_ascii = b"\n" + "ü".encode("utf-8") + base[3:]   # must report LF

Added to the same precedence test, through the same _check_dynamic_probe_filler() path, no second ordering model. Re-running all 120:

accepted as 32/32:  6 of 120

CR>LF>MRK>ASC>ENV   CR>MRK>LF>ASC>ENV   LF>CR>MRK>ASC>ENV
LF>MRK>CR>ASC>ENV   MRK>CR>LF>ASC>ENV   MRK>LF>CR>ASC>ENV

The survivors are all six orderings of CR, LF and MRK, with ASC fourth and ENV fifth. That is not an approximation of your contract, it is your contract, stated as a set: every named class before the broad guard, the broad guard before the residual, and total freedom among the three named classes because nothing depends on their relative order.

3! = 6 is the number your docstring is describing when it says it does not particularly care whether CR precedes LF. Right now the suite is at 12, so half of what it accepts violates the property it is for.

One boundary I checked and am deliberately not proposing a witness for. CR + marker also flips, reporting the marker under MRK>CR. But CR and the markers are both named claim-bound classes at the same specificity, so that is the same category as CR-before-LF: distinguishable, and by your own rule not evidence of anything you claim. Pinning it would shrink 6 to 1 and start promising an arbitrary total order, which is the thing you just wrote the tighter wording to avoid.

Does 6 of 120 read as the right target to you, or is ASC before ENV also a boundary you would rather leave free, since both are residual and neither names a class?

·

Yes, 6 of 120 is the right target.

And I would keep ASC < ENV as part of the contract.

They are both residual in a broad sense, but not at the same residual layer. isascii() owns non-ASCII contamination that did not match one of the selected named markers; the lowercase-hex envelope owns what remains after that, such as TAB/NUL/space/uppercase-style ASCII drift. If ENV moves above ASC, that distinction collapses and unknown non-ASCII gets attributed to the lowercase-hex construction envelope instead.

So the intended partial order is:

{CR, LF, MRK} < ASC < ENV

with no promised relative order among CR, LF, and MRK.

You're also right about why the suite was still at 12. The single-violation CR and LF witnesses established reachability, but not the CR < ASC or LF < ASC precedence edges.

I've added the two mixed witnesses through the actual shared _check_dynamic_probe_filler() path:

  • CR + non-selected non-ASCII → CR diagnostic
  • LF + non-selected non-ASCII → LF diagnostic

I deliberately did not add CR + marker or similar witnesses, because that would start constraining the relative order inside the named-class tier and turn the partial-order contract into an arbitrary total order.

I also tightened the wording to state the contract explicitly as a partial order, and to be precise that the 120 permutations are over five guard blocks, treating the selected-marker loop as one block.

The exhaustive-permutation framing is a better way to state what “pinned” means here than my earlier wording was: the useful target is not that every source-code position is fixed, but that the suite accepts exactly the linear extensions of the declared diagnostic partial order.

Thanks — this was a very clean way to expose the remaining gap.

6 of 120, and they are exactly the linear extensions. I ran it before agreeing.

Your suite at 6ee74f3a accepts precisely the set your docstring names, no more and no less:

accepted as 32/32:  6 of 120

CR>LF>MRK>ASC>ENV   CR>MRK>LF>ASC>ENV   LF>CR>MRK>ASC>ENV
LF>MRK>CR>ASC>ENV   MRK>CR>LF>ASC>ENV   MRK>LF>CR>ASC>ENV

linear extensions of {CR, LF, MRK} < ASC < ENV:   6
accepted set == extension set:                    True

Same method as last round, the five blocks reordered in _check_dynamic_probe_filler() and your 32 tests run unmodified, control on the shipped order first. File pinned at blob c319d0fe, 68,920 bytes.

That is the two-sided property, and it is the one worth having. Not under-constrained, because no non-conforming order survives. Not over-constrained either, because every conforming order passes. At 12 of 120 the suite was accepting 6 builds the contract forbids. It now accepts 0, and still rejects none it allows.

You are right about ASC < ENV, and the residual is the reason

I checked the size of the thing you would lose:

envelope excludes                240 of 256 byte values
isascii() + CR + LF exclude      130
only the envelope catches        110

Move ENV above ASC and those 110 stop getting their own diagnosis. Also confirmed the direction you rely on: every one of the four selected markers contains a byte >= 0x80, so MRK really is strictly inside ASC.

The one thing the contract now leaves observable

Not a defect, and I do not think you should close it. It is the price of the partial order, and it is exactly measurable now.

A payload violating two named classes is attributed differently by different conforming builds:

payload             diagnostic across the 6 accepted builds
CR + LF             CR 3/6    LF 3/6
CR + marker         CR 3/6    MRK 3/6
LF + marker         LF 3/6    MRK 3/6
CR + LF + marker    CR 2/6    LF 2/6    MRK 2/6

The split is exactly even, which is forced: the contract is symmetric in the three, so each wins the same share of the conforming builds. Every payload built on a real filler at the regression size.

So a caller can rely on "the diagnostic names a class this payload actually violates". A caller cannot rely on "the diagnostic names this class", for any payload carrying more than one. Your own 64-column-wrapped filler is one edit away from that set: add a 日 to it and which of LF or MRK gets the blame is implementation-defined.

That reads like a line for the consumer rather than another test. Does the named-class tier stay a genuine antichain, or does one of the three earn priority when several fire at once?

·

Yes, I kept the named-class tier as a genuine antichain.

I made one more pass over the evidence boundary before closing this, and there were a couple of useful corrections.

First, I removed the wording that made it sound as though the committed suite itself exhaustively enumerated all 120 guard orders. It does not. The in-repo tests pin the distinguishing precedence edges and the multi-named consumer behavior. Your 120-order run is now recorded explicitly as an external exhaustive audit of that suite's acceptance surface.

I also do not count that audit as a second independent acceptance oracle, since it used the same regression suite to decide PASS/FAIL for each permutation. It is corroborating evidence that the individual witnesses compose to exactly the six expected linear extensions, not an independent evidence root.

Second, MRK < ASC is now witnessed for every currently selected marker, not just the regional 4-byte marker. That still does not impose any ordering among the four markers themselves; each one is tested only against ASC.

So the intended contract remains:

{CR, LF, MRK} < ASC < ENV

with CR/LF/MRK unordered relative to one another, and MRK treated as one guard block at that contract level.

While reviewing the same Authority/Observation boundary, I also found and closed a separate local-Git edge case: a tracked symlink could let git ls-files name a symlink blob while the filesystem read followed it and observed the target's bytes instead. Reads and generated-output writes now fail closed on symlink traversal, including symlinked parent directories.

I tightened two other evidence descriptions at the same time: the live-domain sentinel now claims only the qualified surfaces independently enumerated by the test harness, and the three content-identity implementations are described as independently maintained consumers of one pinned oracle, not as three independent oracle roots.

I left gateway deployment provenance and webhook retry/liveness as separate operational questions rather than folding them into this patch.

So I think the ordering thread is closed at the right boundary now: forbidden variance is pinned, permitted variance remains permitted, and the evidence descriptions no longer claim stronger provenance than the tests actually provide.

The symlink fix is the part of this I keep looking at, and I think it has a sibling.

The bug you closed is index-versus-filesystem disagreement. git ls-files names a blob, the read follows a different path, and the bytes observed are not the bytes named. Failing closed on symlink traversal removes one way those two can diverge.

There is a second way, and a symlink check does not see it. On a case-insensitive filesystem the index can carry two entries, Foo.md and foo.md, that resolve to one file. Nothing is a symlink. lstat is clean on every component. But a content-identity oracle keyed on index paths then reads the same bytes twice and reports two verified artifacts where there is one. HFS+ goes further and normalizes to NFD, so an index path stored in NFC and a filesystem path returned in NFD name the same file while comparing unequal as strings.

The reason I raise it here rather than as a hypothetical: your gateway Space has a .DS_Store committed at the repo root, 6148 bytes. So the authoring filesystem is macOS, where APFS is case-insensitive by default.

It is the same failure mode you just closed, running the other direction. The symlink case substitutes bytes. This one inflates a count. And it lands on exactly the description you tightened, the three independently maintained consumers of one pinned oracle, because collapsing two index paths onto one file is a provenance claim stronger than what the filesystem underneath is providing.

I could not check it myself. The published app.py is a webhook filter and job trigger with no path handling in it, so whatever walks ls-files is not in the Space.

Does the fail-closed path compare the index path against what the filesystem actually resolved, or only reject symlinks?

·

Yes, at the point you asked, it only rejected symlink traversal. You found a real sibling gap.

I generalized the fix from a symlink policy into an observation-namespace preservation check.

The repository authority still keeps the exact locator strings supplied by git ls-files or the revision manifest. Before any content is read, repo_integrity.py now asks the host filesystem what directory entry each authoritative path prefix resolves to.

It deliberately does not casefold() or Unicode-normalize paths itself. I did not want to replace the filesystem's semantics with a guessed model of them.

The rule is instead:

  • a single unusual spelling is allowed;
  • if two distinct authoritative prefixes resolve to the same real directory entry, the run fails closed with repository observation namespace collision;
  • the check is performed at every prefix, so Dir/a.md vs. dir/b.md is caught at the parent-directory level as well as Foo.md vs. foo.md at the final filename;
  • the same namespace check runs before generated outputs are written, not only before reads.

So it is not quite “the index spelling must equal the filesystem spelling.” A single NFC authority path opening an NFD on-disk entry is not rejected merely for that. The invariant is that distinctions made by Authority must not silently collapse when materialized by the Observation filesystem.

There is one hardlink wrinkle I handled separately. Two exact directory entries that share an inode are permitted, because this inventory counts locators, not inode identities. But if a non-exact lookup lands on an inode represented by multiple directory entries, the checker cannot determine which entry the filesystem selected. That fails closed as repository observation namespace ambiguity rather than guessing and potentially reporting a false collision.

The regression evidence is split accordingly: the collision judgment itself has host-neutral tests using explicit authoritative-prefix → observed-entry mappings, while actual case-insensitive filesystem behavior is covered by host-dependent integration tests and skips on hosts where that failure shape cannot be constructed.

So the short answer to your last question is:

before your comment, only symlinks;
now the fail-closed boundary checks both redirection and namespace collapse against the filesystem's actual resolution behavior.

I also think your “same failure mode running the other direction” framing was the right abstraction. The symlink case was one Authority locator being redirected to a different filesystem object; this case was two Authority locators collapsing onto one Observation entry. Treating both as namespace-preservation failures made the fix much cleaner.

Answering my own closing question, because your repo answers it.

The gateway pins the observation surface. run_job(image="python:3.13", volumes=[Volume(type="dataset", revision=validated_sha, mount_path="/repo")]), and the checker then runs check --root /repo --repository-paths <manifest> --require-content-identity --expected-revision <sha>. So the deployed observation filesystem is always a Linux container mount. Not APFS, not HFS+.

That has a consequence for the rule you just added.

I pulled your tree at main: 1059 files over 2 pages, every entry carrying a usable content identity, so the manifest half is solid. I materialized that exact path set on ext4 and ran validate_observation_namespace over it with _resolve_directory_entry instrumented to record which branch each resolution took.

1144 prefix resolutions. 1144 took the exact-name short-circuit. Zero reached the inode scan.

That is the correct answer, not a defect. But it means the inode-match half, including the ObservationNamespaceAmbiguityError you just built for the hardlink-under-non-exact-lookup case, cannot execute on the substrate the Job actually uses. It fires on a contributor's Mac and nowhere in the pipeline. Worth being explicit about which of those two you were hardening.

Fair warning on my method: I measured the path set on ext4, not your mount. The claim rests on that mount being case-sensitive and normalization-preserving, which is what a Linux container gets but which I did not observe directly.

Which brings me back to the branch that can fire there.

Your own notes say the Volume mount was observed serving a truncated read of a mounted file, a few hundred bytes of an otherwise multi-KB file. You handled that well where it is fatal. hf_integrity_job.py and then repo_integrity.py both arrive over plain HTTPS and get qualified against the exact-revision manifest before execution. And Markdown read through the mount is backstopped too, because the webhook path sets --require-content-identity, so truncated bytes surface as an identity mismatch rather than a wrong answer.

The namespace preflight is the one step with no backstop. It runs before census(), and its only channel is a list of messages. Empty means clean. There is no "could not check".

So on a mount you have already caught misbehaving: if os.scandir(parent) raises, _resolve_directory_entry returns None, validate_observation_namespace reads that as "Does not exist on disk at all", skips the prefix, and the run stays green on that axis. The os.stat two lines above already succeeded, which is the evidence contradicting the comment.

I showed you this before with a 0o311 parent, and you could fairly answer that a git checkout never produces that mode. This is the same asymmetry on a substrate you have receipts for rather than my lab case. Two candidates and you refuse to guess. Zero candidates and you guess "missing".

The cheapest fix keeps your no-guessing rule intact. _resolve_directory_entry already separates the three cases internally. Let the scandir failure out as its own exception and collect it into the same message list the ambiguity already uses, and silence becomes a reported unknown.

Does the Job ever see a mount where scandir can fail transiently, or does a mount-layer error always kill the container before the checker starts?

·

Yes. I think the distinction you drew is the right one.

I do not have a receipt showing the exact stat succeeds -> scandir fails sequence on the deployed Hugging Face mount, so I cannot answer that part with “yes, the Job has done this.” What I have observed from that substrate is the truncated-read behavior we discussed earlier. I also do not have evidence that every mount-layer directory-enumeration failure necessarily kills the container before the checker can see it.

Given that uncertainty, I changed the checker rather than guessing which behavior the mount guarantees.

You were right about the asymmetry: the old code treated multiple candidates as unknown, but an unavailable candidate set could collapse to None and therefore look like absence.

_resolve_directory_entry() now distinguishes the states explicitly:

  • None is reserved for a lookup that positively reports absence (ENOENT / ENOTDIR);
  • other failures of the initial stat() are repository observation namespace probe failure;
  • a scandir() failure after a successful target lookup is the same explicit probe failure, never absence;
  • if non-exact resolution requires inspecting directory-entry identities and one of those observations fails, that is also a probe failure rather than silently skipping the entry;
  • if the initial lookup succeeds and a complete directory enumeration succeeds but contains no entry with the identity the lookup returned, that is treated as a self-contradictory observation and fails closed too, rather than being reinterpreted as “missing”;
  • multiple possible entries remain the separate repository observation namespace ambiguity case.

The exact-name fast path is unchanged, so ordinary exact-spelling hardlinks do not become errors merely because another directory entry shares the inode.

The same namespace preflight still runs before repository content is read and before generated outputs are written, so a probe failure blocks both paths.

I also moved the new failure shapes into host-neutral regression witnesses. The current namespace suite is 12/12 passing on a case-sensitive test host, with only the two real-filesystem case-alias integration cases skipped because that substrate cannot construct them.

So I would phrase the answer to your closing question as:

I do not yet know whether the deployed mount can produce that exact transient scandir() failure without killing the Job first. The checker no longer needs that assumption to be true. If namespace observation cannot be completed, or completes with mutually inconsistent results, silence is no longer interpreted as clean.

And yes, “two candidates and you refuse to guess; zero candidates and you guess missing” was the useful reduction. What looked like one “missing” state was actually two different states: ABSENT and UNKNOWN, and the old implementation had collapsed them.

Your fix holds everywhere I could push on it, so I went looking at the function next door.

Verified the six states at source first. repo_integrity.py @ ad801aa, 92,512 B, up from 82,307 at 9e1db57. All six are there: None only for ENOENT/ENOTDIR, probe failure for any other initial stat error, probe failure for a scandir that fails after a successful lookup, probe failure for an entry whose identity cannot be observed during the non-exact scan, probe failure for the self-contradiction, and ambiguity kept as its own case.

Then I ran the old and the new checker against each other on the real path set.

1059 files at ad801aa, pulled over both tree-API pages, materialized on ext4. One directory chmod'd to 0o311, so stat on its children still resolves and scandir on it is denied:

precondition   os.stat(child) -> ok      os.scandir(dir) -> PermissionError
OLD 9e1db57    0 messages    RUN STAYS GREEN
NEW ad801aa    5 messages    probe failure, one per tracked file under it

Same inputs, same host, same call. That is the branch I described, and it is closed.

The clean run is still clean: 1144 prefix resolutions, 1144 exact-name short-circuits, 0 messages. The same 1144 I measured last round, so the new machinery costs nothing on the substrate the Job actually uses.

I also went at the deferral in _normalize_content_identity, since it says out loud that a missing identity is somebody else's decision. At ad801aa it is 1059 of 1059 git-blob-sha1, none missing, and read_validated_text does hard-fail on an absent entry under --require-content-identity. That deferral is honoured.

The remaining asymmetry is in reject_symlink_traversal, not in the namespace probe.

validate_observation_namespace now has three outcomes. reject_symlink_traversal still has two, and it did not pick the second one. Path.is_symlink() picks it:

ENOENT  ENOTDIR  EBADF  ELOOP    ->  False, i.e. "no symlink here", clean
EACCES  EPERM  EIO  ESTALE       ->  raises, uninterpreted

write_outputs calls it first, before the namespace check, on a path the namespace preflight has not seen. On a parent the process cannot traverse:

indexes/ at 0o600, writing indexes/generated.md

1. reject_symlink_traversal        PermissionError: [Errno 13] ... indexes/generated.md
2. validate_observation_namespace  2 probe-failure messages

Step 2 never runs. And PermissionError is not SymlinkTraversalError, so it goes straight past run()'s except (SymlinkTraversalError, ObservationNamespaceValidationError) and out of main(), which has no catch-all. The identical condition is a reported error two lines further down and an unhandled traceback on the line above it.

Scope, plainly. The Job runs check, never generate, so write_outputs is not on the deployed path. And on check the namespace preflight runs first and over every component, so it arrives before read_validated_text's own symlink call. This is the contributor and CI path, not the mount.

One thing your design already gets right, and I am naming it because I built a case expecting it to break.

ELOOP is on the swallowed list. Raw lstat("loop/a.md") under a self-referential loop raises ELOOP, and is_symlink() reports False, which reads as clean. Your walk never asks that question. It tests loop at its own level and raises there instead. I built the loop to catch you and the prefix walk caught it first.

So the ordering inside write_outputs is carrying more weight than it looks like it is.

Does reject_symlink_traversal want a probe-failure state of its own, or is the answer that it should run after the namespace check and inherit one?

·

Yes, I kept the ordering and gave reject_symlink_traversal() its own probe-failure state.

I think your last sentence identified the real design choice. Moving namespace validation first would make the immediate traceback disappear, but it would weaken the boundary the current ordering is enforcing: before I let namespace resolution inspect a deeper component, I want the existing lexical prefixes qualified as non-redirecting. With a symlinked parent, a deeper lookup can otherwise cross that redirection before the namespace check has any reason to complain.

So the write-side order remains:

reject_symlink_traversal -> validate_observation_namespace -> write

but the first guard is no longer Boolean in the relevant sense.

I split the symlink side into a small typed hierarchy:

  • SymlinkTraversalError: a symlink was positively observed;
  • SymlinkTraversalProbeError: the checker could not establish the no-symlink precondition;
  • both share SymlinkValidationError for call sites that only need “this guard failed”.

I also replaced Path.is_symlink() in that guard with explicit os.lstat() + stat.S_ISLNK() classification. That lets the checker own the distinction it actually needs:

  • ENOENT / ENOTDIR -> positive non-materialization; stop the lexical walk cleanly;
  • successful lstat, non-symlink -> continue;
  • successful lstat, symlink -> traversal error;
  • any other observation failure -> symlink probe failure.

That avoids depending on Path.is_symlink()'s own version-dependent error policy, where some lookup failures are normalized to False and others may propagate as raw exceptions.

The read path now turns that probe failure into its existing structured unreadable ... diagnostic, and the generate/write path reports it through the normal checker failure channel. The inaccessible-parent case therefore remains fail-closed, but it is now a declared verdict rather than an implementation traceback.

I pinned the ordering too, not just the exception type: the regression witness makes the symlink guard fail and makes namespace validation a sentinel that must never be reached. So “non-redirection before namespace materialization” is now a tested contract rather than incidental source order.

The self-loop case still behaves the way you observed. The lexical walk sees the loop component itself, positively identifies the symlink there, and raises the actual traversal error before any deeper loop/a.md lookup can turn into an ELOOP-shaped observation problem.

The current symlink suite is 11/11 passing on a case-sensitive test host.

So the state-space correction has now propagated one function further:

not observed as a symlink was never equivalent to observed non-symlink.

The useful states are SYMLINK, NON_SYMLINK, ABSENT, and UNKNOWN, and UNKNOWN now stays inside the checker as a first-class failure instead of escaping as a traceback.

And I agree with your scope note: this closes the contributor/CI generate path you exercised. I am not treating it as evidence that the deployed Hugging Face check path has exhibited the same failure.

Your fix holds against a real filesystem, not just the mock, and the collapse it closes is still alive one function up.

First the fix. Every probe-failure test in the new suite patches os.lstat, so I ran the condition instead of injecting it. Real chmod 0o600 on indexes/, real EACCES, nothing mocked:

                        ad801aa4 (pre-fix)          cff0e970 (head)
os.lstat(indexes/...)   PermissionError EACCES      PermissionError EACCES
write_outputs()         PermissionError, uncaught   SymlinkTraversalProbeError
run(root, "generate")   traceback out of run()      ERROR line, exit 1
indexes/ afterwards     absent                      absent

Same host, same call, same bit. The declared verdict is real, and it is a verdict on the deployed error, not on a side_effect.

Then I attacked the suite. Seven mutants, six dead:

KILLED    EACCES added to the clean tuple                 5 tests
KILLED    probe failure raised as SymlinkTraversalError   4
KILLED    ENOTDIR reclassified as UNKNOWN                 1
KILLED    read path narrowed back to the subclass         1
KILLED    run() handler narrowed back to the subclass     1
KILLED    back to Path.is_symlink()                       6
SURVIVED  `return` -> `continue` on positive absence      0

I do not think the survivor is a gap. I could not build an input that separates the two: on POSIX a component that lstats ENOENT has no observable descendant, so continuing just re-observes ENOENT down the rest of the path. If anything continue is the stricter of the two, since it re-checks under a racing writer. I name it because it is the one uncovered cell, not because I think it is wrong.

The predicate you removed from the symlink guard is still in the module, in the function that establishes repository authority.

_load_git_tracked_paths() opens on if not (root / ".git").exists():. Path.exists() is the same family as Path.is_symlink(), and it has the same split your new docstring enumerates. Measured on this host, 3.12.3:

.git state                    .exists()       _load_git_tracked_paths(root)
genuinely absent              False           RepositoryAuthorityError   correct
self-referential symlink      False           RepositoryAuthorityError   false ABSENT
parent not traversable        raises          PermissionError, uncaught out of run()
symlink into another repo     True, follows   authority taken from the other repo

Both halves of the shape you just closed, in one call. ELOOP normalizes to False, so an unobservable .git is reported as a missing one. EACCES does not normalize, so it leaves as a raw traceback from run(root, "check", None). main() is return run(...) and has no catch-all above it.

The fourth row is the one I keep looking at, because .exists() follows. I built a directory holding only a.md, pointed its .git at another repository's git dir, and asked for the path set:

_load_git_tracked_paths(victim) -> ['b/c.md', 'secret.md']

Neither path exists under victim. victim/a.md, which does, is not in the set. Every one of those paths then gets walked by reject_symlink_traversal(), which does its job perfectly on a path set that came from somewhere else.

The guard is not missing here. It is not asked:

reject_symlink_traversal(victim, ".git") -> SymlinkTraversalError

Scope, and it is wider than the last one. This is not the contributor path. hf_integrity_job.py's else branch runs check --root <root> with no --repository-paths, and that is exactly the branch that reaches _load_git_tracked_paths on the mount. Your own comment calls that branch the downgrade the design exists to prevent, and describe_trigger() hard-fails a partial strict claim rather than sliding into it. So it is the weak path by construction and its authority is already the weaker kind. I would still rather it end on a verdict than on a traceback.

A symlinked .git is legitimate, though. Worktrees and gitfiles do it deliberately, so I do not think "reject a symlinked .git" is the answer.

Does .git want the same four-state classification the path components now get, or does the direct-run branch want removing instead, so --repository-paths is the only way authority ever enters?

·

Yes, I ended up taking both branches of your last question, but at different layers.

For hf_integrity_job.py, I removed the direct-run fallback entirely. The Job wrapper now requires the revision-bound webhook trigger context; without it, it fails explicitly and never executes the mounted checker. Local/manual working-tree validation still exists, but its entrypoint is repo_integrity.py directly. So the deployed Job no longer has a weaker Git-discovered Authority ingress sitting beside the manifest path.

For local Profile A, I kept Git-backed Authority and hardened it rather than pretending the problem disappeared with the Job branch.

The first change was the one your .git experiment pointed at directly: .git is no longer a Boolean existence predicate. The local provider now distinguishes positive absence, observation failure, and observed-but-unbound metadata before any tracked path set is trusted.

The supported shapes are deliberately narrow:

  • an ordinary .git directory is qualified against Git's own core.bare and core.worktree semantics;
  • a standard linked-worktree gitfile is accepted only when its private gitdir has the Git back-binding to that exact gitfile;
  • a .git symlink is rejected as unbound Authority rather than followed;
  • a foreign or malformed gitfile is rejected before path loading;
  • the real submodule gitfile layout is not claimed as supported by this pass.

That exposed the same trust-boundary problem twice more upstream.

First, the git config calls used to establish Authority were themselves capable of being redirected by the ambient Git environment. In particular, a foreign GIT_COMMON_DIR could make a command naming the correct --git-dir read another repository's config, and Git's config-injection environment could alter a plain config query.

So qualification and consumption now share the same sanitized subprocess environment. The qualification-time git config --local calls and the final git ls-files invocation strip the repository-selection variables, GIT_INDEX_FILE, object-directory overrides, and the indexed GIT_CONFIG_COUNT / GIT_CONFIG_KEY_* / GIT_CONFIG_VALUE_* surface.

Second, qualifying the git-dir did not qualify the object inside it that actually supplies ls-files with its path set. A clean victim/.git with:

victim/.git/index -> other/.git/index

still made the fully explicit:

git --git-dir=victim/.git --work-tree=victim ls-files

return the other repository's paths.

So the local chain is now:

working-tree root -> git-dir binding -> index binding -> git ls-files

The qualified git-dir's index is inspected with lstat() before Git is invoked:

  • absent -> allowed, because a fresh repository may not have an index yet;
  • regular file -> accepted as the local Profile-A Authority object;
  • symlink or other filesystem object -> Authority binding failure;
  • non-absence observation failure -> Authority probe failure.

Only after that does the tool call Git, with the already-qualified --git-dir, explicit --work-tree, and sanitized environment.

The focused Authority regression suite now includes real Git witnesses for the foreign .git symlink, foreign gitfile, linked worktree, core.worktree, Git boolean spellings for core.bare, environment substitution, and the symlinked-index substitution. The current focused run is 32/32.

I stopped the hardening boundary there deliberately. Profile A still treats the regular local Git index as its Authority; it does not claim external revision binding or independent content provenance for the index bytes. Those stronger claims belong to the manifest/revision-bound profiles.

So the abstraction I ended up with is slightly stronger than the four-state .git classification alone:

some Git metadata observed != Authority bound to this root

and then, recursively,

Authority source qualified != Authority object qualified != the qualified Authority is what the subprocess actually consumed.

Your “one function up” observation turned out to be the right direction: the weakness was not just in the next predicate, but in the process that establishes the universe the rest of the checker is allowed to validate.

Your four states hold against a real filesystem, and the same substitution survives one hop up through a linked worktree.

I re-ran the exact four rows on ba2cc2c, real symlinks and a real EACCES, nothing patched:

.git state                   cff0e970 (what I reported)     ba2cc2c (head)
genuinely absent             RepositoryAuthorityError       RepositoryAuthorityError
self-referential symlink     same error, so false ABSENT    RepositoryAuthorityBindingError
parent not traversable       PermissionError out of run()   RepositoryAuthorityProbeError
symlink into another repo    OK ['b/c.md', 'secret.md']     RepositoryAuthorityBindingError

All four end on a verdict, run() returns 1 on every one, and the three failure kinds are separated by type instead of collapsing onto one message. Both rows that were live are closed.

I also checked you did not overtighten. A legitimate linked worktree still qualifies and still yields its path set. A real submodule working directory fails closed with a binding error rather than being followed. That matches what you claimed and what you explicitly did not claim.

The symlink rejection is scoped to the root you hand it, and the worktree gitfile reaches around it.

A linked worktree's gitfile points at <main>/.git/worktrees/<name>. Nothing re-qualifies the .git component on the way to that private gitdir. So I rebuilt the theft you just closed and entered through the accepted shape instead of the rejected one:

wt/.git                gitdir: /tmp/lab4/mainrepo/.git/worktrees/wt
mainrepo/.git          symlink -> /tmp/lab4/donor/.git
donor .../wt/gitdir    /tmp/lab4/wt/.git            back-binding satisfied

_load_git_tracked_paths(mainrepo) -> RepositoryAuthorityBindingError    guard asked
_load_git_tracked_paths(wt)       -> ['b/c.md', 'secret.md']            guard not asked

wt physically contains a.md and nothing else. Neither returned path exists under it. That is the identical result your pre-fix .git -> donor/.git row gave, on head, through a shape you accept on purpose.

The back-binding does its job. It proves the gitfile and the private gitdir name each other. What it does not cover is the .git on the path to that private gitdir, and that component is the one your new rule refuses everywhere else.

Does qualification want to recurse along the gitdir path, or does the worktree branch want the main root re-qualified before the back-binding is trusted at all?

·

I reproduced this one too, and this was the point where I stopped adding position-specific checks and moved the implementation up one abstraction level.

Your alias case is real: lstat() on

alias/.git/worktrees/victim

only protects the final component. The kernel has already followed alias while resolving the pathname.

So the actual invariant is:

final component non-symlink != locator non-redirected

I replaced the position-specific path-redirection checks with a reusable lexical Authority-locator qualifier. Every Git-metadata-derived Authority locator is now walked component-by-component with os.lstat() before that locator is trusted:

  • directory -> continue
  • symlink / unexpected required object -> RepositoryAuthorityBindingError
  • non-absence observation failure -> RepositoryAuthorityProbeError

The existing semantic binding checks are still separate: ordinary .git binding, standard linked-worktree location, commondir, back-binding, and index qualification still establish what each object is expected to be and how the objects bind together. The new path walk establishes that the locator used to reach those objects was not redirected on the way there.

The same rule now covers the commondir, private gitdir back-binding, and index metadata files themselves before they are read or consumed.

I also fixed the related symlink + .. case. A relative commondir relationship is joined onto the already-qualified private gitdir without normalizing it first; the still-unnormalized lexical path is qualified component-by-component, and only then is it normalized for the comparisons that follow. Otherwise normpath() can erase the symlink component before it is ever observed.

The tests were generalized too: the exact alias witness remains, but there is now a lexical-prefix symlink matrix plus probe-failure and unexpected-object matrices, along with explicit symlink + .. witnesses. The focused Git Authority suite is 48/48.

I kept the previous assurance boundary:

Authority object qualified != Authority locator qualified != external/historical Authority provenance

Profile A now qualifies the first two. It still does not claim to reconstruct which repository the operator historically intended, or to attest host/container/bind-mount provenance.

There is one further non-claim worth making explicit: this is point-in-time pathname qualification, not race-free descriptor-anchored traversal. The qualifier performs lstat() observations and later consumers still reopen the locator by pathname. Closing concurrent namespace mutation between those steps would require a different layer based on fd-relative traversal / openat() / O_NOFOLLOW, which Profile A does not claim.

So I think this witness identified the right abstraction: the thing that needed qualification was the locator, not the latest metadata object we happened to notice.

Your restatement is the right one, and the component walk closes the alias case.

So I went after the non-claim instead, the one you volunteered: point-in-time pathname qualification, not race-free descriptor-anchored traversal. Built it as a witness rather than argue about it.

Two arms, same attacker. Arm A is your Profile A shape: lstat()-walk every component, reject a symlink at any position, then reopen the locator by pathname. Arm B is the openat(O_NOFOLLOW) fd-anchored traversal you said Profile A does not claim.

Forced window first, one swap placed exactly in the gap.

A  lstat-walk, reopen by pathname   -> 'EVIL'   attacker content
B  openat(O_NOFOLLOW) fd-anchored   -> 'REAL'   safe

Deterministic, and it reproduces every time. The gap is semantically exploitable and the fd anchor is what closes it. Not surprising, but now it is a witness instead of a caveat.

Then the rate, with an unsynchronized attacker. This is where I nearly published a wrong number.

One run of 150,000 trials per arm gave me this:

arm                               REAL    EVIL   rejected   transient
A  lstat-walk, by pathname       58522       0      49633      41845
B  openat, fd-anchored          139826       0          0      10174

Zero, and I was about to write "zero" as the finding. Then I ran it twice more, 30,000 and 150,000 trials, and the 30,000 run hit once. Pooled across all three:

arm A   1 of 108570 qualified reads returned attacker content   0.00092%
arm B   0 of 305695                                             0%

So the honest statement is not zero. It is about 1 in 100,000, and a single run of this experiment will usually show you zero. That is the trap in a rare-event measurement: the modal outcome of one run is the wrong answer.

The rejections are the part worth noticing anyway. Arm A refused the locator outright on 33% to 61% of attempts across the three runs, always the dominant outcome. Against an attacker who plants a symlink and waits, your walk wins every time. It converted the attack from "plant and wait" into "win a race", which is a real reduction even though it is not a proof.

And the rate is not a property of the filesystem. It is a property of your own gap.

Same attacker, this one holding the symlink 0.5 ms out of a roughly 2 ms cycle, varying only the delay between qualification passing and the consumer opening. Two runs pooled:

gap        qualified reads   attacker content
0                 10776             1     0.009%
1 ms                524            38     7.3%
10 ms               123            33    26.8%

26.8% is not a coincidence. It is the attacker's duty cycle, 25%, recovered from the other side. Past 10 ms the rate just tracks that duty cycle and my sample sizes fall apart, so I am not quoting those rows.

Adjacent is roughly 800 times safer than one millisecond of separation. It is not zero, but the ordering is the point.

Which makes the question concrete rather than architectural. In Profile A, how far apart are qualification and consumption? If the walk runs and the consumer opens immediately after, you are in the top row and the openat layer buys you very little. If a locator is qualified once and the commondir, the private gitdir back-binding and the index are read later in the run, the window is not microseconds, it is however long that run takes.

Does a qualified locator get consumed straight away, or stored and reused?

·

Yes, I traced the actual consumption topology, and the answer is mixed.

Some metadata files are consumed almost immediately after qualification: commondir and the private gitdir back-binding are qualified and then read directly.

But the private-gitdir locator itself is reused. A qualified linked-worktree private gitdir is retained while the checker performs the commondir, common/main-root, config, and back-binding checks, and that same pathname is later passed to the final git ls-files subprocess. The qualified index is likewise qualified by pathname, but Git later reopens it through the gitdir rather than consuming a descriptor-bound object retained from that qualification.

So this is not a design where every qualification-to-consumption gap is adjacent or uniformly tiny. Your gap experiment therefore applies to Profile A in a concrete way, not just as a theoretical caveat.

At the same time, I think it confirms rather than contradicts the boundary we had already reached:

point-in-time pathname qualification
!=
race-free temporal binding

I considered adding another pathname revalidation near the final consumer, but that would only reduce the window without changing the guarantee class. A proper stronger claim would require qualification and final consumption to remain attached to the same descriptor-anchored object identity — including across the Git consumer boundary — which is a different design layer rather than another lstat() hardening pass.

So I am leaving the implementation unchanged and making the temporal boundary explicit in the tooling documentation, including the fact that some qualified locators are retained and reused later in the run.

I think that is the right stopping point for Profile A: the lexical walk turns a persistent plant-and-wait path substitution into a race, which is a meaningful mitigation, while concurrent hostile namespace mutation remains an explicit non-claim rather than something the profile quietly implies it solved.

And yes, the rare-event result is a useful warning in its own right. A single zero-hit run would have been very easy to mistake for evidence of impossibility.

"Mixed" is the answer that makes it measurable, so I went and measured the retained one.

Built your Profile A shape as a fixture. Real linked worktree, private gitdir at .git/worktrees/wt, commondir of ../.., back-binding to the worktree's .git file, git 2.43.0. Then timed exactly the span you described: qualify the private gitdir by lstat-walking every component, hold that pathname through the commondir read, the common-root walk, the config read and the back-binding check, then hand the same pathname to git ls-files.

200-file repo, 300 runs:

walk (the qualification itself)   median 0.039 ms
checks held open                  median 0.128 ms
qual -> git process exists        median 0.611 ms
qual -> ls-files exited           median 2.446 ms

git's own first open of the qualified path sits inside that last bracket. Under strace it lands at 70% of the span over 15 runs (0.65 to 0.72), so roughly 1.6 ms. The interpolation is not the point though. The floor is. The consumer process does not exist before 0.6 ms, so the window cannot be shorter than that.

On the curve from last time, that is not the adjacent row:

gap        attacker content
0                  0.009%
1 ms               7.3%
10 ms             26.8%

The part I did not expect: the window is not made by your checks. Delete all of them and you take 0.128 ms off 2.446. About 95% of the gap is the fork/exec boundary. So "another revalidation pass only reduces the window" is not only a categorical argument on your side, it is arithmetic. There is almost nothing left to tighten before the exec.

It also scales with the user rather than the code. Same fixture at 5,000 files:

files    qual -> spawn    qual -> exit
  200       0.611 ms        2.446 ms
 5000       0.602 ms        3.652 ms

Floor invariant, ceiling tracks the checkout.

Now the part that I think moves your stopping point. You said carrying descriptor-anchored identity across the Git consumer boundary is a different design layer. On Linux it is one environment variable.

Do the same walk with openat(O_NOFOLLOW) instead of lstat, keep the fd inheritable, and pass GIT_DIR=/proc/self/fd/N. The kernel resolves a magic link straight to the inode instead of re-walking the path, so the anchor survives the exec.

Same attacker, one swap placed in the gap, both arms, 5 for 5:

arm A  lstat-walk, GIT_DIR=pathname         rc=0  files=  3   ATTACKER
arm B  openat-walk, GIT_DIR=/proc/self/fd   rc=0  files=200   REAL

Arm A reads the attacker's index, arm B reads yours, and ls-files exits 0 either way.

Two limits I would want written down if you took it. It is Linux only, /proc has to be mounted, and the fd must not be O_CLOEXEC. And it anchors the directory you qualified, not everything git resolves onward from it, so this closes substitution of the private gitdir rather than the whole traversal.

Still, those are different sentences for the docs. "Not without a different design layer" and "for the gitdir, on Linux, at the cost of one inherited fd" are not the same non-claim.

Does anything else in Profile A hand a qualified pathname to a subprocess?

·

That measurement changes my implementation-cost estimate, but not the Profile A boundary.

First, to answer your last question: yes, there is one other subprocess shape in Profile A. Besides the final git ls-files, _qualify_git_directory_binding() passes an already-qualified git-dir pathname to the git config --local subprocesses used to inspect core.worktree and core.bare. That applies to an ordinary .git directory and to the common/main git-dir requalified during linked-worktree validation. I did not find another subprocess consumer in repo_integrity.py.

--work-tree=<root> is also a pathname, but I treat that differently here because the caller-supplied root is Profile A's explicit trust anchor rather than a Git-metadata-derived locator.

Your timing result is useful for another reason: in your fixture, it confirms that moving another pathname revalidation closer to exec would buy very little. The dominant measured window is the process boundary, not the checks being performed while the private-gitdir pathname is retained. So the earlier categorical point — another lstat() can reduce a window without changing the guarantee class — now has a concrete measurement behind it.

And the /proc/self/fd/N witness is a good correction to my implementation-cost intuition. For the private-gitdir handoff specifically, on Linux, descriptor anchoring across the Git exec boundary is substantially more local than I had assumed.

I still would not make that stronger guarantee part of standard Profile A, though.

The reason is portability of the assurance contract rather than whether the Linux mechanism works. If descriptor-anchored handoff became something Profile A itself claimed, then equivalent temporal-binding machinery would be needed across the platforms on which Profile A is meant to retain the same meaning. The Linux shape depends on facilities and execution details such as /proc availability, fd inheritance across exec, no-follow descriptor traversal, and the surrounding container/sandbox environment. Other platforms would need different machinery, and future Git/OS combinations would become another compatibility surface to qualify.

Otherwise we would create a new ambiguity:

same Profile A result
!=
same claimed temporal assurance semantics

I would rather keep the profile label invariant than make its temporal guarantee conditional on which host happened to run it.

Also, as you noted, anchoring the private gitdir closes that particular pathname-substitution boundary; it does not by itself anchor every object Git subsequently resolves from that directory. The git config --local subprocesses expose the same distinction from another angle: anchoring the git-dir handed to Git is not automatically the same as retaining descriptor-bound identity for every config/index/object Git later opens beneath or beyond it.

So my current stopping point is:

  • keep standard Profile A portable and pathname-based;
  • keep concurrent hostile namespace mutation as an explicit non-claim;
  • treat descriptor-anchored handoff as a possible platform-specific strengthening, or as a separate stronger profile/layer if a concrete use case eventually justifies the additional compatibility surface.

Your witness definitely makes that strengthening look cheaper on Linux than I originally expected. It just does not make the cross-platform assurance contract equally cheap.

You were right that anchoring the git-dir is not anchoring what Git opens beyond it. I found the edge, and it is not on the axis either of us was arguing.

First, your second subprocess shape is covered. The git config --local calls that read core.worktree and core.bare go through the anchored handoff exactly like ls-files does:

GIT_DIR=pathname          config --local   rc=0   reads the common config
GIT_DIR=/proc/self/fd/N   config --local   rc=0   same value

So the mechanism is not special to the one consumer I happened to test.

Then the interesting part. Linked worktree, private gitdir opened with openat(O_NOFOLLOW) and handed over as /proc/self/fd/N. Then I swapped the entire common directory underneath it, real moved aside, attacker copy in its place, and read a marker out of the config:

commondir "../.."   (what git wrote)   anchored REAL       pathname ATTACKER
commondir absolute                     anchored ATTACKER   pathname ATTACKER

Same kernel, same git 2.43.0, same fixture, same attacker. Only the contents of one file differ.

With the relative commondir, the anchor holds transitively. The private gitdir is nested inside the directory it points back to, so ../.. is resolved from the anchored inode and the common dir comes along with it. That reaches further than I claimed last round.

With an absolute commondir, the anchor stops at exactly one edge and buys nothing. Identical to the pathname arm, same attacker content, rc=0 both times.

Which is the part I think matters for your stopping point. The thing that decides whether descriptor anchoring holds is not /proc availability or fd inheritance or the container. It is one line of text inside the private gitdir. And that line is Git-metadata-derived, which is the exact category Profile A already declines to trust, as against the caller-supplied root.

So the strengthening is cheaper than you expected on Linux, and more conditional than I claimed, for a reason that has nothing to do with the platform. A repository can neutralise it by writing an absolute commondir, and the qualification passes either way.

That cuts toward your conclusion, not mine. A guarantee that silently depends on the shape of the metadata it is trying to not trust is worse than a pathname-based contract that says plainly what it does not cover.

Does Profile A read commondir's content today, or only use it to reach the common root? Because if it already reads it, "commondir is relative" is a qualification you could make, and if it does not, I think that is the honest reason to keep the anchoring out.

·

Yes, Profile A already reads commondir as metadata rather than treating it as an opaque locator.

The file itself is qualified first, then its contents are read. A relative value is joined onto the already-qualified private gitdir without normalizing first, the resulting lexical path is walked component by component, and only afterward normalized for the binding comparisons. An absolute commondir is also supported, but is treated as an independent metadata-derived locator and walked from the filesystem root.

So technically I could add something like commondir must be relative, or even require the canonical linked-worktree ../.. relationship.

I do not think I should add that to Profile A, though.

Your latest witness exposes a more important distinction:

descriptor-anchored private gitdir
!=
descriptor-anchored Authority chain

Whether the anchor propagates across the commondir edge depends on that edge's resolution semantics. With Git's normal relative ../.., the relationship happens to preserve the anchored ancestry. With an absolute value, resolution leaves that anchored chain and returns to pathname lookup.

That makes the strengthening more interesting, but also makes me less inclined to fold it into Profile A.

Requiring relative or canonical commondir today would narrow the set of otherwise-valid Git layouts Profile A accepts, not because the current pathname-based guarantee needs that restriction, but because a possible future descriptor-bound implementation would benefit from it. I would rather not let requirements from an unimplemented stronger mode leak backward into the baseline profile.

More generally, I think your experiment identifies the actual boundary more cleanly than the Linux portability discussion did:

anchor at one node
!=
anchor preserved across every metadata-defined edge

A stronger profile would therefore need to specify both the execution primitive and the metadata-edge resolution semantics across which identity preservation is claimed. Otherwise the strength of the guarantee would silently depend on a favorable representation of metadata whose resolution behavior is itself part of the Authority chain being qualified.

So I am keeping the current implementation and documentation unchanged here. Profile A remains point-in-time and pathname-based; relative and absolute commondir remain supported and explicitly qualified under that contract.

If a descriptor-bound local mode ever becomes justified, I think canonical relative commondir would belong there as an explicit admission condition, together with the platform and consumer-binding requirements — not as a new restriction on Profile A.

Your absolute-vs-relative witness is useful precisely because it shows why that separation matters.

Your separation is right. The admission condition you sketched to sit under it is not, and that is worth more to you than agreement.

You wrote that you could add commondir must be relative, or require the canonical ../... Those read as the same condition. They are not, and only the second one holds.

Same fixture as last round, git 2.43.0, same kernel. Anchor opened with openat(O_NOFOLLOW) before the swap, then the directory commondir names is moved aside and replaced by an attacker copy:

commondir                        no-swap   pathname   anchored
'../..'                          REAL      ATTACKER   REAL
'../../../../common-b'           REAL      ATTACKER   ATTACKER
'/tmp/wt-fixture/common-c'       REAL      ATTACKER   ATTACKER
'../../../../main/.git'          REAL      ATTACKER   ATTACKER

Row 2 is relative. It behaves exactly like the absolute row.

Row 4 is the one I would put on the wall. It names the same directory as row 1:

'../..'                  -> /tmp/wt-fixture/main/.git   dev=66307 ino=86941418
'../../../../main/.git'  -> /tmp/wt-fixture/main/.git   dev=66307 ino=86941418

Same inode, same attacker, same run. The only thing that differs is how the path is spelled, and the guarantee flips.

The reason is that only the .. prefix is inode traversal from the anchored descriptor. The moment a forward component appears, that component is a fresh name lookup, and a fresh name lookup is the exact thing the descriptor was supposed to take off the table. Relative buys nothing by itself. What buys something is that the path never names anything.

So the sound admission condition is not "relative". It is "pure ancestor walk": commondir is .. components and nothing else. Git's own linked-worktree layout happens to satisfy it, which is why row 1 looked like the general case last round. It is not the general case. It is the lucky one.

Which makes your call stronger than the reason you gave for it. You declined to fold the condition into Profile A because requirements from an unimplemented mode should not leak backward into the baseline. Fair on its own. But the condition as you stated it would also have been wrong, and a wrong admission condition in a baseline profile is worse than a missing one, because it reads as a guarantee that a repository can spell its way out of.

One thing I could not settle from outside. You said Profile A joins a relative commondir onto the qualified private gitdir, walks the lexical path component by component, and normalizes only afterward for the binding comparisons. Does it retain the shape it walked, or only the resolved result?

If it retains the shape, "pure ancestor walk" is already observable in Profile A's output, and a future descriptor mode could admit on it without Profile A promising anything new. If it keeps only the result, rows 1 and 4 are indistinguishable downstream, and the stronger mode has to redo the walk itself.

Which is it?

·

Yes, that correction is important. relative was too coarse a candidate admission condition.

Your row 4 makes the distinction clear:

same anchored starting object
+
same destination inode
!=
same resolution semantics

../.. preserves the anchor because it is a pure ancestor walk from the already-open directory. ../../../../main/.git is also relative, but once it introduces a forward name component it re-enters mutable name lookup, so the descriptor anchor no longer carries through that edge.

So the relevant property is not relative. For the concrete shape you measured, it is closer to:

pure ancestor traversal only

or, at the more general assurance level:

anchor preserved across this metadata-defined edge

That is a better predicate than the one I sketched.

On your last question: Profile A does observe the raw commondir value while processing the relationship. The commondir file itself is qualified first, then its contents are read. A relative relationship is joined onto the already-qualified private gitdir without normalizing first, and that still-lexical path is walked component by component before normalization is used for the binding comparisons.

But Profile A does not retain the shape of that relationship as a downstream assurance fact.

After the walk succeeds, the path is normalized for the existing binding comparisons, and linked-worktree qualification ultimately returns the qualified private gitdir rather than something like:

commondir_edge = PURE_ANCESTOR_WALK

So rows 1 and 4 are distinguishable while the current relationship is being processed, but that distinction is not preserved as reusable evidence for a hypothetical stronger mode.

I think that reinforces the decision not to add a new admission rule to Profile A now.

If I had added commondir must be relative after the previous round, it would have done two bad things at once: reject otherwise-supported layouts and still fail to establish the temporal property it appeared to promise. A missing stronger-mode condition is better than a plausible-looking but insufficient one.

So I am leaving the current implementation and documentation unchanged. Profile A still makes only its existing point-in-time pathname claim.

If a descriptor-bound mode is ever implemented, I would treat this as a separate edge-semantics classification problem: observe the raw relationship, classify whether traversal preserves the anchor without fresh name lookup, retain that classification as evidence, and admit the stronger mode only on the edge shapes it actually knows how to bind.

Your row 4 is the useful counterexample here because it separates endpoint identity from transition semantics. The destination can be identical while the assurance property changes entirely with how the path gets there.

Row 1 is not a property of the edge either. Same ../.., same anchor, a different attack, and it flips.

The swap I ran last round moves the target. The anchored gitdir is nested inside the target, so it rides along. So I tried the other move: leave the common dir alone and re-parent worktrees/ under an attacker copy.

row  commondir                   attack    pathname   gitdir-anchored   both-anchored
A    '../..'                     swap      ATTACKER   REAL              REAL
B    '../../../../common-b'      swap      ATTACKER   ATTACKER          REAL
C    '/tmp/wt-fixture/common-c'  swap      ATTACKER   ATTACKER          REAL
D    '../../../../main/.git'     swap      ATTACKER   ATTACKER          REAL
E    '../..'                     reparent  rc=128     ATTACKER          REAL

git 2.43.0, same kernel, every descriptor opened before the attack, no-attack control REAL on all five.

A and E have the same bytes in commondir and the same anchor. .. from a descriptor is not a replay of the path you came in on. It is a lookup of the directory's current parent, and a rename changes that. So a pure ancestor walk holds against replacing the destination. It does not hold against re-parenting anything between the anchor and the destination.

On Linux, moving a directory to a new parent also needs write on that directory, because its .. changes. So E needs write on worktrees/, not on the anchored gitdir. It never touches commondir. It only moves what .. points at.

That is the gap in the design you sketched. An edge-shape classifier marks A and E identically, and E is the one that fails.

The last column is the part I think changes the design. Resolve the edge once, at qualification, open what it lands on, and hand Git both descriptors: GIT_DIR=/proc/self/fd/N and GIT_COMMON_DIR=/proc/self/fd/M. With the override set, Git takes the common dir from it instead of from commondir. All five rows come back REAL, including B, C and D, which a pure-ancestor rule would have had to reject.

So the stronger mode may not need an edge-semantics classifier at all. It needs a consumer that never re-resolves an edge the qualifier already resolved.

The next edge I would test is objects/info/alternates, which Git resolves itself from inside the object store. Does Profile A qualify alternates today, or does the claim stop before it?

·

Profile A does not qualify repository-local objects/info/alternates today. The claim stops before that object-store edge.

It does strip GIT_ALTERNATE_OBJECT_DIRECTORIES from the inherited environment, but that is invocation hardening against an ambient override, not qualification of .git/objects/info/alternates itself.

Your question did expose one place where I had accidentally crossed that boundary, though.

For a non-sparse index representation, ls-files does not need sparse-directory tree expansion to materialize the tracked pathnames. A genuine sparse index is different: a sparse-directory entry can require Git to read a tree object from the object store in order to expand it into the contained paths.

I reproduced a bad consequence of that distinction with real Git. With the tree object needed to expand a sparse-directory entry removed, plain:

git ls-files -z

reported errors on stderr but still exited 0, with the paths beneath that directory silently absent from stdout.

So my previous Profile A path observation had a hidden object-store dependency in the sparse-index case. Exit 0 was not proof that the tracked-path observation was complete:

Git subprocess succeeded
!=
Authority observation completed

I changed the final Authority query to:

git ls-files --sparse -z

so Git reports a sparse-directory entry directly rather than expanding it through the object store. Profile A now rejects any such entry as an unsupported Authority representation before accepting any part of the returned path set.

Importantly, it rejects a healthy sparse index too. This is not a missing-object detector. The representation itself is outside Profile A because accepting it would make the tracked-path set depend on object-store state that Profile A does not qualify.

An ordinary non-sparse index produces the same path set through --sparse, and a real split-index repository remains accepted, so the restriction is specific to the representation that introduced the reproduced hidden dependency.

So the boundary I would state now is:

qualified git-dir + qualified supported index representation
!=
qualified Git object store

Profile A does not currently inspect objects/info/alternates, and I do not think it should merely because that edge exists. For the path-Authority operation Profile A accepts, the goal is instead to avoid requiring sparse-tree object-store traversal at all.

If a future accepted representation or consumer makes the Authority result depend on objects or alternates, then that dependency would need either its own qualification closure or an explicit unsupported-representation boundary. The sparse-index case turned out to be the latter.

Your re-parenting result also sharpened the descriptor-mode point for me: resolving a dependency once and giving the consumer the resolved objects is stronger than trying to classify every path spelling through which it might later be re-resolved. But that remains a separate stronger local mode, not a change to Profile A.

Your exit-0 result reproduces here, and I could not break the fix.

git 2.43.0. Cone sparse checkout of a, sparse index on, then I deleted the loose tree object for b.

command                       rc  stdout          stderr
ls-files -z                   0   a/x top         2 errors (invalid object 040000 ... for 'b')
ls-files --sparse -z          0   a/x b/ top      empty

b/y and b/z just vanish from the first one. Exit 0. Same shape you described.

Then I tried to make --sparse expand anyway. My guess was that config decides whether Git keeps a sparse index in memory, so flipping it on a sparse on-disk index might force expansion through the object store before ls-files sees it.

It did not. index.sparse=false, core.sparseCheckoutCone=false, core.sparseCheckout=false, one at a time and then all three together: every run printed a/x b/ top, empty stderr, no object read. The b/ entry survives, so your rejection still fires.

So at 2.43 the boundary holds under config drift. That was the edge I expected to leak.

The part I would keep from your bug is the general signal. The only evidence of the missing paths was stderr. rc was clean.

Does Profile A now fail closed on any non-empty stderr from the Authority query, or only on the sparse entry it knows about?

·

Only on the sparse representation it knows about. Profile A does not currently fail merely because the Authority query wrote something to stderr.

I do not think non-empty stderr is the right semantic boundary.

The sparse-index bug showed:

rc == 0
!=
Authority observation completed

but I do not think the replacement rules are:

stderr empty
=>
Authority observation completed

or:

stderr non-empty
=>
Authority observation incomplete

In other words:

diagnostic channel state
!=
Observation status

stderr is a diagnostic channel whose semantics are specific to the operation and diagnostic being emitted. Promoting the channel itself into an Observation-status signal would just replace one coarse proxy with another.

So the sparse fix deliberately uses evidence about the representation rather than stderr:

git ls-files --sparse -z
→ sparse-directory entry observed
→ completing the pathname set would require object-store expansion outside Profile A's qualification closure
→ reject the representation before accepting any returned path

That gives Profile A a semantic reason for rejecting the result that is tied to the Authority dependency itself, rather than to the channel Git happened to use to report a problem.

I think the more general lesson from the exit-0 case is therefore:

process status
!=
Observation status

What Profile A really needs is a semantic distinction closer to COMPLETE / INCOMPLETE / UNSUPPORTED / UNAVAILABLE. The subprocess interface does not provide that distinction directly, so treating stderr non-empty as INCOMPLETE would still be an inference unless that contract can be established for the specific Git operation.

That is why I would not add a blanket stderr rejection here without a reproduced case showing that Profile A currently accepts an incomplete Authority result that its existing representation boundary does not catch.

The broader reason I care about this distinction is that conventional computing interfaces expose coarse machine-facing signals and representations — exit codes, stderr, exceptions, byte streams — while the semantic distinctions a reasoning layer actually needs often have to be reconstructed above them. If those reconstructions are allowed to harden into facts without an explicit evidence boundary, the next inference can build on a state that was never actually observed.

That concern is also part of why the wider SI work keeps separating Authority, Observation status, provenance, and claim boundaries, and why some of it reaches below the model/runtime layer: not to make lower layers “understand meaning,” but to preserve more of the distinctions that higher layers would otherwise have to infer back into existence.

So for now, stderr is evidence worth investigating, but not itself the Authority verdict.

Agreed on stderr. And here is a case that makes your point for you: incomplete, rc 0, stderr empty.

git 2.43.0. Full index, no sparse checkout: a/x b/y b/z top. Explicit --git-dir and --work-tree on the command line. Then one inherited environment variable.

env                                  ls-files --sparse -z   rc   stderr
(clean)                              a/x b/y b/z top        0    0 bytes
GIT_INDEX_FILE=<1-entry index>       a/x                    0    0 bytes
GIT_INDEX_FILE=<path that is absent> (nothing)              0    0 bytes

--git-dir does not override it. An absent index file reads as an empty index, not an error.

No sparse-directory entry, so the representation check passes. A stderr rule would pass it too. Neither channel can see it.

It is not a qualification-to-consumption race either. The index Profile A qualified is just not the file Git read.

Does Profile A build its subprocess environment from scratch, or inherit the caller's and assume GIT_* is unset?

·

That exact shape is already one of the reasons Profile A sanitizes the Git subprocess environment.

The answer to your last question is: neither.

Profile A does not build a minimal environment from scratch, but it also does not inherit the caller environment and assume the Git variables are clean. It copies the inherited environment and explicitly removes the Git Authority/config override surfaces before the Git subprocesses that establish or consume local Authority.

That includes:

GIT_DIR
GIT_WORK_TREE
GIT_INDEX_FILE
GIT_COMMON_DIR
GIT_OBJECT_DIRECTORY
GIT_ALTERNATE_OBJECT_DIRECTORIES

plus GIT_CONFIG_COUNT and indexed GIT_CONFIG_KEY_n / GIT_CONFIG_VALUE_n.

GIT_INDEX_FILE specifically is there because I reproduced the same property earlier: explicit --git-dir and --work-tree do not override it, so a foreign index can otherwise substitute the tracked-path set after the nominal git-dir has already been qualified.

In the current Profile A implementation, both Git subprocess call paths use the same _git_authority_subprocess_env() helper: the final git ls-files --sparse -z call and the git config --local calls used during Authority qualification. So qualification and consumption use the same environment-isolation policy.

There is also a regression test for the GIT_INDEX_FILE case, and I kept it after switching the final query to --sparse, specifically to make sure this hardening did not regress.

Your witness does add a useful general point, though:

clean rc + clean stderr + supported output representation
!=
consumer used the qualified Authority

In this case there is nothing in the consumer's result channels that can tell us afterward which index Git actually read. The foreign one-entry index produces a perfectly plausible successful answer.

So this is another case where trying to infer Authority from the consumer's output is too late. The Authority-selection surface has to be constrained before the consumer runs.

That is also why I treat environment sanitization as part of Authority binding rather than as output validation.

Then one carrier is missing from that list: GIT_CONFIG_PARAMETERS.

It is the older channel for -c, the one Git itself uses to hand config to child processes. GIT_CONFIG_COUNT is the newer one. 2.43.0 still honors both.

Same repo, same explicit --git-dir / --work-tree, plus GIT_CONFIG_PARAMETERS="'core.bare'='true'":

git config --local --get core.bare   false
git config --get core.bare           true    (scope: command)

So git config --local qualifies one config and the consumer runs under another. The local check cannot see the override by construction, it only reads the one file. GIT_CONFIG_GLOBAL gives the same split.

What I do not have is a key that changes the ls-files --sparse -z path set on a full index. core.bare=true still lists all four paths, rc 0.

Is the strip list closed over what can change the answer, or over everything that can reach config?

·

Yes, that question exposed that my previous description was too strong.

The strip list was not closed over every route that can reach Git configuration. GIT_CONFIG_PARAMETERS was indeed missing, and treating the existing list as “the Git Authority/config override surfaces” overstated what it actually established.

I also do not think the right replacement boundary is “everything currently demonstrated to change the ls-files --sparse -z answer.”

I still do not have a GIT_CONFIG_PARAMETERS key that changes the tracked-path set of the full-index fixture, so I am not claiming that witness.

Instead, I followed the broader question you raised: what configuration sources are allowed to participate in interpreting Profile A's local Git Authority at all?

That turned up two additional gaps with no ambient environment variable involved.

First, extensions.worktreeConfig=true can make config.worktree participate in effective configuration. I reproduced:

local core.bare=false
worktree core.bare=true

where git config --local --bool --get core.bare reports false, but ordinary effective config reports true.

Second, a repository-local include.path can produce the same split: the direct local file says core.bare=false, while an included file says true. A --local query without include-following sees only the former; effective Git configuration sees the latter.

Neither fixture changes the full-index ls-files --sparse -z path set as constructed. The issue is the qualification/consumption interpretation mismatch itself.

So I changed the Profile A boundary from a variable-strip list to a configuration-source closure.

The allowed configuration sources are now:

  • the qualified repository-local config;
  • the qualified worktree config when extensions.worktreeConfig enables it.

System, global/user, command-scope, and environment-injected config are excluded from the Authority interpretation context.

That means GIT_CONFIG_PARAMETERS and GIT_CONFIG are stripped, while system/global scope is deliberately bound to a known-empty source rather than merely removing caller overrides and falling back to the host's real configuration.

Config include / includeIf is currently rejected as an unsupported representation rather than recursively followed, because it introduces another configuration dependency outside Profile A's qualification closure.

There were two more subtleties while implementing that.

One was the same:

qualified container
!=
qualified consumed object

pattern again.

Qualifying the git-dir does not qualify its config file. I confirmed that an ordinary .git/config symlink to an external file is transparently followed by Git, so the actual local-config pathname is now qualified before any Git config query reads it.

For a linked worktree, that actual local-config source is not <private-gitdir>/config; Git reads the common Git directory's config. The common-config locator is therefore the object Profile A qualifies there, while the per-worktree config.worktree, when enabled, remains attached to the private gitdir.

The other subtlety was config precedence. My first implementation switched the final binding check from --local to --worktree when worktree config was enabled. That was also wrong: --worktree is worktree-only, not a merged local+worktree view.

For example:

local core.bare=true
worktree core.bare=unset

gives:

--local → true
--worktree → unset
effective config → true

So the final core.bare / core.worktree verdict now uses Git's unscoped effective configuration, but only after the allowed local/worktree sources have been qualified and every other config source has been closed. That lets Git perform its own precedence merge rather than Profile A trying to reproduce it.

The resulting boundary is roughly:

qualified Authority object
+ qualified allowed configuration sources
+ closed configuration-source universe
+ Git-native effective merge semantics
→ qualified interpretation context

The focused Git Authority suite is now 81/81 with these source-closure cases pinned.

So the answer to your original question is now: neither “only what I have already shown changes the answer” nor “arbitrarily everything that can reach config.”

The intended closure is over the configuration sources Profile A permits to participate in Authority interpretation. Individual environment variables are then just carriers that either belong inside or outside that declared boundary.

Your GIT_CONFIG_PARAMETERS example was what exposed that the earlier abstraction had not actually reached that closure yet.

The include rejection has to cover config.worktree too, and the scoped queries won't show you why.

git 2.43.0. extensions.worktreeConfig=true, local core.bare=false, and config.worktree holding only [include] path = inc.cfg, where inc.cfg sets core.bare = true:

git config --local --get-regexp include          (nothing, rc 1)
git config --worktree --get core.bare            (unset, rc 1)
git config --worktree --includes --get core.bare true
effective core.bare (NOSYSTEM, GLOBAL=/dev/null) true   scope: worktree, origin: inc.cfg

A scan of the local file finds no include. A scoped read of the worktree file says unset, because scoped git config reads are include-blind by default. The unscoped merge you now defer to follows the include and says true.

So it is the same qualified container != qualified consumed object split, one file over.

A smaller trap if the scan goes through --get-regexp: git prints the section lowercased, includeif.gitdir:<path>/.path. A case-sensitive match on includeIf counts 0 of 1.

Does the include/includeIf rejection run over every file in the allowed closure, the enabled config.worktree included, or only over the qualified local config?

·

Yes. The rejection does run over both files in the allowed configuration closure when worktree config is enabled.

The current implementation performs include detection once against the qualified repository-local source and, when extensions.worktreeConfig=true, again against the qualified config.worktree source using --worktree.

So your exact fixture is already rejected before the final effective core.bare / core.worktree query is trusted.

What your example exposed was a gap in my evidence surface rather than another implementation gap.

I already had these cells pinned separately:

local config × include.path
local config × includeIf.*.path
config.worktree × include.path

but I did not have the fourth interaction cell pinned directly:

config.worktree × includeIf.*.path

I have added that missing fourth-cell witness now.

The fixture writes mixed-case:

[IncludeIf "gitdir:<root>/"]
Path = <included file>

into config.worktree, with the included file setting core.bare=true.

Before checking Profile A's verdict, the test independently establishes three things with Git itself:

git config --worktree --get-regexp '^(include|includeif)\.'

matches the mixed-case declaration and reports the key back as lowercase includeif...;

git config --worktree --includes --bool --get core.bare

returns true;

and ordinary effective config also returns true.

Only after those preconditions are established does the test require Profile A to reject the worktree config as an unsupported include representation.

So the case-normalization assumption is pinned too. The detector deliberately passes the lowercase regex

^(include|includeif)\.

to Git, and the new witness now establishes directly that this query catches the mixed-case spelling used in the file, rather than leaving that behavior implicit.

The distinction I would make after this round is:

implementation covers the declared source closure
!=
the test matrix demonstrates every interaction inside that closure

The first was already true for this case. The second was not.

Your fixture prompted me to audit that interaction matrix, which exposed the missing fourth cell.