Skip to content

feat(hooks): refuse a CAT-UNVERIFIED tag from a turn that ran no check - #468

Closed
EdbertChan wants to merge 1 commit into
stack/EdbertChan/reflect/unverified-tag-ledger-20260911/log-cat-unverified-tags-instead-treating-them--1846b813from
stack/EdbertChan/reflect/unverified-tag-ledger-20260911/refuse-cat-unverified-tag-turn-ran-check--356d181c
Closed

feat(hooks): refuse a CAT-UNVERIFIED tag from a turn that ran no check#468
EdbertChan wants to merge 1 commit into
stack/EdbertChan/reflect/unverified-tag-ledger-20260911/log-cat-unverified-tags-instead-treating-them--1846b813from
stack/EdbertChan/reflect/unverified-tag-ledger-20260911/refuse-cat-unverified-tag-turn-ran-check--356d181c

Conversation

@EdbertChan

@EdbertChan EdbertChan commented Sep 12, 2026

Copy link
Copy Markdown
Owner

Summary

A reply that says "I could not check this" now has to show it tried.

If the reply tags a claim and the turn ran no check at all, the turn is refused
and must be written again.

The first slice only logged these claims. The reason given was that refusing
the reply would trap it in a loop.

That reason was wrong. Asking for an attempt is not asking for it to succeed.

Run the check; if it cannot settle the claim, the tag is then honest.

One release valve prevents the loop. A reply already being rewritten because of
this hook is let through, since that rewrite has no check of its own.

Review Claim

A well-formed unverified-claim tag in a turn that used no verification tool
fails that turn, unless the turn is already a rewrite prompted by this hook.

Review Lane

behavior

Review Unit

engine-runtime

Safety Invariant

The refusal needs three things at once: a well-formed tag in the reply, no
verification tool in that same turn, and no stop_hook_active. Any one of them
missing returns the previous allow, and the hook still touches only its own
ledger. The stop_hook_active release is covered by its own test, so the block
cannot become unescapable without that test failing.

Slice Rationale

The first slice landed the ledger and the parsing; this one changes only the
verdict. Split that way, a reviewer who dislikes the block can drop this commit
and keep the recording. Bundled, rejecting the block would also throw away the
ledger.

Non-goals

  • Does not change diu-stop, hedge-runs-prove-it, or prove-it-ship-gate.
  • Does not weaken any existing gate; every turn allowed before is still allowed.
  • Does not ask whether the claim is true, only whether a check was attempted.
  • Does not touch the reminder or the ledger format from the first slice.

Architecture

Before

graph TD
    A["reply carries a well-formed tag"] --> B["append to session ledger"]
    B --> C["turn ends, exit 0"]
    C --> D["reminder on the next prompt"]
Loading

After

graph TD
    A["reply carries a well-formed tag"] --> B["append to session ledger"]
    B --> C{"did this turn run Bash / Read / Grep / Glob?"}
    C -->|"yes"| D["turn ends, exit 0"]
    C -->|"no"| E{"stop_hook_active?"}
    E -->|"yes, a rewrite"| D
    E -->|"no"| F["refuse the turn, exit 2"]
    D --> G["reminder on the next prompt"]
Loading

Test Plan

Test Plan
  • cd engine/hooks/unverified-tag-ledger && python3 -m unittest discover -s tests
  • python3 engine/skills/make-pr/scripts/preflight.py --base origin/main
  • python3 scripts/check_hook_test_coverage.py engine/hooks/unverified-tag-ledger

Replaying the real turn that motivated this, through the hook:

A) tag, zero tools      -> exit=2  "ran no verification tool. Untried claim(s):
                                    that it widened scope past the one session I gave it"
B) same tag after Bash  -> exit=0
C) stop_hook_active     -> exit=0  (no refusal loop)
D) no tag, no tools     -> exit=0

Ran 17 tests in 0.021s
OK
check_hook_test_coverage: OK (1 hook(s) checked)
ok      preflight passed

check_hook_test_coverage.py failed first, because this hook now blocks and had
no test whose name showed it firing. Renaming the test to
test_tag_with_no_attempt_is_blocked is what made it pass.

Fixture vs live: unit and fixture only. The hook is not installed on this
machine, so the refusal has never fired inside a real session:

$ ls -la ~/.claude/hooks/unverified-tag-ledger
ls: /Users/edbertchan/.claude/hooks/unverified-tag-ledger: No such file or directory
$ ls -d ~/.cache/catstack-unverified-ledger
ls: /Users/edbertchan/.cache/catstack-unverified-ledger: No such file or directory

Revert Plan

Revert Plan
  • Safe to revert? Yes
  • Revert command: git revert <sha> — leaves the ledger from the first slice in place
  • Post-revert steps: None
  • Data migration? No

The first commit only logged tags after the fact, on the argument that
blocking the emitting turn would deadlock. That argument was wrong: requiring
an ATTEMPT is not requiring success. cat-mode/SKILL.md:269 asks for the verify
in the same turn, so a tag from a turn with no verification tool is a claim
nobody tried, and that turn is now refused with exit 2.

`stop_hook_active` releases the refusal. Without it the block loops forever,
because the reply being rewritten to satisfy the hook has no tool call either.

Evidence this closes the real case, replaying this session's own turn 419:

  A) tag, zero tools      -> exit=2, "ran no verification tool. Untried
                             claim(s): that it widened scope past the one
                             session I gave it"
  B) same tag after Bash  -> exit=0
  C) stop_hook_active     -> exit=0, no loop
  D) no tag, no tools     -> exit=0

17 tests pass. check_hook_test_coverage.py now demands a positive firing test
for this hook and gets one (test_tag_with_no_attempt_is_blocked); it passes,
36 hooks checked.

Found by reflect on this session: both tags were emitted in the turn directly
after a diu-stop block, and diu-stop's own text at claude_stop_check.py:199-200
offers the tag as an alternative to pasted evidence -- so the tooling taught
the behaviour the prose forbids. Two sibling fixes to diu-stop itself (drop the
tag template from the complaint; do not bill a word-count cut against an
evidence demand in the same message) are follow-ups, not in this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018u8S5ct3kFhosinSbybc7W
Change-Id: I356d181c284194e7ee134f6c88ca5436c8e66313
@EdbertChan

EdbertChan commented Sep 12, 2026

Copy link
Copy Markdown
Owner Author

This pull request is part of a Mergify stack:

# Pull Request Link
1 feat(hooks): log CAT-UNVERIFIED tags instead of treating them as evidence #464
2 feat(hooks): refuse a CAT-UNVERIFIED tag from a turn that ran no check #468 👈
3 fix(hooks): wire unverified-tag-ledger into settings and declare its subagent opt-out #474

@cursor

cursor Bot commented Sep 12, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_249e3121-361d-4dc4-bc1b-5303f0a7786f)

@EdbertChan EdbertChan closed this Sep 12, 2026
@EdbertChan
EdbertChan deleted the stack/EdbertChan/reflect/unverified-tag-ledger-20260911/refuse-cat-unverified-tag-turn-ran-check--356d181c branch September 12, 2026 06:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant