fix(capture): name what escalated the classification in an I1 refusal (#91 part 1) #95

Merged
mathias merged 1 commits from fix/capture-i1-refusal-names-target into main 2026-08-18 18:13:48 +00:00
Owner

Closes the part-1 half of #91. The tickets array was never broken — the bug is in what the refusal says.

Reproduction, live against brain-mcp

probe result
1 ticket, comment, hyperguild, dry run ok
1 ticket, comment, hyperguild, real write ok — comment 6356
2 tickets, create + comment, real write ok — created #94 and comment 6358 in one call
20 tickets, create, dry run ok — decode, validation and transport all handle 20
1 ticket targeting trucker-ecommerce refused
MCP error -32000: capture refused by I1 sovereignty gate:
effective classification confidential through us-nexus origin

infra#265 tried 20, then 5, both targeting a client repo, and never tried 1. Length was never the variable — the repo classification was. trucker-ecommerce is absent from classification.yaml, which means confidential by fail-safe default, and the caller was on claude.ai (a us-nexus origin). The gate did exactly its job.

Why it read as an array bug

The refusal named neither the offending target nor that a policy had said no. With twenty tickets in one call, "which of these tripped it?" is unanswerable from that message — so "the array is broken" is a reasonable conclusion from the evidence available. The cost was three wasted round trips, a 20-call fallback, and two issues filed against the wrong component (including a repo-split proposal).

That is the defect this PR fixes. resolveClassification now also returns the targets carrying the effective level:

... through us-nexus origin (required by: trucker-ecommerce)

Two deliberate choices:

  • Only targets that reach the effective level are named. At twenty tickets, listing the innocent ones hides the one that matters.
  • A caller who declared confidential themselves is told exactly that (declared by the caller; no target required it), because "you asked for this" and "a repo you touched required it" need different fixes.

Tests

  • TicketsArrayWorksAtLengthOne / ...AtLengthMany (20) — the regression tests #91 asks for, pinning a path that was already correct
  • OneFailingTicketDoesNotSinkTheRest — infra#265 asked "if ticket 3 of 5 fails, what happened to 1 and 2?" They land; the receipt says so per item; the call is not an error
  • I1RefusalNamesWhatEscalatedTheClassification — the red test for this change

task check green.

What I did not do

Did not strip tickets from the schema. The instruction was to fix the array path or remove the field, on the premise that it always fails. It does not, so removing it would delete a working capability and leave the actual defect — an unactionable refusal — in place.

Did not touch the gate's behaviour. The refusal is correct: client data must not flow to a us-nexus surface. Only the message changed.

Follow-on worth considering separately

A caller hitting this legitimately (filing a client-repo backlog) has no path except switching harness. Whether a sovereign-origin principal should be able to file client tickets through capture is a policy question, not a message one — say the word and I'll file it.

Closes the part-1 half of #91. **The `tickets` array was never broken** — the bug is in what the refusal says. ## Reproduction, live against brain-mcp | probe | result | |---|---| | 1 ticket, `comment`, `hyperguild`, dry run | ok | | 1 ticket, `comment`, `hyperguild`, **real write** | ok — [comment 6356](https://git.d-ma.be/mathias/hyperguild/issues/91#issuecomment-6356) | | 2 tickets, `create` + `comment`, real write | ok — created #94 and [comment 6358](https://git.d-ma.be/mathias/hyperguild/issues/91#issuecomment-6358) in one call | | 20 tickets, `create`, dry run | ok — decode, validation and transport all handle 20 | | **1 ticket targeting `trucker-ecommerce`** | **refused** | ``` MCP error -32000: capture refused by I1 sovereignty gate: effective classification confidential through us-nexus origin ``` infra#265 tried 20, then 5, both targeting a client repo, and never tried 1. Length was never the variable — the **repo classification** was. `trucker-ecommerce` is absent from `classification.yaml`, which means confidential by fail-safe default, and the caller was on claude.ai (a us-nexus origin). The gate did exactly its job. ## Why it read as an array bug The refusal named neither the offending target nor that a policy had said no. With twenty tickets in one call, "which of these tripped it?" is unanswerable from that message — so "the array is broken" is a reasonable conclusion from the evidence available. The cost was three wasted round trips, a 20-call fallback, and two issues filed against the wrong component (including a repo-split proposal). **That is the defect this PR fixes.** `resolveClassification` now also returns the targets carrying the effective level: ``` ... through us-nexus origin (required by: trucker-ecommerce) ``` Two deliberate choices: - **Only targets that reach the effective level are named.** At twenty tickets, listing the innocent ones hides the one that matters. - **A caller who declared `confidential` themselves is told exactly that** (`declared by the caller; no target required it`), because "you asked for this" and "a repo you touched required it" need different fixes. ## Tests - `TicketsArrayWorksAtLengthOne` / `...AtLengthMany` (20) — the regression tests #91 asks for, pinning a path that was already correct - `OneFailingTicketDoesNotSinkTheRest` — infra#265 asked "if ticket 3 of 5 fails, what happened to 1 and 2?" They land; the receipt says so per item; the call is not an error - `I1RefusalNamesWhatEscalatedTheClassification` — the red test for this change `task check` green. ## What I did not do **Did not strip `tickets` from the schema.** The instruction was to fix the array path *or* remove the field, on the premise that it always fails. It does not, so removing it would delete a working capability and leave the actual defect — an unactionable refusal — in place. **Did not touch the gate's behaviour.** The refusal is correct: client data must not flow to a us-nexus surface. Only the message changed. ## Follow-on worth considering separately A caller hitting this legitimately (filing a client-repo backlog) has no path except switching harness. Whether a sovereign-origin principal should be able to file client tickets through capture is a policy question, not a message one — say the word and I'll file it.
mathias added 1 commit 2026-08-18 13:38:38 +00:00
fix(capture): name what escalated the classification in an I1 refusal (#91)
CI / Lint / Test / Vet (pull_request) Successful in 30s
CI / Mirror to GitHub (pull_request) Skipped
db00644cab
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m
mathias merged commit eb9e3e69a9 into main 2026-08-18 18:13:48 +00:00
Sign in to join this conversation.