diff --git a/cmd/assent/run.go b/cmd/assent/run.go index 8dae01be..4d48577b 100644 --- a/cmd/assent/run.go +++ b/cmd/assent/run.go @@ -522,6 +522,9 @@ func selectBinding(rb *policy.RulesetBinding) (*policy.Binding, error) { // an obligation that does not apply and could APPROVE, so an opaque/empty diff // short-circuits to the fail-safe REVIEW here, matching the skeleton's // failSafe(), never a silent APPROVE. +// - empty require (RVW-S01 / D-184): a binding that declares no required +// obligations makes the obligation layer vacuous, so a decidable subject is +// refused fail-closed rather than APPROVEd on no positive vouch. // // Otherwise the frozen engine decides over the decoded EvaluationInput with // forge-resolved ApprovalEvidence (E4-S06) and the pack phase ceiling. Every @@ -537,6 +540,17 @@ func decide(subject, subjectClass string, cs change.ChangeSet, mp *policy.MergeP case cs.Opaque || len(cs.Changes) == 0: res = undecidableReview(subject) default: + // RVW-S01 (D-184): an empty/absent require[] declares no required + // obligations, so the obligation layer is vacuous — the run would APPROVE a + // governed change no rule positively vouches. Fail CLOSED before any forge + // write (the seam note at internal/core/decision/record.go assigns this duty + // to the CLI wiring; GUIDELINES §Safety-1: every change must be positively + // vouched). Reached only for a non-reserved, decidable subject: a reserved + // `.assent/**` subject BLOCKs above, and an opaque/empty changeset REVIEWs + // above, so neither depends on require[]. + if len(bind.Require) == 0 { + return aggregate.Result{}, fmt.Errorf("ruleset-binding binding (class=%q, environment=%q) declares no required obligations (require is empty) — refusing to arm APPROVE; add at least one obligation to require[] (GUIDELINES §Safety-1, D-184)", bind.Class, bind.Environment) + } in := buildEvaluationInput(cs, mrFrom(info, mrAuthor), bind.Require) if len(facts) > 0 { in.Facts = facts diff --git a/cmd/assent/run_test.go b/cmd/assent/run_test.go index 7b359aa7..589b166e 100644 --- a/cmd/assent/run_test.go +++ b/cmd/assent/run_test.go @@ -641,6 +641,18 @@ const rulesetBindingLabelGuard = `{ ] }` +// rulesetBindingEmptyRequire is schema-valid (require is not required, no +// minItems) but declares no required obligations — the RVW-S01 vacuous shape. The +// pre-guard engine decides APPROVE with zero findings on a change its block rules +// do not fire on; the run-path guard must refuse before any forge write. +const rulesetBindingEmptyRequire = `{ + "apiVersion": "assent.dev/v1alpha1", + "kind": "RulesetBinding", + "bindings": [ + { "class": "topic-registry", "environment": "prod", "packs": ["topic-safety"], "risk": { "threshold": 10 }, "require": [] } + ] +}` + // configOwnerFailOpen configures the controlling `owner` provider failure:open — // a posture ValidateProviderPosture must REJECT (a controlling fact must fail closed). const configOwnerFailOpen = `apiVersion: assent.dev/v1alpha1 @@ -742,6 +754,34 @@ func TestRunApproveArmedMerges(t *testing.T) { } } +// RVW-S01 polarity: an empty-require binding is refused fail-closed, never +// APPROVEd. The partitions increase (12 -> 24) is the exact change +// TestRunApproveArmedMerges shows deciding APPROVE under a non-empty require; with +// require: [] the obligation layer is vacuous and the pre-guard engine would +// APPROVE with zero findings and (armed) approve+merge. The guard must stop the +// run before any forge write and emit no APPROVE record. +func TestRunEmptyRequireNeverApproves(t *testing.T) { + f := newFakeGitLab(t) + f.rulesetBinding = rulesetBindingEmptyRequire + f.baseFile = "partitions: 12\n" + f.headFile = "partitions: 24\n" + + var out bytes.Buffer + code := runRun(runArgs("--arm"), env("tok"), fixedClock(), &out, &out, f.factory()) + if code == 0 { + t.Fatalf("empty require must fail closed (non-zero exit), got 0\n%s", out.String()) + } + if f.approvals != 0 || f.merges != 0 || f.discussionsPosted != 0 { + t.Errorf("empty require must write NOTHING to the forge: approvals=%d merges=%d discussions=%d", f.approvals, f.merges, f.discussionsPosted) + } + if strings.Contains(out.String(), `"decision":"APPROVE"`) { + t.Errorf("empty require must never emit an APPROVE record:\n%s", out.String()) + } + if !strings.Contains(out.String(), "require is empty") { + t.Errorf("refusal must be contributor-readable and name the empty require:\n%s", out.String()) + } +} + // APPROVE with forge probe refusing: same increase → APPROVE decision, but // forge-probed ArmEligible=false → Reconcile refuses (ErrArmingRefused) → ZERO // approve/merge writes. --arm alone cannot override (E4-S06 / D-034). diff --git a/docs/adr/0009-execution-modes.md b/docs/adr/0009-execution-modes.md index bad94564..3c11fc09 100644 --- a/docs/adr/0009-execution-modes.md +++ b/docs/adr/0009-execution-modes.md @@ -53,8 +53,9 @@ Shipping order: `run`/`--dry-run`/`explain` in v1; `serve` in v1.x once the CI p > zero risk."* **There is no zero-risk adoption path via a mode switch.** The only way to run > `assent` without approve/merge writes is to leave one of the forge-probed arming > preconditions unmet; `docs/usage/cli.md` §*How to keep assent advisory* states this, and -> explains why a pack's `spec.phase` must not be used as a substitute (with no `require:` -> obligations declared, an `observe` or `off` ceiling turns a BLOCK into an approve+merge). +> explains why a pack's `spec.phase` must not be used as a substitute (a binding with no +> `require:` obligations declared is refused on the run path, failing closed before any forge +> write, so an `observe` or `off` ceiling can no longer turn a BLOCK into an approve+merge). > > This gap is *why* the CLI reference previously recommended `phase: observe` as the advisory > lever: the sanctioned mechanism was never built, so the docs invented one. Same open options diff --git a/docs/decisions/decisions.md b/docs/decisions/decisions.md index c04c0230..614c7b78 100644 --- a/docs/decisions/decisions.md +++ b/docs/decisions/decisions.md @@ -187,3 +187,4 @@ project/process decisions. | D-181 | 2026-09-10 | **The changelog drift gate tolerates exactly one drift: HEAD is itself a release tag and `CHANGELOG.md` lacks only that tag's section. Closes the lockout window D-180 left open.** Finding (D-180's own review, recorded there as the open F2 proposal): between `git push origin vX.Y.Z` and the stamp commit, any `verify` run that checks out the tagged SHA — a re-run, or the weekly schedule on an unchanged `main` — rendered the new section, found it missing and went red; `release.yaml`'s verify-green tag gate (REQ-AUD-S03-01) requires EVERY `verify` run on the SHA to be green, so one such red locked the tag out of its release job, re-runs and `workflow_dispatch` rebuilds. The only defence was a written rule ("never re-run verify on the tagged SHA; stamp before Monday 06:00 UTC"). **Decision:** `verify-changelog.sh`, after the normal diff fails, collects the `v[0-9]*` tags pointing AT HEAD (never tags further back) and renders the committed form again through `render-changelog.sh` with `--ignore-tags` for exactly those tags — the same bytes the tagged commit was green with before the tag existed. Equal → exit 0 with a notice naming the stamp commit to land (a `::warning::` annotation under `GITHUB_ACTIONS`); otherwise the original diff and red. `render-changelog.sh` forwards extra arguments to git-cliff for this; it stays the one definition of the committed form. **Still red:** a hand edit or a history-re-rendering `cliff.toml` change on the tagged HEAD, an unstamped tag with ANY commit on top (so the stamp must be the next commit on `main`), and an older unstamped tag behind a newer tagged HEAD. **Every other check on the tagged commit follows the same allowance (review F1):** CI runs the whole of `task check` in `release-exitgate` (`hack/audit/exitgate_test.sh`), so a red anywhere in it on the tag SHA locks the tag out exactly as the drift gate did. Measured on a freshly tagged clone, stage by stage, the only other red was `changelog_gate_test.sh` — §1 (newest section = newest tag) and the §10 sandbox baseline, whose overlay commit sat on top of the tag. §1 now reads `verify-changelog.sh`'s own allowance from its output (not a second implementation) and warns instead of failing on that commit; the §10 sandbox drops HEAD tags whose section is missing, starting from the pre-tag state. The stamp therefore cannot be forgotten because the first commit on top of an unstamped tag is red everywhere, not because the tagged commit is. **Empty releases (review N2):** a tag whose commits are all cliff-skipped (e.g. a tooling-only `:memo: chore(release):` patch) renders no section, so there is nothing to stamp; §1 accepts it when `verify-changelog.sh` is plainly green, which means the file is byte-identical to the released-only render of every tag and therefore cannot hide a missing stamp. §10e's overlay commit uses a rendering subject for the same reason — with a skipped one, v98.0.0 was an empty release right after every stamp commit and `main` went red (review N1). **Proof:** §10c at both polarities (tagged HEAD green; hand edit, re-render, commit on top, older unstamped tag behind a tagged HEAD red; a release and its -rc on one commit green with the hint naming the release; an older `v99x0x0` that `v99.0.0` matches as a bare regex red), and §10e runs this whole script on a tagged-but-unstamped clone (green) and with a commit on top (red), and grades the stamp commit and an empty release through §1's function. Six mutants each red the suite: no allowance; an allowance that skips the pre-tag comparison; one keyed on the newest reachable tag instead of HEAD's; no regex escaping; §1 without the exception; the §10 sandbox without the rewind. Amends REQ-E9-S03-03, REQ-AUD-S02-01 and AUD-S02 criterion #2 in their specs; `hack/release/README.md` "Cutting a release" drops the never-re-run and before-Monday rules. | | D-182 | 2026-09-25 | **`mr.labels` is WIRED through the live `assent run` path rather than retired by a lint hard error. The frozen predicate-scope contract keeps its label field, and the fail-open is closed by making the forge fetch honest.** The six-leg review found `mr.labels` schema-carried (`evaluation-input.schema.json`), engine-bound (`aggregate/evaluate.go` binds `labels`) and harness-tested (`internal/adoptertest/mr_test.go`) but never fetched on the live path: `cmd/assent/run.go:581` `mrFrom` built `aggregate.MR` with no `Labels`, and the GitLab adapter's two MR decoders (`GetMR` in `internal/forge/gitlab/gitlab.go` and `mrWithAuthor` in `snapshot.go`) ignored the API's `labels` array. **Consequence, and why it is a safety defect not a missing feature:** every label predicate in production evaluated against an empty list, so a NEGATIVE guard (`!('security-hold' in mr.labels)`) was vacuously true and a `security-hold` MR could APPROVE and merge. **Decision — the alternative is recorded and rejected:** a lint hard error rejecting any `mr.labels` reference plus a `predicate-scope.md` amendment would have made the contract truthful by deleting a capability adopters were already sold; wiring keeps the contract and costs two JSON decodes. **Implementation:** `forge.MRInfo` gains `Labels`; `GetMR` and `mrWithAuthor` decode the string array (absent field → nil, never an error); `Snapshot` surfaces them on `forge.MRHeads.Labels`; `mrFrom` copies `info.Labels` into `aggregate.MR.Labels`. **Proven, not asserted:** a run-path test through the real GitLab adapter over `httptest` (`fakeGitLab`) shows a labelled MR reaching the engine (a label-proven obligation APPROVEs with the label, REVIEWs without it) and the negative-guard polarity failing CLOSED (the guarded label yields BLOCK with zero approve/merge writes). Spec: `openspec/specs/p5-e4-gitlab-forge/spec.md` REQ-E4-S02-05 + REQ-E4-S06-08. Revert: drop `Labels` from `MRInfo`/`MRHeads`, the two decodes and the `mrFrom` copy — reopens the empty-label fail-open for every negative guard. | | D-183 | 2026-09-25 | **The six-review round's first fix slice lands: `assent run` reads every judged byte at the pinned commit SHA, not the mutable branch name (U-04 item 1).** Origin: the 2026-09-25 six-leg review (`data/assent-unify/report.md` §4.1 U-04, §6 lane B1; `data/assent-diff-qfn/report.md` QFN-01) at HEAD `b8123cc`. **The defect, stated precisely:** `orchestrate` pins `info.SourceSHA`/`info.TargetSHA` from `GetMR` (`run.go:177`), and the approval and `merge?sha=` CAS are pinned to those commits — but the six content reads on the live path resolved a mutable branch name (`run.go:203,211,230,249,270` = `info.TargetBranch`; `:274` = `info.SourceBranch`). The invariant the merge leans on, *final head == pin ⇒ final head == what was judged*, is therefore false: a contributor with push rights on the MR branch pushes benign X2 (judged), then force-pushes back to pinned X1 before `Reconcile`'s re-read, the SHA-guard passes (head == pin), and the forge merges X1 — bytes never evaluated. The `DecisionRecord` pins `policySha/sourceSha/targetSha` but no content digest, so the record cannot distinguish the states either; ADR-0015 §2's "Merge is SHA-guarded (no TOCTOU)" heading describes the *branch-moved* guard, not a bytes-equal-to-pin guarantee. **The change:** pass `info.TargetSHA` at `:203`, `:211`, `:230`, `:249`, `:270` and `info.SourceSHA` at `:274`. The GitLab adapter already accepts a commit SHA as `?ref=` (`gitlab.go:474`, the same URL-escaped parameter), so this is one argument at six sites — no adapter API change, no new discriminator, no reordering. Target-side reads are pinned too (judgment call (a)): not strictly required for the source-side attack, but it removes the maintainer-push-window ambiguity on the policy/config/pack loads and leaves one uniform rule rather than an asymmetry a later reader must re-justify. **The tests are FLIPPED, not merely updated (judgment call (b)):** the run-path fake's file router now serves the pinned SHAs and errors on any other ref, and `TestFileAtRef` asserts the SHA it sends — so a regression to `info.TargetBranch`/`info.SourceBranch` reddens the existing suite rather than passing against a fake that still accepts branch names. `run_self_vouch_test.go:70`'s policy-load ref assertion moves from `f.target` to `f.targetTip` for the same reason. **The move-and-restore case is in two places, deliberately (judgment call (c)):** the `internal/forge/conformance` case `sha-guard-source-moved-and-restored` (with a new `Fixture.MoveSourceHead` seam and a `catalog.yaml` row) drives `forge.Reconcile` only, so it proves the CAS **merges once the head is back at the pin** — i.e. the CAS alone cannot catch a move-and-restore, which is exactly why the read must be pinned; the run-path case `TestRunJudgesPinnedSHAWhenBranchMoves` proves the read itself follows the pin (branch points at benign bytes, pin at violating bytes, decision follows the pin). **Strictly stronger pinning, no behaviour change beyond it:** the same bytes are read when the branch has not moved. **Explicitly NOT in this lane:** U-04 items 2–4 (SHA-bound `/repository/compare` enumeration re-fold — shares its mechanism with the D-139 trio, lane B5; the `CI_MERGE_REQUEST_SOURCE_BRANCH_SHA` belt; the additive `headContentSha` pin, lane B9), the `--checkout` tree↔SHA binding (U-08/QFN-04, lane B5), the `forgePort` shape (E10), and the `docs/usage/cli.md` "runs without `--checkout` are not exposed" claim (its enumeration half is item 2). Spec: `openspec/specs/p5-rev1-pinned-sha/spec.md`. **Revert:** restore `info.TargetBranch`/`info.SourceBranch` at the six sites and un-flip the fake routers — which re-opens the move-and-restore merge of un-evaluated bytes on the checkout-less path the docs call safe. | +| D-184 | 2026-09-25 | **RVW-S01 — an empty/absent binding `require:` must never arm APPROVE: the run path refuses it, `assent lint` hard-errors `binding-require-empty`, and this row supersedes D-021's "vacuously covered" clause. The frozen schema's `minItems: 1` is deferred.** **Finding (reproduced on the run path):** a schema-valid `RulesetBinding` binding with `require: []` (or no `require:` key) decides **APPROVE with zero findings** for any governed change its block rules do not fire on. The obligation layer iterates `bind.Require`, so an empty list makes it vacuous; with the forge-probed arming preconditions met the run then approves and merges. The schema accepts it (`require` is not in the binding's `required` list and carries no `minItems`; `schemas/policy/v1alpha1/ruleset-binding.schema.json:52-57`) and lint passed it (`checkObligationCoverage` iterates the same empty list), while two in-repo authorities say the opposite: `GUIDELINES.md` §Safety-1 ("Every change must be positively vouched") and `internal/core/decision/record.go`'s own seam note (S03 review F3: the CLI wiring "must guarantee require is non-empty before an APPROVE is armed"). D-021 added `require` as optional and recorded "absent/empty ⇒ no required obligations (vacuously covered)" — a true statement about the schema's shape that was read as a safety property, and was never one. **Decision:** the invariant wins. (a) **Run path** — `decide`'s decidable, non-reserved arm returns a hard error when the covering binding's `require` is empty; the run exits `1` with a contributor-readable message naming the binding `(class, environment)`, and **zero forge writes** happen (the guard sits before `buildEvaluationInput`, so no record is emitted either). It is deliberately NOT placed in `selectBinding`: that function is shared with `assent compare`, whose whole purpose is to compare permissive candidates, and refusing to compare an unsafe candidate would defeat it. (b) **Lint** — new hard error `binding-require-empty`, located to the binding, with a `good`/`bad` fixture pair registered in the E3-S08 `hardErrorCorpus`. (c) **Reserved-class exemption, stated because it is a real carve-out:** the reserved `assent-policy` meta-class is block-by-default (ADR-0015 §1) and its safety comes from GUARD 1 (any `.assent/**` edit BLOCKs), not from obligation coverage — and it *cannot* carry a non-empty `require` at all, because any `prove` rule in a reserved-bound pack routes the policy class to a vouch/APPROVE-arming disposition the reserved-class check forbids. The lint check therefore skips `class == assent-policy`; the run-path guard still refuses an empty require whenever the SUBJECT is not the reserved class. (d) **Reconciliation** — the schema description's "Absent or empty ⇒ no required obligations (vacuously covered)" and D-021's identical clause are superseded as *safety* statements by this row; D-021 is left byte-unchanged (supersede, don't edit); D-134's ARM-08 run-path consequence ("with no `require:` on the binding it turns a BLOCK into APPROVE+merge") is superseded the same way — the engine still decides APPROVE for an empty `require`, but `decide` (a) refuses before it, so that approve+merge path is gone. The schema's `minItems: 1` is **deferred to the next schema change window**: `schemas/**/*.json` is under the ref-relative freeze guard (D-132) against the released `v0.1.0` tag, which permits exactly one diff (the D-120 `toolDigest` description) and no other, so editing the frozen file in this lane would red `hack/audit/exitgate_test.sh`. Until that window, (a)+(b) are the enforcement. **Not in scope, deliberately:** the unmatched-edit fail-open (a rule that names an obligation but whose `match` selects nothing still marks it covered) shares the `cover()` invariant but is its own register row and is gated on a held captain decision; `assent test` does not arm APPROVE and is unchanged; `assent compare` is unchanged per (a). **Scope note:** `examples/lint-fixtures/{fail-open,reserved-class}/` were touched — `fail-open`'s bindings gained `require: [reviewed]` (the rule already proved it), and `reserved-class/good` is covered by the exemption; both were previously "clean" only because the check did not exist. **Revert:** delete `checkBindingRequireEmpty` and its corpus row, drop the `decide` guard, restore the two `fail-open` bindings, and the vacuous APPROVE returns. | diff --git a/docs/planning/lint-hard-errors.md b/docs/planning/lint-hard-errors.md index 125d0530..b837b556 100644 --- a/docs/planning/lint-hard-errors.md +++ b/docs/planning/lint-hard-errors.md @@ -17,6 +17,7 @@ in a way lint could have caught before any MR triggered evaluation. | Hard error | Triggering condition | Mandated by | | --- | --- | --- | | **Obligation coverage** | A `RulesetBinding`'s `require:` list names an obligation that no rule in the bound packs `prove`s (`prove.obligation`) — a required safety property with zero rules proving it. | ADR-0017 §2 (required obligations replace anonymous vouch coverage); `schemas/policy/v1alpha1/merge-policy.schema.json`'s `prove.obligation` description | +| **Empty binding `require`** | A `RulesetBinding` binding's `require:` list is empty or absent — it declares no required obligations, so the obligation layer is vacuous and the run can APPROVE a governed change no rule positively vouches. The reserved `assent-policy` meta-class is exempt (block-by-default, ADR-0015 §1). | GUIDELINES.md §Safety-1 (every change must be positively vouched); D-184 (supersedes D-021's "vacuously covered" clause); the run path refuses to arm on it (`cmd/assent/run.go`) | | **Reserved-class violation** | An MR that touches `.assent/**` carries a pack rule routing the built-in `assent-policy` meta-class to anything other than `block`/`challenge` — i.e. to `vouch` or to a `prove`/`onFailure` obligation-satisfying outcome — independent of what the rule's predicate evaluates to. | ADR-0015 §1 (policy MRs are `block`-by-default and may only relax to `challenge`, never `vouch`) | | **Fail-open restriction** | A `config.yaml` `providers` entry backing a controlling or authorization fact (e.g. an `entries`-identity, ownership, or approval-eligibility provider) declares `failure: open`. | ADR-0004 amendment; ADR-0017 §6 ("Controlling facts never fail open") | | **Tests-per-rule** | A rule has zero cases exercising it under `assent test --coverage` (directory-form `.assent/tests/**` or inline `cases.yaml`). | ADR-0010 ("`assent lint` fails packs without tests"); `schemas/testfixture/v1alpha1/test-expectation.schema.json` | diff --git a/docs/planning/meta-plan.md b/docs/planning/meta-plan.md index 91225c6b..c56190e4 100644 --- a/docs/planning/meta-plan.md +++ b/docs/planning/meta-plan.md @@ -95,9 +95,8 @@ GitHub adapter; both moved to the deferred tier, so the numbering shifted.) Follow-on epics cut during Phase 5, outside the E1–E9 sequence: **EFE** (`p5-e-fileevents`, whole-file `match.fileEvents`), **PCS** (`p5-pcs-policy-comparison`, full comparison-suite runner), **AUD** -(`p5-aud-audit-remediation`, post-release audit remediation), **REV1** -(`p5-rev1-pinned-sha`, the 2026-09-25 six-review round's first fix slice — read the judged -content at the pinned SHA; lanes B2–B15 are separate specs as they are claimed). +(`p5-aud-audit-remediation`, post-release audit remediation), **RVW** +(`p5-rvw-review-remediation`, six-review fail-open remediation). Deferred tiers keep their own numbers. **E10** GitHub adapter (**unlocked D-140**, spec `p5-e10-github-forge`, governed by ADR-0021) and **E11** Rego backend (**implementation diff --git a/docs/usage/cli.md b/docs/usage/cli.md index 754bcb25..f22fd426 100644 --- a/docs/usage/cli.md +++ b/docs/usage/cli.md @@ -150,16 +150,19 @@ that were withholding approval. Measured on an enforcing rule that produces a BL | `require: [signal]` | `enforce` | BLOCK | | `require: [signal]` | `observe` | REVIEW | | `require: [signal]` | `off` | REVIEW | -| *(no `require:`)* | `enforce` | BLOCK | -| *(no `require:`)* | `observe` | **APPROVE** — approves and merges | -| *(no `require:`)* | `off` | **APPROVE** — approves and merges | +| *(no `require:`)* | any | **refused** — fails closed before any forge write | The saving grace in the top half is the binding's `require:` list: an uncovered required obligation is what degrades the run to REVIEW, because only an `enforce`-phase rule can mark -one covered. `require:` is **optional** in the RulesetBinding schema — absent or empty means -"no required obligations, vacuously covered" — so a binding that has not declared one yet gets -the bottom half. That is the first-pack-rollout case, which is exactly when someone reaches for -`observe`. +one covered. `require:` is **optional** in the RulesetBinding schema, but a binding that omits +it declares no required obligations, so the obligation layer is vacuous — the shape the schema +description once called "vacuously covered". That is **no longer a silent APPROVE**: `assent +run` refuses to arm on an empty `require:` (a hard error, exit `1`, zero forge writes), and +`assent lint` reports it as the `binding-require-empty` hard error. The bottom row is therefore +a broken rollout, not a safe one — the refusal is fail-closed, but it is not a working advisory +mode. A binding that has not declared a `require:` yet is exactly the first-pack-rollout case, +which is when someone reaches for `observe`: declare the obligations before rolling the pack +out. `spec.phase` is also **inert unless you pass `--pack`**: without the flag the ceiling is `enforce` and the manifest is never read. Editing the manifest alone changes nothing, so an diff --git a/docs/usage/walkthrough.md b/docs/usage/walkthrough.md index 5ec11fcb..12f3ae4a 100644 --- a/docs/usage/walkthrough.md +++ b/docs/usage/walkthrough.md @@ -206,7 +206,10 @@ assent compare --suite compare-suite/ Use it the way you would use a backtest for *policy edits*. For first-adoption evidence on live MRs, set the pack's rollout `phase: observe` (ADR-0018) — rules evaluate and land in `findings.observed`, structurally excluded from the decision — and read the emitted -`DecisionRecord`s rather than expecting a scan report. +`DecisionRecord`s rather than expecting a scan report. Declare the binding's `require:` +obligations first: an empty `require:` is refused on the run path, so a binding with none +never reaches this evidence-gathering step — see +[How to keep assent advisory](cli.md#how-to-keep-assent-advisory). ## Step 5 — wire CI (GitLab first) diff --git a/examples/lint-fixtures/binding-require-empty/bad/.assent/bindings.yaml b/examples/lint-fixtures/binding-require-empty/bad/.assent/bindings.yaml new file mode 100644 index 00000000..e49b2e8c --- /dev/null +++ b/examples/lint-fixtures/binding-require-empty/bad/.assent/bindings.yaml @@ -0,0 +1,7 @@ +apiVersion: assent.dev/v1alpha1 +kind: RulesetBinding +bindings: + - class: demo + environment: prod + packs: [p] + risk: { threshold: 10 } diff --git a/examples/lint-fixtures/binding-require-empty/bad/.assent/packs/p/rules/rules.yaml b/examples/lint-fixtures/binding-require-empty/bad/.assent/packs/p/rules/rules.yaml new file mode 100644 index 00000000..14a28c6e --- /dev/null +++ b/examples/lint-fixtures/binding-require-empty/bad/.assent/packs/p/rules/rules.yaml @@ -0,0 +1,17 @@ +apiVersion: assent.dev/v1alpha1 +kind: MergePolicy +metadata: + name: base +spec: + rules: + - name: base-ok + phase: enforce + match: + files: + paths: ["**/*.yaml"] + prove: + obligation: safe + when: 'kind == "modify"' + onFailure: + effect: require-review + code: needs.review diff --git a/examples/lint-fixtures/binding-require-empty/bad/.assent/tests/p/cases.yaml b/examples/lint-fixtures/binding-require-empty/bad/.assent/tests/p/cases.yaml new file mode 100644 index 00000000..84e837b4 --- /dev/null +++ b/examples/lint-fixtures/binding-require-empty/bad/.assent/tests/p/cases.yaml @@ -0,0 +1,2 @@ +cases: + - name: safe diff --git a/examples/lint-fixtures/binding-require-empty/good/.assent/bindings.yaml b/examples/lint-fixtures/binding-require-empty/good/.assent/bindings.yaml new file mode 100644 index 00000000..ee18e5af --- /dev/null +++ b/examples/lint-fixtures/binding-require-empty/good/.assent/bindings.yaml @@ -0,0 +1,8 @@ +apiVersion: assent.dev/v1alpha1 +kind: RulesetBinding +bindings: + - class: demo + environment: prod + packs: [p] + risk: { threshold: 10 } + require: [safe] diff --git a/examples/lint-fixtures/binding-require-empty/good/.assent/packs/p/rules/rules.yaml b/examples/lint-fixtures/binding-require-empty/good/.assent/packs/p/rules/rules.yaml new file mode 100644 index 00000000..14a28c6e --- /dev/null +++ b/examples/lint-fixtures/binding-require-empty/good/.assent/packs/p/rules/rules.yaml @@ -0,0 +1,17 @@ +apiVersion: assent.dev/v1alpha1 +kind: MergePolicy +metadata: + name: base +spec: + rules: + - name: base-ok + phase: enforce + match: + files: + paths: ["**/*.yaml"] + prove: + obligation: safe + when: 'kind == "modify"' + onFailure: + effect: require-review + code: needs.review diff --git a/examples/lint-fixtures/binding-require-empty/good/.assent/tests/p/cases.yaml b/examples/lint-fixtures/binding-require-empty/good/.assent/tests/p/cases.yaml new file mode 100644 index 00000000..84e837b4 --- /dev/null +++ b/examples/lint-fixtures/binding-require-empty/good/.assent/tests/p/cases.yaml @@ -0,0 +1,2 @@ +cases: + - name: safe diff --git a/examples/lint-fixtures/fail-open/bad/.assent/bindings.yaml b/examples/lint-fixtures/fail-open/bad/.assent/bindings.yaml index e49b2e8c..b6cc3527 100644 --- a/examples/lint-fixtures/fail-open/bad/.assent/bindings.yaml +++ b/examples/lint-fixtures/fail-open/bad/.assent/bindings.yaml @@ -5,3 +5,4 @@ bindings: environment: prod packs: [p] risk: { threshold: 10 } + require: [reviewed] diff --git a/examples/lint-fixtures/fail-open/good/.assent/bindings.yaml b/examples/lint-fixtures/fail-open/good/.assent/bindings.yaml index e49b2e8c..b6cc3527 100644 --- a/examples/lint-fixtures/fail-open/good/.assent/bindings.yaml +++ b/examples/lint-fixtures/fail-open/good/.assent/bindings.yaml @@ -5,3 +5,4 @@ bindings: environment: prod packs: [p] risk: { threshold: 10 } + require: [reviewed] diff --git a/hack/lint/workflow_pins_test.sh b/hack/lint/workflow_pins_test.sh index 78e437fd..07c133bd 100755 --- a/hack/lint/workflow_pins_test.sh +++ b/hack/lint/workflow_pins_test.sh @@ -837,7 +837,7 @@ sonar_if_guard_ok() { # case "$v" in "$SONAR_IF_NONPR_ARM"*) : ;; *) - echo " the SonarCloud scan step's if: does not OPEN with the unconditional non-PR arm \"$SONAR_IF_NONPR_ARM…\" — a clause outside the pull_request scope can skip the push-to-main run, which is the analysis of the merged code (D-178): '$v'" >&2 + echo " the SonarCloud scan step's if: does not OPEN with the unconditional non-PR arm \"${SONAR_IF_NONPR_ARM}…\" — a clause outside the pull_request scope can skip the push-to-main run, which is the analysis of the merged code (D-178): '$v'" >&2 rc=1 ;; esac diff --git a/internal/core/decision/record.go b/internal/core/decision/record.go index ed3aa193..eef1fdcc 100644 --- a/internal/core/decision/record.go +++ b/internal/core/decision/record.go @@ -186,10 +186,13 @@ type Report struct { // Build serializes the S03 aggregate outcome + the pins into the DecisionRecord // and its companion PresentationModel. // -// P3 SEAM NOTE (S03 review F3, not fixed here): the aggregator treats an empty -// require as APPROVE-by-design; the CLI wiring (a different lane) must guarantee -// require is non-empty before an APPROVE is armed. This serializer only reports -// whatever decision S03 produced — it does not re-derive or change it. +// P3 SEAM NOTE (S03 review F3): the aggregator treats an empty require as +// APPROVE-by-design; the CLI wiring (a different lane) must guarantee require is +// non-empty before an APPROVE is armed. ENFORCED as of RVW-S01 / D-184: the run +// path refuses a decidable subject whose covering binding has an empty require +// (cmd/assent/run.go's decide), and `assent lint` hard-errors +// `binding-require-empty`. This serializer only reports whatever decision S03 +// produced — it does not re-derive or change it. func Build(res aggregate.Result, pins Pins) (Report, error) { fs := toFindings(res.Findings) diff --git a/internal/lint/coverage.go b/internal/lint/coverage.go index 2bb4beeb..88bf7342 100644 --- a/internal/lint/coverage.go +++ b/internal/lint/coverage.go @@ -25,6 +25,7 @@ import ( "gopkg.in/yaml.v3" + "github.com/PlatformRelay/assent/internal/core/classify" "github.com/PlatformRelay/assent/internal/core/policy" ) @@ -324,6 +325,38 @@ func checkObligationCoverage(m *model, rep *Report) { } } +// checkBindingRequireEmpty is the RVW-S01 hard error: a binding whose require[] +// is empty (or absent) declares no required obligations, so the obligation layer +// is vacuous — the run can APPROVE a governed change no rule positively vouches. +// The frozen v1alpha1 schema still describes this shape as "vacuously covered" +// and carries no minItems; the CLI run path refuses to arm on it +// (cmd/assent/run.go) and this is the authoring-surface half. D-184 supersedes +// the schema/D-021 wording; the minItems: 1 schema change is deferred to the next +// change window (the frozen schemas are under a ref-relative freeze guard, D-132). +func checkBindingRequireEmpty(m *model, rep *Report) { + for _, bb := range m.bindings { + // The reserved assent-policy meta-class is block-by-default (ADR-0015 §1) + // and its safety comes from GUARD 1 (any `.assent/**` edit BLOCKs), not + // from obligation coverage. It also CANNOT carry a non-empty require: any + // `prove` rule in a reserved-bound pack routes the policy class to a + // vouch/APPROVE-arming disposition, which the reserved-class check forbids. + // So the shape is exempt here; the run path still refuses an empty require + // whenever the SUBJECT is not the reserved class (decide's default arm). + if bb.binding.Class == classify.ClassAssentPolicy { + continue + } + if len(bb.binding.Require) > 0 { + continue + } + rep.addError( + CodeBindingRequireEmpty, + Location{File: bb.file, Name: bindingName(bb.binding)}, + fmt.Sprintf("binding (class=%q, environment=%q) declares no required obligations (require is empty) — an empty require makes the obligation layer vacuous and can APPROVE without a positive vouch; add at least one obligation to require[] (GUIDELINES §Safety-1, D-184)", + bb.binding.Class, bb.binding.Environment), + ) + } +} + // provenObligations is the union of prove.obligation values across every rule in // the named packs — the static "proven set" (reused E2-S04 require↔prove mapping). func provenObligations(m *model, packs []string) map[string]bool { diff --git a/internal/lint/coverage_test.go b/internal/lint/coverage_test.go index ff9f204e..7f5ac932 100644 --- a/internal/lint/coverage_test.go +++ b/internal/lint/coverage_test.go @@ -84,6 +84,49 @@ func TestObligationCoverageUncovered(t *testing.T) { } } +// TestBindingRequireEmpty — RVW-S01: a binding whose require[] is empty declares +// no required obligations, so the obligation layer is vacuous; lint emits exactly +// one located binding-require-empty hard error. The non-empty case is asserted +// alongside so the guard is not merely "always errors". +func TestBindingRequireEmpty(t *testing.T) { + empty := Lint([]Source{ + bindingSrc(""), // require: [] — the vacuous shape + ruleSrc("owns", "ownership"), // proves an obligation, but nothing requires it + caseDirSrc("p", "ownership"), // keep tests-per-rule silent + }) + diags := empty.Diagnostics() + if len(diags) != 1 { + t.Fatalf("want exactly 1 diagnostic, got %d: %#v", len(diags), diags) + } + d := diags[0] + if d.Code != CodeBindingRequireEmpty { + t.Errorf("code = %q, want %q", d.Code, CodeBindingRequireEmpty) + } + if d.Severity != SeverityError { + t.Errorf("severity = %q, want %q", d.Severity, SeverityError) + } + if !strings.Contains(d.Location.Name, "kafka-topic") || !strings.Contains(d.Location.Name, "dev") { + t.Errorf("location must name the binding (class, environment), got %q", d.Location.Name) + } + if d.Location.File != ".assent/bindings.yaml" { + t.Errorf("location file = %q, want the binding file", d.Location.File) + } + if !empty.HasErrors() { + t.Error("report must signal a non-zero exit when an error diagnostic is present") + } + + nonEmpty := Lint([]Source{ + bindingSrc("ownership"), + ruleSrc("owns", "ownership"), + caseDirSrc("p", "ownership"), + }) + for _, d := range nonEmpty.Diagnostics() { + if d.Code == CodeBindingRequireEmpty { + t.Errorf("non-empty require must not emit %q, got %+v", CodeBindingRequireEmpty, d) + } + } +} + // TestTolerantIngestionAccumulatesAllDiagnostics — REQ-E3-S01-02 (the critical // test): a pack with TWO distinct defects — a strict-schema violation (bad // onFailure.effect enum) AND an uncovered obligation — reports BOTH in one run. diff --git a/internal/lint/exitgate_test.go b/internal/lint/exitgate_test.go index 55a1a779..6677426e 100644 --- a/internal/lint/exitgate_test.go +++ b/internal/lint/exitgate_test.go @@ -50,6 +50,7 @@ type hardErrorFixture struct { // pins. Adding a hard error to the lint pipeline means adding a fixture pair here. var hardErrorCorpus = []hardErrorFixture{ {code: "obligation-coverage", wantBad: []string{"obligation-coverage"}}, + {code: "binding-require-empty", wantBad: []string{"binding-require-empty"}}, {code: "facts-reference-syntax", wantBad: []string{"facts-reference-syntax"}}, {code: "facts-reference-shape", wantBad: []string{"facts-reference-shape"}}, {code: "reserved-class", wantBad: []string{"reserved-class"}}, diff --git a/internal/lint/lint.go b/internal/lint/lint.go index dd4a9e0a..06c98b73 100644 --- a/internal/lint/lint.go +++ b/internal/lint/lint.go @@ -46,6 +46,10 @@ const SeverityError Severity = "error" const ( // CodeObligationCoverage: a Binding require[] obligation no bound rule proves. CodeObligationCoverage = "obligation-coverage" + // CodeBindingRequireEmpty: a Binding declares no required obligations (require + // is empty or absent) — the obligation layer is vacuous and can APPROVE + // without a positive vouch (GUIDELINES §Safety-1; D-184). + CodeBindingRequireEmpty = "binding-require-empty" // CodeSchemaInvalid: a doc the strict loader rejects (the tolerant-ingestion // bridge — the strict loader's first-error abort captured as one diagnostic). CodeSchemaInvalid = "schema-invalid" @@ -145,6 +149,7 @@ func Lint(sources []Source) *Report { rep := &Report{} model := ingest(sources, rep) checkObligationCoverage(model, rep) + checkBindingRequireEmpty(model, rep) checkFactsReferences(model, rep) checkStructural(model, rep) checkPredicateScope(model, rep) diff --git a/openspec/specs/backlog.md b/openspec/specs/backlog.md index fa7bd8c1..387ecb18 100644 --- a/openspec/specs/backlog.md +++ b/openspec/specs/backlog.md @@ -718,6 +718,21 @@ S02 cannot start until the operator creates the bestpractices.dev project (INBOX | SEC-SC-S01 | Native Go fuzz targets on YAML/JSON/HCL differ (+ CI smoke) | **[autonomous]** | none | **do first** — Scorecard Fuzzing (#3); untrusted-byte crash/fail-open fence | | SEC-SC-S02 | OpenSSF Best Practices passing badge + honest evidence page | **[operator-gated]** | operator creates the bestpractices.dev project | Scorecard CII-Best-Practices (#6); no fake README badge | +## Phase 5 — RVW six-review remediation (2026-09-25) + +Full INVEST stories in [p5-rvw-review-remediation/spec.md](p5-rvw-review-remediation/spec.md). +A six-leg review (adversarial + differential, all at the same HEAD) found a schema-valid +`RulesetBinding` with an empty/absent `require:` decides APPROVE with zero findings — the +obligation layer is vacuous — while `GUIDELINES.md` §Safety-1 and the repo's own `record.go` +seam note promise the opposite. RVW-S01 closes that contradiction: the CLI refuses to arm on an +empty `require`, `assent lint` hard-errors it, and D-184 supersedes the schema/D-021 "vacuously +covered" wording. Sibling review rows are decomposed as they are claimed; the unmatched-edit +fail-open is a separate row gated on a held captain decision. + +| ID | Story | Execution | Depends on | Gate contribution | +| --- | --- | --- | --- | --- | +| RVW-S01 | ⚠️ empty `require:` never arms APPROVE: run-path guard + lint `binding-require-empty` + polarity test + D-184 reconciliation | **[autonomous · engine-adjacent]** | none | **do first** — closes a reproduced APPROVE-not-proven fail-open on the shipped run path | + ## Phase 5 — WG `writes: false` runtime gate (D-145) No spec yet — decompose spec-first (`openspec/` change proposal) before implementation, per diff --git a/openspec/specs/p5-rvw-review-remediation/spec.md b/openspec/specs/p5-rvw-review-remediation/spec.md new file mode 100644 index 00000000..14fad2fe --- /dev/null +++ b/openspec/specs/p5-rvw-review-remediation/spec.md @@ -0,0 +1,95 @@ +# P5-RVW — six-review remediation: empty `require` must never arm APPROVE + +**Epic ID / REQ prefix:** `RVW` / `REQ-RVW-Snn-nn`. Cross-cutting remediation epic (same +naming class as `AUD` / `AUD2` / `PCS` / `SEC-SC`). Does **not** consume E10–E14. + +**Problem**: a schema-valid `RulesetBinding` with an empty or absent `require:` decides +**APPROVE with zero findings** for every governed change its block rules do not fire on. The +obligation layer iterates `bind.Require`, so an empty list makes it vacuous — there is no +positive vouch at all, and the run still arms approve/merge once the forge-probed arming +preconditions are met. The repo already states the invariant it is missing in two places: + +- `GUIDELINES.md` §Safety-1: "an empty, broken, or non-matching policy set never auto-merges + anything. Every change must be positively vouched." +- `internal/core/decision/record.go` P3 seam note (S03 review F3): the CLI wiring "must + guarantee `require` is non-empty before an APPROVE is armed." + +Nothing enforces it. The frozen v1alpha1 schema does not put `require` in the binding's +`required` list and carries no `minItems`, and its description plus D-021 bless the shape as +"Absent or empty ⇒ no required obligations (vacuously covered)". `assent lint` checks nothing +for an empty `require` (`internal/lint/coverage.go`'s obligation-coverage loop iterates the +list, so an empty list checks nothing). `cmd/assent/run.go`'s `selectBinding` checks the +binding *count* only. The result is a gate that stays clean on a document the schema accepts +and lint passes, while silently converting to a rubber stamp. + +**Decision (this epic, D-184):** the invariant wins. An empty `require` is a policy-authoring +defect, and the CLI refuses to arm on it (fail closed, no forge write). The schema description +and D-021's "vacuously covered" clause are reconciled to the invariant by superseding decision +row; the schema's `minItems: 1` is deferred to its next change window, because the frozen +`schemas/**/*.json` artifacts are under a ref-relative freeze guard (D-132) that permits no +edit here. + +**Not in scope:** the unmatched-edit fail-open (a rule that names an obligation but whose +`match` selects nothing still marks it covered) — that is a separate register row and is gated +on a held captain decision; it shares the `cover()` invariant but not this lane's fix. The +`minItems: 1` schema change. Making `assent compare` refuse an empty-require candidate: the +comparison tool exists to compare permissive candidates, so it is deliberately left able to +read one. `assent test` (the adopter harness) does not arm APPROVE and is unchanged. + +**Lanes:** **A** run-path guard + polarity test (`cmd/assent/run.go`, `cmd/assent/run_test.go`) · +**B** lint hard error + fixture pair (`internal/lint`, `examples/lint-fixtures`) · +**C** reconciliation docs/decision row. + +--- + +## RVW-S01 — an empty `require:` never arms APPROVE `[autonomous · engine-adjacent]` + +As an adopter, I want `assent run` to refuse to decide when my binding declares no required +obligations, and `assent lint` to tell me at authoring time, so that a forgotten or deleted +`require:` key cannot silently turn my merge gate into an auto-approver. + +**Depends on:** none. **Do first.** + +Acceptance criteria: + +- Given a `RulesetBinding` whose covering binding has `require: []` (or omits `require:`), when + `assent run` executes against a change its block rules do not fire on, then the run exits + **non-zero**, writes **nothing** to the forge (no approval, no merge, no thread), and prints a + contributor-readable error naming the binding `(class, environment)` and the missing + obligations — it never emits a DecisionRecord with `decision: APPROVE`. +- Given the same empty-require document, when `assent lint` runs, then it emits exactly one + **hard error** `binding-require-empty` located to the binding, and exits non-zero. +- Given a binding with at least one `require:` entry, when either command runs, then the new + guard does not fire (no false positive on the conformant corpus). +- **Adversarial polarity** — the run-path test asserts the negative directly: the empty-require + fixture is one the old code decided `APPROVE` with zero findings; the test fails if an + approval or merge reaches the fake forge, or if the summary contains an `APPROVE` record. +- **Both polarities of the lint fixture** — `examples/lint-fixtures/binding-require-empty/good` + lints clean; `.../bad` emits exactly `binding-require-empty`. The pair is registered in + `internal/lint/exitgate_test.go`'s `hardErrorCorpus`, so deleting the check reds the E3-S08 + gate by name. + +**Definition of done:** run-path guard landed; `binding-require-empty` in the lint pipeline, +the hard-error table, and the E3-S08 fixture corpus; D-184 records the reconciliation and the +deferred `minItems: 1`; `task check` green. + +**Not in scope:** as the epic's *Not in scope* above. + +Requirements: + +- **REQ-RVW-S01-01** *(run-path guard)* — an empty-require binding fails closed before any + forge write. Test: `cmd/assent/run_test.go`; Verify: + `go test ./cmd/assent -run TestRunEmptyRequireNeverApproves -count=1`; Level: L1 +- **REQ-RVW-S01-02** *(lint hard error)* — `assent lint` emits `binding-require-empty` for an + empty/absent `require` and nothing for a non-empty one. Test: + `internal/lint/coverage_test.go`; Verify: + `go test ./internal/lint -run TestBindingRequireEmpty -count=1`; Level: L0 +- **REQ-RVW-S01-03** *(fixture corpus)* — the `good`/`bad` fixture pair is in the E3-S08 + corpus. Test: `internal/lint/exitgate_test.go` + `examples/lint-fixtures/binding-require-empty`; + Verify: `go test ./internal/lint -run TestEveryHardErrorFixtureCaught -count=1`; Level: L0 +- **REQ-RVW-S01-04** *(reconciliation · doc)* — D-184 supersedes the D-021 "vacuously covered" + clause and records the deferred schema `minItems: 1`; `docs/planning/lint-hard-errors.md` + lists the new hard error; `docs/usage/cli.md` no longer presents empty `require` as a live + APPROVE path on the run path. Test: those files; Verify: + `rg 'binding-require-empty' docs/planning/lint-hard-errors.md && rg 'D-184' docs/decisions/decisions.md`; + Level: doc