package audit import ( "context" "fmt" "github.com/mathiasbq/hyperguild/ingestion/internal/capture" "github.com/mathiasbq/hyperguild/ingestion/internal/classification" ) // Central is the central audit substrate (loki). Ready is a cheap // reachability probe used by the pre-write reserve; Push writes a record. type Central interface { Ready(ctx context.Context) error Push(ctx context.Context, e capture.AuditEntry) error } // Buffer is the durable local fallback for internal/public-tier records // when the central sink is unreachable. It must survive process restart. type Buffer interface { // Writable reports whether the buffer can currently be appended to. Writable() error Append(e capture.AuditEntry) error // Pending returns buffered records awaiting reconciliation, each with a // stable ID used to Confirm (delete) it after a confirmed central write. Pending() ([]Buffered, error) Confirm(id string) error } // Buffered is a buffered audit record plus its stable buffer ID. type Buffered struct { ID string Entry capture.AuditEntry } // Notifier raises an out-of-band alert (ntfy) about a degraded state. type Notifier interface { Notify(ctx context.Context, msg string) error } // DegradingSink is the classification-aware AuditSink (§4.4): // // - central reachable → AuditCentral (all tiers). // - central down + confidential → refuse (no buffer): confidential must // be centrally auditable at write time. // - central down + internal/public + buffer writable → AuditBuffered. // - central down + (confidential, or buffer not writable) → refuse (floor). // // The decision is made in Reserve, before any write; Record then executes it. type DegradingSink struct { central Central buffer Buffer notifier Notifier } // NewDegradingSink wires the central sink, durable buffer, and notifier. func NewDegradingSink(central Central, buffer Buffer, notifier Notifier) *DegradingSink { return &DegradingSink{central: central, buffer: buffer, notifier: notifier} } // Reserve decides, before any write, how the capture will be audited — or // returns an error to refuse it. func (d *DegradingSink) Reserve(ctx context.Context, level classification.Level) (capture.AuditOutcome, error) { if err := d.central.Ready(ctx); err == nil { return capture.AuditCentral, nil } // Central sink is down. if level == classification.Confidential { return 0, fmt.Errorf("confidential capture requires the central audit sink, which is unreachable") } if err := d.buffer.Writable(); err != nil { // Floor: neither central nor local buffer can record the audit. return 0, fmt.Errorf("audit floor: central sink down and local buffer unwritable: %w", err) } return capture.AuditBuffered, nil } // Record persists the entry per the reserved outcome. For AuditBuffered it // also fires the degraded-state alert. func (d *DegradingSink) Record(ctx context.Context, e capture.AuditEntry, outcome capture.AuditOutcome) error { switch outcome { case capture.AuditBuffered: if err := d.buffer.Append(e); err != nil { return fmt.Errorf("buffer audit record: %w", err) } // Best-effort alert; the record is already durably buffered. if d.notifier != nil { _ = d.notifier.Notify(ctx, fmt.Sprintf( "capture audit BUFFERED LOCALLY (loki unreachable) — principal=%s class=%s items=%d", e.Principal, e.EffectiveClassification, len(e.Items))) } return nil default: return d.central.Push(ctx, e) } }