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