Distinguish compilation provenance digests - #19
Conversation
Compilation artifacts record both the exact submitted Head digest and the canonical Foundation Plan digest. These values legitimately differ after normalization, so validate each at its actual boundary while keeping artifact bytes bound to succeeded status metadata. Exercise the distinction in unit, CLI, and packed-runtime tests.
ReviewCorrect fix, and the reasoning in the description holds up under checking. The relaxation does not weaken authentication, because the chain that authenticates the value it stops comparing is independent and intact. Verification
I could not reproduce the packed runtime-tree digest The bug is real and the diagnosis is rightThe change: - value.foundation_plan.sha256 !== expected.headSourceSha256 ||
+ typeof value.foundation_plan.sha256 !== "string" ||
+ !SHA256_PATTERN.test(value.foundation_plan.sha256) ||The old line asserted that the digest of the canonical Foundation Plan inside the artifact equals the digest of the exact bytes the user submitted. Those are different bytes, so that equality was wrong whenever normalization changed anything, which is essentially always. I reintroduced the old check and reran the suite: 3 failures, reporting Coverage reaches the packed executable too. const headSha256 = sha256(plan);
const foundationPlanSha256 = sha256(
Buffer.from("canonical Foundation Plan snapshot"),
);Two visibly different values rather than the same digest reused, which is exactly the scenario that used to be rejected. Good choice of fixture; a smoke that happened to use one digest for both would have passed before and after. What still authenticates the valueThe relaxation replaces a value comparison with a shape check, so the question is what proves Everything else in The outer chain is the part that matters, and I checked it rather than taking the description's word. response.headers.get("cache-control") !== "no-store, no-transform" ||
response.headers.get("content-encoding") !== null ||
contentLength === null ||
contentLength !== String(metadata.byte_size) ||
source.byteLength !== metadata.byte_size ||
response.headers.get("etag") !== `"sha256:${metadata.sha256}"` ||
sha256(source) !== metadata.sha256The last line is the one I most wanted to see. The CLI hashes the bytes it actually received and compares, rather than trusting the ETag header, so a server sending a matching ETag over different bytes is caught. Refusing any
The tradeoff worth namingThere is a real reduction here, and it is the right call, but it should be explicit somewhere durable. Before, the CLI could independently verify that the Plan inside the artifact was the Plan it submitted, by recomputing one digest. Now it cannot. It verifies that the artifact is the artifact the service said it was, and it trusts the service's canonicalization to have been faithful to the submitted bytes. That trust is unavoidable without the CLI implementing the same canonicalization the Compiler does, which would mean duplicating normalization logic in a second language and keeping it in step forever. Not worth it. But the property being given up is one a reader might assume still holds, particularly given how much of this codebase is built on content addressing. Worth a line in the README or the design note saying that StackingThe description's ordering is clear: land #17 and #16, update #18, then rebase this. Worth adding that #18 carries the flag-day coupling with firstdraft#301 that I detailed on both of those, since this branch inherits it. The |
The same document, two different fingerprintsThis PR fixes a bug that only appeared when somebody actually ran the thing. The fix is two lines. The misunderstanding behind it is one you will make yourself, because it comes from a habit that is normally correct. The habitHash something, compare the hash, know you have the same thing. It is the foundation of content addressing, and this codebase leans on it everywhere: Git commit SHAs, ETags, artifact digests. So when the CLI downloads a compiled artifact and wants to check the Plan inside is the Plan it submitted, the obvious move: value.foundation_plan.sha256 !== expected.headSourceSha256 ||Same Plan, same digest. Reject if they differ. Reasonable, and wrong. Why it is wrongThe submitted Plan and the Plan inside the artifact are the same document and not the same bytes. Here is what happened in between. The user's file went to the server. The server parsed it and stored it in a normalized form: keys in a canonical order, consistent indentation, arrays in a defined sequence, no incidental whitespace. Then the Compiler read that normalized form and recorded its digest in the artifact. Two JSON files, semantically identical, byte-wise different: {"name":"Movie","fields":[{"key":"title"}]}{
"fields": [{ "key": "title" }],
"name": "Movie"
}Same meaning. Same object if you parse both. Completely different SHA-256. So the check demanded that two legitimately different byte sequences produce the same digest, which is precisely what a hash function guarantees will not happen. The general trapA digest identifies bytes, not meaning. Whenever you hash something to establish identity, the real question is which serialization you are hashing, and whether everyone in the chain agrees. This bites in ordinary work too:
The pattern: two parties each hash "the document" and disagree, because they were never hashing the same bytes. How the fix worksThe digest is no longer compared to a value. It is checked for shape: typeof value.foundation_plan.sha256 !== "string" ||
!SHA256_PATTERN.test(value.foundation_plan.sha256) ||Must be a string, must look like a SHA-256. That is all. Which raises the obvious objection: if you are not checking its value, is it not meaningless? Could the server not put any 64 hex characters there? No, and the reason is worth following because it is a nice piece of layered reasoning. The download path checks this, in response.headers.get("content-encoding") !== null ||
contentLength !== String(metadata.byte_size) ||
source.byteLength !== metadata.byte_size ||
response.headers.get("etag") !== `"sha256:${metadata.sha256}"` ||
sha256(source) !== metadata.sha256That last line is the load-bearing one. The CLI hashes the bytes it actually received and compares to the digest the service declared earlier, in the compilation response. Not the ETag header, which a server could set to anything; the real bytes. So the chain is:
The canonical digest does not need its own comparison because it is covered by the digest of the envelope containing it. One check authenticates the whole payload. Note the What was genuinely given upBeing honest about this matters, because it is easy to present a fix as pure improvement. Before, the CLI could prove on its own that the Plan in the artifact was the Plan it submitted, by recomputing a digest it already had. Now it cannot. It proves the artifact is what the service said it was, and it trusts that the service's normalization faithfully represents the submitted bytes. That is a real reduction in what the client verifies independently. It is also the right call: the alternative is implementing the server's exact canonicalization rules in JavaScript and keeping the two implementations in step forever. Every divergence would surface as a false rejection of a valid artifact, which is the bug this PR is fixing, permanently. The lesson is that layered verification lets you stop checking something only if an outer layer covers it. Before relaxing an inner check, trace what the outer one actually proves. Here it proves plenty. Sometimes it proves less than you assumed. How this was foundWorth noticing:
Not a code review, not a unit test. Somebody ran the whole thing end to end and it rejected a valid artifact. The bug was invisible to tests because the tests constructed both digests from the same source. When your fixture builds the input and the expectation the same way, an assertion that they are equal always passes and tells you nothing. You can see the fix handle that too. The packed smoke now uses deliberately different values: const headSha256 = sha256(plan);
const foundationPlanSha256 = sha256(
Buffer.from("canonical Foundation Plan snapshot"),
);If those were both The habit worth taking: when a test asserts two things are equal, check whether your fixture made them equal by construction. If it did, you have tested your fixture. |
Commit the exact length-delimited hashing recipe used by external evidence. Normalized relative-path ordering keeps the same runtime identity reproducible across supported platforms.
Record why normalization alone cannot preserve these letters before the ASCII identifier filter. This keeps the explicit map from looking arbitrary.
|
Addressed the reproducibility follow-up in At current head |
Preserve the reviewed artifact provenance revision and its evidence identity while inheriting the landed CLI stack and advisory repair.
Summary
Why
The controlled Movie Catalog journey reached a successful Publication and Compilation, then exposed that exact submitted Head bytes and normalized Compiler-input bytes legitimately have different digests. The CLI incorrectly required them to be equal and rejected the valid retained artifact.
No service API expansion is needed: retained
artifact.sha256authenticates the full envelope, whilehead_source_sha256continues to pin exact submitted Head provenance.Integration
CLI #17, #16, and #18 have landed. This branch now targets current
mainwhile preserving the exact reviewed behavioral revisionf55edffc…in its ancestry. The service response and CLI package remain an intentional coordinated release pair; no mixed-version compatibility layer is included.Verification
npm run check: 147 tests plus typecheck, lint, format, package allowlist, and packed executable smokenpm audit: 0 vulnerabilitiesnode scripts/runtime-digest.jsat current heade5d375164e4bc9536b9dff5cf34d11aa19e06612:3ef1252eaa30c3d436339cb238b099821b1bde9b97b518fdac182f13781c9e10f55edffc9e88924f9a4c95f41c4d0bc9b72422f8and its runtime digest9e5a4bd0f16f49ab2e17c04f7defc59366f8fa073f772b310d8f684177890eab; the later source-runtime change is the reviewed Unicode explanation propagated from Unify local application identity tools #16