Skip to content

fix(unplugin): keep the content comparison while a same-tick rewrite can hide behind a metadata signature - #1228

Merged
samchon merged 7 commits into
masterfrom
fix/unplugin-signature-clock
Aug 18, 2026
Merged

fix(unplugin): keep the content comparison while a same-tick rewrite can hide behind a metadata signature#1228
samchon merged 7 commits into
masterfrom
fix/unplugin-signature-clock

Conversation

@samchon

@samchon samchon commented Aug 18, 2026

Copy link
Copy Markdown
Owner

Intent

This pull request owns the complete accepted cycle for issue #1227: @ttsc/unplugin's persistent transform cache lets a metadata signature stand in for a content comparison, but a filesystem stamps a write once per clock tick, so a second same-length write inside the tick that minted the recorded stamp is served from the cache as though it never happened.

The invariant to restore: a signature may stand for content only while a later write is guaranteed to move it. That guarantee needs a reference instant the observed filesystem itself produced, so that "the same tick" is a comparison between two stamps of equal granularity — the process clock (rejected in #1224's first attempt) and constant margins (rejected as guesses about unobserved filesystems) are both out.

Scope

  • packages/unplugin/src/core/transform.ts — the recording rule for every signature manifest (universal descriptor inputs, the derived per-file set, and the whole-snapshot path), plus the filesystem-minted reference that authorizes it.
  • Regression coverage in tests/test-unplugin, deterministic on every platform, driven through the cache-owned filesystem operations rather than real timing.
  • packages/unplugin/README.md and website/src/content/docs/setup/unplugin.mdx where the signature acceptance is documented.

Verification

Pending. Planned local evidence: pnpm --filter @ttsc/test-unplugin test, pnpm --filter @ttsc/test-metro test, repository pnpm test:typecheck; the pull request's ordinary CI is the acceptance gate.

🤖 Generated with Claude Code

samchon and others added 3 commits August 18, 2026 22:35
…content

The persistent transform cache let an input's metadata signature stand in
for a content comparison whenever the recorded signature still held. A
filesystem stamps a write once per clock tick, so two writes inside one
tick that produce the same byte length are indistinguishable by that
signature and the second one was served from the cache as though it never
happened.

A signature may stand for content only while a later write is guaranteed
to move it. That guarantee now comes from a reference instant the observed
filesystem itself produced: every metadata observation raises a per-device
clock floor, seeded per generation by stamping a probe in the adapter's own
scratch directory, and a signature is recorded only while the floor holds a
stamp strictly newer than the input's modification stamp. This is git's
racily-clean index rule under the read-only filesystem contract; the
process clock never participates, so a filesystem clock running behind the
host changes nothing. An input that cannot be separated keeps its content
comparison and re-earns the signature the moment the clock leaves its tick.

The rule covers all three manifests: the universal descriptor/config
inputs, the derived per-file set, and the whole-snapshot path's project
walk and out-of-walk re-check.

Close #1227: fix(unplugin): a metadata signature can stand for content a same-tick rewrite already replaced

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Corrections from the Individual Self-Review of the previous commit.

The re-earn regression case asserted that one delivery reads only the
input sitting at the clock floor, after two warm-up deliveries. That was
wrong about when the floor rises: it rises when the raised input is
observed, and a delivery validates its reachable siblings before the
globals that carry it, so the module delivered while the floor rose left
its siblings unproven, and no delivery proves the module it is delivering.
One full pass after raising the floor covers every module through some
other module's post-floor delivery.

The clock-floor comment also claimed parity with git's racily-clean rule,
which compares against a stamp git minted itself. This floor additionally
accepts stamps it merely observed, which a stamp-preserving copy from a
machine whose clock ran ahead can push past the present. State that
tradeoff, and why the minted probe cannot replace observed stamps: it
lands on the scratch volume, which is frequently not the inputs' volume,
so a probe-only floor would re-read every input on every delivery there.
The README and website guide repeated the same overclaim and now state the
probe's device condition, and both drop the em-dashes the documentation
skill forbids.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@samchon samchon left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Individual Self-Review — 965ae5348 (fix(unplugin): prove a signature's tick is over before it stands for content)

Resolves #1227. The commit makes a metadata signature stand in for a content comparison only once the observed filesystem's own clock has provably minted a stamp strictly newer than the input's modification stamp, across all three manifests: the universal descriptor/config inputs, the derived per-file set, and the whole-snapshot path's project walk and out-of-walk re-check.

Verified sound

  • No self-separation circularity. observeFilesystemClock raises the floor to max(mtimeNs, ctimeNs) before stampSeparable compares mtimeNs < floor strictly. Where ctime <= mtime the strict comparison refuses the file's own stamp. Where ctime > mtime (chmod, rename, or a Windows birthtime after a stamp-preserving copy) the file does separate itself, soundly: ctime is a real stamp the same clock minted after mtime, so a later write must mint something newer than both. The link and target halves are each separated only by a stamp minted on their own device.
  • No cross-device leak. Every observation and comparison keys by the stats object's own dev. A probe on a different volume contributes nothing rather than a false proof.
  • Ordering. The probe is minted after the compile and before every signature-recording read. The pre-compile walk runs before the mint, which is why the fileSignatures / provenSignatures split exists: fileSignatures is separability-independent and feeds only the generation-time stability comparison, while provenSignatures becomes cached.inputSignatures.
  • All six recording sites guarded, and only those. The strict blocker entry is correctly exempt (it proves a kind, not content, and cannot reach the re-earn assignment). The broken-link missing-target evidence is conservatively non-separable, which cannot regress: such a path is unreadable on the universal manifest and never held a proven hash on the walk.
  • The existing exact read-count assertions still hold. Fixtures and the scratch probe both live under os.tmpdir(), so they share a device on every platform, and the probe is minted after a compile far longer than any tick.
  • The new cases pin the regression. Every rewrite they perform is length-preserving, so each is invisible to a pre-fix signature and the generation-replacement assertions fail without the change.

Findings adjudicated

  1. Accumulating observed stamps is weaker than git's own reference (accepted, comment corrected in 0107f9b0f). Git compares against a stamp it minted itself; this floor also accepts stamps it merely observed, so one future-dated file raises its device's floor past the present. The claim of parity with git was inaccurate and has been corrected in place, together with why a probe-only floor is not a usable replacement.
  2. On a split-volume layout the probe never participates, and the prose asserted otherwise (accepted in part, docs corrected in 0107f9b0f). createTransformScratchDirectory prefers os.tmpdir(), so a project on another volume is separated by observed stamps alone. The consequence is bounded and in the safe direction: an input that cannot be separated is re-read, never trusted. Relocating the scratch directory onto the inputs' volume would make the probe universal but would move every compiler and plugin temporary write into the project's parent, frequently a monorepo root or a home directory. That is a product decision about where ttsc writes rather than a property of this rule, so it is declined here and recorded in the comment.
  3. Documentation voice: em-dashes (accepted, fixed in 0107f9b0f).
  4. assert.deepEqual(reads, [touched]) is the most brittle new assertion (accepted; it failed CI and is fixed in 0107f9b0f). The floor rises only when the raised input is observed, and a delivery validates its reachable siblings before the globals that carry it, so the module delivered while the floor rose left its siblings unproven, while no delivery proves the module it is delivering. One full pass after raising the floor now covers every module through some other module's post-floor delivery.

No monkey-patching, hardcoded constants, or test-shaped logic: the rule holds for whatever tree the fixtures generate, and the cases drive it through the cache-owned filesystem operations rather than real timing, as the issue requires.

…uides

Corrections from the Individual Self-Review of the previous commit, all
wording-level.

The source comment claimed parity with git's racily-clean rule in one
paragraph and called itself weaker in another; it now says it adapts that
rule. It also named only imported future stamps as a defeater, so a clock
stepped backwards is named again, and "re-read every input" is narrowed to
the inputs that carry bytes, since a missing state and a strict blocker
have none.

The README and the website guide asserted that both sides of every
comparison are stamps minted by one clock without the caveat the same
change had just written into the source: a stamp can be set rather than
minted, and a floor sitting above the present reopens the window until the
clock catches up.

The re-earn case's comment claimed a pass covers every module. That holds
only from three modules up, so it now states what the four-module mesh
actually gives: three post-floor deliveries that jointly prove all four.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@samchon samchon left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Individual Self-Review — 0107f9b0f (fix(unplugin): re-earn a signature only after a full post-floor pass)

Correction commit for the review findings recorded above. It fixes the one regression case that failed CI on 965ae5348 and corrects the comment and documentation claims that overstated the rule.

Verified sound

  • The test fix's diagnosis matches the observed failure exactly. deriveWatchInputs appends dependencies, then graph inputs, then resolution candidates, then host inputs, and inside the graph half the reach edges land strictly before graph.globals. The raised input is the first global, so the module delivered while the floor rose has already validated every sibling and external against the lower floor and records nothing for them. A module is also excluded from its own derived set, which left exactly one module unproven under the previous two-delivery warm-up. The cancelled run's failure named that module and nothing else.
  • After one full pass every member of the measured module's closure is proven except the raised input, which is the input sitting at the floor itself and therefore the one input the rule must keep re-reading. The universal descriptor inputs re-earn inside matchesUniversalHostInputEntries on the second post-floor delivery and are excluded from the per-module loop by covered, so they contribute no reads to the measurement.
  • The raised input is read exactly once per delivery: the signature slot is never populated for it, so validation falls to the content comparison, and the post-read observation is a stat rather than a read.
  • The new comment claims check out. A probe-only floor leaves stampSeparable with no entry for the inputs' device, so it returns false forever and both the derived and universal paths fall through to a content read on every delivery. createTransformScratchDirectory reaches the inputs'-volume candidate only after os.tmpdir() and the platform temp directory both fail, so the split-volume case is the common one rather than the exotic one.
  • Behavior is unchanged outside the rule. The case still pins what it pinned: an unseparated stamp keeps the content comparison, re-earning never costs the generation, and the generation identity is still asserted across the extra deliveries.

Findings adjudicated (all applied in f4e5a2a4e)

  1. The test comment over-generalized. "A pass covers every module" holds only from three modules up; with two, the module delivered second is proven by nobody. Restated as what the four-module mesh actually gives.
  2. The README and website guide asserted "stamps minted by one clock" without the caveat the same change had just written into the source. A stamp can be set rather than minted (archive extraction, a stamp-preserving copy from a machine whose clock ran ahead), and a clock stepped backwards has the same effect. Both guides now carry that boundary.
  3. The source comment named only imported future stamps as a defeater and overstated the probe-only cost. The backwards-stepped clock is named again, and the cost is narrowed to inputs that carry bytes, since a missing state and a strict blocker have none.

CI

bundler defenses — the lane that runs @ttsc/test-unplugin and @ttsc/test-metro, the two commands the issue names — passes on 0107f9b0f, together with typecheck, plan, deploy, and the platform lanes settled so far. All four regression cases pass, and no pre-existing case regressed.

samchon and others added 2 commits August 18, 2026 23:20
Overall Self-Review finding on this branch's own work. The case's numbered
summary still said it delivers twice after raising the clock floor, which
was the shape that failed CI; it now delivers every module once, so the
summary named a sequence the code no longer runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Corrections from the Individual Self-Review of the previous commit.

The source comment said a probe-only floor "would decline every signature".
It would not: a strict blocker is exempt from the separability rule
because it proves a kind rather than content, so it keeps a usable
signature either way. The claim now says every content signature, and
names the exemption.

Both the comment and the guides listed a set stamp as a defeater without
saying that only a future-dated one is. The floor is a maximum, so a
restored past stamp never raises it and an ordinary archive extraction is
harmless; the hazard is a stamp-preserving copy from a machine whose clock
ran ahead.

The guides also placed "a clock stepped backwards" one sentence after "a
filesystem clock running behind the host changes nothing", which reads as
self-contradiction. They are different hazards and both texts now say why:
a constant offset moves both stamps being compared, while a backwards jump
moves only the present and strands the floor above it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@samchon samchon left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Individual Self-Review — f4e5a2a4e and f46c174d6

Both are prose-only commits: source comment accuracy, the README and website guide, and one case summary. A diff filtered to non-comment lines is empty, so no behavior changed.

Verified sound

  • A clock that jumps backwards does strand the floor above the present. observeFilesystemClock only ever raises a floor, so the claim holds and stampSeparable would then answer for stamps whose tick is still open.
  • The probe-only consequence clause is exact about what gets re-read. With no floor for the inputs' device, both the derived path and the universal manifest fall through to a content comparison on every delivery. A recorded missing state is short-circuited before the read and resolves through the marker instead, so "every input carrying bytes" is the right set.
  • The re-earn case's comment is correct. The delivered file is excluded from its own derived set by identity and by spelling, and again through a cycle. The fixture builds a full mesh, so with four modules every module sits in every other module's closure, and reachable edges are appended before the globals that carry the raised input. The floor therefore rises part-way through the pass's first delivery, leaving exactly three fully post-floor deliveries whose derived sets union to all four modules.
  • The guides stayed in sync. The shared sentences are byte-identical between the README and the website guide and sit at the same position in both. No em-dashes in the added lines, one source line per paragraph.

Findings adjudicated (all applied in a175ab2fa)

  1. "Decline every signature" was false. A strict blocker is deliberately exempt from the separability gate, because it proves a kind rather than content, so under a probe-only floor it keeps a usable signature and is never re-read. The claim now says every content signature and names the exemption. This is the one finding that contradicted the code rather than merely underspecifying it.
  2. A set stamp was listed as a defeater without the future-dated qualifier. The floor is a maximum, so a restored past stamp never raises it and an ordinary archive extraction is harmless. Both the comment and the guides now say the hazard is a stamp set into the future.
  3. "A clock stepped backwards" abutted "a clock running behind changes nothing". Different hazards, adjacent sentences, shared vocabulary, opposite conclusions. Both texts now state the distinction: a constant offset moves both operands together, a backwards jump moves only the present.

Overall Self-Review round on this branch's own work

One further finding, from my own round rather than the subagent's: the re-earn case's numbered summary still described the two-delivery warm-up that failed CI, after the code had moved to a full pass. Fixed in f46c174d6.

…he opposite

Overall Self-Review finding on this branch's own work. A clock that jumps
backwards and a clock running at a constant offset are not opposites; they
are different hazards with opposite consequences for the floor, which the
sentence already explains.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@samchon samchon left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall Self-Review — complete base-to-head round

Fresh full round over cd890c61f..a8b03ec4f, the whole declared surface: the rule in packages/unplugin/src/core/transform.ts, the four regression cases and their helper, the README, and the website guide.

The behavioral surface, isolated

Filtering the diff to non-comment lines leaves exactly the intended set and nothing else:

  • the clock floor itself (FILESYSTEM_CLOCK_FLOORS, filesystemClockFloors, observeFilesystemClock, stampSeparable, mintFilesystemClockReference);
  • inputMetadataEvidence, which returns the signature together with its separability and is what inputMetadataSignature now delegates to;
  • a separable conjunct at each of the six recording sites, covering all three manifests: universal capture and re-earn, the derived per-file set, the out-of-walk capture and re-check, and the project walk;
  • the provenSignatures channel, which carries only separability-proven signatures into cached.inputSignatures while fileSignatures keeps feeding the generation-time stability comparison unchanged;
  • the mint call, placed after the compile and before every signature-recording read.

No consumption site changed. Separability is required to record a signature, never to use one already recorded, which is what makes the rule self-healing instead of permanently degrading: an input seen mid-tick keeps its content comparison and re-earns the signature the moment the clock leaves that tick.

Invariants checked this round

  • No self-separation. The floor takes the maximum of an observation's mtime and ctime while separability compares mtime strictly, so an input can never separate itself through its own mtime. Where ctime is genuinely newer (chmod, rename, a Windows birthtime after a stamp-preserving copy) it does separate, soundly: ctime is a real stamp the same clock minted later, so any subsequent write moves the signature.
  • No cross-device leak. Every observation and comparison keys by the reporting device. A probe on another volume contributes nothing rather than a false proof.
  • Reuse of an already-proven signature stays sound. The three fast paths compare the recorded signature without re-checking separability, which is correct: a floor only rises, so a proof that the clock passed a tick never expires.
  • The failure direction is safe. Declining a signature costs reads, never the generation. No path added here can evict a generation that content comparison would have kept.
  • Monotonic floor across generations. The floor lives with the operations object rather than the generation, and stale-but-higher values remain true statements about the clock.

Verified empirically

  • Windows/NTFS: a probe minted 66ms after a burst of eight fixture writes separates all eight.
  • os.tmpdir() and this repository's checkout sit on different devices, so the split-volume production case is real and the accumulating observed-stamp floor is load-bearing rather than decorative.
  • Fixtures and the scratch probe both derive from os.tmpdir(), so they share a device under CI and the pre-existing exact read-count assertions keep their signatures.
  • The test seam's stamp pinning preserves the kind predicates on fs.BigIntStats.

Consequence surface outside the diff

Every other signature in the repository was inspected and none belongs to this class. The project host's config bracket and its generated descriptor loader always read the bytes, using the signature to detect an A-B-A across reads rather than to replace a comparison. The GOROOT and Go compiler identity memos cover a toolchain rather than user-editable inputs. @ttsc/metro's fingerprint always reads. Only this file let a signature stand in for content, and all three of its manifests are now covered.

Two proposals were declined with reasons recorded in the source: relocating the scratch directory onto the inputs' volume to make the probe universal, which would move every compiler and plugin temporary write into the project's parent, and seeding the floor only from minted stamps, which would disable signature reuse entirely on split-volume layouts.

Rounds

Four findings from the first round, three from the second, three from the third, and two from my own rounds, all applied. This round produced one further improvement, a comment that called a backwards clock jump the opposite of a constant offset rather than a different hazard, fixed in a8b03ec4f. The repeat round after it found nothing that survived verification.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant