diff --git a/.github/tests/go_source_metadata.go b/.github/tests/go_source_metadata.go index 2bd0ab1..56c31e1 100644 --- a/.github/tests/go_source_metadata.go +++ b/.github/tests/go_source_metadata.go @@ -27,6 +27,7 @@ type metadata struct { Path string `json:"path"` Imports []string `json:"imports"` RuntimeConsumers []string `json:"runtime_consumers"` + Vocabulary []string `json:"vocabulary"` } func main() { @@ -35,11 +36,11 @@ func main() { if filename == "--" { continue } - parsed, err := parser.ParseFile(token.NewFileSet(), filename, nil, parser.AllErrors) + parsed, err := parser.ParseFile(token.NewFileSet(), filename, nil, parser.AllErrors|parser.ParseComments) if err != nil { fatalf("parse %s: %v", filename, err) } - item := metadata{Path: filename, Imports: []string{}, RuntimeConsumers: []string{}} + item := metadata{Path: filename, Imports: []string{}, RuntimeConsumers: []string{}, Vocabulary: []string{}} kernelAliases := map[string]bool{} for _, spec := range parsed.Imports { importPath, err := strconv.Unquote(spec.Path.Value) @@ -58,20 +59,33 @@ func main() { } ast.Inspect(parsed, func(node ast.Node) bool { switch value := node.(type) { + case *ast.ImportSpec: + return false case *ast.SelectorExpr: alias, ok := value.X.(*ast.Ident) if ok && kernelAliases[alias.Name] && runtimeSymbols[value.Sel.Name] { item.RuntimeConsumers = append(item.RuntimeConsumers, value.Sel.Name) } case *ast.Ident: + item.Vocabulary = append(item.Vocabulary, value.Name) if kernelAliases["."] && runtimeSymbols[value.Name] { item.RuntimeConsumers = append(item.RuntimeConsumers, value.Name) } + case *ast.BasicLit: + if value.Kind == token.STRING { + decoded, err := strconv.Unquote(value.Value) + if err == nil { + item.Vocabulary = append(item.Vocabulary, decoded) + } + } + case *ast.Comment: + item.Vocabulary = append(item.Vocabulary, value.Text) } return true }) sort.Strings(item.Imports) sort.Strings(item.RuntimeConsumers) + sort.Strings(item.Vocabulary) results = append(results, item) } if err := json.NewEncoder(os.Stdout).Encode(results); err != nil { diff --git a/.github/tests/test_repository_contract.py b/.github/tests/test_repository_contract.py index 92f26d0..5d56248 100644 --- a/.github/tests/test_repository_contract.py +++ b/.github/tests/test_repository_contract.py @@ -16,6 +16,19 @@ REPO = Path(__file__).resolve().parents[2] RUNTIME = REPO / "boatstack" CONFIG = REPO / "project.example.json" +DOMAIN_NEUTRAL_ROOTS = ( + ("pullrequest", "pull request"), + ("codingagent", "coding agent"), + ("github", "github"), + ("repository", "repository"), + ("worktree", "worktree"), + ("publication", "publication"), + ("branch", "branch"), + ("git", "git"), +) +DOMAIN_NEUTRAL_PHRASE = re.compile(r"\b(?:pull\s+request|coding\s+agent)\b", re.IGNORECASE) +GO_IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") +GO_IDENTIFIER_PART = re.compile(r"[A-Z]?[a-z]+|[A-Z]+(?![a-z])|[0-9]+") def go_source_metadata(paths: list[Path]) -> list[dict[str, object]]: @@ -36,6 +49,48 @@ def go_source_metadata(paths: list[Path]) -> list[dict[str, object]]: return json.loads(result.stdout) +def identifier_domain_token(identifier: str) -> str | None: + for component in identifier.split("_"): + for part in GO_IDENTIFIER_PART.findall(component): + normalized = part.lower() + for root, token in DOMAIN_NEUTRAL_ROOTS: + if normalized == root or normalized.startswith(root): + return token + return None + + +def import_domain_token(import_path: str) -> str | None: + components = import_path.split("/") + if components and components[0] == "github.com": + components = components[1:] + for component in components: + for identifier in re.split(r"[.-]", component): + token = identifier_domain_token(identifier) + if token is not None: + return token + return None + + +def domain_vocabulary_hits(paths: list[Path]) -> list[tuple[Path, str]]: + hits: list[tuple[Path, str]] = [] + for path, metadata in zip(paths, go_source_metadata(paths), strict=True): + for import_path in metadata["imports"]: + token = import_domain_token(import_path) + if token is not None: + hits.append((path, token)) + for source in metadata["vocabulary"]: + phrase = DOMAIN_NEUTRAL_PHRASE.search(source) + if phrase is not None: + hits.append((path, phrase.group(0).lower())) + continue + for identifier in GO_IDENTIFIER.findall(source): + token = identifier_domain_token(identifier) + if token is not None: + hits.append((path, token)) + break + return hits + + class RepositoryContract(unittest.TestCase): @classmethod def setUpClass(cls) -> None: @@ -415,15 +470,35 @@ def test_public_tree_excludes_private_context_and_v1_operating_guidance(self) -> def test_general_kernel_is_domain_neutral_and_owns_shared_control_laws(self) -> None: kernel = REPO / "boatstack" / "kernel" kernel_files = sorted(kernel.glob("*.go")) - production_files = [ - path for path in kernel_files if not path.name.endswith("_test.go") - ] - source = "\n".join(path.read_text() for path in production_files) - for token in ( - "git", "repository", "worktree", "branch", "pull request", - "coding agent", "publication", - ): - self.assertNotIn(token, source.lower(), token) + self.assertEqual([], domain_vocabulary_hits(kernel_files)) + with tempfile.TemporaryDirectory() as temporary: + fixture = Path(temporary) / "domain_leak_test.go" + for source, token in ( + ('package kernel\nconst fixtureDomain = "pull request"\n', "pull request"), + ("package kernel\ntype gitClient struct{}\n", "git"), + ("package kernel\nvar testRepository string\n", "repository"), + ("package kernel\ntype worktreeManager struct{}\n", "worktree"), + ('package kernel\nconst provider = "github"\n', "github"), + ("package kernel\ntype githubClient struct{}\n", "github"), + ("package kernel\ntype gitclient struct{}\n", "git"), + ("package kernel\ntype repositoryclient struct{}\n", "repository"), + ("package kernel\ntype worktreemanager struct{}\n", "worktree"), + ("package kernel\ntype branchmanager struct{}\n", "branch"), + ("package kernel\ntype publicationqueue struct{}\n", "publication"), + ("package kernel\ntype pullrequesthandler struct{}\n", "pull request"), + ("package kernel\ntype codingagentpolicy struct{}\n", "coding agent"), + ): + with self.subTest(token=token): + fixture.write_text(source) + self.assertEqual([(fixture, token)], domain_vocabulary_hits([fixture])) + fixture.write_text('package kernel\nimport "github.com/example/provider"\n') + self.assertEqual([], domain_vocabulary_hits([fixture])) + fixture.write_text( + 'package kernel\nimport vcs "github.com/go-git/go-git/v5"\nvar _ = vcs.PlainClone\n' + ) + self.assertEqual([(fixture, "git")], domain_vocabulary_hits([fixture])) + fixture.write_text("package kernel\nfunc TestDigitalSignature() {}\n") + self.assertEqual([], domain_vocabulary_hits([fixture])) boatstack_packages = "github.com/operatorstack/boatstack/boatstack/" kernel_package = boatstack_packages + "kernel" diff --git a/boatstack/kernel/conformance/conformance.go b/boatstack/kernel/conformance/conformance.go index 1edecb0..5cb0e91 100644 --- a/boatstack/kernel/conformance/conformance.go +++ b/boatstack/kernel/conformance/conformance.go @@ -51,6 +51,8 @@ type Scenario struct { ChangeObservation func() RebindObjective func(kernel.Objective) BumpStateRevision func() + RetargetProgram func(kernel.ProgramIdentity) + AdvanceClock func(time.Duration) IndependentLocker func() kernel.Locker VerifyCommitted func(Snapshot, Snapshot, kernel.Receipt) error InterruptNextOperator func() @@ -87,6 +89,8 @@ func (suite KernelConformance) Run(t *testing.T) { t.Run("stale_prescription_precedes_effects", suite.stalePrescriptionPrecedesEffects) t.Run("authority_denial_fails_closed", suite.authorityDenialFailsClosed) t.Run("future_authority_fails_closed", suite.futureAuthorityFailsClosed) + t.Run("expired_authority_fails_closed", suite.expiredAuthorityFailsClosed) + t.Run("authority_expiry_invalidates_prescription_before_effects", suite.authorityExpiryInvalidatesPrescription) t.Run("capability_classifier_cannot_be_weakened", suite.capabilityClassifierCannotBeWeakened) t.Run("targeted_and_untargeted_share_one_relation", suite.targetedAndUntargetedShareRelation) t.Run("interrupted_operator_requires_explicit_recovery", suite.interruptedOperatorRequiresExplicitRecovery) @@ -161,19 +165,42 @@ func (suite KernelConformance) stateRevisionInvalidatesPrescription(t *testing.T } func (suite KernelConformance) programFingerprintInvalidatesPrescription(t *testing.T) { - fixture, runtime := suite.fresh(t, SetupBound) - transition := fixture.Scenario.AdvanceTransitions[0] - request, prescription := resolve(t, runtime, fixture.Scenario, transition, &fixture.Scenario.Objective, fixture.Scenario.Authority) - alternate, err := kernel.NewRuntime(fixture.Scenario.AlternateProgram, fixture.Domain, fixture.Operator, fixture.CapabilityClassifier, fixture.Store, fixture.Locker, fixture.Clock) - if err != nil { - t.Fatal(err) - } - before := fixture.Scenario.Snapshot() - _, err = alternate.Apply(context.Background(), kernel.ApplyRequest{ResolveRequest: request, Prescription: prescription}) - after := fixture.Scenario.Snapshot() - if unchangedErr := refusedApplyMutationError(before, after, transition); err == nil || unchangedErr != nil { - t.Fatalf("control-law program-fingerprint-freshness: error=%v mutation=%v before=%#v after=%#v", err, unchangedErr, before, after) - } + t.Run("executable_mismatch", func(t *testing.T) { + fixture, runtime := suite.fresh(t, SetupBound) + transition := fixture.Scenario.AdvanceTransitions[0] + request, prescription := resolve(t, runtime, fixture.Scenario, transition, &fixture.Scenario.Objective, fixture.Scenario.Authority) + alternate, err := kernel.NewRuntime(fixture.Scenario.AlternateProgram, fixture.Domain, fixture.Operator, fixture.CapabilityClassifier, fixture.Store, fixture.Locker, fixture.Clock) + if err != nil { + t.Fatal(err) + } + before := fixture.Scenario.Snapshot() + _, err = alternate.Apply(context.Background(), kernel.ApplyRequest{ResolveRequest: request, Prescription: prescription}) + after := fixture.Scenario.Snapshot() + if unchangedErr := refusedApplyMutationError(before, after, transition); err == nil || unchangedErr != nil { + t.Fatalf("control-law executable-program-freshness: error=%v mutation=%v before=%#v after=%#v", err, unchangedErr, before, after) + } + }) + t.Run("prescription_fingerprint", func(t *testing.T) { + fixture, runtime := suite.fresh(t, SetupBound) + transition := fixture.Scenario.AdvanceTransitions[0] + request, prescription := resolve(t, runtime, fixture.Scenario, transition, &fixture.Scenario.Objective, fixture.Scenario.Authority) + beforeRetarget := fixture.Scenario.Snapshot() + fixture.Scenario.RetargetProgram(fixture.Scenario.AlternateProgram.Identity()) + afterRetarget := fixture.Scenario.Snapshot() + if err := retargetProgramError(beforeRetarget, afterRetarget, fixture.Scenario.AlternateProgram.Identity()); err != nil { + t.Fatal(err) + } + alternate, err := kernel.NewRuntime(fixture.Scenario.AlternateProgram, fixture.Domain, fixture.Operator, fixture.CapabilityClassifier, fixture.Store, fixture.Locker, fixture.Clock) + if err != nil { + t.Fatal(err) + } + before := fixture.Scenario.Snapshot() + _, err = alternate.Apply(context.Background(), kernel.ApplyRequest{ResolveRequest: request, Prescription: prescription}) + after := fixture.Scenario.Snapshot() + if unchangedErr := refusedApplyMutationError(before, after, transition); !kernel.IsStale(err) || unchangedErr != nil { + t.Fatalf("control-law prescription-program-freshness: error=%v mutation=%v before=%#v after=%#v", err, unchangedErr, before, after) + } + }) } func (suite KernelConformance) stalePrescriptionPrecedesEffects(t *testing.T) { @@ -219,6 +246,58 @@ func (suite KernelConformance) futureAuthorityFailsClosed(t *testing.T) { } } +func (suite KernelConformance) expiredAuthorityFailsClosed(t *testing.T) { + fixture, runtime := suite.fresh(t, SetupBound) + transition := fixture.Scenario.AdvanceTransitions[0] + authority := fixture.Scenario.Authority + authority.Receipts = append([]kernel.AuthorityReceipt(nil), authority.Receipts...) + authority.Receipts[0].ExpiresAt = fixture.Clock.Now() + resolution, err := resolveWithoutMutation(context.Background(), runtime, fixture.Scenario, kernel.ResolveRequest{InstanceID: fixture.Scenario.InstanceID, Objective: &fixture.Scenario.Objective, Authority: authority, Requested: transition}) + if err != nil || resolution.Decision.Kind != kernel.Refused { + t.Fatalf("control-law expired-authority: decision=%#v error=%v", resolution.Decision, err) + } +} + +func (suite KernelConformance) authorityExpiryInvalidatesPrescription(t *testing.T) { + fixture, runtime := suite.fresh(t, SetupBound) + transition := fixture.Scenario.AdvanceTransitions[0] + authority := fixture.Scenario.Authority + authority.Receipts = append([]kernel.AuthorityReceipt(nil), authority.Receipts...) + authority.Receipts[0].ExpiresAt = fixture.Clock.Now().Add(time.Hour) + request, prescription := resolve(t, runtime, fixture.Scenario, transition, &fixture.Scenario.Objective, authority) + duration := 2 * time.Hour + beforeClock := fixture.Clock.Now() + beforeAdvance := fixture.Scenario.Snapshot() + fixture.Scenario.AdvanceClock(duration) + afterClock := fixture.Clock.Now() + afterAdvance := fixture.Scenario.Snapshot() + if err := clockAdvanceError(beforeAdvance, afterAdvance, beforeClock, afterClock, duration); err != nil { + t.Fatal(err) + } + before := fixture.Scenario.Snapshot() + _, err := runtime.Apply(context.Background(), kernel.ApplyRequest{ResolveRequest: request, Prescription: prescription}) + after := fixture.Scenario.Snapshot() + if unchangedErr := refusedApplyMutationError(before, after, transition); err == nil || unchangedErr != nil { + t.Fatalf("control-law apply-time-authority-expiry: error=%v mutation=%v before=%#v after=%#v", err, unchangedErr, before, after) + } +} + +func retargetProgramError(before, after Snapshot, program kernel.ProgramIdentity) error { + expected := before + expected.State.Program = program + if !reflect.DeepEqual(after, expected) { + return fmt.Errorf("control-law program-retarget fixture changed evidence outside State.Program: before=%#v after=%#v", before, after) + } + return nil +} + +func clockAdvanceError(before, after Snapshot, beforeTime, afterTime time.Time, duration time.Duration) error { + if !reflect.DeepEqual(after, before) || !afterTime.Equal(beforeTime.Add(duration)) { + return fmt.Errorf("control-law clock-advance fixture changed snapshot evidence or advanced by the wrong duration: before=%#v after=%#v before_time=%v after_time=%v duration=%v", before, after, beforeTime, afterTime, duration) + } + return nil +} + func (suite KernelConformance) capabilityClassifierCannotBeWeakened(t *testing.T) { fixture := suite.fixture(t, SetupBound) transition := fixture.Scenario.AdvanceTransitions[0] @@ -653,7 +732,7 @@ func (suite KernelConformance) fixture(t testing.TB, setup Setup) KernelConforma t.Fatal("kernel conformance requires a fresh fixture factory") } fixture := suite.New(t, setup) - if fixture.Domain == nil || fixture.Operator == nil || fixture.CapabilityClassifier == nil || fixture.Store == nil || fixture.Locker == nil || fixture.Clock == nil || fixture.Scenario.Snapshot == nil || fixture.Scenario.ChangeObservation == nil || fixture.Scenario.RebindObjective == nil || fixture.Scenario.BumpStateRevision == nil || fixture.Scenario.IndependentLocker == nil || fixture.Scenario.VerifyCommitted == nil || fixture.Scenario.InterruptNextOperator == nil || fixture.Scenario.PanicNextOperator == nil || fixture.Scenario.FailNextCommit == nil || fixture.Scenario.RetargetInstance == nil || fixture.Scenario.InstanceID == "" || fixture.Scenario.BindTransition == "" || len(fixture.Scenario.AdvanceTransitions) == 0 || fixture.Scenario.MaintenanceTransition == "" || fixture.Scenario.RecoveryTransition == "" || fixture.Scenario.RecoveryCapability.Validate() != nil || fixture.Scenario.ExtraCapability.Validate() != nil { + if fixture.Domain == nil || fixture.Operator == nil || fixture.CapabilityClassifier == nil || fixture.Store == nil || fixture.Locker == nil || fixture.Clock == nil || fixture.Scenario.Snapshot == nil || fixture.Scenario.ChangeObservation == nil || fixture.Scenario.RebindObjective == nil || fixture.Scenario.BumpStateRevision == nil || fixture.Scenario.RetargetProgram == nil || fixture.Scenario.AdvanceClock == nil || fixture.Scenario.IndependentLocker == nil || fixture.Scenario.VerifyCommitted == nil || fixture.Scenario.InterruptNextOperator == nil || fixture.Scenario.PanicNextOperator == nil || fixture.Scenario.FailNextCommit == nil || fixture.Scenario.RetargetInstance == nil || fixture.Scenario.InstanceID == "" || fixture.Scenario.BindTransition == "" || len(fixture.Scenario.AdvanceTransitions) == 0 || fixture.Scenario.MaintenanceTransition == "" || fixture.Scenario.RecoveryTransition == "" || fixture.Scenario.RecoveryCapability.Validate() != nil || fixture.Scenario.ExtraCapability.Validate() != nil { t.Fatal("kernel conformance fixture is incomplete") } if fixture.Scenario.RevisedObjective.Validate() != nil || fixture.Scenario.RevisedObjective.ID != fixture.Scenario.Objective.ID || fixture.Scenario.RevisedObjective.Revision <= fixture.Scenario.Objective.Revision || fixture.Scenario.RevisedObjective.Fingerprint == fixture.Scenario.Objective.Fingerprint { diff --git a/boatstack/kernel/conformance/conformance_test.go b/boatstack/kernel/conformance/conformance_test.go index 4e923cb..4d74995 100644 --- a/boatstack/kernel/conformance/conformance_test.go +++ b/boatstack/kernel/conformance/conformance_test.go @@ -38,6 +38,31 @@ func TestIntegerBoundFixtureIncludesCommittedHistory(t *testing.T) { } } +func TestRetargetProgramCheckRejectsAdditionalFreshnessChanges(t *testing.T) { + fixture := newIntegerFixture(SetupBound) + before := fixture.Scenario.Snapshot() + fixture.Scenario.RetargetProgram(fixture.Scenario.AlternateProgram.Identity()) + fixture.Scenario.BumpStateRevision() + after := fixture.Scenario.Snapshot() + if err := retargetProgramError(before, after, fixture.Scenario.AlternateProgram.Identity()); err == nil { + t.Fatal("expected revision-changing program hook to be rejected") + } +} + +func TestAdvanceClockCheckRejectsAdditionalFreshnessChanges(t *testing.T) { + fixture := newIntegerFixture(SetupBound) + duration := time.Hour + beforeTime := fixture.Clock.Now() + before := fixture.Scenario.Snapshot() + fixture.Scenario.AdvanceClock(duration) + fixture.Scenario.ChangeObservation() + afterTime := fixture.Clock.Now() + after := fixture.Scenario.Snapshot() + if err := clockAdvanceError(before, after, beforeTime, afterTime, duration); err == nil { + t.Fatal("expected observation-changing clock hook to be rejected") + } +} + func TestResolveWithoutMutationRejectsEffectfulLoad(t *testing.T) { fixture := newIntegerFixture(SetupBound) base := fixture.Store.(*MemoryStateStore) diff --git a/boatstack/kernel/conformance/integer.go b/boatstack/kernel/conformance/integer.go index 9cf5034..bf19db6 100644 --- a/boatstack/kernel/conformance/integer.go +++ b/boatstack/kernel/conformance/integer.go @@ -263,10 +263,23 @@ func (l memoryLock) Unlock() error { return nil } -// FixedClock returns one deterministic time. -type FixedClock struct{ Time time.Time } +// FixedClock returns one deterministic, test-controlled time. +type FixedClock struct { + mu sync.Mutex + Time time.Time +} + +func (c *FixedClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.Time +} -func (c FixedClock) Now() time.Time { return c.Time } +func (c *FixedClock) advance(duration time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.Time = c.Time.Add(duration) +} // IntegerProgram compiles the reference control program. func IntegerProgram() (kernel.Program, error) { @@ -334,17 +347,18 @@ func newIntegerFixture(setup Setup) KernelConformance { state.Mode, state.ObjectiveBinding, value = "one", &binding, 1 } now := time.Date(2026, 8, 12, 10, 0, 0, 0, time.UTC) - authority := kernel.Authority{Receipts: []kernel.AuthorityReceipt{{ID: "human-counter", Subject: "fixture", Fingerprint: "fixture-authority", Capabilities: []kernel.Capability{"counter.audit", "counter.increment", "counter.reset", "objective.bind"}, IssuedAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Hour)}}} + authority := kernel.Authority{Receipts: []kernel.AuthorityReceipt{{ID: "human-counter", Subject: "fixture", Fingerprint: "fixture-authority", Capabilities: []kernel.Capability{"counter.audit", "counter.increment", "counter.reset", "objective.bind"}, IssuedAt: now.Add(-time.Minute), ExpiresAt: now.Add(24 * time.Hour)}}} domain := &IntegerDomain{value: value, executions: map[string]int{}} receipts := &MemoryReceipts{} store := &MemoryStateStore{state: state, receipts: receipts} + clock := &FixedClock{Time: now} fixture := KernelConformance{ Domain: domain, Operator: IntegerOperator{Domain: domain}, CapabilityClassifier: IntegerCapabilities{}, Store: store, Locker: &MemoryLocker{}, - Clock: FixedClock{Time: now}, + Clock: clock, Program: program, } fixture.Scenario = Scenario{ @@ -363,6 +377,8 @@ func newIntegerFixture(setup Setup) KernelConformance { ChangeObservation: domain.changeObservation, RebindObjective: store.rebind, BumpStateRevision: store.bumpRevision, + RetargetProgram: store.retargetProgram, + AdvanceClock: clock.advance, IndependentLocker: func() kernel.Locker { return &MemoryLocker{} }, VerifyCommitted: func(before, after Snapshot, receipt kernel.Receipt) error { return verifyIntegerCommitted(program, before, after, receipt) diff --git a/release-notes/2026-08-12-conformance-freshness-authority-guards.md b/release-notes/2026-08-12-conformance-freshness-authority-guards.md new file mode 100644 index 0000000..2de4210 --- /dev/null +++ b/release-notes/2026-08-12-conformance-freshness-authority-guards.md @@ -0,0 +1,3 @@ +### Close kernel conformance review gaps + +Kernel conformance now independently checks program-fingerprint freshness, authority expiry, and domain-neutral root tests.