diff --git a/UPSTREAM.json b/UPSTREAM.json
index a21910d..b2da47f 100644
--- a/UPSTREAM.json
+++ b/UPSTREAM.json
@@ -33,8 +33,9 @@
"clients/typescript/src/protocol.ts": "a5413e22897330135591f8c44a24eb64cdbfea7762f3f1d2e0866e1424ece145",
"clients/typescript/tsconfig.json": "97647800a147a82d9cdadf00cd2b84d82e07512c9e55971654dd60ba21b4e061",
"cmd/interlock/demo.go": "4593c47d679b455ae9800e1197f648071f4b85ec14f3a7f8b726414c91783764",
+ "cmd/interlock/derive.go": "6c2eda35cdf73d61f22dd3702999390e84c945416a0f699c709bf59ad42246cc",
"cmd/interlock/init.go": "ea4df1ba3bcf0ec2f62cf02752b39598027376096ba974b2e770b0a79dc4ba0f",
- "cmd/interlock/main.go": "c7e042f48d25c5b19925b729e6341c41de991a5b5b088cf859cbcf972fc56146",
+ "cmd/interlock/main.go": "0e4088a462c74963ae5c693b3f37e11dcc9ca0ce91fdc5b39ba6fae82d14ecae",
"cmd/interlock/test.go": "e9b680cde45e061155dcc375b057f8ff4e69559d5f2be7dcd15f3685af0e1079",
"cmd/interlock/verify.go": "5613c04febf6731fd25a75e615a524d57f99cb762016f458be5243034376b025",
"cmd/interlock/version.go": "262fedc77a86623a48ee5a52940356a399fc71466d6da52f902cd655b9d7303d",
@@ -63,6 +64,19 @@
"conformance/fixtures/hashes.jsonl": "7d33aea8dd5cc961d69f40cda0bf12353552a9e54d39a4e3c0b40e7e53dc2273",
"conformance/fixtures/negative.jsonl": "0ef6dbe7df1879564ddb609f4823c440fd43514b92b5a1724babcdc8880a65cc",
"conformance/fixtures/positive.jsonl": "0186ae490e1f9cb9ccfadb8f04dd88e1c6e75b1b578918c84314a6305bfc5e64",
+ "derive/adapters.go": "24aad0fb25b8a8b089248255387d9fe0112717c22247468558434e6d53df05da",
+ "derive/candidate.go": "73bd2b4d7fdf42745f5dcb27f51b5c01d02571745d48ad27b075dfbc8ff11471",
+ "derive/classify.go": "314194eeb06508a3fccc1b16e5c68e82c0e03debdf4bcdf31288d7874ce6fe27",
+ "derive/conflicts.go": "32d43044030b74c072aadd93a83497ff942ea0955e2d2775b40235e5bceb0184",
+ "derive/derive.go": "893ef79e63e2bd7fb98494cac99fa3a9b612c64e9f24f92faab6b136fa968ddb",
+ "derive/derive_conformance_test.go": "0460a3a414dac4510efad5cc4c6269fa141ea4020fbaec785e1722e4d70834f2",
+ "derive/derive_test.go": "d97c4a8bfbbb9f000de1056b352d52e913360d956563983d7bd3cec0830ed86c",
+ "derive/discover.go": "2d5f82b60ceb1780d8a117f6572c49ae693b7e710e3d50b9dd68d498c746eaef",
+ "derive/evidence.go": "406dd709eb9b02f3048be99e147044c93106d7c1e5200f31cb7f82956c589c4c",
+ "derive/ground.go": "24a9fb01387e6ea9bd732021f444bbd544f35750775bea132ba5f6e263cd4250",
+ "derive/report.go": "eb81df37497712fa513a35b69b8894e8db284ed138282687b042a7418b53a005",
+ "derive/review.go": "59349539a8dbb8664b89b0f2a3cabc50da03e3975727ccae36bda70d3968f704",
+ "derive/schema.go": "4d10bca81110512a10aaf6306d5a5a2ebc4193058b4500cd92022a99b42d4f1e",
"doc.go": "ffda943422fc0104acff178f17f096df5d9d0e9065e598aa0d817c457edfb198",
"emitspec_test.go": "b669fc73361f275311221ac450008963885801742fe14d47b12e61e677b67545",
"engine/engine.go": "8ee1d012bbf9661288507056c7a015ab9d46fb16e9596d4717b38f15af748b8f",
@@ -98,7 +112,7 @@
"generator": "operatorstack/interlock:project-upstream",
"schema_version": 1,
"source": {
- "commit": "d06e6a0ab205b05e9ee1ce85ebd4a051b9cd8ff3",
+ "commit": "59ec27984383d0c956b8b6bc0ec26465d5cdd952",
"path": "labs/21-interlock",
"repository": "operatorstack/intelligence-flow"
}
diff --git a/cmd/interlock/derive.go b/cmd/interlock/derive.go
new file mode 100644
index 0000000..dcd4111
--- /dev/null
+++ b/cmd/interlock/derive.go
@@ -0,0 +1,223 @@
+package main
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/operatorstack/interlock/derive"
+)
+
+const defaultDerivedDir = ".interlock/derived"
+
+// cmdDerive is the thin CLI for `interlock derive`: it owns flag parsing and file
+// I/O only. All classification and grounding live in the derive package, which
+// writes nothing. The command never activates policy — it writes a reviewable
+// candidate under --output and prints the "not enforced" framing.
+func cmdDerive(args []string) error {
+ repo := "."
+ outDir := defaultDerivedDir
+ format := "text"
+ force := false
+ nonInteractive := false
+ review := false
+ var from []string
+ var positional []string
+
+ i := 0
+ for i < len(args) {
+ switch args[i] {
+ case "--from":
+ if i+1 >= len(args) {
+ return fmt.Errorf("derive: --from wants a path")
+ }
+ from = append(from, args[i+1])
+ i += 2
+ case "--output", "-o":
+ if i+1 >= len(args) {
+ return fmt.Errorf("derive: --output wants a directory")
+ }
+ outDir = args[i+1]
+ i += 2
+ case "--format":
+ if i+1 >= len(args) {
+ return fmt.Errorf("derive: --format wants text|json")
+ }
+ format = args[i+1]
+ i += 2
+ case "--force", "-f":
+ force = true
+ i++
+ case "--non-interactive":
+ nonInteractive = true
+ i++
+ case "--review":
+ review = true
+ i++
+ default:
+ if strings.HasPrefix(args[i], "-") {
+ return fmt.Errorf("derive: unexpected flag %q", args[i])
+ }
+ positional = append(positional, args[i])
+ i++
+ }
+ }
+ if len(positional) > 1 {
+ return fmt.Errorf("derive: want at most one repository path")
+ }
+ if len(positional) == 1 {
+ repo = positional[0]
+ }
+ if format != "text" && format != "json" {
+ return fmt.Errorf("derive: --format wants text|json, got %q", format)
+ }
+ // Refuse to point --output at an active policy file (defense in depth; derive
+ // never writes a file named policy.json regardless).
+ if filepath.Base(outDir) == "policy.json" {
+ return fmt.Errorf("derive: --output must be a directory, not policy.json")
+ }
+
+ if review {
+ return runReview(outDir, format, nonInteractive)
+ }
+
+ res, err := derive.Derive(repo, from)
+ if err != nil {
+ return err
+ }
+ if err := writeResult(res, outDir, force); err != nil {
+ return err
+ }
+ return report(res, outDir, format)
+}
+
+// runReview re-opens an existing candidate, walks its unresolved questions, and
+// rewrites the candidate with the answers applied. It never activates policy.
+func runReview(outDir, format string, nonInteractive bool) error {
+ if nonInteractive {
+ return fmt.Errorf("derive --review needs interactive stdin (omit --non-interactive)")
+ }
+ raw, err := os.ReadFile(filepath.Join(outDir, derive.FileDerivation))
+ if err != nil {
+ return fmt.Errorf("derive --review: reading %s: %w (run `interlock derive` first)", derive.FileDerivation, err)
+ }
+ d, err := derive.DecodeDerivation(raw)
+ if err != nil {
+ return err
+ }
+ // Rebuild once to learn the freeze state (whether to ask the baseline question).
+ current, err := derive.Rebuild(d)
+ if err != nil {
+ return err
+ }
+
+ answers := promptAnswers(d, current.Candidate.FreezeWarning)
+ updated := derive.ApplyAnswers(d, answers)
+ res, err := derive.Rebuild(updated)
+ if err != nil {
+ return err
+ }
+ if err := writeResult(res, outDir, true); err != nil {
+ return err
+ }
+ return report(res, outDir, format)
+}
+
+// promptAnswers reads one answer per unresolved question from stdin, plus the
+// baseline question when the candidate is deny-only. Blank input skips a question.
+func promptAnswers(d derive.Derivation, freeze bool) map[string]string {
+ r := bufio.NewReader(os.Stdin)
+ answers := map[string]string{}
+ fmt.Println("Answer each question to ground it into the candidate. Press Enter to skip.")
+ fmt.Println()
+ for _, rec := range d.Records {
+ if rec.Status != derive.StatusUnresolved {
+ continue
+ }
+ fmt.Printf("[%s] %s:%d\n %q\n %s\n", rec.ID, rec.Source.Path, rec.Source.LineStart, rec.Excerpt, rec.Question)
+ fmt.Print(" answer> ")
+ line, _ := r.ReadString('\n')
+ answers[rec.ID] = strings.TrimSpace(line)
+ fmt.Println()
+ }
+ if freeze {
+ fmt.Println("[baseline] The candidate only denies; under default-deny that blocks everything.")
+ fmt.Println(" What baseline should the agent be allowed to read/write? (e.g. repo://src/**)")
+ fmt.Print(" answer> ")
+ line, _ := r.ReadString('\n')
+ answers["baseline"] = strings.TrimSpace(line)
+ fmt.Println()
+ }
+ return answers
+}
+
+// writeResult writes all candidate artifacts atomically: it renders every file
+// first, guards against overwrite, then writes. A render error leaves the output
+// dir untouched (failure-state invariant). It never writes policy.json.
+func writeResult(res derive.Result, outDir string, force bool) error {
+ files, err := res.Files()
+ if err != nil {
+ return err
+ }
+ policyPath := filepath.Join(outDir, derive.FileCandidatePolicy)
+ if _, err := os.Stat(policyPath); err == nil && !force {
+ return fmt.Errorf("derive: %s already exists (use --force to overwrite, or --review to refine)", policyPath)
+ }
+ if err := os.MkdirAll(outDir, 0o755); err != nil {
+ return err
+ }
+ // Stable write order for deterministic output.
+ for _, name := range []string{
+ derive.FileCandidatePolicy, derive.FileCandidateTests,
+ derive.FileDerivation, derive.FileQuestions, derive.FileReadme,
+ } {
+ if err := os.WriteFile(filepath.Join(outDir, name), files[name], 0o644); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// report prints the closing summary. The text form leads with the "not enforced"
+// framing so no one mistakes a candidate for an active policy.
+func report(res derive.Result, outDir, format string) error {
+ proposed, unresolved, rejected := 0, 0, 0
+ for _, r := range res.Derivation.Records {
+ switch r.Status {
+ case derive.StatusProposed:
+ proposed++
+ case derive.StatusUnresolved:
+ unresolved++
+ case derive.StatusRejected:
+ rejected++
+ }
+ }
+ if format == "json" {
+ return printJSON(map[string]any{
+ "output": outDir,
+ "policy_id": res.Derivation.PolicyID,
+ "rules": res.Candidate.RuleCount,
+ "proposed": proposed,
+ "unresolved": unresolved,
+ "rejected": rejected,
+ "freeze": res.Candidate.FreezeWarning,
+ "enforced": false,
+ })
+ }
+ fmt.Printf("Reviewed your repository and drafted a candidate Interlock policy.\n\n")
+ fmt.Printf(" %s/\n", outDir)
+ fmt.Printf(" %-22s %d proposed rule(s) — interlock.spec.v1\n", derive.FileCandidatePolicy, res.Candidate.RuleCount)
+ fmt.Printf(" %-22s test vectors for each rule\n", derive.FileCandidateTests)
+ fmt.Printf(" %-22s provenance for every record\n", derive.FileDerivation)
+ fmt.Printf(" %-22s %d unresolved question(s)\n", derive.FileQuestions, unresolved)
+ if rejected > 0 {
+ fmt.Printf("\n %d record(s) were rejected (conflict or would weaken an existing policy) — see %s.\n", rejected, derive.FileDerivation)
+ }
+ fmt.Printf("\nThese enforceable rules appear to be implied by your repository. Review and approve them.\n")
+ fmt.Printf("Nothing is enforced yet. To activate after review:\n")
+ fmt.Printf(" interlock derive --review\n")
+ fmt.Printf(" interlock compile %s -o .interlock/policy.json && interlock test\n", filepath.Join(outDir, derive.FileCandidatePolicy))
+ return nil
+}
diff --git a/cmd/interlock/main.go b/cmd/interlock/main.go
index c3561a6..f1f65d8 100644
--- a/cmd/interlock/main.go
+++ b/cmd/interlock/main.go
@@ -31,6 +31,8 @@ func main() {
switch os.Args[1] {
case "init":
err = cmdInit(os.Args[2:])
+ case "derive":
+ err = cmdDerive(os.Args[2:])
case "compile":
err = cmdCompile(os.Args[2:])
case "check":
@@ -76,6 +78,7 @@ usage:
interlock init set up a no-toolchain JSON policy (interactive)
interlock init --authoring json [dir] set up a JSON policy (dir defaults to .interlock)
interlock init --authoring go
scaffold a programmable Go policy module
+ interlock derive [repo] [--from PATH] [--output DIR] [--review] draft a candidate policy from a repo's existing instructions (never enforces)
interlock test [dir] run the policy's tests (dir defaults to .interlock)
interlock demo [name] narrate a built-in policy (default repository-policy; --list)
interlock compile [-o policy.json] build+run a Go policy module → canonical IR
diff --git a/derive/adapters.go b/derive/adapters.go
new file mode 100644
index 0000000..f5f42eb
--- /dev/null
+++ b/derive/adapters.go
@@ -0,0 +1,198 @@
+package derive
+
+import (
+ "encoding/json"
+ "strings"
+)
+
+// adapters.go holds the deterministic V1 source parsers. Each is a pure function
+// from (path, content) to raw statements. They deliberately do NOT classify or
+// ground — a prose adapter just yields lines and lets classify.go read the
+// language; a machine-config adapter yields a Suggest hint + a Note describing the
+// decision a human must make, so structured config becomes a question rather than
+// a silently widened rule.
+
+// parseProse emits one statement per non-empty content line. It handles all
+// free-text authority sources (AGENTS.md, CLAUDE.md, .cursor/rules/*,
+// SKILL.md). Prose is weak evidence: only lines with an explicit imperative
+// marker survive classification into an emittable rule.
+func parseProse(path string, content []byte) []RawStatement {
+ var out []RawStatement
+ inFence := false
+ for i, line := range splitLines(content) {
+ trimmed := strings.TrimSpace(line)
+ if strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~") {
+ inFence = !inFence
+ continue
+ }
+ if inFence || trimmed == "" {
+ continue
+ }
+ excerpt := cleanExcerpt(line)
+ if excerpt == "" {
+ continue
+ }
+ out = append(out, RawStatement{
+ Path: path,
+ LineStart: i + 1,
+ LineEnd: i + 1,
+ Text: excerpt,
+ Strength: StrengthWeak,
+ })
+ }
+ return out
+}
+
+// parseCodeowners reads a CODEOWNERS file. Ownership is NOT an Interlock approval
+// gate — treating it as one would be semantic widening — so each entry becomes an
+// unresolved suggestion (Suggest=ClassUnresolved) carrying the owners in Note.
+func parseCodeowners(path string, content []byte) []RawStatement {
+ var out []RawStatement
+ for i, line := range splitLines(content) {
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
+ continue
+ }
+ fields := strings.Fields(trimmed)
+ if len(fields) < 2 {
+ continue
+ }
+ pattern, owners := fields[0], strings.Join(fields[1:], " ")
+ out = append(out, RawStatement{
+ Path: path,
+ LineStart: i + 1,
+ LineEnd: i + 1,
+ Text: "changes under `" + pattern + "` are owned by " + owners,
+ Strength: StrengthStrong,
+ Suggest: ClassUnresolved,
+ Note: "CODEOWNERS assigns " + owners + " to `" + pattern + "`. Ownership is not the same as an enforced approval gate. " +
+ "Should writes under `" + pattern + "` require human approval?",
+ })
+ }
+ return out
+}
+
+// parseWorkflow scans a GitHub Actions workflow for job/step names that read like
+// required checks. A CI check *existing* does not tell derive which receipt
+// schema/status counts as evidence, so each becomes an unresolved verification
+// question rather than a receipt requirement invented from nothing (invariant 5).
+func parseWorkflow(path string, content []byte) []RawStatement {
+ var out []RawStatement
+ for i, line := range splitLines(content) {
+ trimmed := strings.TrimSpace(line)
+ lower := strings.ToLower(trimmed)
+ // Match "name: " job or step names.
+ if !strings.HasPrefix(lower, "name:") && !strings.HasPrefix(lower, "- name:") {
+ continue
+ }
+ if !containsWord(lower, "test", "lint", "build", "check", "verify", "ci") {
+ continue
+ }
+ name := strings.TrimSpace(strings.SplitN(trimmed, ":", 2)[1])
+ name = strings.Trim(name, `"'`)
+ if name == "" {
+ continue
+ }
+ out = append(out, RawStatement{
+ Path: path,
+ LineStart: i + 1,
+ LineEnd: i + 1,
+ Text: "CI defines a check named " + name,
+ Strength: StrengthStrong,
+ Suggest: ClassUnresolved,
+ Note: "The workflow defines a check named " + name + ". If a passing " + name +
+ " run should be required evidence before an effect, name the receipt schema and status it produces",
+ })
+ }
+ return out
+}
+
+// parsePackageScripts reads package.json scripts. A "test"/"publish" script is
+// evidence a project *has* those operations, but not what must gate them, so each
+// is an unresolved suggestion.
+func parsePackageScripts(path string, content []byte) []RawStatement {
+ var doc struct {
+ Scripts map[string]string `json:"scripts"`
+ }
+ if err := json.Unmarshal(content, &doc); err != nil {
+ return nil
+ }
+ // Deterministic order over map keys.
+ names := make([]string, 0, len(doc.Scripts))
+ for k := range doc.Scripts {
+ names = append(names, k)
+ }
+ sortStrings(names)
+
+ var out []RawStatement
+ for _, name := range names {
+ lower := strings.ToLower(name)
+ if !containsWord(lower, "test", "publish", "release", "deploy", "lint") {
+ continue
+ }
+ out = append(out, RawStatement{
+ Path: path,
+ LineStart: 1,
+ LineEnd: 1,
+ Text: "package.json defines a `" + name + "` script",
+ Strength: StrengthStrong,
+ Suggest: ClassUnresolved,
+ Note: "package.json defines a `" + name + "` script. Should any effect be gated on it? " +
+ "If so, name the operation, resource, and evidence",
+ })
+ }
+ return out
+}
+
+// parseMakefile reads Makefile targets, applying the same "operation exists,
+// gate unknown" treatment as package scripts.
+func parseMakefile(path string, content []byte) []RawStatement {
+ var out []RawStatement
+ for i, line := range splitLines(content) {
+ // A target line is "name:" at column 0 (not indented, not a variable).
+ if line == "" || line[0] == '\t' || line[0] == ' ' || line[0] == '#' {
+ continue
+ }
+ colon := strings.Index(line, ":")
+ if colon <= 0 {
+ continue
+ }
+ name := strings.TrimSpace(line[:colon])
+ lower := strings.ToLower(name)
+ if strings.ContainsAny(name, " =") || !containsWord(lower, "test", "publish", "release", "deploy", "lint", "build") {
+ continue
+ }
+ out = append(out, RawStatement{
+ Path: path,
+ LineStart: i + 1,
+ LineEnd: i + 1,
+ Text: "Makefile defines a `" + name + "` target",
+ Strength: StrengthStrong,
+ Suggest: ClassUnresolved,
+ Note: "The Makefile defines a `" + name + "` target. Should any effect be gated on it? " +
+ "If so, name the operation, resource, and evidence",
+ })
+ }
+ return out
+}
+
+// splitLines splits content into lines without a trailing empty element from a
+// final newline, so line numbers are stable.
+func splitLines(content []byte) []string {
+ s := strings.ReplaceAll(string(content), "\r\n", "\n")
+ lines := strings.Split(s, "\n")
+ if n := len(lines); n > 0 && lines[n-1] == "" {
+ lines = lines[:n-1]
+ }
+ return lines
+}
+
+// sortStrings is a tiny local sort to avoid importing sort here (discover.go
+// already imports it; keep this file's imports minimal and its behavior obvious).
+func sortStrings(s []string) {
+ for i := 1; i < len(s); i++ {
+ for j := i; j > 0 && s[j-1] > s[j]; j-- {
+ s[j-1], s[j] = s[j], s[j-1]
+ }
+ }
+}
diff --git a/derive/candidate.go b/derive/candidate.go
new file mode 100644
index 0000000..f2baba9
--- /dev/null
+++ b/derive/candidate.go
@@ -0,0 +1,295 @@
+package derive
+
+import (
+ "sort"
+ "strings"
+
+ il "github.com/operatorstack/interlock"
+ "github.com/operatorstack/interlock/ir"
+ "github.com/operatorstack/interlock/protocol"
+ "github.com/operatorstack/interlock/scaffold"
+)
+
+// candidate.go assembles the grounded, proposed records into the SAME artifacts
+// interlock init emits: an interlock.spec.v1 document (via il.Builder.EmitSpec,
+// which runs the real compiler to validate) and scaffold.Vector test rows. This
+// is the crux of "propose, never enforce": the candidate is authoring input that
+// only becomes authority when a human runs it back through compiler.Compile.
+
+// Vector re-exports scaffold.Vector: derive emits the exact same test-row shape
+// interlock init does, so `interlock test` reads a derived candidate unchanged.
+type Vector = scaffold.Vector
+
+// Candidate is the emittable output of a derivation.
+type Candidate struct {
+ Spec []byte
+ Vectors []Vector
+ // FreezeWarning is set when the candidate contains deny rules but no allow
+ // rule. Under Interlock's default-deny, such a policy would block everything,
+ // so derive surfaces a baseline question rather than inventing an allow rule
+ // (which would be unprovenanced authority).
+ FreezeWarning bool
+ RuleCount int
+}
+
+// buildCandidate compiles the proposed records into a Candidate. A record is
+// emitted only if it is StatusProposed and its class is Emittable — every other
+// status (unresolved, rejected) is excluded, and every emitted rule carries a
+// reason citing its source (invariant 1).
+func buildCandidate(d Derivation) (Candidate, error) {
+ b := il.Policy(d.PolicyID).Actor(defaultActor)
+
+ // Deterministic resource registry keyed by URI.
+ reg := newResReg()
+ var emitted []Record
+ for _, rec := range d.Records {
+ if rec.Status != StatusProposed || !rec.Class.Emittable() {
+ continue
+ }
+ if rec.ResourceURI == "" || len(rec.Operations) == 0 {
+ continue // defensive: never emit a rule missing scope
+ }
+ emitted = append(emitted, rec)
+ reg.add(rec.ResourceKind, rec.ResourceURI)
+ }
+
+ // Declare resources in stable (kind-order, then URI) order.
+ for _, r := range reg.declared() {
+ switch r.kind {
+ case ir.KindFile:
+ b.File(r.id, r.uri)
+ case ir.KindTree:
+ b.Tree(r.id, r.uri)
+ case ir.KindProcess:
+ b.Process(r.id, r.uri)
+ case ir.KindBranch:
+ b.Branch(r.id, r.uri)
+ }
+ }
+
+ var vectors []scaffold.Vector
+ hasAllow, hasDeny := false, false
+ for _, rec := range emitted {
+ resID := reg.idFor(rec.ResourceURI)
+ ruleID := ruleID(rec, resID)
+ rb := ruleBuilder(b, rec, ruleID, resID)
+ rb.Add()
+ if rec.Effect == ir.EffectDeny {
+ hasDeny = true
+ } else {
+ hasAllow = true
+ }
+ vectors = append(vectors, vectorsFor(rec, ruleID)...)
+ }
+
+ specBytes, err := b.EmitSpec()
+ if err != nil {
+ return Candidate{}, err
+ }
+ return Candidate{
+ Spec: specBytes,
+ Vectors: vectors,
+ FreezeWarning: hasDeny && !hasAllow,
+ RuleCount: len(emitted),
+ }, nil
+}
+
+// ruleBuilder maps a record onto the fluent builder in the closed vocabulary.
+func ruleBuilder(b *il.Builder, rec Record, ruleID, resID string) *il.RuleBuilder {
+ var rb *il.RuleBuilder
+ if rec.Effect == ir.EffectDeny {
+ rb = b.Deny(ruleID)
+ } else {
+ rb = b.Allow(ruleID)
+ }
+ rb = rb.By(rec.Actor).To(rec.Operations...).On(resID)
+ if rec.Requirement != nil {
+ rb = rb.Requiring(*rec.Requirement)
+ }
+ return rb.Because(rec.Reason)
+}
+
+// ruleID builds a unique, readable rule id: effect-resource-recordID. The record
+// id suffix guarantees uniqueness even when two rules touch one resource.
+func ruleID(rec Record, resID string) string {
+ return string(rec.Effect) + "-" + resID + "-" + rec.ID
+}
+
+// vectorsFor generates the conformance vectors for one emitted rule (invariant 5).
+//
+// - A deny rule gets a blocking vector (the cited effect → deny, attributed to
+// this rule) and a scoping vector (a sibling URI this rule must NOT catch →
+// default-deny with no rule id), proving the rule neither under- nor
+// over-reaches its cited scope.
+// - An allow+require rule gets a require vector (no evidence → require) and an
+// allowed vector (with the approval evidence → allow), proving the gate both
+// holds and opens.
+func vectorsFor(rec Record, ruleID string) []scaffold.Vector {
+ op := rec.Operations[0]
+ member := memberURI(rec.ResourceKind, rec.ResourceURI)
+
+ if rec.Effect == ir.EffectDeny {
+ return []scaffold.Vector{
+ {
+ Name: "derived: " + rec.ID + " blocks " + string(op),
+ Request: request(rec.Actor, op, rec.ResourceKind, member),
+ Expect: protocol.OutcomeDeny,
+ ExpectRuleID: ruleID,
+ },
+ {
+ Name: "derived: " + rec.ID + " does not over-reach",
+ Request: request(rec.Actor, op, rec.ResourceKind, scopingURI(rec.ResourceKind)),
+ Expect: protocol.OutcomeDeny, // default-deny, NOT attributed to this rule
+ },
+ }
+ }
+
+ // allow + human_approval
+ approval := ""
+ if rec.Requirement != nil {
+ approval = rec.Requirement.Approval
+ }
+ return []scaffold.Vector{
+ {
+ Name: "derived: " + rec.ID + " requires approval for " + string(op),
+ Request: request(rec.Actor, op, rec.ResourceKind, member),
+ Expect: protocol.OutcomeRequire,
+ ExpectRuleID: ruleID,
+ },
+ {
+ Name: "derived: " + rec.ID + " allows " + string(op) + " with approval",
+ Request: request(rec.Actor, op, rec.ResourceKind, member, evidenceApproval(approval)),
+ Expect: protocol.OutcomeAllow,
+ ExpectRuleID: ruleID,
+ },
+ }
+}
+
+// request builds a protocol.EffectRequest (local copy of scaffold's unexported
+// req helper).
+func request(actor string, op ir.Operation, kind ir.ResourceKind, uri string, ev ...protocol.Evidence) protocol.EffectRequest {
+ return protocol.EffectRequest{
+ Protocol: protocol.EffectRequestProtocol,
+ RunID: "derive",
+ Actor: actor,
+ Operation: op,
+ Resource: protocol.TargetResource{Kind: kind, URI: uri},
+ Evidence: ev,
+ }
+}
+
+func evidenceApproval(id string) protocol.Evidence {
+ return protocol.Evidence{Kind: ir.ReqHumanApproval, Value: id}
+}
+
+// memberURI turns a tree glob into a concrete member for a request; exact
+// resources (files, branches) are used verbatim.
+func memberURI(kind ir.ResourceKind, uri string) string {
+ if kind == ir.KindTree && strings.HasSuffix(uri, "**") {
+ return uri[:len(uri)-2] + "member"
+ }
+ return uri
+}
+
+// scopingURI is a URI outside any declared resource, used to prove a deny rule
+// does not over-reach. It matches nothing, so the engine returns default-deny
+// with an empty rule id.
+func scopingURI(kind ir.ResourceKind) string {
+ if kind == ir.KindBranch {
+ return "repo://branch/__derive_unscoped__"
+ }
+ return "repo://__derive_unscoped__/probe"
+}
+
+// --- resource registry ----------------------------------------------------
+
+type resEntry struct {
+ id, uri string
+ kind ir.ResourceKind
+}
+
+type resReg struct {
+ byURI map[string]resEntry
+ ids map[string]bool
+}
+
+func newResReg() *resReg {
+ return &resReg{byURI: map[string]resEntry{}, ids: map[string]bool{}}
+}
+
+func (r *resReg) add(kind ir.ResourceKind, uri string) {
+ if _, ok := r.byURI[uri]; ok {
+ return
+ }
+ id := uniqueID(r.ids, resourceSlug(uri))
+ r.ids[id] = true
+ r.byURI[uri] = resEntry{id: id, uri: uri, kind: kind}
+}
+
+func (r *resReg) idFor(uri string) string { return r.byURI[uri].id }
+
+// declared returns the resource entries in a stable order (by kind order, then
+// URI) so the emitted policy is deterministic.
+func (r *resReg) declared() []resEntry {
+ out := make([]resEntry, 0, len(r.byURI))
+ for _, e := range r.byURI {
+ out = append(out, e)
+ }
+ kindRank := map[ir.ResourceKind]int{ir.KindFile: 0, ir.KindTree: 1, ir.KindProcess: 2, ir.KindBranch: 3}
+ sort.Slice(out, func(i, j int) bool {
+ if kindRank[out[i].kind] != kindRank[out[j].kind] {
+ return kindRank[out[i].kind] < kindRank[out[j].kind]
+ }
+ return out[i].uri < out[j].uri
+ })
+ return out
+}
+
+// resourceSlug derives a readable resource id from a URI.
+func resourceSlug(uri string) string {
+ s := strings.TrimPrefix(uri, "repo://")
+ s = strings.TrimSuffix(s, "/**")
+ s = strings.TrimSuffix(s, "/")
+ var b strings.Builder
+ prevDash := false
+ for _, c := range strings.ToLower(s) {
+ if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
+ b.WriteRune(c)
+ prevDash = false
+ } else if !prevDash {
+ b.WriteByte('-')
+ prevDash = true
+ }
+ }
+ slug := strings.Trim(b.String(), "-")
+ if slug == "" {
+ return "resource"
+ }
+ return slug
+}
+
+func uniqueID(taken map[string]bool, base string) string {
+ if !taken[base] {
+ return base
+ }
+ for i := 2; ; i++ {
+ candidate := base + "-" + itoa(i)
+ if !taken[candidate] {
+ return candidate
+ }
+ }
+}
+
+func itoa(n int) string {
+ if n == 0 {
+ return "0"
+ }
+ var buf [20]byte
+ i := len(buf)
+ for n > 0 {
+ i--
+ buf[i] = byte('0' + n%10)
+ n /= 10
+ }
+ return string(buf[i:])
+}
diff --git a/derive/classify.go b/derive/classify.go
new file mode 100644
index 0000000..bf3f954
--- /dev/null
+++ b/derive/classify.go
@@ -0,0 +1,83 @@
+package derive
+
+import "strings"
+
+// classify.go is the deterministic V1 classifier: it reads the language of a
+// statement and assigns it a Class. It makes no network call and consults no
+// model. The Classifier interface (schema.go) exists so a later semantic
+// implementation can slot in, but even then ground.go re-validates every proposal
+// against the closed vocabulary and compiler.Compile is the final gate — no
+// classifier output ever becomes authority on its own.
+
+// Marker sets, checked in precedence order. Precedence is deliberately biased
+// toward the *most restrictive safe* reading: an explicit prohibition wins over
+// everything (fail-closed), and advisory language can never be promoted past it.
+var (
+ // A hard prohibition. "should not"/"shouldn't" are included so plain
+ // "should" (advisory) does not swallow them.
+ prohibitionMarkers = []string{
+ "must not", "must never", "never ", "do not ", "don't ", "does not ",
+ "cannot ", "can not ", "may not ", "not allowed", "not permitted",
+ "forbidden", "prohibited", "off-limits", "off limits",
+ "should not", "shouldn't", "no one may", "under no circumstances",
+ }
+ // An explicit human sign-off gate.
+ approvalMarkers = []string{
+ "ask before", "require approval", "requires approval", "require human",
+ "human approval", "approval before", "approval to", "approval from",
+ "get approval", "sign-off", "sign off", "must be approved",
+ "only with approval", "without approval", "needs approval",
+ }
+ // A demand for evidence before an effect.
+ verificationMarkers = []string{
+ "run tests before", "tests must pass", "must pass tests", "before pushing",
+ "ensure tests pass", "must pass ci", "ci must pass", "tests before you",
+ "before you push", "require passing", "only after tests",
+ }
+ // A preference, never authority.
+ advisoryMarkers = []string{
+ "prefer", "preferably", "usually", "consider", "try to", "should ",
+ "recommend", "when possible", "ideally", "avoid ", "it's best",
+ "we like", "tend to",
+ }
+)
+
+func containsAny(haystack string, needles []string) bool {
+ for _, n := range needles {
+ if strings.Contains(haystack, n) {
+ return true
+ }
+ }
+ return false
+}
+
+// deterministicClassifier is the only V1 Classifier. It is pure.
+type deterministicClassifier struct{}
+
+func (deterministicClassifier) Classify(text string) Class {
+ // Pad with spaces so word-boundary-ish markers ("never ", "avoid ") match at
+ // the start/end of the line too.
+ h := " " + strings.ToLower(text) + " "
+ switch {
+ case containsAny(h, prohibitionMarkers):
+ return ClassEnforceableEffect
+ case containsAny(h, approvalMarkers):
+ return ClassHumanDecision
+ case containsAny(h, verificationMarkers):
+ return ClassVerificationRequirement
+ case containsAny(h, advisoryMarkers):
+ return ClassAdvisoryGuidance
+ default:
+ return ClassDomainKnowledge
+ }
+}
+
+// classify resolves a raw statement's class. An adapter's Suggest hint wins (a
+// machine-config adapter knows its own shape); otherwise the deterministic
+// classifier reads the prose.
+func classify(raw RawStatement, c Classifier) Class {
+ if raw.Suggest != "" {
+ return raw.Suggest
+ }
+ return c.Classify(raw.Text)
+}
diff --git a/derive/conflicts.go b/derive/conflicts.go
new file mode 100644
index 0000000..5cac34b
--- /dev/null
+++ b/derive/conflicts.go
@@ -0,0 +1,85 @@
+package derive
+
+import (
+ "github.com/operatorstack/interlock/ir"
+)
+
+// conflicts.go enforces two fail-closed invariants:
+// - Conflicting sources produce a conflict, never an inferred winner (7).
+// - An existing active policy is never silently weakened (6).
+// Both work by demoting affected records to StatusRejected with a reason, so they
+// are recorded transparently in derivation.json but never emitted.
+
+// detectConflicts scans the proposed records for the same (actor, operation,
+// resource URI) governed by opposing effects. When found, BOTH records are
+// rejected — derive does not pick a winner. Mutates records in place.
+func detectConflicts(records []Record) {
+ // key -> effects seen and the record indexes that produced them.
+ type acc struct {
+ effects map[ir.Effect]bool
+ idx []int
+ }
+ groups := map[string]*acc{}
+ for i := range records {
+ r := &records[i]
+ if r.Status != StatusProposed || !r.Class.Emittable() {
+ continue
+ }
+ for _, op := range r.Operations {
+ k := r.Actor + "|" + string(op) + "|" + r.ResourceURI
+ g := groups[k]
+ if g == nil {
+ g = &acc{effects: map[ir.Effect]bool{}}
+ groups[k] = g
+ }
+ g.effects[r.Effect] = true
+ g.idx = append(g.idx, i)
+ }
+ }
+ for _, g := range groups {
+ if len(g.effects) < 2 {
+ continue
+ }
+ for _, i := range g.idx {
+ records[i].Status = StatusRejected
+ records[i].RejectReason = "conflicting sources declare both allow and deny for the same actor/operation/resource; derive does not infer a winner"
+ }
+ }
+}
+
+// checkWeakening rejects any proposed allow that would loosen a rule the existing
+// active policy already denies. Derivation may only add restriction on top of an
+// existing policy, never remove it. Mutates records in place.
+func checkWeakening(records []Record, existing ir.Policy) {
+ // Build the set of (actor, operation, resourceURI) the existing policy denies.
+ denied := map[string]bool{}
+ uriByID := map[string]string{}
+ for _, res := range existing.Resources {
+ uriByID[res.ID] = res.URI
+ }
+ for _, rule := range existing.Rules {
+ if rule.Effect != ir.EffectDeny {
+ continue
+ }
+ uri := uriByID[rule.Resource]
+ for _, op := range rule.Operations {
+ denied[rule.Actor+"|"+string(op)+"|"+uri] = true
+ }
+ }
+ if len(denied) == 0 {
+ return
+ }
+ for i := range records {
+ r := &records[i]
+ if r.Status != StatusProposed || r.Effect != ir.EffectAllow {
+ continue
+ }
+ for _, op := range r.Operations {
+ if denied[r.Actor+"|"+string(op)+"|"+r.ResourceURI] {
+ r.Status = StatusRejected
+ r.RejectReason = "would weaken the existing active policy, which denies this actor/operation/resource; derivation never loosens an existing rule"
+ break
+ }
+ }
+ }
+}
diff --git a/derive/derive.go b/derive/derive.go
new file mode 100644
index 0000000..189d297
--- /dev/null
+++ b/derive/derive.go
@@ -0,0 +1,182 @@
+package derive
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+
+ "github.com/operatorstack/interlock/ir"
+)
+
+// derive.go is the package entrypoint: it runs the full deterministic pipeline
+// (discover → classify → ground → conflicts/weakening → candidate) and renders
+// the output artifacts. The command layer (cmd/interlock/derive.go) only handles
+// flags and file I/O; everything decision-shaped lives here and is pure given the
+// repository bytes, so the whole pipeline is unit- and conformance-testable.
+
+// Output file names. None is "policy.json": derive writes only candidates, never
+// an active policy (invariant 8).
+const (
+ FileCandidatePolicy = "candidate.policy.json"
+ FileCandidateTests = "candidate.tests.jsonl"
+ FileDerivation = "derivation.json"
+ FileQuestions = "QUESTIONS.md"
+ FileReadme = "README.md"
+
+ // activePolicyRel is the existing active policy derive diffs against for the
+ // weakening check — and never writes to.
+ activePolicyRel = ".interlock/policy.json"
+)
+
+// Result is a completed derivation: the typed records and the emittable candidate.
+type Result struct {
+ Derivation Derivation
+ Candidate Candidate
+}
+
+// Derive runs the pipeline over a repository root. from, when non-empty, is the
+// explicit source set; otherwise the default V1 sources are auto-discovered. It
+// reads files but writes nothing — rendering and writing are the caller's job.
+func Derive(root string, from []string) (Result, error) {
+ refs, err := discover(root, from)
+ if err != nil {
+ return Result{}, err
+ }
+
+ var raws []RawStatement
+ explicit := len(from) > 0
+ for _, ref := range refs {
+ content, rerr := os.ReadFile(ref.path)
+ if rerr != nil {
+ if explicit {
+ return Result{}, fmt.Errorf("interlock/derive: reading %s: %w", ref.relPath, rerr)
+ }
+ continue // auto-discovery tolerates a missing/unreadable candidate
+ }
+ for _, s := range ref.parse(ref.relPath, content) {
+ raws = append(raws, s)
+ }
+ }
+
+ records := recordsFrom(raws)
+ detectConflicts(records)
+ if existing, ok := loadActivePolicy(root); ok {
+ checkWeakening(records, existing)
+ }
+
+ d := Derivation{Schema: DerivationSchema, PolicyID: DefaultPolicyID, Records: records}
+ cand, err := buildCandidate(d)
+ if err != nil {
+ return Result{}, err
+ }
+ return Result{Derivation: d, Candidate: cand}, nil
+}
+
+// Rebuild re-assembles the candidate from a (possibly review-updated) Derivation
+// without re-reading the repository. It is the second half of the --review flow:
+// load derivation.json → ApplyAnswers → Rebuild → write. It runs the same
+// buildCandidate as a fresh derive, so a reviewed candidate is byte-identical to
+// one that had been fully grounded from the start.
+func Rebuild(d Derivation) (Result, error) {
+ cand, err := buildCandidate(d)
+ if err != nil {
+ return Result{}, err
+ }
+ return Result{Derivation: d, Candidate: cand}, nil
+}
+
+// DecodeDerivation parses a derivation.json document (fail-closed) for the
+// --review flow.
+func DecodeDerivation(b []byte) (Derivation, error) { return decodeDerivation(b) }
+
+// recordsFrom sorts raw statements into a stable order, then classifies and
+// grounds each into a Record with a deterministic id. Sorting before id
+// assignment is what makes `--from A B` == `--from B A` (invariant 10).
+func recordsFrom(raws []RawStatement) []Record {
+ sort.SliceStable(raws, func(i, j int) bool {
+ a, b := raws[i], raws[j]
+ if a.Path != b.Path {
+ return a.Path < b.Path
+ }
+ if a.LineStart != b.LineStart {
+ return a.LineStart < b.LineStart
+ }
+ if a.LineEnd != b.LineEnd {
+ return a.LineEnd < b.LineEnd
+ }
+ return a.Text < b.Text
+ })
+
+ c := deterministicClassifier{}
+ records := make([]Record, 0, len(raws))
+ for i, raw := range raws {
+ excerpt := cleanExcerpt(raw.Text)
+ if excerpt == "" {
+ continue
+ }
+ rec := Record{
+ ID: "r" + itoa(i+1),
+ Source: makeSource(raw, excerpt),
+ Excerpt: excerpt,
+ Class: classify(raw, c),
+ Strength: raw.Strength,
+ }
+ ground(&rec, raw.Note)
+ records = append(records, rec)
+ }
+ return records
+}
+
+// loadActivePolicy loads the existing canonical active policy for the weakening
+// check. A missing file (the common case) or a non-canonical document yields
+// ok=false — the check simply does not run, and derive never touches the file.
+func loadActivePolicy(root string) (ir.Policy, bool) {
+ b, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(activePolicyRel)))
+ if err != nil {
+ return ir.Policy{}, false
+ }
+ p, err := ir.LoadPolicy(b)
+ if err != nil {
+ return ir.Policy{}, false
+ }
+ return p, true
+}
+
+// Files renders the result's output artifacts as a name→bytes map. The caller
+// writes them atomically (all-or-nothing) so a failure never leaves a partial
+// candidate (invariant 8, failure-state).
+func (r Result) Files() (map[string][]byte, error) {
+ derivationJSON, err := encodeDerivation(r.Derivation)
+ if err != nil {
+ return nil, err
+ }
+ tests, err := renderTests(r.Candidate.Vectors)
+ if err != nil {
+ return nil, err
+ }
+ return map[string][]byte{
+ FileCandidatePolicy: r.Candidate.Spec,
+ FileCandidateTests: tests,
+ FileDerivation: derivationJSON,
+ FileQuestions: renderQuestions(r.Derivation, r.Candidate.FreezeWarning),
+ FileReadme: renderReadme(r.Derivation, r.Candidate),
+ }, nil
+}
+
+// renderTests writes the candidate test vectors as JSONL with a comment header,
+// matching the shape `interlock test` reads (it skips lines starting with '#').
+func renderTests(vectors []Vector) ([]byte, error) {
+ var buf bytes.Buffer
+ buf.WriteString("# derived candidate tests — review, then: interlock test\n")
+ enc := json.NewEncoder(&buf)
+ enc.SetEscapeHTML(false)
+ for _, v := range vectors {
+ if err := enc.Encode(v); err != nil {
+ return nil, err
+ }
+ }
+ return buf.Bytes(), nil
+}
diff --git a/derive/derive_conformance_test.go b/derive/derive_conformance_test.go
new file mode 100644
index 0000000..444100f
--- /dev/null
+++ b/derive/derive_conformance_test.go
@@ -0,0 +1,308 @@
+package derive
+
+// control-law: derivation-proposes-never-enforces
+//
+// Boundary: the transition from advisory repository intent (prose + machine
+// config) to enforceable Interlock authority (a rule in the active canonical
+// policy the engine decides on). The boundary has a checkpoint (derive → a
+// candidate, never active) and a gate (explicit human promotion → compiler.Compile
+// → active).
+//
+// Control law: a derived artifact may only be a candidate — grounded in cited
+// evidence, expressed strictly within the closed V1 vocabulary, neither broader
+// nor narrower than its source. Derivation never activates policy, never
+// manufactures authority from advisory language, and never weakens an existing
+// policy.
+//
+// This suite realizes the five conformance categories (positive, negative,
+// relation, bypass, failure-state) plus determinism/parity, each asserting one or
+// more of the decomposed invariants. It exercises the REAL compiler and engine —
+// no mock authority — so a passing candidate is one the production decision path
+// accepts.
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/operatorstack/interlock/compiler"
+ "github.com/operatorstack/interlock/engine"
+ "github.com/operatorstack/interlock/ir"
+ "github.com/operatorstack/interlock/protocol"
+ "github.com/operatorstack/interlock/spec"
+)
+
+// --- POSITIVE: a grounded prohibition becomes a proven deny rule -------------
+
+func TestConformance_Positive_GroundedProhibition(t *testing.T) {
+ root := writeRepo(t, map[string]string{
+ "AGENTS.md": "# Agent rules\n\n- Never force-push the main branch.\n",
+ })
+ res := mustDerive(t, root, nil)
+
+ rec := findProposed(res.Derivation, func(r Record) bool {
+ return r.Effect == ir.EffectDeny && hasOp(r.Operations, ir.OpForcePush) && r.ResourceURI == "repo://branch/main"
+ })
+ if rec == nil {
+ t.Fatalf("expected a proposed deny on force_push@repo://branch/main, got records: %+v", res.Derivation.Records)
+ }
+ // Invariant 1: no emitted rule without source{path,line,sha256} provenance.
+ if rec.Source.Path == "" || rec.Source.LineStart == 0 || !strings.HasPrefix(rec.Source.SHA256, "sha256:") {
+ t.Fatalf("emitted rule lacks provenance: %+v", rec.Source)
+ }
+ if res.Candidate.RuleCount != 1 {
+ t.Fatalf("want exactly 1 emitted rule, got %d", res.Candidate.RuleCount)
+ }
+ // Invariant 9: the candidate compiles through the real compiler and its
+ // vectors pass the real engine (blocking + scoping).
+ decideVectors(t, res.Candidate)
+}
+
+// --- NEGATIVE: advisory/domain/ambiguous never become authority --------------
+
+func TestConformance_Negative_NoUnfoundedAuthority(t *testing.T) {
+ root := writeRepo(t, map[string]string{
+ "AGENTS.md": strings.Join([]string{
+ "# Agent rules",
+ "",
+ "- Prefer functional components over class components.", // advisory (invariant 3)
+ "- The API uses OAuth 2.0 for authentication.", // domain
+ "- Ask before publishing a release.", // effect, but resource ambiguous (invariant 4)
+ "",
+ }, "\n"),
+ })
+ res := mustDerive(t, root, nil)
+
+ if res.Candidate.RuleCount != 0 {
+ t.Fatalf("no rule should be emitted from advisory/domain/ambiguous text, got %d", res.Candidate.RuleCount)
+ }
+ // Advisory and domain lines must not surface as effect rules at all.
+ for _, r := range res.Derivation.Records {
+ if r.Class == ClassAdvisoryGuidance || r.Class == ClassDomainKnowledge {
+ if r.Status == StatusProposed {
+ t.Fatalf("advisory/domain became proposed: %+v", r)
+ }
+ }
+ }
+ // The ambiguous approval line must be an unresolved question, not a rule.
+ q := findRecord(res.Derivation, func(r Record) bool {
+ return r.Class == ClassHumanDecision && r.Status == StatusUnresolved
+ })
+ if q == nil || q.Question == "" {
+ t.Fatalf("ambiguous approval statement should be an unresolved question, got: %+v", res.Derivation.Records)
+ }
+}
+
+// --- RELATION: request → boundary → decision reaches the real authorities ----
+
+func TestConformance_Relation_EndToEndReachesEngine(t *testing.T) {
+ root := writeRepo(t, map[string]string{
+ "AGENTS.md": "- Do not edit generated files.\n",
+ })
+ res := mustDerive(t, root, nil)
+ if res.Candidate.RuleCount == 0 {
+ t.Fatal("expected a rule from an explicit prohibition")
+ }
+ // Compile the candidate spec through the REAL compiler, then decide a request
+ // that the derived rule must block — proving the artifact reaches production
+ // authority, not a stand-in.
+ pol := compileCandidate(t, res.Candidate)
+ d := engine.Decide(pol, request("agent", ir.OpWrite, ir.KindTree, "repo://generated/client.ts"))
+ if d.Outcome != protocol.OutcomeDeny {
+ t.Fatalf("derived rule did not block a generated-file write: %s", d.Outcome)
+ }
+}
+
+// --- BYPASS: a candidate cannot reach the active table except via the compiler -
+
+func TestConformance_Bypass_NeverWritesActivePolicy(t *testing.T) {
+ root := writeRepo(t, map[string]string{
+ "AGENTS.md": "- Never force-push the main branch.\n",
+ })
+ res := mustDerive(t, root, nil)
+ files, err := res.Files()
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Invariant 8: derive emits candidates only; no artifact is a policy.json.
+ for name := range files {
+ if name == "policy.json" || strings.HasSuffix(name, "/policy.json") {
+ t.Fatalf("derive emitted an active policy file %q", name)
+ }
+ }
+ if _, ok := files[FileCandidatePolicy]; !ok {
+ t.Fatal("candidate policy missing from output")
+ }
+ // The candidate is authoring input (spec.v1), not canonical IR — it only
+ // becomes authority by running through compiler.Compile.
+ if _, err := ir.LoadPolicy(files[FileCandidatePolicy]); err == nil {
+ t.Fatal("candidate.policy.json decoded as canonical IR; it must be spec.v1 that requires compilation")
+ }
+ if _, err := spec.DecodeToSpec(files[FileCandidatePolicy]); err != nil {
+ t.Fatalf("candidate.policy.json is not valid spec.v1: %v", err)
+ }
+}
+
+// --- FAILURE-STATE: rejection/conflict leaves existing authority untouched ----
+
+func TestConformance_FailureState_ConflictRejectsBothNoEmit(t *testing.T) {
+ root := writeRepo(t, map[string]string{
+ "AGENTS.md": strings.Join([]string{
+ "- Never force-push the main branch.",
+ "- Require approval to force-push the main branch.",
+ "",
+ }, "\n"),
+ })
+ res := mustDerive(t, root, nil)
+
+ // Invariant 7: conflicting sources → both rejected, no inferred winner.
+ if res.Candidate.RuleCount != 0 {
+ t.Fatalf("conflict must not emit any rule, got %d", res.Candidate.RuleCount)
+ }
+ rejected := 0
+ for _, r := range res.Derivation.Records {
+ if r.Status == StatusRejected {
+ rejected++
+ if r.RejectReason == "" {
+ t.Fatalf("rejected record missing reason: %+v", r)
+ }
+ }
+ }
+ if rejected < 2 {
+ t.Fatalf("expected both conflicting records rejected, got %d rejected", rejected)
+ }
+}
+
+func TestConformance_FailureState_ExistingPolicyUnchanged(t *testing.T) {
+ // An active policy that denies the agent writing generated files.
+ existing := activeGeneratedPolicy(t)
+ hashBefore, err := existing.Hash()
+ if err != nil {
+ t.Fatal(err)
+ }
+ canon, err := existing.CanonicalBytes()
+ if err != nil {
+ t.Fatal(err)
+ }
+ root := writeRepo(t, map[string]string{
+ "AGENTS.md": "- Never force-push the main branch.\n",
+ ".interlock/policy.json": string(canon),
+ })
+
+ // Derive reads the active policy (for the weakening check) but writes nothing.
+ _ = mustDerive(t, root, nil)
+
+ after := readFile(t, root, ".interlock/policy.json")
+ reloaded, err := ir.LoadPolicy(after)
+ if err != nil {
+ t.Fatal(err)
+ }
+ hashAfter, err := reloaded.Hash()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if hashBefore != hashAfter {
+ t.Fatalf("active policy hash changed: %s -> %s", hashBefore, hashAfter)
+ }
+ if string(after) != string(canon) {
+ t.Fatal("active policy bytes changed during derive")
+ }
+}
+
+// --- DETERMINISM / PARITY -----------------------------------------------------
+
+func TestConformance_Determinism_StableAndOrderIndependent(t *testing.T) {
+ files := map[string]string{
+ "AGENTS.md": "- Never force-push the main branch.\n",
+ "CLAUDE.md": "- Do not edit generated files.\n",
+ }
+ root := writeRepo(t, files)
+
+ // Repeated derive → byte-identical candidate + derivation (invariant 10).
+ a := mustDerive(t, root, nil)
+ b := mustDerive(t, root, nil)
+ if string(a.Candidate.Spec) != string(b.Candidate.Spec) {
+ t.Fatal("candidate spec not deterministic across runs")
+ }
+ da, _ := encodeDerivation(a.Derivation)
+ db, _ := encodeDerivation(b.Derivation)
+ if string(da) != string(db) {
+ t.Fatal("derivation not deterministic across runs")
+ }
+
+ // --from order independence.
+ o1 := mustDerive(t, root, []string{"AGENTS.md", "CLAUDE.md"})
+ o2 := mustDerive(t, root, []string{"CLAUDE.md", "AGENTS.md"})
+ if string(o1.Candidate.Spec) != string(o2.Candidate.Spec) {
+ t.Fatal("candidate spec depends on --from order")
+ }
+}
+
+// --- shared helpers -----------------------------------------------------------
+
+func decideVectors(t *testing.T, cand Candidate) {
+ t.Helper()
+ pol := compileCandidate(t, cand)
+ for _, v := range cand.Vectors {
+ req := v.Request
+ if v.UsePolicyHash {
+ h, err := pol.Hash()
+ if err != nil {
+ t.Fatal(err)
+ }
+ req.ClaimedPolicyHash = h
+ }
+ d := engine.Decide(pol, req)
+ if d.Outcome != v.Expect {
+ t.Fatalf("vector %q: outcome %s, want %s", v.Name, d.Outcome, v.Expect)
+ }
+ if v.ExpectRuleID != "" && d.RuleID != v.ExpectRuleID {
+ t.Fatalf("vector %q: rule %q, want %q", v.Name, d.RuleID, v.ExpectRuleID)
+ }
+ // A scoping vector (empty ExpectRuleID, deny) must be default-deny — proof
+ // the deny rule does not over-reach (invariant 2).
+ if v.ExpectRuleID == "" && v.Expect == protocol.OutcomeDeny && d.RuleID != "" {
+ t.Fatalf("vector %q: scoping request matched rule %q; deny over-reaches", v.Name, d.RuleID)
+ }
+ }
+}
+
+func compileCandidate(t *testing.T, cand Candidate) ir.Policy {
+ t.Helper()
+ s, err := spec.DecodeToSpec(cand.Spec)
+ if err != nil {
+ t.Fatalf("candidate spec.v1 invalid: %v", err)
+ }
+ pol, err := compiler.Compile(s)
+ if err != nil {
+ t.Fatalf("candidate does not compile through the real compiler: %v", err)
+ }
+ return pol
+}
+
+func activeGeneratedPolicy(t *testing.T) ir.Policy {
+ t.Helper()
+ s := spec.Spec{
+ PolicyID: "active.v1",
+ Actors: []spec.Actor{{ID: "agent"}},
+ Resources: []spec.Resource{
+ {ID: "generated", Kind: ir.KindTree, URI: "repo://generated/**"},
+ },
+ Rules: []spec.Rule{
+ {ID: "deny-generated", Effect: ir.EffectDeny, Actor: "agent", Operations: []ir.Operation{ir.OpWrite}, Resource: "generated", Reason: "active policy"},
+ },
+ }
+ pol, err := compiler.Compile(s)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return pol
+}
+
+func hasOp(ops []ir.Operation, want ir.Operation) bool {
+ for _, o := range ops {
+ if o == want {
+ return true
+ }
+ }
+ return false
+}
diff --git a/derive/derive_test.go b/derive/derive_test.go
new file mode 100644
index 0000000..a27fa37
--- /dev/null
+++ b/derive/derive_test.go
@@ -0,0 +1,262 @@
+package derive
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/operatorstack/interlock/ir"
+)
+
+// derive_test.go holds the unit tests for the pure pipeline stages (classify,
+// ground, conflicts, weakening, review) plus the shared test helpers the
+// conformance suite also uses. Everything here runs against in-memory bytes; no
+// stage reaches the network or a model.
+
+// --- classify ---------------------------------------------------------------
+
+func TestClassify_Precedence(t *testing.T) {
+ c := deterministicClassifier{}
+ cases := []struct {
+ text string
+ want Class
+ }{
+ {"Never force-push the main branch.", ClassEnforceableEffect},
+ {"Do not edit generated files.", ClassEnforceableEffect},
+ {"Require approval before publishing.", ClassHumanDecision},
+ {"Run tests before pushing.", ClassVerificationRequirement},
+ {"Prefer functional components over class components.", ClassAdvisoryGuidance},
+ {"The API uses OAuth 2.0 for authentication.", ClassDomainKnowledge},
+ }
+ for _, tc := range cases {
+ if got := c.Classify(tc.text); got != tc.want {
+ t.Errorf("Classify(%q) = %s, want %s", tc.text, got, tc.want)
+ }
+ }
+}
+
+func TestClassify_SuggestOverrides(t *testing.T) {
+ c := deterministicClassifier{}
+ raw := RawStatement{Text: "Never force-push main.", Suggest: ClassUnresolved}
+ if got := classify(raw, c); got != ClassUnresolved {
+ t.Fatalf("Suggest should override classifier, got %s", got)
+ }
+}
+
+// --- ground -----------------------------------------------------------------
+
+func TestGround_Effect_Prohibition(t *testing.T) {
+ rec := Record{Class: ClassEnforceableEffect, Excerpt: "Never force-push the main branch."}
+ ground(&rec, "")
+ if rec.Status != StatusProposed {
+ t.Fatalf("grounded prohibition should be proposed, got %s (%s)", rec.Status, rec.Question)
+ }
+ if rec.Effect != ir.EffectDeny {
+ t.Fatalf("effect = %s, want deny", rec.Effect)
+ }
+ if !hasOp(rec.Operations, ir.OpForcePush) {
+ t.Fatalf("operations = %v, want force_push", rec.Operations)
+ }
+ if rec.ResourceKind != ir.KindBranch || rec.ResourceURI != "repo://branch/main" {
+ t.Fatalf("resource = %s %s, want branch repo://branch/main", rec.ResourceKind, rec.ResourceURI)
+ }
+}
+
+func TestGround_HumanDecision_Grounded(t *testing.T) {
+ rec := Record{Class: ClassHumanDecision, Excerpt: "Require approval to force-push the main branch."}
+ ground(&rec, "")
+ if rec.Status != StatusProposed {
+ t.Fatalf("grounded approval should be proposed, got %s (%s)", rec.Status, rec.Question)
+ }
+ if rec.Effect != ir.EffectAllow {
+ t.Fatalf("effect = %s, want allow", rec.Effect)
+ }
+ if rec.Requirement == nil || rec.Requirement.Kind != ir.ReqHumanApproval {
+ t.Fatalf("requirement = %+v, want human_approval", rec.Requirement)
+ }
+}
+
+func TestGround_HumanDecision_AmbiguousResource(t *testing.T) {
+ rec := Record{Class: ClassHumanDecision, Excerpt: "Ask before publishing a release."}
+ ground(&rec, "")
+ if rec.Status != StatusUnresolved {
+ t.Fatalf("approval with no citable resource should be unresolved, got %s", rec.Status)
+ }
+ if rec.Question == "" {
+ t.Fatal("unresolved record must carry a question")
+ }
+}
+
+func TestGround_Verification_AlwaysUnresolved(t *testing.T) {
+ // v1 never infers a verifier — a verification requirement is always a question.
+ rec := Record{Class: ClassVerificationRequirement, Excerpt: "All changes must pass the test suite."}
+ ground(&rec, "")
+ if rec.Status != StatusUnresolved {
+ t.Fatalf("verification should be unresolved in v1, got %s", rec.Status)
+ }
+}
+
+func TestGround_Advisory_NotEmittable(t *testing.T) {
+ rec := Record{Class: ClassAdvisoryGuidance, Excerpt: "Prefer functional components."}
+ ground(&rec, "")
+ if rec.Status == StatusProposed {
+ t.Fatal("advisory guidance must never be proposed")
+ }
+}
+
+// --- conflicts / weakening --------------------------------------------------
+
+func TestDetectConflicts_RejectsBoth(t *testing.T) {
+ records := []Record{
+ {ID: "r1", Status: StatusProposed, Class: ClassEnforceableEffect, Actor: "agent",
+ Effect: ir.EffectDeny, Operations: []ir.Operation{ir.OpForcePush}, ResourceURI: "repo://branch/main"},
+ {ID: "r2", Status: StatusProposed, Class: ClassHumanDecision, Actor: "agent",
+ Effect: ir.EffectAllow, Operations: []ir.Operation{ir.OpForcePush}, ResourceURI: "repo://branch/main"},
+ }
+ detectConflicts(records)
+ for _, r := range records {
+ if r.Status != StatusRejected || r.RejectReason == "" {
+ t.Fatalf("record %s not rejected on conflict: %+v", r.ID, r)
+ }
+ }
+}
+
+func TestCheckWeakening_RejectsAllowOverExistingDeny(t *testing.T) {
+ existing := activeGeneratedPolicy(t) // denies agent write on repo://generated/**
+ records := []Record{
+ {ID: "r1", Status: StatusProposed, Class: ClassHumanDecision, Actor: "agent",
+ Effect: ir.EffectAllow, Operations: []ir.Operation{ir.OpWrite}, ResourceURI: "repo://generated/**"},
+ }
+ checkWeakening(records, existing)
+ if records[0].Status != StatusRejected || records[0].RejectReason == "" {
+ t.Fatalf("allow over an existing deny must be rejected: %+v", records[0])
+ }
+}
+
+func TestCheckWeakening_LeavesUnrelatedAllow(t *testing.T) {
+ existing := activeGeneratedPolicy(t)
+ records := []Record{
+ {ID: "r1", Status: StatusProposed, Class: ClassHumanDecision, Actor: "agent",
+ Effect: ir.EffectAllow, Operations: []ir.Operation{ir.OpWrite}, ResourceURI: "repo://src/**"},
+ }
+ checkWeakening(records, existing)
+ if records[0].Status != StatusProposed {
+ t.Fatalf("unrelated allow should be untouched, got %s", records[0].Status)
+ }
+}
+
+// --- review -----------------------------------------------------------------
+
+func TestApplyAnswers_GroundsUnresolvedResource(t *testing.T) {
+ root := writeRepo(t, map[string]string{
+ "AGENTS.md": "- Ask before publishing a release.\n",
+ })
+ res := mustDerive(t, root, nil)
+ q := findRecord(res.Derivation, func(r Record) bool { return r.Status == StatusUnresolved })
+ if q == nil {
+ t.Fatal("expected an unresolved approval record")
+ }
+ // Answer with a concrete resource; ApplyAnswers should ground it to proposed.
+ updated := ApplyAnswers(res.Derivation, map[string]string{q.ID: "repo://dist/**"})
+ got := findRecord(updated, func(r Record) bool { return r.ID == q.ID })
+ if got == nil || got.Status != StatusProposed {
+ t.Fatalf("answered record should become proposed, got %+v", got)
+ }
+ // And the rebuilt candidate must now compile + decide cleanly.
+ rebuilt, err := Rebuild(updated)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rebuilt.Candidate.RuleCount != 1 {
+ t.Fatalf("rebuilt candidate should have 1 rule, got %d", rebuilt.Candidate.RuleCount)
+ }
+ decideVectors(t, rebuilt.Candidate)
+}
+
+func TestApplyAnswers_BlankSkips(t *testing.T) {
+ root := writeRepo(t, map[string]string{"AGENTS.md": "- Ask before publishing a release.\n"})
+ res := mustDerive(t, root, nil)
+ q := findRecord(res.Derivation, func(r Record) bool { return r.Status == StatusUnresolved })
+ if q == nil {
+ t.Fatal("expected an unresolved record")
+ }
+ updated := ApplyAnswers(res.Derivation, map[string]string{q.ID: ""})
+ got := findRecord(updated, func(r Record) bool { return r.ID == q.ID })
+ if got == nil || got.Status != StatusUnresolved {
+ t.Fatalf("blank answer should leave record unresolved, got %+v", got)
+ }
+}
+
+// --- report round-trip ------------------------------------------------------
+
+func TestDerivation_EncodeDecodeRoundTrip(t *testing.T) {
+ root := writeRepo(t, map[string]string{"AGENTS.md": "- Never force-push the main branch.\n"})
+ res := mustDerive(t, root, nil)
+ b, err := encodeDerivation(res.Derivation)
+ if err != nil {
+ t.Fatal(err)
+ }
+ back, err := decodeDerivation(b)
+ if err != nil {
+ t.Fatalf("decode failed: %v", err)
+ }
+ if back.Schema != res.Derivation.Schema || len(back.Records) != len(res.Derivation.Records) {
+ t.Fatalf("round-trip mismatch: %+v vs %+v", back, res.Derivation)
+ }
+}
+
+func TestDecodeDerivation_FailsClosedOnUnknownField(t *testing.T) {
+ if _, err := decodeDerivation([]byte(`{"schema":"interlock.derivation.v1","surprise":true}`)); err == nil {
+ t.Fatal("decode should reject unknown fields")
+ }
+}
+
+// --- shared helpers ---------------------------------------------------------
+
+// writeRepo materializes a temp repository from a path→content map and returns
+// its root. Nested paths (e.g. ".interlock/policy.json") are created as needed.
+func writeRepo(t *testing.T, files map[string]string) string {
+ t.Helper()
+ root := t.TempDir()
+ for rel, content := range files {
+ p := filepath.Join(root, filepath.FromSlash(rel))
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return root
+}
+
+func readFile(t *testing.T, root, rel string) []byte {
+ t.Helper()
+ b, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(rel)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return b
+}
+
+func mustDerive(t *testing.T, root string, from []string) Result {
+ t.Helper()
+ res, err := Derive(root, from)
+ if err != nil {
+ t.Fatalf("Derive(%s): %v", root, err)
+ }
+ return res
+}
+
+func findRecord(d Derivation, pred func(Record) bool) *Record {
+ for i := range d.Records {
+ if pred(d.Records[i]) {
+ return &d.Records[i]
+ }
+ }
+ return nil
+}
+
+func findProposed(d Derivation, pred func(Record) bool) *Record {
+ return findRecord(d, func(r Record) bool { return r.Status == StatusProposed && pred(r) })
+}
diff --git a/derive/discover.go b/derive/discover.go
new file mode 100644
index 0000000..4fa2517
--- /dev/null
+++ b/derive/discover.go
@@ -0,0 +1,115 @@
+package derive
+
+import (
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+)
+
+// discover.go resolves which sources to read and dispatches each to its adapter.
+// Discovery is deterministic: the candidate path set is fixed and each glob is
+// sorted, so a given repository always yields the same statements in the same
+// order regardless of filesystem enumeration order.
+
+// adapter parses one source file's bytes into raw statements. Adapters never
+// touch the filesystem themselves (discover reads the bytes) so they are pure and
+// unit-testable.
+type adapter func(path string, content []byte) []RawStatement
+
+// sourceRef is a resolved source: an absolute path and the adapter that parses it.
+type sourceRef struct {
+ path string
+ relPath string
+ parse adapter
+}
+
+// discover returns the ordered source set. When from is non-empty it is the
+// explicit, user-chosen set (each dispatched by filename); otherwise the default
+// V1 set is auto-discovered under root. Order is stable and sorted by relPath.
+func discover(root string, from []string) ([]sourceRef, error) {
+ var refs []sourceRef
+ seen := map[string]bool{}
+
+ add := func(abs string) {
+ abs = filepath.Clean(abs)
+ if seen[abs] {
+ return
+ }
+ info, err := os.Stat(abs)
+ if err != nil || info.IsDir() {
+ return
+ }
+ rel, rerr := filepath.Rel(root, abs)
+ if rerr != nil {
+ rel = abs
+ }
+ refs = append(refs, sourceRef{path: abs, relPath: filepath.ToSlash(rel), parse: adapterFor(abs)})
+ seen[abs] = true
+ }
+
+ if len(from) > 0 {
+ for _, p := range from {
+ if !filepath.IsAbs(p) {
+ p = filepath.Join(root, p)
+ }
+ add(p)
+ }
+ } else {
+ for _, c := range defaultCandidates(root) {
+ add(c)
+ }
+ }
+
+ sort.Slice(refs, func(i, j int) bool { return refs[i].relPath < refs[j].relPath })
+ return refs, nil
+}
+
+// defaultCandidates is the fixed V1 auto-discovery set, in a stable order. Globs
+// are expanded and sorted so enumeration is deterministic.
+func defaultCandidates(root string) []string {
+ var out []string
+ fixed := []string{
+ "AGENTS.md", "CLAUDE.md",
+ "CODEOWNERS", ".github/CODEOWNERS", "docs/CODEOWNERS",
+ "package.json", "Makefile",
+ }
+ for _, f := range fixed {
+ out = append(out, filepath.Join(root, filepath.FromSlash(f)))
+ }
+ globs := []string{
+ ".cursor/rules/*",
+ ".claude/skills/*/SKILL.md",
+ ".github/workflows/*.yml",
+ ".github/workflows/*.yaml",
+ }
+ for _, g := range globs {
+ matches, _ := filepath.Glob(filepath.Join(root, filepath.FromSlash(g)))
+ sort.Strings(matches)
+ out = append(out, matches...)
+ }
+ return out
+}
+
+// adapterFor selects a parser by filename. Unknown files fall back to the prose
+// adapter, which is safe: prose statements without an explicit imperative marker
+// classify as advisory/domain and are dropped, never emitted.
+func adapterFor(path string) adapter {
+ base := filepath.Base(path)
+ lower := strings.ToLower(base)
+ switch {
+ case base == "CODEOWNERS":
+ return parseCodeowners
+ case lower == "package.json":
+ return parsePackageScripts
+ case lower == "makefile":
+ return parseMakefile
+ case strings.HasSuffix(lower, ".yml") || strings.HasSuffix(lower, ".yaml"):
+ if strings.Contains(filepath.ToSlash(path), "/.github/workflows/") {
+ return parseWorkflow
+ }
+ return parseProse
+ default:
+ return parseProse
+ }
+}
diff --git a/derive/evidence.go b/derive/evidence.go
new file mode 100644
index 0000000..72ece37
--- /dev/null
+++ b/derive/evidence.go
@@ -0,0 +1,42 @@
+package derive
+
+import (
+ "strings"
+
+ "github.com/operatorstack/interlock/ir"
+)
+
+// evidence.go turns an adapter's raw line span into a fail-closed Source. The
+// provenance hash is always computed here from the excerpt bytes — never accepted
+// from a caller — mirroring broker/envelope.go: hash-binding, not authenticity.
+
+// cleanExcerpt normalizes a line of source into the stored excerpt: trimmed, with
+// leading Markdown bullet/heading/quote punctuation removed so the same sentence
+// hashes identically whether it appears as prose, a list item, or a heading.
+func cleanExcerpt(text string) string {
+ s := strings.TrimSpace(text)
+ // Strip a leading run of markdown list/heading/quote markers.
+ for {
+ trimmed := strings.TrimLeft(s, "#>-*+ \t")
+ if trimmed == s {
+ break
+ }
+ s = trimmed
+ }
+ // Collapse internal runs of whitespace to single spaces for stable hashing.
+ return strings.Join(strings.Fields(s), " ")
+}
+
+// makeSource binds a raw statement to its exact source bytes. SHA256 is
+// ir.HashBytes(excerpt): tagged "sha256:"+hex, computed internally. There is no
+// timestamp, so the same repository always produces the same provenance (the
+// determinism invariant). This proves the record refers to these exact excerpt
+// bytes at this path — it is NOT a claim that a trusted author wrote them.
+func makeSource(raw RawStatement, excerpt string) Source {
+ return Source{
+ Path: raw.Path,
+ LineStart: raw.LineStart,
+ LineEnd: raw.LineEnd,
+ SHA256: ir.HashBytes([]byte(excerpt)),
+ }
+}
diff --git a/derive/ground.go b/derive/ground.go
new file mode 100644
index 0000000..3d44532
--- /dev/null
+++ b/derive/ground.go
@@ -0,0 +1,317 @@
+package derive
+
+import (
+ "regexp"
+ "strings"
+
+ "github.com/operatorstack/interlock/ir"
+)
+
+// ground.go maps a classified statement onto the closed Interlock vocabulary
+// (ir.Operations / ir.ResourceKinds / ir.RequirementKinds). It is where the "no
+// invented authority" and "no semantic widening" invariants live: a field is
+// filled ONLY when the source text literally cites it. Anything not cited becomes
+// a Missing entry and a Question — never a guessed default. A statement that
+// cannot be fully grounded stays StatusUnresolved and is not emitted.
+
+// defaultActor is the coding agent — the principal these repository instructions
+// address. V1 does not infer other actors; a rule about a different principal is
+// surfaced as a question, not guessed.
+const defaultActor = "agent"
+
+var (
+ repoURIRe = regexp.MustCompile(`repo://[^\s"'` + "`" + `]+`)
+ backtickRe = regexp.MustCompile("`([^`]+)`")
+)
+
+// ground turns a classified record into either a fully-grounded proposal
+// (StatusProposed) or an unresolved question (StatusUnresolved). It mutates rec.
+func ground(rec *Record, note string) {
+ rec.Actor = defaultActor
+ text := rec.Excerpt
+
+ switch rec.Class {
+ case ClassEnforceableEffect:
+ groundEffect(rec, text)
+ case ClassHumanDecision:
+ groundHumanDecision(rec, text)
+ case ClassVerificationRequirement:
+ groundVerification(rec, text, note)
+ default:
+ // Advisory / domain / caller-suggested unresolved: never a rule.
+ if rec.Class == ClassUnresolved {
+ rec.Status = StatusUnresolved
+ if note != "" {
+ rec.Question = note
+ } else {
+ rec.Question = "This statement implies an effect restriction, but its operation and resource are not stated explicitly. Which operation and resource (repo:// URI) should it govern?"
+ }
+ // Record whatever we could detect, to help the reviewer.
+ rec.Operations = detectOperations(text)
+ if kind, uri, ok := detectResource(text, rec.Operations); ok {
+ rec.ResourceKind, rec.ResourceURI = kind, uri
+ }
+ }
+ }
+}
+
+// groundEffect handles an explicit prohibition → a deny rule.
+func groundEffect(rec *Record, text string) {
+ rec.Effect = ir.EffectDeny
+ rec.Operations = detectOperations(text)
+ kind, uri, hasRes := detectResource(text, rec.Operations)
+ rec.ResourceKind, rec.ResourceURI = kind, uri
+ rec.Reason = deriveReason(rec)
+
+ var missing []string
+ if len(rec.Operations) == 0 {
+ missing = append(missing, "operation")
+ }
+ if !hasRes {
+ missing = append(missing, "resource")
+ }
+ finalize(rec, missing,
+ "This prohibition is clear but its "+strings.Join(missing, " and ")+
+ " is not stated in the closed vocabulary. Which "+strings.Join(missing, " and ")+" does it govern?")
+}
+
+// groundHumanDecision handles an explicit approval gate → an allow rule requiring
+// human_approval.
+func groundHumanDecision(rec *Record, text string) {
+ rec.Effect = ir.EffectAllow
+ rec.Operations = detectOperations(text)
+ kind, uri, hasRes := detectResource(text, rec.Operations)
+ rec.ResourceKind, rec.ResourceURI = kind, uri
+
+ var missing []string
+ if len(rec.Operations) == 0 {
+ missing = append(missing, "operation")
+ }
+ if !hasRes {
+ missing = append(missing, "resource")
+ }
+ if len(missing) == 0 {
+ req := ir.Requirement{Kind: ir.ReqHumanApproval, Approval: approvalID(rec.Operations)}
+ rec.Requirement = &req
+ }
+ rec.Reason = deriveReason(rec)
+ finalize(rec, missing,
+ "This requires human approval, but its "+strings.Join(missing, " and ")+
+ " is not stated. Which "+strings.Join(missing, " and ")+" needs approval?")
+}
+
+// groundVerification handles a "requires passing evidence" statement. In V1 the
+// *verifier* (which receipt schema and status count as passing) is never inferred
+// — a receipt requirement without a real verifier would be authority with no
+// meaning (invariant 5) — so these always resolve to a question until --review
+// supplies the schema.
+func groundVerification(rec *Record, text, note string) {
+ rec.Effect = ir.EffectAllow
+ rec.Operations = detectOperations(text)
+ kind, uri, hasRes := detectResource(text, rec.Operations)
+ rec.ResourceKind, rec.ResourceURI = kind, uri
+ rec.Reason = deriveReason(rec)
+
+ missing := []string{"verifier"}
+ if len(rec.Operations) == 0 {
+ missing = append(missing, "operation")
+ }
+ if !hasRes {
+ missing = append(missing, "resource")
+ }
+ q := "This demands evidence before an effect, but no receipt schema/status is named"
+ if note != "" {
+ q = note
+ }
+ rec.Status = StatusUnresolved
+ rec.Missing = missing
+ rec.Question = q + ". Which receipt schema and passing status count as evidence?"
+}
+
+// finalize sets a record's status from its Missing list: empty → proposed,
+// otherwise unresolved with the given question.
+func finalize(rec *Record, missing []string, question string) {
+ if len(missing) == 0 {
+ rec.Status = StatusProposed
+ return
+ }
+ rec.Status = StatusUnresolved
+ rec.Missing = missing
+ rec.Question = question
+}
+
+// detectOperations returns the closed-vocabulary operations literally cited in
+// the text, in canonical (ir.Operations) order, deduplicated.
+func detectOperations(text string) []ir.Operation {
+ h := strings.ToLower(text)
+ set := map[ir.Operation]bool{}
+
+ // VCS: force-push must be checked before push (it contains "push").
+ if strings.Contains(h, "force-push") || strings.Contains(h, "force push") || strings.Contains(h, "force-pushing") {
+ set[ir.OpForcePush] = true
+ } else if strings.Contains(h, "push") {
+ set[ir.OpPush] = true
+ }
+ if containsWord(h, "publish", "release", "deploy", "ship", "publishing", "releasing", "deploying") {
+ set[ir.OpPublish] = true
+ }
+ // Filesystem.
+ if containsWord(h, "edit", "edited", "editing", "modify", "modified", "modifying",
+ "change", "changed", "changing", "write", "writing", "touch", "touching", "alter", "update", "updating") {
+ set[ir.OpWrite] = true
+ }
+ if containsWord(h, "delete", "deleting", "remove", "removing") {
+ set[ir.OpDelete] = true
+ }
+ if containsWord(h, "rename", "renaming", "move", "moving") {
+ set[ir.OpRenameFrom] = true
+ set[ir.OpRenameTo] = true
+ }
+ if containsWord(h, "execute", "executing", "run ", "running") {
+ set[ir.OpExecute] = true
+ }
+ if containsWord(h, "read", "reading") {
+ set[ir.OpRead] = true
+ }
+
+ var out []ir.Operation
+ for _, op := range ir.Operations {
+ if set[op] {
+ out = append(out, op)
+ }
+ }
+ return out
+}
+
+// containsWord reports whether any of the substrings appear in h. (Names it
+// "word" for intent; matching is substring, which is sufficient for the fixed
+// keyword set above.)
+func containsWord(h string, subs ...string) bool {
+ for _, s := range subs {
+ if strings.Contains(h, s) {
+ return true
+ }
+ }
+ return false
+}
+
+// detectResource extracts the resource a statement governs, only from what the
+// text literally cites. Order: an explicit repo:// URI, then a protected branch
+// (main/master) when the ops are branch ops, then a generated-files reference,
+// then a backtick-quoted path. Returns ok=false when nothing is cited — the
+// caller must then raise a question, never assume a scope.
+func detectResource(text string, ops []ir.Operation) (ir.ResourceKind, string, bool) {
+ // 1. Explicit repo:// URI wins — no interpretation needed.
+ if m := repoURIRe.FindString(text); m != "" {
+ return classifyURI(m)
+ }
+
+ h := strings.ToLower(text)
+ branchOps := hasAny(ops, ir.OpPush, ir.OpForcePush)
+
+ // 2. A named protected branch, when the operation is a branch operation.
+ if branchOps {
+ for _, b := range []string{"main", "master", "release", "production"} {
+ if strings.Contains(h, b+" branch") || strings.Contains(h, "branch "+b) ||
+ strings.Contains(h, "the "+b) || strings.Contains(h, " "+b+" ") {
+ return ir.KindBranch, "repo://branch/" + b, true
+ }
+ }
+ }
+
+ // 3. Generated files — a well-known machine-owned tree.
+ if strings.Contains(h, "generated") {
+ return ir.KindTree, "repo://generated/**", true
+ }
+
+ // 4. A backtick-quoted path (e.g. `src/**`, `config/prod.yaml`).
+ for _, m := range backtickRe.FindAllStringSubmatch(text, -1) {
+ p := strings.TrimSpace(m[1])
+ if looksLikePath(p) {
+ return pathToResource(p)
+ }
+ }
+
+ return "", "", false
+}
+
+func hasAny(ops []ir.Operation, want ...ir.Operation) bool {
+ for _, o := range ops {
+ for _, w := range want {
+ if o == w {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// looksLikePath is a conservative filter so an inline code span for a symbol
+// (`someFunc`) is not mistaken for a file path.
+func looksLikePath(p string) bool {
+ if p == "" || strings.Contains(p, " ") {
+ return false
+ }
+ return strings.HasPrefix(p, "repo://") || strings.Contains(p, "/") ||
+ strings.Contains(p, "*") || strings.Contains(p, ".")
+}
+
+// classifyURI infers a resource kind from an explicit repo:// URI without
+// widening it: a branch path is a branch, a glob/dir is a tree, a file with an
+// extension is a file, everything else defaults to a tree (the least surprising
+// containing scope for a bare directory name).
+func classifyURI(uri string) (ir.ResourceKind, string, bool) {
+ uri = strings.TrimRight(uri, ".,;:)")
+ switch {
+ case strings.Contains(uri, "/branch/"):
+ return ir.KindBranch, uri, true
+ case strings.HasSuffix(uri, "/"):
+ return ir.KindTree, uri + "**", true
+ case strings.HasSuffix(uri, "**"):
+ return ir.KindTree, uri, true
+ case hasFileExt(uri):
+ return ir.KindFile, uri, true
+ default:
+ return ir.KindTree, strings.TrimRight(uri, "/") + "/**", true
+ }
+}
+
+// pathToResource converts a repo-relative or repo:// path into a resource.
+func pathToResource(p string) (ir.ResourceKind, string, bool) {
+ if !strings.HasPrefix(p, "repo://") {
+ p = "repo://" + strings.TrimPrefix(strings.TrimPrefix(p, "./"), "/")
+ }
+ return classifyURI(p)
+}
+
+func hasFileExt(uri string) bool {
+ base := uri
+ if i := strings.LastIndex(uri, "/"); i >= 0 {
+ base = uri[i+1:]
+ }
+ dot := strings.LastIndex(base, ".")
+ return dot > 0 && dot < len(base)-1 && !strings.Contains(base, "*")
+}
+
+// approvalID derives a stable approval id from the governed operations, e.g.
+// publish → "approve-publish". It is a label on the requirement, not authority.
+func approvalID(ops []ir.Operation) string {
+ if len(ops) == 0 {
+ return "approve"
+ }
+ seg := string(ops[0])
+ if i := strings.LastIndex(seg, "."); i >= 0 {
+ seg = seg[i+1:]
+ }
+ return "approve-" + seg
+}
+
+// deriveReason renders the human-facing reason carried into the candidate rule.
+// It cites the provenance so a reviewer can trace every rule to its source line.
+func deriveReason(rec *Record) string {
+ verb := "restricted"
+ if rec.Effect == ir.EffectAllow {
+ verb = "gated"
+ }
+ return "derived (" + verb + ") from " + rec.Source.Path + " — review before enforcing"
+}
diff --git a/derive/report.go b/derive/report.go
new file mode 100644
index 0000000..a106baf
--- /dev/null
+++ b/derive/report.go
@@ -0,0 +1,128 @@
+package derive
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "strings"
+)
+
+// report.go renders the human-facing artifacts and decodes derivation.json for
+// --review. The framing is load-bearing: every artifact says "candidate, not
+// enforced" so the control law is visible to the reader, not just the code.
+
+// encodeDerivation renders the typed derivation record as indented JSON with a
+// trailing newline (diff-friendly authoring form, like spec.Encode).
+func encodeDerivation(d Derivation) ([]byte, error) {
+ var buf bytes.Buffer
+ enc := json.NewEncoder(&buf)
+ enc.SetIndent("", " ")
+ enc.SetEscapeHTML(false)
+ if err := enc.Encode(d); err != nil {
+ return nil, fmt.Errorf("interlock/derive: encode derivation: %w", err)
+ }
+ return buf.Bytes(), nil
+}
+
+// decodeDerivation parses derivation.json, rejecting a wrong schema tag and any
+// unknown field (fail closed, mirroring spec.Decode). This is the --review entry.
+func decodeDerivation(data []byte) (Derivation, error) {
+ dec := json.NewDecoder(bytes.NewReader(data))
+ dec.DisallowUnknownFields()
+ var d Derivation
+ if err := dec.Decode(&d); err != nil {
+ return Derivation{}, fmt.Errorf("interlock/derive: decode derivation: %w", err)
+ }
+ if d.Schema != DerivationSchema {
+ return Derivation{}, fmt.Errorf("interlock/derive: unexpected schema %q, want %q", d.Schema, DerivationSchema)
+ }
+ return d, nil
+}
+
+// renderQuestions renders QUESTIONS.md: one entry per unresolved record, plus the
+// baseline-freeze question when the candidate is deny-only. Each entry names the
+// record id so --review (and a human) can map an answer back to it.
+func renderQuestions(d Derivation, freeze bool) []byte {
+ var b strings.Builder
+ b.WriteString("# Unresolved questions\n\n")
+ b.WriteString("These are decisions `interlock derive` could not make for you without guessing. ")
+ b.WriteString("Nothing here is enforced. Answer them with `interlock derive --review` (or edit ")
+ b.WriteString("`candidate.policy.json` directly), then compile the candidate to activate it.\n\n")
+
+ any := false
+ for _, r := range d.Records {
+ if r.Status != StatusUnresolved {
+ continue
+ }
+ any = true
+ fmt.Fprintf(&b, "## %s\n\n", r.ID)
+ fmt.Fprintf(&b, "- Source: `%s:%d`\n", r.Source.Path, r.Source.LineStart)
+ fmt.Fprintf(&b, "- Excerpt: %q\n", r.Excerpt)
+ fmt.Fprintf(&b, "- Classification: `%s`\n", r.Class)
+ if len(r.Missing) > 0 {
+ fmt.Fprintf(&b, "- Missing: %s\n", strings.Join(r.Missing, ", "))
+ }
+ fmt.Fprintf(&b, "- Question: %s\n\n", r.Question)
+ }
+
+ if freeze {
+ any = true
+ b.WriteString("## baseline\n\n")
+ b.WriteString("- Question: The candidate only *denies*. Under Interlock's default-deny, every\n")
+ b.WriteString(" other request is also blocked, which would freeze the repository. What baseline\n")
+ b.WriteString(" should the agent be allowed to do (e.g. read/write `repo://src/**`)? Answering\n")
+ b.WriteString(" adds a grounded allow rule; leaving it unanswered keeps the candidate deny-only.\n\n")
+ }
+
+ if !any {
+ b.WriteString("_None — every classified statement was either grounded into the candidate or is advisory._\n")
+ }
+ return []byte(b.String())
+}
+
+// renderReadme renders README.md: what this directory is, the "not enforced"
+// framing, and the exact promotion command through the real compiler.
+func renderReadme(d Derivation, c Candidate) []byte {
+ var b strings.Builder
+ b.WriteString("# Derived Interlock policy (candidate — NOT enforced)\n\n")
+ b.WriteString("`interlock derive` read your repository's existing instructions and produced the\n")
+ b.WriteString("enforceable rules they appear to imply. **This is a proposal for you to review, not\n")
+ b.WriteString("an active policy.** Nothing here changes what your agent can do until you compile it.\n\n")
+
+ proposed, unresolved, rejected := countByStatus(d)
+ b.WriteString("## What's here\n\n")
+ fmt.Fprintf(&b, "- `candidate.policy.json` — %d proposed rule(s) as `interlock.spec.v1`\n", c.RuleCount)
+ b.WriteString("- `candidate.tests.jsonl` — a blocking + allowed test vector for each rule\n")
+ fmt.Fprintf(&b, "- `derivation.json` — every record with source provenance (%d proposed, %d unresolved, %d rejected)\n", proposed, unresolved, rejected)
+ b.WriteString("- `QUESTIONS.md` — decisions we would not guess\n\n")
+
+ if c.FreezeWarning {
+ b.WriteString("> ⚠️ The candidate only denies. Under default-deny that blocks everything else —\n")
+ b.WriteString("> see the `baseline` question in `QUESTIONS.md` before compiling.\n\n")
+ }
+
+ b.WriteString("## Review, then enforce\n\n")
+ b.WriteString("1. Read `derivation.json` — every rule cites the source line it came from.\n")
+ b.WriteString("2. Answer `QUESTIONS.md`: `interlock derive --review`.\n")
+ b.WriteString("3. Compile the candidate through the real compiler and run its tests:\n\n")
+ b.WriteString(" ```\n")
+ b.WriteString(" interlock compile .interlock/derived/candidate.policy.json -o .interlock/policy.json\n")
+ b.WriteString(" interlock test\n")
+ b.WriteString(" ```\n\n")
+ b.WriteString("Only step 3 activates anything, and only because *you* ran the compiler.\n")
+ return []byte(b.String())
+}
+
+func countByStatus(d Derivation) (proposed, unresolved, rejected int) {
+ for _, r := range d.Records {
+ switch r.Status {
+ case StatusProposed:
+ proposed++
+ case StatusUnresolved:
+ unresolved++
+ case StatusRejected:
+ rejected++
+ }
+ }
+ return
+}
diff --git a/derive/review.go b/derive/review.go
new file mode 100644
index 0000000..3d905a3
--- /dev/null
+++ b/derive/review.go
@@ -0,0 +1,145 @@
+package derive
+
+import (
+ "strings"
+
+ "github.com/operatorstack/interlock/ir"
+)
+
+// review.go implements the pure answer-application step behind `--review`. It has
+// no I/O: the command layer reads derivation.json, collects answers from stdin,
+// calls ApplyAnswers, and re-emits the candidate. Keeping it pure makes it
+// unit- and conformance-testable and deterministic (same answers → same output).
+//
+// ApplyAnswers only ever moves a record unresolved → proposed (or adds a
+// reviewer-authored baseline allow). It never activates policy and never
+// weakens one — promotion is still the separate compile step.
+
+// ApplyAnswers returns a new Derivation with the given answers applied. answers
+// is keyed by record ID (as printed in QUESTIONS.md); the special key "baseline"
+// adds a reviewer-authored allow rule for the deny-only freeze case. An empty or
+// "skip" answer leaves a record unresolved. The input is not mutated.
+func ApplyAnswers(d Derivation, answers map[string]string) Derivation {
+ out := Derivation{Schema: d.Schema, PolicyID: d.PolicyID}
+ out.Records = make([]Record, len(d.Records))
+ copy(out.Records, d.Records)
+
+ for i := range out.Records {
+ r := &out.Records[i]
+ if r.Status != StatusUnresolved {
+ continue
+ }
+ ans := strings.TrimSpace(answers[r.ID])
+ if ans == "" || strings.EqualFold(ans, "skip") {
+ continue
+ }
+ applyAnswer(r, ans)
+ }
+
+ if base := strings.TrimSpace(answers["baseline"]); base != "" && !strings.EqualFold(base, "skip") {
+ if rec, ok := baselineRecord(base); ok {
+ out.Records = append(out.Records, rec)
+ }
+ }
+ return out
+}
+
+// applyAnswer fills a single unresolved record's missing fields from one answer,
+// then re-checks completeness. The answer is interpreted by what is missing:
+// verifier → "schema:status"; resource → a repo:// URI or path; operation → an
+// operation keyword.
+func applyAnswer(r *Record, ans string) {
+ missing := map[string]bool{}
+ for _, m := range r.Missing {
+ missing[m] = true
+ }
+
+ if missing["verifier"] {
+ schema, status := parseVerifier(ans)
+ req := ir.Requirement{Kind: ir.ReqReceiptStatus, Receipt: schema, Status: status}
+ r.Requirement = &req
+ delete(missing, "verifier")
+ // A verifier answer resolves only the verifier; other fields, if still
+ // missing, keep the record unresolved below.
+ } else if missing["resource"] {
+ if kind, uri, ok := pathToResource(ans); ok {
+ r.ResourceKind, r.ResourceURI = kind, uri
+ delete(missing, "resource")
+ }
+ } else if missing["operation"] {
+ if ops := parseOperations(ans); len(ops) > 0 {
+ r.Operations = ops
+ delete(missing, "operation")
+ }
+ }
+
+ // Rebuild the remaining-missing list in a stable order.
+ r.Missing = r.Missing[:0]
+ for _, m := range []string{"verifier", "operation", "resource"} {
+ if missing[m] {
+ r.Missing = append(r.Missing, m)
+ }
+ }
+ if len(r.Missing) > 0 {
+ return
+ }
+
+ // Fully grounded: attach a human_approval requirement if this was a human
+ // decision and none is set, refresh the reason, and mark proposed.
+ if r.Class == ClassHumanDecision && r.Requirement == nil {
+ req := ir.Requirement{Kind: ir.ReqHumanApproval, Approval: approvalID(r.Operations)}
+ r.Requirement = &req
+ }
+ if r.Reason == "" {
+ r.Reason = deriveReason(r)
+ }
+ r.Question = ""
+ r.Status = StatusProposed
+}
+
+// baselineRecord builds the reviewer-authored allow rule for the freeze case. Its
+// provenance is the human answer itself (path "review:baseline"), hash-bound like
+// any other source — authority the reviewer explicitly granted, not invented by
+// derive.
+func baselineRecord(ans string) (Record, bool) {
+ kind, uri, ok := pathToResource(ans)
+ if !ok {
+ return Record{}, false
+ }
+ excerpt := "baseline allow: " + ans
+ rec := Record{
+ ID: "baseline",
+ Source: Source{Path: "review:baseline", SHA256: ir.HashBytes([]byte(excerpt))},
+ Excerpt: excerpt,
+ Class: ClassEnforceableEffect,
+ Strength: StrengthStrong,
+ Status: StatusProposed,
+ Actor: defaultActor,
+ Operations: []ir.Operation{ir.OpRead, ir.OpWrite},
+ Effect: ir.EffectAllow,
+ ResourceKind: kind,
+ ResourceURI: uri,
+ Reason: "baseline access granted by reviewer via --review",
+ }
+ return rec, true
+}
+
+// parseVerifier splits a "schema:status" answer; a bare schema defaults to
+// status "pass".
+func parseVerifier(ans string) (schema, status string) {
+ if i := strings.Index(ans, ":"); i >= 0 {
+ return strings.TrimSpace(ans[:i]), strings.TrimSpace(ans[i+1:])
+ }
+ return ans, "pass"
+}
+
+// parseOperations reads an operation answer: either a full ir.Operation value or
+// a keyword the detector understands (e.g. "publish", "force-push").
+func parseOperations(ans string) []ir.Operation {
+ for _, op := range ir.Operations {
+ if string(op) == ans {
+ return []ir.Operation{op}
+ }
+ }
+ return detectOperations(ans)
+}
diff --git a/derive/schema.go b/derive/schema.go
new file mode 100644
index 0000000..d472a8e
--- /dev/null
+++ b/derive/schema.go
@@ -0,0 +1,163 @@
+// Package derive is Interlock's policy-authoring frontend. It reads the intent a
+// repository already encodes — agent instructions, skills, CODEOWNERS, CI
+// workflows, package scripts, generated-file markers — and produces a *candidate*
+// Interlock policy for a human to review, never an active one.
+//
+// Control law (control-law: derivation-proposes-never-enforces): a derived
+// artifact may only be a candidate. Every emitted rule is grounded in cited
+// repository evidence, expressed strictly within the closed V1 vocabulary
+// (ir.Operations / ir.ResourceKinds / ir.RequirementKinds), and neither broader
+// nor narrower than its source. Derivation never activates policy, never
+// manufactures authority from advisory language, and never weakens an existing
+// policy. Promotion to the active decision table happens only through a separate,
+// explicit human step that re-runs the real compiler (compiler.Compile) and
+// engine (engine.Decide).
+//
+// This package is strictly upstream of the deterministic authorities. It depends
+// on ir/spec/scaffold for vocabulary and emission, but engine/compiler/broker
+// never depend on it: a bug here can at worst produce a bad *draft*, which the
+// human review + compile gate rejects.
+package derive
+
+import "github.com/operatorstack/interlock/ir"
+
+// DerivationSchema tags the derivation.json document.
+const DerivationSchema = "interlock.derivation.v1"
+
+// DefaultPolicyID is the policy_id stamped on a derived candidate. It is
+// deliberately generic: derivation proposes structure, the human names it.
+const DefaultPolicyID = "derived-policy.v1"
+
+// Class is the classification of an extracted statement. Only the first three
+// classes may become candidate policy rules; the rest are recorded (or dropped)
+// but never emitted as authority.
+type Class string
+
+const (
+ // ClassEnforceableEffect is an explicit prohibition or permission on an
+ // effect (e.g. "never force-push main") — maps to an allow/deny rule.
+ ClassEnforceableEffect Class = "enforceable_effect"
+ // ClassVerificationRequirement demands evidence before an effect (e.g. "run
+ // tests before pushing") — maps to an allow rule with a receipt requirement.
+ ClassVerificationRequirement Class = "verification_requirement"
+ // ClassHumanDecision demands human sign-off (e.g. "ask before publishing") —
+ // maps to an allow rule with a human_approval requirement.
+ ClassHumanDecision Class = "human_decision"
+ // ClassAdvisoryGuidance is a preference ("prefer functional components") —
+ // never an Interlock rule.
+ ClassAdvisoryGuidance Class = "advisory_guidance"
+ // ClassDomainKnowledge is a fact ("the API uses OAuth") — never a rule.
+ ClassDomainKnowledge Class = "domain_knowledge"
+ // ClassUnresolved is effect-related but not yet groundable — becomes a
+ // question, never a rule until resolved.
+ ClassUnresolved Class = "unresolved"
+)
+
+// Emittable reports whether a class may become a candidate policy rule.
+func (c Class) Emittable() bool {
+ switch c {
+ case ClassEnforceableEffect, ClassVerificationRequirement, ClassHumanDecision:
+ return true
+ default:
+ return false
+ }
+}
+
+// Status is the lifecycle state of a derivation record.
+type Status string
+
+const (
+ // StatusProposed: fully grounded and emitted into the candidate.
+ StatusProposed Status = "proposed"
+ // StatusUnresolved: effect-related but missing a material decision; carries a
+ // Question and is NOT emitted.
+ StatusUnresolved Status = "unresolved"
+ // StatusRejected: cannot be admitted (conflict, or would weaken an existing
+ // policy). Carries a RejectReason and is NOT emitted.
+ StatusRejected Status = "rejected"
+)
+
+// Strength grades how much authority a source carries. Machine-readable config
+// is strong; aspirational prose is weak. Weak evidence may create a suggestion
+// (a question) but never, on its own, an activated rule.
+type Strength string
+
+const (
+ StrengthStrong Strength = "strong"
+ StrengthWeak Strength = "weak"
+)
+
+// Source is fail-closed provenance that binds a record to the exact source bytes
+// it was derived from. It mirrors broker/envelope.go's conventions: the SHA256 is
+// always computed internally via ir.HashBytes (tagged "sha256:"+hex, never a
+// caller-supplied value), it carries no timestamps (replay-safe), and it is
+// decoded with DisallowUnknownFields (see report.go). Like the broker envelope,
+// this is HASH-BINDING, not authenticity: it proves the record refers to these
+// exact excerpt bytes at this path, not that a trusted author wrote them.
+type Source struct {
+ Path string `json:"path"`
+ LineStart int `json:"line_start"`
+ LineEnd int `json:"line_end"`
+ SHA256 string `json:"sha256"`
+}
+
+// RawStatement is a single unit of intent an adapter extracts from a source. The
+// adapter fills Text, the line span, and Strength; evidence.go computes the
+// provenance hash; classify/ground turn it into a Record.
+//
+// Suggest and Note are the adapter's optional structured hints. A prose adapter
+// leaves them empty and lets classify.go read the language. A machine-config
+// adapter (CODEOWNERS, workflows) that knows the *shape* of what it found but not
+// the operator's intent sets Suggest=ClassUnresolved and a Note explaining the
+// decision the human must make — this is how "ownership" or "a CI check exists"
+// becomes a QUESTIONS.md entry rather than a silently widened rule (invariant 2).
+type RawStatement struct {
+ Path string
+ LineStart int
+ LineEnd int
+ Text string
+ Strength Strength
+ Suggest Class
+ Note string
+}
+
+// Record is one reviewed unit written to derivation.json: the cited source, the
+// classification, and — when emittable and grounded — the proposed rule fields.
+type Record struct {
+ ID string `json:"id"`
+ Source Source `json:"source"`
+ Excerpt string `json:"excerpt"`
+ Class Class `json:"class"`
+ Strength Strength `json:"strength"`
+ Status Status `json:"status"`
+
+ // Proposed rule (populated only for grounded, emittable records).
+ Actor string `json:"actor,omitempty"`
+ Operations []ir.Operation `json:"operations,omitempty"`
+ Effect ir.Effect `json:"effect,omitempty"`
+ ResourceKind ir.ResourceKind `json:"resource_kind,omitempty"`
+ ResourceURI string `json:"resource_uri,omitempty"`
+ Requirement *ir.Requirement `json:"requirement,omitempty"`
+ Reason string `json:"reason,omitempty"`
+
+ // Resolution.
+ Missing []string `json:"missing,omitempty"`
+ Question string `json:"question,omitempty"`
+ RejectReason string `json:"reject_reason,omitempty"`
+}
+
+// Derivation is the typed derivation.json document.
+type Derivation struct {
+ Schema string `json:"schema"`
+ PolicyID string `json:"policy_id"`
+ Records []Record `json:"records"`
+}
+
+// Classifier is the seam for an optional semantic (LLM-assisted) classifier. V1
+// ships only the deterministic implementation (see classify.go) and never makes a
+// network call. Any future model implementation returns a proposal that ground.go
+// still validates against the closed vocabulary — no model output becomes a rule
+// without passing the same deterministic grounding + compiler gate.
+type Classifier interface {
+ Classify(text string) Class
+}