ADR-0011: Core Go ports and public contracts (draft shapes)¶
| Status | Accepted (partial: Reconcile + schemas-are-API per ADR-0017 §1/§7; P2-E5) |
| Date | 2026-07-21 |
| Deciders | Konrad Heimel |
| Context links | ADR-0003 · ADR-0004 · ADR-0005 · ADR-0007 · ADR-0009 |
Context¶
Contracts before code (meta-plan Phase 3). These sketches exist to get a feeling for the seams and to be attacked in review; exact shapes freeze in Phase 3 with fixtures. Everything here is draft.
Data contracts (serialized, versioned — the real public API)¶
// --- change model (ADR-0003, ADR-0008) ---
type ChangeSet struct {
Files []FileEvent // added | deleted | renamed | modified | opaque
Changes []Change
}
type Change struct {
File string // repo-relative path
Path string // RFC-6901-style pointer within the file
Kind Kind // add | modify | delete
Old, New Value // typed scalars/trees; nil per Kind
Classes []string // set by classifier
Environment string // set by classifier
}
// --- policy input: everything a predicate may see (pure) ---
type PolicyInput struct {
Change *Change // the matched change (change scope)
Changes []Change // all changes in this class slice
Branch BranchState // branch scope only: parsed trees at head SHA (lazy)
Facts map[string]any // provider results, keyed by provider name
MR MergeRequestMeta // author, source/target branch, labels, forge
}
// --- decision (ADR-0007) ---
type Finding struct {
Rule string
Effect Effect // comment | challenge | block | vouch | score
Points int
Paths []string
Message string
}
type Decision struct {
Outcome Outcome // APPROVE | REVIEW | BLOCK
Findings []Finding
Score int
Threshold int
Trace Trace // classes, bindings, per-rule eval — powers `explain`
Pins Pins // head SHA, policy SHA, tool version — replayability (OQ-9)
}
Ports (Go interfaces at the hexagon boundary)¶
// Format adapters (ADR-0003): one per file type, registry-selected by extension.
type FormatAdapter interface {
Match(path string) bool
Parse(data []byte) (Value, error)
}
// Predicate backends (ADR-0002): assert-tree/CEL and rego implement this.
type PredicateBackend interface {
Compile(rule RuleSpec) (Predicate, error) // compile-once, at policy load
}
type Predicate interface {
Eval(in PolicyInput) (Result, error) // pure; no I/O possible by construction
}
// Providers (ADR-0004): permissions are just facts; one port, four transports
// (builtin | http | exec | grpc). Resolved BEFORE evaluation; results become
// PolicyInput.Facts — predicates never call out.
type Provider interface {
Name() string
Resolve(ctx context.Context, q FactQuery) (map[string]any, error)
}
// FactQuery carries MR author, touched classes/paths — enough for a permission
// service to answer "which groups / which owned entries" in one round-trip.
// Forge (ADR-0005): read side.
type Forge interface {
MergeRequest(ctx context.Context, ref MRRef) (MergeRequestMeta, error)
EnsureCheckout(ctx context.Context, ref MRRef, dir string) (BranchInfo, error)
Threads(ctx context.Context, ref MRRef) ([]Thread, error) // resolution state
}
// Publisher (ADR-0007/0009): write side; one method per effect, plus verdict.
// Dry-run/explain swap in a recorder implementation — core can't tell.
type Publisher interface {
Comment(ctx context.Context, f Finding) error
OpenThread(ctx context.Context, f Finding) error // resolvable
Approve(ctx context.Context, d Decision) error
Deny(ctx context.Context, d Decision) error
Merge(ctx context.Context, d Decision) error
}
Invariants¶
internal/core+internal/changeimport no port implementations, and neither does the rest of the pure tree this boundary is designed to cover as it grows (internal/glob,internal/lint,internal/catalogue, and laterinternal/evaldecode/internal/compareas those packages join the decision path — Amendment 3). The enforcement mechanism is two machine-checked gates, not review: golangci-lintdepguarddeny-rules (package-level imports) and an AST purity walk (TestCorePurity, call-level: notime.Now,os.Getenv/os.Environ,math/rand). Amendment 3 records the package list and the extension of both gates to their final scope.Predicate.Evalis pure: facts pre-resolved, branch state pre-parsed (lazy but memoized), no clock, no randomness. This is what makes golden tests and replay trivial.- Every contract change goes through an openspec change proposal; serialized forms carry
apiVersion.
Counterpoints considered¶
- "Providers as two interfaces (PermissionProvider / FactProvider)." — Collapsed into one: a permission check is a fact ("author's groups", "owned entries"). Fewer seams, and the per-company reimplementation story is one interface with four transport options.
- "Let rules call providers on demand." — Rejected: kills purity, caching, and dry-run fidelity; pre-resolution with declared provider deps keeps evaluation a pure function.
Amendment (2026-07-21, adversarial review F3/F9/F11)¶
- SHA-guarded writes (F3):
Approve(ctx, d Decision, sha Pin)andMerge(ctx, d Decision, sha Pin)— adapters must use the forge's compare-and-swap (GitLabmerge?sha=, GitHub mergesha); on mismatch they fail closed. Conformance-suite case, per ADR-0015 §2. - Hermetic pins (F9):
Pinsadditionally records the full resolved fact set and per-provider resolution timestamps. Replay of a historical decision re-uses pinned facts — never re-resolves.scanresolves facts at scan time unless a fact snapshot is supplied; its report must carry afacts: livecaveat flag thatstatssurfaces next to any backtest percentage. - Per-change predicate binding (F11): a rule's predicate is evaluated once per matched
change, with
old/new/path/kind/file/entry/oldEntrybound to that change (scope table in the ADR-0013 appendix). Avouchcovers exactly the changes whose predicate returned true; false or error leaves that change uncovered (tri-state per ADR-0007 amendment 1).entry/oldEntry(containing entry at head/base) are added to the PolicyInput contract.
Amendment 2 (2026-07-21, second review P1-4/P1-5/P2-11)¶
- Content-keyed facts (P1-4):
FactQuerycarries the class-sliced ChangeSet (paths, old/new values, classes, environment) — not just author+paths — andconfig.yamlprovider entries may declare key extractors (JSON-pointer expressions over changed entries, e.g.extract: { costCenter: "/metadata/costCenter" }) whose extracted values arrive in the query. Without this, any "referenced X must exist" provider is unimplementable. - Publisher lifecycle ops (P1-5): the port gains
UpsertComment(idempotent, marker- keyed), andSyncThreads(open new / resolve stale / reopen or re-post when a resolved thread's underlying value changed) driven by the finding-lifecycle state machine in ADR-0012 amendment 2.Comment/OpenThreadalone cannot express idempotent re-runs. - Positions (P2-11):
Changecarries file+line/column spans (ADR-0003 amendment 2) so findings can anchor inline threads;Findinggains an optionalAnchor.
Amendment 3 (2026-08-06, D-123 — boundary enforcement mechanism made true)¶
The first invariant above claimed "arch-lint enforced" while enforcement was in fact manual review plus a purity walk covering only part of the pure tree (audit finding ARCH-01, open across three audits). As of D-123 the invariant reads, and is enforced as:
internal/core/**,internal/change/**,internal/glob,internal/lint,internal/catalogue,internal/evaldecode,internal/compare, andschemas/**import no port implementations (internal/forge/**,internal/render/**,cmd/**) and nonet/**— enforced by golangci-lintdepguarddeny-rules in.golangci.yml(package-level, failstask check/CI verify);- the same tree contains no
time.Now,os.Getenv/os.Environ, ormath/randcall-sites — enforced by theTestCorePurityAST walk ininternal/core/purity_test.go, extended beyond its original directories to../evaldecode,../compare, and../../schemas(call-level; keeps its adversarial self-test proving the guard would fire).
internal/evaldecode and internal/compare are added to the guarded tree
deliberately: both sit on decision paths (engine input decode; D-116/D-117 compare
gates) and inherit the hard rule that nothing probabilistic, wall-clock- or
randomness-dependent may live there. "arch-lint enforced" elsewhere in this ADR should
be read as "depguard + purity-walk enforced" per this amendment.
Amendment 4 (2026-08-16, D-144 — Rego/OPA capability boundary for internal/core/policy)¶
E11 (ADR-0002 v2's Rego/OPA complex-rule backend) adopts github.com/open-policy-agent/opa/rego
inside internal/core/policy. OPA ships an http.send Rego builtin (plus its own clock and
randomness use via time.now_ns/rand.intn), so the package's dependency closure reaches
net/http even though the file itself imports nothing but opa/rego. Neither of Amendment 3's
enforcement mechanisms catches this: .golangci.yml's pure-tree depguard denies net/** only
over each file's own direct imports, and internal/core/purity_test.go's AST walk flags only
call-sites the file itself writes. A file importing opa/rego passes both gates green while
quietly linking the network stack transitively (verified during E11 design, D-141). This
amendment records the operator's resolution of that gap (D-144).
- What narrows, precisely. AGENTS.md hard rule 7's own text — no LLM calls, no wall-clock or
randomness dependence — is unchanged and still call-level-enforced everywhere in the guarded
tree,
internal/core/policyincluded: first-party code in that package still may not calltime.Now,os.Getenv, ormath/rand, andTestCorePuritykeeps checking it. What narrows is Amendment 3's separatenet/**link-deny, and only forinternal/core/policy's dependency closure: OPA's own use of the clock, randomness, andhttp.sendis no longer absent from the package's link graph (structural, greppable) — it is present but made uncallable from policy (behavioural, resting on a capability configuration). Read Amendment 3'snet/**invariant, for this one package only, as capability-enforced rather than link-enforced; it stays link-enforced, unamended, everywhere else in the guarded tree listed there. - Why this package, not an injected boundary. The evaluator lives in
internal/core/policy, not behind a port interface implemented incmd/assent(rejected option (d2)). Moving it out would keep the tree formally OPA-free, but it would place live decision-path evaluation entirely outside every rule-7 guard — a weaker guarantee dressed as a stronger one. (d1) keeps the evaluator inside the guarded, tested, reviewed tree and states the narrower guarantee honestly instead of hiding it behind a boundary that looks stricter and enforces nothing. ((d3), dropping OPA outright, was rejected as strictly worse than either.) - What compensates, together:
- Capability sandbox (E11-S04, REQ-E11-S04-01): the OPA runtime is configured with a
deny-by-default capability set —
http.send,net.*,opa.runtime,time.*,rand.*, and any other I/O builtin are absent from what a compiled module may call; a module referencing one fails to compile, not at runtime. - Golden allowlist (E11-S04, REQ-E11-S04-02): the effective allowed-builtin set is pinned against a committed golden file, so an OPA upgrade that introduces new builtins cannot widen what policy can call without a deliberate, reviewed diff.
- Transitive purity check (E11-S04, REQ-E11-S04-03):
internal/core/purity_test.goand.golangci.ymlgain ago list -deps-based assertion over the guarded tree's transitive closure — not just direct imports — allowlisting exactly the OPA import path and failing on any other dependency that reachesnet/net/http. This closes the non-transitivity gap itself, independent of Rego, and is a strict improvement over today's gates regardless of how (d) had resolved. Sequencing note: because E11-S03 is the story that adds OPA togo.mod, and both purity gates are non-transitive today, S03 would land green under the old gates even though it is the story that effects this narrowing — the transitive check is part of S04's guard work but must be in place before S03's dependency lands, not after, or S03 merges the very gap this amendment closes. - This amendment is itself the deliverable REQ-E11-S04-04 requires (an ADR amendment plus a
D-nnnrow landing before E11-S05); it fulfils that requirement rather than merely describing it. Item 3 above is REQ-E11-S04-03, not REQ-E11-S04-04 — the D-144 decision-log row cites REQ-E11-S04-04 for the transitive check, which is loose; the spec text names REQ-E11-S04-03 for that work. - Both the sandbox and the transitive purity check are engine-grade, security-relevant changes to the decision-path boundary and land under maintainer LGTM, per E11-S04's own tag and GOVERNANCE.
- What this does not authorize. This amendment is scoped to the OPA/Rego evaluator inside
internal/core/policy. It does not relax rule 7's own text anywhere. It does not relax Amendment 3's structural, link-enforcednet/**guarantee for any other package in the guarded tree (internal/core/**elsewhere,internal/change/**,internal/glob,internal/lint,internal/catalogue,internal/evaldecode,internal/compare,schemas/**) — a second transitively-networked dependency anywhere in that tree is still a purity-gate failure, not a precedent this amendment sets. It does not authorize a wall-clock evaluation timeout as a substitute safeguard; the machine-independent evaluation budget that bounds Rego execution is a separate requirement (E11-S06) and is not re-litigated here.
See D-144 (docs/decisions/decisions.md) for the full evidentiary trail — including the (d1)
vs (d2) vs (d3) tradeoff in full and the supply-chain question it does not settle — and D-141 for
the judgment call this amendment resolves.