feat(capture): two-phase classification-aware AuditSink port (#54)
Splits the audit port into Reserve (before any write) + Record (after), so "confidential + sink-down → refuse before any write" is literally true even though the audit record — which lists what landed — can only be written afterwards. - AuditSink.Reserve(ctx, level) → AuditOutcome | error. The error path refuses the capture before writing: confidential + central sink down, or the all-tiers floor (nothing can record). - AuditSink.Record(ctx, entry, outcome) persists per the reserved outcome. - Service: I5 gate runs after the I1 gate and after the dry-run short-circuit (dry-run never probes the sink). AuditBuffered surfaces on the receipt. New ErrAuditUnavailable sentinel (→ HTTP 503). The tier→behaviour decision lives in the sink impl (#54's DegradingSink), not the service — the service just honours Reserve's verdict. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -112,11 +112,20 @@ func (p fakePolicy) Derive(t classification.Target) classification.Level {
|
||||
}
|
||||
|
||||
type fakeAudit struct {
|
||||
entries []AuditEntry
|
||||
err error
|
||||
entries []AuditEntry
|
||||
err error // Record error
|
||||
reserveErr error // Reserve error (refuse before write)
|
||||
reserveMode AuditOutcome
|
||||
}
|
||||
|
||||
func (f *fakeAudit) Record(_ context.Context, e AuditEntry) error {
|
||||
func (f *fakeAudit) Reserve(_ context.Context, _ classification.Level) (AuditOutcome, error) {
|
||||
if f.reserveErr != nil {
|
||||
return 0, f.reserveErr
|
||||
}
|
||||
return f.reserveMode, nil
|
||||
}
|
||||
|
||||
func (f *fakeAudit) Record(_ context.Context, e AuditEntry, _ AuditOutcome) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
@@ -408,3 +417,55 @@ func TestCaptureInternalViaUSNexusAllowed(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.True(t, rec.Insights[0].OK)
|
||||
}
|
||||
|
||||
// --- I5 audit gate (#54) ---
|
||||
|
||||
func TestCaptureRefusesWhenAuditReserveFails(t *testing.T) {
|
||||
// Reserve refusing (e.g. confidential + central sink down, or the floor)
|
||||
// aborts the capture before any write.
|
||||
b := &fakeBrain{}
|
||||
tr := &fakeTracker{}
|
||||
au := &fakeAudit{reserveErr: errors.New("central sink unreachable")}
|
||||
svc := newSvc(b, tr, nil, fakePolicy{}, au)
|
||||
|
||||
_, err := svc.Capture(context.Background(), CaptureInput{
|
||||
Context: baseCtx(),
|
||||
Insights: []Insight{{Text: "x", Wing: "hyperguild", Hall: "facts"}},
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrAuditUnavailable)
|
||||
assert.Empty(t, b.writes, "nothing written when audit unavailable")
|
||||
assert.Empty(t, tr.created)
|
||||
}
|
||||
|
||||
func TestCaptureFlagsLocallyBufferedAudit(t *testing.T) {
|
||||
// Reserve returns AuditBuffered (internal/public, central down) → capture
|
||||
// proceeds and the receipt flags the degraded audit state.
|
||||
b := &fakeBrain{}
|
||||
au := &fakeAudit{reserveMode: AuditBuffered}
|
||||
svc := newSvc(b, &fakeTracker{}, nil, fakePolicy{}, au)
|
||||
|
||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||
Context: baseCtx(),
|
||||
Insights: []Insight{{Text: "x", Wing: "hyperguild", Hall: "facts"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, rec.Insights[0].OK, "capture proceeds on degraded audit")
|
||||
assert.True(t, rec.AuditBuffered, "receipt flags locally-buffered audit")
|
||||
require.Len(t, au.entries, 1)
|
||||
}
|
||||
|
||||
func TestCaptureDryRunSkipsAuditGate(t *testing.T) {
|
||||
// dry_run must not even probe the audit sink (writes nothing anywhere).
|
||||
au := &fakeAudit{reserveErr: errors.New("would refuse")}
|
||||
svc := newSvc(&fakeBrain{}, &fakeTracker{}, nil, fakePolicy{}, au)
|
||||
|
||||
rec, err := svc.Capture(context.Background(), CaptureInput{
|
||||
Context: baseCtx(),
|
||||
DryRun: true,
|
||||
Insights: []Insight{{Text: "x", Wing: "hyperguild", Hall: "facts"}},
|
||||
})
|
||||
require.NoError(t, err, "dry-run does not hit the audit gate")
|
||||
assert.True(t, rec.DryRun)
|
||||
assert.Empty(t, au.entries)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user