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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/adr/0002-policy-frontends-rego-declarative.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ Downstream (engine, findings, harness, docs) a rule is a rule regardless of back
- Whether `assert` is implemented on kyverno-json or cel-go is an implementation detail
hidden behind the wrapper — but the *authored syntax* it implies is not; Spike A must fix
the syntax before Phase 3 freezes contracts.
- The wrapper (`PredicateBackend`, ADR-0011) is what keeps adding the `rego` tier a placement
decision rather than a redesign: where the OPA evaluator lives and how its runtime
capabilities (`http.send`, wall-clock, randomness) stay contained inside the guarded core
tree is settled in ADR-0011's amendments (D-141/D-144). Rego still never touches routing,
effects, or aggregation — only what it computes as a predicate.

## Counterpoints considered

Expand Down
38 changes: 25 additions & 13 deletions docs/adr/0004-plugin-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,25 +29,37 @@ runtime-loaded data (ADR-0002); this ADR is about *imperative* extension points.

**Tiered provider model behind one `FactProvider` / `PermissionProvider` port:**

1. **Built-ins** (in-tree, config-activated): forge group membership (GitLab/GitHub), OIDC/
Keycloak group lookup, LDAP, ownership-file (CODEOWNERS-style) — covering the common cases
with zero plugin code.
1. **Built-ins** (in-tree, config-activated): forge group membership (GitLab/GitHub),
ownership-file (CODEOWNERS-style) — covering the common cases with zero plugin code.
**OIDC/Keycloak/LDAP group lookup did not ship as built-ins and are not planned as
such** (OQ-32/D-147, decided while designing P5-DEM): every token-authenticated IdP
needs a bearer credential on each call, and no provider transport carries one
in-process by design ([ADR-0015 §7](0015-trust-boundaries-merge-integrity.md),
Amendment 2 below) — a credential entering the built-in tier would put it inside
the decision path's trust boundary for a capability tier 2 already covers.
The supported shape is tier 2 (HTTP/exec) behind either a credential-holding broker
documented for adopters, or (planned, D-147) a host-side secret resolver that keeps
the credential out of repo-side, MR-editable config. `docs/architecture/c4-context.md`
states this plainly; treat this row as superseding the built-in claim below.
2. **HTTP / exec provider**: declare an endpoint or executable in config; assent calls it
with a versioned JSON request and expects a versioned JSON response. Any language, no SDK.
3. **gRPC plugins** (`hashicorp/go-plugin`): for providers needing streaming, caching hooks,
or richer lifecycle; subprocess model matches the one-shot CI execution well.
4. **WASM (wazero)** — reserved future tier for sandboxed, hot-loadable providers; recorded as
reversible option, not built in v1.

All tiers implement the same request/response contract; the contract (not the transport) is
the versioned public API.
with a versioned JSON request and expects a versioned JSON response — any language, no SDK,
the tier a third-party adopter is expected to land on first.
3. **gRPC plugins** (`hashicorp/go-plugin`): for providers needing streaming, caching hooks, or
richer lifecycle than one-shot request/response gives; the subprocess model matches the
one-shot CI execution that tier 2 already assumes.
4. **WASM (wazero)** — reserved future tier for sandboxed, hot-loadable providers; recorded as a
reversible option from day one, not built in v1.

All four tiers implement the same request/response contract; the contract (not the transport)
is the versioned public API, so moving a provider from tier 2 to tier 3 changes nothing a
policy author can see.

## Consequences

- Provider results become **facts** in PolicyInput — policies never call providers directly,
keeping evaluation pure/deterministic and trivially testable (facts are fixtures in tests).
- Caching, timeouts, and failure semantics (fail-open vs. fail-closed per provider) must be
spec'd; default is **fail-closed → human review**.
- Caching, timeouts, and failure semantics (fail-open vs. fail-closed per provider) are pinned
down in the amendments below; default is **fail-closed → human review**.

## Counterpoints considered

Expand Down
39 changes: 26 additions & 13 deletions docs/adr/0007-rule-effects-decision-aggregation.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ Severity levels don't capture this — these are **effects** with different forg
different aggregation semantics. Additionally, a scalar **risk score** is wanted so that many
small oddities can add up to "human, please look" even when no single rule blocks.

A predicate is not strictly binary either — a missing fact, a type mismatch, or a cost-limit
hit must still resolve to *something*, never a silent pass. And a bulk change authored as many
small edits must not slip under a per-rule threshold just because each edit fires its own rule
once. The effect table, aggregation order, and scoring rule below hold both properties
together.

## Decision (proposed)

### Effects (per rule, declared in the envelope)
Expand All @@ -29,16 +35,22 @@ small oddities can add up to "human, please look" even when no single rule block
| `score` | contribute risk points (`points: N`) | none (recorded) | via threshold |

A rule = match + predicate + **one effect** (plus optional `points`, allowed alongside any
effect). Findings carry rule id, effect, paths, message, points.
effect, accruing **per firing** rather than per rule — see amendment 2 for the bulk-change
arithmetic this implies) and an optional `onFail:` block (`effect`, `message`, `points`)
applied when that same predicate returns **false**. One predicate serving both outcomes avoids
hand-negated twin rules that drift apart; see amendment 2 for the worked example. Findings
carry rule id, effect, paths, message, points.

### Aggregation (deterministic, order-independent)

1. Any `block` finding → **BLOCK**.
2. Else any unresolved `challenge` → **REVIEW** (threads posted; on the forge the MR merges
only after all threads are resolved *and* re-evaluation passes).
3. Else **coverage check**: every entry in the ChangeSet must be matched by ≥1 `vouch` rule.
Unvouched changes → **REVIEW** with an explicit "uncovered change" finding. Fail-safe by
construction: an empty or non-matching policy set never automerges anything.
3. Else **coverage check**: every entry in the ChangeSet must be matched by ≥1 `vouch` rule
from the routed pack set — packs may combine (union of denies, single-vouch trust; see
amendment 1 for the multi-pack semantics and the `coverage: exclusive` escape). Unvouched
changes → **REVIEW** with an explicit "uncovered change" finding. Fail-safe by construction:
an empty or non-matching policy set never automerges anything.
4. Else **risk check**: `sum(points)` ≤ threshold for the active (environment, change class)
binding (ADR-0008) → **APPROVE** (+ merge); over threshold → **REVIEW**.

Expand All @@ -63,7 +75,9 @@ finding list, per-rule traces, score arithmetic, and the aggregation path taken.
- *"Vouch-coverage is annoying; default-allow with deny rules is less work."* — Default-allow
automerge on config repos is how outages happen; annoyance is the feature.

## Amendment (2026-07-21, adversarial review F6/F7/F10)
## Amendment 1 (2026-07-21, adversarial review F6/F7/F10)

Pinning down the tri-state, multi-pack, and cross-MR properties anticipated above:

**Tri-state predicates (F6).** A predicate evaluates to true / false / **error** (missing
fact, type mismatch, cost-limit hit, undefined). Error is fail-safe by effect: on a `vouch`
Expand All @@ -85,6 +99,8 @@ require serve-mode state and is explicitly out of scope for v1.

## Amendment 2 (2026-07-21, second review P1-6/P1-7)

Arithmetic and a worked example for the `points` and `onFail` primitives declared above:

**Points multiplicity.** The predicate runs once per matched change (ADR-0011 amendment);
`points` accrue **per firing**, not per rule. `vouch` + `points` is therefore the built-in
bulk-change guard: ten vouched partition bumps at `points: 1` against a prod threshold of 4
Expand All @@ -96,11 +112,8 @@ use points sparingly and the docs must state this multiplication explicitly.
set of vouched change paths (`vouch contains path if { … }`); anything not in the set stays
uncovered. No implicit "no violation = vouch".

**`onFail` branch (kills negation pairs).** A rule may declare an `onFail:` block
(`effect`, `message`, `points`) applied to matched changes whose predicate is **false** —
one predicate, both outcomes, no hand-negated twin rules that drift. The shipped
bounded-change example demonstrated the failure this fixes: `vouch` on
`new >= old && new <= quota` left the quota-exceeded case silently uncovered with no
message; with `onFail: {effect: challenge, message: "…exceeds quota…"}` the contributor gets
told. Predicate **error** remains its own case (tri-state, amendment 1): errors never take
the `onFail` branch — they fail safe by effect.
**`onFail` in practice.** The bounded-change worked example shows the shape `onFail` is for:
`vouch` on `new >= old && new <= quota` alone leaves the quota-exceeded case silently
uncovered with no message; with `onFail: {effect: challenge, message: "…exceeds quota…"}` the
contributor gets told. Predicate **error** remains its own case (tri-state, amendment 1):
errors never take the `onFail` branch — they fail safe by effect.
80 changes: 78 additions & 2 deletions docs/adr/0011-core-ports-and-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,14 @@ type Publisher interface {

## Invariants

- `internal/core` + `internal/change` import no port implementations (arch-lint enforced).
- `internal/core` + `internal/change` import 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 later `internal/evaldecode`/`internal/compare`
as those packages join the decision path — Amendment 3). The enforcement mechanism is two
machine-checked gates, not review: golangci-lint `depguard` deny-rules (package-level
imports) and an AST purity walk (`TestCorePurity`, call-level: no `time.Now`,
`os.Getenv`/`os.Environ`, `math/rand`). Amendment 3 records the package list and the
extension of both gates to their final scope.
- `Predicate.Eval` is 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
Expand Down Expand Up @@ -132,7 +139,7 @@ type Publisher interface {
- **Per-change predicate binding (F11):** a rule's predicate is evaluated **once per matched
change**, with `old/new/path/kind/file/entry/oldEntry` bound to that change (scope table in
the ADR-0013 appendix). A `vouch` covers exactly the changes whose predicate returned true;
false or error leaves that change uncovered (tri-state per ADR-0007 amendment). `entry` /
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)
Expand Down Expand Up @@ -171,3 +178,72 @@ deliberately: both sit on decision paths (engine input decode; D-116/D-117 compa
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/policy` included: first-party code in that package still may not call
`time.Now`, `os.Getenv`, or `math/rand`, and `TestCorePurity` keeps checking it. What narrows is
**Amendment 3's separate `net/**` link-deny**, and only for `internal/core/policy`'s dependency
closure: OPA's own use of the clock, randomness, and `http.send` is 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's `net/**` 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 in `cmd/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:**
1. **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.
2. **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.
3. **Transitive purity check** (E11-S04, REQ-E11-S04-03): `internal/core/purity_test.go` and
`.golangci.yml` gain a `go 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 reaches `net`/`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 to
`go.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-nnn` row 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-enforced `net/**` 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.
Loading