// Package classification defines the data-sensitivity taxonomy and the // per-wing / per-repo tagging the capture server reads to enforce the I1 // sovereignty gate (issue #50, capture spec §4.1). // // The single load-bearing property is fail-safe-to-strictest: a target // with no explicit tag and no known default classifies as Confidential, // never as something more permissive. A missing tag must never silently // downgrade — that would turn the I1 gate into theatre. // // Classification is read from an optional classification.yaml at the // brain root. A central, Flux-reconcilable file is deliberate: it is // auditable in one place (I2/I5), it does not require a live Gitea client // to classify a repo (so this package has no dependency on the gitea // tracker work), and it avoids tagging a wing's _index.md frontmatter — // which BuildWingIndex regenerates and would clobber. package classification import ( "fmt" "os" "path/filepath" "strings" "gopkg.in/yaml.v3" ) // Level is a data-sensitivity tier. Higher is stricter, so the "stricter // wins" rule (spec §4.1 model C) is a plain max. type Level int const ( Public Level = iota Internal Confidential ) // String returns the canonical lowercase token for a level. func (l Level) String() string { switch l { case Public: return "public" case Internal: return "internal" case Confidential: return "confidential" default: return fmt.Sprintf("level(%d)", int(l)) } } // ParseLevel parses a level token (case-insensitive, surrounding space // tolerated). An unknown token is an error — callers must decide what to // do with bad input rather than have it silently coerced. func ParseLevel(s string) (Level, error) { switch strings.ToLower(strings.TrimSpace(s)) { case "public": return Public, nil case "internal": return Internal, nil case "confidential": return Confidential, nil default: return Confidential, fmt.Errorf("unknown classification level %q (want public/internal/confidential)", s) } } // Stricter returns the more restrictive of two levels. func Stricter(a, b Level) Level { if a > b { return a } return b } // TargetKind distinguishes the two kinds of capture destination. type TargetKind int const ( WingTarget TargetKind = iota // a brain wing (insights land here) RepoTarget // a Gitea repo (tickets / summaries land here) ) // Target names a capture destination to classify. type Target struct { Kind TargetKind Name string } // Config holds the explicit per-wing / per-repo classification tags read // from classification.yaml. Absent entries fall through to the built-in // defaults in defaultFor. The zero value (no file) is valid and applies // defaults to everything. type Config struct { wings map[string]Level repos map[string]Level } // rawConfig is the on-disk YAML shape: string→string maps, parsed into // validated levels by Load. type rawConfig struct { Wings map[string]string `yaml:"wings"` Repos map[string]string `yaml:"repos"` } // Load reads classification.yaml from brainDir. An absent file is not an // error — it yields an empty config where every target classifies by the // built-in defaults. A malformed file, or any unparseable level token in // it, is a hard error: a classification source the server cannot trust // must fail loud, not degrade silently. func Load(brainDir string) (*Config, error) { cfg := &Config{wings: map[string]Level{}, repos: map[string]Level{}} data, err := os.ReadFile(filepath.Join(brainDir, "classification.yaml")) if err != nil { if os.IsNotExist(err) { return cfg, nil } return nil, fmt.Errorf("read classification.yaml: %w", err) } var raw rawConfig if err := yaml.Unmarshal(data, &raw); err != nil { return nil, fmt.Errorf("parse classification.yaml: %w", err) } for name, lvl := range raw.Wings { parsed, perr := ParseLevel(lvl) if perr != nil { return nil, fmt.Errorf("wing %q: %w", name, perr) } cfg.wings[normalise(name)] = parsed } for name, lvl := range raw.Repos { parsed, perr := ParseLevel(lvl) if perr != nil { return nil, fmt.Errorf("repo %q: %w", name, perr) } cfg.repos[normalise(name)] = parsed } return cfg, nil } // Derive returns the classification for any target — the function the // capture use-case calls per item. func (c *Config) Derive(t Target) Level { if t.Kind == RepoTarget { return c.Repo(t.Name) } return c.Wing(t.Name) } // Wing classifies a brain wing: an explicit tag wins, else defaults. func (c *Config) Wing(name string) Level { if lvl, ok := c.wings[normalise(name)]; ok { return lvl } return defaultFor(name) } // Repo classifies a Gitea repo: an explicit tag wins, else defaults. func (c *Config) Repo(name string) Level { if lvl, ok := c.repos[normalise(name)]; ok { return lvl } return defaultFor(name) } // defaultFor applies the built-in defaulting rules when a target has no // explicit tag: // - client-* → Confidential (client work is confidential by default) // - hyperguild / homelab → Internal (the operator's own infra) // - everything else → Confidential (fail safe to strictest) func defaultFor(name string) Level { n := normalise(name) if strings.HasPrefix(n, "client-") { return Confidential } switch n { case "hyperguild", "homelab": return Internal default: return Confidential } } // normalise lowercases and trims a wing/repo name so matching and the // client-* prefix check are case-insensitive. func normalise(name string) string { return strings.ToLower(strings.TrimSpace(name)) }