Files
pad/internal/models/document.go
T
xarmian 71fa167614 fix(documents): the rename cascade decides a link by the wiki grammar, so a legacy title containing | \ or ] keeps its links and no longer rewrites a link to another target (BUG-2806) (#1572)
* fix(documents): the rename cascade matches a legacy title's links in their escaped form, so a title containing | \ or ] no longer leaves its links stale or rewrites a link to another target (BUG-2806)

* test(documents): a linker holding only the escaped link must be found by the scan (BUG-2806)

* docs(links,models): the ReplaceTitle limitation notes reflect the escaped cascade (BUG-2806)

* fix(documents): a link is decided by the wiki grammar, so every encoding of a legacy title's link is rewritten, and the scan allocates nothing under the cascade's memory bound (BUG-2806)

* refactor(links): drop the replaceAll helper ReplaceTitle no longer uses, keeping its termination reasoning on ReplaceTitle (BUG-2806)

* fix(documents): the refusal of | and \ in a new title gives its true reason, since the rename cascade now finds their escaped links (BUG-2806)
2026-09-26 10:52:41 -04:00

220 lines
8.9 KiB
Go

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"
}