From bd2515f848d836da3765430aa4bee0378e43fc18 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Sat, 26 Sep 2026 08:30:12 -0400 Subject: [PATCH 1/9] docs(plan): change 0459 implementation plan Docket-Plan-Path: docs/superpowers/plans/2026-09-26-worker-s-gate-drive-acknowledge-is-refused-scope-closed-afte.md --- ...cknowledge-is-refused-scope-closed-afte.md | 581 ++++++++++++++++++ 1 file changed, 581 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-26-worker-s-gate-drive-acknowledge-is-refused-scope-closed-afte.md diff --git a/docs/superpowers/plans/2026-09-26-worker-s-gate-drive-acknowledge-is-refused-scope-closed-afte.md b/docs/superpowers/plans/2026-09-26-worker-s-gate-drive-acknowledge-is-refused-scope-closed-afte.md new file mode 100644 index 000000000..7023355fb --- /dev/null +++ b/docs/superpowers/plans/2026-09-26-worker-s-gate-drive-acknowledge-is-refused-scope-closed-afte.md @@ -0,0 +1,581 @@ + +> ↩ **[Change 0459 — Worker's gate.drive.acknowledge is refused scope-closed after the parent claims its WAITING drive](https://github.com/danielhanold/docket/blob/docket/docs/changes/active/0459-worker-s-gate-drive-acknowledge-is-refused-scope-closed-afte.md)** + +# Worker scope-transferred refusal (change 0459) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A handed-off worker whose scope the parent claimed gets a distinct typed refusal, `scope-transferred`, on `gate.drive.acknowledge` and scoped `gate.drive.start` — and the worker/parent skill contracts stop steering a completed task into a false `BLOCKED`. + +**Architecture:** Keep the gate-drive authority model strict: `Claim` still closes the worker's scope, and the parent-side takeover path keeps `scope-closed`. Split the existing closed-scope refusals on the two child-capability operations by `FinalAcked`: a scope finished by its own terminal acknowledgement keeps `ErrScopeClosed`; a scope closed by a claim or takeover (`Closed && !FinalAcked`) returns a new `ErrScopeTransferred` whose app-layer message names the real state and never says "return BLOCKED". The worker contract (docket-build-task) learns that a post-handoff continuation reports on the verdict the continuation supplies and never touches the original scope; the parent contract (docket-build) makes the continuation carry that verdict plus a fresh scope bundle. Contract guards land in `internal/repoguard`. + +**Tech Stack:** Go (no new dependencies); the repo's own test suite via `go run ./cmd/docket development test`. + +**Spec:** `docs/superpowers/specs/2026-09-26-worker-s-gate-drive-acknowledge-is-refused-scope-closed-afte-design.md` (on the `docket` metadata branch; the synchronized copy is at `.docket/docs/superpowers/specs/…` from the repo root). + +## Global Constraints + +- Test suite: run through `go run ./cmd/docket development test` from the feature worktree — never a hand-rolled runner. Focused runs during TDD use `go test -count=1 ./internal// -run `; **every** mutation probe and manual re-verification passes `-count=1` (Go's test cache otherwise serves a pre-mutation verdict). +- **Unchanged by this change** (spec "Unchanged"): `Claim` still closes the scope; the parent-side takeover path (`takeover.go`, `takeoverClose`, the "second detached-crash takeover halts scope-closed" rule referenced in `internal/app/rungate_verdict.go`) keeps `ErrScopeClosed`; `bindScopeChange` keeps `ErrScopeClosed`; the `closeScopeFinal` race branch (`ownershipErr(ErrScopeClosed, "acknowledge-close")` in `acknowledge.go`) keeps `ErrScopeClosed` — the spec scopes the new kind to Acknowledge's closed-scope branch and the scoped-start admission checks only. +- Both new refusals write nothing (assert with the existing `assertUnchanged` byte-compare helpers). +- The result-vocabulary mapping for `scope-transferred` is `invalid-input` — this falls out of the existing generic `AsOwnershipError` branch in `mapDriveFailure` (`internal/app/gate_drive.go`); pin it with a test, do not add a special case. +- Prose guards: `internal/repoguard/prose_contracts_test.go`'s `scanProse` is raw `strings.Contains` over file bytes — every guarded phrase must sit on a single unwrapped line in the skill file. When editing SKILL.md prose, keep each phrase listed in Task 5's table intact on one line. +- Cross-reference comments anchor on symbol names or verbatim-quoted clauses, never line numbers (`TestCommentAnchorStyle`). +- Guards are code: each new repoguard row and each flipped/new assert gets mutation evidence (strip the guarded clause → red; restore → green). Take before/after counts through a whitespace-flattened copy when a phrase could wrap, and back up the working tree state before a mutation (`git stash` is forbidden mid-task — copy the file aside instead; `git checkout --` restores to HEAD, destroying uncommitted work). +- Point-in-time records (`docs/superpowers/plans/*`, `docs/results/*`, archived changes, Accepted ADRs) are history — never edit them, even where they mention `scope-closed`. + +## Review Focus + +1. **Byte-identical repeat of a successful acknowledgement** (FinalAcked scope, matching drive, owner-cleared terminal record) must still return the recorded document, not `scope-transferred` — pinned in Task 1's non-regression subtest. +2. **Wrong child capability on a claim-closed scope** must still refuse `scope-capability-mismatch` (capability is checked before the closed check in `Acknowledge` and in `scopeReserveRefusal`) — a transferred scope must not leak its state to an unauthenticated caller. Pinned in Task 2 Step 1's ordering subtest. +3. **Takeover racing a completed ack / second detached-crash takeover** must keep HALT cause `scope-closed` — proven by the existing `takeover_test.go` / `integration_takeover_test.go` / rungate second-takeover tests staying green unchanged (Task 3 Step 6 runs them explicitly). +4. **`bind-scope-change` on a claim-closed scope** must keep `ErrScopeClosed` — pinned in Task 2's Step 1 subtest (today no test pins this kind on a closed bind; deleting the `rec.Closed` branch or retyping it would otherwise go unnoticed). +5. **Message hygiene both ways**: the `scope-transferred` message must not contain "BLOCKED" and must name the fresh-scope next action; the reworded `scope-closed` message must no longer say "transferred". Pinned in Task 3's app-layer test. + +--- + +### Task 1: `ErrScopeTransferred` kind + Acknowledge closed-branch split + +**Files:** +- Modify: `internal/gatedrive/ownership.go` (the `OwnershipErrorKind` const block, after `ErrScopeClosed`) +- Modify: `internal/gatedrive/acknowledge.go` (the `if scope.Closed { … }` branch of `Driver.Acknowledge`) +- Test: `internal/gatedrive/acknowledge_test.go` (extend `TestAcknowledgeRefusals`), `internal/gatedrive/ownership_test.go` (extend `TestOwnershipKindSpellings`) + +**Interfaces:** +- Consumes: existing fixtures `startedScope`, `passObserveProc`, `readScopeBytes`, `readDriveBytes`, `assertUnchanged`, `isOwnershipKind`, `store.closeScope`, `overwriteDriveRecord`, `seedRecord`, `seedDrive` (all already in `internal/gatedrive` test files). +- Produces: `ErrScopeTransferred OwnershipErrorKind = "scope-transferred"` — Tasks 2 and 3 reference this exact identifier and wire spelling. + +- [ ] **Step 1: Write the failing tests** + +In `internal/gatedrive/acknowledge_test.go`, inside `TestAcknowledgeRefusals`, **replace** the existing subtest `t.Run("scope closed by claim or takeover", …)` (it currently expects `ErrScopeClosed`) with this pair: + +```go + t.Run("scope closed by claim or takeover is transferred", func(t *testing.T) { + d, store, grant, started, _ := startedScope(t, passObserveProc()) + if err := store.closeScope(grant.ScopeID); err != nil { + t.Fatalf("closeScope: %v", err) + } + scopeBytes := readScopeBytes(t, store, grant.ScopeID) + driveBytes := readDriveBytes(t, store, started.DriveID) + if _, err := d.Acknowledge(grant.ScopeID, grant.ChildCapability, started.DriveID, started.Generation); !isOwnershipKind(err, ErrScopeTransferred) { + t.Fatalf("a claim/takeover-closed scope (not final-acked) must reject ack ErrScopeTransferred, got %v", err) + } + assertUnchanged(t, store, grant.ScopeID, scopeBytes, started.DriveID, driveBytes) + }) + + t.Run("final-acked scope with a non-matching drive stays scope-closed", func(t *testing.T) { + d, store, grant, started, _ := startedScope(t, passObserveProc()) + if started.Outcome != PASSED { + t.Fatalf("want a PASSED current drive, got %s", started.Outcome) + } + // A NORMAL terminal acknowledgement closes the scope with FinalAcked. + if _, err := d.Acknowledge(grant.ScopeID, grant.ChildCapability, started.DriveID, started.Generation); err != nil { + t.Fatalf("terminal Acknowledge: %v", err) + } + // A separate durable drive: acknowledging it against the finished scope is + // the finished-scope refusal, never the transferred one. + other := seedRecord(t) + other.LastOutcome = PASSED + otherID, otherGen := seedDrive(t, store, other) + scopeBytes := readScopeBytes(t, store, grant.ScopeID) + otherBytes := readDriveBytes(t, store, otherID) + if _, err := d.Acknowledge(grant.ScopeID, grant.ChildCapability, otherID, otherGen); !isOwnershipKind(err, ErrScopeClosed) { + t.Fatalf("a FinalAcked scope must keep the scope-closed refusal, got %v", err) + } + assertUnchanged(t, store, grant.ScopeID, scopeBytes, otherID, otherBytes) + }) +``` + +Do **not** touch `TestAcknowledgeIdempotentRepeat` — it already pins Review Focus 1 (the byte-identical repeat still returns the recorded document); re-run it in Step 4 as the non-regression witness. + +In `internal/gatedrive/ownership_test.go`, extend the `cases` map in `TestOwnershipKindSpellings` with the two scope-terminal spellings (this is the reason-vocabulary enumeration surface — the app layer surfaces these tokens verbatim): + +```go + ErrScopeClosed: "scope-closed", + ErrScopeTransferred: "scope-transferred", +``` + +Also update that test's doc comment first line to say it pins the wire spellings of the sequential scope kinds **including the two closed-scope terminals** (keep the existing protocol-break rationale sentence). + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test -count=1 ./internal/gatedrive/ -run 'TestAcknowledgeRefusals|TestOwnershipKindSpellings'` +Expected: FAIL — `ErrScopeTransferred` undefined (compile error). That is the red for both. + +- [ ] **Step 3: Implement the kind and the branch split** + +In `internal/gatedrive/ownership.go`, immediately after the `ErrScopeClosed` const (keep its comment, but tighten it as shown so the two kinds partition the closed states): + +```go + // ErrScopeClosed: a transition was attempted on a scope already finished by + // its own terminal acknowledgement (Closed && FinalAcked). A closed scope is + // terminal. + ErrScopeClosed OwnershipErrorKind = "scope-closed" + // ErrScopeTransferred: a child-capability transition (an acknowledgement, or + // a scoped start) was attempted on a scope closed by a claim or takeover + // (Closed && !FinalAcked) — authority over the scope's drive moved to the + // parent, so the scope is no longer the worker's to acknowledge or reuse. + // Distinct from ErrScopeClosed so the refusal names the real state instead of + // directing a finished worker to report BLOCKED (change 0459). Parent-side + // paths (takeoverClose, bindScopeChange, closeScopeFinal's race branch) keep + // ErrScopeClosed. + ErrScopeTransferred OwnershipErrorKind = "scope-transferred" +``` + +(Replace the current two-line `ErrScopeClosed` comment "closed by a normal claim or an event-authorized takeover" — that sentence describes exactly the state that is now `ErrScopeTransferred`, so leaving it would make the comment lie.) + +In `internal/gatedrive/acknowledge.go`, rework the tail of the `if scope.Closed { … }` branch in `Driver.Acknowledge`. Today it ends with one `return DriveDoc{}, ownershipErr(ErrScopeClosed, "acknowledge")` covering every closed case. Replace that single return with: + +```go + if !scope.FinalAcked { + // Closed by a claim or takeover, not by a terminal acknowledgement: + // scope authority transferred to the parent (change 0459). + return DriveDoc{}, ownershipErr(ErrScopeTransferred, "acknowledge") + } + return DriveDoc{}, ownershipErr(ErrScopeClosed, "acknowledge") +``` + +so the branch reads: FinalAcked + matching drive + owner-cleared terminal → recorded document (unchanged); `!FinalAcked` → `ErrScopeTransferred`; every other closed case (FinalAcked with a non-matching drive or a non-terminal record) → `ErrScopeClosed`. Update the branch's leading comment: the sentence "A scope closed by a claim or takeover (FinalAcked false), or a non-matching drive, is a fail-closed ErrScopeClosed." becomes "A scope closed by a claim or takeover (FinalAcked false) is ErrScopeTransferred — authority moved to the parent; a FinalAcked scope with a non-matching drive is a fail-closed ErrScopeClosed." + +- [ ] **Step 4: Run the package tests** + +Run: `go test -count=1 ./internal/gatedrive/ -run 'TestAcknowledge'` +Expected: PASS — including `TestAcknowledgeIdempotentRepeat`, `TestAcknowledgeHappyPathClosesScope`, `TestAcknowledgeFailedFinalResult`, `TestAcknowledgePostRetirementOwnerGenAsymmetry` unchanged. +Then: `go test -count=1 ./internal/gatedrive/ -run 'TestOwnershipKindSpellings'` — PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/gatedrive/ownership.go internal/gatedrive/acknowledge.go internal/gatedrive/acknowledge_test.go internal/gatedrive/ownership_test.go +git commit -m "fix(gatedrive): claim-closed scope acknowledgement refuses scope-transferred (change 0459)" +``` + +--- + +### Task 2: Scoped start on a transferred scope + end-to-end regression + +**Files:** +- Modify: `internal/gatedrive/driver.go` (`precheckScopedStart`, its `if scope.Closed` check) +- Modify: `internal/gatedrive/scope.go` (`scopeReserveRefusal`, its `if rec.Closed` clause, plus the function's ordered-refusal doc comment) +- Modify: `internal/gatedrive/handoff_test.go` (`TestScopedWaitingHandoffClaimClosesScope`) +- Test: `internal/gatedrive/handoff_test.go` (new `TestClaimedScopeAcknowledgeAndStartAreTransferred`), `internal/gatedrive/scope_test.go` or `acknowledge_test.go` (bind/capability-order subtests) + +**Interfaces:** +- Consumes: `ErrScopeTransferred` from Task 1; existing fixtures in `handoff_test.go` (`fakeClock`, `startEpoch`, `OpenStore`, `testsupport.TempDir`, `fakeProc`, `obs`, `scopedTestDriver`, `stableGit`, `sampleStart`, `scopeReqFor`, `isOwnershipKind`) — copy the setup shape of `TestScopedWaitingHandoffClaimClosesScope`, which is in the same file. +- Produces: nothing new for later tasks; both scoped-start admission sites (`precheckScopedStart` and `scopeReserveRefusal`, the latter serving both `reserveScopeDrive` and `admitScopedWorktree`) return `ErrScopeTransferred` for `Closed && !FinalAcked`. + +- [ ] **Step 1: Write the failing tests** + +In `internal/gatedrive/handoff_test.go`, add the spec's end-to-end regression (spec Testing 1 + 2): handoff → claim → advance to `PASSED` → the worker's acknowledge is `ErrScopeTransferred` and writes nothing; a scoped start under the same scope is `ErrScopeTransferred` and launches nothing. Model the setup on `TestScopedWaitingHandoffClaimClosesScope` directly above it: + +```go +// TestClaimedScopeAcknowledgeAndStartAreTransferred is the change-0459 +// regression: a worker hands off its WAITING drive, the parent claims it and +// advances it to PASSED, and the returning worker's child-capability operations +// on the original scope — acknowledge, and a scoped start — are refused with the +// distinct ErrScopeTransferred (never the finished-scope ErrScopeClosed), with +// nothing written and nothing launched. Observed on change 0458 Task 2, where +// the old ErrScopeClosed refusal steered a finished worker into a false BLOCKED. +func TestClaimedScopeAcknowledgeAndStartAreTransferred(t *testing.T) { + clk := &fakeClock{now: startEpoch()} + store := OpenStore(testsupport.TempDir(t)) + running := true + proc := &fakeProc{ + observe: func(runDir string) (*process.Observation, error) { + if running { + return obs(process.StateRunning, runDir), nil + } + return obs(process.StatePassed, runDir), nil + }, + } + d := scopedTestDriver(store, clk, proc, stableGit()) + req := sampleStart() + grant, err := store.PrepareScope(scopeReqFor(req, "")) + if err != nil { + t.Fatalf("PrepareScope: %v", err) + } + req.ScopeID = grant.ScopeID + req.ChildCapability = grant.ChildCapability + + started, err := d.Start(req) + if err != nil { + t.Fatalf("Start: %v", err) + } + if started.Outcome != WAITING { + t.Fatalf("drive must WAIT, got %s (%s)", started.Outcome, started.Cause) + } + + // Worker hands off; parent claims and advances to the terminal PASSED. + handoff, err := d.Handoff(started.DriveID, started.Generation) + if err != nil { + t.Fatalf("Handoff: %v", err) + } + claimed, err := d.Claim(started.DriveID, handoff.Generation) + if err != nil { + t.Fatalf("Claim: %v", err) + } + running = false + final, err := d.Advance(started.DriveID, claimed.Generation) + if err != nil { + t.Fatalf("Advance: %v", err) + } + if final.Outcome != PASSED { + t.Fatalf("parent-driven drive must PASS, got %s (%s)", final.Outcome, final.Cause) + } + + // The returning worker's acknowledge on its original scope: transferred, no write. + scopeBytes := readScopeBytes(t, store, grant.ScopeID) + driveBytes := readDriveBytes(t, store, started.DriveID) + if _, err := d.Acknowledge(grant.ScopeID, grant.ChildCapability, started.DriveID, started.Generation); !isOwnershipKind(err, ErrScopeTransferred) { + t.Fatalf("acknowledge after a parent claim must be ErrScopeTransferred, got %v", err) + } + assertUnchanged(t, store, grant.ScopeID, scopeBytes, started.DriveID, driveBytes) + + // A scoped start under the same scope: transferred, nothing launched. + further := req + further.PredecessorDriveID = started.DriveID + further.PredecessorOwnerGen = claimed.Generation + launchesBefore := proc.launchN + if _, err := d.Start(further); !isOwnershipKind(err, ErrScopeTransferred) { + t.Fatalf("a scoped start under a claim-closed scope must be ErrScopeTransferred, got %v", err) + } + if proc.launchN != launchesBefore { + t.Fatalf("a transferred-scope start must not launch, launched %d->%d", launchesBefore, proc.launchN) + } +} +``` + +(If `assertUnchanged`/`readScopeBytes`/`readDriveBytes` are not visible from `handoff_test.go` — they live in `acknowledge_test.go`, same package, so they are — use them directly.) + +In `TestScopedWaitingHandoffClaimClosesScope` (same file), flip the further-successor expectation: change `!isOwnershipKind(err, ErrScopeClosed)` to `!isOwnershipKind(err, ErrScopeTransferred)` and reword its failure message to `"a successor start under a claimed (transferred) scope must fail ErrScopeTransferred, got %v"`. Update the test's doc-comment clause "is refused ErrScopeClosed" to "is refused ErrScopeTransferred (change 0459)". Leave `TestAcknowledgePostAckSuccessorRefused` untouched — it pins that a **FinalAcked** scope's successor start stays `ErrScopeClosed`. + +Add two ordering/non-regression subtests (put them in `acknowledge_test.go` beside `TestAcknowledgeRefusals`, as one new test function): + +```go +// TestTransferredScopeRefusalOrdering pins two boundaries of the change-0459 +// split: a wrong child capability on a claim-closed scope is still refused +// scope-capability-mismatch (a transferred scope leaks nothing to an +// unauthenticated caller), and bindScopeChange on a claim-closed scope keeps +// the parent-side ErrScopeClosed (the spec's "Unchanged" list). +func TestTransferredScopeRefusalOrdering(t *testing.T) { + t.Run("wrong capability outranks transferred", func(t *testing.T) { + d, store, grant, started, _ := startedScope(t, passObserveProc()) + if err := store.closeScope(grant.ScopeID); err != nil { + t.Fatalf("closeScope: %v", err) + } + if _, err := d.Acknowledge(grant.ScopeID, "wrong-capability", started.DriveID, started.Generation); !isOwnershipKind(err, ErrScopeCapabilityMismatch) { + t.Fatalf("wrong capability on a transferred scope must stay ErrScopeCapabilityMismatch, got %v", err) + } + }) + t.Run("bind-scope-change keeps scope-closed", func(t *testing.T) { + _, store, grant, _, _ := startedScope(t, passObserveProc()) + if err := store.closeScope(grant.ScopeID); err != nil { + t.Fatalf("closeScope: %v", err) + } + if err := store.bindScopeChange(grant.ScopeID, "0459"); !isOwnershipKind(err, ErrScopeClosed) { + t.Fatalf("bindScopeChange on a closed scope must keep ErrScopeClosed, got %v", err) + } + }) +} +``` + +(Check `startedScope`'s actual return signature at the top of `acknowledge_test.go` before writing — it returns five values in the existing subtests; if the scope request already binds a change id, bind the **same** id first or pick the idempotent path deliberately; the assert must exercise the `rec.Closed` clause, which is checked before the change-id clauses in `bindScopeChange`, so any id works.) + +- [ ] **Step 2: Run the tests to verify the red** + +Run: `go test -count=1 ./internal/gatedrive/ -run 'TestClaimedScopeAcknowledgeAndStartAreTransferred|TestScopedWaitingHandoffClaimClosesScope|TestTransferredScopeRefusalOrdering'` +Expected: `TestClaimedScopeAcknowledgeAndStartAreTransferred` FAILs on the scoped-start assertion (start still returns `ErrScopeClosed`; the acknowledge leg already passes from Task 1), `TestScopedWaitingHandoffClaimClosesScope` FAILs the same way, `TestTransferredScopeRefusalOrdering` PASSes (it pins current behavior — that is deliberate: it is a mutation tripwire, and Step 5 proves it can redden). + +- [ ] **Step 3: Implement the start-side split** + +In `internal/gatedrive/driver.go`, `precheckScopedStart`, replace: + +```go + if scope.Closed { + return ownershipErr(ErrScopeClosed, "start") + } +``` + +with: + +```go + if scope.Closed { + if !scope.FinalAcked { + // Closed by a claim or takeover: scope authority transferred to the + // parent, not finished by its own terminal acknowledgement (change 0459). + return ownershipErr(ErrScopeTransferred, "start") + } + return ownershipErr(ErrScopeClosed, "start") + } +``` + +In `internal/gatedrive/scope.go`, `scopeReserveRefusal`, replace: + +```go + if rec.Closed { + return ownershipErr(ErrScopeClosed, op) + } +``` + +with: + +```go + if rec.Closed { + if !rec.FinalAcked { + return ownershipErr(ErrScopeTransferred, op) + } + return ownershipErr(ErrScopeClosed, op) + } +``` + +and update the function's ordered-refusal doc comment bullet from "a closed scope is ErrScopeClosed;" to "a scope closed by its terminal acknowledgement is ErrScopeClosed, and one closed by a claim or takeover is ErrScopeTransferred;". `scopeReserveRefusal` serves both `reserveScopeDrive` (the locked authority) and `admitScopedWorktree` (driver.go's snapshot pre-check), so both agree by construction. Also update the stale comment in `scope.go` near the `Store.PrepareScope`/scope-lifecycle prose if it enumerates "a closed scope is ErrScopeClosed" (search the file for `ErrScopeClosed` mentions in comments and reword the ones describing the claim/takeover closure — `git grep -n "ErrScopeClosed" internal/gatedrive/scope.go`). + +- [ ] **Step 4: Run the package** + +Run: `go test -count=1 ./internal/gatedrive/` +Expected: PASS — including all of `takeover_test.go` and `integration_takeover_test.go` untouched and green (the parent-side takeover path still refuses/halts `scope-closed`; those tests double as the spec's Testing 4 proof). + +- [ ] **Step 5: Mutation-test the ordering tripwires** + +`TestTransferredScopeRefusalOrdering` was born green, so prove it can redden (assert-detects-removal, cached-runner rules): + +1. Copy `internal/gatedrive/acknowledge.go` and `internal/gatedrive/scope.go` aside (e.g. `cp internal/gatedrive/scope.go "${TMPDIR:-/tmp}/scope.go.bak.XXXX"` — actual `cp`, not stash). +2. Mutation A: in `Acknowledge`, move the `scope.Closed` check above the capability check → `wrong capability outranks transferred` must FAIL. Restore. +3. Mutation B: in `bindScopeChange`, change `ErrScopeClosed` to `ErrScopeTransferred` → `bind-scope-change keeps scope-closed` must FAIL. Restore. +4. Mutation C: in `scopeReserveRefusal`, delete the `!rec.FinalAcked` inner branch (always return `ErrScopeClosed`) → `TestScopedWaitingHandoffClaimClosesScope` and `TestClaimedScopeAcknowledgeAndStartAreTransferred` must FAIL. Restore. +Every probe runs with `-count=1`. Record the three red observations in the task notes. After restoring, re-run Step 4's command green. + +- [ ] **Step 6: Commit** + +```bash +git add internal/gatedrive/driver.go internal/gatedrive/scope.go internal/gatedrive/handoff_test.go internal/gatedrive/acknowledge_test.go +git commit -m "fix(gatedrive): scoped start on a claim-closed scope refuses scope-transferred (change 0459)" +``` + +--- + +### Task 3: App-layer messages and the JSON envelope + +**Files:** +- Modify: `internal/app/gate_drive.go` (`ownershipNextAction`) +- Test: `internal/app/gate_drive_test.go` (new test beside `TestAcknowledgeForwardsArgsAndMapsDoc`) + +**Interfaces:** +- Consumes: `gatedrive.ErrScopeTransferred` (Task 1); `newGateDriveService`, `fakeDriveEngine`, `OperationGateDriveAcknowledge` (existing test seam in `gate_drive_test.go` — see `TestAcknowledgeForwardsArgsAndMapsDoc` for the exact shape). +- Produces: the two message strings below, verbatim — Task 5's repoguard work does **not** guard them (they are pinned here, in Go tests). + +- [ ] **Step 1: Write the failing test** + +Add to `internal/app/gate_drive_test.go` (import `strings` if not already imported): + +```go +// TestAcknowledgeScopeTransferredEnvelope is the change-0459 app-layer pin: a +// scope-transferred ownership rejection surfaces reason "scope-transferred" +// under invalid-input, with a next-action message that names the real state +// (parent claimed/took over; report on the continuation's verdict; fresh scope +// for further tests) and never says BLOCKED — while the reworded scope-closed +// message keeps BLOCKED and drops the old "transferred or" wording. +func TestAcknowledgeScopeTransferredEnvelope(t *testing.T) { + bad := &fakeDriveEngine{err: &gatedrive.OwnershipError{Kind: gatedrive.ErrScopeTransferred, Op: "acknowledge"}} + svc := newGateDriveService(bad, 0, "", "") + got := svc.Acknowledge("sc-x", "childcap", "dx", "genx") + if got.Result != ResultInvalidInput || got.Drive != nil { + t.Fatalf("scope-transferred must map to invalid-input with no drive, got result=%s", got.Result) + } + if got.Reason != string(gatedrive.ErrScopeTransferred) { + t.Fatalf("reason = %q, want %q", got.Reason, string(gatedrive.ErrScopeTransferred)) + } + if strings.Contains(got.Message, "BLOCKED") { + t.Fatalf("the scope-transferred message must never direct the worker to BLOCKED, got %q", got.Message) + } + for _, want := range []string{"parent claimed or took over", "verdict your continuation supplied", "fresh scope"} { + if !strings.Contains(got.Message, want) { + t.Fatalf("scope-transferred message must contain %q, got %q", want, got.Message) + } + } + + // The finished-scope message: still directs BLOCKED, no longer claims a transfer. + closedMsg := ownershipNextAction(gatedrive.ErrScopeClosed) + if !strings.Contains(closedMsg, "BLOCKED") { + t.Fatalf("the scope-closed message must keep directing BLOCKED, got %q", closedMsg) + } + if strings.Contains(closedMsg, "transferred") { + t.Fatalf("the scope-closed message must no longer say transferred, got %q", closedMsg) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test -count=1 ./internal/app/ -run 'TestAcknowledgeScopeTransferredEnvelope'` +Expected: FAIL — `ownershipNextAction` has no `ErrScopeTransferred` case (empty Message), and the current `ErrScopeClosed` message contains "transferred". + +- [ ] **Step 3: Implement the two messages** + +In `internal/app/gate_drive.go`, `ownershipNextAction`, replace the `ErrScopeClosed` case and add the new one: + +```go + case gatedrive.ErrScopeClosed: + return "this scope was already finished by its terminal acknowledgement; stop and return BLOCKED" + case gatedrive.ErrScopeTransferred: + return "the parent claimed or took over this scope's drive; this scope is no longer yours — report on the verdict your continuation supplied, and run further tests only under a fresh scope" +``` + +(No change to `mapDriveFailure`: the generic `AsOwnershipError` branch already surfaces `scope-transferred` as `ResultInvalidInput` + the kind token, which is the spec's required result-vocabulary mapping.) + +- [ ] **Step 4: Run the app tests** + +Run: `go test -count=1 ./internal/app/ -run 'TestAcknowledge|TestStartForwards|TestTakeoverMapsDoc'` +Expected: PASS, including the pre-existing `TestAcknowledgeForwardsArgsAndMapsDoc` (it asserts only a non-empty message for `ErrScopeClosed`, so the reword keeps it green — verify, don't assume). + +- [ ] **Step 5: Sweep every `scope-closed` enumeration site (derive, never hand-list)** + +Run: `git grep -n "scope-closed" -- ':!docs/superpowers' ':!docs/results' ':!docs/changes'` and `git grep -rn "ErrScopeClosed"` from the worktree root. Sort the hits into executable vs prose: +- Executable sites already handled: `ownership.go`, `acknowledge.go`, `driver.go`, `scope.go`, `gate_drive.go` (this task), plus the tests updated in Tasks 1–2. +- Parent-side sites that must stay untouched: `takeover.go` (`haltDoc(… string(ErrScopeClosed))`, `takeoverClose`), `internal/app/rungate_verdict.go` (the second-takeover halt comment). +- Comment-only sites: reword any comment whose "closed by a claim or takeover → scope-closed" description is now false (Task 2 Step 3 covered `scope.go`; check `takeover.go`'s comment above `takeoverClose` — its scope-closed wording describes the parent path and stays TRUE, so leave it). +Confirm no schema/JSON/docs file outside point-in-time records enumerates the reason tokens (at authoring time the whole-repo grep found none — re-derive rather than trusting this sentence). If the sweep turns up a reason-member enumeration this plan missed (for example a docs/reference table or a JSON schema), add `scope-transferred` beside `scope-closed` there in this task. + +- [ ] **Step 6: Non-regression witnesses for the untouched parent path** + +Run: `go test -count=1 ./internal/gatedrive/ -run 'Takeover' && go test -count=1 ./internal/app/ -run 'RunGate'` +Expected: PASS with zero diffs to those test files in `git status` (spec Testing 4). + +- [ ] **Step 7: Commit** + +```bash +git add internal/app/gate_drive.go internal/app/gate_drive_test.go +git commit -m "fix(app): scope-transferred names the real state and drops BLOCKED from its message (change 0459)" +``` + +--- + +### Task 4: Worker and parent skill contracts + +**Files:** +- Modify: `skills/docket-build-task/SKILL.md` (the "**Sequential drives within your scope.**" paragraph's neighborhood) +- Modify: `skills/docket-build/SKILL.md` (the "## Task-level WAITING and the continuation" section) + +**Interfaces:** +- Consumes: the `scope-transferred` wire spelling (Task 1). +- Produces: the exact contract sentences Task 5's repoguard rows grep for — every phrase in Task 5's table must appear **verbatim and unwrapped on a single line** in these edits. Write these paragraphs with the guarded phrases on their own lines (do not let a re-wrap split them; the guard matcher is raw `strings.Contains`). + +- [ ] **Step 1: Worker contract (docket-build-task)** + +In `skills/docket-build-task/SKILL.md`, insert a new paragraph immediately **after** the "**Sequential drives within your scope.** …" paragraph (which ends "…keep the final drive id and verdict in `VERIFICATION`/`NOTES`."): + +```markdown +**Continued after a `WAITING` handoff — the original scope is no longer yours.** A worker that +performed `gate.drive.handoff` and returned `WAITING` surrendered its drive; the parent's `claim` +closed the scope, so when you are resumed or re-dispatched to continue that task you +never `acknowledge` the original scope and never start a drive on it. +A `scope-transferred` refusal means you misapplied this rule +— it is not an acknowledgement failure of an owned scope and it never means the work failed. +Report on the terminal verdict your continuation supplies +(the handed-off drive id and its `PASSED`/`FAILED` disposition) plus your own work: +`PASSED` with exactly one task commit → `COMPLETE`; `FAILED` → the existing repair discretion, and +never `COMPLETE` on that verdict. Any further test drive runs only under the fresh scope bundle the +continuation provides, under the normal sequential-drive rules above; with no fresh bundle you +cannot run tests — return `BLOCKED` naming +"continuation needs a fresh scope". +The rule that a failed acknowledgement returns `BLOCKED`, never `COMPLETE`, continues to bind the +scopes you still own, and the final drive id and verdict stay in `VERIFICATION`/`NOTES` as always. +``` + +Keep the five guarded phrases exactly as spelled in Task 5 Step 1's table, each intact on one physical line as shown. + +- [ ] **Step 2: Parent contract (docket-build)** + +In `skills/docket-build/SKILL.md`, in "## Task-level WAITING and the continuation", extend the paragraph that today reads "…When agent judgment is needed again, dispatch a fresh worker for the **same** task and worktree with an explicit continuation; a trusted `PASSED` is not re-driven for a changed transcript. …". After the sentence ending "…is not re-driven for a changed transcript.", insert: + +```markdown +The continuation — a same-agent resume or a fresh dispatch alike — must carry +the claimed drive's id, its terminal verdict, and an explicit statement that the original scope is closed +by your claim and must never be acknowledged or reused. When the continued task may still need test +drives, run `gate.drive.prepare-scope` again +for the same change, task, phase, branch, and worktree (and dispatch context) and include the new +start-ready scope bundle — child capability only; the parent capability stays in your notes, as for +any dispatch. Reading the continuation's return is unchanged: a `COMPLETE` is settled against git +state exactly as *Reading a worker's return* requires. +``` + +Again: the guarded phrases from Task 5 Step 1's table stay verbatim on single lines as shown. + +- [ ] **Step 3: Sanity-read both sections end to end** + +Re-read each edited section as a worker in an unknown consuming repo (learnings: distributed-body-has-no-local-repo): no sentence may be true only of the docket repo, and no aside may contradict a numbered rule in its own section. Specifically check the new worker paragraph does not contradict "After a first `WAITING` never `advance` or restart — the controller owns the drive" (it must read as its continuation) and that the closed-outcome vocabulary is respected — every prohibition names the enumerated return it maps to (learnings: prohibition-needs-a-return-value; here: `COMPLETE`, repair discretion, or `BLOCKED "continuation needs a fresh scope"`). + +- [ ] **Step 4: Commit** + +```bash +git add skills/docket-build-task/SKILL.md skills/docket-build/SKILL.md +git commit -m "docs(skills): post-handoff continuation contract — transferred scope is never acknowledged or reused (change 0459)" +``` + +--- + +### Task 5: Repoguard contract guards + mutation evidence + +**Files:** +- Modify: `internal/repoguard/prose_contracts_test.go` (append rows to the `proseContracts` table) + +**Interfaces:** +- Consumes: the exact sentences committed in Task 4. If Task 4's final wording drifted from this plan, repoint these phrases at the committed wording **in this task** — the row must match the file as committed, and each phrase must still be a distinctive, load-bearing clause bound to its claim (learnings: prose-guard-binds-phrase-to-claim), not a floating word. +- Produces: nothing further. + +- [ ] **Step 1: Add the guard rows** + +Append to the `proseContracts` table in `internal/repoguard/prose_contracts_test.go`: + +```go + // change 0459 — a handed-off worker's scope authority ends at the parent's + // claim: the worker contract forbids acknowledging or reusing the claim-closed + // scope, keys the outcome to the continuation's verdict, names the honest + // BLOCKED for a missing fresh bundle, and classifies scope-transferred as a + // misapplied-rule signal; the parent contract makes the continuation carry the + // verdict, the closed-scope statement, and a fresh prepare-scope bundle. + {sentinel: "change_0459_scope_transferred", file: "skills/docket-build-task/SKILL.md", + present: []string{ + "never `acknowledge` the original scope and never start a drive on it", + "A `scope-transferred` refusal means you misapplied this rule", + "Report on the terminal verdict your continuation supplies", + "never `COMPLETE` on that verdict", + "\"continuation needs a fresh scope\"", + }}, + {sentinel: "change_0459_scope_transferred", file: "skills/docket-build/SKILL.md", + present: []string{ + "the claimed drive's id, its terminal verdict, and an explicit statement that the original scope is closed", + "run `gate.drive.prepare-scope` again", + }}, +``` + +- [ ] **Step 2: Run the guard green** + +Run: `go test -count=1 ./internal/repoguard/ -run 'TestProseContracts'` +Expected: PASS (the population floor rises by 7; it is a `>=` floor, so no constant needs editing — verify the assertion still reads `checks < 40` and leave it). + +- [ ] **Step 3: Mutation-test each guard row (both files)** + +For each of the two edited skill files: copy the file aside (`cp "${TMPDIR:-/tmp}/skill.md.bak.XXXX"`), delete the entire new paragraph from Task 4, run `go test -count=1 ./internal/repoguard/ -run 'TestProseContracts'`, and confirm it FAILS naming the `change_0459_scope_transferred` sentinel and the missing phrases. Restore the file (`mv -f` the backup back), re-run green. Then one finer probe per file: reword a single guarded clause (e.g. change "never be acknowledged or reused" to "should not generally be reused") and confirm the guard reddens — proving the phrase is bound to the claim, not satisfied by leftover vocabulary. Restore, re-run green, and confirm `git status` shows only `prose_contracts_test.go` modified. Record all four red observations in the task notes. + +- [ ] **Step 4: Commit** + +```bash +git add internal/repoguard/prose_contracts_test.go +git commit -m "test(repoguard): guard the post-handoff continuation contract sentences (change 0459)" +``` + +--- + +### Task 6: Whole-suite gate + +**Files:** none (verification only) + +- [ ] **Step 1: Run the full suite from the feature worktree** + +Run: `go run ./cmd/docket development test` +Expected: SUITE PASS. Read the budget report even on green: a `BUDGET WATCH:` / `PARALLEL-SENSITIVE:` line is a screening finding to note; a `SERIAL CONFIRMED OVER BUDGET:` line must be acted on (serial-confirm per `tests/README.md`) before calling the gate met. + +- [ ] **Step 2: Verify the branch state** + +`git log --oneline` shows the five commits above on `fix/worker-s-gate-drive-acknowledge-is-refused-scope-closed-afte`; `git status` is clean. No file under `docs/superpowers/plans/` (other than this plan), `docs/results/`, or `docs/changes/` was modified. From d96cdef7ee8789441f68a66b45183a63419f71db Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Sat, 26 Sep 2026 08:36:43 -0400 Subject: [PATCH 2/9] fix(gatedrive): claim-closed scope acknowledgement refuses scope-transferred (change 0459) --- internal/gatedrive/acknowledge.go | 10 +++++++-- internal/gatedrive/acknowledge_test.go | 28 +++++++++++++++++++++++--- internal/gatedrive/ownership.go | 14 +++++++++++-- internal/gatedrive/ownership_test.go | 7 +++++-- internal/gatedrive/takeover_test.go | 9 +++++---- 5 files changed, 55 insertions(+), 13 deletions(-) diff --git a/internal/gatedrive/acknowledge.go b/internal/gatedrive/acknowledge.go index a7d632375..1fdbb3d4e 100644 --- a/internal/gatedrive/acknowledge.go +++ b/internal/gatedrive/acknowledge.go @@ -47,8 +47,9 @@ func (d *Driver) Acknowledge(scopeID, childCapability, driveID, ownerGen string) // successful acknowledgement: a scope that is Closed AND FinalAcked, still naming // this drive, whose drive record is already owner-cleared with a durable // PASSED/FAILED verdict, is the recorded terminal — return its document with no - // write. A scope closed by a claim or takeover (FinalAcked false), or a - // non-matching drive, is a fail-closed ErrScopeClosed. + // write. A scope closed by a claim or takeover (FinalAcked false) is + // ErrScopeTransferred — authority moved to the parent; a FinalAcked scope with a + // non-matching drive is a fail-closed ErrScopeClosed. // // Intentional ownerGen asymmetry (change 0405): unlike the normal path, this // branch does NOT verify the presented ownerGen — childCapability (checked above) @@ -73,6 +74,11 @@ func (d *Driver) Acknowledge(scopeID, childCapability, driveID, ownerGen string) return d.recordedDoc(driveID, ownerGen, rec), nil } } + if !scope.FinalAcked { + // Closed by a claim or takeover, not by a terminal acknowledgement: + // scope authority transferred to the parent (change 0459). + return DriveDoc{}, ownershipErr(ErrScopeTransferred, "acknowledge") + } return DriveDoc{}, ownershipErr(ErrScopeClosed, "acknowledge") } diff --git a/internal/gatedrive/acknowledge_test.go b/internal/gatedrive/acknowledge_test.go index bb9ce71cd..6cc6659d9 100644 --- a/internal/gatedrive/acknowledge_test.go +++ b/internal/gatedrive/acknowledge_test.go @@ -388,19 +388,41 @@ func TestAcknowledgeRefusals(t *testing.T) { assertUnchanged(t, store, grant.ScopeID, scopeBytes, started.DriveID, driveBytes) }) - t.Run("scope closed by claim or takeover", func(t *testing.T) { + t.Run("scope closed by claim or takeover is transferred", func(t *testing.T) { d, store, grant, started, _ := startedScope(t, passObserveProc()) if err := store.closeScope(grant.ScopeID); err != nil { t.Fatalf("closeScope: %v", err) } scopeBytes := readScopeBytes(t, store, grant.ScopeID) driveBytes := readDriveBytes(t, store, started.DriveID) - if _, err := d.Acknowledge(grant.ScopeID, grant.ChildCapability, started.DriveID, started.Generation); !isOwnershipKind(err, ErrScopeClosed) { - t.Fatalf("a scope closed (not final-acked) must reject ack ErrScopeClosed, got %v", err) + if _, err := d.Acknowledge(grant.ScopeID, grant.ChildCapability, started.DriveID, started.Generation); !isOwnershipKind(err, ErrScopeTransferred) { + t.Fatalf("a claim/takeover-closed scope (not final-acked) must reject ack ErrScopeTransferred, got %v", err) } assertUnchanged(t, store, grant.ScopeID, scopeBytes, started.DriveID, driveBytes) }) + t.Run("final-acked scope with a non-matching drive stays scope-closed", func(t *testing.T) { + d, store, grant, started, _ := startedScope(t, passObserveProc()) + if started.Outcome != PASSED { + t.Fatalf("want a PASSED current drive, got %s", started.Outcome) + } + // A NORMAL terminal acknowledgement closes the scope with FinalAcked. + if _, err := d.Acknowledge(grant.ScopeID, grant.ChildCapability, started.DriveID, started.Generation); err != nil { + t.Fatalf("terminal Acknowledge: %v", err) + } + // A separate durable drive: acknowledging it against the finished scope is + // the finished-scope refusal, never the transferred one. + other := seedRecord(t) + other.LastOutcome = PASSED + otherID, otherGen := seedDrive(t, store, other) + scopeBytes := readScopeBytes(t, store, grant.ScopeID) + otherBytes := readDriveBytes(t, store, otherID) + if _, err := d.Acknowledge(grant.ScopeID, grant.ChildCapability, otherID, otherGen); !isOwnershipKind(err, ErrScopeClosed) { + t.Fatalf("a FinalAcked scope must keep the scope-closed refusal, got %v", err) + } + assertUnchanged(t, store, grant.ScopeID, scopeBytes, otherID, otherBytes) + }) + t.Run("reserved slot", func(t *testing.T) { clk := &fakeClock{now: startEpoch()} store := OpenStore(testsupport.TempDir(t)) diff --git a/internal/gatedrive/ownership.go b/internal/gatedrive/ownership.go index 910f894ac..48d66ce25 100644 --- a/internal/gatedrive/ownership.go +++ b/internal/gatedrive/ownership.go @@ -74,9 +74,19 @@ const ( // opening a second drive. A receipt-less start is refused rather than // overwriting the current one. ErrScopeSecondDrive OwnershipErrorKind = "scope-second-live-drive" - // ErrScopeClosed: a transition was attempted on a scope already closed by a - // normal claim or an event-authorized takeover. A closed scope is terminal. + // ErrScopeClosed: a transition was attempted on a scope already finished by + // its own terminal acknowledgement (Closed && FinalAcked). A closed scope is + // terminal. ErrScopeClosed OwnershipErrorKind = "scope-closed" + // ErrScopeTransferred: a child-capability transition (an acknowledgement, or + // a scoped start) was attempted on a scope closed by a claim or takeover + // (Closed && !FinalAcked) — authority over the scope's drive moved to the + // parent, so the scope is no longer the worker's to acknowledge or reuse. + // Distinct from ErrScopeClosed so the refusal names the real state instead of + // directing a finished worker to report BLOCKED (change 0459). Parent-side + // paths (takeoverClose, bindScopeChange, closeScopeFinal's race branch) keep + // ErrScopeClosed. + ErrScopeTransferred OwnershipErrorKind = "scope-transferred" // ErrScopeIdentityMismatch: a scope's identity (its bound change, or an // identity field a takeover re-verifies) no longer matches what the caller // presented — e.g. rebinding a scope to a different change. Fail closed. diff --git a/internal/gatedrive/ownership_test.go b/internal/gatedrive/ownership_test.go index 2c7d9da38..bb7768df7 100644 --- a/internal/gatedrive/ownership_test.go +++ b/internal/gatedrive/ownership_test.go @@ -38,8 +38,9 @@ func newHandedOffDrive(t *testing.T) (s *Store, id, oldOwner string, receipt han return s, id, oldOwner, receipt } -// TestOwnershipKindSpellings pins the exact wire spellings of the four sequential -// scope kinds Task 1 adds: later tasks return these and the app-layer mapping +// TestOwnershipKindSpellings pins the exact wire spellings of the sequential scope +// kinds, including the two closed-scope terminals (scope-closed, scope-transferred): +// later tasks return these and the app-layer mapping // surfaces them verbatim, so a rename here is a protocol break the guard catches. func TestOwnershipKindSpellings(t *testing.T) { cases := map[OwnershipErrorKind]string{ @@ -47,6 +48,8 @@ func TestOwnershipKindSpellings(t *testing.T) { ErrPredecessorNotReusable: "predecessor-not-reusable", ErrUnresolvedLaunchTransition: "unresolved-launch-transition", ErrScopeBusy: "scope-busy", + ErrScopeClosed: "scope-closed", + ErrScopeTransferred: "scope-transferred", } for kind, want := range cases { if string(kind) != want { diff --git a/internal/gatedrive/takeover_test.go b/internal/gatedrive/takeover_test.go index e41a5ad62..8a4fcd43d 100644 --- a/internal/gatedrive/takeover_test.go +++ b/internal/gatedrive/takeover_test.go @@ -1112,11 +1112,12 @@ func TestTakeoverRaceVsFinalAcknowledge(t *testing.T) { t.Fatalf("the takeover must win, got HALTED %s", took.Cause) } // The final acknowledgement can no longer consume this scope: the takeover - // closed it (Closed, not FinalAcked), so the ack is refused ErrScopeClosed and - // writes nothing — the takeover, not the ack, owns this terminal transition. + // closed it (Closed, not FinalAcked), so the ack is refused ErrScopeTransferred + // (change 0459: authority moved to the parent) and writes nothing — the + // takeover, not the ack, owns this terminal transition. scopeBytes := readScopeBytes(t, store, grant.ScopeID) - if _, aerr := d.Acknowledge(grant.ScopeID, grant.ChildCapability, second.DriveID, second.Generation); !isOwnershipKind(aerr, ErrScopeClosed) { - t.Fatalf("an ack after a takeover closed the scope must fail ErrScopeClosed, got %v", aerr) + if _, aerr := d.Acknowledge(grant.ScopeID, grant.ChildCapability, second.DriveID, second.Generation); !isOwnershipKind(aerr, ErrScopeTransferred) { + t.Fatalf("an ack after a takeover closed the scope must fail ErrScopeTransferred, got %v", aerr) } if string(scopeBytes) != string(readScopeBytes(t, store, grant.ScopeID)) { t.Fatalf("a refused ack must not rewrite the scope record") From 6e94d6491df10b0157549add59bfc0ba7c0c3eef Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Sat, 26 Sep 2026 08:43:26 -0400 Subject: [PATCH 3/9] fix(gatedrive): scoped start on a claim-closed scope refuses scope-transferred (change 0459) --- internal/gatedrive/acknowledge_test.go | 26 ++++++ .../gatedrive/admission_successor_test.go | 9 ++- internal/gatedrive/driver.go | 5 ++ internal/gatedrive/driver_concurrency_test.go | 17 +++- internal/gatedrive/driver_test.go | 6 +- internal/gatedrive/handoff_test.go | 81 ++++++++++++++++++- internal/gatedrive/scope.go | 6 +- internal/gatedrive/scope_test.go | 29 ++++++- internal/gatedrive/takeover_test.go | 8 +- 9 files changed, 165 insertions(+), 22 deletions(-) diff --git a/internal/gatedrive/acknowledge_test.go b/internal/gatedrive/acknowledge_test.go index 6cc6659d9..fed64aa6d 100644 --- a/internal/gatedrive/acknowledge_test.go +++ b/internal/gatedrive/acknowledge_test.go @@ -295,6 +295,32 @@ func TestAcknowledgePostRetirementOwnerGenAsymmetry(t *testing.T) { }) } +// TestTransferredScopeRefusalOrdering pins two boundaries of the change-0459 +// split: a wrong child capability on a claim-closed scope is still refused +// scope-capability-mismatch (a transferred scope leaks nothing to an +// unauthenticated caller), and bindScopeChange on a claim-closed scope keeps +// the parent-side ErrScopeClosed (the spec's "Unchanged" list). +func TestTransferredScopeRefusalOrdering(t *testing.T) { + t.Run("wrong capability outranks transferred", func(t *testing.T) { + d, store, grant, started, _ := startedScope(t, passObserveProc()) + if err := store.closeScope(grant.ScopeID); err != nil { + t.Fatalf("closeScope: %v", err) + } + if _, err := d.Acknowledge(grant.ScopeID, "wrong-capability", started.DriveID, started.Generation); !isOwnershipKind(err, ErrScopeCapabilityMismatch) { + t.Fatalf("wrong capability on a transferred scope must stay ErrScopeCapabilityMismatch, got %v", err) + } + }) + t.Run("bind-scope-change keeps scope-closed", func(t *testing.T) { + _, store, grant, _, _ := startedScope(t, passObserveProc()) + if err := store.closeScope(grant.ScopeID); err != nil { + t.Fatalf("closeScope: %v", err) + } + if err := store.bindScopeChange(grant.ScopeID, "0459"); !isOwnershipKind(err, ErrScopeClosed) { + t.Fatalf("bindScopeChange on a closed scope must keep ErrScopeClosed, got %v", err) + } + }) +} + // TestAcknowledgeRefusals reproduces the acknowledgement half of spec verification // 4: every wrong-credential, wrong-drive, non-terminal, or mid-transition // acknowledgement is a typed rejection that writes NOTHING (asserted by a diff --git a/internal/gatedrive/admission_successor_test.go b/internal/gatedrive/admission_successor_test.go index 65751f812..e76650e1c 100644 --- a/internal/gatedrive/admission_successor_test.go +++ b/internal/gatedrive/admission_successor_test.go @@ -218,8 +218,9 @@ func TestLatePredecessorReleaseCannotFreeSuccessor(t *testing.T) { // // A FRESH receipt whose scope closes after the rotation but before the scope // reservation (modelled through scopedAdmissionHook, which fires between worktree -// admission and reserveScopeDrive) is refused ErrScopeClosed by the -// reserveScopeDrive authority: the rotated slot must be released, and the scope +// admission and reserveScopeDrive) is refused ErrScopeTransferred by the +// reserveScopeDrive authority (a claim/takeover-style close, not FinalAcked — +// change 0459): the rotated slot must be released, and the scope // record stays byte-unchanged by the failed reservation. (A scope already closed // when the guard reads it is refused before the rotation — // TestSameScopeSuccessorGuardAppliesWholeReservePredicate.) @@ -297,8 +298,8 @@ func TestSuccessorAdmissionFailureLegsReleaseRotatedSlot(t *testing.T) { fresh := req fresh.PredecessorDriveID = cur.DriveID fresh.PredecessorOwnerGen = cur.Generation - if _, serr := d.Start(fresh); !isOwnership(serr, ErrScopeClosed) { - t.Fatalf("a successor whose scope closed mid-admission must be refused ErrScopeClosed, got %v", serr) + if _, serr := d.Start(fresh); !isOwnership(serr, ErrScopeTransferred) { + t.Fatalf("a successor whose scope closed (not final-acked) mid-admission must be refused ErrScopeTransferred, got %v", serr) } if proc.launchN != launchesBefore { t.Fatalf("a refused successor must never launch, launched %d->%d", launchesBefore, proc.launchN) diff --git a/internal/gatedrive/driver.go b/internal/gatedrive/driver.go index 42cf13c5a..4ca6b3fd8 100644 --- a/internal/gatedrive/driver.go +++ b/internal/gatedrive/driver.go @@ -679,6 +679,11 @@ func (d *Driver) precheckScopedStart(req StartRequest) error { return err } if scope.Closed { + if !scope.FinalAcked { + // Closed by a claim or takeover: scope authority transferred to the + // parent, not finished by its own terminal acknowledgement (change 0459). + return ownershipErr(ErrScopeTransferred, "start") + } return ownershipErr(ErrScopeClosed, "start") } if req.ChildCapability == "" || scope.ChildCapHash != capHash(req.ChildCapability) { diff --git a/internal/gatedrive/driver_concurrency_test.go b/internal/gatedrive/driver_concurrency_test.go index d0619e46a..db472bfc7 100644 --- a/internal/gatedrive/driver_concurrency_test.go +++ b/internal/gatedrive/driver_concurrency_test.go @@ -1708,10 +1708,21 @@ func TestSameScopeSuccessorGuardAppliesWholeReservePredicate(t *testing.T) { want OwnershipErrorKind }{ { - name: "closed scope with a stale receipt is ErrScopeClosed", + // A claim/takeover-style close (not FinalAcked) is transferred (change 0459). + name: "transferred scope with a stale receipt is ErrScopeTransferred", mutate: func(t *testing.T, store *Store, req *StartRequest, _ string) { setScope(t, store, req.ScopeID, func(rec *scopeRecord) { rec.Closed = true }) }, + want: ErrScopeTransferred, + }, + { + name: "final-acked closed scope with a stale receipt is ErrScopeClosed", + mutate: func(t *testing.T, store *Store, req *StartRequest, _ string) { + setScope(t, store, req.ScopeID, func(rec *scopeRecord) { + rec.Closed = true + rec.FinalAcked = true + }) + }, want: ErrScopeClosed, }, { @@ -1747,13 +1758,13 @@ func TestSameScopeSuccessorGuardAppliesWholeReservePredicate(t *testing.T) { want: ErrStalePredecessor, }, { - name: "closed scope with a receipt naming the current drive is ErrScopeClosed before rotation", + name: "transferred scope with a receipt naming the current drive is ErrScopeTransferred before rotation", mutate: func(t *testing.T, store *Store, req *StartRequest, s1ID string) { setScope(t, store, req.ScopeID, func(rec *scopeRecord) { rec.Closed = true }) req.PredecessorDriveID = s1ID req.PredecessorOwnerGen = "any-generation" }, - want: ErrScopeClosed, + want: ErrScopeTransferred, }, } for _, tc := range cases { diff --git a/internal/gatedrive/driver_test.go b/internal/gatedrive/driver_test.go index f42232d89..61e1e8258 100644 --- a/internal/gatedrive/driver_test.go +++ b/internal/gatedrive/driver_test.go @@ -2026,7 +2026,7 @@ func TestScopedSuccessorPredecessorStateRejections(t *testing.T) { } }) - t.Run("closed scope", func(t *testing.T) { + t.Run("transferred scope", func(t *testing.T) { clk := &fakeClock{now: startEpoch()} store := OpenStore(testsupport.TempDir(t)) proc := passObserveProc() @@ -2043,8 +2043,8 @@ func TestScopedSuccessorPredecessorStateRejections(t *testing.T) { succ.PredecessorDriveID = first.DriveID succ.PredecessorOwnerGen = first.Generation launchesBefore := proc.launchN - if _, err := d.Start(succ); !isOwnershipKind(err, ErrScopeClosed) { - t.Fatalf("a closed scope must reject a successor ErrScopeClosed, got %v", err) + if _, err := d.Start(succ); !isOwnershipKind(err, ErrScopeTransferred) { + t.Fatalf("a claim/takeover-closed scope must reject a successor ErrScopeTransferred (change 0459), got %v", err) } if proc.launchN != launchesBefore { t.Fatalf("no launch on a rejected successor") diff --git a/internal/gatedrive/handoff_test.go b/internal/gatedrive/handoff_test.go index 0ccda387e..0a563c3e0 100644 --- a/internal/gatedrive/handoff_test.go +++ b/internal/gatedrive/handoff_test.go @@ -310,7 +310,7 @@ func porcelain(t *testing.T, repo string) string { // path mid-sequence (spec verification 7): after a completed predecessor, the // current WAITING successor is handed off and cooperatively claimed; Claim closes // the child's scope (later worker dispatches get fresh scopes), so a further -// successor start is refused ErrScopeClosed and the child's original owner +// successor start is refused ErrScopeTransferred (change 0459) and the child's original owner // generation is dead. func TestScopedWaitingHandoffClaimClosesScope(t *testing.T) { clk := &fakeClock{now: startEpoch()} @@ -378,8 +378,8 @@ func TestScopedWaitingHandoffClaimClosesScope(t *testing.T) { further.PredecessorDriveID = second.DriveID further.PredecessorOwnerGen = claimed.Generation launchesBefore := proc.launchN - if _, err := d.Start(further); !isOwnershipKind(err, ErrScopeClosed) { - t.Fatalf("a successor start under a claimed (closed) scope must fail ErrScopeClosed, got %v", err) + if _, err := d.Start(further); !isOwnershipKind(err, ErrScopeTransferred) { + t.Fatalf("a successor start under a claimed (transferred) scope must fail ErrScopeTransferred, got %v", err) } if proc.launchN != launchesBefore { t.Fatalf("a rejected successor start must not launch, launched %d->%d", launchesBefore, proc.launchN) @@ -394,3 +394,78 @@ func TestScopedWaitingHandoffClaimClosesScope(t *testing.T) { t.Fatalf("the child's original owner must be dead after handoff/claim, got %s", stale.Outcome) } } + +// TestClaimedScopeAcknowledgeAndStartAreTransferred is the change-0459 +// regression: a worker hands off its WAITING drive, the parent claims it and +// advances it to PASSED, and the returning worker's child-capability operations +// on the original scope — acknowledge, and a scoped start — are refused with the +// distinct ErrScopeTransferred (never the finished-scope ErrScopeClosed), with +// nothing written and nothing launched. Observed on change 0458 Task 2, where +// the old ErrScopeClosed refusal steered a finished worker into a false BLOCKED. +func TestClaimedScopeAcknowledgeAndStartAreTransferred(t *testing.T) { + clk := &fakeClock{now: startEpoch()} + store := OpenStore(testsupport.TempDir(t)) + running := true + proc := &fakeProc{ + observe: func(runDir string) (*process.Observation, error) { + if running { + return obs(process.StateRunning, runDir), nil + } + return obs(process.StatePassed, runDir), nil + }, + } + d := scopedTestDriver(store, clk, proc, stableGit()) + req := sampleStart() + grant, err := store.PrepareScope(scopeReqFor(req, "")) + if err != nil { + t.Fatalf("PrepareScope: %v", err) + } + req.ScopeID = grant.ScopeID + req.ChildCapability = grant.ChildCapability + + started, err := d.Start(req) + if err != nil { + t.Fatalf("Start: %v", err) + } + if started.Outcome != WAITING { + t.Fatalf("drive must WAIT, got %s (%s)", started.Outcome, started.Cause) + } + + // Worker hands off; parent claims and advances to the terminal PASSED. + handoff, err := d.Handoff(started.DriveID, started.Generation) + if err != nil { + t.Fatalf("Handoff: %v", err) + } + claimed, err := d.Claim(started.DriveID, handoff.Generation) + if err != nil { + t.Fatalf("Claim: %v", err) + } + running = false + final, err := d.Advance(started.DriveID, claimed.Generation) + if err != nil { + t.Fatalf("Advance: %v", err) + } + if final.Outcome != PASSED { + t.Fatalf("parent-driven drive must PASS, got %s (%s)", final.Outcome, final.Cause) + } + + // The returning worker's acknowledge on its original scope: transferred, no write. + scopeBytes := readScopeBytes(t, store, grant.ScopeID) + driveBytes := readDriveBytes(t, store, started.DriveID) + if _, err := d.Acknowledge(grant.ScopeID, grant.ChildCapability, started.DriveID, started.Generation); !isOwnershipKind(err, ErrScopeTransferred) { + t.Fatalf("acknowledge after a parent claim must be ErrScopeTransferred, got %v", err) + } + assertUnchanged(t, store, grant.ScopeID, scopeBytes, started.DriveID, driveBytes) + + // A scoped start under the same scope: transferred, nothing launched. + further := req + further.PredecessorDriveID = started.DriveID + further.PredecessorOwnerGen = claimed.Generation + launchesBefore := proc.launchN + if _, err := d.Start(further); !isOwnershipKind(err, ErrScopeTransferred) { + t.Fatalf("a scoped start under a claim-closed scope must be ErrScopeTransferred, got %v", err) + } + if proc.launchN != launchesBefore { + t.Fatalf("a transferred-scope start must not launch, launched %d->%d", launchesBefore, proc.launchN) + } +} diff --git a/internal/gatedrive/scope.go b/internal/gatedrive/scope.go index da8f2d51c..e1833b8bc 100644 --- a/internal/gatedrive/scope.go +++ b/internal/gatedrive/scope.go @@ -314,7 +314,8 @@ func (s *Store) reserveScopeDrive(scopeID, childCapability, newDriveID string, r // FIRST failing clause names the refusal: // // - a missing or wrong child capability is ErrScopeCapabilityMismatch; -// - a closed scope is ErrScopeClosed; +// - a scope closed by its terminal acknowledgement is ErrScopeClosed, and one +// closed by a claim or takeover is ErrScopeTransferred; // - a half-filled receipt is ErrStalePredecessor; // - an EMPTY slot admits only an empty receipt (else ErrStalePredecessor); // - an OCCUPIED slot refuses a reserved (unconfirmed) current drive @@ -340,6 +341,9 @@ func scopeReserveRefusal(rec scopeRecord, childCapability string, receipt predec return ownershipErr(ErrScopeCapabilityMismatch, op) } if rec.Closed { + if !rec.FinalAcked { + return ownershipErr(ErrScopeTransferred, op) + } return ownershipErr(ErrScopeClosed, op) } if receipt.halfFilled() { diff --git a/internal/gatedrive/scope_test.go b/internal/gatedrive/scope_test.go index ca6927cbc..adf3eaaea 100644 --- a/internal/gatedrive/scope_test.go +++ b/internal/gatedrive/scope_test.go @@ -333,8 +333,10 @@ func TestScopeReserveReceiptShape(t *testing.T) { } // TestScopeReserveCapabilityAndClosed proves reserveScopeDrive refuses a wrong or -// empty capability (ErrScopeCapabilityMismatch) and a closed scope -// (ErrScopeClosed), and that each rejection leaves the persisted bytes unchanged. +// empty capability (ErrScopeCapabilityMismatch), a scope closed by a claim or +// takeover (ErrScopeTransferred, change 0459), and a scope closed by its terminal +// acknowledgement (ErrScopeClosed), and that each rejection leaves the persisted +// bytes unchanged. func TestScopeReserveCapabilityAndClosed(t *testing.T) { s := OpenStore(testsupport.TempDir(t)) grant, err := s.PrepareScope(sampleScopeReq()) @@ -359,12 +361,31 @@ func TestScopeReserveCapabilityAndClosed(t *testing.T) { t.Fatalf("closeScope: %v", err) } before := readScopeBytes(t, s, closed.ScopeID) - if err := s.reserveScopeDrive(closed.ScopeID, closed.ChildCapability, scopeDriveA, predecessorReceipt{}); !isOwnershipKind(err, ErrScopeClosed) { - t.Fatalf("reserve on a closed scope must fail ErrScopeClosed, got %v", err) + if err := s.reserveScopeDrive(closed.ScopeID, closed.ChildCapability, scopeDriveA, predecessorReceipt{}); !isOwnershipKind(err, ErrScopeTransferred) { + t.Fatalf("reserve on a claim/takeover-closed scope must fail ErrScopeTransferred, got %v", err) } if after := readScopeBytes(t, s, closed.ScopeID); string(after) != string(before) { t.Fatalf("a rejected reserve on a closed scope must not write: bytes changed") } + + finished, err := s.PrepareScope(sampleScopeReq()) + if err != nil { + t.Fatalf("PrepareScope finished: %v", err) + } + if err := s.scopeCAS(finished.ScopeID, func(rec *scopeRecord) error { + rec.Closed = true + rec.FinalAcked = true + return nil + }); err != nil { + t.Fatalf("final-ack close: %v", err) + } + before = readScopeBytes(t, s, finished.ScopeID) + if err := s.reserveScopeDrive(finished.ScopeID, finished.ChildCapability, scopeDriveA, predecessorReceipt{}); !isOwnershipKind(err, ErrScopeClosed) { + t.Fatalf("reserve on a final-acked scope must fail ErrScopeClosed, got %v", err) + } + if after := readScopeBytes(t, s, finished.ScopeID); string(after) != string(before) { + t.Fatalf("a rejected reserve on a final-acked scope must not write: bytes changed") + } } // TestScopeConfirmLaunch proves confirmScopeLaunch flips reserved→launched only diff --git a/internal/gatedrive/takeover_test.go b/internal/gatedrive/takeover_test.go index 8a4fcd43d..43c957984 100644 --- a/internal/gatedrive/takeover_test.go +++ b/internal/gatedrive/takeover_test.go @@ -948,7 +948,7 @@ func TestClaimScopeForTakeoverRevalidates(t *testing.T) { // rendezvous at the fingerprint barrier, then contend on the scope. Exactly one // wins a coherent ownership transition — either the start wins (the takeover HALTs // scope-busy/scope-closed and supersedes no generation) or the takeover wins (the -// start is rejected ErrScopeClosed) — and at most the winner's Launch happened. Run +// start is rejected ErrScopeTransferred, change 0459) — and at most the winner's Launch happened. Run // under -race. func TestTakeoverRaceVsSuccessorStart(t *testing.T) { store := OpenStore(testsupport.TempDir(t)) @@ -1045,13 +1045,13 @@ func TestTakeoverRaceVsSuccessorStart(t *testing.T) { t.Fatalf("the predecessor must be retired (owner cleared) by the winning successor, got %q", firstRec.OwnerGeneration) } } else { - // The takeover wins: nothing launched, the start is rejected ErrScopeClosed, the + // The takeover wins: nothing launched, the start is rejected ErrScopeTransferred, the // scope is closed, and the predecessor's owner is the takeover's fresh generation. if proc.launches() != 0 { t.Fatalf("when the takeover wins no successor launch must happen, got %d", proc.launches()) } - if !isOwnershipKind(startErr, ErrScopeClosed) { - t.Fatalf("a losing successor start must be rejected ErrScopeClosed, got %v", startErr) + if !isOwnershipKind(startErr, ErrScopeTransferred) { + t.Fatalf("a losing successor start must be rejected ErrScopeTransferred, got %v", startErr) } if !scope.Closed { t.Fatalf("when the takeover wins the scope must be closed") From f7e5a748a5e8cab7ac8a53decbb1a8f617401f65 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Sat, 26 Sep 2026 08:49:09 -0400 Subject: [PATCH 4/9] fix(app): scope-transferred names the real state and drops BLOCKED from its message (change 0459) --- internal/app/gate_drive.go | 4 +++- internal/app/gate_drive_test.go | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/internal/app/gate_drive.go b/internal/app/gate_drive.go index 725e4e92f..12fea38d8 100644 --- a/internal/app/gate_drive.go +++ b/internal/app/gate_drive.go @@ -728,7 +728,9 @@ func ownershipNextAction(kind gatedrive.OwnershipErrorKind) string { case gatedrive.ErrHandoffOutstanding: return "claim the outstanding handoff instead of starting or taking over" case gatedrive.ErrScopeClosed: - return "scope authority was transferred or finished; stop and return BLOCKED" + return "this scope was already finished by its terminal acknowledgement; stop and return BLOCKED" + case gatedrive.ErrScopeTransferred: + return "the parent claimed or took over this scope's drive; this scope is no longer yours — report on the verdict your continuation supplied, and run further tests only under a fresh scope" case gatedrive.ErrStalePredecessor: return "the presented predecessor is not the scope's current drive" case gatedrive.ErrPredecessorNotReusable: diff --git a/internal/app/gate_drive_test.go b/internal/app/gate_drive_test.go index 7dc36371c..c844f09cb 100644 --- a/internal/app/gate_drive_test.go +++ b/internal/app/gate_drive_test.go @@ -706,6 +706,41 @@ func TestAcknowledgeForwardsArgsAndMapsDoc(t *testing.T) { } } +// TestAcknowledgeScopeTransferredEnvelope is the change-0459 app-layer pin: a +// scope-transferred ownership rejection surfaces reason "scope-transferred" +// under invalid-input, with a next-action message that names the real state +// (parent claimed/took over; report on the continuation's verdict; fresh scope +// for further tests) and never says BLOCKED — while the reworded scope-closed +// message keeps BLOCKED and drops the old "transferred or" wording. +func TestAcknowledgeScopeTransferredEnvelope(t *testing.T) { + bad := &fakeDriveEngine{err: &gatedrive.OwnershipError{Kind: gatedrive.ErrScopeTransferred, Op: "acknowledge"}} + svc := newGateDriveService(bad, 0, "", "") + got := svc.Acknowledge("sc-x", "childcap", "dx", "genx") + if got.Result != ResultInvalidInput || got.Drive != nil { + t.Fatalf("scope-transferred must map to invalid-input with no drive, got result=%s", got.Result) + } + if got.Reason != string(gatedrive.ErrScopeTransferred) { + t.Fatalf("reason = %q, want %q", got.Reason, string(gatedrive.ErrScopeTransferred)) + } + if strings.Contains(got.Message, "BLOCKED") { + t.Fatalf("the scope-transferred message must never direct the worker to BLOCKED, got %q", got.Message) + } + for _, want := range []string{"parent claimed or took over", "verdict your continuation supplied", "fresh scope"} { + if !strings.Contains(got.Message, want) { + t.Fatalf("scope-transferred message must contain %q, got %q", want, got.Message) + } + } + + // The finished-scope message: still directs BLOCKED, no longer claims a transfer. + closedMsg := ownershipNextAction(gatedrive.ErrScopeClosed) + if !strings.Contains(closedMsg, "BLOCKED") { + t.Fatalf("the scope-closed message must keep directing BLOCKED, got %q", closedMsg) + } + if strings.Contains(closedMsg, "transferred") { + t.Fatalf("the scope-closed message must no longer say transferred, got %q", closedMsg) + } +} + // TestTakeoverMapsDoc proves Takeover delegates to the engine, carries a // successful document verbatim under the takeover operation name, and maps a // command failure through the shared mapDriveFailure classifier. From 02fe373140a744d3d8c03d32c973feb4f68b4b96 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Sat, 26 Sep 2026 08:51:12 -0400 Subject: [PATCH 5/9] =?UTF-8?q?docs(skills):=20post-handoff=20continuation?= =?UTF-8?q?=20contract=20=E2=80=94=20transferred=20scope=20is=20never=20ac?= =?UTF-8?q?knowledged=20or=20reused=20(change=200459)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/assets/embedded/manifest.json | 10 +++++----- .../tree/skills/docket-build-task/SKILL.md | 16 ++++++++++++++++ .../embedded/tree/skills/docket-build/SKILL.md | 12 ++++++++++-- internal/repoguard/budgets_test.go | 4 ++-- skills/docket-build-task/SKILL.md | 16 ++++++++++++++++ skills/docket-build/SKILL.md | 12 ++++++++++-- 6 files changed, 59 insertions(+), 11 deletions(-) diff --git a/internal/assets/embedded/manifest.json b/internal/assets/embedded/manifest.json index e58f7fec1..1fa71528e 100644 --- a/internal/assets/embedded/manifest.json +++ b/internal/assets/embedded/manifest.json @@ -1,7 +1,7 @@ { "format_version": 1, "asset_protocol": 1, - "asset_set_id": "sha256:99499348930cd7288941f1c5f8207d27e814d01aec0773189fd01268fb903a23", + "asset_set_id": "sha256:ffac8db623e2f3660fca51a30267487ebfc39c86d071d8a0ad2fe57d9afeb3b4", "entries": [ { "path": ".docket.example.yml", @@ -301,15 +301,15 @@ "path": "skills/docket-build-task/SKILL.md", "role": "skill", "mode": 420, - "size": 12765, - "sha256": "1ad6289f881105cda0c34ecbbdd2b441113925a0f33ee1a270bfcd91c46c8186" + "size": 13990, + "sha256": "37ecdd531f0e43a7935b64955f87ca343c83e46de2425f78d2cf005c8122bc6a" }, { "path": "skills/docket-build/SKILL.md", "role": "skill", "mode": 420, - "size": 28876, - "sha256": "4505b15a67fd24feeefeb137bbfcdfb9dd6420e98fe83919109e1c44e20f8216" + "size": 29562, + "sha256": "6f1d155314e9c530bbcba49a856c1fdef4968626cf02858eec114ebdc5dfed4c" }, { "path": "skills/docket-build/references/gate-caller-loop.md", diff --git a/internal/assets/embedded/tree/skills/docket-build-task/SKILL.md b/internal/assets/embedded/tree/skills/docket-build-task/SKILL.md index 8e0def631..460d5b7a2 100644 --- a/internal/assets/embedded/tree/skills/docket-build-task/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-build-task/SKILL.md @@ -108,6 +108,22 @@ authorize a success report, and a failed acknowledgement returns `BLOCKED` with never `COMPLETE`. Acknowledgement retires the scope's recovery authority, not your evidence: keep the final drive id and verdict in `VERIFICATION`/`NOTES`. +**Continued after a `WAITING` handoff — the original scope is no longer yours.** A worker that +performed `gate.drive.handoff` and returned `WAITING` surrendered its drive; the parent's `claim` +closed the scope, so when you are resumed or re-dispatched to continue that task you +never `acknowledge` the original scope and never start a drive on it. +A `scope-transferred` refusal means you misapplied this rule +— it is not an acknowledgement failure of an owned scope and it never means the work failed. +Report on the terminal verdict your continuation supplies +(the handed-off drive id and its `PASSED`/`FAILED` disposition) plus your own work: +`PASSED` with exactly one task commit → `COMPLETE`; `FAILED` → the existing repair discretion, and +never `COMPLETE` on that verdict. Any further test drive runs only under the fresh scope bundle the +continuation provides, under the normal sequential-drive rules above; with no fresh bundle you +cannot run tests — return `BLOCKED` naming +"continuation needs a fresh scope". +The rule that a failed acknowledgement returns `BLOCKED`, never `COMPLETE`, continues to bind the +scopes you still own, and the final drive id and verdict stay in `VERIFICATION`/`NOTES` as always. + Two obligations the cycle does not relax: - A bug fix requires a **failing regression test** that reproduces the bug before the fix. diff --git a/internal/assets/embedded/tree/skills/docket-build/SKILL.md b/internal/assets/embedded/tree/skills/docket-build/SKILL.md index f738abe48..48a811811 100644 --- a/internal/assets/embedded/tree/skills/docket-build/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-build/SKILL.md @@ -141,8 +141,16 @@ capture the **fresh** owner generation from its response, and drive the same dri operation calls yourself to a terminal disposition — never a raw observe loop, background suite, or notification wait. When agent judgment is needed again, dispatch a fresh worker for the **same** task and worktree with an explicit continuation; a trusted `PASSED` is not re-driven for a changed -transcript. Waiting consumes neither the task's repair allowance nor its one escalation. If you must -unwind, hand off to your parent rather than stranding the drive. +transcript. The continuation — a same-agent resume or a fresh dispatch alike — must carry +the claimed drive's id, its terminal verdict, and an explicit statement that the original scope is closed +by your claim and must never be acknowledged or reused. When the continued task may still need test +drives, run `gate.drive.prepare-scope` again +for the same change, task, phase, branch, and worktree (and dispatch context) and include the new +start-ready scope bundle — child capability only; the parent capability stays in your notes, as for +any dispatch. Reading the continuation's return is unchanged: a `COMPLETE` is settled against git +state exactly as *Reading a worker's return* requires. Waiting consumes neither the task's repair +allowance nor its one escalation. If you must unwind, hand off to your parent rather than stranding +the drive. **Exceptional branch — a return with no valid handoff.** When the worker's dispatch returns **without** a valid handoff while its scope still binds a nonterminal (or terminal-unconsumed) drive, diff --git a/internal/repoguard/budgets_test.go b/internal/repoguard/budgets_test.go index b232a52e8..348c75de0 100644 --- a/internal/repoguard/budgets_test.go +++ b/internal/repoguard/budgets_test.go @@ -177,7 +177,7 @@ var skillBudgets = []skillBudget{ {"docket-adr/adr-template.md", 26, 90}, {"docket-auto-groom/SKILL.md", 70, 1750}, {"docket-brainstorm/SKILL.md", 84, 692}, - {"docket-build/SKILL.md", 424, 4284}, // 0405: sequential-drive contract; 0420: shell-safe capture; 0421: budgeted repair cycle (see note above) + {"docket-build/SKILL.md", 432, 4391}, // 0459: +continuation carries the claimed verdict, closed-scope statement, and fresh prepare-scope bundle (424/4284 -> 432/4391); 0405: sequential-drive contract; 0420: shell-safe capture; 0421: budgeted repair cycle (see note above) // 0154: docket-build/references/delegation-execution.md removed — it was the // evidence record for the Bash delegation facade that change 0370 deleted; its // budget row is deleted with it. @@ -185,7 +185,7 @@ var skillBudgets = []skillBudget{ {"docket-build/references/gate-execution-evidence.md", 110, 1050}, {"docket-build/references/gate-execution.md", 170, 1520}, {"docket-build/references/task-routing.md", 50, 500}, - {"docket-build-task/SKILL.md", 188, 1964}, // 0405: sequential-drive receipt and acknowledgement; 0420: shell-safe capture; 0375: worktree-busy-not-a-retry rule (179/1842 -> 188/1964) + {"docket-build-task/SKILL.md", 204, 2151}, // 0459: +post-handoff continuation never acknowledges or reuses the transferred scope (188/1964 -> 204/2151); 0405: sequential-drive receipt and acknowledgement; 0420: shell-safe capture; 0375: worktree-busy-not-a-retry rule (179/1842 -> 188/1964) {"docket-convention/SKILL.md", 400, 7969}, // 0410: +required-results lifecycle prose; 0399: +schema request/result contract prose; 0388: +sync-integration prose (see note above) // 0154: docket-convention/github-board-mirror.md removed — the GitHub mirror is // retired (unsupported, mutation-blocking); its budget row is deleted with it. diff --git a/skills/docket-build-task/SKILL.md b/skills/docket-build-task/SKILL.md index 8e0def631..460d5b7a2 100644 --- a/skills/docket-build-task/SKILL.md +++ b/skills/docket-build-task/SKILL.md @@ -108,6 +108,22 @@ authorize a success report, and a failed acknowledgement returns `BLOCKED` with never `COMPLETE`. Acknowledgement retires the scope's recovery authority, not your evidence: keep the final drive id and verdict in `VERIFICATION`/`NOTES`. +**Continued after a `WAITING` handoff — the original scope is no longer yours.** A worker that +performed `gate.drive.handoff` and returned `WAITING` surrendered its drive; the parent's `claim` +closed the scope, so when you are resumed or re-dispatched to continue that task you +never `acknowledge` the original scope and never start a drive on it. +A `scope-transferred` refusal means you misapplied this rule +— it is not an acknowledgement failure of an owned scope and it never means the work failed. +Report on the terminal verdict your continuation supplies +(the handed-off drive id and its `PASSED`/`FAILED` disposition) plus your own work: +`PASSED` with exactly one task commit → `COMPLETE`; `FAILED` → the existing repair discretion, and +never `COMPLETE` on that verdict. Any further test drive runs only under the fresh scope bundle the +continuation provides, under the normal sequential-drive rules above; with no fresh bundle you +cannot run tests — return `BLOCKED` naming +"continuation needs a fresh scope". +The rule that a failed acknowledgement returns `BLOCKED`, never `COMPLETE`, continues to bind the +scopes you still own, and the final drive id and verdict stay in `VERIFICATION`/`NOTES` as always. + Two obligations the cycle does not relax: - A bug fix requires a **failing regression test** that reproduces the bug before the fix. diff --git a/skills/docket-build/SKILL.md b/skills/docket-build/SKILL.md index f738abe48..48a811811 100644 --- a/skills/docket-build/SKILL.md +++ b/skills/docket-build/SKILL.md @@ -141,8 +141,16 @@ capture the **fresh** owner generation from its response, and drive the same dri operation calls yourself to a terminal disposition — never a raw observe loop, background suite, or notification wait. When agent judgment is needed again, dispatch a fresh worker for the **same** task and worktree with an explicit continuation; a trusted `PASSED` is not re-driven for a changed -transcript. Waiting consumes neither the task's repair allowance nor its one escalation. If you must -unwind, hand off to your parent rather than stranding the drive. +transcript. The continuation — a same-agent resume or a fresh dispatch alike — must carry +the claimed drive's id, its terminal verdict, and an explicit statement that the original scope is closed +by your claim and must never be acknowledged or reused. When the continued task may still need test +drives, run `gate.drive.prepare-scope` again +for the same change, task, phase, branch, and worktree (and dispatch context) and include the new +start-ready scope bundle — child capability only; the parent capability stays in your notes, as for +any dispatch. Reading the continuation's return is unchanged: a `COMPLETE` is settled against git +state exactly as *Reading a worker's return* requires. Waiting consumes neither the task's repair +allowance nor its one escalation. If you must unwind, hand off to your parent rather than stranding +the drive. **Exceptional branch — a return with no valid handoff.** When the worker's dispatch returns **without** a valid handoff while its scope still binds a nonterminal (or terminal-unconsumed) drive, From ceb1fb4dccd1f5ebdbdcd4c226264e24d328545a Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Sat, 26 Sep 2026 08:52:35 -0400 Subject: [PATCH 6/9] test(repoguard): guard the post-handoff continuation contract sentences (change 0459) --- internal/repoguard/prose_contracts_test.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/internal/repoguard/prose_contracts_test.go b/internal/repoguard/prose_contracts_test.go index a5afffd28..d676bab37 100644 --- a/internal/repoguard/prose_contracts_test.go +++ b/internal/repoguard/prose_contracts_test.go @@ -404,6 +404,25 @@ var proseContracts = []proseContract{ "runs inline at its Step 0 — not a mode of this skill", "(`docket-implement-next` Step 0 runs that operation inline)", }}, + // change 0459 — a handed-off worker's scope authority ends at the parent's + // claim: the worker contract forbids acknowledging or reusing the claim-closed + // scope, keys the outcome to the continuation's verdict, names the honest + // BLOCKED for a missing fresh bundle, and classifies scope-transferred as a + // misapplied-rule signal; the parent contract makes the continuation carry the + // verdict, the closed-scope statement, and a fresh prepare-scope bundle. + {sentinel: "change_0459_scope_transferred", file: "skills/docket-build-task/SKILL.md", + present: []string{ + "never `acknowledge` the original scope and never start a drive on it", + "A `scope-transferred` refusal means you misapplied this rule", + "Report on the terminal verdict your continuation supplies", + "never `COMPLETE` on that verdict", + "\"continuation needs a fresh scope\"", + }}, + {sentinel: "change_0459_scope_transferred", file: "skills/docket-build/SKILL.md", + present: []string{ + "the claimed drive's id, its terminal verdict, and an explicit statement that the original scope is closed", + "run `gate.drive.prepare-scope` again", + }}, } // scanProse checks one file's content against a contract, returning a violation From 78fa5b76846a1d8e985ae7e36aabba4cf0388f7e Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Sat, 26 Sep 2026 09:07:07 -0400 Subject: [PATCH 7/9] fix(gatedrive): transferred-scope refusal on the close race and capability-first scoped start (change 0459) Review fixes: - Finding 1: closeScopeFinal's already-closed branch returned ErrScopeClosed even when the worker's own acknowledge lost the race to a claim/takeover between retirePredecessor and closeScopeFinal (Closed && !FinalAcked), so the refusal falsely said the scope was finished by its terminal acknowledgement. It now returns ErrScopeTransferred for !FinalAcked and keeps ErrScopeClosed for FinalAcked; ownership.go/acknowledge.go doc comments updated accordingly. - Finding 3: precheckScopedStart checked scope.Closed before the child capability, unlike Acknowledge and scopeReserveRefusal, so a wrong-capability caller learned transferred-vs-finished. The capability check now runs first. TestTransferredScopeRefusalOrdering gains a wrong-capability scoped-start case and a closeScopeFinal-on-claim-closed-scope case. --- internal/gatedrive/acknowledge.go | 14 ++++++++-- internal/gatedrive/acknowledge_test.go | 38 ++++++++++++++++++++++---- internal/gatedrive/driver.go | 9 ++++-- internal/gatedrive/ownership.go | 8 +++--- 4 files changed, 53 insertions(+), 16 deletions(-) diff --git a/internal/gatedrive/acknowledge.go b/internal/gatedrive/acknowledge.go index 1fdbb3d4e..72bd33ae2 100644 --- a/internal/gatedrive/acknowledge.go +++ b/internal/gatedrive/acknowledge.go @@ -145,7 +145,8 @@ func (d *Driver) Acknowledge(scopeID, childCapability, driveID, ownerGen string) // Close the scope as terminally acknowledged, revalidating the slot under the // scope lock (revalidate-after-authority). The owner is already retired, so a // concurrent transition that moved the slot fails this close closed rather than - // closing over the wrong drive. + // closing over the wrong drive; a claim or takeover that closed the scope in + // between surfaces as ErrScopeTransferred, not ErrScopeClosed. if cerr := d.store.closeScopeFinal(scopeID, driveID); cerr != nil { return DriveDoc{}, cerr } @@ -162,11 +163,18 @@ func (d *Driver) Acknowledge(scopeID, childCapability, driveID, ownerGen string) // FinalAcked, but ONLY when driveID is still the scope's current drive — the // revalidation-after-authority the lock order requires (a concurrent transition // that moved the slot between the caller's read and this close is caught here). A -// mismatched drive id is a fail-closed ErrStalePredecessor; an already-closed -// scope is ErrScopeClosed. On any rejection the persisted record is untouched. +// mismatched drive id is a fail-closed ErrStalePredecessor. An already-closed +// scope is ErrScopeClosed when it was finished by its terminal acknowledgement +// (FinalAcked), and ErrScopeTransferred when a claim or takeover closed it +// (!FinalAcked) — the worker's own acknowledgement lost the race between +// retirePredecessor and this close, so authority moved to the parent (change +// 0459). On any rejection the persisted record is untouched. func (s *Store) closeScopeFinal(scopeID, driveID string) error { return s.scopeCAS(scopeID, func(rec *scopeRecord) error { if rec.Closed { + if !rec.FinalAcked { + return ownershipErr(ErrScopeTransferred, "acknowledge-close") + } return ownershipErr(ErrScopeClosed, "acknowledge-close") } if rec.CurrentDriveID != driveID { diff --git a/internal/gatedrive/acknowledge_test.go b/internal/gatedrive/acknowledge_test.go index fed64aa6d..b93f74d5d 100644 --- a/internal/gatedrive/acknowledge_test.go +++ b/internal/gatedrive/acknowledge_test.go @@ -295,11 +295,13 @@ func TestAcknowledgePostRetirementOwnerGenAsymmetry(t *testing.T) { }) } -// TestTransferredScopeRefusalOrdering pins two boundaries of the change-0459 +// TestTransferredScopeRefusalOrdering pins the boundaries of the change-0459 // split: a wrong child capability on a claim-closed scope is still refused -// scope-capability-mismatch (a transferred scope leaks nothing to an -// unauthenticated caller), and bindScopeChange on a claim-closed scope keeps -// the parent-side ErrScopeClosed (the spec's "Unchanged" list). +// scope-capability-mismatch on both acknowledge and scoped start (a transferred +// scope leaks nothing to an unauthenticated caller), the worker's own +// acknowledge losing closeScopeFinal's race to a claim is ErrScopeTransferred, +// and bindScopeChange on a claim-closed scope keeps the parent-side +// ErrScopeClosed (the spec's "Unchanged" list). func TestTransferredScopeRefusalOrdering(t *testing.T) { t.Run("wrong capability outranks transferred", func(t *testing.T) { d, store, grant, started, _ := startedScope(t, passObserveProc()) @@ -310,6 +312,29 @@ func TestTransferredScopeRefusalOrdering(t *testing.T) { t.Fatalf("wrong capability on a transferred scope must stay ErrScopeCapabilityMismatch, got %v", err) } }) + t.Run("wrong capability outranks transferred on a scoped start", func(t *testing.T) { + d, store, grant, started, req := startedScope(t, passObserveProc()) + if err := store.closeScope(grant.ScopeID); err != nil { + t.Fatalf("closeScope: %v", err) + } + succ := successorReq(req, started) + succ.ChildCapability = "wrong-capability" + if _, err := d.Start(succ); !isOwnershipKind(err, ErrScopeCapabilityMismatch) { + t.Fatalf("wrong capability on a transferred scope's start must stay ErrScopeCapabilityMismatch, got %v", err) + } + }) + t.Run("close-final race with a claim is transferred", func(t *testing.T) { + // The worker's own acknowledge lost the race: a claim/takeover closed the + // scope (Closed && !FinalAcked) between retirePredecessor and + // closeScopeFinal. The refusal must name the transfer, not a finished scope. + _, store, grant, started, _ := startedScope(t, passObserveProc()) + if err := store.closeScope(grant.ScopeID); err != nil { + t.Fatalf("closeScope: %v", err) + } + if err := store.closeScopeFinal(grant.ScopeID, started.DriveID); !isOwnershipKind(err, ErrScopeTransferred) { + t.Fatalf("closeScopeFinal on a claim-closed scope must fail ErrScopeTransferred, got %v", err) + } + }) t.Run("bind-scope-change keeps scope-closed", func(t *testing.T) { _, store, grant, _, _ := startedScope(t, passObserveProc()) if err := store.closeScope(grant.ScopeID); err != nil { @@ -501,8 +526,9 @@ func TestAcknowledgePostAckSuccessorRefused(t *testing.T) { // TestCloseScopeFinalRevalidates unit-tests the closeScopeFinal store transition // directly: it closes a scope as FinalAcked ONLY when driveID is still the scope's // current drive (the revalidation-after-authority the lock order requires); a -// mismatched drive id is ErrStalePredecessor with no write, and an already-closed -// scope is ErrScopeClosed. +// mismatched drive id is ErrStalePredecessor with no write, and a scope already +// closed by its terminal acknowledgement is ErrScopeClosed (a claim-closed scope is +// ErrScopeTransferred; see TestTransferredScopeRefusalOrdering). func TestCloseScopeFinalRevalidates(t *testing.T) { store := OpenStore(testsupport.TempDir(t)) grant, err := store.PrepareScope(sampleScopeReq()) diff --git a/internal/gatedrive/driver.go b/internal/gatedrive/driver.go index 4ca6b3fd8..474d916dc 100644 --- a/internal/gatedrive/driver.go +++ b/internal/gatedrive/driver.go @@ -678,6 +678,12 @@ func (d *Driver) precheckScopedStart(req StartRequest) error { if err != nil { return err } + // The child capability is checked before the closed state, matching Acknowledge + // and scopeReserveRefusal, so a rejected credential never learns whether the + // scope was transferred or finished. + if req.ChildCapability == "" || scope.ChildCapHash != capHash(req.ChildCapability) { + return ownershipErr(ErrScopeCapabilityMismatch, "start") + } if scope.Closed { if !scope.FinalAcked { // Closed by a claim or takeover: scope authority transferred to the @@ -686,9 +692,6 @@ func (d *Driver) precheckScopedStart(req StartRequest) error { } return ownershipErr(ErrScopeClosed, "start") } - if req.ChildCapability == "" || scope.ChildCapHash != capHash(req.ChildCapability) { - return ownershipErr(ErrScopeCapabilityMismatch, "start") - } receipt := predecessorReceipt{DriveID: req.PredecessorDriveID, OwnerGen: req.PredecessorOwnerGen} if receipt.halfFilled() { diff --git a/internal/gatedrive/ownership.go b/internal/gatedrive/ownership.go index 48d66ce25..d038af6cc 100644 --- a/internal/gatedrive/ownership.go +++ b/internal/gatedrive/ownership.go @@ -78,14 +78,14 @@ const ( // its own terminal acknowledgement (Closed && FinalAcked). A closed scope is // terminal. ErrScopeClosed OwnershipErrorKind = "scope-closed" - // ErrScopeTransferred: a child-capability transition (an acknowledgement, or - // a scoped start) was attempted on a scope closed by a claim or takeover + // ErrScopeTransferred: a child-capability transition (an acknowledgement — + // including one whose closeScopeFinal lost the race to a claim — or a scoped + // start) was attempted on a scope closed by a claim or takeover // (Closed && !FinalAcked) — authority over the scope's drive moved to the // parent, so the scope is no longer the worker's to acknowledge or reuse. // Distinct from ErrScopeClosed so the refusal names the real state instead of // directing a finished worker to report BLOCKED (change 0459). Parent-side - // paths (takeoverClose, bindScopeChange, closeScopeFinal's race branch) keep - // ErrScopeClosed. + // paths (takeoverClose, bindScopeChange) keep ErrScopeClosed. ErrScopeTransferred OwnershipErrorKind = "scope-transferred" // ErrScopeIdentityMismatch: a scope's identity (its bound change, or an // identity field a takeover re-verifies) no longer matches what the caller From 62de9bbfb7118218bea280f0f41fce0e8c6cb5d5 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Sat, 26 Sep 2026 09:08:24 -0400 Subject: [PATCH 8/9] docs(gatedrive): anchor ErrScopeTransferred comment on claimScopeForTakeover (change 0459) --- internal/gatedrive/ownership.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/gatedrive/ownership.go b/internal/gatedrive/ownership.go index d038af6cc..2e6f31c3e 100644 --- a/internal/gatedrive/ownership.go +++ b/internal/gatedrive/ownership.go @@ -85,7 +85,7 @@ const ( // parent, so the scope is no longer the worker's to acknowledge or reuse. // Distinct from ErrScopeClosed so the refusal names the real state instead of // directing a finished worker to report BLOCKED (change 0459). Parent-side - // paths (takeoverClose, bindScopeChange) keep ErrScopeClosed. + // paths (claimScopeForTakeover, bindScopeChange) keep ErrScopeClosed. ErrScopeTransferred OwnershipErrorKind = "scope-transferred" // ErrScopeIdentityMismatch: a scope's identity (its bound change, or an // identity field a takeover re-verifies) no longer matches what the caller From a15203f5a0a666146086d1449697aecb2e7496f5 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Sat, 26 Sep 2026 09:08:57 -0400 Subject: [PATCH 9/9] docs(results): change 0459 results --- ...ge-is-refused-scope-closed-afte-results.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 docs/results/2026-09-26-worker-s-gate-drive-acknowledge-is-refused-scope-closed-afte-results.md diff --git a/docs/results/2026-09-26-worker-s-gate-drive-acknowledge-is-refused-scope-closed-afte-results.md b/docs/results/2026-09-26-worker-s-gate-drive-acknowledge-is-refused-scope-closed-afte-results.md new file mode 100644 index 000000000..d3a2c8f5e --- /dev/null +++ b/docs/results/2026-09-26-worker-s-gate-drive-acknowledge-is-refused-scope-closed-afte-results.md @@ -0,0 +1,36 @@ + +> ↩ **[Change 0459 — Worker's gate.drive.acknowledge is refused scope-closed after the parent claims its WAITING drive](https://github.com/danielhanold/docket/blob/docket/docs/changes/active/0459-worker-s-gate-drive-acknowledge-is-refused-scope-closed-afte.md)** + +# Worker's gate.drive.acknowledge is refused scope-closed after the parent claims its WAITING drive — Results + +**Human action:** No action is required before merge. Read the behavior change below: several existing gate-drive tests now expect the new `scope-transferred` refusal where they used to expect `scope-closed`. + +## Outcome + +Before this change, a build worker could report `BLOCKED` for work it had actually finished. The sequence was: the worker's test run outlived one observation slice, so it handed the run off (`WAITING`); the parent claimed the run and finished it; the worker then tried to acknowledge its original test scope. That scope had been closed when the parent claimed it, so the acknowledge was refused with `scope-closed`, and the refusal message told the worker to return `BLOCKED`. + +What changed: + +- **New refusal, `scope-transferred`.** A child-side acknowledge, scoped test start, or scope reservation on a scope that a parent claim or takeover closed now returns `scope-transferred`. Its message says the parent took over the scope's run and tells the worker to report on the verdict its continuation supplied. It never says "return BLOCKED". A scope that was closed by the worker's own final acknowledgement still returns `scope-closed`. Parent-side paths (takeover, bind-scope-change) are unchanged. +- **Worker contract** (`docket-build-task`): a worker that handed off never acknowledges or reuses its original scope. It reports on the continuation's terminal verdict and runs further tests only under a fresh scope. +- **Parent contract** (`docket-build`): the continuation carries the claimed run's id and verdict, states that the original scope is closed, and includes a freshly prepared scope when more test runs may be needed. +- **Guards**: `internal/repoguard` prose-contract rows pin the new contract sentences (mutation-tested). + +Departures from the design: + +- The spec said the existing takeover tests would stay unchanged. That was wrong: several tests assert what a *child* sees on a scope closed by takeover or claim, and by the spec's own rule that is now `scope-transferred`. Those assertions were updated (takeover, admission-successor, driver, driver-concurrency, scope, and handoff tests). The takeover path's own result is still `scope-closed`. +- Review fixes widened the change a little: the close race inside the worker's own acknowledge (it loses to a concurrent claim) now also returns `scope-transferred`, and a scoped start checks the child capability before revealing whether the scope was closed. +- The skill size budgets in `internal/repoguard/budgets_test.go` were raised to fit the new contract prose. + +## Verification performed + +- Each task ran its focused package tests through the gate driver; the claim → advance → acknowledge regression was red before the fix and green after. +- Full suite (`go run ./cmd/docket development test`) passed at 9ff69611 before review: 54/54 files. `BUDGET WATCH` lines were reported for the long integration/race files under parallel load; there was no serial-confirmed breach. +- Whole-branch review (standard rung) returned three minor findings, all fixed in-branch (551b8344, f257fc1d). The final certification suite run is recorded in the PR's build-evidence block. +- Guard mutation probes: deleting or rewording each guarded contract sentence turned `TestProseContracts` red. + +## Known issues and follow-ups + +### Spec's "takeover tests unchanged" line was inaccurate + +This matters when someone reads the spec against the diff. They will see takeover-related tests edited even though the spec said they would not be. Confirmed. Only child-facing assertions changed; the parent takeover path still halts with `scope-closed`. No action is needed beyond knowing this.