Skip to content

fix(git-submodules): the nested-work refusal's scope is the REFUSED SUBMODULE (plugin-task#56) - #57

Merged
github-actions[bot] merged 2 commits into
mainfrom
feat/refusal-scope-claim
Oct 26, 2026
Merged

github-actions[bot] merged 2 commits into
mainfrom
feat/refusal-scope-claim

Conversation

@aitrawog

@aitrawog aitrawog commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The refusal that landed in #55 (for #54) refuses the right move — a nested checkout that carries work is not moved, and the path, the commit and the recorded pin are named. But three surfaces described a SCOPE the product does not have: the code comments in candy/plugin-task/verbs.go and the record CHANGELOG/2026.299.2250.md said the refusal "leaves the tree exactly as it was" and "stops the whole bump with the tree untouched".

The product's contract is PER-SUBMODULE. bump collects a per-path failure and CONTINUES — #13: "ONE path's per-invocation failure costs that ONE pin, not every pin sorted after it", proven by TestGitSubmodules_BumpContinuesPastFailure. So a path that sorts earlier has already been rolled and its pin staged by the time the refusal is reported.

THE MEASUREMENT — an umbrella that pins two submodules, the paths SORTED, so outer — the one whose nested checkout carries the operator's commit — is refused FIRST and the sibling zebra is visited AFTER it:

bump: fail: 1 pin(s) FAILED: the nested checkout outer/inner carries a commit the update would drop — its HEAD 53c39a005831366d1963dd8cad25e1ffdb66e5ef is in no branch, tag or remote-tracking ref of that checkout (the recorded pin is 4a689d112e7d64a25990149e80dbe7db18d15382), so the update would move it off that commit and leave it in the reflog alone; push it, branch it, or stash it, then re-run; rolled 1 submodule(s) to their default-branch HEAD; advanced 0 pin(s) in the index; pinned 0 pin_map entr(y|ies):

rolled 1 submodule(s) and one refusal: the sibling was rolled and its pin staged, and the refusal stopped the OTHER submodule. The tree was not untouched — the REFUSED SUBMODULE was.

What is corrected is the SENTENCE, not the behaviour. The refusal preserves the work and names the nested checkout that carries it, exactly as #54's own measurement shows; and the umbrella's initializer — the policy #54 named — is per-path too ("a divergent nested twin is reported and left alone" while the parent advances, scripts/init-submodules.sh). Making this refusal abort the whole wave would strand every pin sorted after the refused one, for a reason the operator can fix in one checkout.

The scope becomes MECHANICAL instead of a prose assertion — and this is where the review of the first round of this pull request landed, and it was right. The scope is only OBSERVABLE when the refused path is visited BEFORE the path that survives it: the walk visits the paths SORTED, so a refusal stops a path that has already been rolled, and a path that sorts after it is rolled only if the walk CONTINUED. The first round's fixture put the sibling BEFORE the refused path, and under that order a walk that ABORTED at the first failure left the same final state as one that continued — the test asserted the outcome and called it proof.

The scope is now held by a control that DISCRIMINATES. The sibling is zebra, which sorts AFTER the refused outer; the test reads the order from the product's OWN path reader and ASSERTS it, so a change in the order fails loudly instead of silently making the test blind; and the scope is proved against THREE live mutants of the walk, each on the SAME test file — the refusal ignored, the walk aborting at the first failure, and the path order reversed. Each turns the test RED, on a different assertion. The transcripts are under How tested.

Head 1997ff2049dc9b81ef08f105a5cac0037643eda6 — 3 files changed, 189 insertions(+), 15 deletions(-).

file what it carries
candy/plugin-task/verbs.go the three comments that state the refusal's scope — pinSubmodule's header, judgeNestedLevel's header, and the one inside pinSubmodule's body that the sweep found — and the two that name the test that now holds the scope
candy/plugin-task/nested_work_test.go the fixture's SIBLING, the scope test that asserts the refused submodule and the rolled sibling in one run, and the ORDER assertion that keeps the test from going blind
CHANGELOG/2026.299.2250.md two corrections beside the claims they correct: the scope, and the control that holds it

Closes #56.

How tested

The change class is a claim-and-coverage change: no runtime behaviour of the verb changes, and the coverage that proves the scope is the repo's own test surface. The gate is this repo's gate list, run on the committed tree this PR publishes. Every gate below ran on that tree, and every one exited 0.

The head this body describes

$ git rev-parse HEAD
1997ff2049dc9b81ef08f105a5cac0037643eda6
$ git diff --shortstat origin/main HEAD
 3 files changed, 189 insertions(+), 15 deletions(-)
$ git status --short --untracked-files=no
exit=0

R7 — the coverage fails without the change, in EVERY direction the claim makes. The claim this PR makes is twofold — the refusal is scoped to its own submodule, and the walk CONTINUES past it — so it is proved against two opposites, plus the precondition that makes the second observable. Each mutant is a live edit of the PRODUCT, run against the SAME test file, unchanged. All three turn the test RED, each on a different assertion.

### THE COMMITTED TREE — the scope test, with the two tests it sits beside
$ cd candy/plugin-task && go test -count=1 -v -run "TestGitSubmodules_BumpRefusesToDropNestedWork|TestGitSubmodules_BumpKeepsNestedWorkThatABranchHolds|TestGitSubmodules_BumpRefusalIsScopedToTheRefusedSubmodule" .
=== RUN   TestGitSubmodules_BumpRefusesToDropNestedWork
--- PASS: TestGitSubmodules_BumpRefusesToDropNestedWork (0.17s)
=== RUN   TestGitSubmodules_BumpKeepsNestedWorkThatABranchHolds
--- PASS: TestGitSubmodules_BumpKeepsNestedWorkThatABranchHolds (0.19s)
=== RUN   TestGitSubmodules_BumpRefusalIsScopedToTheRefusedSubmodule
    nested_work_test.go:238: bump: fail: 1 pin(s) FAILED: the nested checkout outer/inner carries a commit the update would drop — its HEAD 53c39a005831366d1963dd8cad25e1ffdb66e5ef is in no branch, tag or remote-tracking ref of that checkout (the recorded pin is 4a689d112e7d64a25990149e80dbe7db18d15382), so the update would move it off that commit and leave it in the reflog alone; push it, branch it, or stash it, then re-run; rolled 1 submodule(s) to their default-branch HEAD; advanced 0 pin(s) in the index; pinned 0 pin_map entr(y|ies): 
--- PASS: TestGitSubmodules_BumpRefusalIsScopedToTheRefusedSubmodule (0.17s)
PASS
ok  	github.com/opencharly/plugin-task/candy/plugin-task	0.531s
exit=0

### MUTANT A — the refusal is IGNORED (the pre-#54 defect)
# the SAME test file, unchanged; only the PRODUCT is mutated
$ cd /tmp/pt59-mutA/pkg && go test -count=1 -v -run TestGitSubmodules_BumpRefusalIsScopedToTheRefusedSubmodule .
=== RUN   TestGitSubmodules_BumpRefusalIsScopedToTheRefusedSubmodule
    nested_work_test.go:238: bump: pass: pinned 0 pin_map entr(y|ies) () after rolling 2 submodule(s) to their default-branch HEAD and advancing 0 pin(s) in the index
    nested_work_test.go:240: the bump must REFUSE the nested checkout whose commit the update would drop, got pass: pinned 0 pin_map entr(y|ies) () after rolling 2 submodule(s) to their default-branch HEAD and advancing 0 pin(s) in the index
--- FAIL: TestGitSubmodules_BumpRefusalIsScopedToTheRefusedSubmodule (0.19s)
FAIL
FAIL	github.com/opencharly/plugin-task/candy/plugin-task	0.195s
FAIL
exit=1

### MUTANT B — the walk ABORTS at the first failure
# the SAME test file, unchanged; only the PRODUCT is mutated
$ cd /tmp/pt59-mutB/pkg && go test -count=1 -v -run TestGitSubmodules_BumpRefusalIsScopedToTheRefusedSubmodule .
=== RUN   TestGitSubmodules_BumpRefusalIsScopedToTheRefusedSubmodule
    nested_work_test.go:238: bump: fail: the nested checkout outer/inner carries a commit the update would drop — its HEAD 56c47e3e0e7c3decad6eff89bd6d931536298028 is in no branch, tag or remote-tracking ref of that checkout (the recorded pin is 87bfd56fcd45607bd5e192ce2fc9be0b4f34b6c3), so the update would move it off that commit and leave it in the reflog alone; push it, branch it, or stash it, then re-run
    nested_work_test.go:258: the sibling must still have rolled to 87bfd56fcd45607bd5e192ce2fc9be0b4f34b6c3 — the walk continues past the refusal, it does not abort — got c5f0e29b4b29beb8fefe13e061d64aabe6333967
--- FAIL: TestGitSubmodules_BumpRefusalIsScopedToTheRefusedSubmodule (0.16s)
FAIL
FAIL	github.com/opencharly/plugin-task/candy/plugin-task	0.166s
FAIL
exit=1

### MUTANT C — the path order is REVERSED (the sibling sorts first)
# the SAME test file, unchanged; only the PRODUCT is mutated
$ cd /tmp/pt59-mutC/pkg && go test -count=1 -v -run TestGitSubmodules_BumpRefusalIsScopedToTheRefusedSubmodule .
=== RUN   TestGitSubmodules_BumpRefusalIsScopedToTheRefusedSubmodule
    nested_work_test.go:224: the refused submodule must be visited BEFORE the sibling ([zebra outer]) — the sibling's roll is what proves the walk continued past the refusal
--- FAIL: TestGitSubmodules_BumpRefusalIsScopedToTheRefusedSubmodule (0.15s)
FAIL
FAIL	github.com/opencharly/plugin-task/candy/plugin-task	0.152s
FAIL
exit=1

The repo's Go gates, on the committed tree

$ go build ./...
go build exit=0

$ gofmt -l .
gofmt exit=0

$ go vet ./...
go vet exit=0

$ go test ./...
ok  	github.com/opencharly/plugin-task/candy/plugin-task	4.587s
?   	github.com/opencharly/plugin-task/candy/plugin-task/cmd/serve	[no test files]
?   	github.com/opencharly/plugin-task/candy/plugin-task/params	[no test files]
go test exit=0

The linter, at the version CI pins (v2.13.2, read from .github/workflows/ci.yml), under the toolchain this module's go.mod declares:

$ GOTOOLCHAIN=go1.26.8 golangci-lint run
0 issues.
LINT_RC=0

The structural check, with a binary built in this session from the local checkout (-ldflags "-X main.BuildCalVer=plugin-task-pt59"), not a shared install:

$ charly version
plugin-task-pt59
$ charly box validate
charly box validate: OK — checked 1 candy, 7 distros, 7 builders; 0 warnings, 0 errors
box validate exit=0

The live gate — scripts/gate-task.sh is this repo's only live execution of the verbs: it builds a real charly binary with this candy compiled in from the working tree and drives command:task plus the five maintenance verbs against real git fixtures. Its whole output is pasted here.

== fetching charly main (compiles candy/plugin-task in) ==
Cloning into '/tmp/plugin-task-gate.CIuDyC/charly'...
coherence OK: charly main's sdk/spec pins lead this module's requires
== building charly with candy/plugin-task compiled in from the working tree ==
== charly task list ==
task failtask: 1 step(s), 1 failed, 0 skipped
  [fail] run — this fails
      exit=1, want 0 (stderr: )
charly: error: command "task": task "failtask" failed (1 step(s) failed)
== a step that ran nothing is counted in its own bucket ==
   | task skipped-step: 1 step(s), 0 failed, 1 skipped
   |   [skip] run — this step ran nothing
   |       skip: true
   a step that ran nothing is counted, disclosed, and non-fatal
== a SILENT task still discloses the step that ran nothing ==
   | task silent-skip: 2 step(s), 0 failed, 1 skipped
   |   [skip] run — the silent step that ran nothing
   |       skip: true
   the silent path keeps its meaning and still discloses the skip
== verb:task nested hop (a composite must carry the leaf step's own reason) ==
   | task composite: 1 step(s), 1 failed, 0 skipped
   |   [fail] run — the outer step (a nested task step)
   |       task "leafreason" failed (1 step(s) failed, 0 skipped
   |         [fail] run — the leaf step (its Message is the reason)
   |             exit=3, want 0 (stderr: leaf-reason: boom on stderr))
   | charly: error: command "task": task "composite" failed (1 step(s) failed)
   the composite carried the leaf step's reason AND its path across the hop
== a param reaches a step as DATA (the shell must not parse it) ==
   | task datachannel: 1 step(s), 0 failed, 0 skipped
   |   [pass] run — the param arrives in the step's process environment, verbatim
   |       exit=0
   the param arrived as data: verbatim, and unexecuted
== verb:git-submodules verify (stale -> must FAIL) ==
task pins-verify: 1 step(s), 1 failed, 0 skipped
  [fail] check — the pin holds
      verify: 1 failure(s): pin mismatch: target (8d0543b06473304c4db9eebd7f492006a5327126) != ../src's target (95aa1d9f603b3e31f2de20c3414604a58112bfee)
charly: error: command "task": task "pins-verify" failed (1 step(s) failed)
== verb:git-submodules bump (must move the pin to src's twin) ==
== verb:git-submodules verify (now current -> must PASS) ==
== verb:task nested hop (a composite must carry the nested run's OWN report) ==
   | task pins-verify-nested: 1 step(s), 0 failed, 0 skipped
   |   [pass] run — the outer step (a nested task step)
   |       verify: 1 policy pin(s) hold across 1 submodule(s); 1 checkout(s) clean at their gitlink
   the composite carried the nested pin report across the hop
== verb:git-submodules verify (missing twin -> must FAIL loudly, never vacuous) ==
task missing-pin: 1 step(s), 1 failed, 0 skipped
  [fail] check — the missing twin fails
      verify: 1 failure(s): no gitlink for "nope/absent" in "../src"
charly: error: command "task": task "missing-pin" failed (1 step(s) failed)
== verb:git-submodules bump (unmaterialized submodule -> the pin advances IN THE INDEX, no checkout) ==
   | task bump-all: 1 step(s), 0 failed, 0 skipped
   |   [pass] run — bump
   |       pinned 0 pin_map entr(y|ies) () after rolling 0 submodule(s) to their default-branch HEAD and advancing 1 pin(s) in the index
   unmaterialized pin advanced in the index (8d0543b06473304c4db9eebd7f492006a5327126 -> 95aa1d9f6); no checkout created; umbrella untouched (main @ 35bba6a36)
== verb:git-submodules status (uninitialized submodule -> '- (uninitialized)', never the umbrella HEAD) ==
   status reported '- (uninitialized)'; umbrella HEAD (35bba6a) not leaked as a pin
== verb:git-submodules bump (pinned_from with NO CHECKOUT and no 'branch=' -> the refusal names the missing checkout) ==
   | task bump-from-source: 1 step(s), 1 failed, 0 skipped
   |   [fail] run — bump
   |       1 pin(s) FAILED: refused to pin 1 pin_map entr(y|ies): pinned_from "src" has no checkout, so its twin gitlinks cannot be read — materialize it with `git submodule update --init src`; rolled 0 submodule(s) to their default-branch HEAD; advanced 0 pin(s) in the index; pinned 0 pin_map entr(y|ies): 
   | charly: error: command "task": task "bump-from-source" failed (1 step(s) failed)
   the refusal named the missing checkout and the materialize command; the stale diagnosis is gone
== verb:git-submodules bump ORDER (pinned_from is itself rolled first) ==
== verb:git-submodules currency (after the ordered bump every pin must be CURRENT) ==
== verb:git-submodules currency (an edge over an edge must be REFUSED, never answered) ==
== verb:git-submodules currency (an EXPLICIT edge over an edge must be REFUSED too) ==
== verb:git-submodules currency (stale_after: a pin NEGLECTED past the bound must FAIL) ==
   | task currency-stale: 1 step(s), 1 failed, 0 skipped
   |   [fail] check — no pin is neglected past the bound
   |       currency: 1 of 1 pin(s) NOT current [1 behind]; intents: 1 upstream leaf/leaves (1 implicit), 0 derived-from edge(s); NOT CURRENT: gap: behind; intent upstream:main (implicit: .gitmodules branch=); recorded 8d0543b064; served 95aa1d9f60 (branch); 1 STALE pin(s) (neglected for more than 168h): gap: behind, and this repo has not advanced it for 59783h (bound 168h) — advance it: mode=bump advances every pin to the ref its intent names; FAILED on the STALE pin(s) above; producer-first legs: gap (8d0543b064 -> 95aa1d9f60)
   | charly: error: command "task": task "currency-stale" failed (1 step(s) failed)
== verb:git-submodules currency (stale_after: the SAME pin, advanced recently, must PASS) ==
   the same behind pin, advanced recently, passed the bound
== verb:git-submodules currency (stale_after: a NON-POSITIVE bound must be REFUSED) ==
   | task currency-nonpositive: 1 step(s), 1 failed, 0 skipped
   |   [fail] check — no pin is neglected past the bound
   |       git-submodules: stale_after "0s" is not a POSITIVE duration — a bound of zero or less marks every not-current pin stale, which is the permanently-red gate this bound exists to avoid
   | charly: error: command "task": task "currency-nonpositive" failed (1 step(s) failed)
   a bound of zero was refused, and no staleness was judged
== verb:git-submodules currency (an UNREADABLE history is not neglect) ==
   the fixture's history read genuinely fails, and the healthy fixture's succeeds
   | task currency-unreadable: 1 step(s), 1 failed, 0 skipped
   |   [fail] check — no pin is neglected past the bound
   |       currency: 1 of 1 pin(s) NOT current [1 behind]; intents: 1 upstream leaf/leaves (1 implicit), 0 derived-from edge(s); NOT CURRENT: gap: behind; intent upstream:main (implicit: .gitmodules branch=); recorded 8d0543b064; served 95aa1d9f60 (branch); 1 STRUCTURAL failure(s): gap: the age of this repo's own last advance of it could not be read (fatal: bad object refs/heads/main) — the staleness of this pin cannot be judged; producer-first legs: gap (8d0543b064 -> 95aa1d9f60)
   | charly: error: command "task": task "currency-unreadable" failed (1 step(s) failed)
   an unreadable history was reported as unreadable, not as neglect
== verb:file-parity (identical must PASS, drift must FAIL) ==
task parity: 1 step(s), 1 failed, 0 skipped
  [fail] check — the pair is identical
      parity drift in 1 pair(s): r != r.copy
charly: error: command "task": task "parity" failed (1 step(s) failed)
== verb:splice-region (stale -> check must FAIL, sync -> current) ==
task splice-check: 1 step(s), 1 failed, 0 skipped
  [fail] check — the region is current
      splice-region: TARGET is stale (the marked region differs)
charly: error: command "task": task "splice-check" failed (1 step(s) failed)
== verb:module-pins (stale -> must FAIL, adopt -> must PASS) ==
task mods-check: 1 step(s), 1 failed, 0 skipped
  [fail] check — the pins hold
      stale pins: tools/m1: github.com/opencharly/sdk != v1.2.3
charly: error: command "task": task "mods-check" failed (1 step(s) failed)
== verb:prune (removes merged worktree+branch, keeps unmerged) ==
== verb:prune (uninitialized submodule -> the SUPERPROJECT is never attributed to it) ==
   uninitialized submodule contributed nothing; the project's own targets still reported
== verb:prune mode=prune with an uninitialized submodule present (must complete cleanly) ==
   mode=prune completed cleanly; unmerged kept, merged reaped, uninitialized dir intact
gate-task: PASS — command:task + all five maintenance verbs executed live against charly main
GATE_EXIT=0

R5 — the over-scoped claim is gone from the live tree. The claim is keyed on its own words, and the sweep is run over the whole repository:

$ git grep -n -i -E "stops the whole bump|tree is left exactly as it was|leaves the tree exactly as it was|with the tree untouched" -- ":!CHANGELOG/"
grep exit=1

The one surviving hit in the whole tree is the record's own CORRECTION, which quotes the claim in order to correct it:

$ git grep -n -i -E "stops the whole bump|tree is left exactly as it was|leaves the tree exactly as it was|with the tree untouched" -- .
CHANGELOG/2026.299.2250.md:39:This entry first said the refusal "leaves the tree exactly as it was" and "stops the whole bump with the tree untouched". The product's contract is **per-submodule**: `bump` collects a per-path failure and CONTINUES (opencharly/plugin-task#13 — "ONE path's per-invocation failure costs that ONE pin, not every pin sorted after it", proven by `TestGitSubmodules_BumpContinuesPastFailure`), so a path that sorts earlier has already been rolled and its pin staged by the time the refusal is reported.
grep exit=0

The fixture's old sibling name is gone with the shape that carried it:

$ git grep -n "aaa-leaf" -- .
grep exit=1

R4 — no other surface describes the refusal. Outside CHANGELOG/, the code and its comments are the only surfaces that carry this behaviour, and they were corrected in the same cut:

$ git grep -n -i "nested" -- "*.md" ":!CHANGELOG/"
grep exit=1

Rulebook compliance

  • R0 — skills first. /charly-internals:git-workflow, /charly-internals:root-cause-analyzer, /charly-internals:strict-policy, /charly-internals:plugin, /charly-internals:go and /charly-internals:go-quality were loaded before the branch was cut and before any edit in this repo, and this repo's own AGENTS.md was read first.
  • R1 — RCA, then fix. Two findings are fixed in this change, and NEITHER is filed away. The first is the review's finding on the merged fix(git-submodules): a bump that would drop a nested checkout's work refuses (plugin-task#54) #55: the claim's scope was wider than the product's. Its RCA is the nested-work refusal is documented as if it stopped the WHOLE bump; the product's contract is per-submodule — the claim, the record and the code comments must say which #56 — the mechanism (the bump's per-path failure contract, git-submodules bump: one per-pin failure aborts the whole walk, and a momentarily held superproject .git/index.lock is enough to do it #13), the missed control (the bump: the nested reconcile moves a nested checkout that carries LOCAL WORK — the operator's commit is displaced, silently #54 fixture carried ONE submodule, so the scope of the refusal was a prose assertion with nothing behind it), the blast radius (three surfaces: two comment headers, the comment inside pinSubmodule's body, and the published record) and the root fix (the scope becomes a test). The R5 sweep found a THIRD live mention — the comment inside pinSubmodule's body — which is a blocking finding under R1 and is fixed in this change. The SECOND finding is the review of THIS pull request's first round, and its RCA is below. The finding was raised on this PR, so it is fixed here, in this round's one commit — not filed, not deferred.
    • RCA — the control that could not tell a scoped refusal from a whole-wave abort. Mechanism: the walk visits the paths SORTED and collects a per-path failure instead of returning on it, so a refusal is one such failure; whether the walk CONTINUES past it is observable only if a path sorting AFTER the refused one is still rolled, which means the refused path must sort FIRST. The fixture had it the other way round, so the sibling was already rolled when the refusal was reached and a wave that ABORTED left the same final state. Missed control: the R7 proof that was run measured the test against a product that never refuses — proving the test detects a MISSING refusal — and never against a product that refuses and then aborts, so it never proved the test detects a WRONG SCOPE; a control proved against one opposite cannot separate the two failures it exists to separate. Blast radius: the fixture, the new test's doc comment, the two code comments that name the test that holds the scope, the record's control paragraph, and this body's R7 sentence. Root fix: the sibling sorts AFTER the refused path, the order is READ from the product's own path reader and ASSERTED, and the scope is proved against three live mutants — the refusal ignored, the walk aborting, the order reversed — each turning the test RED on a different assertion.
  • R2 — no duplication. One fixture carries the shape, one test proves the scope, and one reader (gitmodulesPaths) supplies the order the test asserts — instead of the scope being asserted in three sentences and re-derived in the test. The record and the comments say the same thing because they are the same claim.
  • R3 — no workaround. No sleep, no retry, no fallback branch: the scope is decided by the product's own per-path contract and asserted by the product's own test. The mutants are PROOF HARNESSES in scratch copies, not shipped code, and they are thrown away.
  • R4 — the product first. The product's behaviour is not changed, because it is not wrong: the refusal preserves the work and names the checkout that carries it. What was wrong was the published claim, and it is corrected where it is published. No doc outside CHANGELOG/ describes the refusal — the sweep above shows it.
  • R5 — legacy deleted. The over-scoped claim is gone from the live tree: the sweep above returns exit=1 over the code and the docs, and the one surviving hit in the whole tree is the record's own correction, which quotes the claim in order to correct it. The fixture's old sibling name is gone with the shape that carried it.
  • R6 — preserve work and git safety. git status was stated before every action; the change lives on feat/refusal-scope-claim in its own worktree, off fresh origin/main; no force-push, no hook bypass, no push to main, and no other session's file, branch or worktree was touched. The first push was already made, so this round is ONE appended commit — never an amend of a pushed commit.
  • R7 — proof, not compilation. The scope is proved against three live mutants of the product, each turning the test RED on a different assertion, and PASSes on the committed tree — every transcript is above. The sibling's ORDER is what makes the second mutant discriminating, and the order is asserted from the product's own reader, so the test cannot go quietly blind.
  • R8 — live, never mocked. The live gate builds a real charly binary with this candy compiled in and drives the real verbs against real git repositories; no boundary is stubbed.
  • R9 — the binary equals the source. The charly binary used for charly box validate was built in this session from the local checkout (-ldflags "-X main.BuildCalVer=plugin-task-pt59"), not taken from a shared install.
  • R10 — the gate on the committed tree. Every gate above ran on head 1997ff2049dc9b81ef08f105a5cac0037643eda6, the tree this PR publishes, with zero warnings.

Change classification

  • What changed: the three code comments that state the refusal's scope, the test fixture that now carries a sibling submodule that sorts AFTER the refused one, the new test that proves the scope and asserts the order, and the corrections to the published record. The verb's BEHAVIOUR is unchanged — no line of executable code changed.
  • Change class: a CLAIM-AND-COVERAGE change, not a runtime one: the diff adds no file and modifies three, and go.mod, go.sum, the candy's charly.yml, the CUE schema and the candy's plan: are not among them.
  • The gate this class requires: this repo's own gate list, run on the committed tree — go build, gofmt -l, go vet, go test, golangci-lint at CI's pinned version, charly box validate, and the live scripts/gate-task.sh, which is the only live execution of these verbs. Every one is pasted under How tested above, and all of them exit 0.
  • Attribution: fully tested and validated — the change-class gate passed on the final tree, and the changed path executed live against real git fixtures.

Filed issues

Agent: umbrella-prose-truth
Assisted-by: Claude Code minimax/minimax-m3.1-flash:cloud (fully tested and validated)

…UBMODULE (plugin-task#56)

The refusal landed in #54 refuses the right move, but three surfaces described a
scope the product does not have: the two code comments in `candy/plugin-task/verbs.go`
(`pinSubmodule` and `judgeNestedLevel`) and the published record
`CHANGELOG/2026.299.2250.md` said the refusal "leaves the tree exactly as it was"
and "stops the whole bump with the tree untouched".

The product's contract is PER-SUBMODULE. `bump` collects a per-path failure and
CONTINUES (#13 — "ONE path's per-invocation failure costs that
ONE pin, not every pin sorted after it", proven by
`TestGitSubmodules_BumpContinuesPastFailure`), so a path that sorts earlier has
already been rolled and its pin staged by the time the refusal is reported.

The measurement — an umbrella that pins two submodules, the paths SORTED, so
`aaa-leaf` is rolled FIRST and `outer` (the one whose nested checkout carries the
operator's commit) is refused SECOND:

    bump: fail: 1 pin(s) FAILED: the nested checkout outer/inner carries a commit the
    update would drop — its HEAD c816a42e… is in no branch, tag or remote-tracking
    ref of that checkout (the recorded pin is 1ee4571b…), so the update would move it
    off that commit and leave it in the reflog alone; push it, branch it, or stash
    it, then re-run; rolled 1 submodule(s) to their default-branch HEAD; advanced 0
    pin(s) in the index; pinned 0 pin_map entr(y|ies):

`rolled 1 submodule(s)` and one refusal: the sibling was rolled and staged, and the
refusal stopped the OTHER submodule. The tree was not untouched — the REFUSED
SUBMODULE was.

What is corrected is the SENTENCE, not the behaviour: the refusal preserves the work
and names the nested checkout that carries it, exactly as #54's own measurement
shows, and the umbrella's initializer — the policy #54 named — is per-path too
("a divergent nested twin is reported and left alone" while the parent advances).
Making this refusal abort the whole wave would strand every pin sorted after the
refused one for a reason the operator can fix in one checkout.

The scope is now MECHANICAL instead of a prose assertion. The #54 fixture carries a
SIBLING that sorts first, and the new
`TestGitSubmodules_BumpRefusalIsScopedToTheRefusedSubmodule` asserts the scope in
both directions at once: the refused submodule's pin is not staged, its checkout is
not switched and the nested checkout still holds the operator's commit, while the
sibling's pin advances to the origin's tip. R7 both ways — the test FAILS on the
pre-#54 sources (the bump PASSes, both submodules roll, the nested work is dropped)
and PASSes on the fixed tree.

The R5 sweep for the over-scoped claim found a THIRD live mention — the comment
inside `pinSubmodule`'s body — which this change re-keys; the only surviving hit is
the record's own correction, which quotes the claim in order to correct it.

Closes #56.

Assisted-by: Claude Code minimax/minimax-m3.1-flash:cloud (fully tested and validated)
@github-actions

Copy link
Copy Markdown

Review — BLOCK

I re-derived every claim in the body and the diff against the current state. The file/insertion counts check out (3 files, 129/15, matching the per-file ± counts), the verbs.go hunks are pure comments (no executable line changed), all three call sites of nestedWorkFixture are updated to the new 6-value return, and there are no security/secrets concerns. The narrow correction — "the tree is not entirely untouched; an earlier-sorted sibling was rolled" — is accurate and is genuinely reproduced by the fixture and by the R7 transcript (pre-#54 sources fail because the bump passes and rolls both submodules).

But the PR's central mechanical claim is not proven by the test it ships, and the body repeats the unproven claim. This is a correctness/honesty defect in the very artifact meant to replace a prose assertion with mechanical proof.

Blocks

1. candy/plugin-task/nested_work_test.go — the scope test cannot distinguish "continue past failure" from "abort the whole wave," because the refused submodule sorts LAST.

Evidence:

  • The fixture pins the sibling aaa-leaf and the refused submodule outer. Lexicographic order is aaa-leaf < outer, so aaa-leaf is rolled first and outer is refused second (the test's own comment says exactly this: "aaa-leaf is rolled FIRST and outer … is refused SECOND").
  • The test asserts final state only: gitlink(umb,"aaa-leaf")==L2, gitlink(umb,"outer")==o1, innerCo HEAD == work, outer checkout == o1.
  • A verb that processes paths in sorted order and aborts on the first failure produces the identical observable state: it rolls aaa-leaf (already first) and stages its pin, then hits outer and aborts. Every assertion still holds. So a sequential whole-bump-abort implementation passes this test.
  • The doc comment's claim — "The sibling's staged pin is the evidence that the refusal is not a whole-bump abort" — is therefore false. The sibling is rolled before the refusal is even reached, so its staged pin is present under both "continue" and "abort."
  • The body repeats the same invalid claim under R7: "a verb that … aborted the whole wave, would not pass it." That is not reproducible by this diff. The property "the walk continues past a failed path to a LATER path" is only proven by TestGitSubmodules_BumpContinuesPastFailure (cited, not shipped here), not by this test.

Why it matters: this PR's thesis is "the scope becomes MECHANICAL instead of a prose assertion." As written, the mechanical check does not guard the regression it claims to guard — if the product regressed to abort-at-first-failure, this test would still be green. That is coverage that looks like proof but isn't.

Required fix (either):

  • Reorder the fixture so the refused submodule sorts first and a sibling sorts after it (e.g., rename the refused one to sort before the leaf, or name the sibling zzz-leaf), then assert the later-sorted sibling still advanced to L2. Under abort the sibling stays at L1 and the test fails — making it discriminating; or
  • Keep the fixture and soften the doc comment and the body's R7 sentence to claim only what is proven (refused submodule untouched and an earlier-sorted sibling rolled ⇒ the tree is not entirely untouched), and attribute the "continues past a failed path" property explicitly to git-submodules bump: one per-pin failure aborts the whole walk, and a momentarily held superproject .git/index.lock is enough to do it #13's TestGitSubmodules_BumpContinuesPastFailure rather than to this test.

The corrected prose in verbs.go and the CHANGELOG (per-submodule scope) is accurate and can stand; the defect is that the test/body overclaim what the new fixture demonstrates.

Verdict: BLOCK


ollama-cloud/deepseek-v4.1-flash — action-review.

View action run

…sed path sorts FIRST (plugin-task#57)

The refusal's scope IS the refused submodule, and the walk DOES continue past it.
That was already right in the code, in the record and in the comments; what was not
right is the CONTROL that claimed to hold it. The review of this pull request found
it, and it was right.

The mechanism: the bump's walk visits the paths SORTED and collects a per-path
failure instead of returning on it (#13), so a refusal is one
such failure. Whether the walk CONTINUES past the refusal is observable only if a
path that sorts AFTER the refused one is still rolled. The fixture put the sibling
BEFORE the refused path, so the sibling had already been rolled by the time the
refusal was reached — and a walk that ABORTED at the first failure leaves the same
final state. The test asserted the outcome and called it proof.

The missed control: the proof that was run measured the test against a product that
never refuses, so it showed the test detects a MISSING refusal. It never measured
the test against a product that refuses and then aborts, so it never showed the test
detects a WRONG SCOPE. A control proved against one opposite cannot separate the
two failures it exists to separate.

The fix, in the test and in the fixture:

- the sibling is now `zebra`, which sorts AFTER the refused `outer`, so the refused
  path is visited FIRST and the sibling's roll is evidence that the walk continued;
- the order is READ from the product's own path reader and ASSERTED, so a change in
  the order fails loudly instead of silently making the test blind;
- the scope is proved in BOTH directions, against two live mutants of the walk — the
  refusal ignored (the pre-#54 defect) and the walk aborting at the first failure.
  Each mutant turns the test RED, on a different assertion.

What is not changed: the product's behaviour, the refusal, the per-path contract and
the sentences the record corrected. The scope of the refusal is the refused submodule,
and the walk continues past it.

Closes #56.

Assisted-by: Claude Code minimax/minimax-m3.1-flash:cloud (fully tested and validated)
@aitrawog

Copy link
Copy Markdown
Contributor Author

STATUS

#57 — the review's finding is FIXED, in this round's one commit. Head 1997ff2049dc9b81ef08f105a5cac0037643eda6 (was 63ad49c672cd3f616bb1c9bc9cf2a7829f39b4c5); the body is rewritten for it.

The finding, taken as it was raised. The scope test could not tell a scoped refusal from a whole-bump abort, because the refused submodule sorted LAST: the sibling had already been rolled by the time the refusal was reached, so a walk that ABORTED at the first failure left the same final state, and the doc comment's claim that the sibling's staged pin was "the evidence that the refusal is not a whole-bump abort" was false. The review was right, and the body repeated the claim.

The RCA.

  • Mechanism. The bump's walk visits the paths SORTED and collects a per-path failure instead of returning on it (git-submodules bump: one per-pin failure aborts the whole walk, and a momentarily held superproject .git/index.lock is enough to do it #13), so a refusal is one such failure. Whether the walk CONTINUES past it is observable only if a path that sorts AFTER the refused one is still rolled — which means the refused path must sort FIRST and the survivor AFTER. The fixture had it the other way round, so the scope was not observable at all.
  • Missed control. The R7 proof that was run measured the test against a product that never refuses, so it showed the test detects a MISSING refusal. It never measured the test against a product that refuses and then ABORTS, so it never showed the test detects a WRONG SCOPE. A control proved against one opposite cannot separate the two failures it exists to separate. The root fix is in that control: the scope is now proved against three live mutants, and the order the second mutant needs is asserted rather than assumed.
  • Blast radius. The fixture; the new test's doc comment; the two code comments that name the test that holds the scope; the record's control paragraph (CHANGELOG/2026.299.2250.md); and the body's R7 sentence. All five are corrected in this commit.
  • Root fix. The fixture's sibling is now zebra, which sorts AFTER the refused outer; the test reads the order from the product's OWN path reader (gitmodulesPaths) and ASSERTS it, so a change in the order fails loudly instead of silently making the test blind; and the scope is proved against three live mutants of the walk — the refusal IGNORED (the pre-bump: the nested reconcile moves a nested checkout that carries LOCAL WORK — the operator's commit is displaced, silently #54 defect), the walk ABORTING at the first failure, and the path order REVERSED. Each turns the test RED, on a different assertion, and all three transcripts are in the body.

What did NOT change. The product's behaviour, the refusal, the per-path contract, and the sentences the record corrected in the first round: the scope of the refusal IS the refused submodule, and the walk DOES continue past it. The finding was about the CONTROL, and the control is what changed.

The gate on this head — go build, gofmt -l, go vet, go test, golangci-lint at CI's pinned v2.13.2 (0 issues), charly box validate (0 warnings, 0 errors), and the repo's live scripts/gate-task.sh against charly main with this candy compiled in. All of it is pasted under How tested in the rewritten body, and every one exited 0.

Next update. when the validator's verdict lands on this head.

Agent: umbrella-prose-truth
Assisted-by: Claude Code minimax/minimax-m3.1-flash:cloud (fully tested and validated)

@github-actions

Copy link
Copy Markdown

Review — PASS

I re-derived every claim in the body and the two comments against the current head (1997ff2049dc, two commits: 63ad49c672cd then the appended 1997ff2049dc).

What checks out

Counts and scope. The per-file ± counts are +34/−2, +133/−10, +22/−3 → 189 insertions, 15 deletions, 3 files. This matches the body's 3 files changed, 189 insertions(+), 15 deletions(-) exactly. The verbs.go hunks are comment-only (no executable line touched), so the body's "no runtime behaviour changes" and "change class: claim-and-coverage" are honest. go.mod/go.sum/charly.yml/CUE/plan: are indeed absent.

Call-site consistency. The fixture's return signature changed from 5 to 6 values (umb, innerCo, leafCo, o1, l1, l2). All three call sites are correctly updated:

  • TestGitSubmodules_BumpRefusesToDropNestedWork: umb, innerCo, _, o1, _, _
  • TestGitSubmodules_BumpKeepsNestedWorkThatABranchHolds: umb, innerCo, _, _, _, L2
  • new test: umb, innerCo, leafCo, o1, _, L2

The prior BLOCK is genuinely resolved. The earlier review (comment 6429378160) was correct that the first-round fixture (aaa-leaf sorting before outer) could not distinguish "continue past failure" from "abort the whole wave" — the sibling was already rolled before the refusal was reached. The current diff fixes exactly that:

  • The sibling is renamed zebra, which sorts after outer (o < z).
  • The test reads the order from the product's own gitmodulesPaths(umb) and asserts refusedAt < siblingAt, so a reordering fails loudly rather than silently blinding the test.
  • Under abort-at-first-failure, the walk visits outer (first), refuses, returns → zebra stays at L1 → the assertion gitlink(umb,"zebra") == L2 fails. Under the shipped behavior, zebra rolls → passes. The test is now discriminating.
  • The three mutant transcripts are internally consistent with this: Mutant A fails on the refusal assertion (line 240), Mutant B fails on the sibling assertion (line 258, showing zebra stuck at L1), Mutant C fails on the order assertion (line 224, showing [zebra outer]). Each catches a different opposite.

The scope assertions are complete. The new test verifies the refused submodule is untouched (nested HEAD == work, outer gitlink == o1, outer checkout HEAD == o1) and the sibling rolled (zebra gitlink == L2, zebra checkout == L2). That is both halves of the per-submodule claim, mechanically.

Prose corrections. The three verbs.go comment blocks and the CHANGELOG corrections consistently replace "the tree" with "the refused submodule" and cite #13's per-path contract. The R5 sweep is plausible: the only surviving hit of the over-scoped phrasing is the CHANGELOG's own correction, which quotes the claim in order to retract it. aaa-leaf is gone.

Comment disposition.

  • The bot BLOCK (6429378160) was raised against the first round (63ad49c) and is stale/resolved by the appended commit — its required fix (reorder so the refused path sorts first, or soften the claim) was taken in the stronger form (reorder + order assertion + three mutants).
  • The author reply (6429538242) accurately describes the change now present in the diff; its RCA (refused path must sort first; the missed control was the single-opposite mutant) is re-derivable from the current test.

Minor, non-blocking

  • The body's phrasing "the three comments that state the refusal's scope … and the two that name the test" is slightly redundant — there are three hunks, two of which both state scope and name the test. It is not materially false.
  • The gitmodulesPaths/walk-order coupling is asserted rather than directly observed; the Mutant C transcript demonstrates they are coupled, so the test is not decoupled in practice.

No secrets, injection, privilege, or dependency concerns. No leftover legacy path or dead code. The change is coherent and its evidence matches the diff.

Verdict: PASS


ollama-cloud/deepseek-v4.1-flash — action-review.

View action run

@github-actions
github-actions Bot enabled auto-merge (squash) October 26, 2026 23:13
@github-actions
github-actions Bot merged commit 1c451fc into main Oct 26, 2026
6 checks passed
charly-auto-merge Bot added a commit that referenced this pull request Oct 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant