Files
hyperguild/ingestion/internal/classification/classification.go
mathiasandClaude Opus 5.5 aee5f66398 fix(pipeline,classification): read the brain through brainfs (#157, slice 6)
The ingest pipeline read wiki/sources/<slug>.md and schema.md (both go
into the LLM prompt), the pages it revises, and the pages it adds
source refs to, with os.ReadFile; BackfillRefs listed wiki/sources with
os.ReadDir. A symlinked file's content reached the prompt and the
written pages (1.6M leaks in the race), and a FIFO hung the ingest.
classification.Load (the I1 sovereignty levels) read
classification.yaml the same way and hung startup on a FIFO.

- pipeline: readSourcePage, loadSchema, reviseOrCreate, injectSourceRefs
  read through readBrainFile (brainfs ReadRegular); reviseOrCreate takes
  the brain dir and reads page.Path, a missing page is still created and
  a non-regular one is an error. BackfillRefs lists with ReadDirRegular
  and reads with ReadRegular.
- classification.Load: read through brainfs; a symlinked or special
  classification.yaml is a hard error (as a malformed one is); an absent
  file or brain dir still yields the default config.

Tests use in-brain targets and the [[slug|title]] link form the pipeline
actually follows (bare [[slug]] links are ignored by it, which made a
first draft of the backfill test vacuous).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 20:47:57 +02:00

202 lines
5.9 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 (
"errors"
"fmt"
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
"io/fs"
"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{}}
// Read through the brain root, regular files only (hyperguild#157): a
// symlinked or special file is not trusted as the config, which is a hard
// error like a malformed one.
bfs, err := brainfs.Open(brainDir)
if errors.Is(err, fs.ErrNotExist) {
return cfg, nil
}
if err != nil {
return nil, fmt.Errorf("open brain: %w", err)
}
defer func() { _ = bfs.Close() }()
data, err := bfs.ReadRegular("classification.yaml")
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
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))
}