From db00644cab1cda01441d56e07a458df8bd85ff29 Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 18 Aug 2026 15:38:12 +0200 Subject: [PATCH] fix(capture): name what escalated the classification in an I1 refusal (#91) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit infra#265 reported "capture with a tickets array fails": 20 tickets errored, 5 errored, the caller fell back to 20 individual issue_create calls and filed a bug against the array. The array was never the failing dimension. Every ticket in that batch targeted a client repo, and the I1 sovereignty gate refuses a confidential capture through a us-nexus origin at ANY length — reproduced live today against brain-mcp: length 1 succeeds for an internal repo, length 20 validates clean, and a single ticket targeting trucker-ecommerce is refused. What made it look like an array bug is that the refusal named neither the offending target nor the fact that a policy said no: capture refused by I1 sovereignty gate: effective classification confidential through us-nexus origin With twenty tickets a caller cannot tell which repo tripped it, so "the array is broken" is a reasonable conclusion from the evidence available. resolveClassification now also returns the targets carrying the effective level, and the refusal names them: ... through us-nexus origin (required by: trucker-ecommerce) Only targets that actually reach the effective level are named — at twenty tickets, listing the innocent ones hides the one that matters — and a caller who declared confidential themselves is told exactly that, because "you asked for this" and "a repo you touched required it" call for different fixes. Regression tests at length 1 and 20 pin the array path that was never broken, and a third pins per-item partial success: infra#265 asked "if ticket 3 of 5 fails, what happened to 1 and 2?" — they land, and the receipt says so per item. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m --- ingestion/internal/capture/service.go | 50 ++++++++-- ingestion/internal/capture/service_test.go | 106 ++++++++++++++++++++- 2 files changed, 147 insertions(+), 9 deletions(-) diff --git a/ingestion/internal/capture/service.go b/ingestion/internal/capture/service.go index bec050b..59af865 100644 --- a/ingestion/internal/capture/service.go +++ b/ingestion/internal/capture/service.go @@ -83,7 +83,7 @@ func (s *Service) Capture(ctx context.Context, in CaptureInput) (CaptureReceipt, declared, _ = classification.ParseLevel(in.Context.Classification) } - effective, securityEvents := s.resolveClassification(declared, in) + effective, securityEvents, escalators := s.resolveClassification(declared, in) // Server-derived origin governs the I1 gate; a caller-asserted harness // label that names a different zone is descriptive-only and logged as a @@ -107,8 +107,8 @@ func (s *Service) Capture(ctx context.Context, in CaptureInput) (CaptureReceipt, Items: nil, // refused before any write SecurityEvents: append(securityEvents, "I1 refusal: confidential capture via us-nexus origin"), }, AuditCentral) - return CaptureReceipt{}, fmt.Errorf("%w: effective classification confidential through %s origin", - ErrSovereigntyRefused, in.Context.Origin) + return CaptureReceipt{}, fmt.Errorf("%w: effective classification confidential through %s origin (%s)", + ErrSovereigntyRefused, in.Context.Origin, escalationReason(escalators)) } receipt := CaptureReceipt{ @@ -221,15 +221,28 @@ func (s *Service) validate(in CaptureInput) error { return nil } -// resolveClassification computes the effective level (stricter of -// declared and every target's derived level) and collects a security -// event whenever the caller under-declared relative to a target floor. -func (s *Service) resolveClassification(declared classification.Level, in CaptureInput) (classification.Level, []string) { +// resolveClassification computes the effective level (stricter of declared +// and every target's derived level), collects a security event whenever the +// caller under-declared relative to a target floor, and names the targets +// that carry the strictest level. +// +// That third return is what makes an I1 refusal actionable. A caller sending +// twenty tickets gets one refusal; without the offending target it cannot +// tell which repo tripped the gate, and infra#265 shows what happens next — +// the caller concluded the tickets ARRAY was broken, built a fallback of +// twenty individual calls, and filed a bug against the wrong thing. +func (s *Service) resolveClassification(declared classification.Level, in CaptureInput) (classification.Level, []string, []string) { effective := declared var events []string + derivedFor := map[string]classification.Level{} + var order []string consider := func(kind classification.TargetKind, name string) { derived := s.policy.Derive(classification.Target{Kind: kind, Name: name}) effective = classification.Stricter(effective, derived) + if _, seen := derivedFor[name]; !seen { + derivedFor[name] = derived + order = append(order, name) + } if declared < derived { events = append(events, fmt.Sprintf("classification under-declared: declared=%s target=%s(%s) derived=%s", declared, name, kindString(kind), derived)) @@ -241,7 +254,28 @@ func (s *Service) resolveClassification(declared classification.Level, in Captur for _, tk := range in.Tickets { consider(classification.RepoTarget, tk.Repo) } - return effective, events + + // Only targets that actually reach the effective level are named: at + // twenty tickets, listing the innocent ones is noise that hides the one + // that matters. + var escalators []string + for _, name := range order { + if derivedFor[name] == effective && effective > declared { + escalators = append(escalators, name) + } + } + return effective, events, escalators +} + +// escalationReason renders why the effective classification landed where it +// did, for an operator reading a refusal. Naming the caller's own declaration +// matters as much as naming a target: "you asked for this" and "a repo you +// touched required it" call for different fixes. +func escalationReason(escalators []string) string { + if len(escalators) == 0 { + return "declared by the caller; no target required it" + } + return "required by: " + strings.Join(escalators, ", ") } func (s *Service) persistInsight(ctx context.Context, ins Insight) (InsightResult, string, error) { diff --git a/ingestion/internal/capture/service_test.go b/ingestion/internal/capture/service_test.go index 60b072e..1aa4e76 100644 --- a/ingestion/internal/capture/service_test.go +++ b/ingestion/internal/capture/service_test.go @@ -3,6 +3,7 @@ package capture import ( "context" "errors" + "fmt" "strings" "testing" "time" @@ -59,12 +60,18 @@ type fakeTracker struct { closed []int comments []int err error + // failOnTitle makes CreateIssue fail for one specific title, so a + // partial-failure receipt can be exercised without failing every item. + failOnTitle string } func (f *fakeTracker) CreateIssue(_ context.Context, repo, title, _ string) (IssueRef, error) { if f.err != nil { return IssueRef{}, f.err } + if f.failOnTitle != "" && title == f.failOnTitle { + return IssueRef{}, errors.New("tracker refused this one") + } f.created = append(f.created, repo+":"+title) return IssueRef{Repo: repo, Number: 100 + len(f.created), URL: "https://git/" + repo + "/issues/x"}, nil } @@ -87,7 +94,9 @@ func (f *fakeTracker) CommentIssue(_ context.Context, repo string, number int, _ // fakePolicy derives from an explicit map; default Internal so tests pin // behaviour without depending on the real defaulting. -type fakePolicy struct{ tags map[string]classification.Level } +type fakePolicy struct { + tags map[string]classification.Level +} func (p fakePolicy) Derive(t classification.Target) classification.Level { if lvl, ok := p.tags[t.Name]; ok { @@ -434,3 +443,98 @@ func TestCaptureDryRunSkipsAuditGate(t *testing.T) { assert.True(t, rec.DryRun) assert.Empty(t, au.entries) } + +// ---- hyperguild#91 part 1 ------------------------------------------------- +// +// The reported symptom was "capture with a tickets array fails" (infra#265: +// 20 tickets errored, 5 errored, caller fell back to 20 individual +// issue_create calls). The array was never the failing dimension — every +// ticket targeted a client repo, and the I1 gate refuses that through a +// us-nexus origin at ANY length. The refusal did not say which target +// escalated the classification, so it read as a malfunction. + +func TestCapture_TicketsArrayWorksAtLengthOne(t *testing.T) { + b, tr, au := &fakeBrain{}, &fakeTracker{}, &fakeAudit{} + svc := newSvc(b, tr, fakePolicy{}, au) + + rec, err := svc.Capture(context.Background(), CaptureInput{ + Context: baseCtx(), + Tickets: []Ticket{{Repo: "hyperguild", Action: "create", Title: "one", Body: "b"}}, + }) + require.NoError(t, err) + require.Len(t, rec.Tickets, 1) + assert.True(t, rec.Tickets[0].OK) + assert.Len(t, tr.created, 1) + assert.Empty(t, rec.Errors) +} + +func TestCapture_TicketsArrayWorksAtLengthMany(t *testing.T) { + b, tr, au := &fakeBrain{}, &fakeTracker{}, &fakeAudit{} + svc := newSvc(b, tr, fakePolicy{}, au) + + in := CaptureInput{Context: baseCtx()} + for i := range 20 { + in.Tickets = append(in.Tickets, Ticket{ + Repo: "hyperguild", Action: "create", Title: fmt.Sprintf("t%02d", i), Body: "b", + }) + } + + rec, err := svc.Capture(context.Background(), in) + require.NoError(t, err) + require.Len(t, rec.Tickets, 20, "one receipt entry per ticket") + for i, r := range rec.Tickets { + assert.True(t, r.OK, "ticket %d failed: %+v", i, rec.Errors) + } + assert.Len(t, tr.created, 20) + assert.Empty(t, rec.Errors) +} + +func TestCapture_OneFailingTicketDoesNotSinkTheRest(t *testing.T) { + // infra#265 asked explicitly: "if ticket 3 of 5 fails, what happened to + // 1 and 2?" They land, and the receipt says so per item. + b, au := &fakeBrain{}, &fakeAudit{} + tr := &fakeTracker{failOnTitle: "boom"} + svc := newSvc(b, tr, fakePolicy{}, au) + + rec, err := svc.Capture(context.Background(), CaptureInput{ + Context: baseCtx(), + Tickets: []Ticket{ + {Repo: "hyperguild", Action: "create", Title: "ok-1", Body: "b"}, + {Repo: "hyperguild", Action: "create", Title: "boom", Body: "b"}, + {Repo: "hyperguild", Action: "create", Title: "ok-3", Body: "b"}, + }, + }) + require.NoError(t, err, "a per-item failure is a partial receipt, not a call error") + require.Len(t, rec.Tickets, 3) + assert.True(t, rec.Tickets[0].OK) + assert.False(t, rec.Tickets[1].OK) + assert.True(t, rec.Tickets[2].OK) + require.Len(t, rec.Errors, 1) + assert.Contains(t, rec.Errors[0].Item, "ticket[1]") +} + +func TestCapture_I1RefusalNamesWhatEscalatedTheClassification(t *testing.T) { + // The actual defect behind infra#265. Without the offending target in + // the message, a caller with 20 tickets cannot tell which repo tripped + // the gate — and reasonably concludes the array itself is broken. + b, tr, au := &fakeBrain{}, &fakeTracker{}, &fakeAudit{} + pol := fakePolicy{tags: map[string]classification.Level{"trucker-ecommerce": classification.Confidential}} + svc := newSvc(b, tr, pol, au) + + ctx := baseCtx() + ctx.Origin = ZoneUSNexus + _, err := svc.Capture(context.Background(), CaptureInput{ + Context: ctx, + Tickets: []Ticket{ + {Repo: "hyperguild", Action: "create", Title: "fine", Body: "b"}, + {Repo: "trucker-ecommerce", Action: "create", Title: "client work", Body: "b"}, + }, + }) + + require.ErrorIs(t, err, ErrSovereigntyRefused) + assert.Contains(t, err.Error(), "trucker-ecommerce", + "the refusal must name the target that escalated the classification") + assert.NotContains(t, err.Error(), "hyperguild", + "only the offending target, or the message is noise at 20 tickets") + assert.Empty(t, tr.created, "refused before any write") +} -- 2.54.0