Withhold uncommitted decision effects and pin accepted layer canon

Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade
This commit is contained in:
tegwick 2026-10-11 02:21:40 +02:00
parent f5fad7c285
commit 22185e94ec
12 changed files with 269 additions and 65 deletions

View file

@ -43,22 +43,22 @@ var (
}
)
// FLEX-DEC-2026-018: withholding a deny turns it into an error an open-stance
// consumer reads as proceed, so a restriction is released and counted.
func TestUncommittedDenyIsReleasedAndCounted(t *testing.T) {
// GH-DEC-2026-024: a restriction is not exempt from required commitment.
func TestUncommittedDenyIsWithheld(t *testing.T) {
engine := newTestEngine(t)
recorder := &failingRecorder{}
engine.SetDecisionLog(recorder)
got, err := engine.Check(context.Background(), deniedRead)
if err != nil {
t.Fatalf("Check: %v; a restriction must never be withheld", err)
if !errors.Is(err, decision.ErrRecordNotCommitted) || got.ID != "" {
t.Fatalf("Check = %+v, %v; want unavailable and no decision", got, err)
}
if got.Effect != api.DecisionEffectDeny {
t.Fatalf("effect = %q; want deny", got.Effect)
if len(recorder.uncommitted) != 0 {
t.Fatalf("withheld restriction counted as released: %v", recorder.uncommitted)
}
if len(recorder.uncommitted) != 1 || recorder.uncommitted[0] != api.DecisionEffectDeny {
t.Fatalf("released_uncommitted = %v; want [deny]", recorder.uncommitted)
engine.SetDecisionLog(nil)
committed, err := engine.Check(context.Background(), deniedRead)
if err != nil || committed.Effect != api.DecisionEffectDeny {
t.Fatalf("normal deny = %+v, %v", committed, err)
}
}
@ -99,7 +99,19 @@ func TestBatchCommitsOnceAndWithholdsOnAnyUncommittedAllow(t *testing.T) {
Action: "read",
Resources: []api.ResourceRef{deniedRead.Resource},
})
if err != nil || len(decisions) != 1 {
t.Fatalf("restriction-only batch: decisions=%v err=%v; want it released", decisions, err)
if !errors.Is(err, decision.ErrRecordNotCommitted) || len(decisions) != 0 {
t.Fatalf("restriction-only batch: decisions=%v err=%v; want withheld", decisions, err)
}
}
func TestListAllowedWithholdsWhenAllCandidateDenialsFailCommit(t *testing.T) {
engine := newTestEngine(t)
recorder := &failingRecorder{}
engine.SetDecisionLog(recorder)
got, err := engine.ListAllowed(context.Background(), decision.ListAllowedRequest{
Subject: deniedRead.Subject, Action: "unknown-action", System: "markitect-tool",
})
if !errors.Is(err, decision.ErrRecordNotCommitted) || got != nil || recorder.batches != 1 {
t.Fatalf("ListAllowed = %v, %v; batches=%d; want unavailable", got, err, recorder.batches)
}
}

View file

@ -0,0 +1,51 @@
package decision
import (
"errors"
"testing"
"github.com/netkingdom/access-engine/pkg/api"
)
type commitProbe struct {
fail bool
committed []api.DecisionEnvelope
released int
}
func (p *commitProbe) Append(d api.DecisionEnvelope) error {
if p.fail {
return errors.New("disk unavailable")
}
p.committed = append(p.committed, d)
return nil
}
func (p *commitProbe) NoteReleasedUncommitted(api.DecisionEffect) { p.released++ }
// Every source-owned emission class is load-bearing. An outbox failure must
// hide every effect from both the response and Explain, including restrictions.
func TestRequiredCommitForEveryEffect(t *testing.T) {
for _, effect := range api.DecisionEffects() {
t.Run(string(effect), func(t *testing.T) {
probe := &commitProbe{fail: true}
engine := &Engine{history: make(map[string]api.DecisionEnvelope), log: probe}
d := api.DecisionEnvelope{ID: "decision", Effect: effect}
if err := engine.recordDecisions([]api.DecisionEnvelope{d}); !errors.Is(err, ErrRecordNotCommitted) {
t.Fatalf("commit failure = %v; want unavailable", err)
}
if _, err := engine.Explain(d.ID); err == nil {
t.Fatal("uncommitted decision exposed by Explain")
}
if probe.released != 0 || len(probe.committed) != 0 {
t.Fatal("failed commit claimed a release")
}
probe.fail = false
if err := engine.recordDecisions([]api.DecisionEnvelope{d}); err != nil {
t.Fatal(err)
}
if got, err := engine.Explain(d.ID); err != nil || got.Effect != effect {
t.Fatalf("committed explanation = %+v, %v", got, err)
}
})
}
}

View file

@ -42,15 +42,9 @@ type BatchRecorder interface {
AppendBatch([]api.DecisionEnvelope) error
}
// UncommittedNoter counts restrictions released after their record failed to
// commit (FLEX-DEC-2026-018).
type UncommittedNoter interface {
NoteReleasedUncommitted(api.DecisionEffect)
}
// ErrRecordNotCommitted means a permissive decision was withheld because its
// record did not commit. The service answers 503, and the consumer's declared
// stance applies (FLEX-DEC-2026-018).
// ErrRecordNotCommitted means a decision was withheld because its required
// record did not commit (GH-DEC-2026-024). The service answers 503; this is
// unavailability, never a substitute authorization verdict.
var ErrRecordNotCommitted = errors.New("decision withheld: record not committed")
// ListAllowedRequest describes a deterministic list_allowed call.
@ -134,8 +128,7 @@ func (e *Engine) decide(ctx context.Context, request api.CheckRequest) (api.Deci
}
// Check evaluates one subject/action/resource request. The decision is
// released only after its record commits, except that a restriction is never
// withheld (FLEX-DEC-2026-018).
// released only after its required record commits (GH-DEC-2026-024).
func (e *Engine) Check(ctx context.Context, request api.CheckRequest) (api.DecisionEnvelope, error) {
decision, err := e.decide(ctx, request)
if err != nil {
@ -441,34 +434,22 @@ func callerProvenance(ctx context.Context) *api.CallerProvenance {
return out
}
// recordDecisions commits decisions and applies FLEX-DEC-2026-018 when the
// commit fails: restrictions are released and counted, and any permissive
// decision withholds the call. In a batch that withholds the batch's
// restrictions too; no live consumer calls batch_check (user-engine loops
// /v1/check), and the residual is stated in the ruling.
// recordDecisions releases no effect until its required record commits.
// GH-DEC-2026-024 refuses the former restrictive-effect failure exception.
// Failed decisions must not enter Explain's history as if they were released.
func (e *Engine) recordDecisions(decisions []api.DecisionEnvelope) error {
e.mu.Lock()
for _, decision := range decisions {
e.history[decision.ID] = decision
}
e.mu.RLock()
log := e.log
e.mu.Unlock()
if log == nil || len(decisions) == 0 {
return nil
}
err := appendAll(log, decisions)
if err == nil {
return nil
}
for _, decision := range decisions {
if !decision.Effect.Restricts() {
e.mu.RUnlock()
if log != nil && len(decisions) != 0 {
if err := appendAll(log, decisions); err != nil {
return fmt.Errorf("%w: %v", ErrRecordNotCommitted, err)
}
}
if noter, ok := log.(UncommittedNoter); ok {
for _, decision := range decisions {
noter.NoteReleasedUncommitted(decision.Effect)
}
e.mu.Lock()
defer e.mu.Unlock()
for _, decision := range decisions {
e.history[decision.ID] = decision
}
return nil
}