feat(authz): answer advisory permission verdicts through one BatchCheck (S09 Verdicts plan: 2/10)
What
internal/authz gains the verdict evaluator for S09's Permission verdicts: VerdictEvaluator.Evaluate answers advisory per-action allow/deny sets over namespace and repository objects through one ReadRelationships and one BatchCheck, correlated by (ItemIndex, Action). Nothing routes to it yet; Step 4 wires the Deps.Verdicts seam.
Behavior highlights:
- The unenforced posture answers all-allow without touching IAM or GLAZ;
Postureis a named type whose zero value is the enforced, fail-closed arm. - Anonymous, zero-tuple, and empty-principal evaluations answer all-deny with no GLAZ call.
- The evaluator enforces the spec's consumer-side exactly-one-result obligation itself: a missing, duplicate, or unrequested result, a wrong object echo, or an out-of-range index answers the 503 outcome, never a partial set.
- A malformed query (empty or nil-UUID object id, empty or duplicate action set) fails the whole batch with 503 at WARN, distinguishable from a GLAZ outage.
Beyond the plan's Files list
internal/authz/decision.go: behavior-preserving extraction ofreadObjectTuples, shared by the point-check and verdict paths (the plan's Research Findings record the correction from "reusingreadTuples").internal/auth/auth.go: theIsAnonymousdoc claim is rephrased so it stays true once verdict callers exist.docs/plans/2026-08-31-s09-permission-verdicts.md: operator-authorized inline amendments — research corrections, thePostureandVerdictQuerynaming, Step 3's empty-encoding pin (repositoriesrequired, non-null,[]when empty), Step 4's separate caching-resolver directive, measured sizes, and the Status rows for steps 1 and 2. The Status-table single-writer rule is deliberately carried by this MR while it is the only open step MR editing the table.
Size (guardrail 18)
~880 added LOC, split: 275 source (internal/authz/verdicts.go + the decision.go extraction + the auth.go doc), 598 test (internal/authz/verdicts_test.go), the rest plan text. Splitting would separate the evaluator from its suite; the tests are the bulk and ship with the code.
Authorship
Test-first: the test(authz) commit lands the suite against a panic-skeleton under the documented --no-verify carve-out; every later commit ran the full hook chain (one bypass on the branch, by contract). The feat commit also carries the test file's comment-cap compression; its body says why.
e2e catalogs
No scenario added or affected: the evaluator is unreachable until Step 4 wires it.
Spec coverage
Spec: docs/specs/S09-authorization.md
This MR is Step 2 of the S09 Verdicts plan: the evaluator only. Criteria owned by the shipped decision layer cite the existing internal/authz suite; criteria owned by other steps of this plan or by the S09 enforcement plan say so.
Acceptance criteria
| # | Criterion | Tests |
|---|---|---|
| 1 | Anonymous deny | Point-check arm: existing suite. Verdict arm: TestVerdictEvaluator_AnonymousAllDeny. |
| 2 | Zero-tuple deny | Point-check arm: existing suite. Verdict arm: TestVerdictEvaluator_ShortCircuitAllDeny/zero_tuples_deny_without_a_GLAZ_call. |
| 3 | Read deny masks | Shipped decision layer; existing suite. |
| 4 | Write deny consults the read verdict | Shipped decision layer; existing suite. |
| 5 | Infrastructure failure fails closed | Point-check arm: existing suite. Evaluator arm: TestVerdictEvaluator_DependencyFailureAnswers503. |
| 6 | Grammar misses fail closed | Shipped decision layer; existing suite. |
| 7 | Target-anchored ancestor | Shipped decision layer; existing suite. Evaluator ancestor assembly is Step 4's wiring. |
| 8 | Resolution reuse and pattern preservation | Shipped decision layer; existing suite. |
| 9 | Mount-source check | Shipped decision layer; existing suite. |
| 10 | Maven mapping | Enforcement plan scope; not this MR. |
| 11 | Maven denial shape | Enforcement plan scope; not this MR. |
| 12 | npm route mapping | Enforcement plan scope; not this MR. |
| 13 | npm denial shape | Enforcement plan scope; not this MR. |
| 14 | Management point checks | Enforcement plan scope; not this MR. |
| 15 | Namespace-scoped checks | Enforcement plan scope; not this MR. |
| 16 | Suspension gate ordering | Enforcement plan scope; not this MR. |
| 17 | Listing, namespace branch | Step 6 scope (this plan). |
| 18 | Listing, filtered branch | Enforcement plan scope + Step 6 (this plan). |
| 19 | Listing, no access | Enforcement plan scope; not this MR. |
| 20 | Boot posture | Verdict clause (all-allow, nothing calls IAM or GLAZ): TestVerdictEvaluator_Unenforced. Boot/exit/WARN arms: Step 4 wiring and enforcement plan. |
| 21 | Namespace endpoint gate | Steps 7 and 8 scope (this plan). All-deny groundwork: TestVerdictEvaluator_ShortCircuitAllDeny. |
| 22 | Detail verdicts | Step 5 scope (this plan). Per-action correlation groundwork: TestVerdictEvaluator_Evaluate. |
| 23 | List verdicts under a namespace role | Step 6 scope (this plan). Per-row evaluation groundwork (one BatchCheck, one item per row): TestVerdictEvaluator_Evaluate. |
| 24 | Opt-out is inert | Handler scope, Steps 5, 6, and 8 (this plan): the evaluator is simply not invoked. No evaluator arm exists to test. |
| 25 | Malformed BatchCheck fails closed | Evaluator arm: TestVerdictEvaluator_MalformedBatchResponseAnswers503 (missing, duplicate, unrequested, wrong object echo, out-of-range and negative index) and TestVerdictEvaluator_DependencyFailureAnswers503/BatchCheck_error. Malformed-shape detection also in the glaz client's own suite. Listing-filter arm: enforcement plan. |
| 26 | Management reads are cache-private | Step 9 scope (this plan). |
| 27 | Parameter validation precedes evaluation | Handler scope, Steps 5, 6, and 8 (this plan). Malformed-query refusal at the evaluator boundary: TestVerdictEvaluator_MalformedQueryAnswers503. |
Evaluation rules without their own criterion number, pinned here: whole-action-set sets (absence is never a verdict) in assertUniformVerdicts used by every arm; correlation by (ItemIndex, Action) with an overlapping action across queries, never response order, object, or action alone, in TestVerdictEvaluator_Evaluate; the two surface action sets in TestVerdictActionSets; abandon on a done context across all three failure arms in TestVerdictEvaluator_CanceledContextAbandons; the exported zero value stays fail-closed in TestVerdictEvaluator_ZeroValue.
Error cases
| # | Condition | Tests |
|---|---|---|
| E-1 | Container/OCI denial envelopes | Shipped decision layer; existing suite. |
| E-2 | Maven denial envelopes | Shipped decision layer; existing suite. |
| E-3 | npm denial envelopes | Shipped decision layer; existing suite. |
| E-4 | Management API denial envelopes | Rendering: handler steps. The evaluator's 503 outcome feeding the management row: TestVerdictEvaluator_DependencyFailureAnswers503. |
| E-5 | No denial names the deciding permission/role/policy | Shipped decision layer; existing suite. Evaluator surfaces booleans only, no reasons: TestVerdictEvaluator_Evaluate (VerdictSet shape). |
| E-6 | No denial echoes request-derived values | Shipped decision layer; existing suite. |
| E-7 | 503 Retry-After exceeds dependency timeouts | Handler/renderer scope; not the evaluator's. |
| E-8 | Canceled request receives no write | Evaluator arm: TestVerdictEvaluator_CanceledContextAbandons (all three failure arms). Write suppression is the caller's, as in the decision layer. |
| E-9 | Namespace endpoint: one 404 for unknown slug and cross-org caller | Step 7 scope (this plan). |
Security considerations
| # | Concern | Tests |
|---|---|---|
| S-1 | Existence masking is shape-sensitive | Shipped decision layer; existing renderer suites. |
| S-2 | Every denial is enveloped | Shipped decision layer; existing suite. |
| S-3 | Fail closed everywhere | Evaluator arms: TestVerdictEvaluator_DependencyFailureAnswers503, TestVerdictEvaluator_MalformedBatchResponseAnswers503, TestVerdictEvaluator_MalformedQueryAnswers503, TestVerdictEvaluator_ShortCircuitAllDeny, TestVerdictEvaluator_ZeroValue. Other arms: existing suite and enforcement plan. |
| S-4 | Trusts nothing it did not derive | Principal from IAM's tuples, never the token: TestVerdictEvaluator_Evaluate. Result identity checked against the request (object echo + index): TestVerdictEvaluator_MalformedBatchResponseAnswers503. Ancestor derivation: Step 4 wiring. |
| S-5 | Log injection | Shipped decision layer; existing suite. The evaluator's WARN logs carry only derived fields (query_index, defect). |
| S-6 | Unenforced opt-in hazard | Verdict arm (all-allow matches the posture): TestVerdictEvaluator_Unenforced. Posture zero value pinned enforced: TestVerdictEvaluator_ZeroValue. Boot mitigations: Step 4 and enforcement plan. |
| S-7 | Verdicts advisory and organization-bound | Advisory-by-construction (evaluator returns booleans, grants nothing). Organization binding: Step 7 scope. |
| S-8 | Credential-type-blind decisions | ADR-020/021 accepted trade-off; owned by the decision layer. Not tested in this MR. |
Related to #670 (closed)