diff --git a/internal/kernel/liveness.go b/internal/kernel/liveness.go index 339afb7..71ba3a2 100644 --- a/internal/kernel/liveness.go +++ b/internal/kernel/liveness.go @@ -8,7 +8,7 @@ import ( "moos/kernel/internal/operad" ) -// §M11 liveness gate and §M12 admin-capability hook for kernel rewrites. +// §M11 liveness gate and §M12 admin-capability gate for kernel rewrites. // // Doctrine: // @@ -18,49 +18,64 @@ import ( // - §M12 says admin-scope rewrites additionally require the actor (after // has-occupant resolution) to hold the superadmin role via WF02 governs. // -// This file integrates both checks into Runtime.Apply / Runtime.ApplyProgram -// as small helpers so the main Apply body stays readable. §M12 is plumbed -// here via operad.AdminScopeRewrite + operad.CheckAdminCapability and ships -// as dormant in PR 3 (AdminScopeRewrite returns false until PR 4 fills it in). - -// checkLiveness is the §M11 gate. Called from Apply / ApplyProgram BEFORE -// operad validation so we fail fast on missing session context without -// paying structural-validation cost. Read-only over state; no locking -// required because Apply/ApplyProgram are already serialised by rt.mu in -// the caller (or read-lock during validation). +// Evaluation surface: +// +// - §M11 evaluates emitter-context: the session this envelope runs under. +// That session must pre-exist. For ApplyProgram, §M11 runs in preflight +// against the batch-initial state — emitter references cannot depend on +// prior envelopes in the same batch (see the initial-state-check +// doctrine note in kb/research/kernel/20260421-t171-m11-m12-implementation-plan.md §2.4). // -// Order of checks: -// 1. System-internal allowlist (sweep, kernel-actor, infrastructure ADD). -// These are below the governance line and always pass. -// 2. Session-context resolution via operad.ResolveSessionForEnvelope. -// Explicit env.SessionURN wins; inferred from Actor when unambiguous; -// reject when ambiguous or absent. -// 3. (PR 4 hook) admin-scope classification + capability check. Dormant -// in PR 3. +// - §M12 evaluates target-operation: what the envelope does. That operation +// can depend on nodes created earlier in the same batch. For ApplyProgram, +// §M12 runs per-envelope inside the working-state loop — a MUTATE on a +// node ADDed in envelope N-1 sees that node's type when classifying at +// envelope N. Closes the Gemini PR 31 security-HIGH where §M12 was +// evaluated against initial state and ADD-then-MUTATE could bypass the +// admin gate. + +// checkLiveness runs both §M11 and §M12 against rt.state (no working state +// evolves). Used by Apply. The combined form is safe here because Apply +// handles a single envelope — the target either exists at call time or it +// doesn't, and ApplyProgram's mid-batch scenario cannot arise. // -// Returns nil on pass, a fmt.Errorf wrapping the failure mode on reject. -// Error messages name the doctrine section so log readers can trace back. +// Caller must hold rt.mu at least for read. Returns nil on pass; a +// fmt.Errorf naming the doctrine section on reject. func (rt *Runtime) checkLiveness(env graph.Envelope) error { - // Registry-less mode (no --ontology): liveness is a no-op. The kernel - // still replays and applies rewrites; it just does not enforce the - // occupancy invariant. Matches the existing pattern where validators - // short-circuit when registry is nil. + if err := rt.checkLivenessM11(env, rt.state); err != nil { + return err + } + return rt.checkLivenessM12(env, rt.state) +} + +// checkLivenessM11 is the §M11 emitter-context gate. Single-phase: the +// session URN the envelope runs under must exist in the passed state and +// must have the actor as has-occupant (or the actor must itself be an +// occupied session node). +// +// Parameter `state` is explicit so callers can choose whether to evaluate +// against rt.state (Apply, single-envelope) or some other snapshot. For +// ApplyProgram we always pass the batch-initial state because §M11 is an +// emitter-pre-existence rule — emitter references cannot depend on prior +// envelopes in the batch. +// +// Returns nil on pass; a fmt.Errorf for each resolver failure kind. +func (rt *Runtime) checkLivenessM11(env graph.Envelope, state graph.GraphState) error { if rt.registry == nil { return nil } - // Step 1 — system-internal allowlist. Sweep, kernel actors, and - // bootstrap-infrastructure ADDs are below the liveness line. + // System-internal allowlist precedes all M11/M12 enforcement. Sweep, + // kernel actors, and bootstrap-infrastructure ADDs are below the + // governance line and bypass both gates. if operad.SystemInternalEnvelope(env) { return nil } - // Step 2 — resolve session context. Pass the live state so reverse - // has-occupant walks see the most recent seat assignments. - res := operad.ResolveSessionForEnvelope(rt.state, env) + res := operad.ResolveSessionForEnvelope(state, env) switch res.Kind { case operad.ResolveSessionExplicit, operad.ResolveSessionInferred, operad.ResolveSessionActorIsSession: - // Pass: a single, verified session context was resolved. + return nil case operad.ResolveSessionExplicitMismatch: return fmt.Errorf("kernel(§M11): envelope names session_urn=%s but that session is missing, not of type session, or does not have has-occupant -> actor=%s", res.SessionURN, env.Actor) @@ -71,17 +86,37 @@ func (rt *Runtime) checkLiveness(env graph.Envelope) error { return fmt.Errorf("kernel(§M11): no session context for actor=%s (no has-occupant relation in state and no session_urn on envelope)", env.Actor) } + return nil +} - // Step 3 — admin-scope gate (§M12 hook). Dormant in PR 3 — the - // classifier currently returns false for every envelope. PR 4 tightens - // this to check capability via operad.CheckAdminCapability. - if operad.AdminScopeRewrite(env, rt.state) { - if !operad.CheckAdminCapability(rt.state, env.Actor) { - return fmt.Errorf("kernel(§M12): actor=%s lacks WF02 superadmin capability for admin-scope rewrite", - env.Actor) - } +// checkLivenessM12 is the §M12 admin-capability gate. Classifies the +// envelope via the registry's AdminScopeRewrite method against the passed +// state — if classified admin-scope, the actor must hold WF02 superadmin +// capability. The state parameter matters: for ApplyProgram, the caller +// passes the working state AFTER earlier envelopes in the batch have been +// folded so a MUTATE on a mid-batch-ADDed node sees the node's type +// correctly. +// +// The §M11 allowlist (SystemInternalEnvelope) is re-checked here to keep +// checkLivenessM12 safe to call in isolation — if a future caller uses it +// without first running checkLivenessM11, kernel-actor and infrastructure +// envelopes still bypass correctly. +// +// Returns nil on pass; a fmt.Errorf naming §M12 on reject. +func (rt *Runtime) checkLivenessM12(env graph.Envelope, state graph.GraphState) error { + if rt.registry == nil { + return nil + } + if operad.SystemInternalEnvelope(env) { + return nil + } + if !rt.registry.AdminScopeRewrite(env, state) { + return nil + } + if !operad.CheckAdminCapability(state, env.Actor) { + return fmt.Errorf("kernel(§M12): actor=%s lacks WF02 superadmin capability for admin-scope rewrite", + env.Actor) } - return nil } diff --git a/internal/kernel/liveness_test.go b/internal/kernel/liveness_test.go index 27f35b3..7c49762 100644 --- a/internal/kernel/liveness_test.go +++ b/internal/kernel/liveness_test.go @@ -453,6 +453,362 @@ func TestApply_M11_OccupiedSessionAsActor_Accepted(t *testing.T) { } } +// ------------------------------------------------------------------ +// §M12 admin-capability gate — PR 4 integration tests +// ------------------------------------------------------------------ + +// adminLivenessRegistry extends the liveness-test registry with property +// specs the §M12 classifier consults (owner vs kernel authority_scope). +func adminLivenessRegistry() *operad.Registry { + reg := livenessTestRegistry() + reg.NodeTypes["program"] = operad.NodeTypeSpec{ + ID: "program", Stratum: "S2", + Properties: map[string]operad.PropertySpec{ + "status": {Mutability: "mutable", AuthorityScope: "owner"}, + "target_t": {Mutability: "mutable", AuthorityScope: "kernel"}, + }, + } + // Ontology-governed types need declarations so ValidateADD doesn't reject + // them as "unknown type_id" before §M12 runs. + for _, t := range []graph.TypeID{"system_instruction", "gate", "twin_link", "transport_binding", "role"} { + reg.NodeTypes[t] = operad.NodeTypeSpec{ID: t, Stratum: "S2"} + } + return reg +} + +// injectSuperadminRole wires a role:superadmin node into state plus a +// WF02 governs LINK from principal → role. Mirrors the §M12 admin chain +// from operad.CheckAdminCapability. +func injectSuperadminRole(rt *Runtime, principal graph.URN) { + const superadmin graph.URN = "urn:moos:role:superadmin" + rt.state.Nodes[superadmin] = graph.Node{URN: superadmin, TypeID: "role"} + + relURN := graph.URN("urn:moos:rel:" + string(principal) + ".governs.superadmin") + rt.state.Relations[relURN] = graph.Relation{ + URN: relURN, RewriteCategory: graph.WF02, + SrcURN: principal, SrcPort: "governs", + TgtURN: superadmin, TgtPort: "governed-by", + } + if rt.state.RelationsBySrc[principal] == nil { + rt.state.RelationsBySrc[principal] = map[graph.URN]struct{}{} + } + rt.state.RelationsBySrc[principal][relURN] = struct{}{} + if rt.state.RelationsByTgt[superadmin] == nil { + rt.state.RelationsByTgt[superadmin] = map[graph.URN]struct{}{} + } + rt.state.RelationsByTgt[superadmin][relURN] = struct{}{} +} + +func newAdminRuntime(t *testing.T) *Runtime { + t.Helper() + return &Runtime{ + state: graph.NewGraphState(), + store: NewMemStore(), + registry: adminLivenessRegistry(), + subscribers: make(map[string]chan graph.PersistedRewrite), + } +} + +// Test 1 — Guido's list: non-admin actor + ontology-governed ADD is rejected. +func TestApply_M12_NonAdminActor_OntologyGovernedADD_Rejected(t *testing.T) { + rt := newAdminRuntime(t) + injectOccupancy(rt, + "urn:moos:session:sam.a", + "urn:moos:agent:claude", + "agent", + ) // §M11 passes; §M12 is the gate under test + + env := graph.Envelope{ + RewriteType: graph.ADD, + Actor: "urn:moos:agent:claude", // no superadmin + NodeURN: "urn:moos:gate:bad", + TypeID: "gate", // ontology-governed + } + _, err := rt.Apply(env) + if err == nil { + t.Fatalf("ontology-governed ADD without superadmin should be rejected") + } + if !strings.Contains(err.Error(), "§M12") { + t.Errorf("error should cite §M12; got %q", err.Error()) + } +} + +// Test 2 — admin actor + ontology-governed ADD is accepted. +func TestApply_M12_AdminActor_OntologyGovernedADD_Accepted(t *testing.T) { + rt := newAdminRuntime(t) + injectOccupancy(rt, + "urn:moos:session:sam.a", + "urn:moos:agent:claude", + "agent", + ) + injectSuperadminRole(rt, "urn:moos:agent:claude") + + env := graph.Envelope{ + RewriteType: graph.ADD, + Actor: "urn:moos:agent:claude", + NodeURN: "urn:moos:system_instruction:persona.test", + TypeID: "system_instruction", + } + if _, err := rt.Apply(env); err != nil { + t.Fatalf("admin actor should pass §M12; got %v", err) + } +} + +// Test 3 — ordinary type ADD passes §M12 even for non-admin actor. +func TestApply_M12_NonAdminActor_OrdinaryADD_NotAdminScope(t *testing.T) { + rt := newAdminRuntime(t) + injectOccupancy(rt, + "urn:moos:session:sam.a", + "urn:moos:agent:claude", + "agent", + ) + + env := graph.Envelope{ + RewriteType: graph.ADD, + Actor: "urn:moos:agent:claude", + NodeURN: "urn:moos:program:ordinary", + TypeID: "program", // not ontology-governed + } + if _, err := rt.Apply(env); err != nil { + t.Fatalf("ordinary-type ADD should not trigger §M12; got %v", err) + } +} + +// Test 4 — non-admin actor MUTATEing a kernel-authority field on a +// non-kernel node is rejected with §M12. +func TestApply_M12_NonAdminActor_KernelAuthorityMUTATE_Rejected(t *testing.T) { + rt := newAdminRuntime(t) + injectOccupancy(rt, + "urn:moos:session:sam.a", + "urn:moos:agent:claude", + "agent", + ) + // Pre-seed a program node with target_t (kernel-authority). + rt.state.Nodes["urn:moos:program:p"] = graph.Node{ + URN: "urn:moos:program:p", TypeID: "program", + Properties: map[string]graph.Property{ + "target_t": {Value: 190.0, Mutability: "mutable", AuthorityScope: "kernel"}, + }, + } + + env := graph.Envelope{ + RewriteType: graph.MUTATE, + Actor: "urn:moos:agent:claude", // non-kernel, non-superadmin + TargetURN: "urn:moos:program:p", + Field: "target_t", + NewValue: 200.0, + } + _, err := rt.Apply(env) + if err == nil { + t.Fatalf("kernel-authority MUTATE by non-admin non-kernel actor should be rejected") + } + if !strings.Contains(err.Error(), "§M12") { + t.Errorf("error should cite §M12; got %q", err.Error()) + } +} + +// Test 5 — owner-authority MUTATE is not admin-scope, passes. +func TestApply_M12_NonAdminActor_OwnerAuthorityMUTATE_NotAdminScope(t *testing.T) { + rt := newAdminRuntime(t) + injectOccupancy(rt, + "urn:moos:session:sam.a", + "urn:moos:agent:claude", + "agent", + ) + rt.state.Nodes["urn:moos:program:p"] = graph.Node{ + URN: "urn:moos:program:p", TypeID: "program", + Properties: map[string]graph.Property{ + "status": {Value: "active", Mutability: "mutable", AuthorityScope: "owner"}, + "owner_urn": {Value: "urn:moos:agent:claude", Mutability: "immutable"}, + }, + } + + env := graph.Envelope{ + RewriteType: graph.MUTATE, + Actor: "urn:moos:agent:claude", + TargetURN: "urn:moos:program:p", + Field: "status", + NewValue: "checkpoint", + RewriteCategory: graph.WF18, + } + // Register WF18 so ValidateMUTATE's standard-path check doesn't trip + // on "unknown rewrite_category". Minimal spec is fine — the §M12 test + // doesn't exercise mutate_scope semantics. + rt.registry.RewriteCategories[graph.WF18] = operad.RewriteCategorySpec{ + ID: graph.WF18, + AllowedRewrites: []graph.RewriteType{graph.MUTATE, graph.LINK, graph.ADD}, + MutateScope: []string{"status"}, + } + + if _, err := rt.Apply(env); err != nil { + t.Fatalf("owner-authority MUTATE should pass §M12; got %v", err) + } +} + +// Test 6 — kernel-URN actor bypasses §M12 via §M11 allowlist (precedence). +func TestApply_M12_KernelActor_OntologyGovernedADD_AllowlistBeforeM12(t *testing.T) { + rt := newAdminRuntime(t) + // No sessions, no superadmin role. Kernel actor bypasses §M11 entirely + // via SystemInternalEnvelope, and that bypass precedes the §M12 check + // so admin-scope never runs for kernel actors. + env := graph.Envelope{ + RewriteType: graph.ADD, + Actor: "urn:moos:kernel:hp-z440.primary", + NodeURN: "urn:moos:gate:internal", + TypeID: "gate", + } + if _, err := rt.Apply(env); err != nil { + t.Fatalf("kernel-actor ontology-governed ADD should be allowlisted before §M12; got %v", err) + } +} + +// Test 7 — SeedIfAbsent bypass passes both §M11 and §M12. +func TestSeedIfAbsent_M12_OntologyGovernedADD_BypassesBoth(t *testing.T) { + rt := newAdminRuntime(t) + // No occupancy, no superadmin — seed should still land via skipLiveness. + env := graph.Envelope{ + RewriteType: graph.ADD, + Actor: "urn:moos:user:sam", + NodeURN: "urn:moos:transport_binding:bootstrap", + TypeID: "transport_binding", + } + if err := rt.SeedIfAbsent(env); err != nil { + t.Fatalf("SeedIfAbsent should bypass §M12; got %v", err) + } +} + +// TestApplyProgram_M12_IntraBatchADDThenKernelAuthorityMUTATE_Rejected +// pins the Gemini security-HIGH fix on PR 31: a batch that ADDs a new +// program node and in the same batch MUTATEs a kernel-authority property +// on it previously bypassed §M12 because the preflight checker saw the +// target missing from the batch-initial state and classified as +// not-admin-scope. Post-fix, §M12 runs against the working state inside +// the write-locked loop; by envelope 2, the program node exists and its +// kernel-authority field is correctly classified admin-scope. +func TestApplyProgram_M12_IntraBatchADDThenKernelAuthorityMUTATE_Rejected(t *testing.T) { + rt := newAdminRuntime(t) + injectOccupancy(rt, + "urn:moos:session:sam.a", + "urn:moos:agent:claude", + "agent", + ) // claude occupies a session, passes §M11, no superadmin + + preLen := rt.LogLen() + + program := []graph.Envelope{ + { + RewriteType: graph.ADD, + Actor: "urn:moos:agent:claude", + NodeURN: "urn:moos:program:freshly-baked", + TypeID: "program", + }, + { + RewriteType: graph.MUTATE, + Actor: "urn:moos:agent:claude", + TargetURN: "urn:moos:program:freshly-baked", // created in envelope 1 + Field: "target_t", // authority_scope=kernel per type spec + NewValue: 200.0, + }, + } + _, err := rt.ApplyProgram(program) + if err == nil { + t.Fatalf("ADD+kernel-authority-MUTATE batch without superadmin must be rejected") + } + if !strings.Contains(err.Error(), "§M12") { + t.Errorf("error should cite §M12; got %q", err.Error()) + } + if got := rt.LogLen(); got != preLen { + t.Errorf("atomic rejection should leave log unchanged; pre=%d post=%d", preLen, got) + } +} + +// TestApplyProgram_M12_IntraBatchADDThenOwnerMUTATE_Passes confirms the +// fix does not over-reject: a batch that ADDs a program and then +// MUTATEs an owner-authority field on it (e.g. status) still passes §M12 +// because the mid-batch classification correctly sees the target as a +// program (not ontology-governed) and the field as owner-authority. +func TestApplyProgram_M12_IntraBatchADDThenOwnerMUTATE_Passes(t *testing.T) { + rt := newAdminRuntime(t) + injectOccupancy(rt, + "urn:moos:session:sam.a", + "urn:moos:agent:claude", + "agent", + ) + // Register WF18 so ValidateMUTATE is happy. + rt.registry.RewriteCategories[graph.WF18] = operad.RewriteCategorySpec{ + ID: graph.WF18, + AllowedRewrites: []graph.RewriteType{graph.ADD, graph.MUTATE, graph.LINK}, + MutateScope: []string{"status"}, + } + + program := []graph.Envelope{ + { + RewriteType: graph.ADD, + Actor: "urn:moos:agent:claude", + NodeURN: "urn:moos:program:ordinary", + TypeID: "program", + Properties: map[string]graph.Property{ + "status": {Value: "active", Mutability: "mutable", AuthorityScope: "owner"}, + "owner_urn": {Value: "urn:moos:agent:claude", Mutability: "immutable"}, + }, + }, + { + RewriteType: graph.MUTATE, + Actor: "urn:moos:agent:claude", + TargetURN: "urn:moos:program:ordinary", + Field: "status", // owner-authority + NewValue: "checkpoint", + RewriteCategory: graph.WF18, + }, + } + if _, err := rt.ApplyProgram(program); err != nil { + t.Fatalf("owner-authority MUTATE on same-batch-ADDed program must pass §M12; got %v", err) + } +} + +// Test 8 — fold.Replay of pre-PR-4 rewrites that would now be admin-scope +// still replays cleanly. fold doesn't call checkLiveness, so §M12 has no +// retroactive effect. Mirrors the prospective-only invariant for §M11 +// and the port-pair validator from PR 1. +func TestReplay_M12_PreservesPrePR4AdminScopeRewrites(t *testing.T) { + log := []graph.PersistedRewrite{ + { + LogSeq: 1, + Envelope: graph.Envelope{ + RewriteType: graph.ADD, + Actor: "urn:moos:user:sam", // non-admin + NodeURN: "urn:moos:gate:pre-pr4", + TypeID: "gate", + Properties: map[string]graph.Property{ + "predicate_expr": {Value: "true", Mutability: "immutable"}, + }, + }, + }, + { + LogSeq: 2, + Envelope: graph.Envelope{ + RewriteType: graph.ADD, + Actor: "urn:moos:user:sam", + NodeURN: "urn:moos:system_instruction:pre-pr4", + TypeID: "system_instruction", + Properties: map[string]graph.Property{ + "content": {Value: "legacy", Mutability: "mutable"}, + }, + }, + }, + } + state, err := fold.Replay(log) + if err != nil { + t.Fatalf("replay of pre-PR-4 admin-scope rewrites should succeed; got %v", err) + } + if _, ok := state.Nodes["urn:moos:gate:pre-pr4"]; !ok { + t.Errorf("replayed state missing gate:pre-pr4") + } + if _, ok := state.Nodes["urn:moos:system_instruction:pre-pr4"]; !ok { + t.Errorf("replayed state missing system_instruction:pre-pr4") + } +} + // TestApply_RegistryLess_LivenessNoop — when the Runtime has no registry // loaded (--ontology omitted), the liveness gate is a no-op. Preserves the // existing "registry-less mode" UX and matches the pattern used by the diff --git a/internal/kernel/runtime.go b/internal/kernel/runtime.go index a8240c1..61a5a87 100644 --- a/internal/kernel/runtime.go +++ b/internal/kernel/runtime.go @@ -184,16 +184,15 @@ func (rt *Runtime) applyWithOptions(env graph.Envelope, opts applyOptions) (grap // §M11 liveness is checked per-envelope before structural validation so a // single session-less envelope fails the whole batch fast. func (rt *Runtime) ApplyProgram(envelopes []graph.Envelope) ([]graph.EvalResult, error) { - // Preflight under read-lock: §M11 liveness checks read rt.state maps, - // so we must hold the read-lock for the entire batch preflight. Held - // across all envelopes so liveness observations are consistent with - // each other. Release before acquiring the write-lock below (RWMutex - // does not support upgrade). Same apply-time guarantee as Apply: - // fold.EvaluateProgram under the write-lock is the authoritative - // check; preflight rejects early on clearly-bad batches. + // Preflight under read-lock: §M11 (emitter-context) checks read rt.state + // maps, so we hold the read-lock across the entire batch preflight. §M11 + // is evaluated against the batch-initial state — emitter references + // cannot depend on prior envelopes in the batch (see impl-plan §2.4). + // §M12 is NOT checked here; it runs per-envelope inside the write-locked + // working-state loop below so target-classification sees mid-batch ADDs. rt.mu.RLock() for _, env := range envelopes { - if err := rt.checkLiveness(env); err != nil { + if err := rt.checkLivenessM11(env, rt.state); err != nil { rt.mu.RUnlock() return nil, err } @@ -229,6 +228,17 @@ func (rt *Runtime) ApplyProgram(envelopes []graph.Envelope) ([]graph.EvalResult, } injected[i] = env + // §M12 admin-scope gate against working state. Per-envelope so a + // MUTATE on a node ADDed in an earlier envelope of this batch sees + // the node's type when classifying. Closes the PR 31 Gemini + // security-HIGH where initial-state preflight let a batch ADD a + // target and then MUTATE its kernel-authority property without + // triggering the admin check. §M11 preflight already ran above; + // if we reach here the emitter-context check has passed. + if err := rt.checkLivenessM12(env, workingState); err != nil { + return nil, err + } + // Strata enforcement (M5) + Gate check (M8) against the working state. if env.RewriteType == graph.LINK && rt.registry != nil { if err := rt.registry.ValidateStrataLink(env, workingState); err != nil { diff --git a/internal/operad/session_context.go b/internal/operad/session_context.go index bf84bfb..b6c4989 100644 --- a/internal/operad/session_context.go +++ b/internal/operad/session_context.go @@ -228,25 +228,150 @@ func isInfrastructureType(t graph.TypeID) bool { return false } +// ontologyGovernedTypes is the set of node types whose ADD or MUTATE is +// classified as admin-scope by §M12 (Guido answers on ffs0#33). When the +// §M12 gate runs, envelopes touching these types require superadmin +// capability via WF02 governs → role:superadmin. +// +// IMPORTANT on precedence: the §M11 allowlist (SystemInternalEnvelope) +// runs BEFORE §M12 in the kernel gate. Kernel-URN actors + ADD of +// infrastructure types (user, workstation, kernel) bypass §M11 entirely +// and therefore never reach §M12. So an ADD of type=kernel by a kernel- +// actor or during bootstrap does NOT require superadmin — it's +// allowlisted up the pipeline. §M12's admin-scope rule applies only to +// envelopes that reach the admin check, which excludes system-internal +// and bootstrap paths. +// +// - system_instruction: S4 context overlay, shapes how downstream +// reads interpret the graph. +// - gate: fail-closed flow primitive (§M8); wrong gates brick Apply. +// - twin_link: kernel-replication pairing (§M9); wrong pairing +// corrupts the adjoint. +// - transport_binding: wire-protocol declaration; wrong binding +// breaks federation. +// - kernel: ADD kernel creates a new sovereign substrate — as +// ontology-adjacent as it gets (Guido flag on the M11/M12 plan). +// Allowlisted for kernel-actor and infrastructure-bootstrap paths +// per the precedence note above. +var ontologyGovernedTypes = map[graph.TypeID]struct{}{ + "system_instruction": {}, + "gate": {}, + "twin_link": {}, + "transport_binding": {}, + "kernel": {}, +} + // AdminScopeRewrite classifies whether an envelope touches admin-scope -// surface per §M12. The admin scope covers: +// surface per §M12. The admin scope covers (per Guido's answers on +// ffs0#33 and the M11/M12 implementation plan): // -// 1. Mutations to properties with authority_scope == "kernel" on non-kernel -// nodes, when the actor is NOT a kernel URN (mitigates §M12 Q3 (3)); -// 2. ADDs or MUTATEs of ontology-governed node types (§M12 Q3 (2)): -// system_instruction, gate, twin_link, transport_binding, kernel. +// 1. ADDs or MUTATEs of ontology-governed node types (system_instruction, +// gate, twin_link, transport_binding, kernel). Any change to the +// grammar of the graph itself flows through superadmin. +// 2. MUTATEs of properties with authority_scope == "kernel" on non-kernel +// nodes. The authority declaration on the ontology says "only kernel +// URNs may change this", and §M12 extends that to "or a superadmin- +// capable actor". Non-kernel actors without superadmin fail closed. // 3. (Reserved for §M16) MUTATEs to the ontology file / version. Stays // off until an ontology_publication node type lands. // -// PR 3 ships this as the integration hook; the classifier currently returns -// false (no envelopes admin-scope-gated) so §M12 is effectively dormant. -// PR 4 fills in the logic. Keeping the call site plumbed means PR 4 is a -// pure-additive diff to this one function plus its callers in kernel. +// System-internal envelopes (kernel actors, infrastructure ADDs, sweep +// WF13) are allowlisted by the §M11 gate BEFORE §M12 runs — a kernel +// URN emitting an ontology-governed rewrite bypasses §M12 by design. +// The gate (checkLiveness in kernel package) is where the allowlist +// precedes the admin check. // -// Parameter `state` is reserved for PR 4's property-lookup path; PR 3 -// passes it through without consulting it. -func AdminScopeRewrite(env graph.Envelope, state graph.GraphState) bool { - _ = env - _ = state +// The method takes registry access (vs. the PR 3 package-level form) +// because case 2 requires looking up the type-spec authority_scope for +// additive MUTATEs where the field is not yet on the node. For fields +// already on the node, the existing Property.AuthorityScope is consulted +// directly. +func (r *Registry) AdminScopeRewrite(env graph.Envelope, state graph.GraphState) bool { + if r == nil { + return false + } + + // Case 1 — ADD of ontology-governed type. + if env.RewriteType == graph.ADD { + if _, gov := ontologyGovernedTypes[env.TypeID]; gov { + return true + } + return false + } + + // Case 1+2 — MUTATE on a node. Look up the node's type first. + if env.RewriteType == graph.MUTATE { + node, ok := state.Nodes[env.TargetURN] + if !ok { + // Target missing from the state passed here. Fail closed: + // classify as admin-scope. Rationale (Gemini security flag on + // PR 31): + // - A lone MUTATE on a non-existent node will fold-fail with + // ErrNodeNotFound regardless; marking it admin-scope does + // no harm (the admin-cap check runs and then fold rejects). + // - Inside a program batch where an ADD creates the target + // first and a later MUTATE touches it, callers must pass + // the working-state (post-ADD) to AdminScopeRewrite. If + // they pass pre-batch state here, the classifier does not + // have enough context to rule on authority_scope and + // must not silently pass. Kernel.Runtime.ApplyProgram + // threads workingState through the per-envelope §M12 + // check for exactly this reason (T=171 PR 31 fix). + return true + } + + // Case 1 — target node is of an ontology-governed type. + if _, gov := ontologyGovernedTypes[node.TypeID]; gov { + return true + } + + // Case 2 — kernel-authority property MUTATE. node.TypeID == "kernel" + // is already handled by case 1 (kernel is ontology-governed), so no + // extra exclusion is needed here. Check existing node properties + // first (cheap map lookup); fall back to the registry type spec + // for additive MUTATE (field not yet present on the node) AND for + // cases where the stored property's AuthorityScope is empty (an + // ADD that omitted the authority metadata — fail-closed, trust the + // registry declaration, Gemini security flag on PR 31). + if scope, ok := authorityScopeForField(r, node, env.Field); ok { + if scope == "kernel" { + return true + } + } + return false + } + + // LINK / UNLINK are not admin-scope-gated in §M12 v1. A future PR could + // extend this (e.g. LINK to superadmin role requires superadmin), but + // that's outside the current doctrine scope. return false } + +// authorityScopeForField returns the AuthorityScope for a field on a node. +// Prefers the live property record when it carries a non-empty scope +// (reflects what actually landed); falls back to the registry type-spec +// declaration for both additive MUTATE (field not yet on the node) AND +// the case where the stored property has an empty AuthorityScope (e.g. +// an older ADD that omitted the metadata). +// +// The empty-scope-falls-through pattern closes the Gemini security flag +// on PR 31: trusting whatever's in state could let an ADD that forgot to +// populate AuthorityScope bypass §M12 indefinitely. The registry +// declaration is authoritative and should always be consulted when the +// stored value is missing or blank. +// +// Returns "", false when the field is unknown to both sources. +func authorityScopeForField(r *Registry, node graph.Node, field string) (string, bool) { + if prop, ok := node.Properties[field]; ok && prop.AuthorityScope != "" { + return prop.AuthorityScope, true + } + typeSpec, hasType := r.NodeTypes[node.TypeID] + if !hasType { + return "", false + } + pspec, hasPspec := typeSpec.Properties[field] + if !hasPspec { + return "", false + } + return pspec.AuthorityScope, true +} diff --git a/internal/operad/session_context_test.go b/internal/operad/session_context_test.go index 7317049..e457cf1 100644 --- a/internal/operad/session_context_test.go +++ b/internal/operad/session_context_test.go @@ -235,22 +235,271 @@ func TestSystemInternalEnvelope_UserLINKNotAllowlisted(t *testing.T) { } // ------------------------------------------------------------------ -// AdminScopeRewrite — PR 3 plumbing, dormant (always returns false) +// AdminScopeRewrite — §M12 classifier (PR 4) // ------------------------------------------------------------------ -func TestAdminScopeRewrite_DormantInPR3(t *testing.T) { - // Until PR 4 fills in the classifier, no envelope is admin-scope-gated. - // This test pins the dormant state so PR 4 is explicitly a behavior - // change rather than a silent flip. +// adminScopeRegistry returns a Registry with the node-type specs PR 4 +// exercises: `program` (ordinary, owner-authority), `session` (kernel- +// authority on context_urn + local_t), and the five ontology-governed +// types whose touches are always admin-scope. +func adminScopeRegistry() *Registry { + reg := EmptyRegistry() + reg.NodeTypes["program"] = NodeTypeSpec{ + ID: "program", Stratum: "S2", + Properties: map[string]PropertySpec{ + "status": {Mutability: "mutable", AuthorityScope: "owner"}, + "target_t": {Mutability: "mutable", AuthorityScope: "kernel"}, + "scope": {Mutability: "mutable", AuthorityScope: "owner"}, + "owner_urn": {Mutability: "immutable", AuthorityScope: ""}, + }, + } + reg.NodeTypes["session"] = NodeTypeSpec{ + ID: "session", Stratum: "S2", + Properties: map[string]PropertySpec{ + "context_urn": {Mutability: "mutable", AuthorityScope: "kernel"}, + "local_t": {Mutability: "mutable", AuthorityScope: "kernel"}, + "view_prefs": {Mutability: "mutable", AuthorityScope: "owner"}, + }, + } + for _, t := range []graph.TypeID{"system_instruction", "gate", "twin_link", "transport_binding", "kernel"} { + reg.NodeTypes[t] = NodeTypeSpec{ID: t, Stratum: "S2"} + } + return reg +} + +func TestAdminScopeRewrite_ADDOntologyGoverned_AllTypes(t *testing.T) { + reg := adminScopeRegistry() state := graph.GraphState{Nodes: map[graph.URN]graph.Node{}, Relations: map[graph.URN]graph.Relation{}} - samples := []graph.Envelope{ - {RewriteType: graph.ADD, Actor: "urn:moos:user:sam", TypeID: "system_instruction"}, - {RewriteType: graph.MUTATE, Actor: "urn:moos:user:sam", TargetURN: "urn:moos:program:x", Field: "status"}, - {RewriteType: graph.LINK, Actor: "urn:moos:user:sam", RewriteCategory: graph.WF01}, - } - for _, env := range samples { - if AdminScopeRewrite(env, state) { - t.Errorf("AdminScopeRewrite should be dormant in PR 3; got true for %+v", env) + for _, typeID := range []graph.TypeID{"system_instruction", "gate", "twin_link", "transport_binding", "kernel"} { + env := graph.Envelope{ + RewriteType: graph.ADD, + Actor: "urn:moos:user:sam", + TypeID: typeID, + NodeURN: graph.URN("urn:moos:" + string(typeID) + ":test"), } + if !reg.AdminScopeRewrite(env, state) { + t.Errorf("ADD of ontology-governed type %s should be admin-scope", typeID) + } + } +} + +func TestAdminScopeRewrite_ADDOrdinaryType_NotAdminScope(t *testing.T) { + reg := adminScopeRegistry() + state := graph.GraphState{Nodes: map[graph.URN]graph.Node{}, Relations: map[graph.URN]graph.Relation{}} + env := graph.Envelope{ + RewriteType: graph.ADD, + Actor: "urn:moos:user:sam", + TypeID: "program", + NodeURN: "urn:moos:program:x", + } + if reg.AdminScopeRewrite(env, state) { + t.Errorf("ADD of ordinary type (program) should NOT be admin-scope") + } +} + +func TestAdminScopeRewrite_MUTATEOntologyGovernedNode_AdminScope(t *testing.T) { + reg := adminScopeRegistry() + state := graph.GraphState{ + Nodes: map[graph.URN]graph.Node{ + "urn:moos:gate:approval": {URN: "urn:moos:gate:approval", TypeID: "gate"}, + }, + Relations: map[graph.URN]graph.Relation{}, + } + env := graph.Envelope{ + RewriteType: graph.MUTATE, + Actor: "urn:moos:user:sam", + TargetURN: "urn:moos:gate:approval", + Field: "predicate_expr", + NewValue: "true", + } + if !reg.AdminScopeRewrite(env, state) { + t.Errorf("MUTATE of gate node (ontology-governed) should be admin-scope") + } +} + +func TestAdminScopeRewrite_MUTATEKernelAuthorityField_AdminScope(t *testing.T) { + reg := adminScopeRegistry() + state := graph.GraphState{ + Nodes: map[graph.URN]graph.Node{ + "urn:moos:program:x": { + URN: "urn:moos:program:x", TypeID: "program", + Properties: map[string]graph.Property{ + "target_t": {Value: 190.0, Mutability: "mutable", AuthorityScope: "kernel"}, + }, + }, + }, + Relations: map[graph.URN]graph.Relation{}, + } + env := graph.Envelope{ + RewriteType: graph.MUTATE, + Actor: "urn:moos:user:sam", // non-kernel actor + TargetURN: "urn:moos:program:x", + Field: "target_t", // authority_scope=kernel + } + if !reg.AdminScopeRewrite(env, state) { + t.Errorf("MUTATE of kernel-authority field on non-kernel node should be admin-scope") + } +} + +// TestAdminScopeRewrite_MUTATEKernelAuthorityField_AdditiveLookup exercises the +// registry-type-spec fallback path for an additive MUTATE: the field is not +// yet on the node, so the classifier consults the type spec's AuthorityScope +// declaration directly. +func TestAdminScopeRewrite_MUTATEKernelAuthorityField_AdditiveLookup(t *testing.T) { + reg := adminScopeRegistry() + state := graph.GraphState{ + Nodes: map[graph.URN]graph.Node{ + "urn:moos:program:y": { + URN: "urn:moos:program:y", TypeID: "program", + Properties: map[string]graph.Property{}, // target_t not yet on node + }, + }, + Relations: map[graph.URN]graph.Relation{}, + } + env := graph.Envelope{ + RewriteType: graph.MUTATE, + Actor: "urn:moos:user:sam", + TargetURN: "urn:moos:program:y", + Field: "target_t", // kernel-authority in type spec + } + if !reg.AdminScopeRewrite(env, state) { + t.Errorf("additive MUTATE of kernel-authority field should be admin-scope via type-spec lookup") + } +} + +func TestAdminScopeRewrite_MUTATEOwnerAuthorityField_NotAdminScope(t *testing.T) { + reg := adminScopeRegistry() + state := graph.GraphState{ + Nodes: map[graph.URN]graph.Node{ + "urn:moos:program:x": { + URN: "urn:moos:program:x", TypeID: "program", + Properties: map[string]graph.Property{ + "status": {Value: "active", Mutability: "mutable", AuthorityScope: "owner"}, + }, + }, + }, + Relations: map[graph.URN]graph.Relation{}, + } + env := graph.Envelope{ + RewriteType: graph.MUTATE, + Actor: "urn:moos:user:sam", + TargetURN: "urn:moos:program:x", + Field: "status", // authority_scope=owner + } + if reg.AdminScopeRewrite(env, state) { + t.Errorf("MUTATE of owner-authority field should NOT be admin-scope") + } +} + +func TestAdminScopeRewrite_MUTATEOnKernelNode_AdminScopeViaOntologyGovernedType(t *testing.T) { + // MUTATE on a kernel-typed node is admin-scope via case 1 (kernel is in + // ontology-governed types). Test documents this explicitly so reading + // the suite clarifies that kernel-nodes-are-always-admin-scope follows + // from the ontology-governed-type rule, not from the kernel-authority- + // field rule (which explicitly excludes kernel-typed targets). + reg := adminScopeRegistry() + state := graph.GraphState{ + Nodes: map[graph.URN]graph.Node{ + "urn:moos:kernel:hp-z440.primary": { + URN: "urn:moos:kernel:hp-z440.primary", TypeID: "kernel", + Properties: map[string]graph.Property{ + "status": {Value: "active", Mutability: "mutable", AuthorityScope: "kernel"}, + }, + }, + }, + } + env := graph.Envelope{ + RewriteType: graph.MUTATE, + Actor: "urn:moos:user:sam", + TargetURN: "urn:moos:kernel:hp-z440.primary", + Field: "status", + } + if !reg.AdminScopeRewrite(env, state) { + t.Errorf("MUTATE on kernel-typed node should be admin-scope via ontology-governed-type rule") + } +} + +// TestAdminScopeRewrite_MUTATETargetMissing_FailsClosed pins the Gemini +// security-HIGH fix: a MUTATE whose target is absent from the passed state +// is classified admin-scope (fail-closed). A lone missing-target MUTATE +// will fold-fail with ErrNodeNotFound regardless; the real purpose is to +// prevent an ADD-then-MUTATE batch from bypassing §M12 when the classifier +// is called against the batch-initial state. ApplyProgram threads +// workingState through the §M12 check so normal ADD+MUTATE batches DO see +// the just-created node and classify correctly — this test pins the +// behavior when the caller does NOT thread working-state. +func TestAdminScopeRewrite_MUTATETargetMissing_FailsClosed(t *testing.T) { + reg := adminScopeRegistry() + state := graph.GraphState{Nodes: map[graph.URN]graph.Node{}, Relations: map[graph.URN]graph.Relation{}} + env := graph.Envelope{ + RewriteType: graph.MUTATE, + Actor: "urn:moos:user:sam", + TargetURN: "urn:moos:program:not-in-state", + Field: "status", + } + if !reg.AdminScopeRewrite(env, state) { + t.Errorf("MUTATE on missing target must fail closed (admin-scope=true) to prevent ADD+MUTATE bypass") + } +} + +// TestAdminScopeRewrite_EmptyStoredAuthorityScope_FallsBackToTypeSpec pins +// the Gemini security-HIGH fix on authorityScopeForField: when a node's +// stored property has an empty AuthorityScope (e.g. an older ADD that +// omitted the metadata), the classifier falls back to the registry type +// spec rather than trusting the blank stored value. This prevents an ADD +// that skipped authority_scope from indefinitely bypassing §M12 on its +// kernel-authority properties. +func TestAdminScopeRewrite_EmptyStoredAuthorityScope_FallsBackToTypeSpec(t *testing.T) { + reg := adminScopeRegistry() + state := graph.GraphState{ + Nodes: map[graph.URN]graph.Node{ + "urn:moos:program:legacy": { + URN: "urn:moos:program:legacy", TypeID: "program", + Properties: map[string]graph.Property{ + // Property present but AuthorityScope blank — simulates + // an ADD that didn't populate the metadata correctly. + "target_t": {Value: 100.0, Mutability: "mutable", AuthorityScope: ""}, + }, + }, + }, + } + env := graph.Envelope{ + RewriteType: graph.MUTATE, + Actor: "urn:moos:user:sam", + TargetURN: "urn:moos:program:legacy", + Field: "target_t", // type spec declares authority_scope=kernel + } + if !reg.AdminScopeRewrite(env, state) { + t.Errorf("empty stored AuthorityScope should fall back to type spec (target_t is kernel-authority)") + } +} + +func TestAdminScopeRewrite_LINK_NotAdminScope(t *testing.T) { + // LINK / UNLINK are not admin-scope in §M12 v1. The doctrine gates on + // node-level operations; relation-level admin-gating would be a v2 + // extension with its own doctrine note. + reg := adminScopeRegistry() + state := graph.GraphState{Nodes: map[graph.URN]graph.Node{}, Relations: map[graph.URN]graph.Relation{}} + env := graph.Envelope{ + RewriteType: graph.LINK, + Actor: "urn:moos:user:sam", + RelationURN: "urn:moos:rel:test", + SrcURN: "urn:moos:session:sam.a", + TgtURN: "urn:moos:gate:approval", // ontology-governed tgt + RewriteCategory: graph.WF19, + } + if reg.AdminScopeRewrite(env, state) { + t.Errorf("LINK should not be admin-scope in §M12 v1") + } +} + +func TestAdminScopeRewrite_NilRegistry(t *testing.T) { + // Nil-receiver safety: matches the pattern used elsewhere (ValidateLINK, + // ValidateMUTATE, checkLiveness all short-circuit on nil registry). + var r *Registry + state := graph.GraphState{Nodes: map[graph.URN]graph.Node{}, Relations: map[graph.URN]graph.Relation{}} + env := graph.Envelope{RewriteType: graph.ADD, TypeID: "gate"} + if r.AdminScopeRewrite(env, state) { + t.Errorf("nil registry should return false") } }