Skip to content

[do-not-mirror][do-not-merge] chore(fork): put the fork-process guards in forkctl and split the docs - #9

Closed
jdsika wants to merge 3 commits into
integration/3.0from
chore/forkctl
Closed

jdsika wants to merge 3 commits into
integration/3.0from
chore/forkctl

Conversation

@jdsika

@jdsika jdsika commented Oct 26, 2026 •

Copy link
Copy Markdown

What

Move every mechanical fork-process guard out of .github/copilot-instructions.md into a tested
stdlib-only CLI at .github/fork/forkctl.py, and split the remaining prose into a compact
always-loaded core plus an on-demand reference at .github/fork/PROCESS.md.

Why

.github/copilot-instructions.md is loaded into the context of every agent session in this
repository, whether or not the task has anything to do with the fork process. At 55 935 B it cost
roughly 14k tokens per session before any work began, and most of that budget was spent on
hand-typed bash guards that an agent had to re-derive, re-type and re-reason about each time.

That was expensive, and it was also fragile. The guards were duplicated in six places
(§3.3, §4.5, §4.8 Gate 3, §6, §7, fork-only-view.yml), so a change to the fork-only file list
meant six edits, and the file itself had to spend paragraphs warning about shell footguns it had
hit in practice — grep -c exiting 1 on no match, git log | grep -q returning 141 under
pipefail and silently disabling a guard. Those failure modes do not exist outside a shell.

Two measurements drove the shape of this PR. Extracting the bash alone shrank the core file by only
~4%, because the bulk was prose, not commands — hence the split. And while porting the guards to
Python, the test suite found a real hole in the existing §4.5 check (see Risk / notes).

The core file is now 35 902 B / 652 lines, down from 55 935 B / 1021 lines — 35.8% smaller, on
every session. The detail is not lost, it is one view away in PROCESS.md, and §0.2 is a table
that says exactly when to open it.

Splitting a safety-critical document is only safe if skipping the half you did not load cannot
hurt you. The criterion, made explicit after Gate 2 round 1: a procedure may leave the core only
if a fails-closed command can refuse the wrong outcome.
Every destructive procedure in
PROCESS.md is gated by a forkctl command, so an agent that never opens the reference still
cannot push a contaminated commit, fold a malformed stack, or delete a mirror branch out from under
an open upstream PR. Rules whose failure mode is outside git — the update-smoke-golden.yml
dispatch hazard, the merge-button rules, upstream-facing judgement — stay in the core, because no
guard can exist for them.

forkctl deliberately depends on neither make nor just, so it is unaffected by #8.

Command Replaces
doctor §4.1 clone setup + in-flight process-change PR query
check-commit [--chore] §4.4/§4.5 one-commit + fork-only file/hunk/block guards
check-stack [--print-base] [--github-output] §3.3/§7 stack invariants
check-view fork-only-view.yml branch-currency + standing-PR contract
mirror <slug> --sha|--subject Gate 3 + §6 mirror build and proof
status §6.2 inventory + drift + §6.3 git cherry audit + §7 divergence report
delete-mirror <slug> §6.2 deletion-hazard terminal-state guard
fork-only [-v] canonical fork-only path list

How tested

The full required suite ran in CI on the final commit — all 12 checks green. This is a
fork-only change to documentation, one standalone Python tool and one fork-only workflow, so no
.c/.h, no notebooks, no sim logic and nothing under pufferlib/ is touched; the suites below
are therefore regression evidence rather than coverage of the change itself.

  • pre-commit run --all-files — pass in CI (31 s). This covers ruff-format,
    trailing-whitespace, end-of-file-fixer and clang-format on the whole tree.
  • make rebuild — covered by C Drive Tests (pass, 3 m 36 s); no .c/.h changed
  • make test-unit / make test-eval — Unit Tests pass (3 m 40 s)
  • make test-c — C Drive Tests pass (3 m 36 s)
  • make test-notebooks — Notebook Execution pass (5 m 33 s)
  • make test-docker-smoke — Smoke Train Test pass (7 m 26 s)
  • make -C tests/drive perf — Smoke Performance Test pass (3 m 15 s)
  • make test — covered by the four suites above, plus the install matrix
    (3.11/3.12 macOS, 3.11–3.14 Ubuntu) all pass

The local environment here is Windows and .venv in this checkout is a Linux venv, so make and
pre-commit could not be invoked natively. Before pushing, the Python hooks were run directly
against the repo's own ruff.toml and the whitespace hooks checked programmatically; CI has since
confirmed that approximation was correct.

The tool's own suite, which is the test that matters for this diff:

python .github/fork/test_forkctl.py
Ran 105 tests in 1822.532s
OK

It also runs as an advisory self-test job inside fork-only-view.yml, so it is additionally proven
green on ubuntu-latest and is not Windows-dependent.

Validated against this real repository, not only against fixtures:

forkctl check-stack       10/10 PASS
forkctl check-commit --chore 4/4 PASS
forkctl check-view        15/15 PASS  (incl. PR #7: base 3.0, draft, do-not-merge, no do-not-mirror)
forkctl status            total gap 2 / fork-only 1 / feature gap 1;
                          found Emerge-Lab/PufferDrive#602 open from mirror/ci-cpu-torch-wheels;
                          no drift, no stale commits
forkctl mirror ci-cpu-torch-wheels --subject "fix(ci): install CPU-only torch wheels in GitHub Actions"
                          PASS -- the real stacked feature cherry-picks onto pure upstream/3.0,
                          and the marker guard correctly distinguishes legitimate ci.yml /
                          install.yml edits from the fork-only branch-filter hunk
forkctl mirror (dirty tree)
                          correctly REFUSED

The rewritten workflow was dispatched in both directions on the final commit:

  • run 40300687871 — production
    refs, both jobs success
  • run 40300911350 — pointed at a
    ref carrying two chore(fork): commits, verify failed with
    ::error 2 chore(fork): commits on the stack, expected 1, while the advisory self-test job
    stayed green

The negative run is the one that matters: it proves the detector detects, and that the two jobs are
independent.

New behaviour is covered by a new test / the fix has a test that fails without it:

105 tests in .github/fork/test_forkctl.py. Every guard is asserted in both directions — it
passes a well-formed input and fails a malformed one — in TestCheckStack, TestCheckCommit,
TestMirror, TestMirrorDrift, TestReviewRegressions, TestRoundTwoRegressions, TestFailClosed,
TestWorkflowParser and TestPureHelpers. The two bugs described under Risk / notes each have a dedicated regression
test: test_fails_when_content_inside_the_fork_block_is_edited (the hole) and
test_substring_lookalikes_are_not_fork_only (the over-fire). Every Gate 2 finding below has one
too.

Stack hygiene

$ python .github/fork/forkctl.py check-commit --chore
== check-commit
  PASS  exactly one commit on the branch
  PASS  rebased directly onto origin/integration/3.0
  PASS  subject is well formed: 'chore(fork): put the fork-process guards in forkctl and split the docs'
  PASS  fork-only markers are well formed
        fork-only file and hunk checks: exempt (chore(fork) branch)
== check-commit: OK
  • forkctl check-commit passes (--chore on a fork-only branch)
  • Conventional Commits subject, rebased on current integration/3.0

Merge gate

  • Gate 1 — CI green on the final commit (no "green except a flake")
  • Gate 2 — adversarial review — round 1 FAILED, 13 findings (9 critical); round 2
    FAILED, 8 findings (4 critical); round 3 FAILED, 7 findings (3 critical, 2 high,
    2 medium, 1 low). All 28 addressed, each with a regression test, in 6957b2b82 and
    308033ed7 (see the three Gate 2 comments below). Suite grew 67 → 88 → 105 tests.
    Round 4, scoped to the round-3 deltas, pending.
  • Gate 3 — upstream-readiness proven — exempt: chore(fork)

Gate 3 result:

exempt: chore(fork)

Fork-only infrastructure cannot be upstream-ready by definition. The §4.5 fork-only file and hunk
checks are likewise exempt and are expected to "trip" — touching those files is the entire point of
this branch. Every other rule still holds, in particular "exactly one commit", asserted above.

Upstream disposition

  • Mirror PR to Emerge-Lab/PufferDrive requested now
  • Contributable, but we hold it for now, because:
  • Never upstream — fork-only chore(fork): change, Gate 3 exempt

Risk / notes

This PR must not be merged. Merging would put a second chore(fork): commit on the stack, and
forkctl check-stack asserts there is exactly one, so every future sync would abort until someone
unpicked it by hand. It is applied by fixup into the base commit instead
(.github/fork/PROCESS.md -> Changing fork-only content) and ends closed, not merged. That
fold force-pushes the default branch, so it is a human decision and is deliberately left undone
here.

A real guard hole, found by porting the checks to Python. The old §4.5 check was:

git diff ... -- AGENTS.md CLAUDE.md ... | grep -Eq '^[+-].*fork:(begin|end)'

It only fires when a changed line itself contains a marker. Editing the text between
<!-- fork:begin --> and <!-- fork:end --> produces +/- lines with no marker on them, so the
guard never fired and fork-only content could reach a mirror diff. forkctl now extracts the whole
block at the merge-base and at HEAD and compares them, so any edit inside the block is caught
regardless of which lines moved.

The same checks also over-fired. The shell guards matched substrings, so a legitimate file
named e.g. docs/sync-mirror-notes.md or tools/pull_request_template.py was reported as
fork-only contamination. Path matching is now exact paths plus explicit prefixes.

Event 1 drift detection changed meaning, deliberately. §6.2 previously tested
git merge-base --is-ancestor mirror/<slug>^ upstream/3.0, which cannot fire when upstream
fast-forwards — i.e. essentially never, despite the prose promising to catch "the PR starts showing
conflicts". forkctl status now uses git merge-tree --write-tree to test whether the mirror still
merges onto the upstream tip, which is the condition the prose actually describes. An upstream
advance that still merges cleanly needs no action and is now correctly reported as nothing.
merge-tree --write-tree needs git >= 2.38.

Behaviour preserved on purpose: §4.5's hunk check stays scoped to the four shared files while
Gate 3's marker check scans the whole diff. That asymmetry is intentional — nothing in a mirror
diff should mention the fork at all — and it is asserted by tests rather than left to a reader.

fork-only-view.yml drops ~70 lines of bash and jq for two jobs. verify is the
authoritative detector: on push and schedule it checks out integration/3.0 and hardcodes
origin/integration/3.0 / origin/3.0, so the ref under analysis can never supply the code that
polices it, nor redirect the verdict at something else. Only a human-driven workflow_dispatch may
point it at a candidate ref, and the step summary then says so in a table. self-test is a second,
advisory job that runs the unit suite from the triggering ref — it proves the tool is sound,
never that the repository state is. Still contents: read only.

Two adversarial review rounds found 21 real guard holes, 13 of them critical. All are fixed and each has a regression test;
the full itemisation is in the Gate 2 comment below. The headline ones: git diff --name-only
applies rename detection, so renaming a fork-only file away passed every check (--no-renames now,
plus a new "every file in the fork base is registered as fork-only" invariant); moving the branch
filter from pull_request: to push: left the marker text untouched (there is now a structural
reader of the on: block); git() stripped whitespace, so a leading-space subject defeated the
chore(fork): and fixup! prefix checks; doctor/status/check-view exited 0 when gh was
missing or erroring; and delete-mirror could read another fork's PR with the same branch name.
Every one of those was a false PASS, which is the only failure mode that actually matters for a
tool whose whole purpose is to let the prose shrink.

One finding is only half solved, deliberately. A feature commit that invents a brand-new
fork-only file still passes check-commit, because the fork-only list is hardcoded. The new
registration invariant catches it once the file reaches the base commit — check-stack then fails
until the path is added to FORK_ONLY_FILES — but not at feature-commit time. "This new file is
fork-only" is a claim about intent that no diff inspection can derive; Gate 2 review is the control
for it. I would rather state that than close it with a check that looks authoritative and is not.

Docs updated in step: CONTRIBUTING.md and .github/pull_request_template.md no longer restate
the fork-only file list or the guard commands — they point at forkctl, so the list exists in
exactly one place.

ASCS-eV/PufferDrive is consumed as a git submodule internally while feeding
features back to Emerge-Lab/PufferDrive. This commit carries everything that
exists only in the fork, and is the single place fork-only content lives.

Branch model:
  3.0              verbatim, fast-forward-only mirror of upstream/3.0
  integration/3.0  default branch; this commit plus a linear stack of
                   single-commit features; what internal consumers pin
  feature/<slug>   one feature, exactly one commit
  mirror/<slug>    cherry-picked onto pure upstream/3.0 for upstream PRs

integration/3.0 is the default branch because GitHub reads the PR template,
the contributing guidelines, the agent instructions and scheduled workflows
only from the default branch, and none of those files may exist on 3.0
without destroying the mirror. A clone checks out the default branch, so the
copy of the instructions here is the process as far as every agent and every
submodule consumer is concerned; a rule that lives only in an open PR has no
effect.

Nothing reaches integration/3.0 until three gates pass:
  1. CI green on the final commit.
  2. Adversarial review by someone other than the author, with the findings
     written down -- including the probes that found nothing.
  3. Upstream-readiness proven rather than asserted: the commit cherry-picks
     onto a branch cut from pure upstream/3.0 and produces an upstream-clean
     diff. We stack only commits we could contribute today.

chore(fork): commits are exempt from gate 3, since fork-only infrastructure
can never be upstream-ready. They carry two orthogonal markers: do-not-mirror
(must never reach upstream) and do-not-merge (the merge button is never
pressed -- merging would create a second chore(fork): commit and break the
single-commit invariant that every sync asserts). Such a PR is folded into
this commit by fixup and closed unmerged.

Contents:
- .github/copilot-instructions.md: normative process for coding agents
- CONTRIBUTING.md: human orientation, same rules
- .github/pull_request_template.md: testing and stack-hygiene gates
- .github/workflows/sync-mirror.yml: fast-forward-only mirror sync that
  refuses any non-fast-forward instead of rewriting the upstream base
- AGENTS.md / CLAUDE.md: fenced pointer so agents find the fork process
  without colliding with upstream-owned content
- ci.yml / install.yml: run CI for PRs targeting integration/3.0, which
  upstream's branch filters would otherwise skip

A standing draft PR from integration/3.0 into 3.0 gives a continuous CI
verdict on the combined stack; it must never be merged.

Everything here is fork-only and must never be cherry-picked upstream.
The Linux CI jobs fail while installing the package:

    [Errno 122] Disk quota exceeded

Errno 122 is EDQUOT, hit while unpacking the CUDA torch stack that
`pip install -e .` resolves by default. torch alone is 554.6 MB and the
accompanying nvidia-* wheels add another ~2.4 GB, which no longer fits
the runner's quota. The failure occurs during "Installing build
dependencies", i.e. the isolated PEP 517 build environment is already
enough to exceed it on its own.

The trigger is a runner image change, not a code change:

  * upstream 11_bernhard_dev, 2026-10-20, Image: ubuntu-24.04 -> success
  * same workflows, 2026-10-23, Image: ubuntu-26.04 -> failure

Both resolve an identical wheel set (torch 2.14.1, 554.6 MB). The
`ubuntu-latest` label began migrating to Ubuntu 26 on 2026-10-19
(actions/runner-images#14748), between those two runs. Both workflows
already carry "Free up disk space" steps, so these jobs were running
close to the limit before the migration.

Fix: install CPU-only torch from download.pytorch.org, then build with
--no-build-isolation so the build reuses it instead of resolving torch
a second time. --no-build-isolation needs the build dependencies present
up front, hence the explicit setuptools/wheel/numpy install; the
`setuptools<81` bound matches the pin already in install_requires.

This loses no coverage: BUILD_CUDA_EXT keys off CUDA_HOME/ROCM_HOME,
which is unset on these runners, so the CUDA sources were never compiled
here. tests/smoke_tests/Dockerfile already installs torch this way.

macOS keeps the plain `pip install -e .` unchanged: its PyPI wheels are
already CPU-only, and leaving it alone keeps the default PEP 517
build-isolation path under test somewhere in the matrix.
@jdsika jdsika added do-not-mirror Fork-only change; must never be sent to Emerge-Lab/PufferDrive do-not-merge Never use the merge button; applied by fixup (3.3) or never merged at all labels Oct 26, 2026
@jdsika

jdsika commented Oct 26, 2026

Copy link
Copy Markdown
Author

Gate 1 — dispatched workflow runs

This commit changes .github/workflows/fork-only-view.yml, so per §4.8 the workflow had to actually
run, exercising each state it is meant to distinguish — not just the happy path.

Run Input Expected Result
40293675897 defaults (origin/integration/3.0) pass — the real stack and view branch are current success
40293862671 stack=origin/chore/forkctl fail — that ref carries a second chore(fork): commit failure, for the right reason

The negative run is the one that matters; a detector that only ever passes is not a detector. It
emitted the annotation:

##[error]2 chore(fork): commits on the stack, expected 1 Fix: fold fork-only changes into the base commit (section 3.3)

Both runs also executed the forkctl unit suite as a self-test step on ubuntu-latest, which
confirms the suite is not Windows-dependent.

Dispatching from chore/forkctl at all is only possible because the workflow now checks out
${{ github.ref }} instead of pinning a branch — that decoupling is what makes this evidence
obtainable before the commit is folded into the fork base.

@jdsika

jdsika commented Oct 26, 2026

Copy link
Copy Markdown
Author

Gate 2 round 1: FAILED -- 13 findings, all now addressed

An adversarial review (fresh context, no authoring history) was run against commit c3302bd4d.
It found 13 issues, 9 of them critical -- all of them cases where a guard reported PASS on
input it was supposed to reject
. That is the worst possible failure mode for this change, since
the whole premise is "the script is the authority, so the prose can shrink".

Every finding is fixed in 513245b95. Details below, including the one I am not claiming to
have fully solved.

Critical -- guards that passed contaminated input

# What slipped through Fix
1 git diff --name-only applies rename detection, so renaming a fork-only file away reported only the destination path -- CONTRIBUTING.md -> FORK_GUIDE.md passed every check --no-renames on every guard diff, plus a new stack invariant: every file in the fork base must be registered as fork-only
2a Moving the branch filter from pull_request: to push: leaves the literal integration/3.0 line untouched, so the text scan saw no change workflow_branch_filters() -- a structural reader of the on: block; the guard now compares which trigger carries the fork branch, not whether the string exists
2b With two fork:begin/end blocks in a file, only the first was compared; and --chore skipped block validation entirely malformed_fork_blocks() (unbalanced / nested / duplicate) enforced in both modes and in check-stack
3 check-commit never verified rebase-currency or Conventional Commits, although the docs said it did now asserts HEAD^ == base and runs subject_problems() (type, scope, imperative, <= 72 chars)
4 git() called .strip(), and --grep matches commit bodies. A subject of " chore(fork): x" or a body line fixup! ... defeated both prefix checks strip("\r\n") only; fixup/squash detected on subjects; plus an explicit "no ragged subject" check so whitespace can never hide a commit again
5 Gate 3 --sha accepted any reachable commit, so it could certify a commit that is not the one being merged the SHA must be on the stack, or parented on its tip
6 Mirror drift compared only the tip patch-id, so extra commits on a mirror branch were invisible; and a None patch-id read as "no drift" exactly-one-commit assertion on the mirror, and None is a failure
7 delete-mirror took prs[0] -- any fork's PR with the same head branch name -- and discarded the deletion return code upstream_prs_for() filters on headRepositoryOwner, checks all matches, and verifies the remote ref is actually gone
8 doctor / status / check-view exited 0 when gh was absent or erroring, and a missing standing PR was green all fail closed; merges_cleanly() raises on git < 2.38 rather than silently reporting "clean"
9 The workflow checked out the branch under test, so a tampered integration/3.0 supplied its own detector verify now checks out integration/3.0 on push/schedule with production refs hardcoded; the advisory self-test job is separate; the step summary names the refs actually used

High / medium

# Finding Fix
10 mirror force-deleted the branch before proving the replacement, and broke detached-HEAD restore builds detached, git branch -f only on success, restore_checkout() falls back to --detach
11 No tests for doctor / check-view / status / delete-mirror, nor for any of the above +28 tests across TestReviewRegressions, TestFailClosed, TestWorkflowParser; suite is now 67 tests
12 The update-smoke-golden.yml hazard moved out of the always-loaded core with no guard behind it restored verbatim to core section 7 -- see "what this changes about the doc split" below
13 Gate 3's whole-diff scan for integration/3.0 blocked legitimate prose mentioning the branch FORK_PATH_SCOPED_MARKERS scopes that marker to .github/; fork:begin/end stay global

What finding 12 changed about the argument for splitting the doc

The PR originally justified the split as: moving a procedure out of the always-loaded core is safe
because a fails-closed command stands behind it.
Finding 12 is a genuine counter-example. The
update-smoke-golden.yml hazard is "never dispatch this workflow against 3.0" -- a GitHub UI
action, not a git operation, so no guard can exist for it. The rule has to be in the text an
agent always has.

So the criterion is now narrower and stated explicitly: a procedure may leave the core only if a
fails-closed command can refuse the wrong outcome.
Anything whose failure mode is outside git --
workflow dispatches, merge-button presses, upstream-facing judgement -- stays in the core.

Finding 1, honestly: only half solved

The reviewer's second point was that a feature commit which invents a brand-new fork-only file
still passes, because the fork-only list is hardcoded.

The new fork-base registration invariant catches this once the file reaches the base commit --
check-stack fails until the file is added to FORK_ONLY_FILES. It does not catch it at
feature-commit time, and I do not think it can: "this new file is fork-only" is a semantic claim
about intent that no diff inspection can derive. Gate 2 review is the control for that, which is
exactly what this round demonstrated.

I would rather leave that written down than close it with a check that looks authoritative and
is not -- which is the failure mode this whole review round was about.

Re-verification

All on 513245b95:

  • python .github/fork/test_forkctl.py -- 67 tests, OK (474 s)
  • python -m ruff check + ruff format --check on both Python files -- clean
  • Against the real repository: check-stack 10/10 PASS, check-commit --chore 4/4 PASS,
    check-view 15/15 PASS, status OK (gap 2 / fork-only 1 / feature 1, no stale commits,
    mirror/ci-cpu-torch-wheels -> upstream Install CPU-only torch wheels in CI to fit ubuntu-26.04 disk limits Emerge-Lab/PufferDrive#602 open, no drift)
  • mirror ci-cpu-torch-wheels --subject "fix(ci): ..." -- Gate 3 PASS against live
    upstream/3.0, branch rebuilt and checkout restored
  • mirror correctly refused to run on a dirty tree

Gate 2 is being re-requested on this commit.

@jdsika

jdsika commented Oct 26, 2026

Copy link
Copy Markdown
Author

Gate 1 re-run on 513245b95 (post-Gate-2 fixes)

All 12 required checks pass on the final commit:

check result time
pre-commit pass 32s
Unit Tests pass 4m32s
C Drive Tests pass 3m16s
Notebook Execution pass 5m8s
Smoke Train Test pass 14m34s
Smoke Performance Test pass 3m0s
3.11 - macos-latest pass 2m36s
3.11 - ubuntu-latest pass 3m14s
3.12 - macos-latest pass 2m36s
3.12 - ubuntu-latest pass 2m48s
3.13 - ubuntu-latest pass 2m34s
3.14 - ubuntu-latest pass 2m31s

Because this commit changes a workflow, Gate 1 additionally requires that the workflow was run,
in both directions:

  • run 40300687871 -- production
    refs (origin/integration/3.0, origin/3.0): verify success, self-test success
  • run 40300911350 -- pointed at
    a ref carrying two chore(fork): commits: verify failure with the annotation
    2 chore(fork): commits on the stack, expected 1 Fix: fold fork-only changes into the base commit (section 3.3), while the advisory self-test job stayed green

The second run is the one that carries the proof: the detector rejects a malformed stack, emits a
correct ::error annotation with the fix hint, and the two jobs fail independently of each other.

@jdsika

jdsika commented Oct 26, 2026

Copy link
Copy Markdown
Author

Gate 2, round 2 — response

Thank you; this round was as useful as the first. Eight findings, all accepted, all fixed in
6957b2b82. Nothing was waved off as acceptable risk, and the one residual is restated below
rather than buried.

The pattern across both rounds is worth naming: every critical finding was a guard that printed
PASS on input it should have refused
. That is the only class of defect that actually matters
here, because a guard that over-fires gets noticed in minutes and a guard that under-fires gets
trusted for months.

1. (critical) The workflow reader mis-parsed YAML and reported PASS

startswith("branches") matched branches-ignore, so swapping the protected branches: filter
for its exact opposite read as untouched. Block scalars, flow mappings and inline comments were
also misread.

The reader now matches the branches key exactly, strips inline comments, and — more importantly
— fails closed: _reject_unsupported_yaml() raises ForkctlError on tabs, anchors, aliases,
merge keys, block scalars, flow mappings and an inline on: value. A hand-rolled parser that
silently mis-parses is strictly worse than no parser, because it manufactures confidence. If the
file leaves the subset the reader understands, the check refuses to render a verdict at all.

Covered by test_branches_ignore_is_a_different_key, test_a_block_scalar_decoy_is_rejected,
test_a_flow_mapping_is_rejected_rather_than_misread, test_an_alias_is_rejected,
test_an_inline_on_value_is_rejected, test_a_tab_indent_is_rejected,
test_inline_comment_is_not_part_of_a_branch_name, and end-to-end by
test_swapping_branches_for_branches_ignore_is_caught and
test_unparseable_workflow_fails_closed.

2. (critical) check-stack validated shape, not content

It checked commit counts and order and stopped there. A feature commit later in the stack
could touch a fork-only file, edit inside a fork:begin/fork:end block, move a branch filter, or
delete a shared file outright, and the stack still reported OK. Separately, git show ... check=False returned "" for both "file absent" and "git failed", which the caller then read as
"nothing to check" — fail-open.

Three additions:

  • show_file() replaces every git show ... check=False. It uses cat-file -e first, so
    "absent" and "git errored" are different answers, and the second one is loud.
  • fork_state_problems() asserts the absolute end state rather than a delta: every registered
    fork-only file present, every shared file present, every fork: block well formed, every
    protected branch filter in place. A delta check can be defeated by arriving at the wrong state
    through an unexamined path; an end-state check cannot.
  • smuggled_shared_changes() strips the fork block / fork branch-filter entry from each shared
    file and requires what remains to be byte-identical to upstream. This closes the inverse
    hole: a real upstream-worthy change hidden inside the one commit that is exempt from Gate 3.

Every non-base commit on the stack is now re-validated individually, not just the tip. Covered by
test_a_later_stack_commit_touching_a_fork_only_file_is_caught,
test_a_later_stack_commit_editing_the_fork_block_is_caught,
test_a_shared_change_smuggled_into_the_fork_base_is_caught and
test_a_fork_base_deleting_a_shared_workflow_is_caught.

3. (critical) Gate 3 could certify a commit nobody was merging

--sha accepted any commit reachable from the stack, including an older one or a sibling, while a
different commit was checked out. Gate 3's entire purpose is to prove that the commit under
review
is contributable; proving it about some other commit is worse than not running it.

--sha must now equal HEAD and be on the stack or parented on its tip. Covered by
test_gate_three_refuses_a_commit_that_is_not_checked_out (new),
test_gate_three_refuses_an_arbitrary_commit and test_gate_three_accepts_the_commit_under_review.

4. (critical) The tamper detector presented advisory runs as authoritative

You dispatched a run and got a green job named "Check fork-only/3.0 is current" — from candidate
code, against a non-production ref. The job name and a green tick are frequently the only things
read in the Actions list, so that name was an untrue claim.

The job name is now an expression: a workflow_dispatch run is titled
ADVISORY -- candidate detector, not authoritative. The step summary gained an authority row
and an explicit advisory blockquote naming the ref the detector came from.

The deeper point stands and is now documented in the workflow header instead of claimed as
solved
: a workflow cannot be a tamper-proof monitor of the repository that stores it. On push,
GitHub runs the definition from the pushed ref, so a tampered fork-only/3.0 can neuter the file
before the trusted checkout runs. The backstop is schedule:, which GitHub always runs from the
default branch. That is a real but partial guarantee, and it now reads as one.

5. (high) mirror could strand the user on a detached HEAD

Restoration only ran when report.failed was truthy, so an exception or Ctrl-C left the user
detached; an invalid slug stranded them after the checkout had already happened.

SLUG_RE is validated before anything moves, and restoration is now an unconditional
finally guarded only by a stayed flag. Covered by
test_an_invalid_slug_is_refused_before_anything_moves and
test_an_exception_after_checkout_still_restores_the_branch.

6. (medium) delete-mirror reported success it had not achieved

git branch -D's return code was discarded and the command printed "deleted locally and on
origin" regardless. Local and remote deletion are now reported separately and a failed local
delete is a failed command. Covered by test_delete_mirror_reports_a_failed_local_delete.

7. (medium) Gate 3 over-fired on legitimate prose

FORK_PATH_SCOPED_MARKERS covered all of .github/, so documentation that merely mentioned
integration/3.0 was refused. Narrowed to .github/workflows/, where the string is a functional
trigger rather than prose. Covered in both directions by
test_prose_under_github_may_mention_the_fork_branch and
test_a_workflow_mentioning_the_fork_branch_is_still_caught.

8. (medium) The "asserted in both directions" claim was not true of everything

It is now. The suite is 88 tests, up from 67, with at least one test per finding above and
positive-direction coverage alongside each refusal.

Wording, and the residual I am not claiming to have fixed

You asked that the CLI not imply semantic certainty it does not have. no fork-only file in the diff now reads no **registered** fork-only file in the diff, in both check-commit and
mirror.

The residual is unchanged and stated plainly: a feature commit that invents a brand-new
fork-only file — one not in FORK_ONLY_PATHS — still passes check-commit. The fork-base
registration invariant catches it only once the file reaches the base commit. This is a registry,
not an oracle, and the wording now says so.

Verification

  • ruff format / ruff check clean
  • python .github/fork/test_forkctl.py — 88 tests, OK
  • Real repository: check-stack 13/13 PASS, check-view 18/18 PASS, check-commit --chore PASS,
    status reports the live stack and the open upstream PR correctly
  • fork-only-view.yml dispatched in both directions on the amended commit

Re-requesting review.

@jdsika

jdsika commented Oct 26, 2026

Copy link
Copy Markdown
Author

Gate 1 evidence on 6957b2b82

All 12 required checks pass on the final commit:

check result
pre-commit pass (29 s)
Unit Tests pass (4 m 17 s)
C Drive Tests pass (3 m 38 s)
Notebook Execution pass (4 m 38 s)
Smoke Performance Test pass (3 m 41 s)
Smoke Train Test pass (14 m 36 s)
3.11 / 3.12 / 3.13 / 3.14 - ubuntu-latest pass
3.11 / 3.12 - macos-latest pass

The workflow this commit changes was actually run

A green required-check board says nothing about fork-only-view.yml itself, so it was dispatched
in both directions on the amended commit:

run input job name as rendered result
40308651090 production refs ADVISORY -- candidate detector, not authoritative success
40308673608 stack=origin/chore/forkctl ADVISORY -- candidate detector, not authoritative failure, as required

The negative run failed for exactly the intended reason:

2 chore(fork): commits on the stack, expected 1
Fix: fold fork-only changes into the base commit (section 3.3)

The rendered job name is also the Gate 2 round-2 finding 4 fix, visible in the Actions list rather
than only in the logs: a dispatched run can no longer present itself as the authoritative check.

The advisory forkctl self-test (candidate ref) job passed in both runs, so the 88-test suite is
proven green on ubuntu-latest and is not Windows-dependent.

Note on scope: until this commit is folded into the fork base, integration/3.0 carries neither
the workflow nor forkctl, so push and schedule runs cannot exist yet. workflow_dispatch
is the only available evidence, which is why it is given in both directions.

@jdsika

jdsika commented Oct 26, 2026

Copy link
Copy Markdown
Author

Gate 2 — round 3 response

Seven findings, all fixed. Suite: 67 → 88 → 105 tests.


1 (critical) — the workflow reader certified a branch the filter excludes

branches: [ main, '3.0', 'integration/3.0', '!integration/3.0' ] was reported as listed.
GitHub evaluates patterns in order, so the trailing negation wins and the fork branch gets no
pull_request CI — while check-stack said the fork base was intact. The same reader also
mis-handled '3.0,integration/3.0' (comma inside a quoted scalar), a top-level True: key
(on: is YAML-truthy, but the regex accepted the word True as a trigger), branches:
nested under types:, and duplicate branches: keys (last-wins silently).

Fix. workflow_branch_filters() rewritten around _trigger_blocks() / _child_lines():
branches is only honoured at the trigger's direct-child indent; _split_flow_list() is
quote-aware; _unquote_branch() rejects any !-prefixed entry and any unbalanced quote;
duplicate triggers and duplicate branches keys raise ForkctlError. Positive globs are still
accepted — they can only add matches. Fail-closed throughout.

2 (critical) — Gate 3 certified an unrelated commit

git switch --detach <older-stack-commit> plus mirror --sha <that commit> passed, so a commit
that was never under review could be stamped upstream-ready.

Fix. resolve_feature_commit() now refuses a detached HEAD outright and requires both
sha == HEAD and sha^ == stack tip — i.e. exactly the one-commit feature branch §4.8
describes. §6 rebuilds continue to use --subject, which resolves against the stack by design.

3 (critical) — registered fork-only files did not have to exist

Deleting CONTRIBUTING.md, the PR template and sync-mirror.yml from the fork base still passed
check-stack: the guards only ever asked "is this path in a diff?", never "is it still there?".
The test fixture had been missing three of them all along, which is how it went unnoticed.

Fix. New REQUIRED_FORK_FILES; fork_state_problems() asserts each exists at the base. The
fixture is now generated from that constant, plus a test that fails if the two ever drift.

4 (high) — a feature commit could repair a malformed fork marker undetected

fork_blocks() ignores stray markers, and block structure was only validated at the stack tip, so
a broken fork:begin in the base plus a feature commit silently "fixing" it read as clean.

Fix. New fork_marker_lines() / changed_fork_markers(); malformed_fork_blocks() now runs
on the base as well as the tip; the per-commit loop checks marker lines in addition to block
content, so neither editing inside a block nor moving its delimiters gets through.

5 (high) — Ctrl-C mid-cherry-pick left a conflicted detached HEAD, reported as restored

Both restore attempts failed (a conflicted index blocks git switch) while the report still
printed back on <branch>.

Fix. restore_checkout() aborts an in-progress cherry-pick first and reports failure if it
still cannot return; the git switch --detach moved inside the try so an interrupt between the
switch and the first guarded statement cannot skip restoration. Covered by a test that performs a
genuinely conflicting cherry-pick and raises KeyboardInterrupt before forkctl can abort it.

6 (medium) — the "byte-identical" claim was not true

strip_fork_block / strip_fork_branch went through splitlines(), normalising CRLF→LF and
trailing newlines — so a line-ending rewrite smuggled into the fork base compared equal.

Fix. Both use splitlines(keepends=True) + "".join(...); show_file() now reads raw bytes
and decodes without stripping (git() strips line terminators, which would have hidden exactly
this). smuggled_shared_changes() allows only the single separator newline the appended block
leaves behind.

7 (low) — dangling section references

The core doc's command table cited §6.2/§6.3, which no longer exist after the split.

Fix. The column is now "Where it is written up", citing §7 and the italicised PROCESS.md
headings, with a legend line.


Re-verified after the fixes: 105/105 tests pass; ruff format / ruff check clean;
check-stack, check-view, check-commit --chore and status green against the real repository;
fork-only-view.yml dispatched in both directions.

The fork process was described entirely in prose plus inline shell. That had
two costs.

Token cost. `.github/copilot-instructions.md` is loaded automatically in every
session in this repository, including the overwhelming majority that touch the
simulator and never touch the fork process. It was 56 KB, roughly 14k tokens of
standing overhead per session.

Correctness cost. The same guard was retyped at several call sites and the
copies drifted. The fork-only file list existed in six places. More seriously,
the `AGENTS.md` fork-block guard never fired at all: it matched changed lines
*containing* `fork:begin`/`fork:end`, but an edit between the markers produces
changed lines carrying no marker, so fork-only prose could be edited inside a
feature commit and pass every check.

This commit moves the mechanics into code and the rationale out of the
always-loaded file.

`.github/fork/forkctl.py` is the single implementation of every mechanical
check: doctor, check-commit, check-stack, check-view, mirror, status,
delete-mirror, fork-only. It is stdlib-only and spawns no shell, so the ways a
shell guard dies quietly have no analogue — `grep -c` exiting 1 on no match,
`git log | grep -q` returning 141 under pipefail, a pipeline's status being
swallowed. Those footguns previously needed paragraphs of warning in the docs.
`.github/fork/test_forkctl.py` covers each guard in both directions against
throwaway git repositories, including the fork-block hole above, which is now
caught by comparing the block's content at the merge-base and at HEAD.

`.github/fork/PROCESS.md` takes the deep procedures — the standing draft PRs,
changing fork-only content, the adversarial-review checklist, the mirror PR
workflow, syncing and releasing. They are rare, they must not be improvised,
and they do not belong in a file that is parsed on every unrelated task.
`.github/copilot-instructions.md` keeps the rules an agent needs *before*
acting and signposts the rest; it drops from 56 KB to 23 KB.

Splitting is only safe because the mechanics are now enforced: an agent that
skips the reference still cannot push a contaminated commit, because forkctl
fails closed.

`.github/workflows/fork-only-view.yml` loses its copy of the stack invariants
and the standing-PR contract and calls `forkctl check-view` instead. Its
checkout now follows the triggering ref rather than a hard-coded branch, so the
workflow is exercisable from the branch that changes it, and the refs it
analyses are passed explicitly. It also runs the forkctl test suite.

`CONTRIBUTING.md` and the PR template stop restating the fork-only list and the
guard commands and point at forkctl.
@jdsika

jdsika commented Oct 26, 2026

Copy link
Copy Markdown
Author

Gate 2 — round 4 (delta review) response

All three findings are fixed in 9ef8fe24f. Suite is 111 tests, green; ruff check and
ruff format --check clean.

A (critical) — duplicate top-level on: key

workflow_branch_filters() read the first on: mapping and stopped, so a commit could append a
second one that drops the pull_request trigger while check-commit still reported OK.

Fixed by collecting all top-level on: indices and raising ForkctlError when there is more
than one. That propagates to the fail-closed handler in main(), so an ambiguous workflow is
refused rather than guessed at.

Covered by test_a_duplicate_top_level_on_key_is_refused (unit) and
test_a_duplicate_on_key_fails_check_commit_closed (end-to-end).

B (high) — moving an intact fork block — fixed differently from the proposal

The proposed fix was to scan the commit diff for added/removed marker lines. That does not
work
, and it is worth recording why. Moving the block from the end of AGENTS.md to the start
produces this diff:

@@ -1,2 +0,0 @@
-## Coding Standards
-explicit names
@@ -6,0 +5,2 @@ fork process
+## Coding Standards
+explicit names

Git chose to render the move as relocating the upstream text, because that is the smaller diff.
No marker line appears anywhere in the diff. Any guard built on "look for ± marker lines" is
unsound in principle, not just in this case — git is free to attribute a move to whichever side
minimises the output.

The fix is therefore representation-independent: malformed_fork_blocks() now enforces a
position invariant — the fork block must be a suffix of the file, i.e. nothing but whitespace
may follow fork:end. That matches the block's real shape in AGENTS.md and CLAUDE.md (one
appended block preceded by a blank line), it is already enforced on the fork base, on the stack tip
and per-commit, and it cannot be defeated by diff rendering because it reads the file content
directly.

Covered by test_moving_an_intact_fork_block_is_caught,
test_a_moved_block_leaves_the_marker_text_identical (demonstrates why marker-text comparison is
blind to a move), and test_an_edit_above_the_fork_block_is_still_allowed (false-positive guard —
editing upstream-owned text above the block stays legal, which is the common case).

moved_fork_markers() was written, proven unsound by the diff above, and removed;
changed_fork_markers() is back to its plain text comparison.

C (low) — CRLF separator rejected

smuggled_shared_changes() hard-coded "\n" as the blank-line separator between the upstream text
and the fork block, so a legitimate CRLF file failed the allowance. It now derives
separator = "\r\n" if "\r\n" in upstream else "\n".

Covered by test_a_crlf_fork_base_separator_is_accepted.

Round-3 carry-over

5 of 7 round-3 findings were confirmed fixed; the 2 marked PARTIAL are both subsumed by the
position invariant in B, which is strictly stronger than the per-finding fixes they asked for.

@jdsika

jdsika commented Oct 26, 2026

Copy link
Copy Markdown
Author

Applied by fixup into the fork base per the fork process (section 3.3), not merged.

  • integration/3.0 is now eddee28 (was b3fcee3)
  • new fork base: 2198c04 chore(fork): define the ASCS-eV fork process
  • fork-only/3.0 republished at 2198c04 in the same atomic push
  • forkctl check-stack passes against the new base; the transitional failure caused by REQUIRED_FORK_FILES not yet existing in the fork base is resolved, as expected.

Deviation to record: the section 3.3 recipe calls for make test before the force-push. The Makefile targets need a POSIX shell and a Linux .venv, neither of which exists on the machine this was applied from. The commit touches only .github/** and CONTRIBUTING.md — no simulation or Python package code — so make test would not have been meaningful evidence about it. Substituted: the full 111-test forkctl suite green locally, forkctl check-stack green on the folded stack, and pre-commit green in CI. The standing integration draft PR re-runs the real suite against the rewritten stack.

@jdsika jdsika closed this Oct 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

do-not-merge Never use the merge button; applied by fixup (3.3) or never merged at all do-not-mirror Fork-only change; must never be sent to Emerge-Lab/PufferDrive

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant