Skip to content

feat(findings): every finding names its support; findings.ncl enforced in CI (HYP-01, PR B) - #922

Merged
hyperpolymath merged 2 commits into
mainfrom
claude/hyp-01-b-schema
Oct 14, 2026
Merged

hyperpolymath merged 2 commits into
mainfrom
claude/hyp-01-b-schema

Conversation

@hyperpolymath

Copy link
Copy Markdown
Owner

HYP-01, PR B of 3. It follows #920 (PR A, merged as ce5fc63). PR C (quarantine retraction) comes next.

Ruling applied: schema/ is canonical for findings. schemas/ holds no findings schema, and merging the two directories stays out of scope, as the issue says.

What changes

Every finding in CI output carries support, evidence and a derived level.

  • lib/hypatia/verdict.ex (Hypatia.Verdict) is the JSON mirror of src/Hypatia/ABI/Verdict.idr.
    • CLI.collect_findings/2 funnels every rule-module finding through Verdict.annotate/1 exactly once, after suppression.
    • Today every rule finding is support: {kind: "pattern"}, one evidence token (rule, rule version, span, observations), and level: "epi". Elixir rules carry the tag only; none is proved sound.
    • level is derived by Verdict.level/2, and a level a rule tries to set is overwritten.
  • Verdict.attach_recheck/3 is the only path to "sound". It refuses a re-check of another file, or one whose target digest doesn't match the bytes on disk. A refutes result derives "epi". No scan path attaches a re-check yet.
  • Bash fallback (hypatia-cli-bash.sh): same fields. The level comes from a jq def mirroring Verdict.level/2.
  • Pass/fail text is derived from the record. GitHub annotations and text output render [level: support] from it. SARIF puts hypatia.level and hypatia.support in the result property bag, outside partialFingerprints, so already-triaged alerts don't re-open. The level counts go on their own stderr line, leaving the existing summary line's shape unchanged.

schema/findings.ncl rewritten.

  • The old file never evaluated: its export block isn't valid Nickel syntax. generate-findings.ncl called std.env, which no Nickel release has. Nothing referenced either file, so the generator is removed.
  • The new schema describes what the producers actually emit: Findings (escript array) and BashReport (bash fallback).
  • The LevelDerived contract re-derives level from support + evidence and refuses a record that disagrees.
  • The rules.a2ml thresholds (5 to propose; 10 + 3 fixes to auto-approve) are labelled as belief formation.

Falsifiers

Gate Result locally Negative control
scripts/check-findings-schema.sh --self-test (CI: escript-soundness.yml) PASS: findings schema self-test, 3 accepted, 7 refused 7 bad fixtures, each refused with the message its .expect names: pattern / observed / refuted claiming sound, belief with evidence, missing support, malformed digest, unknown level
scripts/check-findings-schema.sh consumer.json (CI: same job, after the consumer-shaped scan) PASS: … satisfies Findings (42 findings; by level: {"epi":42}) The first schema draft refused real output ("line": null from cicd_rules, git_state and workflow_hardening); line now accepts null
mix test 1810 tests, 0 failures, 242 excluded (baseline 1796 / 0 / 242) Mutating level/2 to call any mechanical support sound → 2 failures
test/verdict_test.exs 14 tests Level names are read from Verdict.idr; the sound re-check is read from PR A's positive control, so the two sides can't drift
run-escript-soundness.sh 14/14 rules fired at expected severity —
ShellCheck New scripts clean; hypatia-cli-bash.sh has the same 74-line report before and after —

Backward compatibility

The fields are additive and every record is open. gitbot-fleet's consumers (run-fleet.sh, fleet-coordinator.sh, submit-finding.sh, apply-baseline.sh) and .hypatia-baseline.json matching all read by key. The escript still prints a bare JSON array, and severities are unchanged.

Notes on local verification

hex.pm is blocked by this session's network policy. Locally, the 14 locked deps were built from their upstream GitHub tags through an uncommitted MIX_EXS override. phoenix_template 1.1.0 isn't tagged on GitHub, so 1.0.4 was used; it satisfies phoenix's ~> 1.0. CI uses the real lock.

Known red checks that aren't this PR's

Security Status, Rust Dependency Audit and Rust License & Ban Check (the Security workflow) were already red on main at 4be38c8 (scheduled runs 2902–2904, before HYP-01). Security Status fails because it reads .github/workflows/security-policy.yml without a checkout step. This PR touches neither Rust nor that workflow.

Anomalies noticed, not touched

mix format --check-formatted flags lib/rules/workflow_audit.ex. That predates this PR, and no workflow gates formatting.

🤖 Generated with Claude Code

https://claude.ai/code/session_0165rGu3tsKmJtevmrdMVyfJ


Generated by Claude Code

…ed level (HYP-01)

Hypatia.Verdict is the JSON mirror of src/Hypatia/ABI/Verdict.idr.
CLI.collect_findings/2 funnels every rule-module finding through
Verdict.annotate/1 once, after suppression, so no module can emit a
finding without:

  support  - %{kind: "pattern"} for every rule match today (Elixir
             rules carry the tag, not a proof);
  evidence - one token: rule, rule version, span, observations;
  level    - derived by Verdict.level/2, never set by a rule (a
             forged level is overwritten): sound only for a mechanical
             re-check that confirms, epi with evidence, belief without.

Verdict.attach_recheck/3 is the only path to "sound". It refuses a
re-check of another file, or one whose target digest does not match
the bytes on disk; a refuting re-check derives "epi".

The bash fallback emits the same fields, with the level derived by a
jq def that mirrors Verdict.level/2. GitHub-annotation and text output
render "[level: support]" from the record; SARIF carries
hypatia.level / hypatia.support in the result property bag, outside
the fingerprint. The level counts go on their own stderr line, so the
existing summary line keeps its shape.

Fields are additive. gitbot-fleet's consumers (run-fleet.sh,
fleet-coordinator.sh, submit-finding.sh, apply-baseline.sh) and
.hypatia-baseline.json matching all read findings by key.

test/verdict_test.exs (14 tests) reads the level names out of
Verdict.idr and the sound re-check out of the positive control
tests/accept/LeanHoleRecheckSound.idr, so the two sides cannot drift.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0165rGu3tsKmJtevmrdMVyfJ
…ed in CI (HYP-01)

schema/ is canonical for findings (ruling 2026-10-14). The old
findings.ncl did not evaluate under Nickel 1.11 (its export block was
not valid syntax), and generate-findings.ncl called std.env, which no
Nickel release has. Nothing referenced either file, so nothing had ever
been validated. generate-findings.ncl is removed.

The new findings.ncl describes what the producers actually emit: the
escript's array (Findings) and the bash fallback's report
(BashReport). Both carry support / evidence / level, and the
LevelDerived contract re-derives the level and refuses a disagreeing
record, so a pattern match can never be reported "sound". The
rules.a2ml observation thresholds are labelled as belief formation.
Records are open, so the change is additive for consumers.

scripts/check-findings-schema.sh --self-test must accept 3 good
fixtures and refuse 7 bad ones, each with the message its .expect file
names (pattern/observed/refuted claiming sound, belief with evidence,
missing support, malformed digest, unknown level).
escript-soundness.yml runs that self-test, then validates the repo's
own consumer-shaped scan (Nickel 1.11.0, SHA-256 pinned). Locally the
self-scan validates: 42 findings, all epi. The first draft of the
schema refused real output: cicd_rules, git_state and
workflow_hardening emit `"line": null`, so `line` accepts null.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0165rGu3tsKmJtevmrdMVyfJ
@coderabbitai

coderabbitai Bot commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: bee0d3cf-b56d-4739-93c1-5a7ea90e08b2
📥 Commits

Reviewing files that changed from the base of the PR and between ce5fc63 and f8982d1.

📒 Files selected for processing: 28
  • .claude/CLAUDE.md
  • .github/workflows/escript-soundness.yml
  • hypatia-cli-bash.sh
  • lib/hypatia/cli.ex
  • lib/hypatia/sarif.ex
  • lib/hypatia/verdict.ex
  • schema/README.adoc
  • schema/findings.ncl
  • schema/generate-findings.ncl
  • scripts/check-findings-schema.sh
  • test/fixtures/findings-schema/bad-belief-with-evidence.expect
  • test/fixtures/findings-schema/bad-belief-with-evidence.json
  • test/fixtures/findings-schema/bad-malformed-digest.expect
  • test/fixtures/findings-schema/bad-malformed-digest.json
  • test/fixtures/findings-schema/bad-missing-support.expect
  • test/fixtures/findings-schema/bad-missing-support.json
  • test/fixtures/findings-schema/bad-observed-sound.expect
  • test/fixtures/findings-schema/bad-observed-sound.json
  • test/fixtures/findings-schema/bad-pattern-sound.expect
  • test/fixtures/findings-schema/bad-pattern-sound.json
  • test/fixtures/findings-schema/bad-refuted-sound.expect
  • test/fixtures/findings-schema/bad-refuted-sound.json
  • test/fixtures/findings-schema/bad-unknown-level.expect
  • test/fixtures/findings-schema/bad-unknown-level.json
  • test/fixtures/findings-schema/good-bash-report.json
  • test/fixtures/findings-schema/good-escript-sound.json
  • test/fixtures/findings-schema/good-escript.json
  • test/verdict_test.exs
 ______________________________________________________________________
< Stop swallowing exceptions. They don't become less real in the dark. >
 ----------------------------------------------------------------------
  \
   \   \
        \ /\
        ( )
      .( o ).
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@hyperpolymath
hyperpolymath marked this pull request as ready for review October 14, 2026 01:42
@hyperpolymath
hyperpolymath merged commit 988e9b7 into main Oct 14, 2026
83 of 87 checks passed
@hyperpolymath
hyperpolymath deleted the claude/hyp-01-b-schema branch October 14, 2026 01:45
hyperpolymath added a commit that referenced this pull request Oct 14, 2026
…name their facts (HYP-01, PR C) (#923)

HYP-01, PR C of 3. It follows #920 (PR A) and
#922 (PR B), both merged. There are three feature
commits, each reviewable on its own, plus one review-fix commit.

## 1. `build(siblings)`: pin residual-evidence-types at `97244f5`

This is a separate commit, one per pin. `Revision.agda` and
`Context.agda` at that pin are the specification for
quarantine-as-retraction (RE-1).

## 2. `feat(safety)`: quarantine is retraction

Quarantining a bot, a rule (`rule:<module>/<id>`) or a prover
(`prover:<name>`) retracts an assumption. Each evidence token rests on
its rule, and on its bot when it names one; a confirming re-check rests
on its prover. The rule is RE-1's `survives-retraction` /
`survives-dependent`:

- A token survives iff its support thins into what remains.
- A finding survives through any surviving token, or through a
confirming re-check whose prover isn't retracted. A sound finding
therefore survives the quarantine of the rule that flagged it, but not
the quarantine of its prover. A refuting re-check supports nothing, so
its prover is never named.
- A survivor keeps only its surviving tokens, and its level is
re-derived from them.
- A finding with nothing left moves to the retracted list, keeping its
original evidence and naming what it rested on in `retracted_by`
(JSON-safe wire names).

**`src/Hypatia/ABI/Retraction.idr`** (`%default total`) transcribes the
shape:
- `Thin` (Context.agda `_⊆_`), `Retraction`, `retractThin`
(`retract-⊆`);
- `Dependent`, without `Holds`, which hypatia can't state as a type;
- `survivesDependent`, `retractedSupportDies`, `survivor`.

It is gated by `scripts/check-verdicts.sh`:
- `tests/reject/RetractedSupportSurvives.idr` (the FalseRetraction
pattern, transcribed) is refused at `invalid` with `Mismatch between:
[?x] and [].`
- `tests/accept/IndependentTokenSurvives.idr` is accepted.

**Runtime:** `Hypatia.Verdict.Retraction`, plus
`Quarantine.retracted_assumptions/0` and
`Quarantine.retract_dependents/1`. A soft quarantine retracts too, since
a high false-positive rate is exactly a reason to stop trusting the
findings.

## 3. `feat(dispatch)`: every manifest step names its facts; factless
steps are refused

You ruled that Signalbox's thesis applies to hypatia's own derived
workflow, since no Signalbox repository exists. The dispatch manifest is
the workflow hypatia derives for `dispatch-runner.sh`.

- Every step now carries `facts`: the pattern it acts on, its support
(`observed` with the fleet occurrence count, or `pattern`), its
evidence, and the derived level.
- `DispatchManifest.compile/1` refuses the **whole** manifest if any
step names no fact, and `write/2` then writes nothing.
- `PatternAnalyzer.dispatch_and_write/2` compiles the manifest
**before** anything reaches the fleet. The routed actions are
dispatched, and the manifest written, only when every step names its
fact. A refusal dispatches nothing and is reported as path nil, zero
counts and the steps named, rather than crashing on `{:ok, _, _} =`.
- A step's Bayesian `confidence` stays in its own field and is never a
fact.
- `facts` is additive for `dispatch-runner.sh`, which reads entries by
key.

## Review round (CodeRabbit), commit `49c9653`

| Finding | Result |
|---|---|
| 🟠 Major: factless actions reached the fleet before the manifest was
refused | Fixed: `dispatch_and_write/2` compiles first. New test plus
negative control; the dispatch-before-compile mutant fails it |
| 🟡 Minor: a refuting re-check's prover was named in `retracted_by` |
Fixed: only a confirming re-check is retracted support. New test; the
old rule fails it |
| 🔵 Trivial: `dependent-holds` citation row vs header | Fixed: the row
is marked "named as deliberately not transcribed" |

## Gate output (local; verify-proofs runs on this PR since it targets
main)

```
PASS: accepted positive control IndependentTokenSurvives
PASS: accepted positive control LeanHoleRecheckSound
PASS: rejected MalformedDigest at invalid: Can't find an implementation for So False.
PASS: rejected ObservedIsNotSound at invalid: Mismatch between: Mechanical recheck and Observed 5.
PASS: rejected PatternIsNotSound at invalid: Mismatch between: Mechanical recheck and Pattern.
PASS: rejected RefutedRecheckIsNotSound at invalid: Mismatch between: Confirms and Refutes.
PASS: rejected RetractedSupportSurvives at invalid: Mismatch between: [?x] and [].
PASS: ABI package, 2 positive control(s), 5 expected rejection(s)
sibling citations: checked 14, stale 0
PROOF-STATUS currency: files named 39, identifiers checked 57, mismatches 0
trusted-base markers: fixture=19 allowed=3 comment=1 debt=0
1825 tests, 0 failures, 242 excluded        (main: 1810 / 0 / 242)
PASS: … satisfies Findings (41 findings; by level: {"epi":41})
PASS: findings schema self-test, 3 accepted, 7 refused
```

**Mutants, all red:**
- Survival premise checked against Γ instead of Δ: the ABI doesn't
build.
- A token survives if *any* of its assumptions remains: 1 test fails.
- `compile/1` never refuses: 1 test fails.
- Dispatch before compile: 1 test fails.
- Any mechanical prover counts as retracted support: 1 test fails.

**Negative controls:**
- A finding not resting on the quarantined rule is untouched.
- After release, nothing is retracted.
- A fact-complete manifest dispatches every action.

## Status rows (PROOF-STATUS "Verdict support")

| Row | Status | Artefact |
|---|---|---|
| `Retraction.idr` lemmas | PROVEN | `retractThin`, `survivesDependent`,
`retractedSupportDies`, `survivor`, `noTokensNoSurvival` |
| RetractedSupportSurvives refused / IndependentTokenSurvives accepted |
TESTED | `check-verdicts.sh` lines above |
| Correspondence to Revision.agda | ASSUMED | pin + 9 names checked |
| Runtime: quarantine retracts dependents | TESTED |
`test/retraction_test.exs` (10 tests, including quarantine → retract →
release through the live GenServer) |
| Runtime wiring: called by a scan or dispatch path | **OPEN** | it
isn't, like the rest of the safety systems (CLAUDE.md Known Gap 2) |
| Derived steps name their facts; factless refused before dispatch |
TESTED | `test/dispatch_manifest_test.exs` (5 new) |
| Signalbox-derived workflow | **OPEN** | no Signalbox repository exists
|

## Known red checks that aren't this PR's

`Security Status`, `Rust Dependency Audit` and `Rust License & Ban
Check` were already red on `main` before HYP-01 (scheduled runs
2902–2904 at `4be38c8`). Root causes and proposed patches are in [this
comment](#923 (comment)).
This PR touches no Rust and not that workflow.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_0165rGu3tsKmJtevmrdMVyfJ

---------

Co-authored-by: Claude <noreply@anthropic.com>
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