From 41c0a5fbbcf13bbf5d34c694504838c67e1d725a Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Mon, 1 Jun 2026 12:38:35 -0400 Subject: [PATCH] Thread per-commitment enforcement level through to the snapshot Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/session/session.go | 31 +++++++++++----- internal/session/session_test.go | 61 +++++++++++++++++++++++--------- internal/store/store.go | 2 ++ internal/web/web.go | 8 ++++- 4 files changed, 76 insertions(+), 26 deletions(-) diff --git a/internal/session/session.go b/internal/session/session.go index 18cd0fb..124602e 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -147,14 +147,15 @@ type Controller struct { carryForward string // latest-wins takeaway; grounds the next coach reflectionGen int - allowedClasses []string // durable: the active session's allowed window classes - judge ai.DriftJudge - driftStatus string - driftReason string - driftGen int - nudgeEpoch int // identifies the current on-task stretch; nudge staleness guard - lastJudgedAt time.Time - judgedClasses map[string]ai.Verdict + allowedClasses []string // durable: the active session's allowed window classes + enforcementLevel domain.EnforcementLevel // durable: block enables window-minimize enforcement + judge ai.DriftJudge + driftStatus string + driftReason string + driftGen int + nudgeEpoch int // identifies the current on-task stretch; nudge staleness guard + lastJudgedAt time.Time + judgedClasses map[string]ai.Verdict nudge ai.Nudger recentTitles []string // in-memory ring of recent distinct titles this session @@ -286,6 +287,7 @@ func New(snapshotPath string) (*Controller, error) { _ = store.PruneOlderThan(c.sessionsDir, sessionRetention, c.clock()) if c.runtimeState == domain.RuntimeActive && s.SessionID != "" { c.allowedClasses = s.AllowedWindowClasses + c.enforcementLevel = s.EnforcementLevel // Drift state is not persisted: recompute fresh after restart to avoid // stale interrupts. This also initializes judgedClasses (the pipeline // writes to it) so a restored Active session never panics on a nil map. @@ -432,6 +434,7 @@ func (c *Controller) persistLocked() error { snap.SessionID = c.stats.SessionID } snap.AllowedWindowClasses = c.allowedClasses + snap.EnforcementLevel = c.enforcementLevel snap.ReflectionStatus = c.reflectionStatus snap.ReflectionRecap = c.reflectionRecap snap.CarryForward = c.carryForward @@ -701,6 +704,14 @@ func (c *Controller) AllowedClassesForTest() []string { return append([]string(nil), c.allowedClasses...) } +// EnforcementLevelForTest exposes the active session's enforcement level. Tests +// only. +func (c *Controller) EnforcementLevelForTest() domain.EnforcementLevel { + c.mu.Lock() + defer c.mu.Unlock() + return c.enforcementLevel +} + // SetDriftJudge injects the AI drift judge. A nil judge keeps local matching // working; unmatched windows simply stay idle. func (c *Controller) SetDriftJudge(j ai.DriftJudge) { @@ -867,7 +878,7 @@ func coachErrorMessage(err error) string { // StartManualCommitment validates input, activates a new commitment, mints a // session, seeds evidence stats from the latest window, and moves Planning -> // Active. -func (c *Controller) StartManualCommitment(nextAction, successCondition string, timebox time.Duration, allowedClasses []string) error { +func (c *Controller) StartManualCommitment(nextAction, successCondition string, timebox time.Duration, allowedClasses []string, level domain.EnforcementLevel) error { c.mu.Lock() defer c.mu.Unlock() commitment, err := domain.NewManual(nextAction, successCondition, timebox) @@ -889,6 +900,7 @@ func (c *Controller) StartManualCommitment(nextAction, successCondition string, c.outcomePending = "" c.resetCoachLocked() c.allowedClasses = append([]string(nil), allowedClasses...) + c.enforcementLevel = level c.resetDriftLocked() sessionID := "session-" + uuid.Must(uuid.NewV7()).String() @@ -956,6 +968,7 @@ func (c *Controller) End() error { c.stats = nil c.outcomePending = "" c.allowedClasses = nil + c.enforcementLevel = "" c.resetDriftLocked() return c.persistLocked() } diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 7755d69..60d6d52 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -37,7 +37,7 @@ func TestHappyPathDrivesStates(t *testing.T) { if err := c.EnterPlanning(); err != nil { t.Fatalf("enter planning: %v", err) } - if err := c.StartManualCommitment("Port session", "session tests pass", 25*time.Minute, nil); err != nil { + if err := c.StartManualCommitment("Port session", "session tests pass", 25*time.Minute, nil, domain.EnforcementWarn); err != nil { t.Fatalf("start commitment: %v", err) } st := c.State() @@ -67,7 +67,7 @@ func TestHappyPathDrivesStates(t *testing.T) { func TestStartCommitmentRejectsInvalidInput(t *testing.T) { c, _ := newTestController(t) _ = c.EnterPlanning() - if err := c.StartManualCommitment("", "x", 25*time.Minute, nil); err == nil { + if err := c.StartManualCommitment("", "x", 25*time.Minute, nil, domain.EnforcementWarn); err == nil { t.Fatalf("empty next action should error") } if c.State().RuntimeState != domain.RuntimePlanning { @@ -82,7 +82,7 @@ func TestStateRestoresFromSnapshot(t *testing.T) { t.Fatalf("new: %v", err) } _ = first.EnterPlanning() - _ = first.StartManualCommitment("Persisted action", "done", 25*time.Minute, nil) + _ = first.StartManualCommitment("Persisted action", "done", 25*time.Minute, nil, domain.EnforcementWarn) second, err := New(path) if err != nil { @@ -104,7 +104,7 @@ func TestRestoredCommitmentKeepsDeadline(t *testing.T) { t.Fatalf("new: %v", err) } _ = first.EnterPlanning() - _ = first.StartManualCommitment("Keep deadline", "done", 25*time.Minute, nil) + _ = first.StartManualCommitment("Keep deadline", "done", 25*time.Minute, nil, domain.EnforcementWarn) want := first.State().Commitment.DeadlineUnixSecs second, err := New(path) @@ -147,7 +147,7 @@ func TestAccumulatesBucketsAndSwitches(t *testing.T) { c.RecordWindow(snap("code", "antidrift")) // latest window before start _ = c.EnterPlanning() - _ = c.StartManualCommitment("work", "done", 25*time.Minute, nil) // seeds at t=1000 with code/antidrift + _ = c.StartManualCommitment("work", "done", 25*time.Minute, nil, domain.EnforcementWarn) // seeds at t=1000 with code/antidrift clk.advance(10 * time.Second) c.RecordWindow(snap("firefox", "docs")) // credits 10s to code/antidrift, switch -> firefox @@ -182,7 +182,7 @@ func TestUnavailableAccruesToReservedBucket(t *testing.T) { clk := &fakeClock{now: time.Unix(1000, 0)} c.SetClock(clk.fn()) _ = c.EnterPlanning() - _ = c.StartManualCommitment("work", "done", 25*time.Minute, nil) // seed: empty unavailable latestWindow + _ = c.StartManualCommitment("work", "done", 25*time.Minute, nil, domain.EnforcementWarn) // seed: empty unavailable latestWindow // latestWindow was zero-value (unavailable) at seed time. clk.advance(5 * time.Second) c.RecordWindow(snap("code", "x")) // credits 5s to the reserved bucket @@ -199,7 +199,7 @@ func TestCrashReplayRebuildsStats(t *testing.T) { first.SetClock(clk.fn()) first.RecordWindow(snap("code", "antidrift")) _ = first.EnterPlanning() - _ = first.StartManualCommitment("work", "done", 25*time.Minute, nil) + _ = first.StartManualCommitment("work", "done", 25*time.Minute, nil, domain.EnforcementWarn) clk.advance(15 * time.Second) first.RecordWindow(snap("firefox", "docs")) // 15s -> code, switch @@ -228,7 +228,7 @@ func TestEndWritesAuditSummary(t *testing.T) { c.SetClock(clk.fn()) c.RecordWindow(snap("code", "antidrift")) _ = c.EnterPlanning() - _ = c.StartManualCommitment("write plan", "plan done", 25*time.Minute, nil) + _ = c.StartManualCommitment("write plan", "plan done", 25*time.Minute, nil, domain.EnforcementWarn) clk.advance(30 * time.Second) c.RecordWindow(snap("firefox", "docs")) clk.advance(10 * time.Second) @@ -374,7 +374,7 @@ func TestStartCommitmentPersistsAllowedClasses(t *testing.T) { t.Fatalf("new: %v", err) } _ = first.EnterPlanning() - if err := first.StartManualCommitment("a", "b", 25*time.Minute, []string{"code"}); err != nil { + if err := first.StartManualCommitment("a", "b", 25*time.Minute, []string{"code"}, domain.EnforcementWarn); err != nil { t.Fatalf("start: %v", err) } second, err := New(path) @@ -389,7 +389,7 @@ func TestStartCommitmentPersistsAllowedClasses(t *testing.T) { func TestOnTaskAppendsCurrentClass(t *testing.T) { c, _ := newTestController(t) _ = c.EnterPlanning() - _ = c.StartManualCommitment("a", "b", 25*time.Minute, nil) + _ = c.StartManualCommitment("a", "b", 25*time.Minute, nil, domain.EnforcementWarn) c.RecordWindow(evidence.WindowSnapshot{Class: "slack", Title: "chat", Health: evidence.EvidenceHealth{Available: true}}) if err := c.OnTask(); err != nil { t.Fatalf("ontask: %v", err) @@ -434,11 +434,16 @@ func waitDriftStatus(t *testing.T, c *Controller, want string) State { } func startActive(t *testing.T, c *Controller, allowed []string) { + t.Helper() + startActiveLevel(t, c, allowed, domain.EnforcementWarn) +} + +func startActiveLevel(t *testing.T, c *Controller, allowed []string, level domain.EnforcementLevel) { t.Helper() if err := c.EnterPlanning(); err != nil { t.Fatalf("planning: %v", err) } - if err := c.StartManualCommitment("write report", "report drafted", 25*time.Minute, allowed); err != nil { + if err := c.StartManualCommitment("write report", "report drafted", 25*time.Minute, allowed, level); err != nil { t.Fatalf("start: %v", err) } } @@ -541,7 +546,7 @@ func TestRestoredActiveSessionCanBeJudged(t *testing.T) { t.Fatalf("new: %v", err) } _ = first.EnterPlanning() - if err := first.StartManualCommitment("write report", "drafted", 25*time.Minute, []string{"code"}); err != nil { + if err := first.StartManualCommitment("write report", "drafted", 25*time.Minute, []string{"code"}, domain.EnforcementWarn); err != nil { t.Fatalf("start: %v", err) } second, err := New(path) @@ -559,7 +564,7 @@ func TestLeavingPlanningClearsCoach(t *testing.T) { c.SetCoach(&fakeCoach{prop: ai.Proposal{NextAction: "x", SuccessCondition: "y", TimeboxSecs: 60}}) _ = c.RequestCoach("x") waitCoachStatus(t, c, "ready") - if err := c.StartManualCommitment("a", "b", 25*time.Minute, nil); err != nil { + if err := c.StartManualCommitment("a", "b", 25*time.Minute, nil, domain.EnforcementWarn); err != nil { t.Fatalf("start: %v", err) } if c.State().Coach != nil { @@ -888,7 +893,7 @@ func TestStaleTasksFetchDiscardedAfterLeavingPlanning(t *testing.T) { waitTasksStatus(t, c, "pending") // Step 2: leave planning via a commitment — the stale fetch is still blocked. - if err := c.StartManualCommitment("task", "done", 25*time.Minute, nil); err != nil { + if err := c.StartManualCommitment("task", "done", 25*time.Minute, nil, domain.EnforcementWarn); err != nil { t.Fatalf("start commitment: %v", err) } if c.State().RuntimeState != domain.RuntimeActive { @@ -1066,7 +1071,7 @@ func driveToReview(t *testing.T, c *Controller) { if err := c.EnterPlanning(); err != nil { t.Fatalf("planning: %v", err) } - if err := c.StartManualCommitment("write plan", "plan done", 25*time.Minute, nil); err != nil { + if err := c.StartManualCommitment("write plan", "plan done", 25*time.Minute, nil, domain.EnforcementWarn); err != nil { t.Fatalf("start: %v", err) } if err := c.Complete(); err != nil { @@ -1158,7 +1163,7 @@ func TestCarryForwardSurvivesRestart(t *testing.T) { if err := first.EnterPlanning(); err != nil { t.Fatalf("planning: %v", err) } - if err := first.StartManualCommitment("a", "b", 25*time.Minute, nil); err != nil { + if err := first.StartManualCommitment("a", "b", 25*time.Minute, nil, domain.EnforcementWarn); err != nil { t.Fatalf("start: %v", err) } if err := first.Complete(); err != nil { @@ -1181,3 +1186,27 @@ func TestCarryForwardSurvivesRestart(t *testing.T) { t.Fatalf("carry-forward not restored onto planning: %+v", st.Reflection) } } + +func TestEnforcementLevelPersists(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "state.json") + first, err := New(path) + if err != nil { + t.Fatal(err) + } + first.SetClock(func() time.Time { return time.Unix(1000, 0) }) + if err := first.EnterPlanning(); err != nil { + t.Fatal(err) + } + if err := first.StartManualCommitment("write report", "drafted", 25*time.Minute, []string{"code"}, domain.EnforcementBlock); err != nil { + t.Fatal(err) + } + + second, err := New(path) + if err != nil { + t.Fatal(err) + } + if got := second.EnforcementLevelForTest(); got != domain.EnforcementBlock { + t.Fatalf("enforcement level not restored: got %q want %q", got, domain.EnforcementBlock) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index fe27bd4..1ecce17 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -25,6 +25,8 @@ type Snapshot struct { ReflectionStatus string `json:"reflection_status,omitempty"` ReflectionRecap string `json:"reflection_recap,omitempty"` CarryForward string `json:"carry_forward,omitempty"` + + EnforcementLevel domain.EnforcementLevel `json:"enforcement_level,omitempty"` } // DefaultPath returns ~/.antidrift/state.json. diff --git a/internal/web/web.go b/internal/web/web.go index 1a5a207..06e336c 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -12,6 +12,7 @@ import ( "sync" "time" + "antidrift/internal/domain" "antidrift/internal/session" "antidrift/internal/statemachine" @@ -110,6 +111,7 @@ type commitmentRequest struct { SuccessCondition string `json:"success_condition"` TimeboxSecs int64 `json:"timebox_secs"` AllowedWindowClasses []string `json:"allowed_window_classes"` + Enforce bool `json:"enforce"` } func (s *Server) handleCommitment(c *gin.Context) { @@ -118,7 +120,11 @@ func (s *Server) handleCommitment(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"}) return } - err := s.ctrl.StartManualCommitment(req.NextAction, req.SuccessCondition, time.Duration(req.TimeboxSecs)*time.Second, req.AllowedWindowClasses) + level := domain.EnforcementWarn + if req.Enforce { + level = domain.EnforcementBlock + } + err := s.ctrl.StartManualCommitment(req.NextAction, req.SuccessCondition, time.Duration(req.TimeboxSecs)*time.Second, req.AllowedWindowClasses, level) if err == nil { s.armExpiry() }