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 (arch-lint enforced).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).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.