Skip to content

feat(ci): derive the merge contract from the host, and gate the copy against it - #267

Merged
wenzowski merged 1 commit into
mainfrom
claude/phase-3-sequential-landing-h26kx0
Aug 11, 2026
Merged

wenzowski merged 1 commit into
mainfrom
claude/phase-3-sequential-landing-h26kx0

Conversation

@wenzowski

Copy link
Copy Markdown
Contributor

Implements CLOUD-54 — the [ci] schema, the derivation from the host ruleset,
and the drift gate that keeps the committed projection honest.

The host is the authority; [ci] is a projection

"Which checks must pass, and how may a branch land" is a fact every gate either
reads from somewhere or hardcodes. This lands the reading. The GitHub rules API
is the authority and [ci] is a derived copy — never the reverse, which is what
CLOUD-35's [ci]-first-with-host-fallback design got wrong: one fact answerable
from two places.

Committed rather than fetched per run, deliberately: agents fetch, gates
decide
. Deriving live would put a credentialed network call inside a gate, and
a gate that can fail because a token expired is not a gate. The committed copy is
offline, deterministic and [epoch]-observable; the drift check is what stops it
quietly becoming a second authority.

Union for checks, intersection for methods

Required checks are the union over required_status_checks rules — each adds
an obligation, and a branch must satisfy all of them.

Allowed merge methods are the intersection over pull_request rules that
carry the key — each narrows what may be used, so a method has to be permitted
by every rule that speaks to it. Union there would widen the contract past what
one of the rules allows, which is the dangerous direction.

No pull_request rule at all yields None — "the host constrains no method" —
which is a different claim from an empty set, and only agrees with None.

Drift is symmetric, and signed

Both directions are findings, with + for a token the host has and - for one
the config claims. A stale name in the projection is not harmless: it is exactly
what a downstream reader would wait on forever.

A host that constrains methods while the config omits the key is the dangerous
case — the projection silently claims freedom the host does not grant — and it
reports.

Deliberate deviations, both flagged

1. Unknown rule types are ignored, but a non-array payload is exit 1. The
host adds rule types over time and failing on one would break the gate on a
change nobody made. But a payload that is not a rules array must not derive an
empty contract from it: empty would read as agreement against an absent [ci]
and as drift against a real one — two wrong answers from one wrong document.

2. The CI wiring is scheduled, not on the landing path. The issue says "a CI
step pipes gh api ... into the gate so drift fails the build". I put it in
.github/workflows/ci-drift.yml on a weekly cron instead, following
lock-currency.yml's recorded lesson — which is the same mistake, avoided
twice:

It used to run inside mise run verify, where it did two things a gate must
not: a remote round trip per tool … That made an unrelated upstream release
fail whichever change happened to be in flight.

Someone editing the ruleset in the GitHub UI is exactly that shape: the drift is
real, but it belongs to no PR and no PR can fix it. Putting it on the landing
path would also have forced the fetch into verify (ci-local-parity refuses a
task CI runs that verify does not), making local verify require a token.

The split stays intact either way: mise run ci-drift does the fetching where a
credential is expected; batten config lint --host-rules - is a pure offline
comparison any developer can run against a saved payload with no token. A fetch
failure is exit 1, not 0 — a check that could not look has not found agreement.

Consumer #1 adoption

[ci] required_checks = ["final"], derived from the live ruleset, with no
allowed_merge_methods because the ruleset carries no pull_request rule. The
comment records why landing-by-/fast-forward is deliberately not encoded
there: it is repository discipline, not a host constraint, and recording a
convention as a host rule would be the second authority this issue exists to
prevent.

min_batten_version is raised to 0.0.59 — the version that introduces the key,
not the next tag: release-plz bumps on landing, so naming the next tag would make
this file refuse the very build that has to lint it.

Bonus finding: the required check is final — which is exactly the check
that read skipped in the landing that produced CLOUD-327. A required-set-aware
ci-wait would have refused it. This table is that issue's intended source.

Tests

Unit (ci.rs): union over checks, intersection over two pull_request rules, a
legacy payload yielding checks and no method constraint, unknown rule types
ignored, every non-array payload a usage error while [] stays valid, agreement
silent, drift in both directions, and validation refusing an empty/duplicated
check list or an unknown method token.

E2E (config_lint.rs), one per Acceptance bullet: agreement is clean; a missing
check, a stale check, an omitted method constraint and an over-claimed method are
each exit 2 naming the key and the signed tokens; a legacy payload is clean on
the method half and still compares checks; a non-rules payload and --host-rules
without a committed [ci] are both exit 1; every malformation is refused at
parse; -J is byte-identical across runs and reads stdin; and lint without the
flag is unchanged.

Refs: CLOUD-54

@linear-code

linear-code Bot commented Aug 11, 2026 •

Copy link
Copy Markdown
CLOUD-54 Read the merge contract from the host ruleset: the derived `[ci]` table and its drift gate

Why

Every consumer of "which checks must pass, and how does a branch land" — the gate/pr surface the house style names, the lifecycle-task ports — either reads that contract from somewhere or hardcodes it. The hardcode instance is measured (CLOUD-35, absorbed here on cancellation): required checks inlined, and configured.unwrap_or("squash") for the merge method. A forbid rule provably cannot express the constraint in either direction: patterning the flag literal ("--squash" over crates/batten/src/**) exits 0 against configured.unwrap_or("squash"), missing the hardcode; patterning the field name ("merge_method" over crates/**) exits 2 against a correct reader's own allowed_merge_methods, firing on the right implementation. The comparison effective pair == host-derived pair is what makes the constraint computable, so it ships here or nowhere. The fact-gap is visible in this repository today: mise-tasks/ci-wait must refuse an all-skipped check set as "not an answer" precisely because nothing local knows which checks are required (the draft-era-skips shape, CLOUD-247).

The authority is the host: the GitHub rules API (GET /repos/{owner}/{repo}/rules/branches/{branch}) exposes the merged required-checks and allowed-merge-methods contract; legacy branch protection exposes no merge-method constraint, so rulesets are the primary surface. [ci] is the derived projection and the host ruleset is the authority — never the reverse. CLOUD-35 specified [ci]-first with host derivation as a fallback, which makes one fact answerable from two places; DoR §1 bars the re-typing and house style §8 admits one committed authority per fact.

Two thirds of the original scope are landed and are regression surface here, not work:

  • The hook deny of hand-typed gh pr merge / gh pr comment carrying /fast-forward / gh pr checks / gh run watch ships as shape rules in batten.toml (CLOUD-48); the shape-mediated-call e2e fixture pins the mechanism.
  • Landed-by-content is git::landing (CLOUD-36): per-commit git patch-id --stable plus the cumulative three-dot diff for the squash shape — evidence-backed, never ancestry, exactly the range-level comparison this issue used to ask for. Untouched here.

What remains — and what this issue is — is the contract itself: the [ci] schema, the derivation from the host payload, and the drift gate that keeps the committed projection honest.

Rejected alternatives

  • A general-purpose policy-language engine for this layer: it evaluates only what the caller feeds it, so it would re-evaluate data the reader must fetch, normalize, and diff anyway — a second policy language removing none of the reader work.
  • A live fetch inside the gate: agents fetch, gates decide (graph-check / landed-check / ready-lint; receipt.rs records the invariant). The gate takes the payload as caller-supplied input and stays a pure, credential-free, byte-stable comparison.
  • No committed [ci] at all (derive live every run): every local gate run would need a credentialed network fetch. A committed projection is offline, deterministic, and epoch-observable (batten.toml is [epoch]-tracked); the drift gate is what keeps it from becoming a second authority.

Mechanism

  1. Schema (crates/batten/src/config.rs): optional [ci] table — required_checks (non-empty, unique, sorted list of exact check-run names; required when the table is present) and allowed_merge_methods (optional sorted subset of merge | squash | rebase; absent means "the host exposes no merge-method constraint"). Validated at parse like [[verb]]/[[marker]] (CLOUD-242's lesson: a table nothing validates is coverage that means nothing). deny_unknown_fields already makes the key a schema change: today a batten.toml declaring [ci] is refused (exit 1), never ignored, and grep -rn 'merge_method' crates/ returns zero — no literal is being replaced.
  2. Derivation — a pure function from the rules-API response array: required checks = the union of parameters.required_status_checks[].context over rules of type required_status_checks, sorted unique; allowed merge methods = the intersection of parameters.allowed_merge_methods over rules of type pull_request that carry the key, sorted; no such rule = no constraint. Unknown rule types are ignored.
  3. Drift gate: batten config lint --host-rules <path|-> — when supplied, lint additionally compares the committed [ci] pair against the derived pair. Any set inequality on either half — including a host constraint the config omits, and vice versa — is a finding naming the key and the differing tokens, under lint's existing smell contract. --host-rules with no committed [ci] is a usage error (exit 1): the caller asked for a comparison the config cannot participate in.
  4. Consumer ci: check in the main-branch protection ruleset #1 adoption: this repository's batten.toml gains [ci] derived from its live ruleset; min_batten_version is raised (the CLOUD-32 precedent — a new key must be refused by an older binary, and the floor names the version); a CI step pipes gh api repos/{owner}/{repo}/rules/branches/main into the gate so drift fails the build.

Definition of done

  • [ci] parses, validates, round-trips config show, and is refused with exit 1 on any malformation (unknown subkey, empty or duplicated required_checks, unknown method token).
  • The derivation is a pure, unit-tested function over committed fixture payloads.
  • config lint --host-rules implements the comparison; existing lint behaviour without the flag is byte-identical to before.
  • JSON schema and completions regenerate (batten generate schema, schema-check, completions-check); the new flag appears in batten spec output by construction (§11).
  • Consumer ci: check in the main-branch protection ruleset #1 adoption lands: [ci] values, the min_batten_version raise, and the CI wiring.
  • The shape-mediated-call deny fixture is untouched and green.

Acceptance

  • [ci] matching the piped payload → exit 0, prints nothing.
  • Host requires a check the config lacks → exit 2, one finding naming ci.required_checks and the missing name.
  • Config lists a method the host does not allow, or the host constrains methods while the config omits the key → exit 2 naming ci.allowed_merge_methods and the differing token.
  • A legacy branch-protection-shaped payload (no pull_request rule) against a config omitting allowed_merge_methods → clean on that half; checks still compared.
  • Payload that is not a rules-API array → exit 1.
  • --host-rules without a committed [ci] → exit 1.
  • [ci] with an unknown subkey, or empty/duplicate required_checks → exit 1 at parse.
  • -J output byte-identical across two runs on identical input.

Refinement — Ready (host ruleset payload in; committed [ci] is the derived pair; drift = set inequality; exit 2)

Refinement gate: Definition of Ready & Done. This body carries only specializations.

  • Source of truth (§1). The host ruleset is the authority for the merge contract; the one artifact this issue changes is crates/batten — the [ci] schema and the config lint comparison. Consumer ci: check in the main-branch protection ruleset #1's [ci] values are that consumer's derived fact in that consumer's batten.toml: a projection the gate polices, never a second authority.
  • Computable predicate (§2). Equality of two sorted string sets per half, derived by a pure function from caller-supplied JSON. Not expressible as a batten.toml rule — no rule kind carries an external payload input — so this issue grows the engine's existing lint surface (CLOUD-87) rather than the bash layer; no capability gap remains once it lands.
  • Effect (§3). No new command; no effect-table change. config lint stays read: --host-rules reads a file or stdin the caller supplies — data, not code, and no network. The write half — regenerating [ci] in place, format-preserving — is deliberately absent: that is the §9 freshness/apply pairing, and the apply arrives with the provision capability (CLOUD-90); until then the drift finding names the exact tokens to write and the gate re-proves the result.
  • Generated artifacts (§4). The config JSON schema regenerates via batten generate schema and is diffed by the schema-check gate; completions via completions-check; the spec is data (§11), so the flag needs no hand-maintained artifact. The committed [ci] itself is a derived artifact whose byte-for-byte diff is this gate.
  • Output & exit (§5). Pointer-only: key path plus differing tokens (check names and method tokens are config identifiers, not payload). Clean = silence + 0; drift = 2 (policy verdict); malformed payload, missing [ci], or bad schema = 1; internal = 3. -J byte-stable.
  • Commit / bump (§6). feat → patch until 0.1.0.
  • Test obligation (§7). E2E over the compiled binary (crates/batten/tests/) covering every Acceptance bullet, with hand-built representative rules-API fixture payloads (no origin literal — no-origin-literal-in-fixtures applies to everything under crates/batten/tests/); derivation unit tests in-module over the same fixtures.
  • Blockers (§8). None: the loader (CLOUD-29), lint (CLOUD-87), and the parse-time validation discipline (CLOUD-242) are landed. relatedTo CLOUD-35 (the measured hardcode this absorbs), CLOUD-90 (the write-effect apply half of the §9 pairing). Refinable and implementable now.

Stated assumptions

  1. First consumers of [ci] are the drift gate (now) and the gate/pr ports (later). ci-wait keeps its wait-for-everything posture until its port, at which point required_checks replaces it — a change owned by that port, not here.
  2. The rules-API payload shape is pinned by committed fixtures; unknown rule types are ignored by the derivation. A new host constraint kind is a schema extension to make deliberately, never silent drift.

Review in Linear

@wenzowski
wenzowski marked this pull request as ready for review August 11, 2026 10:21
@sonarqubecloud

Copy link
Copy Markdown

@wenzowski

Copy link
Copy Markdown
Contributor Author

/fast-forward

@wenzowski
wenzowski merged commit 7d21627 into main Aug 11, 2026
12 checks passed
@wenzowski
wenzowski deleted the claude/phase-3-sequential-landing-h26kx0 branch August 11, 2026 10:27
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.

2 participants