Skip to content

Complete receipt-backed binding compliance and retire legacy claims - #219

Open
evildarkarchon wants to merge 66 commits into
classic-nextfrom
refactor/project-phoenix
Open

Complete receipt-backed binding compliance and retire legacy claims#219
evildarkarchon wants to merge 66 commits into
classic-nextfrom
refactor/project-phoenix

Conversation

@evildarkarchon

Copy link
Copy Markdown
Owner

Summary

  • Add a shared Rust-owned scan-run presentation contract for typed display content, recovery decisions, abandonment, and consistent wording across C++, Qt, TUI, Node, and Python consumers.
  • Introduce executable conformance packs, adapters, applicability and coverage policies, authenticated receipts, reports, and equivalence documentation across the supported operation families.
  • Add blocking same-revision CI aggregation for Rust, Node, Python, and native C++ participants, requiring complete receipt coverage.
  • Retire legacy runtime registries, claim-only summaries, migration ledgers, duplicated assertions, and frontend rewording while updating parity gates, fixtures, documentation, and consumer tests.

Testing

  • Not run locally; this response only drafts the change request.
  • CI is configured to run the participant workflows, the ci compliance profile, and full receipt aggregation.

evildarkarchon and others added 30 commits August 5, 2026 21:21
- Document the Display Content/Layout split and implementation plan
- Resolve infrastructure error stage labels through the core vocabulary in the Python CLI
- Add Ruff to Python development dependencies and lockfile
- Document centralized wording tests and thin frontend renderer audits
A paused Crash Log Scan Run reports whether Reset To Default can succeed
at all, via `local_ignore_reset_available`. The TUI honoured it; both C++
frontends offered the decision unconditionally. Choosing it spends the
one-shot continuation before core validates it, so the user is left with
no scan, no repair, and no second attempt without starting over. Nothing
on disk is touched, so this is a recoverability gap, not data loss.

Native CLI: the prompt seam could not receive the fact at all — its
callback carried only presentation lines. It now carries
`CliLocalIgnoreRecoveryPresentation { details, reset_available }`, and
`read_cli_local_ignore_recovery_choice` takes the availability. When it
is false the `[R]` line is not printed, the bracketed letters narrow to
`[P/C]`, and `r`/`reset` are rejected like any unrecognized word. The
attempt limit, EOF handling, and cancellation checks are unchanged.

Qt GUI: `ScanRunInstalledYamlDataPresentation` mirrored every
neighbouring DTO field and dropped this one; it now projects
`localIgnoreResetAvailable`. The prompt typedef, the controller hop, and
`promptLocalIgnoreRecoveryChoice` gained a `resetAvailable` parameter,
and the reset button is not created when it is false. The null guard on
`clickedButton() == resetButton` is load-bearing: both are null when the
box is closed without a click, so without it a dismissal would report a
durable reset the user never asked for.

All three frontends treat absent Installed YAML Data as available.
Silence is not a denial, and withdrawing an option on an unknown answer
would regress the behaviour that shipped before the fact existed. The
GUI resolves it in one place, `offersLocalIgnoreResetToDefault`, because
`localIgnoreResetAvailable` defaults to false to mirror the bridge DTO
and a site that skipped the presence check would fail the other way.

Tests: the CLI case that pinned the reset option as unconditional is
split into an available case and an unavailable case; a Qt case asserts
the button is absent, that abandonment still cancels, and that the
remaining decisions still work. The end-to-end cases assert availability
survives Rust, the bridge, and the Qt projection in a real run. Both
new cases were confirmed to fail against the restored bug.

Wording matches the TUI's existing sentence verbatim rather than being
phrased afresh, so the presentation crate has one wording to adopt
rather than three to reconcile.

No Rust, core, or binding surface changed; all three parity baselines
are untouched. Verified with `classic-cli/build_cli.ps1 -Test` (71 CTest
cases, 24 integration scenarios) and `classic-gui/build_gui.ps1 -Test`
(21 tests).

Closes #171

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CrashLogScanRunStatus, ScanProgressPhase, LocalIgnoreRecoveryDecision, and
InstalledYamlDataRole now implement the Vocabulary naming contract. Tokens are
byte-identical to what each surface already published; only Display Labels are
added. Closes #172.

Labels follow the glossary rather than any one frontend. CONTEXT.md already
defined a run status as "completed, no Crash Logs found, Local Ignore recovery
required, setup failed, cancelled before discovery, or cancelled after
discovery", so those six are recorded rather than invented -- which is why
Cancelled reads as "cancelled after discovery": its bare token cannot be told
apart from CancelledBeforeDiscovery, and telling them apart is why both
variants exist. The phase labels settle the widest divergence of the four, the
TUI's participles against the GUI's nouns, in favour of the only form that
reads grammatically in the progress sentence both build around it.

InstalledYamlDataRole is the one adopter whose forms differ by case alone. Its
Display renders the *label*, not the token, because every consumer splices it
into prose; routing it through as_str would have silently downcased Main.

ResumeErrorKind deliberately stays token-only. It is a stable error code, not a
name a person reads, and the reason is recorded on the type.

The TUI's naming table for the progress phase is gone -- the four participles
now resolve through label(), output byte-identical. Node and Python delegate
their duplicated token tables to the core, and three hardcoded expectation
arrays in their projection tests became VARIANTS loops. Node keeps publishing
the core token unchanged rather than through js_token: the scan-run surface's
tokens are snake_case, and camelizing one would rename a value consumers match
on.

The TUI audit covers two of the four. CrashLogScanRunStatus and
LocalIgnoreRecoveryDecision sit in a new DEFERRED_ENUMS constant with their
reasons -- the first is still composed into count-bearing sentences only the
presentation crate can absorb, the second has no table at all and trips only
arm_body_end's documented over-read. A meta-test asserts every adopter is
either audited or explicitly deferred, so the gap is checked data rather than a
comment that rots.

Two baselines move by exactly two rows plus a timestamp: replacing the inherent
as_str on two enums deletes entries the Node and Python surface parsers see but
trait members are invisible to. parity_contract.json, both surface inventories,
and both coverage-registry hashes are untouched, which is the shape
binding-parity-policy.md documents for this case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Gate condition 4 of the Local Ignore Recovery Phase asked whether the Qt GUI
can present a recovery prompt driven by core-supplied decision descriptions
without blocking its event loop. It can, and the path is the one the GUI
already ships: Qt::BlockingQueuedConnection parks the scan worker thread, not
the GUI thread, and QMessageBox::exec() starts a nested loop on the GUI thread
rather than suspending it.

The two existing tests each cover one half of that seam. The dialog test calls
the real modal directly on the GUI thread with no worker, and the controller
test drives the real worker-thread marshalling but answers with a stub and
pumps the GUI thread by hand -- which would mask exactly the freeze in
question. This joins them and measures the loop with a heartbeat timer, a
sibling window's repaints, a call queued from inside the nested loop, and
synthesized input, all while the GUI thread sits in an ordinary
QEventLoop::exec().

Four cases isolate the controller seam; two more run a real ScanWorker on a
real thread against a genuinely malformed Local Ignore, so the legs that only
reason could reach -- taking the one-shot continuation before the prompt, and
the block_on resume after it -- are executed rather than argued. Abandoning
and choosing a decision are exercised on both paths.

The finding records the five constraints this fixes on the shared prompt shape:
the call stays synchronous and value-returning, its payload stays copyable and
carries no continuation or rust::Box, rendering happens on the worker thread
before the hop, the answer stays a plain enum, and the same-thread
short-circuit in makeLocalIgnoreRecoveryPrompt must survive because
BlockingQueuedConnection self-deadlocks without it. The prompt shape the brief
proposes satisfies all five. The CLI and TUI are unconstrained by any of it.

Refs #173, #170.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rust now owns Crash Log Scan Run Display Content. The new crate turns a run
result, a run event, an infrastructure error, or a resume error into an ordered
sequence of display lines, each a severity plus typed segments, so a frontend
can style a path as a link or emphasise a number without re-deciding the words
around it.

Nothing consumes it yet, by design: the wording ships pinned first so the
frontends that follow have something fixed to render.

Core never interpolates. Counts, paths, and names stay typed payloads, and a
count arrives with its noun already agreeing with its value. Agreement is
structural rather than remembered -- CountedNoun exposes only for_value(), so a
renderer names a noun pair and the value picks the form. Writing "1 logs" is not
a bug the tests catch; it is one the type will not let you write.

The crate is deliberately separate from classic-scanlog-core rather than a
module inside it: that crate's scan_run and scan_run::contract already form a
cycle, and presentation placed inside would deepen it. As a crate, Cargo
enforces the one-way edge. It also depends on classic-config-core, because the
scan-run contract exposes Installed YAML Data types it does not re-export --
the same edge classic-tui carries for the same reason.

Two renderers take typed facts rather than the structs carrying them, because
InspectedYamlDataFile and InstalledYamlDataRunDiagnostic are accessor-only with
no public constructor. Shaping them that way pins the Installed YAML Data block
without widening classic-config-core's API for a test.

Out of scope and deliberately absent: the Local Ignore recovery prompt, which
lands with the gated recovery phase, and the FCX Mode setup projection, whose
four types have not adopted the shared vocabulary.

Adds docs/api/classic-scan-presentation.md, indexed and placed in the layering
rationale.

Refs #174

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Render scan results and events from Rust-owned display segments
- Preserve TUI-specific layout, recovery handling, and audit coverage
Severity now reaches the status row, not just the two overlays. That row is
written from around sixty places across app.rs, event.rs, and the three workflow
modules, and all but a handful carry no severity at all -- so App stores the
severity paired with the exact text it applied to. Any other write silently
invalidates it, which keeps a failed run's red off the unrelated message that
replaced it without asking sixty call sites to remember to clear a flag.

The recovery overlay stated why the run paused three times over: its own title,
core's status line, and core's Message: line. The copy this frontend was making
is gone, along with the invented fallback sentence behind it -- when a paused run
carries no message the status row falls through to core's rendered status instead
of to prose written here.

classic-vocabulary moved to dev-dependencies. No non-test code in the crate calls
label() any more, because every label the TUI shows arrives inside a
DisplaySegment::Label, and that is the clearest single signal the migration
worked.

Also restores a test rationale that outlived the code it described, replaces a
byte slice that would panic on a multi-byte sentence-initial character, and
strengthens the resume-failure assertion from "not empty" to core's own headline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The native CLI now describes a run in the same words the TUI does, because
both render the display lines classic-scan-presentation produces.

C++ never holds a Rust run result, so the bridge renders while the Rust value
is live and carries the lines across as mirrored data. The continuation is
still taken out of the result first, which is now load-bearing rather than
incidental: the render entry point borrows, so moving it out afterwards would
not compile. Observed events are rendered inline on the observer callback,
before the event reaches C++.

cxx cannot express a payload-carrying Rust enum, so a segment crosses
flattened as a ScanRunDisplaySegmentKind tag plus text, path, and count
fields, with the fields the kind does not use left empty and a count's
Rust-resolved noun in the text field.

execute and resume return the same ScanRunContractExecutionResult, so one
display_lines field covers both entry points and all three payloads rather
than four fields saying the same thing. The seven scan_run_*_label entry
points are unchanged; they remain correct for labelling an enum outside a
display line, and the Qt GUI still uses them.

The CLI keeps section ordering, exit codes, which event kinds earn a durable
line, and which stream a line reaches. Its output stays plain and pipeable:
the per-segment styling is deliberately the empty choice for every kind. What
it loses is the ability to invent or reword what the run says. Its event
rendering migrated too, beyond the ticket's acceptance list, because leaving
it composing sentences would have kept exactly the drift this removes.

Wording is pinned once, in the presentation crate. The CLI tests became
renderer conformance over deliberately unreal words, and the display-label
audit inverted: the CLI calls no label accessor now, so their absence is
asserted and the positive half moved to "the renderer reads every kind".

Refs #176

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three defects surfaced by the display-line work, none introduced by it.

InstalledYamlDataRole's Display Label is asymmetric on purpose: Main names a
glossary domain term while game is the ordinary adjective in "selected-game
YAML Data", and the lowercase form is load-bearing in "no usable Installed
YAML Data source for game". The presentation crate put it at the start of a
line anyway, so "game YAML Data: bundled" sat under "Main YAML Data: bundled"
and read as a capitalization bug rather than the distinction it is. The
sentence now opens on fixed prose - "Selected Main/game YAML Data:" - so the
label is never line-initial, and a test pins that rather than the wording.
The label itself is untouched, being correct everywhere it was designed to
appear. This affected the TUI as well as the CLI.

The parity parser's name scan is a keyword regex over raw text, so a doc
comment merely mentioning `enum Foo` became a contract row. Three phantoms
had reached the committed baseline: definitions twice, from a comment reading
"cannot share enum definitions", and mirroring. Harmless until somebody
reworded the sentence, at which point a bridge that had not changed reported
drift - which is exactly what happened while adding the display types.
Comments are now stripped before the scan. Three rows removed, no real row
added, changed, or dropped. The comments contorted to dodge the bug are
restored to their natural wording, and the gate stays green on them.

--update-baseline mirrored the committed contract straight back, so it could
never accept an added, removed, or modified item: the contract returned
unchanged and the next run reported the same drift. The flag only refreshed
reports, while the contributor guide documented it as the one-step way to
accept an intentional bridge change - the two-step bootstrap was the only
path that worked. It now rewrites the contract from current source, reports
what it accepted, and exits 0. The committed reports are recomputed against
the contract they ship beside, or the next run would read the fresh baseline
as stale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`CrashLogScanRunContinuation::abandon` cancels a paused Crash Log Scan Run and
then claims its continuation with a decision the run never acts on. `resume`
checks cancellation immediately after the one-shot claim and before the retained
recovery plan is consumed, so neither Proceed Without Ignore nor Reset To Default
is ever applied: no backup, no publication, and the malformed Local Ignore file
is left exactly as it was. The caller gets the ordinary post-discovery cancelled
result.

`LocalIgnoreRecoveryDecision` keeps its two variants. A third would be the honest
domain fix, but it reshapes a type crossing five binding surfaces and reopens the
scan-run ADR's cancellation semantics, so this operation encapsulates the
workaround instead.

The TUI's dismissal path now routes to `abandon` rather than picking the
placeholder decision itself. The `cancel()` moves from the UI thread into the
spawned task; nothing reads `is_cancelled()` on that control in between, and the
only other consumer just calls `cancel()` again, which is monotonic.

Contract tests pin the cancelled result, single consumption, replay through both
seams, and an untouched filesystem — compared as whole trees, so a stray Autoscan
Report or Local Ignore backup fails as a readable path diff. The shared
cancelled-after-discovery assertions are extracted so the pre-cancelled `resume`
path and the `abandon` path cannot drift apart.

`abandon` is Rust-only for now: it is a new inherent `pub fn`, so the Node and
Python `rust_api_surface.json` baselines are refreshed, leaving both
`parity_contract.json` files untouched. Exposing it across the bindings and
adopting it in the native CLI and Qt GUI is issue #181.

Closes #177

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both tests claimed to cover concurrent replay, and neither did. `tokio::join!`
polls both futures on one task, and the claim inside `resume` is a synchronous
`take()` under a mutex with nothing awaited before it — so the futures always
resolved in poll order, the mutex was never contended, and "exactly one
succeeded" could not have failed.

`race_two_claims` runs the two claims on real OS threads released together by a
barrier. Verified parallel: with a 400ms sleep injected per thread the test
completes in 0.41s, not 0.8s. The barrier also makes the arrangement
self-verifying, since `Barrier::wait` cannot return until both threads arrive —
if the two ever stopped overlapping this would hang rather than quietly pass.

The helper's docstring bounds what this proves rather than overstating it.
`Option::take` under a `Mutex` makes two winners unreachable for any lock
discipline that compiles, so this is not a test a non-atomic claim would fail.
It proves the contract holds end to end under real parallel entry: one winner,
the loser gets the typed consumed error rather than a wrong-typed one, and
neither thread deadlocks or panics.

`proceed_without_ignore_continuation_rejects_every_replay` had the same defect
and gets the same fix; leaving it would have left the identical false claim
sitting next to the corrected one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Qt GUI now describes a run in the same words the TUI and the native CLI do,
because all three render the display lines classic-scan-presentation produces. A
diagnostic a user learned to read in one frontend is readable here, and a wording
fix reported once reaches this window too.

No bridge surface was added: this phase reuses the DTOs #176 landed, so no parity
baseline moved and no gate had to be regenerated.

What the GUI kept, all of it Display Layout: ScanRunTerminalKind and its
one-to-one mapping onto lifecycle signals, the counts, the discovery-ordered
logs, the FCX Mode setup projection grouped in after the rendered lines, and the
run-level Installed YAML Data warning's decision to interrupt. What it gained is
styling: severity to a dark-theme colour, Count to an emphasised value beside
Rust's noun, Emphasis to italics, and Path to a file: anchor that
showScanRunMessage makes clickable with Qt::TextBrowserInteraction.

The terminal signals that carry prose carry rich text, because all three end in a
QMessageBox. The progress row cannot: setStatusMessage writes a QProgressBar
format string, so scanRunStatusLine reduces the block to its leading line, which
is safe because every render entry point opens on the line stating the outcome.
The signal plumbing itself is untouched, invariant errors for a missing
continuation and a missing prompt included.

Event rendering migrated too, beyond the ticket's acceptance list, for the reason
the CLI's did: leaving eventStatus composing "Found N crash logs" would have kept
exactly the drift this removes in a frontend whose event DTO now carries the
rendered lines. The per-log outcome line moved out of the worker with it — three
composed shapes replaced by logging the rendered run once, and
ScanRunLogPresentation::failures went with them.

Five of the seven bridge label accessors lost their last GUI caller. The two that
survive both label an enum outside a display line, in the one-row YAML Data
status suffix that has no room for a rendered block. test_display_label_audit
inverted for the other five and asserts their absence; its positive half moved
down a level to "the renderer reads every segment kind and every severity".

Per-log disposition stays unlabelled here, as since #167: presentLog still maps
the three variants onto booleans and invents no results-view column.

The Installed YAML Data warning keeps its policy and loses its prose. Its body is
the run's rendered lines under a GUI-owned header, because Rust exposes that
block only through render_run_result and picking it back out by position would be
a structural assumption about a sequence that carries no structure. Which lines
it withholds is preserved exactly, matched on a diagnostic message payload the
GUI already holds typed rather than on prose.

formatScanRunRejections is the one sentence about a run this frontend still
composes. The event's rendered lines state the rejection count but not the
per-rejection path and reason, which the GUI shows at discovery time; migrating
it needs a core change and belongs with the Node/Python phase.

Refs #178

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes #179.

A Node consumer now receives what a run says, in Rust's words, alongside
the projected result and on every observed event, so a frontend built on
this binding speaks with CLASSIC's voice instead of reimplementing its
wording.

This is the first surface with no frontend of its own. It renders nothing
and styles nothing; it carries the lines and hands them to whatever a
consumer builds. That made the flattening decision easy: reuse the C++
bridge's shape unchanged -- a kind tag plus text, path, and count, with
the fields the kind does not select left empty rather than absent --
rather than an idiomatic one with optional payloads, because a consumer
reading two bindings would otherwise read the same segment two ways.

Three fields, not one. The bridge has a single execution envelope with
presence flags, so one display_lines covers all three payloads. Node
resolves two envelopes and rejects a resume error rather than returning
it, so the same coverage costs displayLines on JsScanRunSuccess, on
JsScanRunFailure, and on the rejected error beside its stable code. The
envelope pair is shared by scanRunExecute and scanRunResume, which keeps
the initial run and the continuation resume on one field each.

Rendering the rejected resume goes beyond the ticket's acceptance list,
which named only the two results and the event. The bridge renders resume
errors, and leaving them out would have left a Node consumer composing
its own sentence for a reset conflict -- the exact drift this work
removes, in the place where wording matters most. It is purely additive.

The replay rejection was routed through the shared builder on the way.
ScanRunResumeTaskOutput::ContinuationConsumed used to construct its own
code and message by hand; both strings were already byte-identical to
ResumeErrorKind::as_str and ResumeError's Display, so the pair recorded
the agreement instead of checking it -- and it is what would have left
that one rejection with nothing to say. It gains kind and displayLines
and changes neither the code nor the message; a test pins both as
literals.

The two hand-written ts_arg_type observer unions had to move with the
struct. They narrow JsScanRunEvent to the payloads each kind carries,
which the flat all-optional struct cannot express, and they are a second
copy that no compiler checks against the Rust. The CLI build caught the
disagreement.

Wording stays pinned once, in classic-scan-presentation. No test here
restates a sentence: the Rust tests pin the flattening -- every kind
fills exactly its own fields, segments keep their order, every severity
has a distinct twin, a count carries the noun Rust agreed with, every
resume variant keeps its code out of the prose -- and the Bun and Node
suites prove the same facts across a real run, plus the one thing a unit
test cannot: that every Autoscan Report the run wrote arrives as a whole
Path segment.

Only node_api_surface.json moved in the baseline. No Tier-1 row changed,
because the parity contract maps Tier-1 core crates to Node exports and
classic-scan-presentation is not one of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ce markers

Two follow-ups to the Node display-line work, both of the same shape: a second
copy of something, drifting from the thing it copied.

## The Node demo CLI composes its own sentences no longer

`node-bindings/classic-node/cli/run-scan.ts` is a frontend on the binding rather
than part of it, so no ticket named it. It was migrated anyway, because leaving
it would have left a live `plural(count, "log", "logs")` sitting directly on a
surface that had just started carrying Rust's already-resolved nouns -- one
import away from the thing that removes it.

It renders exactly as the native C++ CLI does: segments concatenated in reading
order with single spaces, no styling, no capitalization rule, paths whole.
Severity is spent entirely on the choice of output stream, cut at Warning for
the same reason -- a run paused awaiting a Local Ignore decision carries that
severity and belongs on stderr.

The sharpest thing this fixes is a raw token printed as prose. The
infrastructure failure path read `${error.stage}: ${error.message}`, so a user
was told the run failed during `formid_database_access`. That is the same bug
#170's problem statement records against `classic-py-cli`, surviving in a second
frontend. It is fixed the same way, and `error.stage` still carries the token
for anything matching on it.

Three invented fallback sentences went with it -- a setup-failure headline, a
two-clause explanation of what Local Ignore recovery means, and a no-logs line
that named directories from `process.cwd()` and the configured scan path rather
than the ones discovery actually searched. The completed summary lost `Scanned`
and `Errors`, exactly as the native CLI's did, and keeps `Reports`, `Failed`,
`Duration`, and `Speed`: the two aggregates over per-log outcomes the contract
does not tally, and the two facts from a clock it does not carry.

JSON mode keeps its shape and takes on no Display Content. Which event kinds
earn a console line is unchanged. The two display enums are `const enum`, so
tsc inlines them and erases the import -- load-bearing, because this command
resolves the binding at run time and must not gain a static require of
`../index.js`.

Wording assertions were re-anchored on paths and exit codes the payload carries,
matching the call the native CLI's end-to-end tests made. Renderer conformance
moved down a level into three tests over fabricated lines. Two assertions were
added for the drift itself: the summary must not print `Scanned:` or `Errors:`
again.

## Two stale evidence markers in the shared scan-run manifest

`scan-run-contract-variants` was failing, blocking, before any of this work
started. `presentations.rust_owned_facts` named four test identifiers by string;
the CLI's two were deleted by the CLI display-line migration and the TUI's two
by the TUI migration, both of which renamed the tests around what those
frontends now do. The check fails fast, so only the CLI pair was visible.

Both facts are still covered under new names, so the markers are re-pointed
rather than the tests renamed back: `an_event_renders_cores_lines_in_cores_order`
and `per_log_lines_stay_in_discovery_order` for the TUI, and the matching
"stated in Rust's words" / "never reorders" pair for the CLI.

Worth naming the shape: a marker list is a copy of a test's identity living in a
JSON file, and it rots exactly like the per-frontend naming tables this epic
deleted -- silently, because a renamed test still passes.

`python tools/binding_compliance/check_compliance.py --profile full` now passes
20/20 with the one documented non-blocking CXX runtime-coverage gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes #180.

The last of the render migrations. A Python consumer now receives what a run
says, in Rust's words, and `classic-py-cli` states a Crash Log Scan Run in the
same sentences as every other frontend.

## The binding

`ScanRunDisplayLine` and `ScanRunDisplaySegment` mirror the C++ bridge's
flattening unchanged -- a kind tag plus text, path, and count, with the fields
the kind does not select left empty rather than absent -- because a consumer
reading two bindings would otherwise read the same segment two ways.

One field, not three. Python's `ScanRunExecution` has result/error presence like
the bridge's single envelope, and `scan_run_execute` and `scan_run_resume`
return the same type, so one `display_lines` covers the initial run, the
continuation resume, and both payloads. Node needs three only because it
resolves two envelopes and rejects a resume.

The two tags are snake_case token strings rather than pyo3 enums. Every other
output tag this surface publishes -- a run status, an event kind, a log
disposition, a scan phase -- already is one; the surface's only pyo3 enum is an
input. Parity is the flattening beneath the tag, not its spelling per language.
`path` is a `str` because every other kind must leave it empty and an empty
`Path` is `Path('.')` -- a wrong value rather than an absent one.

The five resume exceptions carry lines too, beyond the ticket's acceptance list,
for the reason Node's rejected resume does: leaving them out would leave a
consumer composing its own sentence for a reset conflict. The replay rejection
was routed through the shared builder on the way; `scan_run_reset_error_to_py`
became `scan_run_resume_error_to_py` and absorbed the arm the entry point built
by hand. Both hand-written strings were already byte-identical to
`ResumeErrorKind::as_str` and `ResumeError`'s `Display`, so the pair recorded
the agreement instead of checking it. It gains `kind` and `display_lines` and
changes neither the code nor the message; a test pins both as literals.

Only `python_api_surface.json` moved in the baseline, mirroring the Node phase.

## The CLI

`classic-py-cli` is where #170's raw-token bug lives:
`f"...failed during {stage_label}: {message}"` told a user a run failed during
`formid_database_access`. It is gone, and `error.stage` still carries the token.

Four invented sentences went with it. The four terminal branches collapsed into
a status-to-exit-code table: the exit codes are this frontend's and are
unchanged, the prose beside them no longer exists here. The Installed YAML Data
block appears for the first time, arriving inside the rendered sequence.
`_infrastructure_error_stage_label` is deleted with its docstring, and with it
this CLI's only call into the six label accessors.

Severity reaches no surface. This CLI writes one stream of plain lines through
an output envelope every command shares and the compliance harness reads
structurally, so routing by severity would change that shared contract rather
than this run's presentation. Mapping every severity onto plain text is
explicitly correct; the severity stays on the line.

JSON mode is untouched in shape. Where `error.message` fell back to a sentence
written here it now takes the run's leading rendered line, so that field is
Rust's words in every branch. One sentence stays this CLI's: a binding that
hands back a run and says nothing about it is a broken binding promise, not
something a run said.

## The audit

The audit this frontend never had, which is the direct reason the token bug
survived here after the same fix landed in the TUI. It ports the TUI's coverage
test and the C++ audits' inverted accessor-absence assertion, and adds the
sentence-template detector the brief has owed since step 2 -- enforceable only
now that every frontend has stopped writing those phrases.

Three things it does that are worth copying. It covers all four ways Python
builds a string, so a contributor reaching for `+` instead of an f-string does
not pass. Its coverage test walks recursively, so a future subpackage cannot
escape -- the exact failure it exists to prevent, in the one shape a top-level
glob would not see. And each detector is run against the drift it catches,
because an audit whose detector never fires reads as coverage while providing
none. Comments and docstrings are excluded from the literal scans, for the
reason the CXX parity gate's name scan was fixed: a comment describing the drift
is not the drift.

No test restates a sentence. Wording stays pinned once, in
`classic-scan-presentation`. The Rust tests pin the flattening; the Python suite
proves the same facts across a real run plus the one thing a unit test cannot,
that every Autoscan Report the run wrote arrives as a whole `path` segment.

The segment taxonomy held: six kinds, unchanged, across all three baselines.

Validation: 524 pytest, 38 crate tests, `cargo fmt`/`clippy --workspace` clean,
stub validation 17/17 with warnings as failures, Tier-1 Python parity gate
green, CXX parity gate green, binding compliance full 20/20 with the one
documented non-blocking gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comments and docs only; no behaviour, no surface, no gate moves.

The reasoning left behind for `ScanRunContinuationConsumedError.kind` was the
weak half of it -- that the shared builder made the field free. That reads as
an accident kept for convenience, and a reviewer read it exactly that way.

The reason that holds is Python-internal: the four reset exceptions beside it
have published `kind` since they were written, so this was the one member of the
resume family for which `except (...) as error: error.kind` raised
`AttributeError`. The builder made the field free; the family's existing shape
is what makes it correct.

Also names what is explicitly *not* the reason. Node publishes the same property
on its own resume rejections, but binding surfaces answer to the core's
contract, not to each other's shapes -- `node-python-contract-map.md` says so
outright. Citing cross-binding symmetry as justification would invite the next
contributor to widen one surface because another one is wider.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…h native frontends

`CrashLogScanRunContinuation::abandon` was Rust-only: the TUI called it directly
while the native CLI and the Qt GUI each wrote the cancel-then-resume-with-a-
placeholder sequence it encapsulates. Both now route their cancel choice through
the shared operation, and it reaches all three binding surfaces so a consumer can
abandon a run the same way.

- CXX: `scan_run_continuation_abandon`. Unlike `scan_run_continuation_resume` it
  does not throw -- resume is fallible only because it must reject an
  out-of-range recovery decision before claiming the one-shot continuation, and
  abandonment takes no decision. A `throws` contract that can never fire would
  force callers into unreachable handling.
- Node: `scanRunAbandon`. Python: `scan_run_abandon`. Both model abandonment as
  the absence of a decision rather than a third variant, and share one claim
  path with their `resume` sibling so the two cannot drift.
  `LocalIgnoreRecoveryDecision` still has exactly two variants everywhere.
- Both frontends map the choice through an exhaustive switch producing an
  optional decision, so a choice added later trips -Wswitch rather than silently
  resolving to Proceed Without Ignore.

Neither frontend cancels first any more; the shared operation cancels the
control itself, strictly before the claim, which is the ordering each
hand-written copy had to get right independently.

Tests assert what a user would notice: the run reads as cancelled with its
retained discovery intact, the control is left cancelled, the malformed Local
Ignore and the absent backup directory are untouched, no report is written, and
the shared claim rejects a later abandon and a later resume alike. "An abandoned
run emits nothing" is pinned from C++, because `ScanRunObserver` is a C++ virtual
class no Rust-side bridge test can implement. The pre-existing end-to-end cancel
pins in both frontends pass unchanged.

All three parity gates green against regenerated baselines (+1 row each, purely
additive). C++ tests run through the build wrappers; Python tests against a
built virtualenv.

Closes #181

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All four frontend display-label audits gain a sentence-template detector
over a deny-list of the domain phrases classic-scan-presentation owns.
The vocabulary work centralized names and stopped there, so the sentences
around them drifted apart again; this is what stops the same rot after
the presentation consolidation.

The deny-list lives in one file rather than four inline copies. A
per-frontend list would put the four-copies drift back into the test
layer one level up from where this work removed it: adding core-owned
prose would mean remembering four lists, and forgetting one would leave
the phrase unenforced in exactly the frontend nobody was watching.

The detector is deliberately narrow. A general "no format strings" rule
would bury real findings in false positives and get switched off. No
per-frontend golden wording suites are added either; wording stays
pinned once, in the presentation crate.

Each audit carries two guards, because a detector that cannot fire reads
as coverage while providing none: one asserts the shared file is readable
and non-empty, the other feeds the detector the drift it exists to catch
alongside a compliant rendering loop. The native ports search
comment-stripped source, so they handle raw strings, a `//` inside a
literal, and a character literal holding a quote -- each of which would
otherwise drop the rest of a file from the audit silently.

Two gaps are recorded rather than implied:

- `succeeded,` is core-owned but stays a named local supplement in the
  Python CLI audit, which enforced it before the shared file existed.
  The Qt GUI still composes its own status-bar summary from the scan
  worker's `finished` signal, so a shared entry would fail that frontend
  for a migration that has not happened.
- node-bindings/classic-node/cli/run-scan.ts renders these display lines
  and has no audit at all. It is named in the deny-list header so the
  deficit is data a reader can find, not silence.

Verified by reintroducing a locally written sentence into each of the
four frontends in turn and watching its audit fail, then reverting.
C++ suites run through build_cli.ps1 -Test and build_gui.ps1 -Test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ode CLI

Three changes that turned out to be one.

The Qt GUI's status bar was the last surface composing its own sentence
about a Crash Log Scan Run:

    Scan completed: %1 logs scanned in %2s (%3 succeeded, %4 failed)

It re-derived the plural of "logs", so a one-log run read "1 logs" there
while every other frontend read "1 log". The cause was upstream of the
window: ScanWorker::finished(int, int, int) was the one terminal signal
carrying no words, so the window had nothing to state the outcome with.
It now carries the rendered run like the other three, and onScanCompleted
collapses onto the shape onScanCancelled already used -- the run states
its outcome, the window adds only the elapsed time, from a clock Rust
does not carry, and the Installed YAML Data suffix. The counts stay on
the signal because consumers key on them as data.

That unblocked `succeeded,`, which core owns and which had lived as a
named supplement in the Python CLI's audit because a shared entry would
have failed the GUI for a migration that had not happened. It is now an
ordinary entry and the supplement is gone.

Enforcing it exposed that the source-scanning detectors read too much.
They stripped comments and searched the remaining code, so `succeeded,`
matched the GUI's own `emit finished(terminal.succeeded, terminal.failed,
...)` -- a line that says nothing to a user. They now extract string
literals, newline-separated so two adjacent literals cannot fuse into a
phrase neither contains. That removes the whole class of false positive
and matches the Python audit, which reads literals off the AST for the
same reason. The stopgap rule that every entry contain a space is gone
with it, and each audit records why.

The Node CLI gains the audit it never had. run-scan.ts had already
migrated and composed no sentences of its own; what it lacked was
anything keeping it that way -- the same unenforced state the Python CLI
was in when a raw Vocabulary Token survived there. It carries the
deny-list detector and the coverage meta-test; cli.spec.ts already held
the renderer-conformance half. The roster went from four audits to five
without a detector changing, which is the property the shared deny-list
file was chosen for.

Verified by injecting the phrase into the GUI and watching its audit
fail, then reverting. Full suites: Rust workspace, native CLI 82 + 24
integration, Qt GUI 22, Python 526, Node 1043. C++ through
build_cli.ps1 -Test and build_gui.ps1 -Test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ty attached

`render_local_ignore_recovery` produces the content of a Local Ignore recovery
prompt: the display lines explaining the pause, plus one description per recovery
decision stating its label, what choosing it will do, and whether this run can
honor it.

Availability travels attached to the decision it describes rather than as a
separate flag beside the prompt. That closes a confirmed gap by construction: a
consumer cannot offer an unavailable decision without ignoring data placed
directly in its hands. `local_ignore_reset_available` previously sat one level
away on the Installed YAML Data DTO, and two native frontends never read it.

The prompt crosses all three binding surfaces with the same segment flattening as
the rest of Display Content: `ScanRunRecoveryPrompt` behind `has_recovery_prompt`
on CXX, `JsScanRunSuccess.recoveryPrompt` on Node, and
`ScanRunExecution.recovery_prompt` on Python. `LocalIgnoreRecoveryDecision` still
has exactly two variants; backing out stays the absence of a decision.

The decision list is built by walking `LocalIgnoreRecoveryDecision::VARIANTS`, so
it can neither offer something the contract will refuse nor omit something it
accepts, and the exhaustive matches behind it stop the crate compiling if a third
variant is added.

This lands the core and transport half of #183; the frontends adopt the
descriptions in the change that follows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All four frontends now render the Local Ignore recovery prompt's display lines
and decision descriptions from core. A user gets the same explanation of what
each decision does everywhere, and is only offered decisions that can actually
succeed -- not because each frontend remembered to check a flag, but because the
availability fact arrives attached to the decision itself.

Each frontend keeps its own affordance, and only its affordance:

- The native CLI keeps its bracketed letters, input loop, attempt limit, and
  end-of-input handling. The menu, the offered letters, the retry hint, and the
  accepted answers are now built in one pass over the decision list, so none can
  advertise what another withheld. `CliLocalIgnoreRecoveryPresentation` carries
  `decisions` in place of `reset_available`.
- The TUI gates both decision keys on `RecoveryDecisionDescription::available`
  rather than a hand-read flag. Enter remains deliberately unbound, so no
  keypress can authorize a durable reset by reflex.
- The Qt dialog builds one button per available decision, labelled with core's
  Display Label, with the descriptions under core's question. The window stays
  responsive: `BlockingQueuedConnection` parks the worker thread while `exec()`
  runs a nested loop on the GUI thread. The prompt payload is projected on the
  worker thread and carries no `rust::Box`, which is what makes that hop legal.
- The Python CLI renders the prompt without prompting. A paused run stays
  terminal there, but it is no longer unexplained: the lines and every available
  decision reach both the plain stream and `data.recoveryPrompt`.

`offersLocalIgnoreResetToDefault` is gone. Its "silence is not a denial" rule was
one of three copies; `render_local_ignore_recovery` takes the optional Installed
YAML Data so the rule is written once.

The three sentences join `core-owned-phrases.txt` with this change rather than
before it, which is the only point at which all five frontend audits can enforce
them.

Closes #184

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`render_recovery_prompt` pushed `render_local_ignore` into the prompt's lines, and
`render_run_result` pushes the same line into the Installed YAML Data block. Every
surface that receives a prompt receives the rendered run on the same envelope, so
all three interactive frontends showed the malformed file's identity twice once
they started rendering the prompt -- the drift this crate exists to remove, merely
relocated into core.

The prompt now states the question and nothing else: why the run paused, plus a
notice naming any decision being withheld. The identity is stated once, by the run
render, which is where it was already correct. That reduces
`render_recovery_prompt` to a single `bool`, and the "silence is not a denial"
rule stays where it landed, in `render_local_ignore_recovery`.

Also moves the recovery tests out of `lib_tests.rs` into `recovery_tests.rs`,
declared from `recovery.rs` with the path attribute, per AGENTS.md rule 11. They
run as `recovery::tests::*` now. The corpus in `lib_tests.rs` still renders the
prompt, which is deliberate rather than a leftover duplicate: the wording is
pinned beside the module that writes it, while the corpus is what subjects the
decision descriptions to the crate-wide placeholder and grammatical-number
invariants that no per-module file can apply.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Add ADR-0008 for executable cross-adapter parity evidence
- Specify scenario packs, receipts, coverage, CXX integration, and migration gates
Extend the independent compatibility oracle and all semantic adapter runners with preview, caller-controlled commit, rejection, preservation, and stale revision scenarios. Reject known-stale core commits before lock creation while retaining the locked recheck. Keep User Settings conformance in shadow and preserve existing blocking evidence.
Exercise planning, reversal, application, exact backup verification, and receipt-authorized restoration across Rust, CXX, Node, and Python using the independent compatibility oracle. Declare optional empty conflict locks in scenario normalization and retain existing shadow evidence. Remove the unused PyO3 test import that broke workspace CI.
Narrow migrated registry claims after blocking conformance validation while retaining unmigrated loaders and diagnostics. Delegate fixture coverage metadata to receipt requirements and remove duplicate positive assertions.

Fixes #211
- Document executable evidence boundaries for both conformance families
- Add Pyright to the Python binding development dependencies
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 8, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-08T10:16:56.502976Z 694ae4d PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 694ae4d0a7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread .gitignore
Architecture review*.html
Architecture review*_files/
.mcp.json
.pytest-tmp/

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove and ignore the pytest basetemp trees

This rule does not match the five .pytest-215-* trees added by this commit. Those trees contain 477 nested repositories recorded as gitlinks but no .gitmodules mappings; on the reviewed tree, git submodule status --recursive exits 128 with fatal: no submodule mapping found, so the submodules: recursive checkouts in ci-cpp.yml fail before the native builds start. They also add over 142 MB of temporary blobs. Remove these generated trees and ignore the actual basetemp naming pattern.

Useful? React with 👍 / 👎.

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.

1 participant