package core import ( "fmt" "strings" "gopkg.in/yaml.v3" ) // Status is the authored lifecycle marker of a document. // // The enum is exactly draft/review/superseded. "approved" is deliberately not a // status: a document is approved when it is reachable from the approved ref, // and that is the whole definition. Had "approved" stayed here, either the // approved branch would permanently carry `status: draft`, or the merge would // have to rewrite frontmatter nobody authored — which would also invalidate the // agent's If-Match base on its next read. type Status string const ( StatusDraft Status = "draft" StatusReview Status = "review" StatusSuperseded Status = "superseded" ) // DefaultStatuses returns the full status enum, in the order a space's // `.spec.yml` would list it. A fresh slice each call, so a caller cannot mutate // the default out from under everyone else. func DefaultStatuses() []Status { return []Status{StatusDraft, StatusReview, StatusSuperseded} } // ParseStatus validates a status string against the enum. func ParseStatus(s string) (Status, error) { switch Status(s) { case StatusDraft, StatusReview, StatusSuperseded: return Status(s), nil } return "", fmt.Errorf("%w: %q is not one of %s", ErrInvalidStatus, s, joinStatuses(DefaultStatuses())) } func joinStatuses(ss []Status) string { parts := make([]string, len(ss)) for i, s := range ss { parts[i] = string(s) } return strings.Join(parts, "|") } // Frontmatter is the YAML header of a document. The modelled fields are the // ones the service reasons about; anything else an author writes is preserved // in git and reported by Present, but is not interpreted here. type Frontmatter struct { ID string `yaml:"id"` Title string `yaml:"title"` Status Status `yaml:"status"` Supersedes string `yaml:"supersedes"` Owners []string `yaml:"owners"` Tags []string `yaml:"tags"` Type string `yaml:"type"` Summary string `yaml:"summary"` // Present is the set of top-level keys that actually appeared in the // source. Required-key validation asks about presence, not emptiness: // `title:` with no value is present and fails for being blank, whereas an // absent `title:` is a different error with a different fix. A zero-valued // struct field cannot tell those apart. Present map[string]bool `yaml:"-"` } // Has reports whether key appeared in the parsed source. Nil-safe, so a // hand-constructed Frontmatter reports every key as absent rather than panicking. func (f Frontmatter) Has(key string) bool { return f.Present[key] } const ( frontDelim = "---" // docEndDelim is YAML's explicit end-of-document marker. Editors emit it // occasionally, and treating it as a closing fence costs one comparison. docEndDelim = "..." ) // SplitFrontmatter separates the YAML frontmatter block from the markdown body. // The document must open with a "---" line and close the block with a "---" // (or "...") line; both delimiters are excluded from the returned slices. // // It is strict on purpose. A document whose frontmatter is unterminated would // otherwise parse as a body-only document with no ID, silently dropping out of // the registry and the index instead of failing at the door. func SplitFrontmatter(src []byte) (front, body []byte, err error) { s := string(src) if strings.HasPrefix(s, "\ufeff") { return nil, nil, fmt.Errorf("%w: document starts with a UTF-8 BOM before the %q delimiter", ErrMalformedFrontmatter, frontDelim) } first, rest, hasMore := strings.Cut(s, "\n") if strings.TrimSuffix(first, "\r") != frontDelim { return nil, nil, fmt.Errorf("%w: document does not start with a %q delimiter", ErrMalformedFrontmatter, frontDelim) } if !hasMore { return nil, nil, fmt.Errorf("%w: frontmatter block is not terminated by %q", ErrMalformedFrontmatter, frontDelim) } for pos := 0; pos <= len(rest); { line, tail, ok := strings.Cut(rest[pos:], "\n") switch strings.TrimSuffix(line, "\r") { case frontDelim, docEndDelim: return []byte(rest[:pos]), []byte(tail), nil } if !ok { break } pos = len(rest) - len(tail) } return nil, nil, fmt.Errorf("%w: frontmatter block is not terminated by %q", ErrMalformedFrontmatter, frontDelim) } // ParseFrontmatter decodes a frontmatter block into a Frontmatter, recording // which keys were present. // // Duplicate keys are rejected explicitly rather than last-one-wins: a document // carrying two `id:` lines is exactly the typo that corrupts the global ID // registry, and it is far cheaper to reject at the door than to find weeks later. func ParseFrontmatter(front []byte) (Frontmatter, error) { var node yaml.Node if err := yaml.Unmarshal(front, &node); err != nil { return Frontmatter{}, fmt.Errorf("%w: %v", ErrMalformedFrontmatter, err) } if node.Kind == yaml.DocumentNode { if len(node.Content) != 1 { return Frontmatter{}, fmt.Errorf("%w: expected exactly one YAML document, got %d", ErrMalformedFrontmatter, len(node.Content)) } node = *node.Content[0] } // An empty block is well-formed YAML and yields a zero node. It is not a // parse failure; it fails schema validation instead, which names the // missing keys and is the more useful error. if node.Kind == 0 { return Frontmatter{Present: map[string]bool{}}, nil } if node.Kind != yaml.MappingNode { return Frontmatter{}, fmt.Errorf("%w: frontmatter must be a YAML mapping", ErrMalformedFrontmatter) } present := make(map[string]bool, len(node.Content)/2) for i := 0; i+1 < len(node.Content); i += 2 { key := node.Content[i] if key.Kind != yaml.ScalarNode { return Frontmatter{}, fmt.Errorf("%w: non-scalar key at line %d", ErrMalformedFrontmatter, key.Line) } if present[key.Value] { return Frontmatter{}, fmt.Errorf("%w: duplicate key %q at line %d", ErrMalformedFrontmatter, key.Value, key.Line) } present[key.Value] = true } var fm Frontmatter if err := node.Decode(&fm); err != nil { return Frontmatter{}, fmt.Errorf("%w: %v", ErrMalformedFrontmatter, err) } fm.Present = present return fm, nil } // ParseDocument splits and parses a whole document, returning its frontmatter // and its markdown body. func ParseDocument(src []byte) (Frontmatter, []byte, error) { front, body, err := SplitFrontmatter(src) if err != nil { return Frontmatter{}, nil, err } fm, err := ParseFrontmatter(front) if err != nil { return Frontmatter{}, nil, err } return fm, body, nil } // Schema is a space's frontmatter contract, as carried by `.spec.yml`. It is // enforced at propose time and at push time — schema validation at the door is // the cheapest available defence against agent slop. type Schema struct { Required []string `yaml:"required"` Status []Status `yaml:"status"` } // DefaultSchema is the contract a space gets when its `.spec.yml` says nothing. func DefaultSchema() Schema { return Schema{ Required: []string{"id", "title", "status"}, Status: DefaultStatuses(), } } // Validate reports whether the schema itself is usable. The status list is the // interesting half: a space may narrow the enum (say, forbid `review`) but may // not invent a member, which is what keeps "approved" from creeping back in via // a per-space config file. func (s Schema) Validate() error { seenKey := make(map[string]bool, len(s.Required)) for _, key := range s.Required { if key == "" { return fmt.Errorf("%w: schema.required contains an empty key", ErrInvalidPolicy) } if seenKey[key] { return fmt.Errorf("%w: schema.required lists %q twice", ErrInvalidPolicy, key) } seenKey[key] = true } if len(s.Status) == 0 { return fmt.Errorf("%w: schema.status must list at least one status", ErrInvalidPolicy) } seenStatus := make(map[Status]bool, len(s.Status)) for _, st := range s.Status { if _, err := ParseStatus(string(st)); err != nil { return fmt.Errorf("%w: schema.status: %v", ErrInvalidPolicy, err) } if seenStatus[st] { return fmt.Errorf("%w: schema.status lists %q twice", ErrInvalidPolicy, st) } seenStatus[st] = true } return nil } // AllowsStatus reports whether st is permitted by this schema. func (s Schema) AllowsStatus(st Status) bool { for _, allowed := range s.Status { if st == allowed { return true } } return false } // ValidateFrontmatter checks a parsed document header against the schema. It // answers "may this document be proposed into this space", and every write path // (REST, MCP, and the update hook on your own pushes) runs it. func (s Schema) ValidateFrontmatter(fm Frontmatter) error { for _, key := range s.Required { if !fm.Has(key) { return fmt.Errorf("%w: %q", ErrMissingField, key) } } if fm.Has("id") { if err := ValidateDocID(fm.ID); err != nil { return err } } if fm.Has("title") && strings.TrimSpace(fm.Title) == "" { return fmt.Errorf("%w: %q is present but blank", ErrMissingField, "title") } if fm.Has("status") && !s.AllowsStatus(fm.Status) { return fmt.Errorf("%w: %q is not one of %s", ErrInvalidStatus, fm.Status, joinStatuses(s.Status)) } // `supersedes:` with no value is a half-finished edit, not an absent key, // so it is rejected rather than ignored. if fm.Has("supersedes") { if err := ValidateDocID(fm.Supersedes); err != nil { return fmt.Errorf("supersedes: %w", err) } } for _, owner := range fm.Owners { if err := ValidateOwner(strings.TrimPrefix(owner, "~")); err != nil { return fmt.Errorf("owners: %w", err) } } return nil }