Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
26 KiB
M9 — Tame session.go 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: Split the 1278-line internal/session/session.go into five focused files and consolidate the 4×-duplicated async-fetch boilerplate into one helper, with zero behavior change.
Architecture: Everything stays in package session (one Controller behind one sync.Mutex — sub-packages would force private state to be exported). Phase 1 moves declarations into focused files with no logic edits. Phase 2 adds a runFetchAsync(timeout, fetch, stale, apply) helper and migrates the four async roles to it one at a time. The existing session_test.go + web_test.go suites are the safety net; the contract is green-to-green under -race after every commit.
Tech Stack: Go 1.26, stdlib testing, go test -race. No new dependencies.
Critical rules for every task
- No behavior change. Move and re-shape code; never alter what it does. No exported symbol is renamed, removed, or has its signature changed.
- Locate declarations by name (
grep -n), not by the line numbers in this plan — line numbers shift as earlier tasks move code. The line numbers here are hints from the pre-refactor file. - Move bodies verbatim. In Phase 1, cut each declaration exactly as written and paste it into the new file. Do not edit logic.
- Resolve imports with the compiler. After moving declarations, run
go build ./internal/session/. Add the imports the new file needs; delete imports the compiler reports as now-unused insession.go. Ifgoimportsis available (go run golang.org/x/tools/cmd/goimports@latest -w internal/session/), it does both automatically. The "expected imports" lists below are guidance, not gospel. - Green gate after every commit:
go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/. All must pass. Never commit red. - All commands run from the repo root
/home/felixm/dev/antidrift. Commit messages end with theCo-Authored-Bytrailer used in this repo.
File Structure (target)
All package session:
session.go—Controllerstruct,New,SetClock/SetOnChange/notify,State/Deadline,persistLocked, lifecycle transitions (EnterPlanning,StartManualCommitment,Complete/Expire/enterReview,End,buildSummaryLocked),ErrNotPlanning/ErrNotActive, theunavailableTitle/sessionRetentionconsts,AllowedClassesForTest/EnforcementLevelForTest.views.go— the 11*Viewtypes, theStatetype,stateLocked,bucketViews.roles.go—runFetchAsync+ coach/tasks/knowledge/reflection (Set*,start*FetchLocked/RequestCoach,composedGroundingLocked,coachErrorMessage,buildReflectionFinishedLocked,buildReflectionHistory) + their timeout/status consts.drift.go—RecordWindow,evaluateDriftLocked,maybeNudgeLocked,enforceActionLocked,applyVerdictLocked,resetDriftLocked,OnTask,Refocus,recordTitleLocked,commitmentLineLocked,Set{DriftJudge,Guard,Nudge},recentTitlesForTest, the drift/nudge/enforce consts (driftDebounce/driftTimeout/enforceTimeout/nudgeDebounce/nudgeTimeout, thedrift*status consts,recentTitlesMax).stats.go—EvidenceStats,bucketKey,applyEvent,replayStats,keyFor,focusEvent,snapFromEvent.
Task 1: Close the coverage gap — knowledge stale-discard test
The audit found one gap: knowledge has no dedicated stale-discard test, while coach/tasks/reflection do. Add one before refactoring so the shared helper's stale path is covered for every role. This is a characterization test — it documents existing behavior and must pass against the current (un-refactored) code.
Files:
-
Modify:
internal/session/session_test.go -
Step 1: Add a gate channel to
fakeSource
The knowledge test double fakeSource (defined around line 1027 of internal/session/session_test.go) currently has no gate, so a load can't be held in flight. Add one, mirroring fakeProvider (line ~885) and fakeCoach (line ~251). Replace:
type fakeSource struct {
profile knowledge.Profile
err error
}
func (f *fakeSource) Load(ctx context.Context, path string) (knowledge.Profile, error) {
return f.profile, f.err
}
with:
type fakeSource struct {
profile knowledge.Profile
err error
gate chan struct{} // if non-nil, Load blocks until it receives
}
func (f *fakeSource) Load(ctx context.Context, path string) (knowledge.Profile, error) {
if f.gate != nil {
<-f.gate
}
return f.profile, f.err
}
This is behavior-preserving for the existing knowledge tests (they construct fakeSource without a gate, so it stays nil and Load never blocks).
- Step 2: Write the characterization test
Add to internal/session/session_test.go, mirroring TestStaleTasksFetchDiscardedAfterLeavingPlanning (line ~964). The State.Knowledge field is a *KnowledgeView accessed as st.Knowledge.Path/st.Knowledge.Chars in the existing knowledge tests:
// TestStaleKnowledgeFetchDiscardedAfterLeavingPlanning exercises the
// "left planning" arm of the discard guard in startKnowledgeFetchLocked:
// a slow knowledge load that returns after the user has left planning must
// not clobber fresh state.
func TestStaleKnowledgeFetchDiscardedAfterLeavingPlanning(t *testing.T) {
c, _ := newTestController(t)
staleGate := make(chan struct{})
stale := &fakeSource{
profile: knowledge.Profile{Text: "STALE", Path: "/stale"},
gate: staleGate,
}
c.SetKnowledge(stale)
if err := c.EnterPlanning(); err != nil { // launches the gated load (gen 1)
t.Fatal(err)
}
// Leave planning while the gen-1 load is still blocked on the gate.
if err := c.StartManualCommitment("write report", "report drafted", 25*time.Minute, []string{"code"}, domain.EnforcementWarn); err != nil {
t.Fatal(err)
}
close(staleGate) // release gen 1; it must be discarded (left planning)
// Give the released goroutine time to attempt its commit.
time.Sleep(50 * time.Millisecond)
st := c.State()
if st.Knowledge != nil && st.Knowledge.Chars != 0 {
t.Fatalf("stale knowledge fetch clobbered state: %+v", st.Knowledge)
}
}
- Step 3: Run it — expect PASS (characterizes current behavior)
Run: go test -race ./internal/session/ -run TestStaleKnowledgeFetchDiscardedAfterLeavingPlanning -v
Expected: PASS. (It documents existing behavior; if it fails, the test is wrong or the fake's gate is mis-wired — fix the test, not production code.)
- Step 4: Full green gate
Run: go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/
Expected: all PASS.
- Step 5: Commit
git add internal/session/session_test.go
git commit -m "Add knowledge stale-discard characterization test
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
Task 2: Extract views.go (file split, no logic change)
Files:
-
Create:
internal/session/views.go -
Modify:
internal/session/session.go -
Step 1: Create the new file with the package clause
Create internal/session/views.go:
package session
- Step 2: Move the declarations verbatim
Cut these declarations from session.go (locate each by name with grep -n 'type CommitmentView' internal/session/session.go etc.) and paste them, unedited, into views.go:
- the view types, in this order:
CommitmentView,ProposalView,DriftView,CoachView,TaskView,TasksView,KnowledgeView,ReflectionView,WindowView,BucketView,EvidenceView,State - the method
func (c *Controller) stateLocked() State { ... } - the function
func bucketViews(buckets map[bucketKey]time.Duration) []BucketView { ... }
Do not change a single line of their bodies.
- Step 3: Fix imports
Run: go build ./internal/session/
views.go is expected to need: sort, time, antidrift/internal/domain, antidrift/internal/evidence, antidrift/internal/tasks. Add whatever the compiler reports as undefined, and remove from session.go any import the compiler now reports as unused. (Or run goimports -w internal/session/ to do both.)
- Step 4: Green gate
Run: go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/
Expected: all PASS (pure code motion — behavior is identical).
- Step 5: Commit
git add internal/session/views.go internal/session/session.go
git commit -m "Split session views into views.go
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
Task 3: Extract stats.go (file split, no logic change)
Files:
-
Create:
internal/session/stats.go -
Modify:
internal/session/session.go -
Step 1: Create the new file
Create internal/session/stats.go:
package session
- Step 2: Move the declarations verbatim
Cut these from session.go and paste unedited into stats.go:
-
type bucketKey struct{ Class, Title string } -
type EvidenceStats struct { ... } -
func (c *Controller) applyEvent(now time.Time, snap evidence.WindowSnapshot) -
func (c *Controller) replayStats(sessionID string) -
func keyFor(snap evidence.WindowSnapshot) bucketKey -
func focusEvent(now time.Time, snap evidence.WindowSnapshot) store.FocusEvent -
func snapFromEvent(e store.FocusEvent) evidence.WindowSnapshot -
Step 3: Fix imports
Run: go build ./internal/session/
stats.go is expected to need: time, antidrift/internal/evidence, antidrift/internal/store. Add/remove per the compiler (or goimports -w).
- Step 4: Green gate
Run: go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/
Expected: all PASS.
- Step 5: Commit
git add internal/session/stats.go internal/session/session.go
git commit -m "Split evidence-stats accounting into stats.go
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
Task 4: Extract drift.go (file split, no logic change)
Files:
-
Create:
internal/session/drift.go -
Modify:
internal/session/session.go -
Step 1: Create the new file
Create internal/session/drift.go:
package session
- Step 2: Move the consts verbatim
Cut these const groups from session.go and paste into drift.go:
-
the block
const ( driftDebounce ... driftTimeout ... enforceTimeout ... nudgeDebounce ... nudgeTimeout ... ) -
const recentTitlesMax = 10 -
the block
const ( driftIdle ... driftPending ... driftOnTask ... driftDrifting ... ) -
Step 3: Move the methods verbatim
Cut these from session.go and paste unedited into drift.go:
-
Set{DriftJudge,Guard,Nudge}— i.e.SetDriftJudge,SetGuard,SetNudge -
resetDriftLocked,OnTask,Refocus -
commitmentLineLocked -
RecordWindow,enforceActionLocked,evaluateDriftLocked,maybeNudgeLocked -
recordTitleLocked,applyVerdictLocked -
recentTitlesForTest -
Step 4: Fix imports
Run: go build ./internal/session/
drift.go is expected to need: context, log, time, antidrift/internal/ai, antidrift/internal/domain, antidrift/internal/enforce, antidrift/internal/evidence. Add/remove per the compiler (or goimports -w). Pay attention: session.go may still need enforce/ai for fields in the Controller struct (the struct itself stays in session.go), so do not blindly delete its imports — let the compiler decide.
- Step 5: Green gate
Run: go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/
Expected: all PASS.
- Step 6: Commit
git add internal/session/drift.go internal/session/session.go
git commit -m "Split drift/nudge/enforcement into drift.go
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
Task 5: Extract roles.go (file split, no logic change)
This isolates the four async roles so Phase 2's consolidation is a clean diff. session.go becomes the remainder (core controller + lifecycle).
Files:
-
Create:
internal/session/roles.go -
Modify:
internal/session/session.go -
Step 1: Create the new file
Create internal/session/roles.go:
package session
- Step 2: Move the consts verbatim
Cut these from session.go into roles.go:
-
const coachTimeout = 60 * time.Secondand theconst ( coachIdle ... coachPending ... coachReady ... coachError )block -
const tasksTimeout = 30 * time.Secondand theconst ( tasksIdle ... tasksReady ... tasksError )block -
const knowledgeTimeout = 10 * time.Secondand theconst ( knowledgeIdle ... knowledgeError )block -
const reflectionTimeout = 30 * time.Second,const reflectionHistoryN = 5,const reflectionTopBuckets = 3, and theconst ( reflectionIdle ... reflectionAbsent )block -
Step 3: Move the methods/functions verbatim
Cut these from session.go into roles.go:
-
coach:
SetCoach,resetCoachLocked,composedGroundingLocked,RequestCoach,coachErrorMessage -
tasks:
SetTasks,startTasksFetchLocked -
knowledge:
SetKnowledge,SetKnowledgePath,startKnowledgeFetchLocked -
reflection:
SetReviewer,startReflectionFetchLocked,buildReflectionFinishedLocked,buildReflectionHistory -
Step 4: Fix imports
Run: go build ./internal/session/
roles.go is expected to need: context, fmt, strings, time, antidrift/internal/ai, antidrift/internal/domain, antidrift/internal/knowledge, antidrift/internal/store, antidrift/internal/tasks. Add/remove per the compiler (or goimports -w).
- Step 5: Green gate + confirm the monolith shrank
Run: go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/
Expected: all PASS.
Run: wc -l internal/session/*.go
Expected: session.go is now well under ~450 lines; views.go/roles.go/drift.go/stats.go carry the rest.
- Step 6: Commit
git add internal/session/roles.go internal/session/session.go
git commit -m "Split async AI roles into roles.go
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
Task 6: Add runFetchAsync and migrate the tasks role
Phase 2 begins. Introduce the helper and convert the first role. The helper owns only the goroutine dance; the role keeps its setup, stale guard, and apply branches.
Files:
-
Modify:
internal/session/roles.go -
Step 1: Add the helper
In internal/session/roles.go, add:
// runFetchAsync launches a generation-guarded background fetch. The caller has
// already captured its dependencies and (for the *Locked callers) holds c.mu;
// this method only spawns the goroutine, which re-acquires the lock itself.
// fetch performs the I/O with no lock held; stale reports whether to discard the
// result; apply records it under the re-acquired lock (and persists itself when
// the role requires it). On a non-stale completion the controller notifies.
func (c *Controller) runFetchAsync(timeout time.Duration, fetch func(ctx context.Context), stale func() bool, apply func()) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
fetch(ctx)
c.mu.Lock()
if stale() {
c.mu.Unlock()
return
}
apply()
c.mu.Unlock()
c.notify()
}()
}
- Step 2: Migrate
startTasksFetchLocked
Replace the body of startTasksFetchLocked (the go func() { ... }() block) so the function reads exactly:
func (c *Controller) startTasksFetchLocked() {
c.tasksList = nil
if c.tasksProvider == nil {
c.tasksStatus = tasksIdle
return
}
c.tasksGen++
gen := c.tasksGen
c.tasksStatus = tasksPending
provider := c.tasksProvider
var list []tasks.Task
var err error
c.runFetchAsync(tasksTimeout,
func(ctx context.Context) { list, err = provider.Today(ctx) },
func() bool { return gen != c.tasksGen || c.runtimeState != domain.RuntimePlanning },
func() {
if err != nil {
c.tasksStatus = tasksError
c.tasksList = nil
} else {
c.tasksStatus = tasksReady
c.tasksList = list
}
})
}
- Step 3: Targeted tests
Run: go test -race ./internal/session/ -run 'Tasks' -v
Expected: PASS — including TestEnterPlanningFetchesTasks, TestTasksFetchError, TestNoProviderNoTasksView, TestTasksViewAbsentOutsidePlanning, TestStaleTasksFetchDiscardedAfterLeavingPlanning.
- Step 4: Full green gate
Run: go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/
Expected: all PASS.
- Step 5: Commit
git add internal/session/roles.go
git commit -m "Add runFetchAsync helper and migrate the tasks fetch
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
Task 7: Migrate the knowledge role
Files:
-
Modify:
internal/session/roles.go -
Step 1: Migrate
startKnowledgeFetchLocked
Replace the go func() { ... }() block so the function reads exactly (note the three-branch apply and the knowledgePath write-back are preserved verbatim, now inside the apply closure):
func (c *Controller) startKnowledgeFetchLocked() {
c.knowledgeText = ""
c.knowledgeChars = 0
if c.knowledgeSrc == nil {
c.knowledgeStatus = knowledgeIdle
return
}
c.knowledgeGen++
gen := c.knowledgeGen
c.knowledgeStatus = knowledgePending
src := c.knowledgeSrc
path := c.knowledgePath
var prof knowledge.Profile
var err error
c.runFetchAsync(knowledgeTimeout,
func(ctx context.Context) { prof, err = src.Load(ctx, path) },
func() bool { return gen != c.knowledgeGen || c.runtimeState != domain.RuntimePlanning },
func() {
if err != nil {
c.knowledgeStatus = knowledgeError
c.knowledgeText = ""
c.knowledgeChars = 0
if prof.Path != "" {
c.knowledgePath = prof.Path
}
} else if prof.Text == "" {
c.knowledgeStatus = knowledgeAbsent
c.knowledgeText = ""
c.knowledgeChars = 0
c.knowledgePath = prof.Path
} else {
c.knowledgeStatus = knowledgeReady
c.knowledgeText = prof.Text
c.knowledgeChars = len(prof.Text)
c.knowledgePath = prof.Path
}
})
}
- Step 2: Targeted tests
Run: go test -race ./internal/session/ -run 'Knowledge' -v
Expected: PASS — including TestEnterPlanningLoadsKnowledge, TestKnowledgeAbsentWhenEmptyText, TestKnowledgeLoadError, TestNoSourceNoKnowledgeView, TestKnowledgeViewAbsentOutsidePlanning, TestStaleKnowledgeFetchDiscardedAfterLeavingPlanning (from Task 1).
- Step 3: Full green gate
Run: go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/
Expected: all PASS.
- Step 4: Commit
git add internal/session/roles.go
git commit -m "Migrate the knowledge fetch onto runFetchAsync
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
Task 8: Migrate the coach role
RequestCoach is the request-triggered entry point: it manages its own lock and notifies the pending state before launching. Keep that pre-launch unlock+notify; only the trailing go func() { ... }() is replaced.
Files:
-
Modify:
internal/session/roles.go -
Step 1: Migrate
RequestCoach
Replace the trailing goroutine. The function must read exactly:
func (c *Controller) RequestCoach(intent string) error {
c.mu.Lock()
if c.runtimeState != domain.RuntimePlanning {
c.mu.Unlock()
return ErrNotPlanning
}
if c.coach == nil {
c.coachStatus = coachError
c.coachErr = "coach unavailable"
c.coachProposal = nil
c.mu.Unlock()
c.notify()
return nil
}
c.coachGen++
gen := c.coachGen
c.coachStatus = coachPending
c.coachErr = ""
c.coachProposal = nil
coach := c.coach
grounding := c.composedGroundingLocked()
c.mu.Unlock()
c.notify()
var prop ai.Proposal
var err error
c.runFetchAsync(coachTimeout,
func(ctx context.Context) { prop, err = coach.Coach(ctx, intent, grounding) },
func() bool { return gen != c.coachGen || c.runtimeState != domain.RuntimePlanning },
func() {
if err != nil {
c.coachStatus = coachError
c.coachErr = coachErrorMessage(err)
c.coachProposal = nil
} else {
c.coachStatus = coachReady
c.coachProposal = &prop
c.coachErr = ""
}
})
return nil
}
Note: runFetchAsync is called here after c.mu.Unlock(). That is correct — the helper only spawns the goroutine and touches no c field before the goroutine re-locks, so holding the lock is not required.
- Step 2: Targeted tests
Run: go test -race ./internal/session/ -run 'Coach' -v
Expected: PASS — including TestRequestCoachReady, TestRequestCoachError, TestRequestCoachUnavailable, TestRequestCoachWrongState, TestRequestCoachStaleResultDiscarded, TestCoachReceivesCachedGrounding, TestLeavingPlanningClearsCoach, TestCarryForwardGroundsNextCoach.
- Step 3: Full green gate
Run: go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/
Expected: all PASS.
- Step 4: Commit
git add internal/session/roles.go
git commit -m "Migrate the coach fetch onto runFetchAsync
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
Task 9: Migrate the reflection role
Reflection is the subtle one: its stale guard is gen-only (no Planning gate — the carry-forward must survive End before the reviewer returns), it reads history synchronously under the lock before the goroutine (a happens-before requirement against End's audit-chain append), and its apply calls persistLocked. All three must be preserved.
Files:
-
Modify:
internal/session/roles.go -
Step 1: Migrate
startReflectionFetchLocked
Replace the trailing go func() { ... }() block. The function must read exactly (the synchronous history := buildReflectionHistory(c.auditPath) stays outside the closures, before the runFetchAsync call; persistLocked moves into the apply closure):
func (c *Controller) startReflectionFetchLocked() {
c.reflectionRecap = ""
c.carryForward = ""
if c.reviewer == nil {
c.reflectionStatus = reflectionIdle
return
}
c.reflectionGen++
gen := c.reflectionGen
c.reflectionStatus = reflectionPending
reviewer := c.reviewer
finished := c.buildReflectionFinishedLocked()
// Read the history synchronously, here under the lock, on purpose: it must
// happen-before End appends the just-finished session to the audit chain, so
// that session is excluded from "recent history" and not double-counted (it
// is already carried in `finished`). Moving this into the goroutine would
// race with End's append and reintroduce that double-count. The read is
// bounded to reflectionHistoryN summaries and runs once per Review entry, not
// on any hot path.
history := buildReflectionHistory(c.auditPath)
var refl ai.Reflection
var err error
c.runFetchAsync(reflectionTimeout,
func(ctx context.Context) { refl, err = reviewer.Review(ctx, finished, history) },
func() bool { return gen != c.reflectionGen },
func() {
if err != nil || strings.TrimSpace(refl.Recap) == "" {
c.reflectionStatus = reflectionAbsent
c.reflectionRecap = ""
c.carryForward = ""
} else {
c.reflectionStatus = reflectionReady
c.reflectionRecap = refl.Recap
c.carryForward = refl.CarryForward
}
_ = c.persistLocked()
})
}
Confirm the reviewer's return type is ai.Reflection (grep internal/ai for func (b *Backend) Review); if the type name differs, match it in the var refl declaration.
- Step 2: Targeted tests
Run: go test -race ./internal/session/ -run 'Reflection|CarryForward' -v
Expected: PASS — including TestReflectionFetchedOnReview, TestNoReviewerYieldsIdleReflection, TestCarryForwardGroundsNextCoach, TestReflectionStaleResultDiscarded, TestCarryForwardSurvivesRestart.
- Step 3: Full green gate
Run: go build ./... && go vet ./... && go test -race ./internal/session/ ./internal/web/
Expected: all PASS.
- Step 4: Commit
git add internal/session/roles.go
git commit -m "Migrate the reflection fetch onto runFetchAsync
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>"
Task 10: Final verification
Files: none (verification only).
- Step 1: Confirm no inline async-fetch goroutines remain
Run: grep -n 'go func()' internal/session/*.go
Expected: the only matches are inside runFetchAsync (in roles.go) and any pre-existing goroutines in drift.go/elsewhere that are not the four migrated role fetches. The coach/tasks/knowledge/reflection fetches must no longer contain their own go func() — they go through runFetchAsync.
- Step 2: Confirm the split
Run: wc -l internal/session/*.go
Expected: five focused files (session.go, views.go, roles.go, drift.go, stats.go), none dominating; the old 1278-line monolith is gone.
- Step 3: Full suite under the race detector
Run: go build ./... && go vet ./... && go test -race ./...
Expected: all packages PASS, no race warnings.
- Step 4: Confirm behavior contract
Confirm via git log --oneline that the milestone is a series of small commits, each green, with no change to any exported signature (spot-check: git diff <first-m9-commit>^..HEAD -- internal/session/session.go internal/web internal/store cmd shows no signature/behavior changes outside the moved/▸consolidated session code, and no .go file outside internal/session/ was modified except as already committed in prior milestones).
Self-review notes (for the executor)
- Every task is green-to-green; if any
go test -racegoes red, stop and fix before committing — a red gate means a real behavior change slipped in. - The risky preservation points, all called out in their tasks: reflection's gen-only stale guard, its synchronous history read before the goroutine, and its
persistLockedin apply; coach's pre-launch unlock+notify; knowledge's three-branch apply +knowledgePathwrite-back. - No exported symbol is renamed or moved out of
package session;web_test.go's cross-package use ofAllowedClassesForTest/EnforcementLevelForTestkeeps working because those stay insession.goas regular (non-_test.go) methods.