FLEX-WP-0031-T03: durable decision outbox and the FLEX-DEC-2026-018 release rule
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Container Image / build-and-push (push) Successful in 1m27s

internal/emission commits one audit-core-shaped event per decision with
fsync before release (one sync per batch), random event ids with the
decision id as correlation_id, torn-tail and failed-commit truncation, and
per-class committed/released_uncommitted counts at GET /v1/emission.
The engine releases restrictions whose record failed to commit and
withholds allow/audit_only (503). api.DecisionEffects() is pinned by a
source-parsing test and cadence.yaml must classify exactly it.

T03 split under the task budget: heartbeat+drain is T05, reconciliation,
profile check and PVC are T06. Nothing deployed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 307130@bnt-lap001
Assistant-Session: 270c79f7-0823-4b0d-990d-aad5af9935ce
This commit is contained in:
tegwick 2026-09-23 20:20:58 +02:00
parent 7f0e2e37f9
commit cd14a34e33
9 changed files with 842 additions and 23 deletions

View file

@ -6,6 +6,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"reflect"
"slices"
@ -36,6 +37,22 @@ type DecisionRecorder interface {
Append(api.DecisionEnvelope) error
}
// BatchRecorder commits several decisions in one durable write.
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).
var ErrRecordNotCommitted = errors.New("decision withheld: record not committed")
// ListAllowedRequest describes a deterministic list_allowed call.
type ListAllowedRequest struct {
Subject api.SubjectRef `json:"subject"`
@ -104,8 +121,8 @@ func (e *Engine) now() time.Time {
return time.Now().UTC()
}
// Check evaluates one subject/action/resource request.
func (e *Engine) Check(ctx context.Context, request api.CheckRequest) (api.DecisionEnvelope, error) {
// decide evaluates one request without recording it.
func (e *Engine) decide(ctx context.Context, request api.CheckRequest) (api.DecisionEnvelope, error) {
normalized, facts := e.normalizeRequest(request)
expectation, err := e.policy.Evaluate(ctx, normalized)
@ -113,19 +130,29 @@ func (e *Engine) Check(ctx context.Context, request api.CheckRequest) (api.Decis
return api.DecisionEnvelope{}, err
}
decision := e.envelope(ctx, normalized, request, expectation, facts)
if err := e.recordDecision(decision); err != nil {
return e.envelope(ctx, normalized, request, expectation, facts), nil
}
// 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).
func (e *Engine) Check(ctx context.Context, request api.CheckRequest) (api.DecisionEnvelope, error) {
decision, err := e.decide(ctx, request)
if err != nil {
return api.DecisionEnvelope{}, err
}
if err := e.recordDecisions([]api.DecisionEnvelope{decision}); err != nil {
return api.DecisionEnvelope{}, err
}
return decision, nil
}
// BatchCheck evaluates one subject/action/context tuple against resources in
// request order.
// request order, and commits the whole batch in one durable write.
func (e *Engine) BatchCheck(ctx context.Context, request api.BatchCheckRequest) ([]api.DecisionEnvelope, error) {
decisions := make([]api.DecisionEnvelope, 0, len(request.Resources))
for _, resource := range request.Resources {
decision, err := e.Check(ctx, api.CheckRequest{
decision, err := e.decide(ctx, api.CheckRequest{
ID: request.ID,
Tenant: request.Tenant,
Subject: request.Subject,
@ -139,18 +166,21 @@ func (e *Engine) BatchCheck(ctx context.Context, request api.BatchCheckRequest)
}
decisions = append(decisions, decision)
}
if err := e.recordDecisions(decisions); err != nil {
return nil, err
}
return decisions, nil
}
// ListAllowed evaluates candidate resources and returns only allow decisions.
func (e *Engine) ListAllowed(ctx context.Context, request ListAllowedRequest) ([]api.DecisionEnvelope, error) {
candidates := e.store.ResourceRefs(request.System, request.ResourceType)
allowed := make([]api.DecisionEnvelope, 0, len(candidates))
decisions := make([]api.DecisionEnvelope, 0, len(candidates))
for _, resource := range candidates {
if !resourceMatchesFilters(resource, request.Filters) {
continue
}
decision, err := e.Check(ctx, api.CheckRequest{
decision, err := e.decide(ctx, api.CheckRequest{
Subject: request.Subject,
Action: request.Action,
Resource: resource,
@ -160,6 +190,13 @@ func (e *Engine) ListAllowed(ctx context.Context, request ListAllowedRequest) ([
if err != nil {
return nil, err
}
decisions = append(decisions, decision)
}
if err := e.recordDecisions(decisions); err != nil {
return nil, err
}
allowed := make([]api.DecisionEnvelope, 0, len(decisions))
for _, decision := range decisions {
if decision.Effect == api.DecisionEffectAllow {
allowed = append(allowed, decision)
}
@ -404,12 +441,46 @@ func callerProvenance(ctx context.Context) *api.CallerProvenance {
return out
}
func (e *Engine) recordDecision(decision api.DecisionEnvelope) error {
// 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.
func (e *Engine) recordDecisions(decisions []api.DecisionEnvelope) error {
e.mu.Lock()
defer e.mu.Unlock()
e.history[decision.ID] = decision
if e.log != nil {
return e.log.Append(decision)
for _, decision := range decisions {
e.history[decision.ID] = decision
}
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() {
return fmt.Errorf("%w: %v", ErrRecordNotCommitted, err)
}
}
if noter, ok := log.(UncommittedNoter); ok {
for _, decision := range decisions {
noter.NoteReleasedUncommitted(decision.Effect)
}
}
return nil
}
func appendAll(log DecisionRecorder, decisions []api.DecisionEnvelope) error {
if batch, ok := log.(BatchRecorder); ok {
return batch.AppendBatch(decisions)
}
for _, decision := range decisions {
if err := log.Append(decision); err != nil {
return err
}
}
return nil
}