Skip to content

fix(cmd): refuse to arm APPROVE on an empty binding require - #150

Merged
konih merged 5 commits into
mainfrom
fm/assent-b3-empty-require
Sep 26, 2026
Merged

konih merged 5 commits into
mainfrom
fm/assent-b3-empty-require

Conversation

@konih

@konih konih commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Intent

Assent's run path arms APPROVE with zero findings on a schema-valid RulesetBinding whose require: is empty or absent. The schema does not require require and sets no minItems (schemas/policy/v1alpha1/ruleset-binding.schema.json:52-57); the obligation layer starts at APPROVE and iterates bind.Require, so an empty list is vacuous — any governed change the block rules do not fire on is approved with no positive vouch, and assent lint does not catch it. The repo's own seam note assigns the CLI the duty to guarantee require is non-empty before an APPROVE is armed (internal/core/decision/record.go:188-192) and GUIDELINES.md §Safety-1 says every change must be positively vouched, yet the schema description and D-021 bless the shape as vacuously covered. Add the guard so this can never arm APPROVE: fail closed on an empty require on the run path, surface it as a lint hard error, pin the polarity with a test that an empty require never APPROVEs, and reconcile the D-021/schema vacuously covered wording in this lane (the schema minItems change is deferred to its next change window).

What Changed

  • The run path now fails closed when the covering RulesetBinding has an empty or absent require[]: decide returns a hard error naming the binding (class, environment) before buildEvaluationInput, so no APPROVE record is built and zero forge writes occur. The reserved assent-policy class is unaffected (any .assent/** edit already BLOCKs).
  • assent lint gains the binding-require-empty hard error, located to the binding and exempting the reserved meta-class, with a good/bad fixture pair added to the hardErrorCorpus and the fail-open fixtures updated to declare require: [reviewed].
  • Added polarity tests: a run-path test asserts an empty require exits non-zero, emits no APPROVE and writes nothing to the forge, and a lint test asserts exactly one located error for the empty shape and none for a non-empty require.
  • Reconciled the "vacuously covered" wording in ADR-0009, D-183 (superseding D-021's safety reading) and the seam note in internal/core/decision/record.go; the schema minItems: 1 change remains deferred under the frozen-schema guard.

Risk Assessment

✅ Low: The change is well-bounded: it adds one fail-closed guard on the only CLI path that arms APPROVE plus one lint hard error with a good/bad fixture pair, all other Cover callers are explicitly out of scope, and the guard cannot be bypassed by empty/whitespace require entries.

Testing

Drove the real assent binary end-to-end over HTTP against a standalone fake GitLab server (same endpoint surface as the in-repo httptest fake). Empty require: [] and absent require: both exit 1 with a contributor-readable refusal naming the binding and perform zero forge writes; the pre-change base binary on the same input exits 0 with APPROVE + approve + merge, reproducing the closed fail-open; a non-empty binding still approves and merges (merge pinned to srcSHA), so the happy path is intact. assent lint on the new fixture pair emits exactly the located binding-require-empty hard error for the bad tree and lints the good tree clean, with the reserved assent-policy carve-out clean. Targeted Go tests for the run-path polarity and the lint corpus pass. No visual evidence was produced because this change has no UI surface (CLI exit codes, stderr diagnostics, and forge-write state are the user-observable surfaces).

  • Live validation: ✅ go - 7 of 8 scenarios driven live against the product
Scenario Result Live Evidence
Run assent run --arm on a decidable APPROVE-shaped change covered by a binding with require: [] and observe a fail-closed refusal with zero forge writes ✅ pass live Real binary vs fake GitLab mode=empty: exit=1; stderr names the binding and 'require is empty'; /__state approvals=0 merges=0 discussions=0 notes=0; no APPROVE record on stdout. run_empty_stderr.txt
Run assent run --arm on the same change with the require: key entirely ABSENT (schema-valid) and observe the same fail-closed refusal ✅ pass live Real binary vs fake GitLab mode=absent: exit=1; same refusal message; forge state approvals=0 merges=0 discussions=0 notes=0. run_absent_stderr.txt
Adversarial differential: the pre-change base binary on the identical empty-require input must show the fail-open (APPROVE + approve + merge) that the guard closes ✅ pass live /tmp/assent-base (base commit 24e9acf) vs fake GitLab mode=empty: exit=0, decision=APPROVE, forge state approvals=1 merges=1 notes=1 merge?sha=srcSHA. run_empty_BASE_stdout.txt
Positive control: a binding with a non-empty require still arms APPROVE and merges the change (guard does not break the legitimate path) ✅ pass live Real binary vs fake GitLab mode=nonempty: exit=0, decision=APPROVE, approvals=1 merges=1 merge?sha=srcSHA, zero threads. run_nonempty_stdout.txt
Run assent lint on a repo whose binding omits require and observe the located binding-require-empty hard error and non-zero exit ✅ pass live assent lint examples/lint-fixtures/binding-require-empty/bad → exit=1, stderr error: [binding-require-empty] .assent/bindings.yaml (class=demo environment=prod): .... lint_scenarios.txt
Run assent lint on the clean fixture whose binding declares a proven require and observe exit 0 ✅ pass live assent lint examples/lint-fixtures/binding-require-empty/good → exit=0, assent lint: clean. lint_scenarios.txt
Run assent lint on the reserved assent-policy fixture with an empty require and observe it is exempt (clean exit 0) ✅ pass live assent lint examples/lint-fixtures/reserved-class/good → exit=0, assent lint: clean. lint_scenarios.txt
In-repo wording is reconciled to the new fail-closed behavior (ADR-0009 amendment, docs/usage/cli.md, D-183 decision row), with the frozen schema minItems: 1 deferred to its next change window ⏸️ untested no Docs/decision-record text has no runtime product surface to drive live; it is verified by reading the changed files. The schema description's 'vacuously covered' text is intentionally left unchanged:…
Evidence: Run-path refusal: empty require

assent run: evaluate: ruleset-binding binding (class="topic-registry", environment="prod") declares no required obligations (require is empty) — refusing to arm APPROVE; add at least one obligation to require[] (GUIDELINES §Safety-1, D-183)

assent run: evaluate: ruleset-binding binding (class="topic-registry", environment="prod") declares no required obligations (require is empty) — refusing to arm APPROVE; add at least one obligation to require[] (GUIDELINES §Safety-1, D-183)
Evidence: Run-path refusal: absent require
assent run: evaluate: ruleset-binding binding (class="topic-registry", environment="prod") declares no required obligations (require is empty) — refusing to arm APPROVE; add at least one obligation to require[] (GUIDELINES §Safety-1, D-183)
Evidence: Differential: pre-change base binary APPROVEs + merges on empty require (fail-open reproduction)

{"apiVersion":"assent.dev/v1alpha1","kind":"DecisionRecord","decision":"APPROVE",...} decision=APPROVE arm=true → 3 forge operation(s) written

{"apiVersion":"assent.dev/v1alpha1","kind":"DecisionRecord","decision":"APPROVE","findings":{"observed":[],"enforcing":[]},"pins":{"toolVersion":"0.0.0-dev","toolDigest":"sha256:33495ff6935ffb66715f8367e41c447d3d088ffef6e201aca555ee75c9e1de4a","policySha":"sha256:cd8477e49ffcae03362ab0a0d3eccf7d50c71ef13d9f038d412a62cd19a87a5d","sourceSha":"srcSHA","targetSha":"tgtTIP","mergeResultDigest":null,"capabilityGap":"gitlab plain-merge exposes no merge-result digest (no merge train); ADR-0017 §1 capabilityGap","factsResolvedAt":{}}}
decision=APPROVE arm=true → 3 forge operation(s) written
Evidence: Positive control: non-empty require still APPROVEs and merges

{"apiVersion":"assent.dev/v1alpha1","kind":"DecisionRecord","decision":"APPROVE",...} decision=APPROVE arm=true → 3 forge operation(s) written

{"apiVersion":"assent.dev/v1alpha1","kind":"DecisionRecord","decision":"APPROVE","findings":{"observed":[],"enforcing":[]},"pins":{"toolVersion":"0.0.0-dev","toolDigest":"sha256:33495ff6935ffb66715f8367e41c447d3d088ffef6e201aca555ee75c9e1de4a","policySha":"sha256:cd8477e49ffcae03362ab0a0d3eccf7d50c71ef13d9f038d412a62cd19a87a5d","sourceSha":"srcSHA","targetSha":"tgtTIP","mergeResultDigest":null,"capabilityGap":"gitlab plain-merge exposes no merge-result digest (no merge train); ADR-0017 §1 capabilityGap","factsResolvedAt":{}}}
decision=APPROVE arm=true → 3 forge operation(s) written
Evidence: Lint scenarios: bad fixture hard error, good + reserved-class clean
=== assent lint examples/lint-fixtures/binding-require-empty/bad ===
exit=1
--- stdout ---
assent lint: 1 error(s) — see diagnostics above
--- stderr ---
error: [binding-require-empty] .assent/bindings.yaml (class=demo environment=prod): binding (class="demo", environment="prod") 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-183)

=== assent lint examples/lint-fixtures/binding-require-empty/good ===
exit=0
--- stdout ---
assent lint: clean
--- stderr ---

=== assent lint examples/lint-fixtures/reserved-class/good ===
exit=0
--- stdout ---
assent lint: clean
--- stderr ---
Evidence: Standalone fake GitLab server harness used to drive the real binary
#!/usr/bin/env python3
"""Standalone fake GitLab REST v4 server for live `assent run` scenarios.

Mirrors the in-repo httptest fake (cmd/assent/run_test.go) but runs as a real
HTTP server so the real `assent` binary can be driven end-to-end over the
network. Records every mutating call so the test can assert fail-closed writes.

Usage:
  fake_gitlab.py --mode {empty,absent,nonempty} --port-file <path>
"""
import argparse
import json
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs, unquote

MERGE_POLICY_CHALLENGE = json.dumps({
    "apiVersion": "assent.dev/v1alpha1",
    "kind": "MergePolicy",
    "metadata": {"name": "topic-safety"},
    "spec": {
        "entries": {"topic-registry": {"mode": "document", "root": "",
                                        "identity": {"pointer": "/metadata/name"}}},
        "rules": [{
            "name": "partitions-must-not-shrink",
            "phase": "enforce",
            "match": {"valueChanges": {"pointers": ["/partitions"], "kinds": ["modify"]}},
            "prove": {"obligation": "non-destructive", "when": "new >= old"},
            "onFailure": {"effect": "challenge", "code": "partition-count-shrunk"},
        }],
    },
})

BINDING_TEMPLATE = {
    "empty": {"class": "topic-registry", "environment": "prod",
              "packs": ["topic-safety"], "risk": {"threshold": 10}, "require": []},
    "absent": {"class": "topic-registry", "environment": "prod",
               "packs": ["topic-safety"], "risk": {"threshold": 10}},
    "nonempty": {"class": "topic-registry", "environment": "prod",
                 "packs": ["topic-safety"], "risk": {"threshold": 10},
                 "require": ["non-destructive"]},
}

PREMIUM_PROJECT = {
    "only_allow_merge_if_all_discussions_are_resolved": True,
    "merge_trains_enabled": True,
    "ci_config_path": ".gitlab-ci.yml@group/external-ci",
}

BASE_FILE = "partitions: 12\n"
HEAD_FILE = "partitions: 24\n"
BOT_AUTHOR = "assent-bot"


class State:
    def __init__(self, mode):
        self.mode = mode
        self.approvals = 0
        self.merges = 0
        self.discussions_posted = 0
        self.notes_posted = 0
        self.discussions = []
        self.notes = []
        self.last_merge_sha = None
        self.unexpected = []


def make_handler(state):
    class H(BaseHTTPRequestHandler):
        protocol_version = "HTTP/1.1"

        def log_message(self, *a):
            pass

        def _send(self, code, body, ctype="application/json"):
            if isinstance(body, str):
                body = body.encode()
            self.send_response(code)
            self.send_header("Content-Type", ctype)
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)

        def _json(self, obj, code=200):
            self._send(code, json.dumps(obj))

        def _read_form(self):
            n = int(self.headers.get("Content-Length", 0) or 0)
            raw = self.rfile.read(n).decode() if n else ""
            return parse_qs(raw)

        def do_GET(self):
            u = urlparse(self.path)
            p = u.path
            q = parse_qs(u.query)
            if p == "/__state":
                self._json({
                    "mode": state.mode, "approvals": state.approvals,
                    "merges": state.merges,
                    "discussions_posted": state.discussions_posted,
                    "notes_posted": state.notes_posted,
                    "last_merge_sha": state.last_merge_sha,
                    "unexpected": state.unexpected,
                })
                return
            if p == "/api/v4/projects/42/merge_requests/7":
                self._json({
                    "iid": 7, "project_id": 42, "source_project_id": 42,
                    "sha": "srcSHA", "source_branch": "feature",
                    "target_branch": "main", "changes_count": "1",
                    "labels": [], "author": {"id": 101, "username": "alice"},
                })
                return
            if p == "/api/v4/projects/42/repository/branches/main":
                self._json({"commit": {"id": "tgtTIP"}})
                return
            if p == "/api/v4/projects/42":
                self._json(PREMIUM_PROJECT)
                return
            if p == "/api/v4/projects/42/merge_requests/7/diffs":
                if q.get("page", ["1"])[0] != "1":
                    self._json([])
                else:
                    self._json([{"old_path": "topics/orders.yaml",
                                 "new_path": "topics/orders.yaml"}])
                return
            if p == "/api/v4/projects/42/merge_requests/7/approval_rules":
                self._json([{"id": 1, "name": "security-review",
                             "rule_type": "regular", "approvals_required": 1}])
                return
            if p == "/api/v4/projects/42/merge_requests/7/approval_state":
                self._json({"rules": [{
                    "id": 1, "name": "security-review", "rule_type": "regular",
                    "approvals_required": 1, "approved": True,
                    "eligible_approvers": [
                        {"id": 101, "username": "alice"},
                        {"id": 202, "username": "bob"},
                        {"id": 999, "username": BOT_AUTHOR}],
                    "approved_by": [{"user": {"id": 202, "username": "bob"}}],
                }]})
                return
            if p == "/api/v4/user":
                self._json({"id": 999, "username": BOT_AUTHOR})
                return
            if p.startswith("/api/v4/projects/42/repository/files/") and p.endswith("/raw"):
                raw = p[len("/api/v4/projects/42/repository/files/"):-len("/raw")]
                path = unquote(raw)
                ref = q.get("ref", [""])[0]
                self.serve_file(path, ref)
                return
            if p == "/api/v4/projects/42/merge_requests/7/discussions":
                self._json([{
                    "id": d["id"],
                    "notes": [{"body": d["body"], "resolved": d["resolved"],
                               "author": {"username": BOT_AUTHOR}}],
                } for d in state.discussions])
                return
            if p == "/api/v4/projects/42/merge_requests/7/notes":
                self._json([{"id": n["id"], "body": n["body"],
                             "author": {"username": BOT_AUTHOR}} for n in state.notes])
                return
            state.unexpected.append("GET " + self.path)
            self._send(500, "unexpected GET " + self.path, "text/plain")

        def serve_file(self, path, ref):
            if "merge-policy" in path:
                self._send(200, MERGE_POLICY_CHALLENGE, "text/plain")
                return
            if "ruleset-binding" in path:
                binding = {"apiVersion": "assent.dev/v1alpha1",
                           "kind": "RulesetBinding",
                           "bindings": [BINDING_TEMPLATE[state.mode]]}
                self._send(200, json.dumps(binding), "text/plain")
                return
            if path == "topics/orders.yaml":
                self._send(200, BASE_FILE if ref == "main" else HEAD_FILE, "text/plain")
                return
            self._send(404, "not found", "text/plain")

        def do_POST(self):
            u = urlparse(self.path)
            p = u.path
            if p == "/api/v4/projects/42/merge_requests/7/discussions":
                state.discussions_posted += 1
                form = self._read_form()
                did = "disc-%d" % (len(state.discussions) + 1)
                state.discussions.append({"id": did,
                                          "body": form.get("body", [""])[0],
                                          "resolved": False})
                self._json({"id": did}, 201)
                return
            if p == "/api/v4/projects/42/merge_requests/7/notes":
                state.notes_posted += 1
                form = self._read_form()
                nid = 8000 + len(state.notes) + 1
                state.notes.append({"id": nid, "body": form.get("body", [""])[0]})
                self._json({"id": nid}, 201)
                return
            if p == "/api/v4/projects/42/merge_requests/7/approve":
                state.approvals += 1
                self._json({"id": 1}, 201)
                return
            state.unexpected.append("POST " + self.path)
            self._send(500, "unexpected POST " + self.path, "text/plain")

        def do_PUT(self):
            u = urlparse(self.path)
            p = u.path
            q = parse_qs(u.query)
            if p == "/api/v4/projects/42/merge_requests/7/merge":
                state.merges += 1
                state.last_merge_sha = q.get("sha", [None])[0]
                self._json({"state": "merged", "merge_commit_sha": "mc"})
                return
            if p.startswith("/api/v4/projects/42/merge_requests/7/discussions/"):
                did = p.rsplit("/", 1)[-1]
                for d in state.discussions:
                    if d["id"] == did:
                        d["resolved"] = True
                self._json({"id": did})
                return
            if p.startswith("/api/v4/projects/42/merge_requests/7/notes/"):
                nid = int(p.rsplit("/", 1)[-1])
                form = self._read_form()
                for n in state.notes:
                    if n["id"] == nid:
                        n["body"] = form.get("body", [""])[0]
                self._json({"id": nid})
                return
            state.unexpected.append("PUT " + self.path)
            self._send(500, "unexpected PUT " + self.path, "text/plain")

    return H


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--mode", required=True,
                    choices=["empty", "absent", "nonempty"])
    ap.add_argument("--port-file", required=True)
    args = ap.parse_args()

    state = State(args.mode)
    srv = ThreadingHTTPServer(("127.0.0.1", 0), make_handler(state))
    with open(args.port_file, "w") as fh:
        fh.write(str(srv.server_address[1]))
    try:
        srv.serve_forever()
    except KeyboardInterrupt:
        pass


if __name__ == "__main__":
    main()
Evidence: Evidence summary
# RVW-S01 / D-183 — live validation evidence

Change under test: `fm/assent-b3-empty-require`
Base commit: `24e9acf5a46dbfe1ec098b6292b0317e2e0144c4`
Target commit: `e6b4a4ff213dc783d3f1996bd45ba5d726fff338`

## Product build

    CGO_ENABLED=0 go build -o /tmp/assent-rvw-s01 ./cmd/assent   # target
    git archive <base> | tar -x -C /tmp/assent-base-src
    CGO_ENABLED=0 go build -o /tmp/assent-base ./cmd/assent       # pre-change baseline

## Harness

`fake_gitlab.py` — standalone HTTP GitLab REST v4 server (same endpoint surface
as the in-repo httptest fake in `cmd/assent/run_test.go`) so the REAL `assent`
binary is driven end-to-end over the network with `--gitlab-endpoint`.
`--mode {empty,absent,nonempty}` selects the served RulesetBinding.

## Scenario results

\### S1 — `assent run --arm`, empty `require: []`, decidable APPROVE-shaped change
exit=1, zero forge writes, no APPROVE record; refusal names the binding:

    assent run: evaluate: ruleset-binding binding (class="topic-registry",
    environment="prod") declares no required obligations (require is empty) —
    refusing to arm APPROVE; add at least one obligation to require[] ...

    forge state: approvals=0 merges=0 discussions=0 notes=0

\### S2 — `assent run --arm`, ABSENT `require:` key (same schema-valid shape)
exit=1, zero forge writes, same refusal. `run_absent_stderr.txt`.

\### S3 — differential: BASE binary (pre-change) on the SAME empty-require input
exit=0, `"decision":"APPROVE"`, `decision=APPROVE arm=true → 3 forge operation(s) written`,
forge state `approvals=1 merges=1 notes=1 merge?sha=srcSHA`. This reproduces the
fail-open the change closes: at base, an empty `require` approved AND merged.
`run_empty_BASE_stdout.txt`.

\### S4 — positive control: non-empty `require: [non-destructive]`, `--arm`
exit=0, `"decision":"APPROVE"`, `approvals=1 merges=1 merge?sha=srcSHA`. The guard
does not break the legitimate approve+merge path. `run_nonempty_stdout.txt`.

\### S5 — `assent lint examples/lint-fixtures/binding-require-empty/bad`
exit=1; hard error located to the binding:

    error: [binding-require-empty] .assent/bindings.yaml (class=demo environment=prod):
    binding (class="demo", environment="prod") declares no required obligations ...

\### S6 — `assent lint examples/lint-fixtures/binding-require-empty/good`
exit=0, `assent lint: clean`.

\### S7 — `assent lint examples/lint-fixtures/reserved-class/good` (empty require, reserved `assent-policy` class)
exit=0, `assent lint: clean` — the documented reserved-class carve-out (ADR-0015 §1).

## Targeted tests run

    go test ./cmd/assent/ -run 'TestRunEmptyRequireNeverApproves|TestRunApproveArmedMerges' -count=1   # PASS
    go test ./internal/lint/ -run 'TestBindingRequireEmpty|TestEveryHardErrorFixtureCaught' -count=1   # PASS

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

🔧 **Rebase** - 2 issues found → auto-fixed ✅
  • ⚠️ cmd/assent/run_test.go - merge conflict rebasing onto origin/main
  • ⚠️ docs/decisions/decisions.md - merge conflict rebasing onto origin/main

🔧 Fix applied.
✅ Re-checked - no issues remain.

✅ **Review** - passed

✅ No issues found.

✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 7 of 8 scenarios driven live against the product
Scenario Result Live Evidence
Run assent run --arm on a decidable APPROVE-shaped change covered by a binding with require: [] and observe a fail-closed refusal with zero forge writes ✅ pass live Real binary vs fake GitLab mode=empty: exit=1; stderr names the binding and 'require is empty'; /__state approvals=0 merges=0 discussions=0 notes=0; no APPROVE record on stdout. run_empty_stderr.txt
Run assent run --arm on the same change with the require: key entirely ABSENT (schema-valid) and observe the same fail-closed refusal ✅ pass live Real binary vs fake GitLab mode=absent: exit=1; same refusal message; forge state approvals=0 merges=0 discussions=0 notes=0. run_absent_stderr.txt
Adversarial differential: the pre-change base binary on the identical empty-require input must show the fail-open (APPROVE + approve + merge) that the guard closes ✅ pass live /tmp/assent-base (base commit 24e9acf) vs fake GitLab mode=empty: exit=0, decision=APPROVE, forge state approvals=1 merges=1 notes=1 merge?sha=srcSHA. run_empty_BASE_stdout.txt
Positive control: a binding with a non-empty require still arms APPROVE and merges the change (guard does not break the legitimate path) ✅ pass live Real binary vs fake GitLab mode=nonempty: exit=0, decision=APPROVE, approvals=1 merges=1 merge?sha=srcSHA, zero threads. run_nonempty_stdout.txt
Run assent lint on a repo whose binding omits require and observe the located binding-require-empty hard error and non-zero exit ✅ pass live assent lint examples/lint-fixtures/binding-require-empty/bad → exit=1, stderr error: [binding-require-empty] .assent/bindings.yaml (class=demo environment=prod): .... lint_scenarios.txt
Run assent lint on the clean fixture whose binding declares a proven require and observe exit 0 ✅ pass live assent lint examples/lint-fixtures/binding-require-empty/good → exit=0, assent lint: clean. lint_scenarios.txt
Run assent lint on the reserved assent-policy fixture with an empty require and observe it is exempt (clean exit 0) ✅ pass live assent lint examples/lint-fixtures/reserved-class/good → exit=0, assent lint: clean. lint_scenarios.txt
In-repo wording is reconciled to the new fail-closed behavior (ADR-0009 amendment, docs/usage/cli.md, D-183 decision row), with the frozen schema minItems: 1 deferred to its next change window ⏸️ untested no Docs/decision-record text has no runtime product surface to drive live; it is verified by reading the changed files. The schema description's 'vacuously covered' text is intentionally left unchanged:…
  • CGO_ENABLED=0 go build -o /tmp/assent-rvw-s01 ./cmd/assent (target)
  • git archive &lt;base&gt; | tar -x -C /tmp/assent-base-src &amp;&amp; go build -o /tmp/assent-base ./cmd/assent (pre-change baseline)
  • GITLAB_TOKEN=tok /tmp/assent-rvw-s01 run --gitlab-endpoint http://127.0.0.1:&lt;port&gt; --project 42 --mr 7 --bot-author assent-bot --subject file:topics/orders.yaml --arm with fake GitLab mode=empty (exit 1, zero writes)
  • same run invocation with fake GitLab mode=absent (exit 1, zero writes)
  • /tmp/assent-base run ... --arm with fake GitLab mode=empty (exit 0, APPROVE, approvals=1 merges=1) — fail-open reproduction
  • same run invocation with fake GitLab mode=nonempty (exit 0, APPROVE, approvals=1 merges=1, merge?sha=srcSHA) — positive control
  • /tmp/assent-rvw-s01 lint examples/lint-fixtures/binding-require-empty/bad (exit 1, binding-require-empty)
  • /tmp/assent-rvw-s01 lint examples/lint-fixtures/binding-require-empty/good (exit 0, clean)
  • /tmp/assent-rvw-s01 lint examples/lint-fixtures/reserved-class/good (exit 0, clean)
  • go test ./cmd/assent/ -run 'TestRunEmptyRequireNeverApproves|TestRunApproveArmedMerges' -count=1
  • go test ./internal/lint/ -run 'TestBindingRequireEmpty|TestEveryHardErrorFixtureCaught' -count=1
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

…9 locale class)

$SONAR_IF_NONPR_ARM… (unbraced, immediately followed by a UTF-8 ellipsis)
is parsed by bash as a variable whose name absorbs the ellipsis's leading byte
under a UTF-8 locale, so check_sonar_scan_wired's constant-if branch died with
'unbound variable' under set -u instead of returning its finding — the gate was
RED on a clean tree for a developer with LANG=C.UTF-8/en_US.UTF-8 and green
under C. Same class as D-179. Brace the expansion; no behaviour change.
…D-182)

A schema-valid RulesetBinding with require: [] (or no require: key) decided
APPROVE with zero findings for any governed change its block rules did not fire
on: the obligation layer iterates bind.Require, so an empty list made it vacuous.
The schema accepted the shape and lint passed it, while GUIDELINES Safety-1 and
the record.go seam note (S03 review F3) both promised the opposite.

- run path: decide's decidable, non-reserved arm now returns a hard error when
  the covering binding's require is empty, before any forge write. Placed in
  decide (not selectBinding) because selectBinding is shared with 'assent
  compare', whose purpose is to compare permissive candidates.
- lint: new hard error binding-require-empty + good/bad fixture pair in the
  E3-S08 hardErrorCorpus. The reserved assent-policy class is exempt: it is
  block-by-default (GUARD 1) and cannot carry a non-empty require, since any
  prove rule in a reserved-bound pack is itself a reserved-class violation.
- reconciliation: D-182 supersedes D-021's 'vacuously covered' clause; the
  frozen schema's minItems:1 is deferred to its next change window (D-132 freeze
  guard). lint-hard-errors.md, cli.md and the record.go seam note updated.

fail-open fixtures gain require: [reviewed] (their rule already proved it); they
were 'clean' only because the check did not exist.
@konih
konih force-pushed the fm/assent-b3-empty-require branch from a7e6ab2 to 455586c Compare September 26, 2026 15:51
@sonarqubecloud

Copy link
Copy Markdown

@konih
konih merged commit fe4eb3f into main Sep 26, 2026
10 checks passed
@konih
konih deleted the fm/assent-b3-empty-require branch September 26, 2026 16:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant