From 19397f6a0919fceff2dc5b5432a5eec90ea80ce7 Mon Sep 17 00:00:00 2001 From: Sam Maassen <215926119+MSD21091969@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:26:11 +0200 Subject: [PATCH 1/2] =?UTF-8?q?kernel:=20govern=20the=20M6=20event=20pathw?= =?UTF-8?q?ay=20=E2=80=94=20t=5Fhook=20firings=20stage=20governance=5Fprop?= =?UTF-8?q?osals=20(closes=20#53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parity with the TIME sweep per Sam ruling t250-Q4: event-fired t_hooks now stage an ADD-governance_proposal + firing_state pending->proposed pair instead of auto-applying react_template ungoverned (fp-fruits finding v, F-a class). Watcher/reactor (WF17 Pass-1) keeps immediate apply - same class, out of ruling scope, flagged follow-up. - reactive: Firing{Proposal,SourceHook,Kind} + EvaluateDetailed; Evaluate stays as a provenance-erasing wrapper (call sites unchanged) - kernel: stageHookProposalPair extracted from SweepOnce (shared builder, parity by construction); runReactive routes by Kind - stageEventHookLocked: firing_state idempotency filter (sweep parity), substituted proposed_envelope normalized to JSON map (CI-4 Go-value replay parity), per-firing URN discriminator -evt-n, M8 gate pre-flight on the whole pair (fail-closed batch parity), PropertySpec injection + registry-less fallback - applyStagedLocked: staging apply that mirrors ApplyProgram's checks (no ValidateMUTATE - the sweep path never calls it; requiring a category would reject hooks carrying explicit firing_state and spam duplicates) - applyReactiveLocked returns bool (landed or not) - tests: THookFires rewritten as THookStagesProposal; new THookExplicitPendingStages, THookGateBlocksStaging, THookSameSlugNoCollision, THookEventIdempotency Adversarial review (12 agents, 3 lenses, refute-verify): 8 confirmed findings folded (4 root defects: ValidateMUTATE standard-path rejection -> duplicate-proposal spam; M8 gate bypass; same-slug URN collision; typed- struct vs replay-map divergence). Live throwaway gates: additive AND explicit-pending cases stage exactly (+3), template withheld, idempotent. Zero live react_templates fleet-wide (5 folds inventoried) -> no load-bearing fast path -> ruling default (govern), no allowlist machinery. authored-by: agent:claude-cowork.hp-z440 / session:sam.z440-cowork-workspace / t250-m6-event-governance Co-Authored-By: Claude Opus 4.8 --- internal/kernel/runtime.go | 170 ++++++++++++- internal/kernel/runtime_reactive_test.go | 307 ++++++++++++++++++++++- internal/kernel/sweep.go | 124 +++++---- internal/reactive/engine.go | 40 ++- 4 files changed, 566 insertions(+), 75 deletions(-) diff --git a/internal/kernel/runtime.go b/internal/kernel/runtime.go index f17575c..91ca431 100644 --- a/internal/kernel/runtime.go +++ b/internal/kernel/runtime.go @@ -3,6 +3,7 @@ package kernel import ( "crypto/sha1" "encoding/hex" + "encoding/json" "errors" "fmt" "log" @@ -17,6 +18,7 @@ import ( "moos/kernel/internal/hdc" "moos/kernel/internal/operad" "moos/kernel/internal/reactive" + "moos/kernel/internal/tday" ) const ( @@ -573,30 +575,175 @@ func (rt *Runtime) broadcast(pr graph.PersistedRewrite) { } // runReactive evaluates the Watch/React/Guard engine against a just-applied rewrite -// and applies any resulting proposals. Called with rt.mu already held. -// Proposals are applied at depth-1 only — reactive proposals do not trigger further reactions. +// and routes the resulting firings. Called with rt.mu already held. +// Proposals are processed at depth-1 only — reactive results do not trigger further reactions. +// +// Routing (issue #53, Sam ruling t250-Q4 "govern it — parity with TIME"): +// - t_hook firings (M6 event pathway) are STAGED as governance_proposals, +// exactly like the time-driven sweep — never applied immediately. +// - watcher/reactor firings (WF17 Pass 1) keep the immediate-apply path. +// Same F-a class as the pre-#53 t_hook fast path — flagged as follow-up, +// out of #53's ruling scope. func (rt *Runtime) runReactive(trigger graph.PersistedRewrite) { // Snapshot state at the time of the trigger for engine evaluation. snapshot := rt.state eng := reactive.Engine{State: &snapshot} - proposals := eng.Evaluate(trigger) + firings := eng.EvaluateDetailed(trigger) + + for i, f := range firings { + switch f.Kind { + case "t_hook": + rt.stageEventHookLocked(f, trigger, i) + default: // "reactor" — WF17 watcher/reactor chain, unchanged. + rt.applyReactiveLocked(f.Proposal) + } + } +} + +// stageEventHookLocked stages a governance_proposal for an event-fired t_hook +// (M6 pathway) instead of applying its react_template immediately — parity +// with the TIME sweep (issue #53). Called with rt.mu already held. +// +// The staged proposed_envelope is the SUBSTITUTED template (the exact +// envelope an approver would apply — $matched_urn/$actor already resolved), +// unlike the sweep's verbatim snapshot: the substitutions only exist on the +// event pathway. It is stored as a plain map (JSON round-trip of the built +// envelope), NOT as a typed graph.Envelope — replay deserializes property +// values to map[string]any, so storing the struct live would make CI-4 hold +// only up to JSON equivalence instead of Go-value equivalence. +// +// Idempotency parity with SweepOnce: only hooks with firing_state ∈ +// {"", "pending"} stage. Event hooks can match many rewrites; without this +// check every matching trigger would re-stage a duplicate proposal. +// +// Gate parity (M8): both staging envelopes are pre-flighted through +// checkGatesLocked BEFORE either is applied — a gate freezing the hook +// blocks the entire staging pair fail-closed, exactly like the sweep's +// all-or-nothing ApplyProgram batch. +// +// The two staging envelopes are applied sequentially under the held write +// lock (the sweep gets ApplyProgram atomicity; this path cannot re-enter +// ApplyProgram without deadlocking). applyStagedLocked mirrors the sweep's +// governed-path checks (operad structural validation + fold; NO +// ValidateMUTATE — ApplyProgram never calls it either, and requiring a +// rewrite_category here would reject staging for hooks that carry an +// explicit firing_state, silently defeating the idempotency filter). If the +// proposal ADD does not land, the MUTATE is skipped and the hook stays +// pending — the next matching trigger retries. +// +// firingIdx discriminates multiple hooks firing on the SAME trigger: two +// hooks on one owner whose URNs share a final slug segment would otherwise +// compute identical proposal URNs and the loser would be dropped with no +// retry clock (the sweep's per-tick -n counter is the same device). +func (rt *Runtime) stageEventHookLocked(f reactive.Firing, trigger graph.PersistedRewrite, firingIdx int) { + hook, ok := rt.state.Nodes[f.SourceHook] + if !ok { + return + } + if fs := firingStateOf(hook); fs != "" && fs != "pending" { + return + } + + currentT := tday.Now() + nowStr := time.Now().UTC().Format(time.RFC3339) + // URN scheme mirrors the sweep's (kernel.-t-tick-n): + // evt is unique per trigger, -n per firing within the trigger. + proposalURN := graph.URN(fmt.Sprintf("urn:moos:proposal:kernel.%s-t%d-evt%d-n%d", slugFromURN(hook.URN), currentT, trigger.LogSeq, firingIdx)) + + // Normalize the substituted envelope to its JSON map form so the live + // property value is Go-value-identical to what replay reconstructs. + var proposedEnvelope any + if raw, err := json.Marshal(f.Proposal); err == nil { + var m map[string]any + if err := json.Unmarshal(raw, &m); err == nil { + proposedEnvelope = m + } + } + if proposedEnvelope == nil { + return // cannot represent the proposal — stage nothing, hook stays pending + } + + pair := stageHookProposalPair(hook, currentT, rt.sweepActorLocked(), proposalURN, nowStr, proposedEnvelope) - for _, proposal := range proposals { - rt.applyReactiveLocked(proposal) + // The firing_state MUTATE is additive on first firing (field absent on + // the hook) — fold requires an injected PropertySpec for that. Registry + // mode injects the declared t_hook.firing_state spec (same as the + // sweep's ApplyProgram path); registry-less mode falls back to the + // v3.11-declared shape (mutable, kernel scope) so staging works in + // dev/test kernels too. When firing_state is already on the hook the + // fold takes the standard MUTATE path and needs no spec. + pair[1] = rt.injectPropertySpec(pair[1], hook) + if pair[1].PropertySpec == nil { + if _, hasProp := hook.Properties["firing_state"]; !hasProp { + pair[1].PropertySpec = &graph.Property{Mutability: "mutable", AuthorityScope: "kernel", StratumOrigin: 2} + } + } + + // M8 gate pre-flight on BOTH envelopes before applying either — + // fail-closed parity with the sweep's all-or-nothing batch. + if err := rt.checkGatesLocked(pair[0]); err != nil { + return + } + if err := rt.checkGatesLocked(pair[1]); err != nil { + return } + + if !rt.applyStagedLocked(pair[0]) { + return // proposal ADD did not land — hook stays pending, next trigger retries + } + rt.applyStagedLocked(pair[1]) +} + +// applyStagedLocked applies a kernel-internal STAGING envelope while the +// write lock is already held. Mirrors the sweep's governed-path checks for +// its staging pair: operad structural validation + fold + persist. It does +// NOT call ValidateMUTATE — neither does ApplyProgram (the sweep's path); +// the staging MUTATE (firing_state) deliberately carries no +// rewrite_category (see stageHookProposalPair), and ValidateMUTATE's +// standard path would reject it whenever the hook already carries +// firing_state, silently defeating the idempotency filter. Gate checks are +// the caller's responsibility (pre-flighted on the whole pair). +func (rt *Runtime) applyStagedLocked(env graph.Envelope) bool { + if err := rt.validate(env); err != nil { + return false + } + + now := time.Now().UTC() + next, _, err := fold.EvaluateAt(rt.state, env, now) + if err != nil { + return false + } + + seq := rt.logSeq.Add(1) + persisted := graph.PersistedRewrite{ + Envelope: env, + AppliedAt: now, + Timestamp: now, + LogSeq: seq, + } + if err := rt.store.Append([]graph.PersistedRewrite{persisted}); err != nil { + return false // persist failure: drop, don't corrupt in-memory state + } + + rt.state = next + rt.log = append(rt.log, persisted) + rt.broadcast(persisted) + return true } // applyReactiveLocked applies a single envelope while the write lock is already held. -// Used exclusively for reactive proposals — does NOT trigger further reactive evaluation. -func (rt *Runtime) applyReactiveLocked(env graph.Envelope) { +// Used exclusively for kernel-internal reactive/staging emissions — does NOT trigger +// further reactive evaluation. Returns true iff the envelope landed (validated, +// folded, persisted). +func (rt *Runtime) applyReactiveLocked(env graph.Envelope) bool { // Operad validation (registry is read-only, no extra locking needed). if err := rt.validate(env); err != nil { - return // drop invalid reactive proposals silently + return false // drop invalid reactive proposals silently } if env.RewriteType == graph.MUTATE && rt.registry != nil { if node, ok := rt.state.Nodes[env.TargetURN]; ok { if err := rt.registry.ValidateMUTATE(env, node); err != nil { - return + return false } } } @@ -604,7 +751,7 @@ func (rt *Runtime) applyReactiveLocked(env graph.Envelope) { now := time.Now().UTC() next, _, err := fold.EvaluateAt(rt.state, env, now) if err != nil { - return // skip (e.g. ErrNodeExists for idempotent proposals) + return false // skip (e.g. ErrNodeExists for idempotent proposals) } seq := rt.logSeq.Add(1) @@ -615,12 +762,13 @@ func (rt *Runtime) applyReactiveLocked(env graph.Envelope) { LogSeq: seq, } if err := rt.store.Append([]graph.PersistedRewrite{persisted}); err != nil { - return // persist failure: drop, don't corrupt in-memory state + return false // persist failure: drop, don't corrupt in-memory state } rt.state = next rt.log = append(rt.log, persisted) rt.broadcast(persisted) + return true } // checkGatesLocked evaluates all gate nodes linked to the rewrite's affected diff --git a/internal/kernel/runtime_reactive_test.go b/internal/kernel/runtime_reactive_test.go index 4f960cd..3c06ac1 100644 --- a/internal/kernel/runtime_reactive_test.go +++ b/internal/kernel/runtime_reactive_test.go @@ -1,6 +1,7 @@ package kernel import ( + "encoding/json" "strings" "testing" "time" @@ -120,9 +121,11 @@ func TestRuntime_ReactiveChain(t *testing.T) { } } -// TestRuntime_THookFires verifies that a t_hook node owned by an affected node -// fires its react_template when a matching MUTATE is applied (M6). -func TestRuntime_THookFires(t *testing.T) { +// newTHookTestRuntime seeds a runtime with a knowledge_item and an active +// t_hook on it whose react_template would MUTATE the owner's status to +// "hook-fired" on any MUTATE. Shared by the M6 staging tests. +func newTHookTestRuntime(t *testing.T) *Runtime { + t.Helper() rt := &Runtime{ state: graph.NewGraphState(), store: NewMemStore(), @@ -144,7 +147,7 @@ func TestRuntime_THookFires(t *testing.T) { "title": {Value: "Hook Target", Mutability: "immutable"}, }, }, - // T-hook: fires on any MUTATE of the owner node. + // T-hook: matches any MUTATE of the owner node. { RewriteType: graph.ADD, Actor: "urn:moos:kernel", @@ -176,10 +179,17 @@ func TestRuntime_THookFires(t *testing.T) { t.Fatalf("seed: %v", err) } } + return rt +} +// TestRuntime_THookStagesProposal verifies the governed M6 event pathway +// (issue #53, parity with the TIME sweep): a matching MUTATE makes the +// t_hook STAGE a governance_proposal — it does NOT apply the react_template. +func TestRuntime_THookStagesProposal(t *testing.T) { + rt := newTHookTestRuntime(t) logBefore := rt.LogLen() - // Trigger: MUTATE the owner node — t_hook should fire and set status="hook-fired". + // Trigger: MUTATE the owner node. _, err := rt.Apply(graph.Envelope{ RewriteType: graph.MUTATE, Actor: "urn:moos:user:sam", @@ -191,18 +201,293 @@ func TestRuntime_THookFires(t *testing.T) { t.Fatalf("Apply: %v", err) } - // Log should grow by 2: the trigger MUTATE + the t_hook's reactive MUTATE. - if got := rt.LogLen(); got != logBefore+2 { - t.Errorf("expected log +2 (trigger + t_hook proposal), got %d → %d", logBefore, got) + // Log grows by 3: trigger MUTATE + proposal ADD + firing_state MUTATE. + if got := rt.LogLen(); got != logBefore+3 { + t.Errorf("expected log +3 (trigger + proposal ADD + firing_state MUTATE), got %d → %d", logBefore, got) } + // The react_template must NOT have applied: status stays what the + // trigger set, never "hook-fired". ki, ok := rt.Node("urn:moos:ki:hook-target") if !ok { t.Fatal("KI not found after Apply") } - status, _ := ki.Properties["status"].Value.(string) - if status != "hook-fired" { - t.Errorf("expected status=hook-fired after t_hook fires, got %q", status) + if status, _ := ki.Properties["status"].Value.(string); status != "intermediate" { + t.Errorf("expected status=intermediate (template staged, not applied), got %q", status) + } + + // Exactly one governance_proposal exists, carrying the SUBSTITUTED + // template and the source hook URN. + state := rt.State() + proposals := state.NodesOfType("governance_proposal") + if len(proposals) != 1 { + t.Fatalf("expected exactly 1 governance_proposal, got %d", len(proposals)) + } + prop := state.Nodes[proposals[0]] + if src, _ := prop.Properties["source_t_hook_urn"].Value.(string); src != "urn:moos:t_hook:hook-target.on-mutate" { + t.Errorf("source_t_hook_urn = %q, want the firing hook", src) + } + pe, ok := prop.Properties["proposed_envelope"] + if !ok { + t.Fatal("proposal missing proposed_envelope") + } + // CI-4 Go-value parity: the live value must be the JSON map form (what + // replay reconstructs), never a typed graph.Envelope struct. + if _, isMap := pe.Value.(map[string]any); !isMap { + t.Errorf("proposed_envelope live value is %T, want map[string]any (replay parity)", pe.Value) + } + // The event pathway stages the substituted envelope — $matched_urn and + // $actor must be resolved to the trigger's values. + raw, err := json.Marshal(pe.Value) + if err != nil { + t.Fatalf("marshal proposed_envelope: %v", err) + } + var staged graph.Envelope + if err := json.Unmarshal(raw, &staged); err != nil { + t.Fatalf("unmarshal proposed_envelope: %v", err) + } + if staged.TargetURN != "urn:moos:ki:hook-target" { + t.Errorf("staged target_urn = %q, want substituted urn:moos:ki:hook-target", staged.TargetURN) + } + if staged.Actor != "urn:moos:user:sam" { + t.Errorf("staged actor = %q, want substituted urn:moos:user:sam", staged.Actor) + } + if staged.NewValue != "hook-fired" { + t.Errorf("staged new_value = %v, want hook-fired", staged.NewValue) + } + + // The hook parked at firing_state=proposed. + hook, ok := rt.Node("urn:moos:t_hook:hook-target.on-mutate") + if !ok { + t.Fatal("t_hook not found after Apply") + } + if fs := firingStateOf(hook); fs != "proposed" { + t.Errorf("hook firing_state = %q, want proposed", fs) + } +} + +// TestRuntime_THookExplicitPendingStages verifies staging works when the +// hook was ADDed with an EXPLICIT firing_state="pending" property — the +// standard (non-additive) fold MUTATE path. Adversarial-review finding: +// routing the staging MUTATE through ValidateMUTATE rejected this case +// (empty rewrite_category) and produced one duplicate proposal per trigger. +func TestRuntime_THookExplicitPendingStages(t *testing.T) { + rt := newTHookTestRuntime(t) + + // Second hook with firing_state explicitly authored. + if err := rt.SeedIfAbsent(graph.Envelope{ + RewriteType: graph.ADD, + Actor: "urn:moos:kernel", + NodeURN: "urn:moos:t_hook:hook-target.explicit-pending", + TypeID: "t_hook", + Properties: map[string]graph.Property{ + "owner_urn": {Value: "urn:moos:ki:hook-target", Mutability: "immutable"}, + "status": {Value: "active", Mutability: "mutable"}, + "created_at": {Value: time.Now().UTC().Format(time.RFC3339), Mutability: "immutable"}, + "firing_state": {Value: "pending", Mutability: "mutable", AuthorityScope: "kernel"}, + "event_shape": { + Value: map[string]any{"rewrite_type": "MUTATE"}, + Mutability: "mutable", + }, + "react_template": { + Value: map[string]any{ + "rewrite_type": "MUTATE", + "actor": "$actor", + "target_urn": "$matched_urn", + "field": "status", + "new_value": "hook-fired", + }, + Mutability: "mutable", + }, + }, + }); err != nil { + t.Fatalf("seed explicit-pending hook: %v", err) + } + + if _, err := rt.Apply(graph.Envelope{ + RewriteType: graph.MUTATE, + Actor: "urn:moos:user:sam", + TargetURN: "urn:moos:ki:hook-target", + Field: "status", + NewValue: "intermediate", + }); err != nil { + t.Fatalf("Apply: %v", err) + } + + // BOTH hooks (implicit + explicit pending) stage: log = trigger + 2×(ADD+MUTATE). + hook, ok := rt.Node("urn:moos:t_hook:hook-target.explicit-pending") + if !ok { + t.Fatal("explicit-pending hook not found") + } + if fs := firingStateOf(hook); fs != "proposed" { + t.Errorf("explicit-pending hook firing_state = %q, want proposed (standard-path MUTATE must land)", fs) + } + state := rt.State() + if n := len(state.NodesOfType("governance_proposal")); n != 2 { + t.Errorf("expected 2 governance_proposals (one per hook), got %d", n) + } +} + +// TestRuntime_THookGateBlocksStaging verifies M8 fail-closed parity with the +// sweep: a gate node with a failing predicate guarding the t_hook blocks the +// ENTIRE staging pair — no proposal ADD, no firing_state transition. +func TestRuntime_THookGateBlocksStaging(t *testing.T) { + rt := newTHookTestRuntime(t) + + // Gate with a failing predicate (node_property on a value that doesn't match), + // LINKed gate —guards→ t_hook. + seeds := []graph.Envelope{ + { + RewriteType: graph.ADD, + Actor: "urn:moos:kernel", + NodeURN: "urn:moos:gate:freeze-hook", + TypeID: "gate", + Properties: map[string]graph.Property{ + "predicate_type": {Value: "node_property", Mutability: "immutable"}, + "target_urn": {Value: "urn:moos:ki:hook-target", Mutability: "immutable"}, + "field": {Value: "status", Mutability: "immutable"}, + "expected_value": {Value: "never-this-value", Mutability: "immutable"}, + }, + }, + { + RewriteType: graph.LINK, + Actor: "urn:moos:kernel", + RelationURN: "urn:moos:rel:gate.freeze-hook.guards", + SrcURN: "urn:moos:gate:freeze-hook", + SrcPort: "guards", + TgtURN: "urn:moos:t_hook:hook-target.on-mutate", + TgtPort: "guarded-by", + }, + } + for _, env := range seeds { + if err := rt.SeedIfAbsent(env); err != nil { + t.Fatalf("seed gate: %v", err) + } + } + + logBefore := rt.LogLen() + if _, err := rt.Apply(graph.Envelope{ + RewriteType: graph.MUTATE, + Actor: "urn:moos:user:sam", + TargetURN: "urn:moos:ki:hook-target", + Field: "status", + NewValue: "intermediate", + }); err != nil { + t.Fatalf("Apply: %v", err) + } + + // Only the trigger lands: the frozen hook stages NOTHING. + if got := rt.LogLen(); got != logBefore+1 { + t.Errorf("expected log +1 (gate blocks staging pair fail-closed), got %d → %d", logBefore, got) + } + state := rt.State() + if n := len(state.NodesOfType("governance_proposal")); n != 0 { + t.Errorf("expected 0 governance_proposals (gate frozen), got %d", n) + } + hook, _ := rt.Node("urn:moos:t_hook:hook-target.on-mutate") + if fs := firingStateOf(hook); fs != "" { + t.Errorf("hook firing_state = %q, want unset (staging blocked)", fs) + } +} + +// TestRuntime_THookSameSlugNoCollision verifies the per-firing URN +// discriminator: two hooks on one owner whose URNs share the final slug +// segment both stage on a single trigger (adversarial-review finding: the +// URN collided and the loser was silently dropped with no retry clock). +func TestRuntime_THookSameSlugNoCollision(t *testing.T) { + rt := newTHookTestRuntime(t) + + // The fixture hook is urn:moos:t_hook:hook-target.on-mutate — add a + // second whose final colon-delimited segment is IDENTICAL. + if err := rt.SeedIfAbsent(graph.Envelope{ + RewriteType: graph.ADD, + Actor: "urn:moos:kernel", + NodeURN: "urn:moos:t_hook:other:hook-target.on-mutate", + TypeID: "t_hook", + Properties: map[string]graph.Property{ + "owner_urn": {Value: "urn:moos:ki:hook-target", Mutability: "immutable"}, + "status": {Value: "active", Mutability: "mutable"}, + "created_at": {Value: time.Now().UTC().Format(time.RFC3339), Mutability: "immutable"}, + "event_shape": { + Value: map[string]any{"rewrite_type": "MUTATE"}, + Mutability: "mutable", + }, + "react_template": { + Value: map[string]any{ + "rewrite_type": "MUTATE", + "actor": "$actor", + "target_urn": "$matched_urn", + "field": "status", + "new_value": "hook-fired-2", + }, + Mutability: "mutable", + }, + }, + }); err != nil { + t.Fatalf("seed same-slug hook: %v", err) + } + + if _, err := rt.Apply(graph.Envelope{ + RewriteType: graph.MUTATE, + Actor: "urn:moos:user:sam", + TargetURN: "urn:moos:ki:hook-target", + Field: "status", + NewValue: "intermediate", + }); err != nil { + t.Fatalf("Apply: %v", err) + } + + state := rt.State() + if n := len(state.NodesOfType("governance_proposal")); n != 2 { + t.Fatalf("expected 2 governance_proposals (no URN collision), got %d", n) + } + for _, hookURN := range []string{"urn:moos:t_hook:hook-target.on-mutate", "urn:moos:t_hook:other:hook-target.on-mutate"} { + hook, ok := rt.Node(graph.URN(hookURN)) + if !ok { + t.Fatalf("hook %s not found", hookURN) + } + if fs := firingStateOf(hook); fs != "proposed" { + t.Errorf("hook %s firing_state = %q, want proposed", hookURN, fs) + } + } +} + +// TestRuntime_THookEventIdempotency verifies the firing_state filter on the +// event pathway (parity with SweepOnce): once a hook is proposed, further +// matching triggers do NOT stage duplicate proposals. +func TestRuntime_THookEventIdempotency(t *testing.T) { + rt := newTHookTestRuntime(t) + + // First trigger: stages (log +3), parks the hook at proposed. + if _, err := rt.Apply(graph.Envelope{ + RewriteType: graph.MUTATE, + Actor: "urn:moos:user:sam", + TargetURN: "urn:moos:ki:hook-target", + Field: "status", + NewValue: "intermediate", + }); err != nil { + t.Fatalf("Apply 1: %v", err) + } + + logBefore := rt.LogLen() + + // Second matching trigger: hook is proposed — only the trigger lands. + if _, err := rt.Apply(graph.Envelope{ + RewriteType: graph.MUTATE, + Actor: "urn:moos:user:sam", + TargetURN: "urn:moos:ki:hook-target", + Field: "status", + NewValue: "final", + }); err != nil { + t.Fatalf("Apply 2: %v", err) + } + + if got := rt.LogLen(); got != logBefore+1 { + t.Errorf("expected log +1 (trigger only, no re-staging), got %d → %d", logBefore, got) + } + state := rt.State() + if n := len(state.NodesOfType("governance_proposal")); n != 1 { + t.Errorf("expected 1 governance_proposal after second trigger, got %d", n) } } diff --git a/internal/kernel/sweep.go b/internal/kernel/sweep.go index d5b7030..b595c56 100644 --- a/internal/kernel/sweep.go +++ b/internal/kernel/sweep.go @@ -137,63 +137,89 @@ func SweepOnce(state graph.GraphState, currentT int, actor graph.URN, baseLogSeq emitted++ proposalURN := graph.URN(fmt.Sprintf("urn:moos:proposal:kernel.%s-t%d-tick%d-n%d", hookSlug, currentT, baseLogSeq, emitted)) - title := fmt.Sprintf("Fire t_hook %s at T=%d", n.URN, currentT) - - props := map[string]graph.Property{ - "title": {Value: title, Mutability: "immutable"}, - "status": {Value: "pending", Mutability: "mutable", AuthorityScope: "principal"}, - "created_at": {Value: nowStr, Mutability: "immutable"}, - "source_t_hook_urn": {Value: string(n.URN), Mutability: "immutable"}, - "fires_at_t": {Value: currentT, Mutability: "immutable"}, - } + // The sweep stages the hook's react_template VERBATIM — time-fired + // hooks carry no event substitutions ($matched_urn etc. only exist + // on the event pathway, which passes the substituted envelope). + var proposedEnvelope any if p, ok := n.Properties["react_template"]; ok && p.Value != nil { - props["proposed_envelope"] = graph.Property{Value: p.Value, Mutability: "immutable"} - } - if p, ok := n.Properties["owner_urn"]; ok && p.Value != nil { - props["owner_urn"] = graph.Property{Value: p.Value, Mutability: "immutable"} + proposedEnvelope = p.Value } - - // RewriteCategory is intentionally omitted on both envelopes: - // - // - The ADD creates a governance_proposal NODE. Node creation - // is typed by type_id; no WF category applies. (PR #25 - // review — Gemini suggested WF13; WF13's allowed_rewrites - // are LINK/UNLINK/MUTATE, not ADD, and WF13 models a - // governance_proposal PROMOTING to a target, not the - // proposal's creation. We don't carry a WF here.) - // - // - The MUTATE on firing_state is ALWAYS additive on first - // firing (field absent on the hook → pending → proposed). - // Additive MUTATE validation doesn't require a - // rewrite_category per operad.ValidateMUTATE. For future - // non-additive transitions (approver reactor: proposed → - // applied; reopens_at: applied → pending) a WF category - // will be needed. TODO(WF): either extend WF17 Reactive - // mutate_scope to include firing_state, or introduce a new - // WF21 "Reactive firing lifecycle". Pick when the approver - // reactor PR lands. - envelopes = append(envelopes, - graph.Envelope{ - RewriteType: graph.ADD, - Actor: actor, - NodeURN: proposalURN, - TypeID: "governance_proposal", - Properties: props, - }, - graph.Envelope{ - RewriteType: graph.MUTATE, - Actor: actor, - TargetURN: n.URN, - Field: "firing_state", - NewValue: "proposed", - }, - ) + pair := stageHookProposalPair(n, currentT, actor, proposalURN, nowStr, proposedEnvelope) + envelopes = append(envelopes, pair[0], pair[1]) emitted++ } return envelopes } +// stageHookProposalPair builds the atomic two-envelope staging shape for a +// firing t_hook: +// +// 1. ADD a governance_proposal node (title, status=pending, +// source_t_hook_urn, fires_at_t, proposed_envelope, owner_urn) +// 2. MUTATE the source hook's firing_state → "proposed" +// +// Shared by the TIME sweep (SweepOnce) and the M6 event pathway +// (Runtime.stageEventHookLocked) — governance parity by construction +// (issue #53, Sam ruling t250-Q4). proposedEnvelope is what the approver +// would apply: the sweep passes the hook's raw react_template; the event +// pathway passes the SUBSTITUTED envelope built by the reactive engine, so +// the approver sees exactly what will land. +// +// RewriteCategory is intentionally omitted on both envelopes: +// +// - The ADD creates a governance_proposal NODE. Node creation +// is typed by type_id; no WF category applies. (PR #25 +// review — Gemini suggested WF13; WF13's allowed_rewrites +// are LINK/UNLINK/MUTATE, not ADD, and WF13 models a +// governance_proposal PROMOTING to a target, not the +// proposal's creation. We don't carry a WF here.) +// +// - The MUTATE on firing_state is ALWAYS additive on first +// firing (field absent on the hook → pending → proposed). +// Additive MUTATE validation doesn't require a +// rewrite_category per operad.ValidateMUTATE. For future +// non-additive transitions (approver reactor: proposed → +// applied; reopens_at: applied → pending) a WF category +// will be needed. TODO(WF): either extend WF17 Reactive +// mutate_scope to include firing_state, or introduce a new +// WF "Reactive firing lifecycle". Pick when the approver +// reactor PR lands. +func stageHookProposalPair(hook graph.Node, currentT int, actor graph.URN, proposalURN graph.URN, nowStr string, proposedEnvelope any) [2]graph.Envelope { + title := fmt.Sprintf("Fire t_hook %s at T=%d", hook.URN, currentT) + + props := map[string]graph.Property{ + "title": {Value: title, Mutability: "immutable"}, + "status": {Value: "pending", Mutability: "mutable", AuthorityScope: "principal"}, + "created_at": {Value: nowStr, Mutability: "immutable"}, + "source_t_hook_urn": {Value: string(hook.URN), Mutability: "immutable"}, + "fires_at_t": {Value: currentT, Mutability: "immutable"}, + } + if proposedEnvelope != nil { + props["proposed_envelope"] = graph.Property{Value: proposedEnvelope, Mutability: "immutable"} + } + if p, ok := hook.Properties["owner_urn"]; ok && p.Value != nil { + props["owner_urn"] = graph.Property{Value: p.Value, Mutability: "immutable"} + } + + return [2]graph.Envelope{ + { + RewriteType: graph.ADD, + Actor: actor, + NodeURN: proposalURN, + TypeID: "governance_proposal", + Properties: props, + }, + { + RewriteType: graph.MUTATE, + Actor: actor, + TargetURN: hook.URN, + Field: "firing_state", + NewValue: "proposed", + }, + } +} + // firingStateOf reads the t_hook's firing_state property. Returns "" when // the property is absent — callers should treat empty-string as pending // (the v3.11 default) to keep pre-v3.11 hooks working. diff --git a/internal/reactive/engine.go b/internal/reactive/engine.go index 2fb9b6d..d01734f 100644 --- a/internal/reactive/engine.go +++ b/internal/reactive/engine.go @@ -19,11 +19,41 @@ type Engine struct { State *graph.GraphState } +// Firing is a proposed rewrite with provenance: which pass produced it and, +// for t_hook firings, which hook node. The kernel needs the distinction — +// t_hook (M6 event pathway) firings are STAGED as governance_proposals +// (issue #53, parity with the TIME sweep), while watcher/reactor firings +// keep the WF17 immediate-apply path. +type Firing struct { + Proposal graph.Envelope + SourceHook graph.URN // set when Kind == "t_hook" + Kind string // "reactor" | "t_hook" +} + // Evaluate takes a just-applied rewrite and returns proposed rewrites from: // 1. Watcher→Reactor chains (existing WF17 pattern) whose guards all pass. // 2. T-hook nodes (M6) owned by the affected node whose event_shape + guard_ref pass. +// +// Thin provenance-erasing wrapper over EvaluateDetailed, kept for callers +// that only need the envelopes. func (e *Engine) Evaluate(rewrite graph.PersistedRewrite) []graph.Envelope { - var proposals []graph.Envelope + firings := e.EvaluateDetailed(rewrite) + if len(firings) == 0 { + return nil + } + proposals := make([]graph.Envelope, 0, len(firings)) + for _, f := range firings { + proposals = append(proposals, f.Proposal) + } + return proposals +} + +// EvaluateDetailed is Evaluate with provenance: each proposal carries which +// pass produced it ("reactor" | "t_hook") and the source hook URN for +// t_hook firings, so the kernel can route them to different apply/staging +// pathways. +func (e *Engine) EvaluateDetailed(rewrite graph.PersistedRewrite) []Firing { + var firings []Firing env := rewrite.Envelope @@ -42,7 +72,9 @@ func (e *Engine) Evaluate(rewrite graph.PersistedRewrite) []graph.Envelope { continue } proposed := e.react(node.URN, affectedURN, affectedTypeID, env.Actor) - proposals = append(proposals, proposed...) + for _, p := range proposed { + firings = append(firings, Firing{Proposal: p, Kind: "reactor"}) + } } // Pass 2: T-hooks owned by the affected node (M6 first-class hooks). @@ -69,11 +101,11 @@ func (e *Engine) Evaluate(rewrite graph.PersistedRewrite) []graph.Envelope { if err != nil { continue } - proposals = append(proposals, proposal) + firings = append(firings, Firing{Proposal: proposal, SourceHook: thook.URN, Kind: "t_hook"}) } } - return proposals + return firings } // affected extracts the URN and type_id of the node affected by a rewrite. From 93891385a7f0e3c8e0360d5bae3ce0438e9f49ea Mon Sep 17 00:00:00 2001 From: Sam Maassen <215926119+MSD21091969@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:34:46 +0200 Subject: [PATCH 2/2] =?UTF-8?q?kernel:=20fold=20Copilot=20PR=20#56=20catch?= =?UTF-8?q?es=20=E2=80=94=20typed=20FiringKind=20constants=20+=20fail-clos?= =?UTF-8?q?ed=20routing=20on=20unknown=20kinds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit authored-by: agent:claude-cowork.hp-z440 / session:sam.z440-cowork-workspace / t250-m6-event-governance Co-Authored-By: Claude Opus 4.8 --- internal/kernel/runtime.go | 9 +++++++-- internal/reactive/engine.go | 20 ++++++++++++++++---- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/internal/kernel/runtime.go b/internal/kernel/runtime.go index 91ca431..696473f 100644 --- a/internal/kernel/runtime.go +++ b/internal/kernel/runtime.go @@ -592,10 +592,15 @@ func (rt *Runtime) runReactive(trigger graph.PersistedRewrite) { for i, f := range firings { switch f.Kind { - case "t_hook": + case reactive.FiringTHook: rt.stageEventHookLocked(f, trigger, i) - default: // "reactor" — WF17 watcher/reactor chain, unchanged. + case reactive.FiringReactor: // WF17 watcher/reactor chain, unchanged. rt.applyReactiveLocked(f.Proposal) + default: + // Fail-closed: an unknown firing kind must never silently take + // the immediate-apply path (Copilot catch, PR #56). Drop it — + // a new kind gets an explicit routing decision here first. + log.Printf("kernel: runReactive: dropping firing with unknown kind %q (source %s)", f.Kind, f.SourceHook) } } } diff --git a/internal/reactive/engine.go b/internal/reactive/engine.go index d01734f..563ec43 100644 --- a/internal/reactive/engine.go +++ b/internal/reactive/engine.go @@ -19,6 +19,18 @@ type Engine struct { State *graph.GraphState } +// FiringKind identifies which evaluation pass produced a Firing. Typed (not +// free-form strings) so adding a new kind is a compile-time change at every +// routing switch rather than a silent runtime bypass (Copilot catch, PR #56). +type FiringKind string + +const ( + // FiringReactor — Pass 1, watcher→reactor chain (WF17). + FiringReactor FiringKind = "reactor" + // FiringTHook — Pass 2, t_hook owned by the affected node (M6). + FiringTHook FiringKind = "t_hook" +) + // Firing is a proposed rewrite with provenance: which pass produced it and, // for t_hook firings, which hook node. The kernel needs the distinction — // t_hook (M6 event pathway) firings are STAGED as governance_proposals @@ -26,8 +38,8 @@ type Engine struct { // keep the WF17 immediate-apply path. type Firing struct { Proposal graph.Envelope - SourceHook graph.URN // set when Kind == "t_hook" - Kind string // "reactor" | "t_hook" + SourceHook graph.URN // set when Kind == FiringTHook + Kind FiringKind } // Evaluate takes a just-applied rewrite and returns proposed rewrites from: @@ -73,7 +85,7 @@ func (e *Engine) EvaluateDetailed(rewrite graph.PersistedRewrite) []Firing { } proposed := e.react(node.URN, affectedURN, affectedTypeID, env.Actor) for _, p := range proposed { - firings = append(firings, Firing{Proposal: p, Kind: "reactor"}) + firings = append(firings, Firing{Proposal: p, Kind: FiringReactor}) } } @@ -101,7 +113,7 @@ func (e *Engine) EvaluateDetailed(rewrite graph.PersistedRewrite) []Firing { if err != nil { continue } - firings = append(firings, Firing{Proposal: proposal, SourceHook: thook.URN, Kind: "t_hook"}) + firings = append(firings, Firing{Proposal: proposal, SourceHook: thook.URN, Kind: FiringTHook}) } }