From e66fddd29f4247912004f708eca537a8c4e4b908 Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Sun, 31 May 2026 11:36:51 -0400 Subject: [PATCH] Add domain types and validation Co-Authored-By: Claude Opus 4.8 --- go.mod | 2 + go.sum | 2 + internal/domain/domain.go | 127 +++++++++++++++++++++++++++++++++ internal/domain/domain_test.go | 69 ++++++++++++++++++ 4 files changed, 200 insertions(+) create mode 100644 go.sum create mode 100644 internal/domain/domain.go create mode 100644 internal/domain/domain_test.go diff --git a/go.mod b/go.mod index b3e063b..1afbe7e 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module antidrift go 1.26.3 + +require github.com/google/uuid v1.6.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7790d7c --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/internal/domain/domain.go b/internal/domain/domain.go new file mode 100644 index 0000000..92779da --- /dev/null +++ b/internal/domain/domain.go @@ -0,0 +1,127 @@ +// Package domain holds the core commitment types and validation, ported from +// the original Rust implementation. These are pure data types with no I/O. +package domain + +import ( + "errors" + "strings" + "time" + + "github.com/google/uuid" +) + +const PolicySchemaVersion = 1 + +type CommitmentSource string + +const ( + SourceManual CommitmentSource = "manual" + SourcePlanner CommitmentSource = "planner" + SourceRecurring CommitmentSource = "recurring" + SourceRecovery CommitmentSource = "recovery" + SourceTemplate CommitmentSource = "template" +) + +type CommitmentState string + +const ( + CommitmentDraft CommitmentState = "draft" + CommitmentActive CommitmentState = "active" + CommitmentPaused CommitmentState = "paused" + CommitmentCompleted CommitmentState = "completed" + CommitmentAbandoned CommitmentState = "abandoned" + CommitmentViolated CommitmentState = "violated" +) + +type RuntimeState string + +const ( + RuntimeLocked RuntimeState = "locked" + RuntimePlanning RuntimeState = "planning" + RuntimeActive RuntimeState = "active" + RuntimeTransition RuntimeState = "transition" + RuntimeReview RuntimeState = "review" + RuntimeAdminOverride RuntimeState = "admin_override" +) + +type EnforcementLevel string + +const ( + EnforcementObserve EnforcementLevel = "observe" + EnforcementWarn EnforcementLevel = "warn" + EnforcementBlock EnforcementLevel = "block" + EnforcementLocked EnforcementLevel = "locked" +) + +// AllowedContext is carried by PolicySnapshot. Matching logic arrives in a +// later milestone; M0 only stores it. +type AllowedContext struct { + WindowClasses []string `json:"window_classes"` + WindowTitleSubstrings []string `json:"window_title_substrings"` + Domains []string `json:"domains"` + Repos []string `json:"repos"` + Commands []string `json:"commands"` +} + +type Commitment struct { + ID string `json:"id"` + CreatedAtUnixSecs int64 `json:"created_at_unix_secs"` + Source CommitmentSource `json:"source"` + NextAction string `json:"next_action"` + SuccessCondition string `json:"success_condition"` + TimeboxSecs int64 `json:"timebox_secs"` + State CommitmentState `json:"state"` +} + +type PolicySnapshot struct { + ID string `json:"id"` + CommitmentID string `json:"commitment_id"` + SchemaVersion int `json:"schema_version"` + CreatedAtUnixSecs int64 `json:"created_at_unix_secs"` + RuntimeState RuntimeState `json:"runtime_state"` + EnforcementLevel EnforcementLevel `json:"enforcement_level"` + AllowedContext AllowedContext `json:"allowed_context"` +} + +var ( + ErrMissingNextAction = errors.New("next action is required") + ErrMissingSuccessCondition = errors.New("success condition is required") + ErrMissingTimebox = errors.New("timebox must be nonzero") +) + +// NewManual builds a validated draft commitment from user input. +func NewManual(nextAction, successCondition string, timebox time.Duration) (Commitment, error) { + nextAction = strings.TrimSpace(nextAction) + successCondition = strings.TrimSpace(successCondition) + if nextAction == "" { + return Commitment{}, ErrMissingNextAction + } + if successCondition == "" { + return Commitment{}, ErrMissingSuccessCondition + } + if timebox <= 0 { + return Commitment{}, ErrMissingTimebox + } + return Commitment{ + ID: "commitment-" + uuid.Must(uuid.NewV7()).String(), + CreatedAtUnixSecs: time.Now().Unix(), + Source: SourceManual, + NextAction: nextAction, + SuccessCondition: successCondition, + TimeboxSecs: int64(timebox.Seconds()), + State: CommitmentDraft, + }, nil +} + +// ForRuntime builds a minimal accepted policy snapshot for a commitment. +func ForRuntime(commitmentID string, runtime RuntimeState, level EnforcementLevel) PolicySnapshot { + return PolicySnapshot{ + ID: "policy-" + uuid.Must(uuid.NewV7()).String(), + CommitmentID: commitmentID, + SchemaVersion: PolicySchemaVersion, + CreatedAtUnixSecs: time.Now().Unix(), + RuntimeState: runtime, + EnforcementLevel: level, + AllowedContext: AllowedContext{}, + } +} diff --git a/internal/domain/domain_test.go b/internal/domain/domain_test.go new file mode 100644 index 0000000..e016fd0 --- /dev/null +++ b/internal/domain/domain_test.go @@ -0,0 +1,69 @@ +package domain + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +func TestNewManualRejectsEmptyNextAction(t *testing.T) { + _, err := NewManual("", "tests pass", 25*time.Minute) + if err != ErrMissingNextAction { + t.Fatalf("want ErrMissingNextAction, got %v", err) + } +} + +func TestNewManualRejectsEmptySuccessCondition(t *testing.T) { + _, err := NewManual("Refactor state", " ", 25*time.Minute) + if err != ErrMissingSuccessCondition { + t.Fatalf("want ErrMissingSuccessCondition, got %v", err) + } +} + +func TestNewManualRejectsZeroTimebox(t *testing.T) { + _, err := NewManual("Refactor state", "tests pass", 0) + if err != ErrMissingTimebox { + t.Fatalf("want ErrMissingTimebox, got %v", err) + } +} + +func TestNewManualPopulatesDraftCommitment(t *testing.T) { + c, err := NewManual(" Port domain ", "domain tests pass", 25*time.Minute) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if c.NextAction != "Port domain" { + t.Errorf("next action not trimmed: %q", c.NextAction) + } + if c.SuccessCondition != "domain tests pass" { + t.Errorf("success condition wrong: %q", c.SuccessCondition) + } + if c.TimeboxSecs != 1500 { + t.Errorf("timebox secs wrong: %d", c.TimeboxSecs) + } + if c.State != CommitmentDraft { + t.Errorf("state should be draft, got %s", c.State) + } + if c.Source != SourceManual { + t.Errorf("source should be manual, got %s", c.Source) + } + if !strings.HasPrefix(c.ID, "commitment-") { + t.Errorf("id should be prefixed, got %s", c.ID) + } +} + +func TestCommitmentSerializesSnakeCaseEnums(t *testing.T) { + c, _ := NewManual("Port domain", "domain tests pass", 25*time.Minute) + data, err := json.Marshal(c) + if err != nil { + t.Fatalf("marshal failed: %v", err) + } + s := string(data) + if !strings.Contains(s, `"source":"manual"`) { + t.Errorf("missing snake_case source in %s", s) + } + if !strings.Contains(s, `"state":"draft"`) { + t.Errorf("missing snake_case state in %s", s) + } +}