// Package emission delivers the decision-record emission guarantee declared in // cadence.yaml (FLEX-WP-0031). A decision is released only after its event is // durably committed to a local outbox (FLEX-DEC-2026-018); draining to // audit-core is asynchronous. package emission import ( "bufio" "bytes" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "sync" "time" "github.com/netkingdom/access-engine/pkg/api" ) // ClassPrefix prefixes every decision event class; the suffix is the effect. const ClassPrefix = "flex-auth.decision." // PlatformTenant is sent when a decision names no tenant, because audit-core // requires one on every event. const PlatformTenant = "tenant:platform" const outboxFile = "outbox.jsonl" const MaxOutboxBytes int64 = 64 * 1024 * 1024 const MaxEntryBytes = 2 * 1024 * 1024 const MaxBatchBytes = MaxEntryBytes var ErrCapacity = errors.New("outbox capacity exceeded; committed evidence retained") // Class returns the event class for a decision effect. func Class(effect api.DecisionEffect) string { return ClassPrefix + string(effect) } // Event is one audit-core.event envelope: exactly the fields audit-core's // normalize() requires. Data carries the decision record itself. type Event struct { ID string `json:"id"` Type string `json:"type"` Source string `json:"source"` Subject string `json:"subject"` Tenant string `json:"tenant"` CorrelationID string `json:"correlation_id"` OccurredAt string `json:"occurred_at"` Data any `json:"data"` } // entry is one committed outbox line. type entry struct { Seq uint64 `json:"seq"` CommittedAt string `json:"committed_at"` Event Event `json:"event"` } // Status is the local half of reconciliation: what this outbox committed, per // class, and what it released without committing. type Status struct { Source string `json:"source"` MaxBytes int64 `json:"max_bytes"` CommittedBytes int64 `json:"committed_bytes"` RemainingBytes int64 `json:"remaining_bytes"` WriterFenced bool `json:"writer_fenced"` CapacityExhausted bool `json:"capacity_exhausted"` Committed map[string]uint64 `json:"committed"` ReleasedUncommitted map[string]uint64 `json:"released_uncommitted"` // ReleasedUncommitted is held in memory: a restart during an outbox outage // loses it (FLEX-DEC-2026-018, consequences). Means string `json:"means"` Pending uint64 `json:"pending"` OldestPendingAt string `json:"oldest_pending_at,omitempty"` LagExceeded bool `json:"lag_exceeded"` } // Outbox is a durable, append-only, fsync-per-commit event log. type Outbox struct { source string path string clock func() time.Time mu sync.Mutex file *os.File writerLock *os.File maxBytes int64 closed bool writeFault bool seq uint64 size int64 // bytes committed; a failed commit is cut back to it committed map[string]uint64 uncommitted map[string]uint64 drainMu sync.Mutex cursor drainCursor heartbeats map[string]time.Time } // Open opens or creates the outbox in dir and rebuilds committed counts from it. func Open(dir, source string) (*Outbox, error) { return OpenBounded(dir, source, MaxOutboxBytes) } // OpenBounded acquires OS writer exclusion before inspecting/recovering evidence. // The selected limit cannot exceed the reviewed 64MiB source ceiling. func OpenBounded(dir, source string, maxBytes int64) (*Outbox, error) { if maxBytes < 1 || maxBytes > MaxOutboxBytes { return nil, errors.New("outbox byte limit must be between 1 and 67108864") } if source == "" { return nil, errors.New("emission source is required") } if err := os.MkdirAll(dir, 0o750); err != nil { return nil, fmt.Errorf("create outbox directory: %w", err) } writerLock, err := acquireWriterLock(filepath.Join(dir, "writer.lock")) if err != nil { return nil, err } success := false defer func() { if !success { writerLock.Close() } }() o := &Outbox{ source: source, writerLock: writerLock, maxBytes: maxBytes, path: filepath.Join(dir, outboxFile), clock: time.Now, committed: map[string]uint64{}, uncommitted: map[string]uint64{}, heartbeats: map[string]time.Time{}, } if err := o.loadCursor(); err != nil { return nil, err } if err := o.load(); err != nil { return nil, err } if o.cursor.Seq > o.seq || o.cursor.Offset > o.size { return nil, errors.New("drain cursor is ahead of committed outbox") } file, err := openOwnedFile(o.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o640) if err != nil { return nil, fmt.Errorf("open outbox: %w", err) } o.file = file if err := syncOutboxDirectory(dir); err != nil { file.Close() return nil, err } success = true return o, nil } func (o *Outbox) load() error { file, err := openOwnedFile(o.path, os.O_RDONLY, 0) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return fmt.Errorf("read outbox: %w", err) } defer file.Close() info, err := file.Stat() if err != nil { return err } if !info.Mode().IsRegular() { return errors.New("outbox must be a regular file") } if info.Size() > o.maxBytes { return ErrCapacity } reader := bufio.NewReaderSize(file, 64*1024) var good int64 for line := 1; ; line++ { raw, err := boundedLine(reader) if err == io.EOF { // Only an unterminated final write is uncommitted. Never discard a // newline-terminated corrupt or oversized record. o.size = good if len(raw) > 0 { if err := os.Truncate(o.path, good); err != nil { return err } if err := file.Sync(); err != nil { return err } } break } if err != nil { return fmt.Errorf("outbox line %d: %w", line, err) } var e entry decoder := json.NewDecoder(bytes.NewReader(raw)) decoder.UseNumber() if err := decoder.Decode(&e); err != nil { return fmt.Errorf("outbox line %d is corrupt: %w", line, err) } if _, err := decoder.Token(); err != io.EOF { return errors.New("trailing outbox entry data") } if e.Seq != o.seq+1 || e.Event.Source != o.source { return fmt.Errorf("outbox line %d has inconsistent sequence or source", line) } o.seq = e.Seq o.committed[e.Event.Type]++ good += int64(len(raw)) if e.Seq == o.cursor.Seq && good != o.cursor.Offset { return errors.New("drain cursor does not match outbox") } o.noteHeartbeat(e.Event) } if o.cursor.Seq > o.seq { return errors.New("drain cursor is ahead of outbox") } return nil } // boundedLine bounds startup and delivery allocations independently of log size. // Its 2MiB limit includes the record newline. func boundedLine(reader *bufio.Reader) ([]byte, error) { var line []byte for { part, err := reader.ReadSlice('\n') if len(line)+len(part) > MaxEntryBytes { return nil, ErrCapacity } line = append(line, part...) if err == bufio.ErrBufferFull { continue } return line, err } } // SetClock overrides the commit clock. Tests use it. func (o *Outbox) SetClock(clock func() time.Time) { o.mu.Lock() defer o.mu.Unlock() o.clock = clock } // Close closes the outbox file. func (o *Outbox) Close() error { // Drain holds drainMu through HTTP and cursor commit. Do not release writer // exclusion while an old delivery can still mutate the cursor. o.drainMu.Lock() defer o.drainMu.Unlock() o.mu.Lock() defer o.mu.Unlock() if o.closed { return nil } o.closed = true return errors.Join(o.file.Close(), o.writerLock.Close()) } // Append commits one decision; it satisfies decision.DecisionRecorder. func (o *Outbox) Append(decision api.DecisionEnvelope) error { return o.AppendBatch([]api.DecisionEnvelope{decision}) } // AppendBatch commits decisions with one sync, so a batch check pays the // durable-write cost once rather than per resource. func (o *Outbox) AppendBatch(decisions []api.DecisionEnvelope) error { o.mu.Lock() defer o.mu.Unlock() now := o.clock().UTC().Format(time.RFC3339Nano) var events []Event for _, decision := range decisions { id, err := eventID() if err != nil { return err } events = append(events, o.event(id, now, decision)) } return o.appendEvents(now, events) } // appendEvents requires mu; heartbeats use the same durable commit as decisions. func (o *Outbox) appendEvents(now string, events []Event) error { if o.closed { return os.ErrClosed } if o.writeFault { return errors.New("outbox rollback unconfirmed; writer fenced") } var buf []byte seq := o.seq for _, event := range events { seq++ line, err := json.Marshal(entry{Seq: seq, CommittedAt: now, Event: event}) if err != nil { return fmt.Errorf("marshal outbox entry: %w", err) } if len(line)+1 > MaxEntryBytes || len(buf)+len(line)+1 > MaxBatchBytes || int64(len(buf))+int64(len(line))+1 > o.maxBytes-o.size { return ErrCapacity } buf = append(append(buf, line...), '\n') } if _, err := o.file.Write(buf); err != nil { return o.rollback(fmt.Errorf("write outbox: %w", err)) } if err := o.file.Sync(); err != nil { return o.rollback(fmt.Errorf("sync outbox: %w", err)) } o.seq = seq o.size += int64(len(buf)) for _, event := range events { o.committed[event.Type]++ o.noteHeartbeat(event) } return nil } // rollback cuts a failed commit back to the last committed size, so a partial // line never sits in front of the next commit. The commit error is returned // either way; a failed cut is reported alongside it. func (o *Outbox) rollback(cause error) error { err := o.file.Truncate(o.size) if err == nil { err = o.file.Sync() } if err != nil { o.writeFault = true return errors.Join(cause, fmt.Errorf("outbox rollback unconfirmed; writer fenced: %w", err)) } return cause } func syncOutboxDirectory(dir string) error { file, err := os.Open(dir) if err != nil { return err } defer file.Close() return file.Sync() } // NoteReleasedUncommitted counts a restriction released after its commit // failed. Every non-zero count is a finding. func (o *Outbox) NoteReleasedUncommitted(effect api.DecisionEffect) { o.mu.Lock() defer o.mu.Unlock() o.uncommitted[Class(effect)]++ } // Status reports committed and released-uncommitted counts per class. func (o *Outbox) Status() Status { o.mu.Lock() defer o.mu.Unlock() status := Status{ Source: o.source, MaxBytes: o.maxBytes, CommittedBytes: o.size, RemainingBytes: o.maxBytes - o.size, CapacityExhausted: o.size == o.maxBytes, WriterFenced: o.writeFault, Committed: map[string]uint64{}, ReleasedUncommitted: map[string]uint64{}, Means: "committed counts this outbox holds since it was created; " + "released_uncommitted is decisions released without a record " + "since this process started, and any non-zero value is a finding", } status.Pending = o.seq - o.cursor.Seq if status.Pending > 0 { if e, _, err := o.nextEntry(); err == nil { status.OldestPendingAt = e.CommittedAt at, _ := time.Parse(time.RFC3339Nano, e.CommittedAt) status.LagExceeded = status.Pending > 100 || o.clock().Sub(at) > time.Hour } else { status.LagExceeded = true } } for _, effect := range api.DecisionEffects() { class := Class(effect) status.Committed[class] = o.committed[class] status.ReleasedUncommitted[class] = o.uncommitted[class] } return status } func (o *Outbox) event(id, at string, decision api.DecisionEnvelope) Event { tenant := decision.Resource.Tenant if decision.Binding != nil && decision.Binding.Tenant != "" { tenant = decision.Binding.Tenant } if tenant == "" { tenant = PlatformTenant } return Event{ ID: id, Type: Class(decision.Effect), Source: o.source, Subject: resourceSubject(decision.Resource), Tenant: tenant, CorrelationID: decision.ID, OccurredAt: at, Data: decision, } } func resourceSubject(resource api.ResourceRef) string { subject := resource.ID if resource.Type != "" { subject = resource.Type + "/" + subject } if resource.System != "" { subject = resource.System + "/" + subject } return subject } // eventID is random, not derived from the decision: decision ids are content // digests, so two identical checks share one, and audit-core's idempotency key // would merge two real decisions into one. func eventID() (string, error) { var b [16]byte if _, err := rand.Read(b[:]); err != nil { return "", fmt.Errorf("event id: %w", err) } return "flex-auth-evt:" + hex.EncodeToString(b[:]), nil }