package models import ( "fmt" "strings" "time" "unicode/utf8" ) // Valid document types var ValidDocTypes = []string{ "roadmap", "plan", "architecture", "ideation", "feature-spec", "notes", "prompt-library", "reference", } // Valid document statuses var ValidStatuses = []string{ "draft", "active", "completed", "archived", } // Valid actors var ValidActors = []string{"user", "agent"} // Valid sources var ValidSources = []string{"cli", "web", "skill"} type Document struct { ID string `json:"id"` WorkspaceID string `json:"workspace_id"` Title string `json:"title"` Slug string `json:"slug"` Content string `json:"content"` DocType string `json:"doc_type"` Status string `json:"status"` Tags string `json:"tags"` // JSON array Pinned bool `json:"pinned"` SortOrder int `json:"sort_order"` CreatedBy string `json:"created_by"` LastModifiedBy string `json:"last_modified_by"` Source string `json:"source"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt *time.Time `json:"deleted_at,omitempty"` } type DocumentCreate struct { Title string `json:"title"` Content string `json:"content,omitempty"` DocType string `json:"doc_type,omitempty"` Status string `json:"status,omitempty"` Tags string `json:"tags,omitempty"` Pinned bool `json:"pinned,omitempty"` CreatedBy string `json:"created_by,omitempty"` Source string `json:"source,omitempty"` } type DocumentUpdate struct { Title *string `json:"title,omitempty"` Content *string `json:"content,omitempty"` DocType *string `json:"doc_type,omitempty"` Status *string `json:"status,omitempty"` Tags *string `json:"tags,omitempty"` Pinned *bool `json:"pinned,omitempty"` SortOrder *int `json:"sort_order,omitempty"` LastModifiedBy string `json:"last_modified_by,omitempty"` Source string `json:"source,omitempty"` ChangeSummary string `json:"change_summary,omitempty"` } type DocumentListParams struct { Type string Status string Tag string Pinned *bool Query string Sort string Order string } // MaxDocumentTitleRunes bounds a document title at write time. // // Two reasons, and the second is the load-bearing one: // // - A title is emitted into every linking document by the rename cascade // (`[[newTitle]]` per occurrence), so its length is an amplification // factor on a body the renamer does not supply. Unbounded, one rename // could project 10 GB from a 500 KB input — measured, 20,000x (BUG-2798). // - 255 is the conventional identifier-ish bound and comfortably above any // real title; the longest document title this codebase seeds is far short // of it. // // RUNES, not bytes, because "255 characters" is what a user and a UI counter // mean. That makes the byte-level residual up to 4x this number, which is // precisely why the title bound is the cheap door and NOT the wall: the // cascade's own guard (store.MaxRenameCascadeRetainedBytes) is byte-accurate // and is what actually bounds the work. // // Enforced at WRITE time only. Titles already over the bound stay valid and // keep working until their next rename — no retro-breakage of stored data // (Dave's ruling, day-63). const MaxDocumentTitleRunes = 255 // wikiTitleRoundTripFailure reports why emitting `[[title]]` by plain // concatenation, no escaping — the way links.ReplaceTitle did until BUG-2806 // made it emit the escaped form — would produce a bracket that does not read // back as this title. Returns "" when the title survives the round trip. // // BUG-2806 also made the document rename cascade find a link stored in // escaped form, which is the reason given below for refusing `|` and `\`. // That reason no longer holds for the cascade; the refusal stands for the // other consumers, and its message now says so (see the branch below). // // This is BUG-2796 stated as a property rather than as a character blacklist, // and the distinction is not cosmetic: the first version of this function // banned `]`, `\` and `|` on the reasoning that all three "look like // wiki-link syntax", and the test below refuted two thirds of that. The rule // is therefore derived from the two mechanisms that actually consume a stored // bracket, both in web/src/lib/utils/markdown.ts: // // 1. THE GRAMMAR (markdown.ts::WIKI_LINK_PATTERN_SOURCE, shared by renderMarkdown and // wikiLinksToMarkdown since BUG-1744): a bracket body is // `(?:\\.|[^\]\\])+` — escape pairs, or anything that is neither `]` nor // `\`. A raw `]` ends the bracket early, which IS BUG-2796's defect: // renaming to `A]] [[A` emitted `[[A]] [[A]]`, two brackets, neither // resolving to the renamed document, and the rename reported success. A // title ending in `\` fails the same way — the trailing backslash pairs // with the first `]` of the terminator and the bracket never closes. // // 2. THE UNESCAPER (markdown.ts::unescapeWikiBody, `\\(\\|\]|\|)` → `$1`): resolution // unescapes the body before comparing it to a title, and the editor may // therefore STORE a link in escaped form. That matters twice over: a // title containing `\\` or `\|` emitted raw comes back as a different // string, AND a link stored as `[[Alpha\|Beta]]` is invisible to the // rename cascade, which searches for the raw `[[Alpha|Beta]]` only. // // The second half is why `|` and a lone `\` are refused (codex round 9), // having been ALLOWED in the first version of this validator. The property // tested there was "does the renderer read this back as the same title", and // both characters pass it. That was the wrong property: a title also has to be // one whose links the cascade can FIND, or a rename silently leaves them // pointing at a name that no longer exists. The stricter property is the one // that matters, and it is the same mistake as validating against the renderer // while the cascade used an unescaped LIKE — checking the layer that displays // a title instead of the layer that has to maintain it. // // Deliberately NOT rejected, because the code these titles pass through // handles them and refusing them would be a validator inventing a defect: // // - `[` — the grammar excludes only `]` and `\`, so `[[A[B]]` carries the // body `A[B` intact, and the cascade's search term matches it literally. // BUG-2796's filing proposed rejecting "`[[` or `]]`"; measured against // the grammar, the `[[` half of that is overreach. // // Boundary, stated rather than papered over: this is derived from the SHARED // stored-syntax path in markdown.ts. The legacy documents surface has no // renderer of its own that I could locate — every wiki-link consumer found // routes through these two functions — so the rule is pinned to them. func wikiTitleRoundTripFailure(title string) string { if strings.Contains(title, "]") { return `Title may not contain "]" — it would end the [[wiki-links]] that point at this document early, ` + `turning them into broken links` } if strings.HasSuffix(title, `\`) { return `Title may not end with "\" — it would escape the closing bracket of the [[wiki-links]] that point ` + `at this document` } // The reason given to the caller is the TRUE one (lead ruling, BUG-2806). // It used to be "the rename cascade would not find" their escaped links, // which BUG-2806 made false. The refusal stays anyway: lifting it needs // every wiki-link consumer (web rendering and backlinks, MCP, search, the // CLI) to resolve the escaped forms first, which is its own filed item. if strings.ContainsAny(title, `\|`) { return `Title may not contain "\" or "|" — these characters are not supported in document titles yet` } return "" } // ValidateDocumentTitle checks a document title at write time. Returns a // message suitable for a 400 response, or "" when the title is acceptable. // // Covers BUG-2798 (length, which is an amplification factor on OTHER // documents' bodies, not merely a field-size preference) and BUG-2796 // (wiki-link syntax the cascade emits raw). Both are write-time doors on the // same field, which is why they share one validator and one insertion point. func ValidateDocumentTitle(title string) string { if title == "" { return "Title is required" } if n := utf8.RuneCountInString(title); n > MaxDocumentTitleRunes { return fmt.Sprintf("Title is too long: %d characters, maximum %d", n, MaxDocumentTitleRunes) } return wikiTitleRoundTripFailure(title) } func IsValidDocType(t string) bool { for _, v := range ValidDocTypes { if v == t { return true } } return false } func IsValidStatus(s string) bool { for _, v := range ValidStatuses { if v == s { return true } } return false } func IsValidActor(a string) bool { return a == "user" || a == "agent" } func IsValidSource(s string) bool { return s == "cli" || s == "web" || s == "skill" }