ADR-029: A generated view must be committed by something; the remedy says so where it is read

Status Active · Version 2 · Filed 2026-08-05 · Issue #21 · #23 · Influenced ADR-042

Context

ADR-018 put two derived counts on the README and made luria lint fail when they drift. What it did not settle — because in this repository the views were born committed — is who runs the generator.

It surfaced in the first project to adopt the badges after the fact. The adopter added the <!-- luria:badges --> region, CI went red with the message we wrote:

README.md: badge counts are stale — run `luria index`

and they did what it said, in the place they were reading it: added luria index to the docs-lint job, one step ahead of luria lint. The red cleared. Three green builds followed with the badge region still empty.

That shape fails twice. The regenerated README lived in a runner checkout nobody commits, so it never reached the repository. And luria lint verifies views by re-rendering them and diffing against disk, so a generator immediately ahead of it makes the comparison vacuous — it compares the generator’s output against itself. Their whole check_generated_index went inert: index, tag pages and books, not just badges.

luria index && luria lint   → exit 0    (a corrupted index is overwritten)
luria lint                  → exit 1    docs/decisions/README.md: stale

The proximate cause is ours. The remedy text omits the half that matters — that the output has to be committed — and a staleness failure is usually seen first in a build log, where that omission is most expensive.

The first draft of this decision drew the wrong rule from all that, and the correction is the reason this ADR reads as it does. It said a checking job runs nothing that writes, which is tidy, and which outlaws generation jobs: the adopter’s own repository runs bots that rebuild a screenshot gallery and commit it, and this repository’s ci.yml runs luria collect --commit on a schedule and pushes. A rule that condemns the recipe we ship is worse than the trap it closes. The failure was never “a generator ran in CI” — it was “its output was thrown away”.

Decision

A generated view must be committed by something. Both somethings are legitimate; the shape that commits nothing is not.

  • Author-committed — run luria index, commit what it wrote. No write permissions in CI, and every contributor carries a build step.
  • Generation-job-committed — CI runs the generator, commits the diff as a bot and pushes. The better default, and named as such in docs/adopting.md: a view a human has to rebuild by hand is still a hand-maintained projection, and those drift at a rate rather than a risk.

The generation-job route ships as machinery, not as a snippet. Two composite actions hold the one authoritative implementation of the tricky parts (DP-3 — a snippet every adopter restates is a projection that drifts):

  • actions/generate — install, luria link --fix, luria index, the guarded commit-and-push (a fork PR’s read-only token gets a warning and an un-regenerated sha output instead of a 403), and luria index --check so a broken or non-idempotent generator still fails the job.
  • actions/lintluria lint, then the status reports written and uploaded on always(), which is when they are most wanted.

The template workflow luria init scaffolds (template/.github/workflows/docs.yml) is the full recommended shape built from those actions — previously it scaffolded a verify-only lint, handing every new adopter exactly the setup that produced the incident. And this repository’s own ci.yml runs the same two actions by local path (ADR-009): the workflow adopters get is the workflow luria lives on, and a change to an action is exercised by the pull request that makes it. What stays in the caller’s workflow — the fork-safe checkout ref and the needs: + sha handoff — is documented in docs/adopting.md, because it happens before any action can help.

Two changes carry it in the tooling, both in luria/ci.py, and both change only what is said, never what is done:

  • regenerate_remedy(). In a terminal, the bare command. Under CI, it names both routes — regenerate locally, or give CI a job that runs the generator and pushes — and warns against the specific broken shape rather than against generation. Used by luria lint, luria index --check and luria badges --check. It fires only when a check has already failed, so it costs nothing in steady state.
  • Bare luria badges says it only printed — it emits markdown and exits 0, which as a - run: step looks exactly like a write, and is why the adopter’s second CI step also did nothing. On stderr, so redirection still works. DP-1, which the command was quietly failing.

Detection is deliberately crude (every CI sets CI; vendor variables are named so dropping the generic one doesn’t take the advice with it). A false positive costs a sentence of advice; a false negative leaves today’s behaviour. Nothing here gates a write or an exit code.

The staleness check stays in both arrangements. Automation renders staleness moot, not unreachable — the generation job can fail, be disabled, lose its write permission, or be a fork PR whose token cannot push. The check changes what it watches rather than retiring: from a forgetful author to a generator that didn’t run or couldn’t push.

Alternatives considered

  • Ban writes from CI“a checking job runs nothing that writes”. The first draft of this decision, and wrong, for the reason in the Context: it outlaws every generation job, including luria collect --commit in our own workflow and in the adoption guide. It also mistakes the symptom for the cause. Recorded here rather than quietly dropped because it is the tempting conclusion from the evidence, and the next person will reach for it too.
  • Refuse to write when CI is set. The enforcement version of the same mistake, and worse — it breaks the documented cadence job outright.
  • An always-on warning whenever a generator writes inside CI. Built, then removed before merge. It fired on every run of a correct generation job and had to describe itself as noise in that case — and a warning that is usually noise trains readers to skip warnings, the flaky-guard dynamic this record already documents. With the generation job shipped as a first-class action, the correct case is the common case, which makes the warning wrong by default rather than merely verbose. The remedy message suffices: it fires only when a check has actually failed.
  • Detect the inert ordering directly. There is nothing honest to detect: no shared state between steps, and inferring it from mtimes or a clean worktree fires on legitimate sequences. The surface that can carry this is the message.
  • Fold README.md into outputs() so the ordinary staleness machinery covers it. ADR-018 rejected this and the reason holds — it would mark the README generated, and the reference fixer skips generated files, so its prose would stop being linted. It also wouldn’t have helped: the ordering is the bug, not the region.
  • Docs only, no behaviour change. The misleading sentence is a program output, read at the moment of the mistake by someone who hasn’t opened the adoption guide. Documentation that contradicts a message loses to the message.

Consequences

  • Adopters get the warning where the mistake is made, and get pointed at automation rather than away from it — and the automation is a uses: line, not a snippet to transcribe.
  • Contributions to this repository no longer carry luria index as homework: the docs-generate job commits the regenerated views to the branch. A bot commit landing on your branch means git pull before your next push.
  • The actions are referenced @main by the template until a release is tagged; the template says to pin once you depend on it in anger.
  • One new module, luria/ci.py, and tests/test_ci.py covering detection, both remedy forms, and the two messages a human reads.
  • The suite runs in CI, so tests/test_ci.py clears every CI variable in an autouse fixture. Without it those tests read the runner’s environment and invert on GitHub Actions while passing locally — a test for CI-awareness is the one test guaranteed to run in the environment it is trying to control.
  • The downstream fix that produced this is dmarx/strata-g#644, whose own decision rejects its predecessor for the same reason this one rejects its first draft. Its generation job was fired in both directions before being trusted (DP-6): a throwaway Proposed decision pushed as a source-only change moved needs decision 9 → 10 and was committed by the bot; deleting it walked the count back.