// 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 other live item already carries this slug (per-user-global // uniqueness — Phase 6 / Q6=a). // // The Reader is satisfied by both the legacy *store.Store and the slice-B // *store.MBrianReader, so cycle + collision detection runs against // whichever backend PROJAX_BACKEND selects. Handlers pass s.Items here, not // s.Store, so a write pre-flight never validates against the wrong dataset. // // 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 (Phase 6 / Q6=a): slug uniqueness is per-user-global, not // per-parent. mBrian's idx_nodes_slug enforces (user_id, slug) // uniqueness, so two projax items can never share a slug regardless of // where they sit in the DAG — the old "two paliads under different // roots" case no longer holds (m confirmed he doesn't rely on it). This // is strictly tighter than the legacy per-parent rule, so it stays // correct on the legacy *Store backend too (the projax.items per-parent // index is never reached because this pre-flight rejects first). for _, it := range items { if it.ID == in.ID { continue } if it.Slug == in.Slug { return &ValidationError{ Kind: KindSlugCollision, Path: in.Path, Detail: fmt.Sprintf("an item with slug %q already exists", in.Slug), } } } return nil }