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
}