Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions internal/kernel/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,11 @@ func (rt *Runtime) applyWithOptions(env graph.Envelope, opts applyOptions) (grap
if err := rt.registry.ValidateStrataLink(env, rt.state); err != nil {
return graph.EvalResult{}, err
}
// WF21 (round-15 v314-3-wf21-causes) acyclicity check. Other LINK
// rewrite-categories pass through; only WF21 enforces DAG semantics.
if err := rt.registry.ValidateCausalAcyclic(env, rt.state); err != nil {
return graph.EvalResult{}, err
}
}

// Gate check (M8): fail-closed if any gate node guards the affected node and its predicate fails.
Expand Down Expand Up @@ -250,6 +255,12 @@ func (rt *Runtime) ApplyProgram(envelopes []graph.Envelope) ([]graph.EvalResult,
if err := rt.registry.ValidateStrataLink(env, workingState); err != nil {
return nil, err
}
// WF21 acyclicity (round-15) checked against working state so
// intra-batch causes-LINKs can't close a cycle even when
// individual envelopes pass against pre-batch state.
if err := rt.registry.ValidateCausalAcyclic(env, workingState); err != nil {
return nil, err
}
}
if err := rt.checkGatesAgainstState(env, &workingState); err != nil {
return nil, err
Expand Down
68 changes: 68 additions & 0 deletions internal/operad/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -375,3 +375,71 @@ func containsTypeID(list []graph.TypeID, id graph.TypeID) bool {
}
return false
}

// ValidateCausalAcyclic enforces WF21 (causes/caused-by) acyclicity for LINK
// rewrites. Called with the kernel read-lock held (state is consistent), in
// the same apply path as ValidateStrataLink.
Comment on lines +380 to +381

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc comment says this is called with the kernel read-lock held, but the current apply paths call registry LINK validations under rt.mu.Lock() (write-lock). To avoid misleading future callers, consider rewording to something like “called with the kernel lock held / state is consistent” rather than specifying a read-lock.

Suggested change
// rewrites. Called with the kernel read-lock held (state is consistent), in
// the same apply path as ValidateStrataLink.
// rewrites. Called with the kernel lock held (state is consistent), in the
// same apply path as ValidateStrataLink.

Copilot uses AI. Check for mistakes.
//
// When a new LINK src --causes--> tgt is proposed, walk forward through
// existing causes-edges starting from tgt. If we reach src, the new LINK
// would close a cycle in the causes/caused-by DAG and is rejected.
//
// Rules:
// 1. Non-WF21 LINKs pass through (no acyclicity constraint applies).
// 2. Non-LINK rewrites (UNLINK, ADD, MUTATE) pass through.
// 3. Self-LINK (src == tgt) is a 1-cycle and is rejected immediately.
// 4. BFS forward from tgt through outgoing edges where RewriteCategory=WF21
// and SrcPort="causes" (i.e., true causes-direction edges, not the
// reverse caused-by side of the same relation). If env.SrcURN is
// reached, reject.
//
// Round-15 ceremony (T=176): introduced as v314-3-wf21-causes promotion
// companion. Pairs with v314-2-clock-type and v314-4-substrate-property.
// Doctrine anchor: kb/research/spec/07-time-fabric.md §7.3.5 +
// kb/research/spec/05-external-substrates.md §5.1.5/§5.3.5.
func (r *Registry) ValidateCausalAcyclic(env graph.Envelope, state graph.GraphState) error {
if env.RewriteType != graph.LINK {
return nil
}
if env.RewriteCategory != graph.RewriteCategory("WF21") {
return nil
}
Comment on lines +404 to +406

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This introduces a raw rewrite-category literal graph.RewriteCategory("WF21"). Elsewhere the codebase consistently uses typed constants (e.g., graph.WF15/WF19) to avoid typos and keep refactors centralized. Consider adding graph.WF21 in internal/graph/relation.go and using that constant here (and in tests).

Copilot uses AI. Check for mistakes.
if env.SrcURN == env.TgtURN {
return fmt.Errorf("WF21 acyclic: self-LINK %s --causes--> %s would create a 1-cycle",
env.SrcURN, env.TgtURN)
}

// BFS forward from tgt along outgoing WF21 causes-edges. If we reach src,
// the new edge src --causes--> tgt would close a cycle.
visited := map[graph.URN]bool{env.TgtURN: true}
queue := []graph.URN{env.TgtURN}
for len(queue) > 0 {
cur := queue[0]
queue = queue[1:]
Comment on lines +416 to +418

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The BFS queue uses queue = queue[1:], which repeatedly reslices and can retain the underlying array, increasing allocations/GC for large traversals. Consider an index-based queue (head integer) or a simple ring buffer pattern to keep this validation cheaper on large graphs.

Suggested change
for len(queue) > 0 {
cur := queue[0]
queue = queue[1:]
head := 0
for head < len(queue) {
cur := queue[head]
head++

Copilot uses AI. Check for mistakes.
for relURN := range state.RelationsBySrc[cur] {
rel, ok := state.Relations[relURN]
if !ok {
continue
}
Comment on lines +419 to +423

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ValidateCausalAcyclic iterates via state.RelationsBySrc[cur] directly. Per GraphState docs, indexes can be nil/uninitialized (e.g., state loaded from JSON without Rebuild), and read paths are expected to tolerate that. As written, a nil RelationsBySrc will make the BFS see no outgoing edges and may incorrectly allow a cycle. Prefer using the GraphState accessor (RelationsFrom) or falling back to scanning state.Relations when the index is nil.

Copilot uses AI. Check for mistakes.
if rel.RewriteCategory != graph.RewriteCategory("WF21") {
continue
}
// Only follow edges in the canonical causes-direction. The
// reverse caused-by side of the same relation is implicit
// in WF21's port-pair (causes / caused-by) — walking that
// direction would over-detect.
if rel.SrcPort != "causes" {
continue
}
if rel.TgtURN == env.SrcURN {
return fmt.Errorf("WF21 acyclic: adding %s --causes--> %s would close a cycle (path exists: %s --causes...--> %s)",
env.SrcURN, env.TgtURN, env.TgtURN, env.SrcURN)
}
if !visited[rel.TgtURN] {
visited[rel.TgtURN] = true
queue = append(queue, rel.TgtURN)
}
}
}
return nil
}
213 changes: 213 additions & 0 deletions internal/operad/validate_causal_acyclic_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
package operad

import (
"strings"
"testing"
"time"

"moos/kernel/internal/graph"
)

// buildAcyclicTestState returns a GraphState with the given WF21 causes-edges
// pre-installed. Each edge is (src, tgt) using SrcPort="causes" / TgtPort="caused-by".
// Nodes are auto-stubbed with type "derivation" so the state is internally consistent.
func buildAcyclicTestState(edges [][2]graph.URN) graph.GraphState {
state := graph.NewGraphState()
urnSet := map[graph.URN]struct{}{}
for _, e := range edges {
urnSet[e[0]] = struct{}{}
urnSet[e[1]] = struct{}{}
}
for urn := range urnSet {
state.Nodes[urn] = graph.Node{
URN: urn,
TypeID: "derivation",
}
graph.IndexAddNodeByType(state.NodesByType, urn, "derivation")
}
for i, e := range edges {
relURN := graph.URN("urn:moos:rel:test." + string(e[0]) + ".causes." + string(e[1]) + "." + string(rune('a'+i)))
state.Relations[relURN] = graph.Relation{
URN: relURN,
RewriteCategory: graph.RewriteCategory("WF21"),
SrcURN: e[0],
Comment on lines +30 to +33

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file repeatedly uses graph.RewriteCategory("WF21") literals. To match existing usage of typed WF constants (graph.WF18/WF19/etc.) and reduce typo risk, consider adding graph.WF21 and using it throughout these tests and the validator.

Copilot uses AI. Check for mistakes.
SrcPort: "causes",
TgtURN: e[1],
TgtPort: "caused-by",
CreatedAt: time.Now(),
}
graph.IndexAddRelationEndpoints(state.RelationsBySrc, state.RelationsByTgt, relURN, e[0], e[1])
}
return state
Comment on lines +14 to +41

Copilot AI Apr 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests only exercise states built via graph.NewGraphState (indexes initialized). Since GraphState indexes can be nil after JSON load (until Rebuild), it would be good to add a test case where Relations is populated but RelationsBySrc is nil, to ensure ValidateCausalAcyclic still detects cycles (or to enforce the intended fallback behavior).

Copilot uses AI. Check for mistakes.
}

func TestValidateCausalAcyclic_NonWF21LinkPasses(t *testing.T) {
reg := EmptyRegistry()
state := graph.NewGraphState()
env := graph.Envelope{
RewriteType: graph.LINK,
RewriteCategory: graph.WF18, // not WF21
SrcURN: "urn:moos:claim:a",
TgtURN: "urn:moos:claim:b",
SrcPort: "annotates",
TgtPort: "annotated-by",
}
if err := reg.ValidateCausalAcyclic(env, state); err != nil {
t.Fatalf("non-WF21 LINK should pass: got %v", err)
}
}

func TestValidateCausalAcyclic_NonLinkPasses(t *testing.T) {
reg := EmptyRegistry()
state := graph.NewGraphState()
for _, rt := range []graph.RewriteType{graph.ADD, graph.MUTATE, graph.UNLINK} {
env := graph.Envelope{
RewriteType: rt,
RewriteCategory: graph.RewriteCategory("WF21"),
SrcURN: "urn:moos:a",
TgtURN: "urn:moos:b",
}
if err := reg.ValidateCausalAcyclic(env, state); err != nil {
t.Fatalf("non-LINK rewrite %v should pass: got %v", rt, err)
}
}
}

func TestValidateCausalAcyclic_SelfLinkRejected(t *testing.T) {
reg := EmptyRegistry()
state := graph.NewGraphState()
env := graph.Envelope{
RewriteType: graph.LINK,
RewriteCategory: graph.RewriteCategory("WF21"),
SrcURN: "urn:moos:claim:self",
TgtURN: "urn:moos:claim:self",
SrcPort: "causes",
TgtPort: "caused-by",
}
err := reg.ValidateCausalAcyclic(env, state)
if err == nil {
t.Fatal("self-LINK should reject as 1-cycle")
}
if !strings.Contains(err.Error(), "1-cycle") {
t.Fatalf("expected '1-cycle' in error, got %v", err)
}
}

func TestValidateCausalAcyclic_NoPathPasses(t *testing.T) {
// A → B exists; new edge C → D should pass (no path D...→C exists).
reg := EmptyRegistry()
state := buildAcyclicTestState([][2]graph.URN{
{"urn:moos:claim:a", "urn:moos:claim:b"},
})
env := graph.Envelope{
RewriteType: graph.LINK,
RewriteCategory: graph.RewriteCategory("WF21"),
SrcURN: "urn:moos:claim:c",
TgtURN: "urn:moos:claim:d",
SrcPort: "causes",
TgtPort: "caused-by",
}
if err := reg.ValidateCausalAcyclic(env, state); err != nil {
t.Fatalf("disjoint edge should pass: got %v", err)
}
}

func TestValidateCausalAcyclic_DirectCycleRejected(t *testing.T) {
// B → A exists; new edge A → B would close a 2-cycle.
reg := EmptyRegistry()
state := buildAcyclicTestState([][2]graph.URN{
{"urn:moos:claim:b", "urn:moos:claim:a"},
})
env := graph.Envelope{
RewriteType: graph.LINK,
RewriteCategory: graph.RewriteCategory("WF21"),
SrcURN: "urn:moos:claim:a",
TgtURN: "urn:moos:claim:b",
SrcPort: "causes",
TgtPort: "caused-by",
}
err := reg.ValidateCausalAcyclic(env, state)
if err == nil {
t.Fatal("direct cycle should reject")
}
if !strings.Contains(err.Error(), "close a cycle") {
t.Fatalf("expected 'close a cycle' in error, got %v", err)
}
}

func TestValidateCausalAcyclic_TransitiveCycleRejected(t *testing.T) {
// B → C → D → A exists; new edge A → B would close a 4-cycle.
reg := EmptyRegistry()
state := buildAcyclicTestState([][2]graph.URN{
{"urn:moos:claim:b", "urn:moos:claim:c"},
{"urn:moos:claim:c", "urn:moos:claim:d"},
{"urn:moos:claim:d", "urn:moos:claim:a"},
})
env := graph.Envelope{
RewriteType: graph.LINK,
RewriteCategory: graph.RewriteCategory("WF21"),
SrcURN: "urn:moos:claim:a",
TgtURN: "urn:moos:claim:b",
SrcPort: "causes",
TgtPort: "caused-by",
}
err := reg.ValidateCausalAcyclic(env, state)
if err == nil {
t.Fatal("transitive cycle should reject")
}
if !strings.Contains(err.Error(), "close a cycle") {
t.Fatalf("expected 'close a cycle' in error, got %v", err)
}
}

func TestValidateCausalAcyclic_DAGForkPasses(t *testing.T) {
// B → C, B → D (fork); new edge A → B should pass — no path B...→A.
reg := EmptyRegistry()
state := buildAcyclicTestState([][2]graph.URN{
{"urn:moos:claim:b", "urn:moos:claim:c"},
{"urn:moos:claim:b", "urn:moos:claim:d"},
})
env := graph.Envelope{
RewriteType: graph.LINK,
RewriteCategory: graph.RewriteCategory("WF21"),
SrcURN: "urn:moos:claim:a",
TgtURN: "urn:moos:claim:b",
SrcPort: "causes",
TgtPort: "caused-by",
}
if err := reg.ValidateCausalAcyclic(env, state); err != nil {
t.Fatalf("DAG fork should pass: got %v", err)
}
}

func TestValidateCausalAcyclic_NonCausesPortIgnored(t *testing.T) {
// A WF21 relation but with non-"causes" SrcPort should not be followed
// (defensive — only true causes-direction edges count).
reg := EmptyRegistry()
state := graph.NewGraphState()
for _, urn := range []graph.URN{"urn:moos:claim:a", "urn:moos:claim:b"} {
state.Nodes[urn] = graph.Node{URN: urn, TypeID: "derivation"}
}
relURN := graph.URN("urn:moos:rel:reverse-only")
state.Relations[relURN] = graph.Relation{
URN: relURN,
RewriteCategory: graph.RewriteCategory("WF21"),
SrcURN: "urn:moos:claim:b",
SrcPort: "caused-by", // reverse direction, not "causes"
TgtURN: "urn:moos:claim:a",
TgtPort: "causes",
}
graph.IndexAddRelationEndpoints(state.RelationsBySrc, state.RelationsByTgt, relURN, "urn:moos:claim:b", "urn:moos:claim:a")

env := graph.Envelope{
RewriteType: graph.LINK,
RewriteCategory: graph.RewriteCategory("WF21"),
SrcURN: "urn:moos:claim:a",
TgtURN: "urn:moos:claim:b",
SrcPort: "causes",
TgtPort: "caused-by",
}
if err := reg.ValidateCausalAcyclic(env, state); err != nil {
t.Fatalf("non-causes port should not be followed; expected pass, got %v", err)
}
}