diff --git a/cmd/access-engine/main.go b/cmd/access-engine/main.go index 7e94d8e..29a738b 100644 --- a/cmd/access-engine/main.go +++ b/cmd/access-engine/main.go @@ -611,7 +611,7 @@ func mountEmission(mux *http.ServeMux, outbox *emission.Outbox) { func writeHTTP(w http.ResponseWriter, value any, err error) { w.Header().Set("content-type", "application/json") if errors.Is(err, decisioncore.ErrRecordNotCommitted) { - // The consumer's declared stance applies (FLEX-DEC-2026-018). + // Unavailable evaluation is not an authorization verdict (GH-DEC-2026-024). http.Error(w, err.Error(), http.StatusServiceUnavailable) return } diff --git a/cmd/access-engine/readback_test.go b/cmd/access-engine/readback_test.go index 92a2b57..0c3202a 100644 --- a/cmd/access-engine/readback_test.go +++ b/cmd/access-engine/readback_test.go @@ -254,9 +254,9 @@ type failingRecorder struct{} func (failingRecorder) Append(api.DecisionEnvelope) error { return errors.New("disk full") } -// FLEX-DEC-2026-018 holds on the readback: an allow whose record fails to -// commit is withheld (503, rendered "could not be checked"), never released. -func TestReadbackWithholdsUncommittedAllow(t *testing.T) { +// GH-DEC-2026-024: both permissive and restrictive results require their +// record. Failure is unavailable (503), not a manufactured denial or allow. +func TestReadbackWithholdsUncommittedEffects(t *testing.T) { service := newTestReadback(t) service.SetDecisionLog(failingRecorder{}) server := httptest.NewServer(newReadbackMux(service, callerauth.Disabled())) @@ -267,8 +267,8 @@ func TestReadbackWithholdsUncommittedAllow(t *testing.T) { t.Fatalf("uncommitted allow = %d, want 503", got) } deny, _ := json.Marshal(subjectRequest("user:bob", false, "demo-app")) - if got := postReadback(t, server.URL, string(deny)).StatusCode; got != http.StatusOK { - t.Fatalf("uncommitted deny = %d, want 200 (a restriction is never withheld)", got) + if got := postReadback(t, server.URL, string(deny)).StatusCode; got != http.StatusServiceUnavailable { + t.Fatalf("uncommitted deny = %d, want 503", got) } } @@ -343,3 +343,27 @@ func TestReadbackInformedDecisionCatalogue(t *testing.T) { t.Fatalf("catalogue = %+v", systems) } } + +// A real restrictive policy result reaches the same unavailable HTTP contract +// as an allow when its mandatory record cannot commit. +func TestEnforcementWithholdsUncommittedDenyHTTP(t *testing.T) { + engine, err := buildEngine(context.Background(), opsPath("registry_snapshot.json"), opsPath("policy_package.md"), "") + if err != nil { + t.Fatal(err) + } + engine.SetDecisionLog(failingRecorder{}) + server := httptest.NewServer(newServeMux(engine)) + defer server.Close() + body, err := os.ReadFile(opsPath("check_request_deny_ttl_above_max.json")) + if err != nil { + t.Fatal(err) + } + response, err := http.Post(server.URL+"/v1/check", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusServiceUnavailable { + t.Fatalf("uncommitted deny status = %d; want 503", response.StatusCode) + } +} diff --git a/internal/decision/emission_atomicity_test.go b/internal/decision/emission_atomicity_test.go index e400892..16f8f34 100644 --- a/internal/decision/emission_atomicity_test.go +++ b/internal/decision/emission_atomicity_test.go @@ -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) } } diff --git a/internal/decision/emission_release_test.go b/internal/decision/emission_release_test.go new file mode 100644 index 0000000..711099d --- /dev/null +++ b/internal/decision/emission_release_test.go @@ -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) + } + }) + } +} diff --git a/internal/decision/engine.go b/internal/decision/engine.go index 30192b5..185fd08 100644 --- a/internal/decision/engine.go +++ b/internal/decision/engine.go @@ -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 } diff --git a/internal/layer/conformance.go b/internal/layer/conformance.go index 485c79d..1c99c43 100644 --- a/internal/layer/conformance.go +++ b/internal/layer/conformance.go @@ -59,11 +59,10 @@ func Vocabulary() []string { return append([]string(nil), canonicalLayers...) } // visibly stale in every run's output — which is the point (kings-guard's // VALIDATED_AGAINST pattern, adopted as the reference by GH-DEC-2026-020). // -// It names the ACCEPTED v0.7 text plus the gate-house decisions this checker -// enforces beyond it, pinned by commit (GH-DEC-2026-021 §2). v0.8 is held under -// GH-DEC-2026-019, so a run must not claim a check against it; the re-point to -// v0.8 belongs to the post-flip commit (GH-WP-0004-T11). -const ValidatedAgainst = "net-kingdom/canon/standards/security-layer-model_v0.7.md (net-kingdom@66dc491) as amended by GH-DEC-2026-017, GH-DEC-2026-020, GH-DEC-2026-021 (gate-house@39d9287)" +// It names the accepted v0.8 artifact and its exact source digest. The +// canonical flip was accepted through native informed-decision memo revision2; +// this checker remains source-scoped and makes no runtime acceptance claim. +const ValidatedAgainst = "net-kingdom/canon/standards/security-layer-model_v0.8.md (sha256:3d8298560069ef471fba28d2b1245fb2309328f231dad1a667cdf0ce5a5a7c8b)" // Engine roles from §3.3. An Engine declaration must state one. var validEngineRoles = map[string]bool{ @@ -135,6 +134,10 @@ func Check(root string) error { } else if len(pins) > 0 { return fmt.Errorf("layer declaration carries a standard or companion version (A12 r2, GH-DEC-2026-020 §1-§2): %s", strings.Join(pins, "; ")) } + if err := checkDerivedDeclarations(root, decl); err != nil { + return err + } + hits, err := ScanToolingClients(root) if err != nil { return err @@ -145,6 +148,43 @@ func Check(root string) error { return nil } +// Optional sidecars are derived declarations, not another governing source. +// A discrepancy remains a finding even though INTENT.md has precedence. +func checkDerivedDeclarations(root string, governing Declaration) error { + for _, name := range []string{"layer.yaml", "layer.yml"} { + path := filepath.Join(root, name) + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + continue + } + if err != nil { + return fmt.Errorf("read derived declaration %s: %w", name, err) + } + var sidecar struct { + Layer string `yaml:"layer"` + Derived bool `yaml:"derived"` + DerivedFrom string `yaml:"derived_from"` + } + if err := yaml.Unmarshal(data, &sidecar); err != nil { + return fmt.Errorf("parse derived declaration %s: %w", name, err) + } + if !sidecar.Derived || sidecar.DerivedFrom != "INTENT.md" { + return fmt.Errorf("%s must declare derived: true and derived_from: INTENT.md under §11", name) + } + expected, _ := CanonicalLayer(governing.Layer) + actual, valid := CanonicalLayer(sidecar.Layer) + if !valid || expected != actual { + return fmt.Errorf("DECLARATION DISAGREEMENT: INTENT.md layer %q governs; %s derived layer %q remains a §11 finding", governing.Layer, name, sidecar.Layer) + } + if pins, err := DeclarationVersionPins(path); err != nil { + return err + } else if len(pins) > 0 { + return fmt.Errorf("derived layer declaration %s carries a standard or companion pin: %s", name, strings.Join(pins, "; ")) + } + } + return nil +} + // LoadDeclaration reads YAML frontmatter from INTENT.md. func LoadDeclaration(path string) (Declaration, error) { data, err := os.ReadFile(path) diff --git a/internal/layer/conformance_test.go b/internal/layer/conformance_test.go index 80dee38..e02699c 100644 --- a/internal/layer/conformance_test.go +++ b/internal/layer/conformance_test.go @@ -307,3 +307,36 @@ func TestOwnDeclarationCarriesNoVersionAnywhere(t *testing.T) { t.Fatal("the checker must state the version it validates against on every run") } } + +func TestDerivedDeclarationAgreementAndProvenance(t *testing.T) { + for _, tc := range []struct { + name, sidecar string + wantError bool + }{ + {"case folding agrees", "layer: eNgInE\nderived: true\nderived_from: INTENT.md\n", false}, + {"different layer remains finding", "layer: Staff\nderived: true\nderived_from: INTENT.md\n", true}, + {"missing derived provenance", "layer: Engine\n", true}, + {"wrong governing source", "layer: Engine\nderived: true\nderived_from: README.md\n", true}, + {"derived declaration pin refused", "layer: Engine\nderived: true\nderived_from: INTENT.md\nstandard: security-layer-model_v0.8.md\n", true}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + intent := "---\nlayer: Engine\nrole: PDP\nconformance_record: record.md\nsource_of_evidence: false\n---\n" + for name, body := range map[string]string{"INTENT.md": intent, "record.md": "derived record", "layer.yaml": tc.sidecar} { + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0600); err != nil { + t.Fatal(err) + } + } + err := layer.Check(dir) + if (err != nil) != tc.wantError { + t.Fatalf("Check error=%v; wantError=%v", err, tc.wantError) + } + }) + } +} + +func TestValidatedAgainstAcceptedCut(t *testing.T) { + if !strings.Contains(layer.ValidatedAgainst, "security-layer-model_v0.8.md") || !strings.Contains(layer.ValidatedAgainst, "sha256:3d829856") { + t.Fatal("checker must identify the accepted exact cut") + } +} diff --git a/internal/readback/readback.go b/internal/readback/readback.go index 12edeed..95bc8fe 100644 --- a/internal/readback/readback.go +++ b/internal/readback/readback.go @@ -129,7 +129,7 @@ func (s *Service) SetDecisionLog(log decisioncore.DecisionRecorder) { // Evaluate answers one row per requested system. Any evaluation or record // error fails the whole request: the consumer renders every row unavailable, -// and a withheld allow (FLEX-DEC-2026-018) is never released. +// and no effect is released without its required record (GH-DEC-2026-024). func (s *Service) Evaluate(ctx context.Context, request Request) (Response, error) { if err := validate(request); err != nil { return Response{}, err diff --git a/pkg/api/canonical.go b/pkg/api/canonical.go index c2b3a47..3baa784 100644 --- a/pkg/api/canonical.go +++ b/pkg/api/canonical.go @@ -241,9 +241,8 @@ func DecisionEffects() []DecisionEffect { } } -// Restricts reports whether an effect withholds authority. A restriction is -// released even when its record fails to commit, because withholding it turns -// it into an error an open-stance consumer reads as proceed (FLEX-DEC-2026-018). +// Restricts reports whether an effect withholds authority. This classification +// does not exempt an effect from required durable emission (GH-DEC-2026-024). func (e DecisionEffect) Restricts() bool { switch e { case DecisionEffectDeny, DecisionEffectRedact, DecisionEffectNotApplicable: diff --git a/tools/check_layer_conformance.go b/tools/check_layer_conformance.go index 3d74332..74193ee 100644 --- a/tools/check_layer_conformance.go +++ b/tools/check_layer_conformance.go @@ -20,7 +20,7 @@ func main() { os.Exit(2) } fmt.Printf("Validated against: %s\n", layer.ValidatedAgainst) - fmt.Printf("Scope: this repository only — %s/INTENT.md frontmatter (the §11 declaration), the files it names, and production Go sources under cmd/, internal/, pkg/. Stance, claims and classification maps are not read.\n", filepath.Base(root)) + fmt.Printf("Scope: this repository only — %s/INTENT.md frontmatter (the §11 declaration), optional derived layer.yaml/layer.yml declarations, the files INTENT names, and production Go sources under cmd/, internal/, pkg/. Stance, claims and classification maps are not read.\n", filepath.Base(root)) if _, err := os.Stat(filepath.Join(root, "INTENT.md")); err != nil { fmt.Fprintf(os.Stderr, "INTENT.md not found in %s\n", root) os.Exit(2) diff --git a/workplans/FLEX-WP-0031-decision-record-emission.md b/workplans/FLEX-WP-0031-decision-record-emission.md index 0b5df8a..811cc19 100644 --- a/workplans/FLEX-WP-0031-decision-record-emission.md +++ b/workplans/FLEX-WP-0031-decision-record-emission.md @@ -14,7 +14,7 @@ related_workplans: - FLEX-WP-0030 - FLEX-WP-0019 created: "2026-09-21" -updated: "2026-10-04" +updated: "2026-10-10" state_hub_workstream_id: "84f5d9fe-b4c9-584a-b964-efe3e48af095" --- @@ -279,3 +279,43 @@ implementation and its native acceptance. See preparation no longer wait on another repository, but admission, statutory ruling, valid reconciliation time basis and approved native acceptance do. `cadence.yaml` remains `declared-not-yet-emitting` and G2 remains open. + +## 9. Enforce the accepted mandatory-emission ruling in source + +```task +id: FLEX-WP-0031-T09 +status: done +priority: high +state_hub_task_id: "02a8d006-cc70-5a0e-b773-6924f05540ed" +``` + +2026-10-10: implemented `GH-DEC-2026-024` §3, the accepted refusal of +load-bearing failure-path release. This corrects the source release behavior +described under T01/T03; those dated entries remain historical evidence of +`FLEX-DEC-2026-018`, not the current implementation. No new human decision is +needed to implement the accepted doctrine in source. + +With a required recorder attached, a failed durable append now withholds every +effect, including deny/redact/not_applicable. Single checks, restriction-only +batches, list-allowed and application readback retain the existing +`ErrRecordNotCommitted` / HTTP 503 availability representation. No replacement +allow or deny is manufactured. Uncommitted results are not inserted into +Explain's history, and no released-uncommitted counter is incremented. Existing +historical outbox counters and their reporting remain readable. Standalone +operation without an attached recorder remains unchanged; no source change +enables production emission or claims that an unconfigured pin is conformant. + +Tests inject commit failure across the complete five-effect vocabulary, check +that Explain cannot expose uncommitted results and can expose committed ones, +exercise actual restrictive single/batch/list policy paths, and verify +restrictive enforcement and readback HTTP 503. Affected package suites pass: +`go test ./internal/decision ./internal/emission ./internal/readback +./cmd/access-engine ./pkg/api`. + +This source fix is not deployed. Consumer outage behavior remains governed by +each declared PEP stance: a consumer with an open-for-scope stance can treat +unavailable evaluation as proceed, so the exact rollout must review those +stances rather than claim that a 503 enforces denial. T02 native sender/custody, +T06 compatible reconciliation windows, T08 exact approved rollout and silence +proof, and T04 G2 closure remain unfinished. `cadence.yaml` remains declared, +and the emission completeness gap remains open until native acceptance. diff --git a/workplans/FLEX-WP-0035-per-identity-application-readback.md b/workplans/FLEX-WP-0035-per-identity-application-readback.md index 41c7eda..8800b94 100644 --- a/workplans/FLEX-WP-0035-per-identity-application-readback.md +++ b/workplans/FLEX-WP-0035-per-identity-application-readback.md @@ -4,11 +4,11 @@ type: workplan title: "Per-identity application access readback and readable protected-system registry for user-engine" domain: infotech repo: access-engine -status: active +status: finished owner: claude topic_slug: netkingdom created: "2026-10-09" -updated: "2026-10-09" +updated: "2026-10-10" related: [USER-WP-0026, USER-WP-0028] state_hub_workstream_id: "cb9755fa-6824-5288-81c4-3aa2fbff1a9f" --- @@ -191,3 +191,27 @@ without a token, `/v1/check` returns 404 on the readback pin, anonymous lifecycl user-engine's live check with its own token is still to come. The pin ships with the image that contains `serve-readback`. Hand user-engine the evidence that unblocks `USER-WP-0026-T03`. + +## Completion review — 2026-10-10 + +All five tasks are done. Fresh read-only verification on railiance01 (node +92.205.62.239) confirms the lifecycle and application-readback deployments +are each Ready 1/1 on identical immutable image +`sha256:e8c57c18a4a9d5583be099146984f7e9cd881446218b65aeefd7619b18aa9229`. +Readback ingress is restricted to the `user-engine` namespace and +`app.kubernetes.io/name=user-engine` pods on port 8080. The source regression +run `go test ./cmd/access-engine ./pkg/api -run 'TestReadback|TestPurpose' +-count=1` passes, including participant image/package parity, non-transferable +readback purpose, unauthorized-caller refusal, enforcement-route isolation, +MFA denial, response schema and outbox failure handling. + +The earlier task records retain founder rollout authority and native anonymous +route/health receipts; this review performed no deployment or credential action. + +The consumer integration and actual user-engine token probe remain live in the +existing owner task `USER-WP-0026-T03` (and the related consumer UI task +`USER-WP-0028-T02`). Those checks do not require unfinished Access Engine +implementation. The service contract and URL are in this workplan and +`docs/application-access-readback.md`. No new residual workplan is needed: the +receiving owner records already carry the work. This closure does not assert +that the consumer has integrated or that other applications opted in.