Skip to content

feat(proto): name the generator skew a drift red can only imply - #20

Merged
mattwilkinsonn merged 2 commits into
compass-agent-1405-protoc-gen-esfrom
compass-agent-1405-stamp-gate
Jul 29, 2026
Merged

feat(proto): name the generator skew a drift red can only imply#20
mattwilkinsonn merged 2 commits into
compass-agent-1405-protoc-gen-esfrom
compass-agent-1405-stamp-gate

Conversation

@mattwilkinsonn

@mattwilkinsonn mattwilkinsonn commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Closes SEA-1528. Stacked on #18 — the gate asserts against the 2.12.0 regeneration that PR performs, so it cannot pass on main alone.

The other half of #18. That PR moved protoc-gen-es to the nixpkgs store path; this one supplies the guard that went with it in sealed#971, rewritten for a repo with no tools/ and no moon-invoked root-checks.

Why drift is not already this

protoc-gen-es is a store path now, so there is no lockfile pin recording which plugin produced the checked-in code. The only record is the @generated by protoc-gen-es v<version> header each file carries, and nothing was asserting those headers against anything.

compass-proto:drift catches the outcome — a gen tree disagreeing with the plugin on PATH — but renders it as a comment-only diff on a PR that touched neither the schema nor the gen trees. This gate names the cause, and the two causes are different:

devenv.lock bump      → baked 2.12.0 != gen-tree stamp 2.11.0
partial regeneration  → gen-tree stamps disagree: 2.12.0 (agent_gateway_pb.ts)
                                            vs 2.12.1 (agent_pb.ts)

The second is a real state, not a hypothetical: the trees come from three separate buf generate invocations, so one can restamp while the others lag.

It already happened once, while #18 was being written

Verifying #18, I proved drift could fail by mutating a header, then ran git checkout to clean up. That restored the committed 2.12.1 and silently undid the regeneration — git checkout is revert-to-HEAD, not revert-to-what-I-had, and the file was uncommitted work.

The tree it left behind was one file stamped 2.12.1 and five stamped 2.12.0: exactly the intra-tree disagreement above. It was caught by the wide gate going red, not by anything in the cleanup — and drift's red said only that a header disagreed with the plugin, which is equally true of a devenv.lock bump nobody caused.

So the partial-regeneration case is not a hypothetical this gate is written against in advance. It is a state one routine verification step produced on the first day the store path existed, and the reason it was cheap is that the mutation was still fresh in mind. A month from now the same tree arrives with no such context, and the difference between "your regeneration is half-applied" and "the plugin moved under you" is the difference between a one-command fix and an afternoon.

TypeScript, not a shell gate

AGENTS.md — the decision has real logic (three failure modes, each needing its own message), and the core is a pure function over an injected runner and injected source texts. That is what makes the two fail-closed behaviours testable rather than asserted, and both are ones a shell version drops silently:

  • An empty source set fails. A glob matching nothing would otherwise pass by never looking — and passing-by-never-looking renders identically to passing-by-agreeing.
  • A non-zero plugin exit fails before the banner is parsed. protoc-gen-es exits 0 and prints its banner when healthy, so a banner-then-die wrapper (wrong arch, missing interpreter) must not grade as a working plugin.

Gates — run locally, forced, in this repo

This repo has no CI. Zero workflows, so CLEAN means nothing ran. Local testimony.

$ moon run :ci --force --concurrency 4

Tasks: 40 completed
 Time: 1m 57s 550ms

40, up from #18's 35 — the new project contributes typecheck, test, check, ci, install. The wide gate, because this diff reaches package.json, bun.lock and .moon/workspace.yml.

$ moon run stamp-gate:ci --force

stamp-gate:test  | 9 pass  0 fail  22 expect() calls
stamp-gate:check | protoc-gen-es-stamp ok: 2.12.0 == gen-tree stamp (6 files)

Proven capable of failing — all three modes, against the real tree

A gate observed only passing is indistinguishable from one that cannot fail, so each named failure was induced against the actual gen trees and the actual plugin, not just in fixtures:

stamp one file to 2.12.1     → gen-tree stamps disagree: 2.12.0 (agent_gateway_pb.ts)
                                                    vs 2.12.1 (agent_pb.ts)
stamp all six to 2.11.0      → baked 2.12.0 != gen-tree stamp 2.11.0
glob → *_NOMATCH.ts          → exit 1  (empty source set)

Each restored; stamp-gate:check green after, gen trees byte-identical to #18.

The 9 unit tests cover the same three plus the branches the real tree cannot produce on demand: an unstamped file, a plugin printing no recognisable version, a banner-then-die wrapper, a bystander semver on the plugin's output line, and a prerelease version on both sides.

That last one is a false-red guard rather than a false-green one: nixpkgs does ship prereleases, and a regex accepting only x.y.z would report "no version in output" for a perfectly healthy plugin. A false red costs more than a false green here, because a closed door does not get re-tested.

check is cache: false

Its subject is the plugin on PATH, which is not an input moon can hash. A devenv.lock bump moves the binary underneath an unchanged tree — precisely the skew this gate exists to catch — and a cached green would survive exactly the change it must red on. devenv.lock is in inputs so the task re-runs on a bump, but the cache is off regardless, since the store path can move without that file changing in a way moon relates to this task.

Composes with #19's pipeline — checked, not assumed

compass#19 (SEA-1507) lands the first real CI this repo has had. Two ways this PR could have collided with it, both verified against its branch rather than reasoned about:

The gate runs. #19's job is moon run :ci, deliberately not a per-task matrix, so stamp-gate:ci is picked up by existing there — no workflow edit needed, and no enumerated task list to go stale.

The plugin resolves. #19 builds CI's PATH from tools/toolchain/parity.ts --print-nix-attrs, which parses devenv.nix — so #18's protoc-gen-es entry is installed automatically. Ran #19's parity script against this stack's devenv.nix:

ok  protoc-gen-es  store-path  /nix/store/4gcxiy…-protoc-gen-es-2.12.0
exit=0

That is the same store path stamp-gate:check compares against, so the two agree by construction rather than by coincidence. Worth checking because the parity gate asserts per command, not per attribute — a package exposing an unexpected binary name fails there, and finding that out in someone else's PR is expensive.

Spec-impact: none

Refs SEA-1405

Restacked onto #18's current head — the Graphite base branch had gone stale

This PR's body has said "Stacked on #18" since it opened, and that was accurate
about intent. What had drifted is the branch GitHub was diffing against:

before   base = graphite-base/20            f490d617   <- a SNAPSHOT of #18
after    base = compass-agent-1405-protoc-gen-es   b4b85cf6   <- #18 itself

graphite-base/20 captured #18 at the point this PR was created. #18 then
took a review fix — the without-nix protoc-gen-es documentation and the
error-message branch — and the snapshot did not follow. So this branch was
carrying a stale copy of its own parent:

                              graphite-base/20   #18 head
README without-nix list       no protoc-gen-es   protoc-gen-es present
proto/moon.yml "without nix"  0 occurrences      2 occurrences

gt restack resolved the real parent ("Restacked compass-agent-1405-stamp-gate on compass-agent-1405-protoc-gen-es"), and the doc fix is now present on this
branch rather than absent from it.

Why this mattered beyond tidiness — corrected, because my first statement of it
was wrong.
I wrote that the two doc changes "would render as additions here, this
PR appearing to add what its parent already added." False, and measurable:

#20's changed-file set (9 files):  .moon/workspace.yml · bun.lock · package.json ·
                                   tools/stamp-gate/{index.ts,moon.yml,package.json,
                                   stamp-gate.test.ts,stamp-gate.ts,tsconfig.json}
README.md / proto/moon.yml in it:  0

git merge-base f490d617 dda92293  ->  f490d617     <- the snapshot IS the merge base
git merge-base b4b85cf6 dda92293  ->  b4b85cf6

GitHub diffs a PR against the merge base, not the base-branch tip. The snapshot
was ahead=1 behind=0 — strictly ancestor-side — so it was the merge base, and
neither doc ever entered this PR's diff. The apparent-additions failure mode belongs
to a base that is behind on a path the PR touches; this base was ahead of nothing
#20 edits.

The real defect is the second reason, which is sufficient on its own: a merge
computed against the snapshot merges into a tree where those lines do not exist.

It is a defect in what lands, not in what the diff displays. Same class as the
generated-code finding in #18 — a textually clean operation over the wrong base.

Worth recording the shape, since it happened twice in one edit: I caught a
decorative number by measuring it (below) and shipped a decorative mechanism
unmeasured in the same breath. A number reads as checkable and draws the check; a
causal sentence reads as explanation and draws none.

Re-verified after the restack:

stamp-gate:test          3/3
moon run :ci --force     40/40
base                     compass-agent-1405-protoc-gen-es
head                     dda92293   local == origin

One correction to my own first draft of this note, since it is the more useful
half. I wrote that the task count moving 35 → 40 was evidence the base changed
five tasks entering the graph because the branch now really sits on #18. I
checked instead of shipping it, and it is false:

#18 alone            moon run :ci --force   35 completed
#20 (this branch)    moon run :ci --force   40 completed
source of the +5     tools/stamp-gate/moon.yml — a project THIS PR adds

#18 is 35 by itself, so the five extra tasks are this PR's own tools/stamp-gate
package and say nothing about the base. The claim was decorative — the restack was
already established by gt's own output and by the two doc measurements above —
and a decorative number attached to an already-settled conclusion is exactly the
kind nothing pulls on. The base-change evidence is the README/moon.yml deltas
and the base= field, not the task count.

The witness for this claim no longer exists as a branch

git/ref/heads/graphite-base/20  ->  404 Not Found     branch DELETED by gt restack
git cat-file -t f490d617        ->  commit            commit SURVIVES

gt restack garbage-collected the base branch it had created. The drift above is
only checkable because the report named the commit f490d617 rather than only the
branch.
A branch name is a mutable pointer and Graphite deletes the ones it makes,
so for any claim about a base, cite the commit — an hour later the branch-name
version of this note would have pointed at nothing.

State line, restated

#20 is UNSTABLE (pending), not CLEAN: Graphite / mergeability_check
in_progress with conclusion=null, Greptile Review success, 0 failing
checks
. I reported CLEAN in the same message that restacked the branch — a
restack invalidates the state line reporting it
, since the push resets every check.

mattwilkinsonn commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

@linear-code

linear-code Bot commented Jul 28, 2026

Copy link
Copy Markdown

SEA-1528

SEA-1405

@greptile-apps

greptile-apps Bot commented Jul 28, 2026

Copy link
Copy Markdown

Greptile Summary

Adds a generator-stamp gate for checked-in protoc-gen-es output.

  • Registers a new Moon project with typecheck, test, check, CI, and install integration.
  • Compares generated-file stamps for consistency and against the plugin version on PATH.
  • Adds fail-closed handling and unit coverage for missing sources, malformed stamps, plugin failures, version skew, and prerelease versions.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
tools/stamp-gate/index.ts Discovers current generated TypeScript sources, invokes the plugin, and reports the stamp-gate result.
tools/stamp-gate/stamp-gate.ts Implements stamp extraction, consistency checks, plugin-version comparison, diagnostics, and process exit mapping.
tools/stamp-gate/stamp-gate.test.ts Covers successful comparison and the gate’s documented failure modes.
tools/stamp-gate/moon.yml Integrates the uncached real-tree check and supporting test and typecheck tasks into Moon CI.
.moon/workspace.yml Registers the stamp-gate package as a workspace project.
bun.lock Adds the stamp-gate workspace package without introducing new third-party dependencies.

Reviews (4): Last reviewed commit: "refactor(stamp-gate): extract report() a..." | Re-trigger Greptile

@mattwilkinsonn
mattwilkinsonn changed the base branch from compass-agent-1405-protoc-gen-es to graphite-base/20 July 29, 2026 01:00
@seal-agent
seal-agent force-pushed the compass-agent-1405-stamp-gate branch from 119b22f to dda9229 Compare July 29, 2026 01:15
@mattwilkinsonn
mattwilkinsonn changed the base branch from graphite-base/20 to compass-agent-1405-protoc-gen-es July 29, 2026 01:15
@seal-agent
seal-agent force-pushed the compass-agent-1405-stamp-gate branch from dda9229 to 41ab88c Compare July 29, 2026 17:17
mattwilkinsonn and others added 2 commits July 29, 2026 13:57
protoc-gen-es now comes from the pinned nixpkgs, so there is no lockfile
pin recording which plugin produced the checked-in code. The only record is
the `@generated by protoc-gen-es v<version>` header each generated file
carries, and nothing was asserting those headers against anything.

drift catches the outcome — a gen tree disagreeing with the plugin on PATH
— but reports it as a comment-only diff on a PR that touched neither the
schema nor the gen trees. This asserts the stamps agree with each other and
with the plugin, so the two causes separate: a devenv.lock bump reads as
"baked X != gen-tree stamp Y", and a partial regeneration reads as a named
intra-tree disagreement with both paths. The trees come from three separate
buf generate invocations, so one lagging while the others restamp is a real
state, not a hypothetical.

TypeScript rather than a shell gate because the decision has real logic —
three failure modes, each needing its own message — and the core is a pure
function over an injected runner and injected source texts. That is what
makes the two fail-closed behaviours testable rather than asserted: an
empty source set fails, because a glob matching nothing would otherwise
pass by never looking; and a non-zero plugin exit fails before the banner
is parsed, because protoc-gen-es exits 0 and prints its banner when
healthy, so a banner-then-die wrapper must not grade as a working plugin.

check carries cache:false. Its subject is the plugin on PATH, which is not
an input moon can hash — a devenv.lock bump moves the binary underneath an
unchanged tree, which is precisely the skew the gate exists to catch, and a
cached green would survive exactly the change it must red on.

tools/* joins the bun workspaces globs so the package resolves its
toolchain from the root install like every other project.

Co-Authored-By: seal <noreply@sealedsecurity.com>
Extract the exit-code mapping into a unit-tested report(), pin the
GEN_GLOB out-dir invariant, and fix the two-tree count and uncached
inputs notes surfaced by review.

Co-Authored-By: seal <noreply@sealedsecurity.com>
@seal-agent
seal-agent force-pushed the compass-agent-1405-stamp-gate branch from 41ab88c to 93b2e26 Compare July 29, 2026 17:57
@mattwilkinsonn
mattwilkinsonn merged commit 2780052 into main Jul 29, 2026
3 of 6 checks passed
@mattwilkinsonn
mattwilkinsonn deleted the compass-agent-1405-stamp-gate branch July 29, 2026 22:13
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