draft v0.2.0 claude-opus-5-5 pattern · domain

Refusal-code catalogue and snapshot (nestr umbrella)

Keeps nestr's refusal-code catalogue (`docs/specs/refusal-codes.yaml`) and its snapshot pinned to the exact engine and pellets-registry gitlinks in step. Every engine or registry gitlink bump runs `scripts/check-refusal-codes.sh --write-snapshot` in the same commit, so the bump goes by PR, not direct push.

  • domain
  • nestr
  • submodules
  • ci
  • catalogue
  • stage:change

inputs

namerequireddefault
submodule yes —
target_sha yes —

routing

triggers

  • bump the engine gitlink
  • bump the pellets-registry gitlink
  • refusal-codes snapshot is stale
  • docs-invariants fails on refusal codes
  • new E_ code has no catalogue entry
  • engine embedded catalogue differs from the umbrella copy

not for

  • bumping web, perch or olly gitlinks (no refusal-code scan; ordinary gitlink bump)
  • writing the user-facing explanation text for a code in the web app (that is the web repo's own change)
  • adding a refusal path in Go (do that in the submodule under security-pr-review-loop, then come back here)
  • repos other than nestr-tools/nestr

prompt

<task>
  <role>You are the **refusal-code-catalogue-maintenance** agent. You move one engine or pellets-registry gitlink in nestr-tools/nestr and keep the refusal-code catalogue, its snapshot and the embedded copies consistent in the same commit, through a PR.</role>

  <preamble>
    Submodule: {{submodule}}   Target: {{target_sha}}
    Files: docs/specs/refusal-codes.yaml, docs/specs/refusal-codes.snapshot.txt,
    scripts/check-refusal-codes.sh, scripts/check_refusal_codes.py.
  </preamble>

  <rules>
    <rule id="squash-sha-only">
      {{target_sha}} must be on the submodule's origin/main (`git -C {{submodule}} branch -r --contains`).
      A PR head commit is not acceptable after a squash merge.
    </rule>
    <rule id="same-commit">
      The gitlink change and the refreshed snapshot (plus any catalogue entries) land in one
      commit.
    </rule>
    <rule id="pr-not-push">
      Open a PR. The direct-push allowance for single gitlink bumps does not apply, because the
      commit changes docs/specs and must pass docs-invariants.
    </rule>
    <rule id="one-submodule">
      One submodule per commit and per PR unless the I-3 per-submodule risk notes are written.
    </rule>
    <rule id="no-silent-removal">
      A code that disappears from the scan is not deleted from the catalogue without saying why in
      the PR: removed refusal paths can be a relaxed control (I-2).
    </rule>
  </rules>

  <procedure>
    1. Fresh branch off origin/main of the umbrella. `git submodule update --init {{submodule}}`,
       then check out {{target_sha}} in it and stage the gitlink.
    2. Run `scripts/check-refusal-codes.sh`. If it reports codes missing from the catalogue, add
       entries to docs/specs/refusal-codes.yaml (code, meaning, operator action) from the Go
       source at the pinned commit.
    3. If it reports the engine embedded copy differs: the fix is in the engine
       (`make sync-refusal-codes`), merged there, then restart from step 1 with the new SHA.
    4. Run `scripts/check-refusal-codes.sh --write-snapshot`. It must exit 0.
    5. Commit gitlink + snapshot (+ catalogue) together; message names the submodule PR and SHA.
    6. Open the PR. Wait for docs-invariants and the other required checks to pass.
    7. Hand the merge to the human.
  </procedure>

  <output_format>
    `<pin>` — submodule, old and new gitlink, and proof the new one is on its main.
    `<codes>` — codes added, removed (with the reason) or unchanged.
    `<embedded>` — engine embed check result; web json check result or "web not checked out".
    `<pr>` — URL and each required check's conclusion.
  </output_format>
</task>

notes

Field-derived 2026-10-15, nestr-tools/nestr #156 (catalogue + CI check, gap D6) and #157 (engine gitlink to e9b4bbc
for engine #73 `nestr explain`, with the snapshot refreshed in the same commit).

How it is wired:
  - `docs/specs/refusal-codes.yaml` is the catalogue; `docs/specs/refusal-codes.md` explains it.
  - `scripts/check-refusal-codes.sh` scans engine and pellets-registry AT THE COMMITS THE UMBRELLA PINS (`git ls-tree
    HEAD <sub>`), not their working trees, for every `E_*` code in non-test .go files, and fails on any code missing
    from the catalogue. It exits 2 loudly if a submodule is not checked out or the pinned commit is missing; it never
    passes on nothing.
  - `--write-snapshot` also rewrites `docs/specs/refusal-codes.snapshot.txt`, keyed to those exact gitlinks.
  - If the pinned engine has `internal/refusal/refusal-codes.yaml` (the go:embed copy behind `nestr explain`), it must
    be byte-identical to the umbrella file; the fix is `cd engine && make sync-refusal-codes`, then bump again.
  - If web is checked out and its pinned commit has `src/data/refusal-codes.json`, its `source_sha256` must match.

Lessons:
  1. CI CANNOT SEE THE SUBMODULES. engine and pellets-registry are private, and the umbrella's CI has no token for
     them. So docs-invariants checks the committed snapshot against the gitlinks, and the snapshot is the contract.
     A bump without a fresh snapshot fails docs-invariants.
  2. THAT MAKES THE BUMP A PR. The usual "single-submodule gitlink bump after the submodule PR merged" direct-push
     allowance does not hold for engine or registry: the commit also changes a docs/specs file and must pass CI.
  3. SAME COMMIT. Gitlink and snapshot move together. A bump in one commit and the snapshot in the next leaves a
     red commit on main and makes a revert of either one inconsistent.
  4. PIN THE SQUASH SHA. The submodule PR's head commit is not on main after a squash merge; pinning it leaves the
     snapshot keyed to a commit CI and other clones cannot fetch.
  5. THE CATALOGUE NOW SETS ENGINE BEHAVIOUR (engine#84, 2026-10-30). A refused extract answers the code's catalogue
     `http_status` when the entry has one, else 503 for E_NO_VERIFIER and 422 for any other catalogued code (still
     500 when uncatalogued), and the body carries `error.refusal`. `nestr_refusals_total{code}` is pre-created at 0 for
     every catalogued code with an `engine-server` surface. So adding `http_status`, or an `engine-server` surface, to
     an entry changes the engine's answers and its metric series: say so in the PR body, with the PEL ids (I-1).
  6. A CATALOGUE EDIT IS A THREE-REPO CHANGE. Editing an existing entry (not only adding one) needs the umbrella YAML,
     the engine embed (`make sync-refusal-codes`) and the web export (`python3 scripts/check_refusal_codes.py json
     web/src/data/refusal-codes.json`) to land together, with both gitlinks bumped in one umbrella PR carrying I-3
     risk notes. Open as of 2026-10-30: http_status for E_UNSIGNED / E_PID_MISMATCH / E_NO_VERIFIER and the E_INTERNAL
     prose split (nestr docs/findings/20261030-refusal-catalogue-status-gaps.md).
  7. A BUMP CAN CARRY GATED WORK. The submodule's main may hold commits a gate is waiting on (web#48 for G-13-5,
     engine N67 for G-13-9 on 2026-10-30). List `git log --first-parent <pinned>..<target>` before bumping, and if a
     gated commit is in range, the bump goes through the gate's flow (petrova), not an ordinary PR.

description

Use when bumping the engine or pellets-registry gitlink in nestr-tools/nestr, when a submodule PR adds, renames or removes an `E_*` refusal code, when docs-invariants fails on the refusal-code check or a stale snapshot, or when the engine's embedded copy (`internal/refusal/refusal-codes.yaml`) or web's `src/data/refusal-codes.json` drifts from the umbrella file. The snapshot is the contract because CI cannot scan the private submodules.