Implements the I1-prerequisite from #49/#50: the classification taxonomy and the per-wing / per-repo tagging the capture server reads to derive a target's sensitivity. - Levels public < internal < confidential, ordered so "stricter wins" (spec §4.1 model C) is a plain max via Stricter(). - Tags read from an optional classification.yaml at the brain root (wings:/repos: maps). Absent file → defaults-only, not an error. - Defaulting: client-* → confidential; hyperguild/homelab → internal; everything else → confidential. Fail-safe-to-strictest is the load-bearing property: a missing tag never silently downgrades. - Config.Derive(Target) is the function the use-case calls; Wing/Repo are the per-kind helpers. ParseLevel rejects unknown tokens; a bad level in the config file is a hard load error. Central classification.yaml (not _index.md frontmatter, not gitea repo topics): classifying a repo needs no live Gitea client, so #50 has no dependency on the tracker work (#52); it's auditable in one place; and it avoids BuildWingIndex clobbering a wing's regenerated _index.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
190 lines
5.4 KiB
Go
190 lines
5.4 KiB
Go
// 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))
|
|
}
|