// Package itemwrite is the projax write-side validator. Before Phase 5c // every web/MCP write path handed raw form/MCP input straight to // store.Create / store.Update / store.Reparent and relied on the Postgres // triggers in db/migrations/0001 + 0010 to enforce structural rules. The // trigger then raised an opaque pgErr that callers had to substring-match // to render anything human-readable. // // This package mirrors the trigger's rules in Go so: // // - Handlers fail fast with a typed ValidationError before opening a // transaction. // - The /admin/bulk-apply path pre-flights every row outside the txn โ€” // impossible with SQL-only validation. // - Callers render banner copy keyed on err.Kind, no pgErr substring // matching anywhere in projax. // // The Postgres trigger stays as defence-in-depth. If the trigger ever // rejects something this validator allowed, that's a validator bug and the // raw pgErr surfaces unchanged so the gap is visible. // // See docs/plans/itemwrite-validation.md for the rule-to-Kind enumeration. package itemwrite import ( "context" "fmt" "regexp" "strings" "github.com/m/projax/store" ) // Kind values returned on ValidationError. Each kind matches a rule in // docs/plans/itemwrite-validation.md. const ( KindMissingRequired = "missing-required" KindInvalidSlugFormat = "invalid-slug-format" KindInvalidStatus = "invalid-status" KindSlugCollision = "slug-collision" KindCycle = "cycle" KindSelfParent = "self-parent" KindUnknownParent = "unknown-parent" KindUnresolvablePath = "unresolvable-path" ) // allowedStatuses mirrors db/migrations/0001_init.sql items_status_valid. var allowedStatuses = map[string]struct{}{ "active": {}, "done": {}, "archived": {}, } // slugInvalid matches the inverse of db/migrations/0001_init.sql // items_slug_no_dots. Slugs that contain a dot are rejected; we // additionally reject upper-case and whitespace per the convention // documented in docs/design.md ยง2.2 (slugs are lower-case, no dots). var slugInvalid = regexp.MustCompile(`[A-Z.\s]`) // ValidationError is the typed return from ValidateFormat / // ValidateAgainstStore. Handlers switch on Kind to render banner copy and // pick HTTP status codes; the structured shape is what the MCP error // surfaces back to callers via the JSON-RPC envelope. type ValidationError struct { Kind string Path string // dot-path of the offending item ("dev.paliad", "" if not yet a path) Detail string // human-facing message } func (e *ValidationError) Error() string { if e.Path == "" { return fmt.Sprintf("itemwrite: %s: %s", e.Kind, e.Detail) } return fmt.Sprintf("itemwrite: %s at %s: %s", e.Kind, e.Path, e.Detail) } // Input is the validator's view of a write request. Callers populate it // from form values / MCP args / bulk rows before calling either Validate // method. Empty fields are treated as "not provided" โ€” Update paths leave // fields blank to mean "no change". type Input struct { ID string // empty for Create; populated for Update / Reparent Title string Slug string Status string ParentIDs []string // resolved IDs, not paths // Path is optional context for the error: lets handlers report which // row in a bulk-apply (or which detail page) is broken. Not used by // the validation logic itself. Path string } // Reader is the slice of *store.Store the validator needs. Kept as an // interface so unit tests can stub the DB calls cleanly. type Reader interface { GetByID(ctx context.Context, id string) (*store.Item, error) ListAll(ctx context.Context) ([]*store.Item, error) } // ValidateFormat runs the pure (no DB) checks: required fields, slug // format, status whitelist, and self-parent. Cheap; safe to call inside a // tight loop (e.g. the bulk-apply preflight). func ValidateFormat(in Input) *ValidationError { if strings.TrimSpace(in.Title) == "" { return &ValidationError{Kind: KindMissingRequired, Path: in.Path, Detail: "title is required"} } if strings.TrimSpace(in.Slug) == "" { return &ValidationError{Kind: KindMissingRequired, Path: in.Path, Detail: "slug is required"} } if slugInvalid.MatchString(in.Slug) { return &ValidationError{ Kind: KindInvalidSlugFormat, Path: in.Path, Detail: fmt.Sprintf("slug %q must be lower-case, no dots, no whitespace", in.Slug), } } if in.Status != "" { if _, ok := allowedStatuses[in.Status]; !ok { return &ValidationError{ Kind: KindInvalidStatus, Path: in.Path, Detail: fmt.Sprintf("status %q must be one of active|done|archived", in.Status), } } } if in.ID != "" { for _, pid := range in.ParentIDs { if pid == in.ID { return &ValidationError{ Kind: KindSelfParent, Path: in.Path, Detail: "an item cannot be its own parent", } } } } return nil } // ValidateAgainstStore adds the DB-aware checks: every parent id must // resolve to a live item, the proposed parent_ids must not introduce a // cycle, and no sibling under any common parent already carries this slug. // Mirrors db/migrations/0010_multi_parent.sql trigger logic in Go. // // Callers should run ValidateFormat first โ€” this function assumes the // pure checks already passed. func ValidateAgainstStore(ctx context.Context, r Reader, in Input) *ValidationError { if r == nil { return nil } items, err := r.ListAll(ctx) if err != nil { // DB unreachable: surface as raw error rather than masquerading as // validation. The caller wraps this; it's not a ValidationError. return nil } byID := make(map[string]*store.Item, len(items)) for _, it := range items { byID[it.ID] = it } // Rule 10: every parent_id must resolve to a live item. for _, pid := range in.ParentIDs { if _, ok := byID[pid]; !ok { return &ValidationError{ Kind: KindUnknownParent, Path: in.Path, Detail: fmt.Sprintf("parent id %q does not resolve to a live item", pid), } } } // Rules 6/7: cycle detection. Walk the ancestor closure starting from // each proposed parent_id and look for in.ID. Cap at 64 hops to mirror // the trigger's safety rail. if in.ID != "" && len(in.ParentIDs) > 0 { seen := map[string]struct{}{} queue := append([]string{}, in.ParentIDs...) hops := 0 for len(queue) > 0 && hops < 64 { next := queue[0] queue = queue[1:] if next == in.ID { return &ValidationError{ Kind: KindCycle, Path: in.Path, Detail: "the proposed parent_ids would put this item in its own ancestor closure", } } if _, ok := seen[next]; ok { continue } seen[next] = struct{}{} it, ok := byID[next] if !ok { continue } queue = append(queue, it.ParentIDs...) hops++ } } // Rules 8/9: slug uniqueness. For each parent_id, check whether any // sibling under that parent already carries the same slug. Roots // (parent_ids empty) check against other roots. if len(in.ParentIDs) == 0 { for _, it := range items { if len(it.ParentIDs) != 0 { continue } if it.ID == in.ID { continue } if it.Slug == in.Slug { return &ValidationError{ Kind: KindSlugCollision, Path: in.Path, Detail: fmt.Sprintf("a root item with slug %q already exists", in.Slug), } } } } else { parentSet := make(map[string]struct{}, len(in.ParentIDs)) for _, pid := range in.ParentIDs { parentSet[pid] = struct{}{} } for _, it := range items { if it.ID == in.ID { continue } if it.Slug != in.Slug { continue } for _, sibPID := range it.ParentIDs { if _, common := parentSet[sibPID]; common { return &ValidationError{ Kind: KindSlugCollision, Path: in.Path, Detail: fmt.Sprintf("slug %q already exists under parent %q", in.Slug, sibPID), } } } } } return nil }