Skip to main content

memstead_cli/commands/
projection.rs

1//! `memstead projection` — the binding (projection-promotion) command tree.
2//!
3//! The projection is the unit: one versioned binding per source→mem obligation
4//! (bundle plan `03-projection-promotion`). The tree ships nine leaves —
5//! `brief`, `init`, `migrate`, `enable`, `edit`, `advance`, `exclude`,
6//! `verify`, `check-path`:
7//!
8//! - `brief` renders a binding's run-brief — the Markdown prompt an agent
9//!   consumes — for a canonical binding id `<mem>/<stem>` (D3/D9), or the next
10//!   due binding under `--all` (round-robin + backoff selection).
11//! - `init` scaffolds a fresh v2 single-record binding non-interactively.
12//! - `migrate` converts every prior on-disk generation into v2 records in
13//!   place: gen-1 root folders, the gen-2 four-primitive store, and the v1
14//!   three-file store — folding medium+facet content inline.
15//! - `enable` adds a missing `build` / `sync` / `verify` operation block to an
16//!   existing binding (D6 — the remedy a refused mutating op cites).
17//! - `edit` patches an existing binding's author-editable fields in place.
18//! - `advance` records disposition-gated sync-baseline advances (D7).
19//! - `exclude` records authored exclusions for in-scope source artifacts.
20//! - `verify` measures a binding's fidelity, records durable findings, and
21//!   renders the tier-1 fidelity report (E3b).
22//! - `check-path` answers deny verdicts: is a path or pattern hidden by a
23//!   binding's `deny_paths`?
24//!
25//! This tree is the sole binding surface: the retired `ingest` and `pipeline`
26//! command trees folded in here (`ingest brief` → `projection brief`,
27//! `pipeline migrate` → `projection migrate`'s gen-1 path).
28//!
29//! Errors carry `PROJECTION_*` wire tokens (D12); the missing-workspace path is
30//! single-sourced through [`crate::setup::workspace_not_initialised_error`].
31
32use clap::{Args as ClapArgs, Subcommand, ValueEnum};
33use serde_json::json;
34
35use memstead_base::binding::{
36    BuildMode, BuildOperation, CapabilityError, DEFAULT_ADJUDICATION_CAP,
37    DEFAULT_FULL_RESYNC_EVERY, ScaffoldParams, SyncOperation, VerifyOperation, validate_binding,
38};
39use memstead_base::binding_migrate::{
40    BindingMigrateError, check_all_consumed, fold_v1_binding, migrate_gen2_bindings,
41};
42use memstead_base::ingest::advance::{
43    AdvanceError, DispositionInput, ExcludeError, advance_baseline, record_exclusions,
44};
45use memstead_base::ingest::findings::{
46    FindingsError, FullResyncDecision, record_anchor_hash_backfill, record_verified_baseline,
47    verify_binding, verify_binding_full,
48};
49use memstead_base::ingest::report::{
50    DEFAULT_REPORT_BUDGET, compute_fidelity_report, render_fidelity_report,
51};
52use memstead_base::ingest::resolve::{ResolveError, ResolvedSource, resolve_binding_run};
53use memstead_base::ingest::{
54    OperationFilter, OperationKind, RenderBriefError, not_loop_declared, render_ingest_brief,
55    render_sync_brief_for, render_verify_brief_for, select_next_due_operation,
56};
57use memstead_base::pipeline::{IngestTrigger, MediumType};
58use memstead_base::pipeline_store::{
59    ProjectionGeneration, delete_ingest, load_legacy_pipeline_configs, load_pipeline_configs,
60    load_projection_generations, read_binding, remove_mediums_and_facets_trees, write_binding,
61};
62use memstead_base::workspace_store::StoreError;
63use memstead_base::{migrate_legacy_pipeline, read_legacy_pipeline_configs};
64
65use crate::CliError;
66use crate::output::{ExitKind, print_json, print_markdown};
67
68/// Version marker on `projection verify --json`. External contract: bump the
69/// `vN` when the payload's shape changes so a consumer asserting it fails
70/// loudly instead of misparsing. House style — see `memstead-export/v1`
71/// (`commands/export.rs`) and `workspace-dump/v1` (`commands/workspace.rs`).
72pub const JSON_VERIFY_FORMAT: &str = "memstead-verify/v1";
73use crate::setup::{CliContext, workspace_not_initialised_error};
74
75#[derive(ClapArgs, Debug)]
76pub struct Args {
77    #[command(subcommand)]
78    pub command: ProjectionCommand,
79}
80
81#[derive(Subcommand, Debug)]
82pub enum ProjectionCommand {
83    /// Render a binding's run-brief — the Markdown prompt an agent consumes —
84    /// on stdout. Takes the canonical binding id `<mem>/<stem>` (D3), e.g.
85    /// `engine/graph`. Omit the id (or pass `--all`) to select the next due
86    /// (binding, operation) pair by round-robin + backoff and render that
87    /// operation's brief; `--operation` picks which operations rotate (default
88    /// `build` — the classic build-only rotation; `any` rotates every
89    /// loop-declared build / sync / verify pair). An operation participates
90    /// only where its binding block declares `trigger: loop`. Reads the v2
91    /// binding store and the destination mem's schema / writing guidance; the
92    /// assembly lives in the engine, so every consuming surface renders
93    /// byte-identical briefs by construction.
94    ///
95    /// `--verify` renders the **verify brief** (group C) for the named binding:
96    /// measurement + capped-adjudication instructions only, with no
97    /// destination-mutation instruction. `--sync` renders the **sync brief** —
98    /// the sole maintenance-writer prompt, carrying both the cursor slice and the
99    /// open verify findings in one brief with the absorbed reconcile
100    /// conservatism. Both are read-only on the mem; the sync brief's repairs
101    /// reach the mem only when an agent acts on it through the MCP mutation
102    /// surface.
103    Brief(BriefArgs),
104    /// Scaffold a fresh v2 binding non-interactively: ONE record with one
105    /// inline source, at `.memstead/projections/<mem>/<stem>.json`.
106    /// All inputs are flags — no prompts ever (parity across callers). The
107    /// default binding declares build+sync+verify where the medium permits:
108    /// a `web` source scaffolds build-only, with the deferral named in
109    /// `warnings[]`. A `prune` block is scaffolded wherever sync survived,
110    /// with the strongest guarantee the medium supports (never-clobber for a
111    /// git-backed source). Refuses `PROJECTION_EXISTS` (without touching disk)
112    /// when a binding of the same id already exists — never overwrites.
113    Init(InitArgs),
114    /// Migrate every prior on-disk generation into v2 single-record
115    /// bindings, in place. Gen-1 — the root-folder
116    /// `scopes|projections|ingests/` JSON layout — is first materialized
117    /// into the four-primitive store, then folded. Gen-2 — the
118    /// four-primitive store (per-mem `Projection` + flat `Ingest`) — merges
119    /// each ingest into its projection and folds the referenced facets +
120    /// mediums inline. v1 — the three-file store — folds each binding's
121    /// facet references inline the same way, source names preserved
122    /// byte-verbatim (they key sync watermarks). The emptied `mediums/` and
123    /// `facets/` trees are removed; orphan records refuse rather than drop.
124    /// `refinement` mode and dangling refs refuse with a typed error.
125    /// Idempotent on a migrated store. Use `--dry-run` to preview without
126    /// writing.
127    Migrate(MigrateArgs),
128    /// Enable a `build` / `sync` / `verify` operation on an existing binding by
129    /// adding its block (with sensible defaults) if absent. This is the remedy
130    /// a refused *mutating* operation cites (D6): `projection enable sync
131    /// <binding>`. Before writing, the operation is checked against the
132    /// medium-capability matrix (D6) — enabling `sync`/`verify` over a medium
133    /// that cannot support it (e.g. a `web` source) refuses with the capability
134    /// gap and writes nothing. Enabling an already-present operation refuses
135    /// `PROJECTION_OP_ALREADY_ENABLED`; a missing binding refuses
136    /// `PROJECTION_NOT_FOUND`.
137    Enable(EnableArgs),
138    /// Patch an existing binding's author-editable fields in place — the
139    /// general edit surface over the shared `pipeline_edit` layer that
140    /// `init`/`enable` already use. The patch is a JSON object with patch
141    /// semantics: an absent field is preserved, an explicit `null` clears
142    /// `intent`/`rules`/`prune`, and a present `sources`, `operations`,
143    /// `reference_mems` or `deny_paths` value replaces that whole block
144    /// (`version` stays engine-managed and is ignored if supplied). Every
145    /// edit is validated before anything lands: a patch that would
146    /// introduce a refusal the stored record does not already carry (a
147    /// duplicate source name, `sync` over a medium with no change signal)
148    /// refuses with the capability gap and writes nothing — pre-existing
149    /// refusals never block an unrelated edit. Refuses
150    /// `PROJECTION_NOT_FOUND` for a missing binding; adding a source to a
151    /// binding is `edit <binding> --patch '{"sources":[...]}'` with the
152    /// FULL source list, existing entries included, since the block
153    /// replaces.
154    Edit(EditArgs),
155    /// Advance a binding's sync baseline by recording per-artifact
156    /// dispositions (D7). The engine freezes the presented changed slice,
157    /// subtracts already-disposed artifacts on re-presentation, appends
158    /// new-HEAD deltas when the source moves mid-pass, and — when the
159    /// remainder empties — advances the destination mem's `#synced` token via
160    /// the sync-state writer (provenance piggybacks that commit). Dispositions
161    /// are durable (`.memstead/state/advance/`), so a partial pass resumes
162    /// across process restarts. The gate accepts **only** artifact ids the
163    /// engine presented — an unknown id refuses the whole call atomically
164    /// (`PROJECTION_ADVANCE_UNKNOWN_ARTIFACT`). In this cycle the agent supplies
165    /// a disposition for **every** artifact explicitly (auto-derivation lands
166    /// later).
167    Advance(AdvanceArgs),
168    /// Declare authored **exclusions** for in-scope source artifacts. Unlike
169    /// `advance` (whose gate accepts only artifacts in the changed slice), this
170    /// gates on enumerable `S(D)` membership, so a stable, unchanged artifact can
171    /// be recorded as deliberately not-modeled with a rationale. Each accepted
172    /// `(artifact, rationale)` lands in the durable exclusion ledger the fidelity
173    /// report consults, so the artifact stops re-surfacing as `uncovered` under
174    /// exhaustive coverage and keeps its reasoning. An artifact outside `S(D)`
175    /// refuses the whole call atomically (`PROJECTION_EXCLUDE_NOT_SOURCE_MEMBER`);
176    /// re-declaring merges into the ledger. The write path for the option-(a)
177    /// process-mem judgment migration, and the general "this in-scope artifact is
178    /// mined and warrants no destination entity, because …" capability.
179    Exclude(ExcludeArgs),
180    /// Measure a binding's fidelity and record durable findings (E3b, group A).
181    /// Read-only on the destination mem's ENTITIES: verify adjudicates its anchors
182    /// against the live source and samples in-scope artifacts, writing findings
183    /// keyed by the binding's `hash(D)` alone into the engine-owned findings store
184    /// (`.memstead/state/findings/`); the `source_head` a finding was observed
185    /// at is carried as metadata, so findings survive source-head movement and
186    /// stay presented until a later verify supersedes them. A
187    /// binding-declaration edit changes `hash(D)` and segregates prior
188    /// findings as superseded, never presented as current. Verify mutates no entity — any
189    /// repair routes through the (later) sync brief — but it is not a pure read:
190    /// a completed run records the findings store, backfills observed content
191    /// hashes onto hash-less anchors, and records a `#verified` baseline. It then renders the
192    /// deterministic, token-budgeted **tier-1 fidelity report** (group B) over
193    /// the findings just recorded: grain-classed coverage with tree-anchor
194    /// fan-out on its own axis, anchor-resolution %, freshness vs. both
195    /// `sync_state` tokens (`signal: none` → freshness unknowable), the
196    /// capability-matrix block, and the tier-3 backlog depth — aggregates always
197    /// ship; heavy per-artifact lists greedy-fill under `--budget` and drop to
198    /// hints (forced back in with `--include`).
199    ///
200    /// The anchor figures answer for THIS BINDING'S population only: anchors
201    /// another binding wrote, and anchors pointing outside this binding's
202    /// declared scope, are excluded from every figure and named in the report
203    /// rather than dropped. They are never deleted or rewritten: exclusion is
204    /// a reporting decision. The report states what its denominator counted
205    /// (anchor rows, with the distinct-artifact count beside them, since one
206    /// artifact legitimately carries several rows), and says so when anchors
207    /// recording no producing binding are included by the pre-provenance
208    /// fallback.
209    ///
210    /// A sidecar row whose ENTITY the mem no longer holds is reported as
211    /// dangling and named, in no figure and never as resolving: it is a
212    /// sidecar integrity condition, not an anchor state, and nothing repairs
213    /// it, because the row is the trace of a writer that went around the
214    /// engine. Where the entity end could not be reconciled at all (an
215    /// unloaded, quarantined or partly unparsed mem), the report says so
216    /// rather than reporting a clean anchor axis.
217    Verify(VerifyArgs),
218    /// Answer deny verdicts: is a path (or Glob/Grep pattern) hidden by a
219    /// binding's `deny_paths`? Evaluates each candidate against the named
220    /// binding — or, with `--binding` omitted, the ACTIVE binding (the one
221    /// whose brief was last consumed) — using the engine's own deny dialect:
222    /// the facet-scope glob grammar, resolved against the workspace root (an ingest deny spans every source, so it has no pointer to be relative to), plus
223    /// the literal-base directory-prefix rule (`dev/**` also blocks a read of
224    /// `dev` itself). Single-path form takes the candidate as an argument;
225    /// `--batch` reads `{"cwd": "<dir>", "paths": ["...", ...]}` from stdin
226    /// and answers every candidate in one process — the form a per-tool-call
227    /// consumer (the plugin's PreToolUse deny hook) amortizes subprocess cost
228    /// with. Engine-free and read-only: verdicts come from the binding record
229    /// and the path alone, no workspace boot. Output names the matched deny
230    /// entry on a block; the exit code stays 0 for an answered check — a
231    /// non-zero exit means the check itself could not run (unknown or
232    /// quarantined binding, no active binding, malformed batch).
233    CheckPath(CheckPathArgs),
234}
235
236/// The medium type flag for `projection init` — the CLI-facing mirror of
237/// [`MediumType`] (which carries serde, not clap, derives). Decides the
238/// capability matrix (D6) that filters the default binding's operations.
239#[derive(Clone, Copy, Debug, ValueEnum)]
240pub enum MediumTypeArg {
241    /// A source tree of code.
242    Codebase,
243    /// A directory of files (non-code).
244    Filesystem,
245    /// A git history.
246    Git,
247    /// Another mem's graph.
248    Graph,
249    /// Web sources (build-only this cycle — no change signal).
250    Web,
251}
252
253impl MediumTypeArg {
254    fn to_medium_type(self) -> MediumType {
255        match self {
256            MediumTypeArg::Codebase => MediumType::Codebase,
257            MediumTypeArg::Filesystem => MediumType::Filesystem,
258            MediumTypeArg::Git => MediumType::Git,
259            MediumTypeArg::Graph => MediumType::Graph,
260            MediumTypeArg::Web => MediumType::Web,
261        }
262    }
263}
264
265#[derive(ClapArgs, Debug)]
266pub struct BriefArgs {
267    /// The canonical binding id `<mem>/<stem>` (D3) — e.g. `engine/graph`.
268    /// Omit (or pass `--all`) to select the next due binding by round-robin +
269    /// backoff. Required with `--verify` / `--sync` (those operate on one
270    /// binding's live findings/cursor, never a rotation).
271    pub binding: Option<String>,
272    /// Select the next due (binding, operation) pair across all bindings
273    /// (round-robin + backoff) and render its brief, instead of naming one.
274    /// Which operations rotate is decided by `--operation` (default: build
275    /// only). Ignored with `--verify` / `--sync`.
276    #[arg(long)]
277    pub all: bool,
278    /// Which operations the `--all` rotation considers. An operation
279    /// participates only where the binding declares its block with
280    /// `trigger: loop` — consent lives in the declaration. `build` (the
281    /// default) keeps the classic build-only rotation; `any` rotates across
282    /// every loop-declared build / sync / verify pair and renders the matching
283    /// brief (the `--json` output names the picked operation).
284    #[arg(long, value_enum, default_value_t = BriefOperationArg::Build, requires = "all", conflicts_with_all = ["verify", "sync"])]
285    pub operation: BriefOperationArg,
286    /// Take the rotation slot this `--all` render selects — advancing the
287    /// round-robin cursor and the per-pair backoff state. WITHOUT this flag a
288    /// `--all` render is a pure read: repeat it as often as you like and the
289    /// rotation stays exactly where it was (a diagnostic re-render must never
290    /// silently skip the next binding). The loop driver that will act on the
291    /// brief passes `--consume`; nothing else should.
292    #[arg(long, requires = "all")]
293    pub consume: bool,
294    /// Render the **verify brief** (group C) for the named binding instead of
295    /// the build brief: measurement + capped-adjudication instructions only.
296    /// It carries no destination-mutation instruction — repairs route through
297    /// the sync brief. Read-only on the mem. Mutually exclusive with `--sync`.
298    #[arg(long, conflicts_with = "sync")]
299    pub verify: bool,
300    /// Render the **sync brief** (group C) for the named binding instead of the
301    /// build brief: the sole maintenance-writer prompt, carrying both the cursor
302    /// slice and the open verify findings in one brief, with the absorbed
303    /// reconcile conservatism. Read-only on the mem (the agent's writes route
304    /// through MCP). Mutually exclusive with `--verify`.
305    #[arg(long, conflicts_with = "verify")]
306    pub sync: bool,
307}
308
309/// The `--operation` value for `projection brief --all` — which operations the
310/// rotation considers. CLI-facing mirror of the engine's [`OperationFilter`]
311/// (which carries no clap derives).
312#[derive(Clone, Copy, Debug, ValueEnum, PartialEq, Eq)]
313pub enum BriefOperationArg {
314    /// Rotate over build pairs only (the default — the classic rotation).
315    Build,
316    /// Rotate over sync pairs only.
317    Sync,
318    /// Rotate over verify pairs only.
319    Verify,
320    /// Rotate over every loop-declared build / sync / verify pair.
321    Any,
322}
323
324impl BriefOperationArg {
325    fn to_filter(self) -> OperationFilter {
326        match self {
327            BriefOperationArg::Build => OperationFilter::Only(OperationKind::Build),
328            BriefOperationArg::Sync => OperationFilter::Only(OperationKind::Sync),
329            BriefOperationArg::Verify => OperationFilter::Only(OperationKind::Verify),
330            BriefOperationArg::Any => OperationFilter::Any,
331        }
332    }
333}
334
335#[derive(ClapArgs, Debug)]
336pub struct InitArgs {
337    /// Destination mem the binding writes into — the `<mem>` half of the
338    /// binding id `<mem>/<stem>` and the per-mem tier the three files live under.
339    #[arg(long)]
340    pub mem: String,
341    /// The medium pointer — a path (codebase / filesystem / git) or a mem id /
342    /// URL (graph / web). Becomes the scaffolded medium's `pointer`.
343    #[arg(long)]
344    pub source: String,
345    /// The medium type — decides the capability matrix (D6) that filters which
346    /// operations the default binding declares.
347    #[arg(long = "medium-type", value_enum)]
348    pub medium_type: MediumTypeArg,
349    /// Intent prose for the agent (the binding's `intent`). Optional.
350    #[arg(long)]
351    pub intent: Option<String>,
352    /// Binding stem — the `<stem>` half of the binding id and the file name
353    /// of the scaffolded binding record. Defaults to the final
354    /// path component of `--source`.
355    #[arg(long)]
356    pub name: Option<String>,
357}
358
359#[derive(ClapArgs, Debug)]
360pub struct MigrateArgs {
361    /// Preview the produced bindings (and any warnings) without writing them
362    /// to disk or removing the merged ingest files.
363    #[arg(long)]
364    pub dry_run: bool,
365}
366
367/// The operation `projection enable` adds to a binding. Mirror of the binding's
368/// operations block: `build` is always present (required), so enabling it
369/// always refuses as already-enabled; `sync` / `verify` are the enableable
370/// blocks.
371#[derive(Clone, Copy, Debug, ValueEnum, PartialEq, Eq)]
372pub enum EnableOperationArg {
373    /// The build operation (always present — enabling refuses as already-enabled).
374    Build,
375    /// The sync (maintenance-write) operation.
376    Sync,
377    /// The verify (measurement) operation.
378    Verify,
379}
380
381impl EnableOperationArg {
382    fn name(self) -> &'static str {
383        match self {
384            EnableOperationArg::Build => "build",
385            EnableOperationArg::Sync => "sync",
386            EnableOperationArg::Verify => "verify",
387        }
388    }
389}
390
391#[derive(ClapArgs, Debug)]
392pub struct EnableArgs {
393    /// The operation to enable: `build` | `sync` | `verify`.
394    #[arg(value_enum)]
395    pub operation: EnableOperationArg,
396    /// The binding id `<mem>/<stem>` (D3) — e.g. `engine/graph`.
397    pub binding: String,
398}
399
400#[derive(ClapArgs, Debug)]
401pub struct EditArgs {
402    /// The binding id `<mem>/<stem>` (D3) — e.g. `engine/graph`.
403    pub binding: String,
404    /// The JSON patch over the author-editable record, e.g.
405    /// `'{"sources": [ ...the full replacement list... ]}'`. Patch
406    /// semantics: absent = preserved, `null` clears where clearing is
407    /// legal, a present block replaces that block whole.
408    #[arg(long)]
409    pub patch: String,
410}
411
412#[derive(ClapArgs, Debug)]
413pub struct AdvanceArgs {
414    /// The binding id `<mem>/<stem>` (D3) — e.g. `engine/graph`.
415    pub binding: String,
416    /// A JSON object mapping each judged artifact id to its disposition, e.g.
417    /// `'{"src/lib.rs": "worked", "src/old.rs": "irrelevant"}'`. A value may
418    /// instead be an object carrying an authored rationale —
419    /// `'{"src/gen.rs": {"disposition": "excluded", "rationale": "generated, no entity"}}'`
420    /// — and an `excluded` verdict with a rationale is retained in the durable
421    /// exclusion ledger so the artifact stops re-surfacing as `uncovered` and
422    /// keeps its reasoning. Only ids the engine presented in the brief's changed
423    /// slice are accepted — an unknown id refuses the whole call. Omitting the
424    /// flag (or passing `'{}'`) records nothing and re-presents the remainder —
425    /// the shape of a first advance over an empty presented slice, which
426    /// exists only to write the baseline.
427    #[arg(long, default_value = "{}")]
428    pub dispositions: String,
429}
430
431#[derive(ClapArgs, Debug)]
432pub struct ExcludeArgs {
433    /// The binding id `<mem>/<stem>` (D3) — e.g. `project/graph`.
434    pub binding: String,
435    /// A JSON object mapping each in-scope source artifact id to the authored
436    /// rationale for excluding it, e.g.
437    /// `'{"docs/legacy.md": "superseded; no entity", "vendor/x.rs": "generated"}'`.
438    /// Every id must be a member of the binding's enumerable source `S(D)` — an
439    /// id outside scope refuses the whole call.
440    #[arg(long)]
441    pub exclusions: String,
442}
443
444#[derive(ClapArgs, Debug)]
445pub struct CheckPathArgs {
446    /// The candidate — a path (absolute, or relative to `--cwd`) or a
447    /// Glob/Grep pattern. Omitted in `--batch` mode.
448    #[arg(required_unless_present = "batch", conflicts_with = "batch")]
449    pub path: Option<String>,
450    /// Binding to check against, canonical `<mem>/<stem>`. Defaults to the
451    /// active binding — the one whose brief was last consumed; refuses typed
452    /// (`NO_ACTIVE_BINDING`) when none is.
453    #[arg(long)]
454    pub binding: Option<String>,
455    /// Batch mode: read one JSON object from stdin —
456    /// `{"cwd": "<dir>", "paths": ["...", ...]}` — and answer every
457    /// candidate. A malformed payload refuses whole (`INVALID_INPUT`), never
458    /// part-answers. The payload's `cwd` wins over `--cwd`.
459    #[arg(long)]
460    pub batch: bool,
461    /// Directory relative candidates resolve against (default: the process
462    /// working directory).
463    #[arg(long)]
464    pub cwd: Option<std::path::PathBuf>,
465}
466
467#[derive(ClapArgs, Debug)]
468pub struct VerifyArgs {
469    /// The binding id `<mem>/<stem>` (D3) — e.g. `engine/graph`.
470    pub binding: String,
471    /// Token budget for the tier-1 fidelity report's **heavy** content
472    /// (per-artifact lists). Aggregated counts always ship in addition; heavy
473    /// lists greedy-fill and drop to `## Hints` when they do not fit. Defaults
474    /// to the house envelope budget.
475    #[arg(long)]
476    pub budget: Option<usize>,
477    /// Force a heavy report section in past the budget (repeatable):
478    /// `uncovered_artifacts` | `tree_fanout` | `superseded_findings`.
479    #[arg(long = "include")]
480    pub include: Vec<String>,
481    /// Full measurement: walk the entire enumerable source `S(D)` (the
482    /// rotating sample scheduler is bypassed), treat the per-run adjudication
483    /// cap as unlimited, and perform the prepared-hash backfill — the
484    /// report's coverage and accuracy figures are computed over everything,
485    /// with no sampling or truncation caveat. Refuses (typed) rather than
486    /// render a fabricated-complete report in two cases, both per facet: a
487    /// facet whose medium is non-enumerable, and a facet whose medium claims
488    /// enumerability but whose own walk yields no artifacts — a sibling facet
489    /// that walked does not excuse it. Without this flag the capped/sampled
490    /// loop economics are unchanged.
491    #[arg(long)]
492    pub full: bool,
493    /// CI-gate mode: exit 6 when the completed run recorded findings, so a
494    /// pull-request gate can branch on three outcomes without parsing output
495    /// — 0 completed-and-clean, 6 completed-with-findings, any other nonzero
496    /// the measurement itself failed. The full report is rendered first,
497    /// either way. Opt-in by design: without this flag the exit behaviour is
498    /// byte-for-byte what it has always been, so no existing consumer breaks.
499    #[arg(long)]
500    pub fail_on_findings: bool,
501    /// CI-gate mode for blind runs: exit 6 (typed
502    /// `PROJECTION_VERIFY_INCONCLUSIVE`) when the completed run's rollup
503    /// verdict is `inconclusive` — no readable change signal, an empty
504    /// enumerated scope — so a gate cannot go green on a measurement that
505    /// never happened. Replaces the documented two-step verdict read for
506    /// opted-in callers. Evaluated after `--fail-on-findings` (a findings
507    /// run with both flags exits with the findings code). The full report
508    /// is rendered first. Opt-in by design: without this flag an
509    /// inconclusive run keeps its long-standing exit 0.
510    #[arg(long)]
511    pub fail_on_inconclusive: bool,
512}
513
514pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
515    match args.command {
516        ProjectionCommand::Brief(a) => brief(ctx, a),
517        ProjectionCommand::Init(a) => init(ctx, a),
518        ProjectionCommand::Migrate(a) => migrate(ctx, a),
519        ProjectionCommand::Enable(a) => enable(ctx, a),
520        ProjectionCommand::Edit(a) => edit(ctx, a),
521        ProjectionCommand::Advance(a) => advance(ctx, a),
522        ProjectionCommand::Exclude(a) => exclude(ctx, a),
523        ProjectionCommand::Verify(a) => verify(ctx, a),
524        ProjectionCommand::CheckPath(a) => check_path(ctx, a),
525    }
526}
527
528/// The capability gap that would stop `projection enable <operation>`, if
529/// there is one.
530///
531/// Asks the question `enable` asks: validate a CANDIDATE record carrying the
532/// operation. Validating the record as it stands answers nothing — the
533/// operation is absent, so there is no declaration for the matrix to refuse,
534/// and the gap stays invisible exactly where a caller needs it.
535fn operation_capability_gap(
536    binding: &memstead_base::binding::Binding,
537    operation: &'static str,
538) -> Option<String> {
539    let mut candidate = binding.clone();
540    let batch_size = candidate
541        .operations
542        .build
543        .as_ref()
544        .map_or(20, |b| b.batch_size);
545    match operation {
546        "sync" => {
547            candidate.operations.sync = Some(SyncOperation {
548                trigger: IngestTrigger::Manual,
549                batch_size,
550            });
551        }
552        "verify" => {
553            candidate.operations.verify = Some(VerifyOperation {
554                trigger: IngestTrigger::Manual,
555                batch_size,
556                adjudication_cap: DEFAULT_ADJUDICATION_CAP,
557                full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
558            });
559        }
560        // `build` is carried by every medium — no capability row refuses it,
561        // so the enable remedy is always honest for it.
562        _ => return None,
563    }
564    validate_binding(&candidate).err().and_then(|refusals| {
565        refusals
566            .iter()
567            .find(|r| {
568                matches!(
569                    r,
570                    CapabilityError::OperationOutOfScope { operation: op, .. } if *op == operation
571                )
572            })
573            .map(|gap| gap.to_string())
574    })
575}
576
577/// The refusal for a binding that declares no `sync` operation.
578///
579/// `projection enable sync <binding>` is the remedy — but only where the
580/// medium *can* carry sync. Over a medium whose capability row refuses it (a
581/// `web` source), `enable` refuses too, so naming it bounces the reader:
582/// the refusal points at `enable`, `enable` points at the capability gap,
583/// and nothing they can do closes it. Where the gap exists it IS the answer,
584/// so it is what this says.
585///
586/// Both codes are spelled as literals here, at their construction sites, so
587/// the generated error index (xtask) keeps finding them.
588fn absent_sync_error(binding_id: &str, binding: &memstead_base::binding::Binding) -> CliError {
589    if let Some(gap) = operation_capability_gap(binding, "sync") {
590        return CliError::new(
591            ExitKind::Validation,
592            "PROJECTION_CAPABILITY_UNSUPPORTED",
593            format!(
594                "binding `{binding_id}` has no sync operation, and this medium \
595                 cannot carry one: {gap}"
596            ),
597        )
598        .with_details(json!({ "binding": binding_id, "operation": "sync" }));
599    }
600    CliError::new(
601        ExitKind::Validation,
602        "PROJECTION_SYNC_NOT_ENABLED",
603        format!(
604            "binding `{binding_id}` has no sync operation — enable it with \
605             `memstead projection enable sync {binding_id}`"
606        ),
607    )
608    .with_details(json!({
609        "binding": binding_id,
610        "remedy": { "cli": format!("memstead projection enable sync {binding_id}") },
611    }))
612}
613
614/// Map a [`RenderBriefError`] to a typed CLI error (D12). Not-found bindings /
615/// facets / mediums exit `NotFound`; a malformed id is a `Validation` name
616/// error; config-load and mode-unsupported failures are generic. Codes are
617/// spelled as literals at each construction site so the generated error index
618/// (xtask) picks them up.
619fn map_brief_err(binding_id: &str, err: RenderBriefError) -> CliError {
620    let message = err.to_string();
621    let mapped = match &err {
622        RenderBriefError::ConfigLoad(_) => {
623            CliError::new(ExitKind::Generic, "PROJECTION_LOAD_FAILED", message)
624        }
625        // D6/AC4: the binding declares no build op — refuse with the
626        // `projection enable build` remedy the error message already carries.
627        RenderBriefError::BuildOperationAbsent { .. } => CliError::new(
628            ExitKind::Validation,
629            "PROJECTION_BUILD_NOT_ENABLED",
630            message,
631        ),
632        // A malformed findings store while rendering a verify / sync brief.
633        RenderBriefError::FindingsRead { .. } => CliError::new(
634            ExitKind::Generic,
635            "PROJECTION_FINDINGS_READ_FAILED",
636            message,
637        ),
638        RenderBriefError::Resolve(inner) => match inner {
639            ResolveError::BindingNotFound { .. } => {
640                CliError::new(ExitKind::NotFound, "PROJECTION_NOT_FOUND", message)
641            }
642            ResolveError::MalformedProjectionRef { .. } => {
643                CliError::new(ExitKind::Validation, "PROJECTION_INVALID_NAME", message)
644            }
645            // A scope nothing can interpret is a declaration defect the
646            // author must fix — Validation, with the legal forms named in
647            // the message rather than left for the reader to find.
648            ResolveError::UninterpretableScope { .. } => CliError::new(
649                ExitKind::Validation,
650                "PROJECTION_SCOPE_UNINTERPRETABLE",
651                message,
652            ),
653        },
654    };
655    mapped.with_details(json!({ "binding": binding_id }))
656}
657
658fn brief(ctx: &CliContext, args: BriefArgs) -> anyhow::Result<()> {
659    let (_shape, root) = ctx.workspace_shape().ok_or_else(|| {
660        workspace_not_initialised_error(
661            "not inside a Memstead workspace (no `.memstead/workspace.toml` in any ancestor)",
662        )
663    })?;
664
665    let cli_engine = ctx.cli_engine_at(&root)?;
666    let engine = cli_engine.base();
667
668    // Quarantine consult first: a binding whose stored file failed
669    // the load refuses typed with its reason (naming `projection
670    // migrate` for the legacy generations) instead of reporting
671    // not-found (agent-trust plan 04).
672    if let Some(binding_id) = args.binding.as_deref()
673        && let Ok(configs) = load_pipeline_configs(&root)
674        && configs
675            .quarantined
676            .iter()
677            .any(|q| format!("{}/{}", q.mem, q.name) == binding_id)
678    {
679        return Err(binding_miss_error(&configs, binding_id).into());
680    }
681
682    // Group-C briefs: verify / sync render for one named binding (no rotation).
683    // Both are read-only on the destination mem — the sync brief's repairs reach
684    // the mem only when an agent acts on it through the MCP mutation surface.
685    if args.verify || args.sync {
686        let binding_id = args.binding.ok_or_else(|| {
687            CliError::new(
688                ExitKind::Validation,
689                "PROJECTION_BRIEF_BINDING_REQUIRED",
690                format!(
691                    "`projection brief --{}` needs a binding id `<mem>/<stem>` — it renders one \
692                     binding's brief, not an `--all` rotation",
693                    if args.verify { "verify" } else { "sync" }
694                ),
695            )
696        })?;
697        // D6/AC4 (backlog-sweep plan 03, decision 13): the sync brief is the
698        // maintenance-writer prompt — rendering it for a binding whose sync
699        // operation is not enabled spends a loop's work slot on work the
700        // engine will refuse to apply. Gate render exactly like `projection
701        // advance` does, with the one-command remedy in `details`.
702        if args.sync {
703            let configs = load_pipeline_configs(&root).map_err(|e| {
704                CliError::new(
705                    ExitKind::Generic,
706                    "PROJECTION_LOAD_FAILED",
707                    format!("could not load binding store: {e}"),
708                )
709                .with_details(json!({ "error": e.to_string() }))
710            })?;
711            if let Some(record) = configs
712                .bindings
713                .iter()
714                .find(|r| format!("{}/{}", r.mem, r.name) == binding_id)
715                && record.config.operations.sync.is_none()
716            {
717                return Err(absent_sync_error(&binding_id, &record.config).into());
718            }
719        }
720        let (rendered, operation) = if args.verify {
721            (
722                render_verify_brief_for(engine, &root, &binding_id),
723                OperationKind::Verify,
724            )
725        } else {
726            (
727                render_sync_brief_for(engine, &root, &binding_id),
728                OperationKind::Sync,
729            )
730        };
731        let rendered = rendered.map_err(|e| map_brief_err(&binding_id, e))?;
732
733        if ctx.json {
734            print_json(&json!({ "brief": rendered, "operation": operation.as_wire() }))?;
735        } else {
736            print!("{rendered}");
737        }
738        return Ok(());
739    }
740
741    // Resolve which (binding, operation) pair to render: a named binding
742    // (canonical `<mem>/<stem>`, build), or the next due pair in a round-robin
743    // `--all` rotation (which advances the cursor + backoff state). The
744    // rotation's operation set is `--operation` (default: build only — the
745    // classic rotation, byte-stable for existing callers).
746    let (selected, never_rotated) = match args.binding {
747        Some(binding) if !args.all => (Some((binding, OperationKind::Build)), Vec::new()),
748        _ => {
749            let configs = load_pipeline_configs(&root).map_err(|e| {
750                CliError::new(
751                    ExitKind::Generic,
752                    "PROJECTION_LOAD_FAILED",
753                    format!("could not load binding store: {e}"),
754                )
755                .with_details(json!({ "error": e.to_string() }))
756            })?;
757            // Distinguish "nothing is configured" from "everything is backing
758            // off". Both otherwise collapse into the same `None` from
759            // `select_next_due_operation`, but the two outcomes want different
760            // caller responses: an empty store is a setup prompt, a
761            // backing-off pass is a no-op retry. Emit the empty-store signal
762            // explicitly so a caller (the plugin router, a status display) can
763            // branch on it.
764            if configs.bindings.is_empty() {
765                if ctx.json {
766                    print_json(&json!({ "no_bindings": true }))?;
767                } else {
768                    println!("> **[projection] No bindings configured in this workspace yet.**");
769                }
770                return Ok(());
771            }
772            // Criterion 4's --all half (backlog-sweep plan 03): the rotation
773            // drops pairs the binding never loop-declared — by design
774            // (consent lives in the declaration), but never silently. Say
775            // which (binding, op) pairs the requested filter can never
776            // rotate: JSON carries them structurally; markdown puts the note
777            // on stderr so the brief on stdout stays a verbatim prompt.
778            let never_rotated = not_loop_declared(&configs, args.operation.to_filter());
779            if !ctx.json {
780                for (binding, op) in &never_rotated {
781                    eprintln!(
782                        "[projection] skipped from rotation: `{binding}` declares no \
783                         loop-triggered {} operation (enable with `memstead projection \
784                         enable {} {binding}`)",
785                        op.as_wire(),
786                        op.as_wire(),
787                    );
788                }
789            }
790            let picked = select_next_due_operation(
791                engine,
792                &root,
793                &configs,
794                args.operation.to_filter(),
795                args.consume,
796            );
797            (picked, never_rotated)
798        }
799    };
800    let not_rotated_json = never_rotated
801        .iter()
802        .map(|(b, o)| json!({ "binding": b, "operation": o.as_wire() }))
803        .collect::<Vec<_>>();
804
805    let Some((binding_id, operation)) = selected else {
806        // Every eligible pair is backing off (or not due) this pass — a valid
807        // outcome, the loop's quiet yield.
808        if ctx.json {
809            print_json(&json!({ "skipped": true, "not_rotated": not_rotated_json }))?;
810        } else {
811            println!(
812                "> **[projection] Skipped — every eligible binding is backing off this pass.**"
813            );
814        }
815        return Ok(());
816    };
817
818    // Dispatch to the selected operation's renderer: the rotation hands back
819    // build / sync / verify pairs, each with its own brief.
820    let rendered = match operation {
821        OperationKind::Build => render_ingest_brief(engine, &root, &binding_id, args.consume),
822        OperationKind::Sync => render_sync_brief_for(engine, &root, &binding_id),
823        OperationKind::Verify => render_verify_brief_for(engine, &root, &binding_id),
824    }
825    .map_err(|e| map_brief_err(&binding_id, e))?;
826
827    if ctx.json {
828        print_json(&json!({
829            "brief": rendered,
830            "operation": operation.as_wire(),
831            "not_rotated": not_rotated_json,
832        }))?;
833    } else {
834        // The brief *is* the stdout content (the skill pipes it as the agent
835        // prompt) — write it verbatim, no added trailing newline.
836        print!("{rendered}");
837    }
838    Ok(())
839}
840
841/// Is `value` a single, plain path component — safe to use verbatim as a `<mem>`
842/// or `<stem>` dir/file segment and as half of the binding id? Mirrors
843/// `pipeline_store`'s internal component guard so `init` refuses with a clear
844/// typed code up front rather than surfacing a store IO error mid-scaffold.
845fn is_single_component(value: &str) -> bool {
846    !value.is_empty()
847        && value != "."
848        && value != ".."
849        && !value.contains('/')
850        && !value.contains('\\')
851        && !value.contains(':')
852        && !value.contains('\0')
853}
854
855/// Derive a binding stem from a `--source` pointer: its final path component
856/// (trailing slashes trimmed). `../public` → `public`; `home` → `home`;
857/// `https://example.com/manual` → `manual`.
858fn derive_stem(source: &str) -> String {
859    source
860        .trim_end_matches('/')
861        .rsplit('/')
862        .next()
863        .unwrap_or(source)
864        .to_string()
865}
866
867/// Map a store write failure during scaffolding to a typed CLI error.
868fn init_write_error(binding_id: &str, err: StoreError) -> CliError {
869    CliError::new(
870        ExitKind::Generic,
871        "PROJECTION_INIT_FAILED",
872        format!("could not scaffold binding `{binding_id}`: {err}"),
873    )
874    .with_details(json!({ "binding": binding_id, "error": err.to_string() }))
875}
876
877fn init(ctx: &CliContext, args: InitArgs) -> anyhow::Result<()> {
878    let (_shape, root) = ctx.workspace_shape().ok_or_else(|| {
879        workspace_not_initialised_error(
880            "not inside a Memstead workspace (no `.memstead/workspace.toml` in any ancestor)",
881        )
882    })?;
883
884    let mem = args.mem;
885    let stem = args
886        .name
887        .clone()
888        .unwrap_or_else(|| derive_stem(&args.source));
889
890    // `mem` and `stem` become three file-path components and the binding id —
891    // refuse anything that is not a single plain component before touching disk.
892    for (kind, value) in [("mem", mem.as_str()), ("name", stem.as_str())] {
893        if !is_single_component(value) {
894            return Err(CliError::new(
895                ExitKind::Validation,
896                "PROJECTION_INVALID_NAME",
897                format!(
898                    "invalid {kind} '{}': must be a single path component (no separators, \
899                     traversal segments, ':' or NUL) — pass an explicit --name",
900                    value.escape_default()
901                ),
902            )
903            .with_details(json!({ "kind": kind, "value": value }))
904            .into());
905        }
906    }
907
908    let binding_id = format!("{mem}/{stem}");
909    let medium_type = args.medium_type.to_medium_type();
910
911    // Refuse — without touching disk — when a binding of this id already exists
912    // (D8: `init` never overwrites). The binding occupies the per-mem
913    // projections tier; its presence is the id-collision signal.
914    let binding_path = root
915        .join(".memstead")
916        .join("projections")
917        .join(&mem)
918        .join(format!("{stem}.json"));
919    if binding_path.exists() {
920        return Err(CliError::new(
921            ExitKind::Validation,
922            "PROJECTION_EXISTS",
923            format!(
924                "a binding `{binding_id}` already exists at \
925                 .memstead/projections/{mem}/{stem}.json — `projection init` never overwrites; \
926                 choose a different --name or edit the existing binding"
927            ),
928        )
929        .with_details(json!({ "binding": binding_id }))
930        .into());
931    }
932
933    // The scaffolded record comes from the engine — one definition of "a
934    // fresh binding" (source scoped `**/*`, materialised deny defaults,
935    // matrix-filtered operations, prune where sync survived), shared with
936    // every other front door that creates one.
937    let scaffolded = memstead_base::binding::scaffold_binding(ScaffoldParams {
938        destination_mem: &mem,
939        source_name: &stem,
940        pointer: &args.source,
941        medium_type,
942        intent: args.intent.clone(),
943        additional_deny_paths: Vec::new(),
944    });
945    let binding = scaffolded.binding;
946    let operations = scaffolded.operations;
947    let mut warnings = scaffolded.warnings;
948
949    // Out-of-workspace medium base — a supported shape with one honest
950    // caveat, named NOW, at the layout decision. The operation still succeeds.
951    if let Some(w) =
952        memstead_base::ingest::cursor::out_of_root_layout_warning(&args.source, &root, medium_type)
953    {
954        warnings.push(w);
955    }
956
957    // A source that is not there yet is legal to declare — the tree may
958    // arrive later — but silence here is how the setup ramp produces a
959    // binding that can never yield anything from a mistyped answer. Say it
960    // at the moment the pointer is chosen, where the typo is still in view.
961    if matches!(
962        medium_type,
963        memstead_base::MediumType::Codebase
964            | memstead_base::MediumType::Filesystem
965            | memstead_base::MediumType::Git
966    ) {
967        let base = memstead_base::ingest::cursor::medium_base(&args.source, &root);
968        if !base.exists() {
969            warnings.push(format!(
970                "source '{}' resolves to '{}', which does not exist — the binding is \
971                 declared, but nothing can be read from it until that path is there \
972                 (check the path, or correct the pointer in \
973                 .memstead/projections/{mem}/{stem}.json)",
974                args.source,
975                base.display(),
976            ));
977        }
978    }
979
980    // Write the one record. The id-collision refusal above already
981    // guaranteed a fresh binding, so this path only runs on a clean
982    // scaffold; a store IO failure surfaces the typed
983    // `PROJECTION_INIT_FAILED`.
984    write_binding(&root, &mem, &stem, &binding).map_err(|e| init_write_error(&binding_id, e))?;
985
986    let created = vec![format!(".memstead/projections/{mem}/{stem}.json")];
987
988    if ctx.json {
989        // D8's pinned skill contract: { binding, created, operations, warnings }.
990        print_json(&json!({
991            "binding": binding_id,
992            "created": created,
993            "operations": operations,
994            "warnings": warnings,
995        }))?;
996    } else {
997        let mut out = format!("# Projection init\n\nScaffolded binding `{binding_id}`:\n");
998        for c in &created {
999            out.push_str(&format!("- `{c}`\n"));
1000        }
1001        out.push_str(&format!("\nOperations: {}\n", operations.join(", ")));
1002        if !warnings.is_empty() {
1003            out.push_str("\n## Warnings\n\n");
1004            for w in &warnings {
1005                out.push_str(&format!("- {w}\n"));
1006            }
1007        }
1008        print_markdown(&out);
1009    }
1010    Ok(())
1011}
1012
1013fn map_migrate_err(err: BindingMigrateError) -> CliError {
1014    // Spell each `PROJECTION_*` token as a literal at its own construction site
1015    // so the generated error index (xtask) picks them up — a variable `code`
1016    // is invisible to the string-literal scanner.
1017    let message = err.to_string();
1018    match &err {
1019        BindingMigrateError::RefinementModeDeleted { .. } => CliError::new(
1020            ExitKind::Validation,
1021            "PROJECTION_MIGRATE_REFINEMENT",
1022            message,
1023        ),
1024        BindingMigrateError::MalformedProjectionRef { .. } => CliError::new(
1025            ExitKind::Validation,
1026            "PROJECTION_MIGRATE_MALFORMED_REF",
1027            message,
1028        ),
1029        BindingMigrateError::DanglingProjectionRef { .. }
1030        | BindingMigrateError::DanglingFacetRef { .. }
1031        | BindingMigrateError::DanglingMediumRef { .. } => CliError::new(
1032            ExitKind::Validation,
1033            "PROJECTION_MIGRATE_DANGLING_REF",
1034            message,
1035        ),
1036        BindingMigrateError::OrphanRecords { .. } => CliError::new(
1037            ExitKind::Validation,
1038            "PROJECTION_MIGRATE_ORPHAN_RECORDS",
1039            message,
1040        ),
1041    }
1042}
1043
1044/// Does the workspace root carry a gen-1 legacy pipeline layout — the
1045/// pre-four-primitive `scopes|projections|ingests/` JSON folders at the root
1046/// (not under `.memstead/`)? Presence of any of the three marks it. This is the
1047/// trigger for folding the retired `pipeline migrate` conversion into
1048/// `projection migrate` (D10, gen-1 path).
1049fn has_legacy_root_layout(root: &std::path::Path) -> bool {
1050    ["scopes", "projections", "ingests"]
1051        .iter()
1052        .any(|d| root.join(d).is_dir())
1053}
1054
1055/// Map a store load failure during migrate to the typed generic code.
1056fn migrate_load_err(err: StoreError) -> CliError {
1057    CliError::new(
1058        ExitKind::Generic,
1059        "PROJECTION_MIGRATE_FAILED",
1060        format!("could not load pipeline config: {err}"),
1061    )
1062    .with_details(json!({ "error": err.to_string() }))
1063}
1064
1065/// Does the binding's `medium_pointer` (resolved against the workspace root)
1066/// point at the same location as a `reconcile-cursors.json` absolute key? Uses
1067/// canonicalization where both paths exist, else a lexical comparison (D10 —
1068/// "the binding whose medium pointer resolves to that path").
1069fn pointer_resolves_to(root: &std::path::Path, medium_pointer: &str, abs_path: &str) -> bool {
1070    let resolved = if medium_pointer.is_empty() {
1071        root.to_path_buf()
1072    } else {
1073        root.join(medium_pointer)
1074    };
1075    match (
1076        std::fs::canonicalize(&resolved),
1077        std::fs::canonicalize(abs_path),
1078    ) {
1079        (Ok(a), Ok(b)) => a == b,
1080        _ => resolved == std::path::Path::new(abs_path),
1081    }
1082}
1083
1084/// Scan `workspace.toml` for retired pipeline/cursor vocabulary. `projection
1085/// migrate` **never** writes `workspace.toml` (D10) — if it finds a stale
1086/// reference it returns a proposal block for the operator (or the migrating
1087/// session) to apply and commit explicitly, rather than rewriting it.
1088fn propose_workspace_toml(root: &std::path::Path) -> Option<String> {
1089    let path = root.join(".memstead").join("workspace.toml");
1090    let content = std::fs::read_to_string(path).ok()?;
1091    let hits: Vec<(usize, &str)> = content
1092        .lines()
1093        .enumerate()
1094        .filter(|(_, l)| {
1095            let low = l.to_lowercase();
1096            low.contains("reconcile-cursors") || low.contains("ingests/") || low.contains("ingest ")
1097        })
1098        .collect();
1099    if hits.is_empty() {
1100        return None;
1101    }
1102    let mut block = String::from(
1103        "## Proposal: workspace.toml (NOT applied)\n\n`projection migrate` never edits \
1104         `workspace.toml`. It found references to retired pipeline vocabulary — review and \
1105         update these lines by hand, then commit:\n\n",
1106    );
1107    for (i, line) in hits {
1108        block.push_str(&format!("- L{}: `{}`\n", i + 1, line.trim()));
1109    }
1110    Some(block)
1111}
1112
1113/// Binding-miss refusal that honours the quarantine roster
1114/// (agent-trust plan 04): a binding whose stored file failed the v2
1115/// version gate is QUARANTINED, not unknown — the refusal carries the
1116/// typed reason, whose message names `memstead projection migrate`
1117/// for the legacy generations. Healthy-miss keeps the historical
1118/// `PROJECTION_NOT_FOUND`.
1119fn binding_miss_error(configs: &memstead_base::BindingConfigs, binding_id: &str) -> CliError {
1120    if let Some(q) = configs
1121        .quarantined
1122        .iter()
1123        .find(|q| format!("{}/{}", q.mem, q.name) == binding_id)
1124    {
1125        return CliError::new(
1126            ExitKind::Validation,
1127            "PROJECTION_QUARANTINED",
1128            format!(
1129                "binding `{binding_id}` is quarantined — its stored file failed the load and \
1130                 it serves no operations until repaired: [{}] {}",
1131                q.reason_code, q.reason_message
1132            ),
1133        )
1134        .with_details(json!({
1135            "binding": binding_id,
1136            "reason_code": q.reason_code,
1137            "reason_message": q.reason_message,
1138            "path": q.path,
1139        }));
1140    }
1141    CliError::new(
1142        ExitKind::NotFound,
1143        "PROJECTION_NOT_FOUND",
1144        format!(
1145            "no binding `{binding_id}` in this workspace — scaffold one with \
1146             `projection init` or migrate a legacy workspace with `projection migrate`"
1147        ),
1148    )
1149    .with_details(json!({ "binding": binding_id }))
1150}
1151
1152/// Consume a skill-written `reconcile-cursors.json` (D10/AC12): each
1153/// machine-absolute `"<mem>:<abs-path>": <sha>` entry seeds the `#synced`
1154/// baseline of every binding whose medium pointer resolves to that path (via
1155/// the engine's `set_mem_sync_state` writer — the engine owns mem-repo state),
1156/// then the file is **deleted** regardless of whether anything matched
1157/// (cursorless / unmatched bindings stay never-synced). Returns the seeded keys.
1158fn consume_reconcile_cursors(
1159    ctx: &CliContext,
1160    root: &std::path::Path,
1161) -> anyhow::Result<(Vec<String>, Option<String>)> {
1162    let cursor_path = root.join(".memstead").join("reconcile-cursors.json");
1163    if !cursor_path.exists() {
1164        return Ok((Vec::new(), None));
1165    }
1166    let cursors: std::collections::BTreeMap<String, String> = std::fs::read(&cursor_path)
1167        .ok()
1168        .and_then(|b| serde_json::from_slice(&b).ok())
1169        .unwrap_or_default();
1170
1171    let mut seeded: Vec<String> = Vec::new();
1172    if !cursors.is_empty() {
1173        let configs = load_pipeline_configs(root).map_err(migrate_load_err)?;
1174        // Repair-below-boot rule: cursor seeding needs a booted engine
1175        // (`set_mem_sync_state`), but `projection migrate` is itself a
1176        // named boot repair — when the workspace still does not boot
1177        // (e.g. a schema-pin failure alongside the projection
1178        // migration), seeding is explicitly DEFERRED rather than
1179        // deadlocking the repair verb or silently dropping the
1180        // cursors: the file is kept untouched and the notice names the
1181        // follow-up.
1182        let mut cli_engine = match ctx.cli_engine_at(root) {
1183            Ok(e) => e,
1184            Err(boot_err) => {
1185                return Ok((
1186                    Vec::new(),
1187                    Some(format!(
1188                        "RECONCILE_CURSORS_DEFERRED: the workspace does not boot yet \
1189                         ({boot_err:#}); reconcile-cursors.json was kept — repair the boot, \
1190                         then re-run `memstead projection migrate` to seed the sync baselines"
1191                    )),
1192                ));
1193            }
1194        };
1195        let engine = cli_engine.base_mut();
1196        for (cursor_key, sha) in &cursors {
1197            // Key is `"<mem>:<abs-path>"` — split on the first ':'.
1198            let Some((_cursor_mem, abs_path)) = cursor_key.split_once(':') else {
1199                continue;
1200            };
1201            for record in &configs.bindings {
1202                let binding_id = format!("{}/{}", record.mem, record.name);
1203                let Ok(resolved) = resolve_binding_run(&binding_id, &record.config) else {
1204                    continue;
1205                };
1206                for source in &resolved.sources {
1207                    if let ResolvedSource::Primary(p) = source
1208                        && pointer_resolves_to(root, &p.pointer, abs_path)
1209                    {
1210                        let key = format!("{binding_id}/{}#synced", p.name);
1211                        // `.is_ok()` dropped the outcome and with it any
1212                        // report that a sibling had moved the config
1213                        // (04/03, criterion 3). A seeding pass that silently
1214                        // merged over someone is worth one line of output.
1215                        if let Ok(outcome) = engine.set_mem_sync_state(
1216                            &resolved.destination_mem,
1217                            &key,
1218                            sha,
1219                            Some("projection migrate: seeded from reconcile-cursors.json"),
1220                        ) {
1221                            for w in &outcome.warnings {
1222                                crate::output::print_markdown(&format!("> {w}"));
1223                            }
1224                            seeded.push(key);
1225                        }
1226                    }
1227                }
1228            }
1229        }
1230    }
1231    // Consumed — delete regardless of matches (D10: the file is retired here).
1232    let _ = std::fs::remove_file(&cursor_path);
1233    Ok((seeded, None))
1234}
1235
1236fn migrate(ctx: &CliContext, args: MigrateArgs) -> anyhow::Result<()> {
1237    let (_shape, root) = ctx.workspace_shape().ok_or_else(|| {
1238        workspace_not_initialised_error(
1239            "not inside a Memstead workspace (no `.memstead/workspace.toml` in any ancestor)",
1240        )
1241    })?;
1242
1243    // Gen-1 root-folder layout (`scopes|projections|ingests/` at the workspace
1244    // root) — the pre-four-primitive generation the retired `pipeline migrate`
1245    // command handled. Fold it in: materialize it into the four-primitive
1246    // `.memstead/` store first (mediums + facets + projections + ingests),
1247    // then fold to v2 below in the same pass. `--dry-run` reads the
1248    // root-folder configs directly without writing anything.
1249    let gen1 = has_legacy_root_layout(&root);
1250    if gen1 && !args.dry_run {
1251        migrate_legacy_pipeline(&root).map_err(|e| {
1252            CliError::new(
1253                ExitKind::Generic,
1254                "PROJECTION_MIGRATE_FAILED",
1255                format!("could not convert root-folder (gen-1) pipeline layout: {e}"),
1256            )
1257            .with_details(json!({ "error": e.to_string() }))
1258        })?;
1259    }
1260
1261    let configs = if gen1 && args.dry_run {
1262        read_legacy_pipeline_configs(&root).map_err(migrate_load_err)?
1263    } else {
1264        load_legacy_pipeline_configs(&root).map_err(migrate_load_err)?
1265    };
1266
1267    // Pure transforms first: any refusal (refinement / dangling / malformed /
1268    // orphan) aborts before a single file is touched — the migration is
1269    // all-or-nothing.
1270    //
1271    // Leg A (gen-2): merge each flat ingest into its projection and fold the
1272    // referenced facets + mediums inline — one v2 record per pipeline.
1273    let mut migrated = migrate_gen2_bindings(&configs).map_err(map_migrate_err)?;
1274
1275    // Leg B (v1 → v2): fold every on-disk `version: 1` binding of the
1276    // retired three-file store the same way, in place. Source names are the
1277    // facet names byte-verbatim, so sync watermarks keep resolving. A
1278    // version-less projection file no ingest schedules is inert leftovers —
1279    // refused with a remedy rather than silently dropped or left to break
1280    // the loader. (Skipped in the gen-1 dry-run, which previews in-memory.)
1281    let mut already_v2 = 0usize;
1282    if !(gen1 && args.dry_run) {
1283        let generations = load_projection_generations(&root).map_err(migrate_load_err)?;
1284        for (mem, name, generation) in generations {
1285            let binding_id = format!("{mem}/{name}");
1286            match generation {
1287                ProjectionGeneration::V2 => already_v2 += 1,
1288                ProjectionGeneration::V1(v1) => {
1289                    let consumed = v1.source_facets.clone();
1290                    let binding = fold_v1_binding(&binding_id, &mem, v1.as_ref(), &configs)
1291                        .map_err(map_migrate_err)?;
1292                    migrated.push(memstead_base::binding_migrate::MigratedBinding {
1293                        id: binding_id,
1294                        mem,
1295                        name,
1296                        ingest_name: String::new(),
1297                        consumed_facets: consumed,
1298                        binding,
1299                        notes: Vec::new(),
1300                    });
1301                }
1302                ProjectionGeneration::VersionLess => {
1303                    if !migrated.iter().any(|m| m.mem == mem && m.name == name) {
1304                        return Err(CliError::new(
1305                            ExitKind::Validation,
1306                            "PROJECTION_MIGRATE_INERT_PROJECTION",
1307                            format!(
1308                                "projection `{binding_id}` is a version-less gen-2 file no \
1309                                 ingest schedules — inert leftovers the loader refuses; delete \
1310                                 .memstead/projections/{mem}/{name}.json (or add an ingest) and \
1311                                 re-run `projection migrate`"
1312                            ),
1313                        )
1314                        .with_details(json!({ "binding": binding_id }))
1315                        .into());
1316                    }
1317                }
1318            }
1319        }
1320        migrated.sort_by(|a, b| a.id.cmp(&b.id));
1321
1322        // Every medium/facet record must have folded into some binding —
1323        // an orphan would be silently dropped by the tree removal, so the
1324        // whole migration refuses instead, naming each leftover.
1325        let consumed: Vec<(String, String)> = migrated
1326            .iter()
1327            .flat_map(|m| m.consumed_facets.iter().map(|f| (m.mem.clone(), f.clone())))
1328            .collect();
1329        check_all_consumed(&configs, &consumed).map_err(map_migrate_err)?;
1330    }
1331
1332    // Validate each produced binding against the capability matrix. A
1333    // capability refusal reflects a pre-existing config problem the binding
1334    // faithfully carries; surface it as a per-binding warning rather than
1335    // aborting the promotion. The folded v2 record validates directly — no
1336    // external resolution.
1337    let mut warnings: Vec<serde_json::Value> = Vec::new();
1338    for m in &migrated {
1339        if let Err(refusals) = validate_binding(&m.binding) {
1340            for r in refusals {
1341                warnings.push(json!({
1342                    "binding": m.id,
1343                    "kind": "capability",
1344                    "message": r.to_string(),
1345                }));
1346            }
1347        }
1348        for note in &m.notes {
1349            warnings.push(json!({
1350                "binding": m.id,
1351                "kind": "note",
1352                "message": note,
1353            }));
1354        }
1355    }
1356
1357    // Emit to disk unless previewing: promote each projection file to its v2
1358    // binding in place, remove each consumed flat ingest, then remove the
1359    // emptied `mediums/` and `facets/` trees (every record folded — the
1360    // orphan check above guaranteed it).
1361    if !args.dry_run {
1362        for m in &migrated {
1363            write_binding(&root, &m.mem, &m.name, &m.binding).map_err(|e| {
1364                CliError::new(
1365                    ExitKind::Generic,
1366                    "PROJECTION_MIGRATE_FAILED",
1367                    format!("could not write binding `{}`: {e}", m.id),
1368                )
1369                .with_details(json!({ "binding": m.id, "error": e.to_string() }))
1370            })?;
1371            if !m.ingest_name.is_empty() {
1372                delete_ingest(&root, &m.ingest_name).map_err(|e| {
1373                    CliError::new(
1374                        ExitKind::Generic,
1375                        "PROJECTION_MIGRATE_FAILED",
1376                        format!("could not remove merged ingest `{}`: {e}", m.ingest_name),
1377                    )
1378                    .with_details(json!({ "ingest": m.ingest_name, "error": e.to_string() }))
1379                })?;
1380            }
1381        }
1382        remove_mediums_and_facets_trees(&root).map_err(|e| {
1383            CliError::new(
1384                ExitKind::Generic,
1385                "PROJECTION_MIGRATE_FAILED",
1386                format!("could not remove the emptied mediums/facets trees: {e}"),
1387            )
1388            .with_details(json!({ "error": e.to_string() }))
1389        })?;
1390    }
1391
1392    // AC12/D10: consume `reconcile-cursors.json` (seed `#synced` baselines, then
1393    // delete it) and surface a `workspace.toml` proposal for any retired-vocab
1394    // references — never rewriting workspace.toml. Both are no-ops in `--dry-run`.
1395    let ((seeded, cursors_deferred), proposal) = if args.dry_run {
1396        ((Vec::new(), None), None)
1397    } else {
1398        (
1399            consume_reconcile_cursors(ctx, &root)?,
1400            propose_workspace_toml(&root),
1401        )
1402    };
1403
1404    let bindings: Vec<&str> = migrated.iter().map(|m| m.id.as_str()).collect();
1405    if ctx.json {
1406        print_json(&json!({
1407            "ok": true,
1408            "dry_run": args.dry_run,
1409            "migrated": migrated.len(),
1410            "already_v2": already_v2,
1411            "bindings": bindings,
1412            "warnings": warnings,
1413            "cursors_seeded": seeded,
1414            "cursors_deferred": cursors_deferred,
1415            "workspace_toml_proposal": proposal,
1416        }))?;
1417    } else {
1418        let verb = if args.dry_run {
1419            "Would migrate"
1420        } else {
1421            "Migrated"
1422        };
1423        let mut out = format!(
1424            "# Projection migration\n\n{verb} {} binding(s) to v2 ({already_v2} already v2):\n",
1425            migrated.len()
1426        );
1427        for id in &bindings {
1428            out.push_str(&format!("- `{id}`\n"));
1429        }
1430        if !warnings.is_empty() {
1431            out.push_str("\n## Warnings\n\n");
1432            for w in &warnings {
1433                out.push_str(&format!(
1434                    "- [{}] `{}`: {}\n",
1435                    w["kind"].as_str().unwrap_or(""),
1436                    w["binding"].as_str().unwrap_or(""),
1437                    w["message"].as_str().unwrap_or(""),
1438                ));
1439            }
1440        }
1441        if !seeded.is_empty() {
1442            out.push_str("\n## Baselines seeded from reconcile-cursors.json\n\n");
1443            for key in &seeded {
1444                out.push_str(&format!("- `{key}`\n"));
1445            }
1446        }
1447        if let Some(notice) = &cursors_deferred {
1448            out.push_str(&format!("\n## Reconcile cursors deferred\n\n{notice}\n"));
1449        }
1450        if let Some(block) = &proposal {
1451            out.push('\n');
1452            out.push_str(block);
1453        }
1454        if !args.dry_run {
1455            out.push_str(
1456                "\nEach projection file was converted to a v2 single-record binding in place \
1457                 (medium + facet content folded inline, source names preserved verbatim); \
1458                 merged ingests and the emptied mediums/ and facets/ trees were removed.\n",
1459            );
1460        }
1461        print_markdown(&out);
1462    }
1463    Ok(())
1464}
1465
1466/// A malformed binding id (not `<mem>/<stem>`, or a half that is not a single
1467/// plain path component) — the same shape guard `init` applies to its
1468/// scaffolded id, spelled here so the failure is typed before any disk touch.
1469fn invalid_binding_id(binding_id: &str) -> CliError {
1470    CliError::new(
1471        ExitKind::Validation,
1472        "PROJECTION_INVALID_NAME",
1473        format!(
1474            "invalid binding id '{}': expected `<mem>/<stem>` with each half a single path \
1475             component (no extra separators, traversal segments, ':' or NUL)",
1476            binding_id.escape_default()
1477        ),
1478    )
1479    .with_details(json!({ "binding": binding_id }))
1480}
1481
1482/// Map a store IO/parse failure while enabling to a typed CLI error. The
1483/// missing-binding case is handled separately (existence pre-check →
1484/// `PROJECTION_NOT_FOUND`); this covers a present-but-unreadable/unparseable
1485/// binding file and write failures.
1486fn enable_failed(binding_id: &str, err: StoreError) -> CliError {
1487    CliError::new(
1488        ExitKind::Generic,
1489        "PROJECTION_ENABLE_FAILED",
1490        format!("could not enable operation on binding `{binding_id}`: {err}"),
1491    )
1492    .with_details(json!({ "binding": binding_id, "error": err.to_string() }))
1493}
1494
1495fn enable(ctx: &CliContext, args: EnableArgs) -> anyhow::Result<()> {
1496    let (_shape, root) = ctx.workspace_shape().ok_or_else(|| {
1497        workspace_not_initialised_error(
1498            "not inside a Memstead workspace (no `.memstead/workspace.toml` in any ancestor)",
1499        )
1500    })?;
1501
1502    let binding_id = args.binding;
1503    let op = args.operation;
1504
1505    // Parse the binding id `<mem>/<stem>`; refuse a malformed shape (or a half
1506    // that is not a single plain path component) before touching disk. Own the
1507    // halves so `binding_id` is free to move into JSON payloads later.
1508    let (mem, stem) = binding_id
1509        .split_once('/')
1510        .filter(|(m, n)| !m.is_empty() && !n.is_empty())
1511        .filter(|(m, n)| is_single_component(m) && is_single_component(n))
1512        .ok_or_else(|| invalid_binding_id(&binding_id))?;
1513    let mem = mem.to_string();
1514    let stem = stem.to_string();
1515
1516    // Missing binding file → PROJECTION_NOT_FOUND (NotFound exit). A present-
1517    // but-unparseable file is kept apart (→ PROJECTION_ENABLE_FAILED) by this
1518    // existence pre-check.
1519    let binding_path = root
1520        .join(".memstead")
1521        .join("projections")
1522        .join(&mem)
1523        .join(format!("{stem}.json"));
1524    if !binding_path.exists() {
1525        return Err(CliError::new(
1526            ExitKind::NotFound,
1527            "PROJECTION_NOT_FOUND",
1528            format!(
1529                "no binding `{binding_id}` at .memstead/projections/{mem}/{stem}.json — \
1530                 scaffold one with `projection init` or migrate a legacy workspace with \
1531                 `projection migrate`"
1532            ),
1533        )
1534        .with_details(json!({ "binding": binding_id }))
1535        .into());
1536    }
1537    // Quarantine consult before the raw read: a legacy/corrupt file
1538    // refuses with its typed reason (naming `projection migrate` for
1539    // the legacy generations) rather than a generic enable failure.
1540    if let Ok(configs) = load_pipeline_configs(&root)
1541        && configs
1542            .quarantined
1543            .iter()
1544            .any(|q| format!("{}/{}", q.mem, q.name) == binding_id)
1545    {
1546        return Err(binding_miss_error(&configs, &binding_id).into());
1547    }
1548    let mut binding =
1549        read_binding(&root, &mem, &stem).map_err(|e| enable_failed(&binding_id, e))?;
1550
1551    // Already present? Refuse without a partial write. Every operation block is
1552    // optional now (D1/AC4), so `build` is enableable too (the remedy a
1553    // build-less binding's brief refusal cites).
1554    let already = match op {
1555        EnableOperationArg::Build => binding.operations.build.is_some(),
1556        EnableOperationArg::Sync => binding.operations.sync.is_some(),
1557        EnableOperationArg::Verify => binding.operations.verify.is_some(),
1558    };
1559    if already {
1560        return Err(CliError::new(
1561            ExitKind::Validation,
1562            "PROJECTION_OP_ALREADY_ENABLED",
1563            format!(
1564                "operation `{}` is already enabled on binding `{binding_id}` — nothing to do",
1565                op.name()
1566            ),
1567        )
1568        .with_details(json!({ "binding": binding_id, "operation": op.name() }))
1569        .into());
1570    }
1571
1572    // Add the operation block with sensible defaults: `batch_size` mirrors the
1573    // build op's when present, else 20. Sync/verify default `trigger: manual`;
1574    // build defaults to a discovery/loop schedule (the common obligation shape).
1575    let batch_size = binding
1576        .operations
1577        .build
1578        .as_ref()
1579        .map_or(20, |b| b.batch_size);
1580    match op {
1581        EnableOperationArg::Build => {
1582            binding.operations.build = Some(BuildOperation {
1583                mode: BuildMode::Discovery,
1584                trigger: IngestTrigger::Loop,
1585                batch_size,
1586                post_actions: None,
1587            });
1588        }
1589        EnableOperationArg::Sync => {
1590            binding.operations.sync = Some(SyncOperation {
1591                trigger: IngestTrigger::Manual,
1592                batch_size,
1593            });
1594        }
1595        EnableOperationArg::Verify => {
1596            binding.operations.verify = Some(VerifyOperation {
1597                trigger: IngestTrigger::Manual,
1598                batch_size,
1599                adjudication_cap: DEFAULT_ADJUDICATION_CAP,
1600                full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
1601            });
1602        }
1603    }
1604
1605    // Matrix validation: the v2 record carries its sources inline, so the
1606    // candidate validates directly — refuse if a source's medium half cannot
1607    // support the operation being enabled (e.g. `sync`/`verify` over a `web`
1608    // source). Refusals about *other* operations reflect pre-existing config
1609    // and do not block this enable (mirrors `migrate`'s treat-as-warning
1610    // posture). No write on refusal — the file stays byte-identical.
1611    if let Err(refusals) = validate_binding(&binding)
1612        && let Some(err) = refusals.iter().find(|r| {
1613            matches!(
1614                r,
1615                CapabilityError::OperationOutOfScope { operation, .. } if *operation == op.name()
1616            )
1617        })
1618    {
1619        return Err(CliError::new(
1620            ExitKind::Validation,
1621            "PROJECTION_CAPABILITY_UNSUPPORTED",
1622            err.to_string(),
1623        )
1624        .with_details(json!({ "binding": binding_id, "operation": op.name() }))
1625        .into());
1626    }
1627
1628    write_binding(&root, &mem, &stem, &binding).map_err(|e| enable_failed(&binding_id, e))?;
1629
1630    let mut operations: Vec<&str> = Vec::new();
1631    if binding.operations.build.is_some() {
1632        operations.push("build");
1633    }
1634    if binding.operations.sync.is_some() {
1635        operations.push("sync");
1636    }
1637    if binding.operations.verify.is_some() {
1638        operations.push("verify");
1639    }
1640
1641    if ctx.json {
1642        print_json(&json!({
1643            "binding": binding_id,
1644            "enabled": op.name(),
1645            "operations": operations,
1646        }))?;
1647    } else {
1648        print_markdown(&format!(
1649            "# Projection enable\n\nEnabled `{}` on binding `{binding_id}`.\n\nOperations: {}\n",
1650            op.name(),
1651            operations.join(", ")
1652        ));
1653    }
1654    Ok(())
1655}
1656
1657/// `projection edit` — the general binding-field patch over the shared
1658/// `pipeline_edit` layer. The layer owns everything that matters: patch
1659/// semantics (absent preserved, `null` clears, blocks replace whole),
1660/// engine-managed `version`, and validate-before-write with the
1661/// introduced-refusals-only rule. This function is command surface: id
1662/// grammar, quarantine consult, error translation, rendering.
1663fn edit(ctx: &CliContext, args: EditArgs) -> anyhow::Result<()> {
1664    let (_shape, root) = ctx.workspace_shape().ok_or_else(|| {
1665        workspace_not_initialised_error(
1666            "not inside a Memstead workspace (no `.memstead/workspace.toml` in any ancestor)",
1667        )
1668    })?;
1669
1670    let binding_id = args.binding;
1671    let (mem, stem) = binding_id
1672        .split_once('/')
1673        .filter(|(m, n)| !m.is_empty() && !n.is_empty())
1674        .filter(|(m, n)| is_single_component(m) && is_single_component(n))
1675        .ok_or_else(|| invalid_binding_id(&binding_id))?;
1676    let mem = mem.to_string();
1677    let stem = stem.to_string();
1678
1679    // Quarantine consult before the edit: a legacy/corrupt record refuses
1680    // with its typed reason (naming `projection migrate`) rather than a
1681    // generic not-found or parse failure.
1682    if let Ok(configs) = load_pipeline_configs(&root)
1683        && configs
1684            .quarantined
1685            .iter()
1686            .any(|q| format!("{}/{}", q.mem, q.name) == binding_id)
1687    {
1688        return Err(binding_miss_error(&configs, &binding_id).into());
1689    }
1690
1691    let binding =
1692        memstead_base::pipeline_edit::update_binding_json(&root, &mem, &stem, &args.patch)
1693            .map_err(|e| edit_refused(&binding_id, e))?;
1694
1695    let source_names: Vec<&str> = binding.sources.iter().map(|s| s.name.as_str()).collect();
1696    let mut operations: Vec<&str> = Vec::new();
1697    if binding.operations.build.is_some() {
1698        operations.push("build");
1699    }
1700    if binding.operations.sync.is_some() {
1701        operations.push("sync");
1702    }
1703    if binding.operations.verify.is_some() {
1704        operations.push("verify");
1705    }
1706
1707    if ctx.json {
1708        print_json(&json!({
1709            "binding": binding_id,
1710            "edited": true,
1711            "sources": source_names,
1712            "operations": operations,
1713            "record": serde_json::to_value(&binding)?,
1714        }))?;
1715    } else {
1716        print_markdown(&format!(
1717            "# Projection edit\n\nPatched binding `{binding_id}`.\n\nSources: {}\nOperations: {}\n",
1718            source_names.join(", "),
1719            operations.join(", ")
1720        ));
1721    }
1722    Ok(())
1723}
1724
1725/// Translate a [`pipeline_edit`] refusal into the CLI's typed envelope.
1726/// Nothing was written on any of these — the layer validates before it
1727/// writes.
1728fn edit_refused(
1729    binding_id: &str,
1730    err: memstead_base::pipeline_edit::PipelineEditError,
1731) -> CliError {
1732    use memstead_base::pipeline_edit::PipelineEditError as E;
1733    match &err {
1734        E::NotFound { .. } => CliError::new(
1735            ExitKind::NotFound,
1736            "PROJECTION_NOT_FOUND",
1737            format!(
1738                "no binding `{binding_id}` — scaffold one with `projection init` or migrate a \
1739                 legacy workspace with `projection migrate`"
1740            ),
1741        )
1742        .with_details(json!({ "binding": binding_id })),
1743        E::InvalidJson { message, .. } => CliError::new(
1744            ExitKind::Validation,
1745            "PROJECTION_EDIT_INVALID_JSON",
1746            format!("the patch for `{binding_id}` did not deserialize: {message}"),
1747        )
1748        .with_details(json!({ "binding": binding_id, "error": message })),
1749        E::Capability { refusals, .. } => CliError::new(
1750            ExitKind::Validation,
1751            "PROJECTION_EDIT_REFUSED",
1752            format!(
1753                "the patch would introduce validation refusals on `{binding_id}` (nothing was \
1754                 written): {}",
1755                refusals
1756                    .iter()
1757                    .map(|r| r.to_string())
1758                    .collect::<Vec<_>>()
1759                    .join("; ")
1760            ),
1761        )
1762        .with_details(json!({
1763            "binding": binding_id,
1764            "refusals": refusals.iter().map(|r| r.to_string()).collect::<Vec<_>>(),
1765        })),
1766        _ => CliError::new(
1767            ExitKind::Generic,
1768            "PROJECTION_EDIT_FAILED",
1769            format!("could not edit binding `{binding_id}`: {err}"),
1770        )
1771        .with_details(json!({ "binding": binding_id, "error": err.to_string() })),
1772    }
1773}
1774
1775/// `projection check-path` — deny verdicts for tool-call candidates, engine-
1776/// free: binding records are pure file I/O and the dialect evaluation needs
1777/// no store, so the check stays cheap enough to run on every tool call.
1778fn check_path(ctx: &CliContext, args: CheckPathArgs) -> anyhow::Result<()> {
1779    let (_shape, root) = ctx.workspace_shape().ok_or_else(|| {
1780        workspace_not_initialised_error(
1781            "not inside a Memstead workspace (no `.memstead/workspace.toml` in any ancestor)",
1782        )
1783    })?;
1784
1785    // Batch payload first: its `cwd` outranks the flag, and a malformed
1786    // payload refuses before any binding work — never a part-answer.
1787    let (candidates, payload_cwd): (Vec<String>, Option<std::path::PathBuf>) = if args.batch {
1788        let mut raw = String::new();
1789        std::io::Read::read_to_string(&mut std::io::stdin(), &mut raw)
1790            .map_err(|e| batch_invalid(format!("stdin unreadable: {e}")))?;
1791        let value: serde_json::Value =
1792            serde_json::from_str(&raw).map_err(|e| batch_invalid(format!("not JSON: {e}")))?;
1793        let paths = value
1794            .get("paths")
1795            .and_then(|p| p.as_array())
1796            .ok_or_else(|| batch_invalid("missing `paths` array".to_string()))?;
1797        let mut out = Vec::with_capacity(paths.len());
1798        for p in paths {
1799            match p.as_str() {
1800                Some(s) => out.push(s.to_string()),
1801                None => {
1802                    return Err(batch_invalid(format!("non-string entry in `paths`: {p}")).into());
1803                }
1804            }
1805        }
1806        let cwd = match value.get("cwd") {
1807            None | Some(serde_json::Value::Null) => None,
1808            Some(serde_json::Value::String(s)) => Some(std::path::PathBuf::from(s)),
1809            Some(other) => {
1810                return Err(batch_invalid(format!("`cwd` is not a string: {other}")).into());
1811            }
1812        };
1813        (out, cwd)
1814    } else {
1815        (vec![args.path.clone().expect("clap requires PATH")], None)
1816    };
1817
1818    // The binding: named, or the active one (published by the last consuming
1819    // brief render). No active binding is a typed refusal, never an implicit
1820    // "allowed" — the caller decides that an unanswerable check fails open.
1821    let binding_id = match args.binding.clone() {
1822        Some(id) => id,
1823        None => memstead_base::ingest::read_active_binding_file(&root).ok_or_else(|| {
1824            CliError::new(
1825                ExitKind::NotFound,
1826                "NO_ACTIVE_BINDING",
1827                "no active binding — no consuming brief render has published one; name a \
1828                 binding explicitly with --binding <mem>/<stem>",
1829            )
1830        })?,
1831    };
1832
1833    let configs = load_pipeline_configs(&root).map_err(|e| {
1834        CliError::new(
1835            ExitKind::Generic,
1836            "PROJECTION_LOAD_FAILED",
1837            format!("binding store unreadable: {e}"),
1838        )
1839    })?;
1840    let binding = configs
1841        .bindings
1842        .iter()
1843        .find(|r| format!("{}/{}", r.mem, r.name) == binding_id)
1844        .map(|r| &r.config)
1845        .ok_or_else(|| binding_miss_error(&configs, &binding_id))?;
1846
1847    let cwd = match payload_cwd.or(args.cwd) {
1848        Some(dir) => dir,
1849        None => std::env::current_dir()?,
1850    };
1851    let verdicts =
1852        memstead_base::ingest::check_deny_paths(&binding.deny_paths, &candidates, &cwd, &root);
1853
1854    if ctx.json {
1855        print_json(&json!({
1856            "binding": binding_id,
1857            "results": verdicts
1858                .iter()
1859                .map(|v| json!({
1860                    "path": v.path,
1861                    "denied": v.denied,
1862                    "matched": v.matched,
1863                }))
1864                .collect::<Vec<_>>(),
1865        }))?;
1866    } else {
1867        let mut out = format!("# Check path — binding `{binding_id}`\n\n");
1868        for v in &verdicts {
1869            match &v.matched {
1870                Some(entry) => {
1871                    out.push_str(&format!("- DENIED `{}` — matched `{entry}`\n", v.path));
1872                }
1873                None => out.push_str(&format!("- allowed `{}`\n", v.path)),
1874            }
1875        }
1876        print_markdown(&out);
1877    }
1878    Ok(())
1879}
1880
1881/// A malformed `--batch` payload: refuse whole, name the defect.
1882fn batch_invalid(reason: String) -> CliError {
1883    CliError::new(
1884        ExitKind::Validation,
1885        "INVALID_INPUT",
1886        format!(
1887            "--batch expects one JSON object on stdin — {{\"cwd\": \"<dir>\", \
1888             \"paths\": [\"...\"]}} — {reason}"
1889        ),
1890    )
1891}
1892
1893/// Map a `resolve_binding_run` failure to a typed CLI error. With inline
1894/// sources the dangling facet/medium refusals are gone; a malformed id is the
1895/// Validation-shaped name error, everything else generic.
1896fn map_resolve_err(binding_id: &str, err: ResolveError) -> CliError {
1897    let message = err.to_string();
1898    let mapped = match err {
1899        ResolveError::MalformedProjectionRef { .. } => {
1900            CliError::new(ExitKind::Validation, "PROJECTION_INVALID_NAME", message)
1901        }
1902        ResolveError::UninterpretableScope { .. } => CliError::new(
1903            ExitKind::Validation,
1904            "PROJECTION_SCOPE_UNINTERPRETABLE",
1905            message,
1906        ),
1907        _ => CliError::new(ExitKind::Generic, "PROJECTION_ADVANCE_FAILED", message),
1908    };
1909    mapped.with_details(json!({ "binding": binding_id }))
1910}
1911
1912/// Map an [`AdvanceError`] to a typed CLI error. The unknown-artifact refusal
1913/// is the D7 gate (Validation); a malformed id is a Validation-shaped name
1914/// error; store / engine failures are generic. Codes are spelled as literals at
1915/// each site so the generated error index picks them up.
1916fn map_advance_err(binding_id: &str, err: AdvanceError) -> CliError {
1917    let message = err.to_string();
1918    match &err {
1919        AdvanceError::MalformedId(_) => {
1920            CliError::new(ExitKind::Validation, "PROJECTION_INVALID_NAME", message)
1921                .with_details(json!({ "binding": binding_id }))
1922        }
1923        AdvanceError::UnknownArtifact {
1924            artifacts,
1925            suggestions,
1926            ..
1927        } => {
1928            // `corrected_artifacts` maps each medium-relative-looking id to
1929            // the workspace-relative id the slice actually presented — the
1930            // machine-readable half of the message's remedy.
1931            let corrected: serde_json::Map<String, serde_json::Value> = suggestions
1932                .iter()
1933                .map(|(supplied, corrected)| {
1934                    (
1935                        supplied.clone(),
1936                        serde_json::Value::String(corrected.clone()),
1937                    )
1938                })
1939                .collect();
1940            CliError::new(
1941                ExitKind::Validation,
1942                "PROJECTION_ADVANCE_UNKNOWN_ARTIFACT",
1943                message,
1944            )
1945            .with_details(json!({
1946                "binding": binding_id,
1947                "unknown_artifacts": artifacts,
1948                "corrected_artifacts": corrected,
1949            }))
1950        }
1951        AdvanceError::Store(_) | AdvanceError::Engine(_) => {
1952            CliError::new(ExitKind::Generic, "PROJECTION_ADVANCE_FAILED", message)
1953                .with_details(json!({ "binding": binding_id }))
1954        }
1955    }
1956}
1957
1958fn advance(ctx: &CliContext, args: AdvanceArgs) -> anyhow::Result<()> {
1959    let (_shape, root) = ctx.workspace_shape().ok_or_else(|| {
1960        workspace_not_initialised_error(
1961            "not inside a Memstead workspace (no `.memstead/workspace.toml` in any ancestor)",
1962        )
1963    })?;
1964
1965    let binding_id = args.binding;
1966
1967    // Parse the dispositions payload up front — a malformed `--dispositions`
1968    // refuses cheaply (before loading configs or an engine) with a typed code.
1969    let dispositions: std::collections::BTreeMap<String, DispositionInput> =
1970        serde_json::from_str(&args.dispositions).map_err(|e| {
1971            CliError::new(
1972                ExitKind::Validation,
1973                "PROJECTION_INVALID_DISPOSITIONS",
1974                format!(
1975                    "--dispositions must be a JSON object mapping artifact id → either a \
1976                     disposition string (e.g. \"worked\") or an object \
1977                     {{\"disposition\": \"excluded\", \"rationale\": \"...\"}}: {e}"
1978                ),
1979            )
1980            .with_details(json!({ "error": e.to_string() }))
1981        })?;
1982
1983    // Find the binding by canonical id in the v1 store.
1984    let configs = load_pipeline_configs(&root).map_err(|e| {
1985        CliError::new(
1986            ExitKind::Generic,
1987            "PROJECTION_ADVANCE_FAILED",
1988            format!("could not load pipeline config: {e}"),
1989        )
1990        .with_details(json!({ "error": e.to_string() }))
1991    })?;
1992    let record = configs
1993        .bindings
1994        .iter()
1995        .find(|r| format!("{}/{}", r.mem, r.name) == binding_id)
1996        .ok_or_else(|| binding_miss_error(&configs, &binding_id))?;
1997
1998    // D6/AC4: advance is the sync (maintenance-write) path — refuse when the
1999    // binding declares no `sync` operation, carrying the one-command remedy
2000    // `projection enable sync <binding>` (which, run verbatim, makes it
2001    // succeed) — except over a medium whose capability row refuses sync
2002    // outright, where the gap is named instead of a remedy that would bounce.
2003    if record.config.operations.sync.is_none() {
2004        return Err(absent_sync_error(&binding_id, &record.config).into());
2005    }
2006
2007    let resolved = resolve_binding_run(&binding_id, &record.config)
2008        .map_err(|e| map_resolve_err(&binding_id, e))?;
2009
2010    // The engine is mutable — a completing advance writes the `#synced`
2011    // baseline token through the sync-state writer.
2012    let mut cli_engine = ctx.cli_engine_at(&root)?;
2013    let engine = cli_engine.base_mut();
2014
2015    let outcome = advance_baseline(engine, &root, &resolved, &dispositions)
2016        .map_err(|e| map_advance_err(&binding_id, e))?;
2017
2018    if ctx.json {
2019        print_json(&json!({
2020            "binding": outcome.binding,
2021            "completed": outcome.completed,
2022            "disposed": outcome.disposed,
2023            "pending": outcome.pending,
2024            "remainder": outcome.remainder,
2025            "tokens_written": outcome.tokens_written,
2026            "warnings": outcome.warnings,
2027        }))?;
2028    } else {
2029        let mut out = format!(
2030            "# Projection advance\n\nBinding `{}`: {} artifact(s) disposed, {} remaining.\n",
2031            outcome.binding, outcome.disposed, outcome.pending
2032        );
2033        if outcome.completed {
2034            if outcome.disposed == 0 && outcome.pending == 0 {
2035                out.push_str(
2036                    "\nNo artifacts were presented this pass — the sync baseline advanced.\n",
2037                );
2038            } else {
2039                out.push_str(
2040                    "\nEvery presented artifact is disposed — the sync baseline advanced.\n",
2041                );
2042            }
2043            if !outcome.tokens_written.is_empty() {
2044                out.push_str("\nBaseline tokens written:\n");
2045                for key in &outcome.tokens_written {
2046                    out.push_str(&format!("- `{key}`\n"));
2047                }
2048            }
2049        } else {
2050            out.push_str(
2051                "\nRemainder still pending — re-run `projection advance` after judging the rest \
2052                 (a brief re-render shows what is left).\n",
2053            );
2054        }
2055        if !outcome.warnings.is_empty() {
2056            out.push_str("\n## Warnings\n\n");
2057            for w in &outcome.warnings {
2058                out.push_str(&format!("- {w}\n"));
2059            }
2060        }
2061        print_markdown(&out);
2062    }
2063    Ok(())
2064}
2065
2066/// Map an [`ExcludeError`] to a typed CLI error. The non-member refusal is the
2067/// S(D)-membership gate (Validation); a malformed id is a Validation-shaped name
2068/// error; store failures are generic. Codes are spelled as literals at each site
2069/// so the generated error index picks them up.
2070fn map_exclude_err(binding_id: &str, err: ExcludeError) -> CliError {
2071    let message = err.to_string();
2072    match &err {
2073        ExcludeError::MalformedId(_) => {
2074            CliError::new(ExitKind::Validation, "PROJECTION_INVALID_NAME", message)
2075                .with_details(json!({ "binding": binding_id }))
2076        }
2077        ExcludeError::NotSourceMember { artifacts, .. } => CliError::new(
2078            ExitKind::Validation,
2079            "PROJECTION_EXCLUDE_NOT_SOURCE_MEMBER",
2080            message,
2081        )
2082        .with_details(json!({ "binding": binding_id, "not_source_members": artifacts })),
2083        ExcludeError::PartialEnumeration { facet, reason } => CliError::new(
2084            ExitKind::Validation,
2085            "PROJECTION_EXCLUDE_PARTIAL_ENUMERATION",
2086            message,
2087        )
2088        .with_details(json!({ "binding": binding_id, "facet": facet, "reason": reason })),
2089        ExcludeError::Store(_) => {
2090            CliError::new(ExitKind::Generic, "PROJECTION_EXCLUDE_FAILED", message)
2091                .with_details(json!({ "binding": binding_id }))
2092        }
2093    }
2094}
2095
2096fn exclude(ctx: &CliContext, args: ExcludeArgs) -> anyhow::Result<()> {
2097    let (_shape, root) = ctx.workspace_shape().ok_or_else(|| {
2098        workspace_not_initialised_error(
2099            "not inside a Memstead workspace (no `.memstead/workspace.toml` in any ancestor)",
2100        )
2101    })?;
2102
2103    let binding_id = args.binding;
2104
2105    // Parse the exclusions payload up front — a malformed `--exclusions` refuses
2106    // cheaply (before loading configs) with a typed code.
2107    let exclusions: std::collections::BTreeMap<String, String> =
2108        serde_json::from_str(&args.exclusions).map_err(|e| {
2109            CliError::new(
2110                ExitKind::Validation,
2111                "PROJECTION_INVALID_EXCLUSIONS",
2112                format!(
2113                    "--exclusions must be a JSON object mapping in-scope artifact id → \
2114                     rationale string: {e}"
2115                ),
2116            )
2117            .with_details(json!({ "error": e.to_string() }))
2118        })?;
2119
2120    // Find the binding by canonical id in the v1 store.
2121    let configs = load_pipeline_configs(&root).map_err(|e| {
2122        CliError::new(
2123            ExitKind::Generic,
2124            "PROJECTION_EXCLUDE_FAILED",
2125            format!("could not load pipeline config: {e}"),
2126        )
2127        .with_details(json!({ "error": e.to_string() }))
2128    })?;
2129    let record = configs
2130        .bindings
2131        .iter()
2132        .find(|r| format!("{}/{}", r.mem, r.name) == binding_id)
2133        .ok_or_else(|| binding_miss_error(&configs, &binding_id))?;
2134
2135    let resolved = resolve_binding_run(&binding_id, &record.config)
2136        .map_err(|e| map_resolve_err(&binding_id, e))?;
2137
2138    // The S(D) membership gate now spans every enumerable medium, including
2139    // graph — whose artifact set lives in the store, not on disk. So the
2140    // exclude path needs an engine exactly as verify does.
2141    let mut cli_engine = ctx.cli_engine_at(&root)?;
2142    let engine = cli_engine.base_mut();
2143
2144    let outcome = record_exclusions(engine, &root, &resolved, &exclusions)
2145        .map_err(|e| map_exclude_err(&binding_id, e))?;
2146
2147    if ctx.json {
2148        print_json(&json!({
2149            "binding": outcome.binding,
2150            "excluded": outcome.excluded,
2151            "added": outcome.added,
2152        }))?;
2153    } else {
2154        print_markdown(&format!(
2155            "# Projection exclude\n\nBinding `{}`: {} artifact(s) newly excluded, \
2156             {} in the ledger.\n",
2157            outcome.binding, outcome.added, outcome.excluded
2158        ));
2159    }
2160    Ok(())
2161}
2162
2163/// Render a one-block human note for the full-enumeration scheduling decision
2164/// (D3), prepended to the verify report so the typed signal is never silent: a
2165/// scheduled full walk that fired, a not-yet-due countdown, disabled scheduling,
2166/// and — critically — any non-enumerable refusal. Empty for the quiet cases
2167/// keeps a rotating-sample run byte-clean.
2168fn render_full_resync_note(decision: &FullResyncDecision) -> String {
2169    match decision {
2170        FullResyncDecision::Disabled => String::new(),
2171        FullResyncDecision::NotDue { .. } => String::new(),
2172        // An explicit full measurement (`--full`): every facet walked in
2173        // full, scheduler bypassed, cap unlimited — stated up front so the
2174        // report below reads as computed, not sampled.
2175        FullResyncDecision::Forced { walked_facets } => {
2176            let facets = if walked_facets.is_empty() {
2177                "(no primary facets)".to_string()
2178            } else {
2179                walked_facets.join(", ")
2180            };
2181            format!(
2182                "> **Full measurement (`--full`)** — full-enumeration walk over: {facets}. \
2183                 Sampling scheduler bypassed; adjudication cap unlimited. Coverage and \
2184                 accuracy figures below are computed over the whole source, not sampled.\n\n"
2185            )
2186        }
2187        FullResyncDecision::Due {
2188            walked_facets,
2189            refused,
2190            ..
2191        } => {
2192            let mut s = String::from("> **Scheduled full resync (D3)** — ");
2193            if walked_facets.is_empty() {
2194                s.push_str("no enumerable facet to walk this run.");
2195            } else {
2196                s.push_str(&format!(
2197                    "full-enumeration coverage walk fired for: {}.",
2198                    walked_facets.join(", ")
2199                ));
2200            }
2201            for r in refused {
2202                s.push_str(&format!(
2203                    "\n> **Refused (cannot fully walk):** `{}` ({}) — {}",
2204                    r.facet, r.medium_type, r.reason
2205                ));
2206            }
2207            s.push_str("\n\n");
2208            s
2209        }
2210    }
2211}
2212
2213/// `projection verify <binding>` — measure fidelity and record durable findings
2214/// (group A). Read-only on the destination mem's *entities*; a completed run
2215/// makes three sanctioned bookkeeping writes — the findings store, the
2216/// prepared-hash backfill onto hash-less anchors, and the `#verified` baseline
2217/// through the engine's sync-state writer. An aborted or failed run makes none
2218/// of them; in particular it never advances the baseline token.
2219fn verify(ctx: &CliContext, args: VerifyArgs) -> anyhow::Result<()> {
2220    let (_shape, root) = ctx.workspace_shape().ok_or_else(|| {
2221        workspace_not_initialised_error(
2222            "not inside a Memstead workspace (no `.memstead/workspace.toml` in any ancestor)",
2223        )
2224    })?;
2225
2226    let binding_id = args.binding;
2227
2228    let configs = load_pipeline_configs(&root).map_err(|e| {
2229        CliError::new(
2230            ExitKind::Generic,
2231            "PROJECTION_VERIFY_FAILED",
2232            format!("could not load pipeline config: {e}"),
2233        )
2234        .with_details(json!({ "error": e.to_string() }))
2235    })?;
2236    let record = configs
2237        .bindings
2238        .iter()
2239        .find(|r| format!("{}/{}", r.mem, r.name) == binding_id)
2240        .ok_or_else(|| binding_miss_error(&configs, &binding_id))?;
2241
2242    let resolved = resolve_binding_run(&binding_id, &record.config)
2243        .map_err(|e| map_resolve_err(&binding_id, e))?;
2244
2245    // The measurement pass takes a shared engine borrow (A5 — structurally
2246    // incapable of a mem mutation); the mutable binding exists only for the
2247    // completed-run baseline write below.
2248    let mut cli_engine = ctx.cli_engine_at(&root)?;
2249    let engine = cli_engine.base_mut();
2250
2251    // A malformed anchors sidecar reads as "no anchors", which a fidelity
2252    // pass would faithfully report as every artifact uncovered — findings,
2253    // and under `--fail-on-findings` a red build blaming the mem for a file
2254    // the engine could not parse. Refuse instead: the measurement cannot be
2255    // trusted, so this is an operational failure with its own code, never a
2256    // findings exit. Same posture the binding store takes when its own
2257    // record fails to load.
2258    if let Some(err) = engine.anchors_sidecar_error(&resolved.destination_mem) {
2259        return Err(CliError::new(
2260            ExitKind::Validation,
2261            "ANCHORS_SIDECAR_UNREADABLE",
2262            format!(
2263                "verify refused for `{binding_id}`: the anchors sidecar for mem `{}` \
2264does not parse ({err}) — every anchor would read as absent and every artifact \
2265as uncovered, which is a measurement this run cannot honestly make. Repair or \
2266remove the sidecar and re-run",
2267                resolved.destination_mem
2268            ),
2269        )
2270        .with_details(json!({
2271            "binding": binding_id,
2272            "mem": resolved.destination_mem,
2273            // The same key every other surface uses for this condition.
2274            "reason": err,
2275        }))
2276        .into());
2277    }
2278
2279    let run = if args.full {
2280        verify_binding_full
2281    } else {
2282        verify_binding
2283    };
2284    let outcome = run(engine, &root, &record.config, &resolved).map_err(|e| match &e {
2285        // A vanished/unmounted source is a typed refusal, not a failed
2286        // measurement: nothing was observed, no findings were recorded,
2287        // and the `#verified` baseline is deliberately left untouched
2288        // (a transient unmount must never clobber real recorded state).
2289        FindingsError::SourceUnreachable { source_name, path } => CliError::new(
2290            ExitKind::Validation,
2291            "SOURCE_UNREACHABLE",
2292            format!(
2293                "verify refused for `{binding_id}`: source '{source_name}' resolves to \
2294                 `{path}`, which cannot be read (absent, or present but not \
2295                 enumerable) — restore or remount the source (or \
2296                 repoint its pointer); the recorded `#verified` baseline was left \
2297                 untouched"
2298            ),
2299        )
2300        .with_details(json!({
2301            "binding": binding_id,
2302            "source": source_name,
2303            "path": path,
2304        })),
2305        // `--full` over a non-enumerable medium: the existing typed
2306        // capability refusal — a full measurement promises complete
2307        // figures, so the run refuses instead of rendering a report
2308        // with fabricated completeness. Nothing was observed or
2309        // recorded.
2310        FindingsError::FullWalkNonEnumerable(refusal) => CliError::new(
2311            ExitKind::Validation,
2312            "PROJECTION_CAPABILITY_UNSUPPORTED",
2313            format!("verify --full refused for `{binding_id}`: {e}"),
2314        )
2315        .with_details(json!({
2316            "binding": binding_id,
2317            "facet": refusal.facet,
2318            "medium_type": refusal.medium_type,
2319            "reason": refusal.reason,
2320        })),
2321        _ => CliError::new(
2322            ExitKind::Generic,
2323            "PROJECTION_VERIFY_FAILED",
2324            format!("verify failed for `{binding_id}`: {e}"),
2325        )
2326        .with_details(json!({ "binding": binding_id, "error": e.to_string() })),
2327    })?;
2328
2329    // The run completed — record its prepared-hash backfill: every hash the
2330    // pass observed for a hash-less hash-bearing anchor lands on that anchor
2331    // in the engine-owned anchors sidecar (measurement bookkeeping — no
2332    // entity content is touched). Before the report, so the rendered
2333    // anchor-resolution figures reflect the recorded hashes. Idempotent: a
2334    // pass over fully-backfilled anchors observes an empty worklist.
2335    // Bookkeeping-write failures are reported AFTER the report, never
2336    // instead of it. Both of these say "verify completed and findings were
2337    // recorded" — a run that completed owes the caller its measurement, and
2338    // returning here would hand a CI job a red build with nothing to read.
2339    // Same render-then-fail ordering the findings gate uses, extended to the
2340    // paths that can fail between the measurement and the render.
2341    let backfill_result = record_anchor_hash_backfill(
2342        engine,
2343        &resolved.destination_mem,
2344        &outcome,
2345        Some("projection verify: prepared-hash backfill onto hash-less anchors"),
2346    );
2347    let hashes_backfilled = *backfill_result.as_ref().unwrap_or(&0);
2348
2349    // Assemble + render the tier-1 fidelity report (group B) over the findings
2350    // the pass just recorded. Read-only — no destination-mem mutation.
2351    let budget = args.budget.unwrap_or(DEFAULT_REPORT_BUDGET);
2352    let report = compute_fidelity_report(engine, &root, &record.config, &resolved, &outcome.key);
2353    let rendered = render_fidelity_report(&report, budget, &args.include);
2354
2355    // The run completed — record its `#verified` baseline per observed facet
2356    // head through the engine's sync-state writer (the backlog-prescribed
2357    // writer; a failed run returned above and never reaches this).
2358    let baseline_result = record_verified_baseline(
2359        engine,
2360        &resolved.destination_mem,
2361        &outcome,
2362        Some("projection verify: completed-run #verified baseline"),
2363    );
2364    let verified_baseline = baseline_result.as_ref().cloned().unwrap_or_default();
2365
2366    // The rollup is derived from the assembled report, so the headline a
2367    // human reads, the `rollup` block a consumer parses, and the gate exit
2368    // code below are all the same judgement — they cannot disagree.
2369    let rollup = report.rollup();
2370
2371    if ctx.json {
2372        print_json(&json!({
2373            // Version marker in the house style (`memstead-export/v1`,
2374            // `workspace-dump/v1`): consumers assert it before parsing, so a
2375            // future shape change fails loudly instead of misparsing. This
2376            // payload is external contract — see the verify-in-CI guide.
2377            "format": JSON_VERIFY_FORMAT,
2378            // The coverage rule (memstead_base::ops::coverage): the
2379            // axes the rollup verdict answers for; the per-run
2380            // blind-spots inside `rollup` are the refinement.
2381            "verdict_coverage": crate::coverage::PROJECTION_VERIFY
2382                .axis_coverage()
2383                .expect("projection verify is a verdict surface")
2384                .wire_line(),
2385            "rollup": rollup,
2386            "binding": outcome.binding,
2387            "key": {
2388                "binding_hash": outcome.key.binding_hash,
2389                "source_head": outcome.key.source_head,
2390            },
2391            "recorded": outcome.recorded,
2392            "superseded": outcome.superseded,
2393            "backlog": outcome.backlog,
2394            // The tier-3 full-enumeration scheduling decision (D3) — surfaced
2395            // (never a silent skip): whether a scheduled full walk fired, is not
2396            // yet due, is disabled, and any typed non-enumerable refusals.
2397            "full_resync": outcome.full_resync,
2398            // The completed run's `#verified` baseline keys, written through
2399            // the engine's sync-state writer.
2400            "verified_baseline": verified_baseline,
2401            // How many hash-less hash-bearing anchors gained a recorded
2402            // prepared-content hash this run (the completed-run backfill
2403            // write into the engine-owned anchors sidecar). 0 once every
2404            // anchor carries its hash — the backfill is idempotent.
2405            "hash_backfilled": hashes_backfilled,
2406            "report": report,
2407            "report_mode": rendered.mode,
2408            "report_markdown": rendered.markdown,
2409        }))?;
2410    } else {
2411        // The rendered report IS the stdout content (agent-consumable brief);
2412        // prepend the scheduled full-walk decision so D3's typed signal (a full
2413        // sweep, or a non-enumerable refusal) is never silent in human mode,
2414        // and append the recorded `#verified` baseline so the completed-run
2415        // write is visible.
2416        let baseline_note = if verified_baseline.is_empty() {
2417            String::new()
2418        } else {
2419            format!(
2420                "\n> **Verified baseline recorded** — {}\n",
2421                verified_baseline
2422                    .iter()
2423                    .map(|k| format!("`{k}`"))
2424                    .collect::<Vec<_>>()
2425                    .join(", ")
2426            )
2427        };
2428        let backfill_note = if hashes_backfilled == 0 {
2429            String::new()
2430        } else {
2431            format!(
2432                "\n> **Prepared-hash backfill recorded** — {hashes_backfilled} hash-less \
2433                 anchor(s) now carry their observed prepared-content hash; subsequent \
2434                 verifies adjudicate them deterministically.\n"
2435            )
2436        };
2437        // The coverage rule: the axes the rollup verdict answers for,
2438        // in the human rendering too (the JSON envelope stamps above;
2439        // memstead_base::ops::coverage).
2440        let coverage_note = crate::coverage::PROJECTION_VERIFY
2441            .axis_coverage()
2442            .map(|cov| format!("\n**Verdict coverage:** {}\n", cov.wire_line()))
2443            .unwrap_or_default();
2444        print_markdown(&format!(
2445            "{}{}{}{}{}",
2446            render_full_resync_note(&outcome.full_resync),
2447            rendered.markdown,
2448            coverage_note,
2449            backfill_note,
2450            baseline_note
2451        ));
2452    }
2453
2454    // --- Bookkeeping-write failures, now that the report has been rendered ---
2455    // A failed write is an operational failure with its own code, never the
2456    // findings exit: the measurement's own answer is already on stdout above,
2457    // and this says the run could not finish recording it.
2458    if let Err(e) = backfill_result {
2459        return Err(CliError::new(
2460            ExitKind::Generic,
2461            "PROJECTION_VERIFY_BACKFILL_FAILED",
2462            format!(
2463                "verify completed and findings were recorded for `{binding_id}` (the report is \
2464above), but recording the prepared-hash backfill onto the anchors sidecar failed: {e}"
2465            ),
2466        )
2467        .with_details(json!({ "binding": binding_id, "error": e.to_string() }))
2468        .into());
2469    }
2470    if let Err(e) = baseline_result {
2471        return Err(CliError::new(
2472            ExitKind::Generic,
2473            "PROJECTION_VERIFY_BASELINE_FAILED",
2474            format!(
2475                "verify completed and findings were recorded for `{binding_id}` (the report is \
2476above), but writing the `#verified` baseline failed: {e} — the next run will treat this \
2477binding as never verified"
2478            ),
2479        )
2480        .with_details(json!({ "binding": binding_id, "error": e.to_string() }))
2481        .into());
2482    }
2483
2484    // --- CI gate (opt-in) ---
2485    // Report first, then fail: `main` prints the error envelope and nothing
2486    // else, so a findings exit that returned before this point would hand a
2487    // CI job a red build with no report to read. Same ordering `health
2488    // --strict` uses, with the ambiguity that one has removed — this code is
2489    // dedicated, so a job can tell "the mem drifted" from "the engine failed
2490    // to boot".
2491    if args.fail_on_findings && rollup.findings_total > 0 {
2492        return Err(CliError::new(
2493            ExitKind::Findings,
2494            "PROJECTION_VERIFY_FINDINGS",
2495            format!(
2496                "verify completed for `{binding_id}` and recorded {} finding(s) — {}",
2497                rollup.findings_total, rollup.because
2498            ),
2499        )
2500        .with_details(json!({
2501            "binding": binding_id,
2502            "verdict": rollup.verdict.wire(),
2503            "findings_total": rollup.findings_total,
2504            "findings_by_class": report.findings_by_class,
2505            "actions": rollup.actions,
2506        }))
2507        .into());
2508    }
2509    // --- Inconclusive gate (opt-in) ---
2510    // Same report-first ordering; evaluated AFTER the findings gate, so
2511    // a findings run with both flags exits with the findings code (a
2512    // substantive result outranks a blindness report). Without this
2513    // flag an inconclusive run keeps its long-standing exit 0 — the
2514    // gate exists because that 0 is indistinguishable from a
2515    // substantive clean pass to a code-only consumer.
2516    if args.fail_on_inconclusive
2517        && rollup.verdict == memstead_base::ingest::report::RollupVerdict::Inconclusive
2518    {
2519        return Err(CliError::new(
2520            ExitKind::Findings,
2521            "PROJECTION_VERIFY_INCONCLUSIVE",
2522            format!(
2523                "verify completed for `{binding_id}` but the measurement was blind — verdict \
2524                 inconclusive: {}",
2525                rollup.because
2526            ),
2527        )
2528        .with_details(json!({
2529            "binding": binding_id,
2530            "verdict": rollup.verdict.wire(),
2531            "blind_spots": rollup.blind_spots,
2532            "actions": rollup.actions,
2533        }))
2534        .into());
2535    }
2536    Ok(())
2537}