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    BINDING_VERSION, Binding, BuildMode, BuildOperation, CapabilityError, DEFAULT_ADJUDICATION_CAP,
30    DEFAULT_FULL_RESYNC_EVERY, Operations, PruneConfig, SyncOperation, VerifyOperation,
31    prune_guarantee_for_medium, validate_binding,
32};
33use memstead_base::binding_migrate::{
34    BindingMigrateError, check_all_consumed, fold_v1_binding, migrate_gen2_bindings,
35};
36use memstead_base::ingest::advance::{
37    AdvanceError, DispositionInput, ExcludeError, advance_baseline, record_exclusions,
38};
39use memstead_base::ingest::findings::{
40    FindingsError, FullResyncDecision, record_anchor_hash_backfill, record_verified_baseline,
41    verify_binding, verify_binding_full,
42};
43use memstead_base::ingest::report::{
44    DEFAULT_REPORT_BUDGET, compute_fidelity_report, render_fidelity_report,
45};
46use memstead_base::ingest::resolve::{ResolveError, ResolvedSource, resolve_binding_run};
47use memstead_base::ingest::{
48    OperationFilter, OperationKind, RenderBriefError, render_ingest_brief, render_sync_brief_for,
49    render_verify_brief_for, select_next_due_operation,
50};
51use memstead_base::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode, Source};
52use memstead_base::pipeline_store::{
53    ProjectionGeneration, delete_ingest, load_legacy_pipeline_configs, load_pipeline_configs,
54    load_projection_generations, read_binding, remove_mediums_and_facets_trees, write_binding,
55};
56use memstead_base::workspace_store::StoreError;
57use memstead_base::{migrate_legacy_pipeline, read_legacy_pipeline_configs};
58
59use crate::CliError;
60use crate::output::{ExitKind, print_json, print_markdown};
61use crate::setup::{CliContext, workspace_not_initialised_error};
62
63#[derive(ClapArgs, Debug)]
64pub struct Args {
65    #[command(subcommand)]
66    pub command: ProjectionCommand,
67}
68
69#[derive(Subcommand, Debug)]
70pub enum ProjectionCommand {
71    /// Render a binding's run-brief — the Markdown prompt an agent consumes —
72    /// on stdout. Takes the canonical binding id `<mem>/<stem>` (D3), e.g.
73    /// `engine/graph`. Omit the id (or pass `--all`) to select the next due
74    /// (binding, operation) pair by round-robin + backoff and render that
75    /// operation's brief; `--operation` picks which operations rotate (default
76    /// `build` — the classic build-only rotation; `any` rotates every
77    /// loop-declared build / sync / verify pair). An operation participates
78    /// only where its binding block declares `trigger: loop`. Reads the v2
79    /// binding store and the destination mem's schema / writing guidance; the
80    /// assembly is shared with the UniFFI surface, so CLI and app briefs are
81    /// byte-identical by construction.
82    ///
83    /// `--verify` renders the **verify brief** (group C) for the named binding:
84    /// measurement + capped-adjudication instructions only, with no
85    /// destination-mutation instruction. `--sync` renders the **sync brief** —
86    /// the sole maintenance-writer prompt, carrying both the cursor slice and the
87    /// open verify findings in one brief with the absorbed reconcile
88    /// conservatism. Both are read-only on the mem; the sync brief's repairs
89    /// reach the mem only when an agent acts on it through the MCP mutation
90    /// surface.
91    Brief(BriefArgs),
92    /// Scaffold a fresh v2 binding non-interactively: ONE record with one
93    /// inline source, at `.memstead/projections/<mem>/<stem>.json`.
94    /// All inputs are flags — no prompts ever (parity across callers). The
95    /// default binding declares build+sync+verify where the medium permits:
96    /// a `web` source scaffolds build-only, with the deferral named in
97    /// `warnings[]`. A `prune` block is scaffolded wherever sync survived,
98    /// with the strongest guarantee the medium supports (never-clobber for a
99    /// git-backed source). Refuses `PROJECTION_EXISTS` (without touching disk)
100    /// when a binding of the same id already exists — never overwrites.
101    Init(InitArgs),
102    /// Migrate every prior on-disk generation into v2 single-record
103    /// bindings, in place. Gen-1 — the root-folder
104    /// `scopes|projections|ingests/` JSON layout — is first materialized
105    /// into the four-primitive store, then folded. Gen-2 — the
106    /// four-primitive store (per-mem `Projection` + flat `Ingest`) — merges
107    /// each ingest into its projection and folds the referenced facets +
108    /// mediums inline. v1 — the three-file store — folds each binding's
109    /// facet references inline the same way, source names preserved
110    /// byte-verbatim (they key sync watermarks). The emptied `mediums/` and
111    /// `facets/` trees are removed; orphan records refuse rather than drop.
112    /// `refinement` mode and dangling refs refuse with a typed error.
113    /// Idempotent on a migrated store. Use `--dry-run` to preview without
114    /// writing.
115    Migrate(MigrateArgs),
116    /// Enable a `build` / `sync` / `verify` operation on an existing binding by
117    /// adding its block (with sensible defaults) if absent. This is the remedy
118    /// a refused *mutating* operation cites (D6): `projection enable sync
119    /// <binding>`. Before writing, the operation is checked against the
120    /// medium-capability matrix (D6) — enabling `sync`/`verify` over a medium
121    /// that cannot support it (e.g. a `web` source) refuses with the capability
122    /// gap and writes nothing. Enabling an already-present operation refuses
123    /// `PROJECTION_OP_ALREADY_ENABLED`; a missing binding refuses
124    /// `PROJECTION_NOT_FOUND`.
125    Enable(EnableArgs),
126    /// Advance a binding's sync baseline by recording per-artifact
127    /// dispositions (D7). The engine freezes the presented changed slice,
128    /// subtracts already-disposed artifacts on re-presentation, appends
129    /// new-HEAD deltas when the source moves mid-pass, and — when the
130    /// remainder empties — advances the destination mem's `#synced` token via
131    /// the sync-state writer (provenance piggybacks that commit). Dispositions
132    /// are durable (`.memstead/state/advance/`), so a partial pass resumes
133    /// across process restarts. The gate accepts **only** artifact ids the
134    /// engine presented — an unknown id refuses the whole call atomically
135    /// (`PROJECTION_ADVANCE_UNKNOWN_ARTIFACT`). In this cycle the agent supplies
136    /// a disposition for **every** artifact explicitly (auto-derivation lands
137    /// later).
138    Advance(AdvanceArgs),
139    /// Declare authored **exclusions** for in-scope source artifacts. Unlike
140    /// `advance` (whose gate accepts only artifacts in the changed slice), this
141    /// gates on enumerable `S(D)` membership, so a stable, unchanged artifact can
142    /// be recorded as deliberately not-modeled with a rationale. Each accepted
143    /// `(artifact, rationale)` lands in the durable exclusion ledger the fidelity
144    /// report consults, so the artifact stops re-surfacing as `uncovered` under
145    /// exhaustive coverage and keeps its reasoning. An artifact outside `S(D)`
146    /// refuses the whole call atomically (`PROJECTION_EXCLUDE_NOT_SOURCE_MEMBER`);
147    /// re-declaring merges into the ledger. The write path for the option-(a)
148    /// process-mem judgment migration, and the general "this in-scope artifact is
149    /// mined and warrants no destination entity, because …" capability.
150    Exclude(ExcludeArgs),
151    /// Measure a binding's fidelity and record durable findings (E3b, group A).
152    /// Read-only on the destination mem: verify adjudicates the mem's anchors
153    /// against the live source and samples in-scope artifacts, writing findings
154    /// keyed `(hash(D), source_head)` into the engine-owned findings store
155    /// (`.memstead/state/findings/`). A binding-declaration edit or a source-head
156    /// move partitions the keyspace, so prior findings are segregated as
157    /// superseded, never presented as current. Verify never mutates the mem —
158    /// any repair routes through the (later) sync brief. It then renders the
159    /// deterministic, token-budgeted **tier-1 fidelity report** (group B) over
160    /// the findings just recorded: grain-classed coverage with tree-anchor
161    /// fan-out on its own axis, anchor-resolution %, freshness vs. both
162    /// `sync_state` tokens (`signal: none` → freshness unknowable), the
163    /// capability-matrix block, and the tier-3 backlog depth — aggregates always
164    /// ship; heavy per-artifact lists greedy-fill under `--budget` and drop to
165    /// hints (forced back in with `--include`).
166    Verify(VerifyArgs),
167}
168
169/// The medium type flag for `projection init` — the CLI-facing mirror of
170/// [`MediumType`] (which carries serde, not clap, derives). Decides the
171/// capability matrix (D6) that filters the default binding's operations.
172#[derive(Clone, Copy, Debug, ValueEnum)]
173pub enum MediumTypeArg {
174    /// A source tree of code.
175    Codebase,
176    /// A directory of files (non-code).
177    Filesystem,
178    /// A git history.
179    Git,
180    /// Another mem's graph.
181    Graph,
182    /// Web sources (build-only this cycle — no change signal).
183    Web,
184}
185
186impl MediumTypeArg {
187    fn to_medium_type(self) -> MediumType {
188        match self {
189            MediumTypeArg::Codebase => MediumType::Codebase,
190            MediumTypeArg::Filesystem => MediumType::Filesystem,
191            MediumTypeArg::Git => MediumType::Git,
192            MediumTypeArg::Graph => MediumType::Graph,
193            MediumTypeArg::Web => MediumType::Web,
194        }
195    }
196}
197
198#[derive(ClapArgs, Debug)]
199pub struct BriefArgs {
200    /// The canonical binding id `<mem>/<stem>` (D3) — e.g. `engine/graph`.
201    /// Omit (or pass `--all`) to select the next due binding by round-robin +
202    /// backoff. Required with `--verify` / `--sync` (those operate on one
203    /// binding's live findings/cursor, never a rotation).
204    pub binding: Option<String>,
205    /// Select the next due (binding, operation) pair across all bindings
206    /// (round-robin + backoff) and render its brief, instead of naming one.
207    /// Which operations rotate is decided by `--operation` (default: build
208    /// only). Ignored with `--verify` / `--sync`.
209    #[arg(long)]
210    pub all: bool,
211    /// Which operations the `--all` rotation considers. An operation
212    /// participates only where the binding declares its block with
213    /// `trigger: loop` — consent lives in the declaration. `build` (the
214    /// default) keeps the classic build-only rotation; `any` rotates across
215    /// every loop-declared build / sync / verify pair and renders the matching
216    /// brief (the `--json` output names the picked operation).
217    #[arg(long, value_enum, default_value_t = BriefOperationArg::Build, requires = "all", conflicts_with_all = ["verify", "sync"])]
218    pub operation: BriefOperationArg,
219    /// Render the **verify brief** (group C) for the named binding instead of
220    /// the build brief: measurement + capped-adjudication instructions only.
221    /// It carries no destination-mutation instruction — repairs route through
222    /// the sync brief. Read-only on the mem. Mutually exclusive with `--sync`.
223    #[arg(long, conflicts_with = "sync")]
224    pub verify: bool,
225    /// Render the **sync brief** (group C) for the named binding instead of the
226    /// build brief: the sole maintenance-writer prompt, carrying both the cursor
227    /// slice and the open verify findings in one brief, with the absorbed
228    /// reconcile conservatism. Read-only on the mem (the agent's writes route
229    /// through MCP). Mutually exclusive with `--verify`.
230    #[arg(long, conflicts_with = "verify")]
231    pub sync: bool,
232}
233
234/// The `--operation` value for `projection brief --all` — which operations the
235/// rotation considers. CLI-facing mirror of the engine's [`OperationFilter`]
236/// (which carries no clap derives).
237#[derive(Clone, Copy, Debug, ValueEnum, PartialEq, Eq)]
238pub enum BriefOperationArg {
239    /// Rotate over build pairs only (the default — the classic rotation).
240    Build,
241    /// Rotate over sync pairs only.
242    Sync,
243    /// Rotate over verify pairs only.
244    Verify,
245    /// Rotate over every loop-declared build / sync / verify pair.
246    Any,
247}
248
249impl BriefOperationArg {
250    fn to_filter(self) -> OperationFilter {
251        match self {
252            BriefOperationArg::Build => OperationFilter::Only(OperationKind::Build),
253            BriefOperationArg::Sync => OperationFilter::Only(OperationKind::Sync),
254            BriefOperationArg::Verify => OperationFilter::Only(OperationKind::Verify),
255            BriefOperationArg::Any => OperationFilter::Any,
256        }
257    }
258}
259
260#[derive(ClapArgs, Debug)]
261pub struct InitArgs {
262    /// Destination mem the binding writes into — the `<mem>` half of the
263    /// binding id `<mem>/<stem>` and the per-mem tier the three files live under.
264    #[arg(long)]
265    pub mem: String,
266    /// The medium pointer — a path (codebase / filesystem / git) or a mem id /
267    /// URL (graph / web). Becomes the scaffolded medium's `pointer`.
268    #[arg(long)]
269    pub source: String,
270    /// The medium type — decides the capability matrix (D6) that filters which
271    /// operations the default binding declares.
272    #[arg(long = "medium-type", value_enum)]
273    pub medium_type: MediumTypeArg,
274    /// Intent prose for the agent (the binding's `intent`). Optional.
275    #[arg(long)]
276    pub intent: Option<String>,
277    /// Binding stem — the `<stem>` half of the binding id and the shared file
278    /// name of the scaffolded medium / facet / binding. Defaults to the final
279    /// path component of `--source`.
280    #[arg(long)]
281    pub name: Option<String>,
282}
283
284#[derive(ClapArgs, Debug)]
285pub struct MigrateArgs {
286    /// Preview the produced bindings (and any warnings) without writing them
287    /// to disk or removing the merged ingest files.
288    #[arg(long)]
289    pub dry_run: bool,
290}
291
292/// The operation `projection enable` adds to a binding. Mirror of the binding's
293/// operations block: `build` is always present (required), so enabling it
294/// always refuses as already-enabled; `sync` / `verify` are the enableable
295/// blocks.
296#[derive(Clone, Copy, Debug, ValueEnum, PartialEq, Eq)]
297pub enum EnableOperationArg {
298    /// The build operation (always present — enabling refuses as already-enabled).
299    Build,
300    /// The sync (maintenance-write) operation.
301    Sync,
302    /// The verify (measurement) operation.
303    Verify,
304}
305
306impl EnableOperationArg {
307    fn name(self) -> &'static str {
308        match self {
309            EnableOperationArg::Build => "build",
310            EnableOperationArg::Sync => "sync",
311            EnableOperationArg::Verify => "verify",
312        }
313    }
314}
315
316#[derive(ClapArgs, Debug)]
317pub struct EnableArgs {
318    /// The operation to enable: `build` | `sync` | `verify`.
319    #[arg(value_enum)]
320    pub operation: EnableOperationArg,
321    /// The binding id `<mem>/<stem>` (D3) — e.g. `engine/graph`.
322    pub binding: String,
323}
324
325#[derive(ClapArgs, Debug)]
326pub struct AdvanceArgs {
327    /// The binding id `<mem>/<stem>` (D3) — e.g. `engine/graph`.
328    pub binding: String,
329    /// A JSON object mapping each judged artifact id to its disposition, e.g.
330    /// `'{"src/lib.rs": "worked", "src/old.rs": "irrelevant"}'`. A value may
331    /// instead be an object carrying an authored rationale —
332    /// `'{"src/gen.rs": {"disposition": "excluded", "rationale": "generated, no entity"}}'`
333    /// — and an `excluded` verdict with a rationale is retained in the durable
334    /// exclusion ledger so the artifact stops re-surfacing as `uncovered` and
335    /// keeps its reasoning. Only ids the engine presented in the brief's changed
336    /// slice are accepted — an unknown id refuses the whole call. Pass `'{}'` to
337    /// re-present the remainder without recording anything.
338    #[arg(long)]
339    pub dispositions: String,
340}
341
342#[derive(ClapArgs, Debug)]
343pub struct ExcludeArgs {
344    /// The binding id `<mem>/<stem>` (D3) — e.g. `project/graph`.
345    pub binding: String,
346    /// A JSON object mapping each in-scope source artifact id to the authored
347    /// rationale for excluding it, e.g.
348    /// `'{"docs/legacy.md": "superseded; no entity", "vendor/x.rs": "generated"}'`.
349    /// Every id must be a member of the binding's enumerable source `S(D)` — an
350    /// id outside scope refuses the whole call.
351    #[arg(long)]
352    pub exclusions: String,
353}
354
355#[derive(ClapArgs, Debug)]
356pub struct VerifyArgs {
357    /// The binding id `<mem>/<stem>` (D3) — e.g. `engine/graph`.
358    pub binding: String,
359    /// Token budget for the tier-1 fidelity report's **heavy** content
360    /// (per-artifact lists). Aggregated counts always ship in addition; heavy
361    /// lists greedy-fill and drop to `## Hints` when they do not fit. Defaults
362    /// to the house envelope budget.
363    #[arg(long)]
364    pub budget: Option<usize>,
365    /// Force a heavy report section in past the budget (repeatable):
366    /// `uncovered_artifacts` | `tree_fanout` | `superseded_findings`.
367    #[arg(long = "include")]
368    pub include: Vec<String>,
369    /// Full measurement: walk the entire enumerable source `S(D)` (the
370    /// rotating sample scheduler is bypassed), treat the per-run adjudication
371    /// cap as unlimited, and perform the prepared-hash backfill — the
372    /// report's coverage and accuracy figures are computed over everything,
373    /// with no sampling or truncation caveat. Refuses (typed) when a facet's
374    /// medium is non-enumerable rather than render a fabricated-complete
375    /// report. Without this flag the capped/sampled loop economics are
376    /// unchanged.
377    #[arg(long)]
378    pub full: bool,
379}
380
381pub fn run(ctx: &CliContext, args: Args) -> anyhow::Result<()> {
382    match args.command {
383        ProjectionCommand::Brief(a) => brief(ctx, a),
384        ProjectionCommand::Init(a) => init(ctx, a),
385        ProjectionCommand::Migrate(a) => migrate(ctx, a),
386        ProjectionCommand::Enable(a) => enable(ctx, a),
387        ProjectionCommand::Advance(a) => advance(ctx, a),
388        ProjectionCommand::Exclude(a) => exclude(ctx, a),
389        ProjectionCommand::Verify(a) => verify(ctx, a),
390    }
391}
392
393/// Map a [`RenderBriefError`] to a typed CLI error (D12). Not-found bindings /
394/// facets / mediums exit `NotFound`; a malformed id is a `Validation` name
395/// error; config-load and mode-unsupported failures are generic. Codes are
396/// spelled as literals at each construction site so the generated error index
397/// (xtask) picks them up.
398fn map_brief_err(binding_id: &str, err: RenderBriefError) -> CliError {
399    let message = err.to_string();
400    let mapped = match &err {
401        RenderBriefError::ConfigLoad(_) => {
402            CliError::new(ExitKind::Generic, "PROJECTION_LOAD_FAILED", message)
403        }
404        // D6/AC4: the binding declares no build op — refuse with the
405        // `projection enable build` remedy the error message already carries.
406        RenderBriefError::BuildOperationAbsent { .. } => CliError::new(
407            ExitKind::Validation,
408            "PROJECTION_BUILD_NOT_ENABLED",
409            message,
410        ),
411        // A malformed findings store while rendering a verify / sync brief.
412        RenderBriefError::FindingsRead { .. } => CliError::new(
413            ExitKind::Generic,
414            "PROJECTION_FINDINGS_READ_FAILED",
415            message,
416        ),
417        RenderBriefError::Resolve(inner) => match inner {
418            ResolveError::BindingNotFound { .. } => {
419                CliError::new(ExitKind::NotFound, "PROJECTION_NOT_FOUND", message)
420            }
421            ResolveError::MalformedProjectionRef { .. } => {
422                CliError::new(ExitKind::Validation, "PROJECTION_INVALID_NAME", message)
423            }
424        },
425    };
426    mapped.with_details(json!({ "binding": binding_id }))
427}
428
429fn brief(ctx: &CliContext, args: BriefArgs) -> anyhow::Result<()> {
430    let (_shape, root) = ctx.workspace_shape().ok_or_else(|| {
431        workspace_not_initialised_error(
432            "not inside a Memstead workspace (no `.memstead/workspace.toml` in any ancestor)",
433        )
434    })?;
435
436    let cli_engine = ctx.cli_engine_at(&root)?;
437    let engine = cli_engine.base();
438
439    // Quarantine consult first: a binding whose stored file failed
440    // the load refuses typed with its reason (naming `projection
441    // migrate` for the legacy generations) instead of reporting
442    // not-found (agent-trust plan 04).
443    if let Some(binding_id) = args.binding.as_deref()
444        && let Ok(configs) = load_pipeline_configs(&root)
445        && configs
446            .quarantined
447            .iter()
448            .any(|q| format!("{}/{}", q.mem, q.name) == binding_id)
449    {
450        return Err(binding_miss_error(&configs, binding_id).into());
451    }
452
453    // Group-C briefs: verify / sync render for one named binding (no rotation).
454    // Both are read-only on the destination mem — the sync brief's repairs reach
455    // the mem only when an agent acts on it through the MCP mutation surface.
456    if args.verify || args.sync {
457        let binding_id = args.binding.ok_or_else(|| {
458            CliError::new(
459                ExitKind::Validation,
460                "PROJECTION_BRIEF_BINDING_REQUIRED",
461                format!(
462                    "`projection brief --{}` needs a binding id `<mem>/<stem>` — it renders one \
463                     binding's brief, not an `--all` rotation",
464                    if args.verify { "verify" } else { "sync" }
465                ),
466            )
467        })?;
468        let (rendered, operation) = if args.verify {
469            (
470                render_verify_brief_for(engine, &root, &binding_id),
471                OperationKind::Verify,
472            )
473        } else {
474            (
475                render_sync_brief_for(engine, &root, &binding_id),
476                OperationKind::Sync,
477            )
478        };
479        let rendered = rendered.map_err(|e| map_brief_err(&binding_id, e))?;
480
481        if ctx.json {
482            print_json(&json!({ "brief": rendered, "operation": operation.as_wire() }))?;
483        } else {
484            print!("{rendered}");
485        }
486        return Ok(());
487    }
488
489    // Resolve which (binding, operation) pair to render: a named binding
490    // (canonical `<mem>/<stem>`, build), or the next due pair in a round-robin
491    // `--all` rotation (which advances the cursor + backoff state). The
492    // rotation's operation set is `--operation` (default: build only — the
493    // classic rotation, byte-stable for existing callers).
494    let selected = match args.binding {
495        Some(binding) if !args.all => Some((binding, OperationKind::Build)),
496        _ => {
497            let configs = load_pipeline_configs(&root).map_err(|e| {
498                CliError::new(
499                    ExitKind::Generic,
500                    "PROJECTION_LOAD_FAILED",
501                    format!("could not load binding store: {e}"),
502                )
503                .with_details(json!({ "error": e.to_string() }))
504            })?;
505            // Distinguish "nothing is configured" from "everything is backing
506            // off". Both otherwise collapse into the same `None` from
507            // `select_next_due_operation`, but the two outcomes want different
508            // caller responses: an empty store is a setup prompt, a
509            // backing-off pass is a no-op retry. Emit the empty-store signal
510            // explicitly so a caller (the plugin router, a status display) can
511            // branch on it.
512            if configs.bindings.is_empty() {
513                if ctx.json {
514                    print_json(&json!({ "no_bindings": true }))?;
515                } else {
516                    println!("> **[projection] No bindings configured in this workspace yet.**");
517                }
518                return Ok(());
519            }
520            select_next_due_operation(engine, &root, &configs, args.operation.to_filter())
521        }
522    };
523
524    let Some((binding_id, operation)) = selected else {
525        // Every eligible pair is backing off (or not due) this pass — a valid
526        // outcome, the loop's quiet yield.
527        if ctx.json {
528            print_json(&json!({ "skipped": true }))?;
529        } else {
530            println!(
531                "> **[projection] Skipped — every eligible binding is backing off this pass.**"
532            );
533        }
534        return Ok(());
535    };
536
537    // Dispatch to the selected operation's renderer: the rotation hands back
538    // build / sync / verify pairs, each with its own brief.
539    let rendered = match operation {
540        OperationKind::Build => render_ingest_brief(engine, &root, &binding_id),
541        OperationKind::Sync => render_sync_brief_for(engine, &root, &binding_id),
542        OperationKind::Verify => render_verify_brief_for(engine, &root, &binding_id),
543    }
544    .map_err(|e| map_brief_err(&binding_id, e))?;
545
546    if ctx.json {
547        print_json(&json!({ "brief": rendered, "operation": operation.as_wire() }))?;
548    } else {
549        // The brief *is* the stdout content (the skill pipes it as the agent
550        // prompt) — write it verbatim, no added trailing newline.
551        print!("{rendered}");
552    }
553    Ok(())
554}
555
556/// Is `value` a single, plain path component — safe to use verbatim as a `<mem>`
557/// or `<stem>` dir/file segment and as half of the binding id? Mirrors
558/// `pipeline_store`'s internal component guard so `init` refuses with a clear
559/// typed code up front rather than surfacing a store IO error mid-scaffold.
560fn is_single_component(value: &str) -> bool {
561    !value.is_empty()
562        && value != "."
563        && value != ".."
564        && !value.contains('/')
565        && !value.contains('\\')
566        && !value.contains(':')
567        && !value.contains('\0')
568}
569
570/// Derive a binding stem from a `--source` pointer: its final path component
571/// (trailing slashes trimmed). `../public` → `public`; `home` → `home`;
572/// `https://example.com/manual` → `manual`.
573fn derive_stem(source: &str) -> String {
574    source
575        .trim_end_matches('/')
576        .rsplit('/')
577        .next()
578        .unwrap_or(source)
579        .to_string()
580}
581
582/// Map a store write failure during scaffolding to a typed CLI error.
583fn init_write_error(binding_id: &str, err: StoreError) -> CliError {
584    CliError::new(
585        ExitKind::Generic,
586        "PROJECTION_INIT_FAILED",
587        format!("could not scaffold binding `{binding_id}`: {err}"),
588    )
589    .with_details(json!({ "binding": binding_id, "error": err.to_string() }))
590}
591
592fn init(ctx: &CliContext, args: InitArgs) -> anyhow::Result<()> {
593    let (_shape, root) = ctx.workspace_shape().ok_or_else(|| {
594        workspace_not_initialised_error(
595            "not inside a Memstead workspace (no `.memstead/workspace.toml` in any ancestor)",
596        )
597    })?;
598
599    let mem = args.mem;
600    let stem = args
601        .name
602        .clone()
603        .unwrap_or_else(|| derive_stem(&args.source));
604
605    // `mem` and `stem` become three file-path components and the binding id —
606    // refuse anything that is not a single plain component before touching disk.
607    for (kind, value) in [("mem", mem.as_str()), ("name", stem.as_str())] {
608        if !is_single_component(value) {
609            return Err(CliError::new(
610                ExitKind::Validation,
611                "PROJECTION_INVALID_NAME",
612                format!(
613                    "invalid {kind} '{}': must be a single path component (no separators, \
614                     traversal segments, ':' or NUL) — pass an explicit --name",
615                    value.escape_default()
616                ),
617            )
618            .with_details(json!({ "kind": kind, "value": value }))
619            .into());
620        }
621    }
622
623    let binding_id = format!("{mem}/{stem}");
624    let medium_type = args.medium_type.to_medium_type();
625
626    // Refuse — without touching disk — when a binding of this id already exists
627    // (D8: `init` never overwrites). The binding occupies the per-mem
628    // projections tier; its presence is the id-collision signal.
629    let binding_path = root
630        .join(".memstead")
631        .join("projections")
632        .join(&mem)
633        .join(format!("{stem}.json"));
634    if binding_path.exists() {
635        return Err(CliError::new(
636            ExitKind::Validation,
637            "PROJECTION_EXISTS",
638            format!(
639                "a binding `{binding_id}` already exists at \
640                 .memstead/projections/{mem}/{stem}.json — `projection init` never overwrites; \
641                 choose a different --name or edit the existing binding"
642            ),
643        )
644        .with_details(json!({ "binding": binding_id }))
645        .into());
646    }
647
648    // The scaffolded record: ONE v2 binding with one inline source under the
649    // binding stem. The source is scoped `**/*` (a scoped default: an
650    // unscoped source — no allow patterns — would refuse at run time).
651    let source = Source {
652        name: stem.clone(),
653        medium_type,
654        pointer: args.source.clone(),
655        change_detection: None,
656        scope: vec![PatternEntry {
657            path: "**/*".to_string(),
658            mode: PatternMode::Allow,
659        }],
660        engagement: None,
661        preparation: None,
662    };
663
664    // Matrix-filtered defaults: declare build+sync+verify, then let the
665    // capability matrix strip any operation the medium cannot support. A `web`
666    // source has no change signal this cycle, so sync/verify are stripped and
667    // the deferral is named in `warnings[]` (operator decision 7). Every other
668    // medium keeps build+sync+verify.
669    // Default deny paths — materialised into the record at scaffold
670    // time (not injected at load) so the author sees, edits, and can
671    // delete them, and bindings created before the default existed
672    // keep behaving as recorded. Engine self-exclusion is separate:
673    // unconditional in the strategy layer, never a record entry.
674    let deny_paths: Vec<String> = if matches!(
675        medium_type,
676        memstead_base::MediumType::Codebase | memstead_base::MediumType::Filesystem
677    ) {
678        memstead_base::binding::DEFAULT_SCAFFOLD_DENY_PATHS
679            .iter()
680            .map(|s| s.to_string())
681            .collect()
682    } else {
683        Vec::new()
684    };
685
686    let mut binding = Binding {
687        version: BINDING_VERSION,
688        intent: args.intent.clone(),
689        sources: vec![source],
690        reference_mems: Vec::new(),
691        destination_mem: mem.clone(),
692        deny_paths,
693        // Unstated: the scaffold asserts nothing — the effective value
694        // resolves per medium (enumerable → exhaustive, web → curated).
695        coverage_semantics: None,
696        rules: None,
697        prune: None,
698        operations: Operations {
699            build: Some(BuildOperation {
700                mode: BuildMode::Discovery,
701                trigger: IngestTrigger::Loop,
702                batch_size: 20,
703                post_actions: None,
704            }),
705            sync: Some(SyncOperation {
706                trigger: IngestTrigger::Manual,
707                batch_size: 20,
708            }),
709            verify: Some(VerifyOperation {
710                trigger: IngestTrigger::Manual,
711                batch_size: 20,
712                adjudication_cap: DEFAULT_ADJUDICATION_CAP,
713                full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
714            }),
715        },
716    };
717
718    let mut warnings: Vec<String> = Vec::new();
719
720    // Out-of-workspace medium base — legitimate but fragile: artifact
721    // ids are rendered workspace-relative (`../../…` chains), and
722    // anchors hand-written against source-relative paths resolve as
723    // orphaned. Name the consequence NOW, before any work is wasted;
724    // the operation still succeeds.
725    if matches!(
726        medium_type,
727        memstead_base::MediumType::Codebase | memstead_base::MediumType::Filesystem
728    ) {
729        let base = memstead_base::ingest::cursor::medium_base(&args.source, &root);
730        // Canonicalize both sides when possible so symlinked roots
731        // (macOS /tmp) don't false-positive; fall back to the lexical
732        // forms for not-yet-existing paths.
733        let canon_base = std::fs::canonicalize(&base).unwrap_or(base);
734        let canon_root = std::fs::canonicalize(&root).unwrap_or_else(|_| root.clone());
735        if !canon_base.starts_with(&canon_root) {
736            warnings.push(format!(
737                "medium base '{}' resolves outside the workspace root '{}': artifact ids will be \
738                 workspace-relative ('../…' chains), and anchors written against source-relative \
739                 paths will fail to resolve (orphaned). Consider rooting the workspace at the \
740                 source tree.",
741                canon_base.display(),
742                canon_root.display()
743            ));
744        }
745    }
746
747    if let Err(refusals) = validate_binding(&binding) {
748        for r in &refusals {
749            if let CapabilityError::OperationOutOfScope { operation, .. } = r {
750                match *operation {
751                    "sync" => binding.operations.sync = None,
752                    "verify" => binding.operations.verify = None,
753                    _ => {}
754                }
755            }
756            warnings.push(r.to_string());
757        }
758    }
759
760    // Prune (F1) rides the sync path — scaffold it wherever sync survived the
761    // matrix filter, with the strongest guarantee the medium supports (a
762    // base-retrievable / git-backed medium gets never-clobber; every
763    // sync-capable medium is also base-retrievable, so this never refuses). A
764    // `web` binding (sync stripped) gets no prune block.
765    if binding.operations.sync.is_some() {
766        binding.prune = Some(PruneConfig {
767            guarantee: prune_guarantee_for_medium(medium_type),
768        });
769    }
770
771    let mut operations: Vec<&str> = vec!["build"];
772    if binding.operations.sync.is_some() {
773        operations.push("sync");
774    }
775    if binding.operations.verify.is_some() {
776        operations.push("verify");
777    }
778
779    // Write the one record. The id-collision refusal above already
780    // guaranteed a fresh binding, so this path only runs on a clean
781    // scaffold; a store IO failure surfaces the typed
782    // `PROJECTION_INIT_FAILED`.
783    write_binding(&root, &mem, &stem, &binding).map_err(|e| init_write_error(&binding_id, e))?;
784
785    let created = vec![format!(".memstead/projections/{mem}/{stem}.json")];
786
787    if ctx.json {
788        // D8's pinned skill contract: { binding, created, operations, warnings }.
789        print_json(&json!({
790            "binding": binding_id,
791            "created": created,
792            "operations": operations,
793            "warnings": warnings,
794        }))?;
795    } else {
796        let mut out = format!("# Projection init\n\nScaffolded binding `{binding_id}`:\n");
797        for c in &created {
798            out.push_str(&format!("- `{c}`\n"));
799        }
800        out.push_str(&format!("\nOperations: {}\n", operations.join(", ")));
801        if !warnings.is_empty() {
802            out.push_str("\n## Warnings\n\n");
803            for w in &warnings {
804                out.push_str(&format!("- {w}\n"));
805            }
806        }
807        print_markdown(&out);
808    }
809    Ok(())
810}
811
812fn map_migrate_err(err: BindingMigrateError) -> CliError {
813    // Spell each `PROJECTION_*` token as a literal at its own construction site
814    // so the generated error index (xtask) picks them up — a variable `code`
815    // is invisible to the string-literal scanner.
816    let message = err.to_string();
817    match &err {
818        BindingMigrateError::RefinementModeDeleted { .. } => CliError::new(
819            ExitKind::Validation,
820            "PROJECTION_MIGRATE_REFINEMENT",
821            message,
822        ),
823        BindingMigrateError::MalformedProjectionRef { .. } => CliError::new(
824            ExitKind::Validation,
825            "PROJECTION_MIGRATE_MALFORMED_REF",
826            message,
827        ),
828        BindingMigrateError::DanglingProjectionRef { .. }
829        | BindingMigrateError::DanglingFacetRef { .. }
830        | BindingMigrateError::DanglingMediumRef { .. } => CliError::new(
831            ExitKind::Validation,
832            "PROJECTION_MIGRATE_DANGLING_REF",
833            message,
834        ),
835        BindingMigrateError::OrphanRecords { .. } => CliError::new(
836            ExitKind::Validation,
837            "PROJECTION_MIGRATE_ORPHAN_RECORDS",
838            message,
839        ),
840    }
841}
842
843/// Does the workspace root carry a gen-1 legacy pipeline layout — the
844/// pre-four-primitive `scopes|projections|ingests/` JSON folders at the root
845/// (not under `.memstead/`)? Presence of any of the three marks it. This is the
846/// trigger for folding the retired `pipeline migrate` conversion into
847/// `projection migrate` (D10, gen-1 path).
848fn has_legacy_root_layout(root: &std::path::Path) -> bool {
849    ["scopes", "projections", "ingests"]
850        .iter()
851        .any(|d| root.join(d).is_dir())
852}
853
854/// Map a store load failure during migrate to the typed generic code.
855fn migrate_load_err(err: StoreError) -> CliError {
856    CliError::new(
857        ExitKind::Generic,
858        "PROJECTION_MIGRATE_FAILED",
859        format!("could not load pipeline config: {err}"),
860    )
861    .with_details(json!({ "error": err.to_string() }))
862}
863
864/// Does the binding's `medium_pointer` (resolved against the workspace root)
865/// point at the same location as a `reconcile-cursors.json` absolute key? Uses
866/// canonicalization where both paths exist, else a lexical comparison (D10 —
867/// "the binding whose medium pointer resolves to that path").
868fn pointer_resolves_to(root: &std::path::Path, medium_pointer: &str, abs_path: &str) -> bool {
869    let resolved = if medium_pointer.is_empty() {
870        root.to_path_buf()
871    } else {
872        root.join(medium_pointer)
873    };
874    match (
875        std::fs::canonicalize(&resolved),
876        std::fs::canonicalize(abs_path),
877    ) {
878        (Ok(a), Ok(b)) => a == b,
879        _ => resolved == std::path::Path::new(abs_path),
880    }
881}
882
883/// Scan `workspace.toml` for retired pipeline/cursor vocabulary. `projection
884/// migrate` **never** writes `workspace.toml` (D10) — if it finds a stale
885/// reference it returns a proposal block for the operator (or the migrating
886/// session) to apply and commit explicitly, rather than rewriting it.
887fn propose_workspace_toml(root: &std::path::Path) -> Option<String> {
888    let path = root.join(".memstead").join("workspace.toml");
889    let content = std::fs::read_to_string(path).ok()?;
890    let hits: Vec<(usize, &str)> = content
891        .lines()
892        .enumerate()
893        .filter(|(_, l)| {
894            let low = l.to_lowercase();
895            low.contains("reconcile-cursors") || low.contains("ingests/") || low.contains("ingest ")
896        })
897        .collect();
898    if hits.is_empty() {
899        return None;
900    }
901    let mut block = String::from(
902        "## Proposal: workspace.toml (NOT applied)\n\n`projection migrate` never edits \
903         `workspace.toml`. It found references to retired pipeline vocabulary — review and \
904         update these lines by hand, then commit:\n\n",
905    );
906    for (i, line) in hits {
907        block.push_str(&format!("- L{}: `{}`\n", i + 1, line.trim()));
908    }
909    Some(block)
910}
911
912/// Binding-miss refusal that honours the quarantine roster
913/// (agent-trust plan 04): a binding whose stored file failed the v2
914/// version gate is QUARANTINED, not unknown — the refusal carries the
915/// typed reason, whose message names `memstead projection migrate`
916/// for the legacy generations. Healthy-miss keeps the historical
917/// `PROJECTION_NOT_FOUND`.
918fn binding_miss_error(configs: &memstead_base::BindingConfigs, binding_id: &str) -> CliError {
919    if let Some(q) = configs
920        .quarantined
921        .iter()
922        .find(|q| format!("{}/{}", q.mem, q.name) == binding_id)
923    {
924        return CliError::new(
925            ExitKind::Validation,
926            "PROJECTION_QUARANTINED",
927            format!(
928                "binding `{binding_id}` is quarantined — its stored file failed the load and \
929                 it serves no operations until repaired: [{}] {}",
930                q.reason_code, q.reason_message
931            ),
932        )
933        .with_details(json!({
934            "binding": binding_id,
935            "reason_code": q.reason_code,
936            "reason_message": q.reason_message,
937            "path": q.path,
938        }));
939    }
940    CliError::new(
941        ExitKind::NotFound,
942        "PROJECTION_NOT_FOUND",
943        format!(
944            "no binding `{binding_id}` in this workspace — scaffold one with \
945             `projection init` or migrate a legacy workspace with `projection migrate`"
946        ),
947    )
948    .with_details(json!({ "binding": binding_id }))
949}
950
951/// Consume a skill-written `reconcile-cursors.json` (D10/AC12): each
952/// machine-absolute `"<mem>:<abs-path>": <sha>` entry seeds the `#synced`
953/// baseline of every binding whose medium pointer resolves to that path (via
954/// the engine's `set_mem_sync_state` writer — the engine owns mem-repo state),
955/// then the file is **deleted** regardless of whether anything matched
956/// (cursorless / unmatched bindings stay never-synced). Returns the seeded keys.
957fn consume_reconcile_cursors(
958    ctx: &CliContext,
959    root: &std::path::Path,
960) -> anyhow::Result<(Vec<String>, Option<String>)> {
961    let cursor_path = root.join(".memstead").join("reconcile-cursors.json");
962    if !cursor_path.exists() {
963        return Ok((Vec::new(), None));
964    }
965    let cursors: std::collections::BTreeMap<String, String> = std::fs::read(&cursor_path)
966        .ok()
967        .and_then(|b| serde_json::from_slice(&b).ok())
968        .unwrap_or_default();
969
970    let mut seeded: Vec<String> = Vec::new();
971    if !cursors.is_empty() {
972        let configs = load_pipeline_configs(root).map_err(migrate_load_err)?;
973        // Repair-below-boot rule: cursor seeding needs a booted engine
974        // (`set_mem_sync_state`), but `projection migrate` is itself a
975        // named boot repair — when the workspace still does not boot
976        // (e.g. a schema-pin failure alongside the projection
977        // migration), seeding is explicitly DEFERRED rather than
978        // deadlocking the repair verb or silently dropping the
979        // cursors: the file is kept untouched and the notice names the
980        // follow-up.
981        let mut cli_engine = match ctx.cli_engine_at(root) {
982            Ok(e) => e,
983            Err(boot_err) => {
984                return Ok((
985                    Vec::new(),
986                    Some(format!(
987                        "RECONCILE_CURSORS_DEFERRED: the workspace does not boot yet \
988                         ({boot_err:#}); reconcile-cursors.json was kept — repair the boot, \
989                         then re-run `memstead projection migrate` to seed the sync baselines"
990                    )),
991                ));
992            }
993        };
994        let engine = cli_engine.base_mut();
995        for (cursor_key, sha) in &cursors {
996            // Key is `"<mem>:<abs-path>"` — split on the first ':'.
997            let Some((_cursor_mem, abs_path)) = cursor_key.split_once(':') else {
998                continue;
999            };
1000            for record in &configs.bindings {
1001                let binding_id = format!("{}/{}", record.mem, record.name);
1002                let Ok(resolved) = resolve_binding_run(&binding_id, &record.config) else {
1003                    continue;
1004                };
1005                for source in &resolved.sources {
1006                    if let ResolvedSource::Primary(p) = source
1007                        && pointer_resolves_to(root, &p.pointer, abs_path)
1008                    {
1009                        let key = format!("{binding_id}/{}#synced", p.name);
1010                        if engine
1011                            .set_mem_sync_state(
1012                                &resolved.destination_mem,
1013                                &key,
1014                                sha,
1015                                Some("projection migrate: seeded from reconcile-cursors.json"),
1016                            )
1017                            .is_ok()
1018                        {
1019                            seeded.push(key);
1020                        }
1021                    }
1022                }
1023            }
1024        }
1025    }
1026    // Consumed — delete regardless of matches (D10: the file is retired here).
1027    let _ = std::fs::remove_file(&cursor_path);
1028    Ok((seeded, None))
1029}
1030
1031fn migrate(ctx: &CliContext, args: MigrateArgs) -> anyhow::Result<()> {
1032    let (_shape, root) = ctx.workspace_shape().ok_or_else(|| {
1033        workspace_not_initialised_error(
1034            "not inside a Memstead workspace (no `.memstead/workspace.toml` in any ancestor)",
1035        )
1036    })?;
1037
1038    // Gen-1 root-folder layout (`scopes|projections|ingests/` at the workspace
1039    // root) — the pre-four-primitive generation the retired `pipeline migrate`
1040    // command handled. Fold it in: materialize it into the four-primitive
1041    // `.memstead/` store first (mediums + facets + projections + ingests),
1042    // then fold to v2 below in the same pass. `--dry-run` reads the
1043    // root-folder configs directly without writing anything.
1044    let gen1 = has_legacy_root_layout(&root);
1045    if gen1 && !args.dry_run {
1046        migrate_legacy_pipeline(&root).map_err(|e| {
1047            CliError::new(
1048                ExitKind::Generic,
1049                "PROJECTION_MIGRATE_FAILED",
1050                format!("could not convert root-folder (gen-1) pipeline layout: {e}"),
1051            )
1052            .with_details(json!({ "error": e.to_string() }))
1053        })?;
1054    }
1055
1056    let configs = if gen1 && args.dry_run {
1057        read_legacy_pipeline_configs(&root).map_err(migrate_load_err)?
1058    } else {
1059        load_legacy_pipeline_configs(&root).map_err(migrate_load_err)?
1060    };
1061
1062    // Pure transforms first: any refusal (refinement / dangling / malformed /
1063    // orphan) aborts before a single file is touched — the migration is
1064    // all-or-nothing.
1065    //
1066    // Leg A (gen-2): merge each flat ingest into its projection and fold the
1067    // referenced facets + mediums inline — one v2 record per pipeline.
1068    let mut migrated = migrate_gen2_bindings(&configs).map_err(map_migrate_err)?;
1069
1070    // Leg B (v1 → v2): fold every on-disk `version: 1` binding of the
1071    // retired three-file store the same way, in place. Source names are the
1072    // facet names byte-verbatim, so sync watermarks keep resolving. A
1073    // version-less projection file no ingest schedules is inert leftovers —
1074    // refused with a remedy rather than silently dropped or left to break
1075    // the loader. (Skipped in the gen-1 dry-run, which previews in-memory.)
1076    let mut already_v2 = 0usize;
1077    if !(gen1 && args.dry_run) {
1078        let generations = load_projection_generations(&root).map_err(migrate_load_err)?;
1079        for (mem, name, generation) in generations {
1080            let binding_id = format!("{mem}/{name}");
1081            match generation {
1082                ProjectionGeneration::V2 => already_v2 += 1,
1083                ProjectionGeneration::V1(v1) => {
1084                    let consumed = v1.source_facets.clone();
1085                    let binding = fold_v1_binding(&binding_id, &mem, v1.as_ref(), &configs)
1086                        .map_err(map_migrate_err)?;
1087                    migrated.push(memstead_base::binding_migrate::MigratedBinding {
1088                        id: binding_id,
1089                        mem,
1090                        name,
1091                        ingest_name: String::new(),
1092                        consumed_facets: consumed,
1093                        binding,
1094                        notes: Vec::new(),
1095                    });
1096                }
1097                ProjectionGeneration::VersionLess => {
1098                    if !migrated.iter().any(|m| m.mem == mem && m.name == name) {
1099                        return Err(CliError::new(
1100                            ExitKind::Validation,
1101                            "PROJECTION_MIGRATE_INERT_PROJECTION",
1102                            format!(
1103                                "projection `{binding_id}` is a version-less gen-2 file no \
1104                                 ingest schedules — inert leftovers the loader refuses; delete \
1105                                 .memstead/projections/{mem}/{name}.json (or add an ingest) and \
1106                                 re-run `projection migrate`"
1107                            ),
1108                        )
1109                        .with_details(json!({ "binding": binding_id }))
1110                        .into());
1111                    }
1112                }
1113            }
1114        }
1115        migrated.sort_by(|a, b| a.id.cmp(&b.id));
1116
1117        // Every medium/facet record must have folded into some binding —
1118        // an orphan would be silently dropped by the tree removal, so the
1119        // whole migration refuses instead, naming each leftover.
1120        let consumed: Vec<(String, String)> = migrated
1121            .iter()
1122            .flat_map(|m| m.consumed_facets.iter().map(|f| (m.mem.clone(), f.clone())))
1123            .collect();
1124        check_all_consumed(&configs, &consumed).map_err(map_migrate_err)?;
1125    }
1126
1127    // Validate each produced binding against the capability matrix. A
1128    // capability refusal reflects a pre-existing config problem the binding
1129    // faithfully carries; surface it as a per-binding warning rather than
1130    // aborting the promotion. The folded v2 record validates directly — no
1131    // external resolution.
1132    let mut warnings: Vec<serde_json::Value> = Vec::new();
1133    for m in &migrated {
1134        if let Err(refusals) = validate_binding(&m.binding) {
1135            for r in refusals {
1136                warnings.push(json!({
1137                    "binding": m.id,
1138                    "kind": "capability",
1139                    "message": r.to_string(),
1140                }));
1141            }
1142        }
1143        for note in &m.notes {
1144            warnings.push(json!({
1145                "binding": m.id,
1146                "kind": "note",
1147                "message": note,
1148            }));
1149        }
1150    }
1151
1152    // Emit to disk unless previewing: promote each projection file to its v2
1153    // binding in place, remove each consumed flat ingest, then remove the
1154    // emptied `mediums/` and `facets/` trees (every record folded — the
1155    // orphan check above guaranteed it).
1156    if !args.dry_run {
1157        for m in &migrated {
1158            write_binding(&root, &m.mem, &m.name, &m.binding).map_err(|e| {
1159                CliError::new(
1160                    ExitKind::Generic,
1161                    "PROJECTION_MIGRATE_FAILED",
1162                    format!("could not write binding `{}`: {e}", m.id),
1163                )
1164                .with_details(json!({ "binding": m.id, "error": e.to_string() }))
1165            })?;
1166            if !m.ingest_name.is_empty() {
1167                delete_ingest(&root, &m.ingest_name).map_err(|e| {
1168                    CliError::new(
1169                        ExitKind::Generic,
1170                        "PROJECTION_MIGRATE_FAILED",
1171                        format!("could not remove merged ingest `{}`: {e}", m.ingest_name),
1172                    )
1173                    .with_details(json!({ "ingest": m.ingest_name, "error": e.to_string() }))
1174                })?;
1175            }
1176        }
1177        remove_mediums_and_facets_trees(&root).map_err(|e| {
1178            CliError::new(
1179                ExitKind::Generic,
1180                "PROJECTION_MIGRATE_FAILED",
1181                format!("could not remove the emptied mediums/facets trees: {e}"),
1182            )
1183            .with_details(json!({ "error": e.to_string() }))
1184        })?;
1185    }
1186
1187    // AC12/D10: consume `reconcile-cursors.json` (seed `#synced` baselines, then
1188    // delete it) and surface a `workspace.toml` proposal for any retired-vocab
1189    // references — never rewriting workspace.toml. Both are no-ops in `--dry-run`.
1190    let ((seeded, cursors_deferred), proposal) = if args.dry_run {
1191        ((Vec::new(), None), None)
1192    } else {
1193        (
1194            consume_reconcile_cursors(ctx, &root)?,
1195            propose_workspace_toml(&root),
1196        )
1197    };
1198
1199    let bindings: Vec<&str> = migrated.iter().map(|m| m.id.as_str()).collect();
1200    if ctx.json {
1201        print_json(&json!({
1202            "ok": true,
1203            "dry_run": args.dry_run,
1204            "migrated": migrated.len(),
1205            "already_v2": already_v2,
1206            "bindings": bindings,
1207            "warnings": warnings,
1208            "cursors_seeded": seeded,
1209            "cursors_deferred": cursors_deferred,
1210            "workspace_toml_proposal": proposal,
1211        }))?;
1212    } else {
1213        let verb = if args.dry_run {
1214            "Would migrate"
1215        } else {
1216            "Migrated"
1217        };
1218        let mut out = format!(
1219            "# Projection migration\n\n{verb} {} binding(s) to v2 ({already_v2} already v2):\n",
1220            migrated.len()
1221        );
1222        for id in &bindings {
1223            out.push_str(&format!("- `{id}`\n"));
1224        }
1225        if !warnings.is_empty() {
1226            out.push_str("\n## Warnings\n\n");
1227            for w in &warnings {
1228                out.push_str(&format!(
1229                    "- [{}] `{}`: {}\n",
1230                    w["kind"].as_str().unwrap_or(""),
1231                    w["binding"].as_str().unwrap_or(""),
1232                    w["message"].as_str().unwrap_or(""),
1233                ));
1234            }
1235        }
1236        if !seeded.is_empty() {
1237            out.push_str("\n## Baselines seeded from reconcile-cursors.json\n\n");
1238            for key in &seeded {
1239                out.push_str(&format!("- `{key}`\n"));
1240            }
1241        }
1242        if let Some(notice) = &cursors_deferred {
1243            out.push_str(&format!("\n## Reconcile cursors deferred\n\n{notice}\n"));
1244        }
1245        if let Some(block) = &proposal {
1246            out.push('\n');
1247            out.push_str(block);
1248        }
1249        if !args.dry_run {
1250            out.push_str(
1251                "\nEach projection file was converted to a v2 single-record binding in place \
1252                 (medium + facet content folded inline, source names preserved verbatim); \
1253                 merged ingests and the emptied mediums/ and facets/ trees were removed.\n",
1254            );
1255        }
1256        print_markdown(&out);
1257    }
1258    Ok(())
1259}
1260
1261/// A malformed binding id (not `<mem>/<stem>`, or a half that is not a single
1262/// plain path component) — the same shape guard `init` applies to its
1263/// scaffolded id, spelled here so the failure is typed before any disk touch.
1264fn invalid_binding_id(binding_id: &str) -> CliError {
1265    CliError::new(
1266        ExitKind::Validation,
1267        "PROJECTION_INVALID_NAME",
1268        format!(
1269            "invalid binding id '{}': expected `<mem>/<stem>` with each half a single path \
1270             component (no extra separators, traversal segments, ':' or NUL)",
1271            binding_id.escape_default()
1272        ),
1273    )
1274    .with_details(json!({ "binding": binding_id }))
1275}
1276
1277/// Map a store IO/parse failure while enabling to a typed CLI error. The
1278/// missing-binding case is handled separately (existence pre-check →
1279/// `PROJECTION_NOT_FOUND`); this covers a present-but-unreadable/unparseable
1280/// binding file and write failures.
1281fn enable_failed(binding_id: &str, err: StoreError) -> CliError {
1282    CliError::new(
1283        ExitKind::Generic,
1284        "PROJECTION_ENABLE_FAILED",
1285        format!("could not enable operation on binding `{binding_id}`: {err}"),
1286    )
1287    .with_details(json!({ "binding": binding_id, "error": err.to_string() }))
1288}
1289
1290fn enable(ctx: &CliContext, args: EnableArgs) -> anyhow::Result<()> {
1291    let (_shape, root) = ctx.workspace_shape().ok_or_else(|| {
1292        workspace_not_initialised_error(
1293            "not inside a Memstead workspace (no `.memstead/workspace.toml` in any ancestor)",
1294        )
1295    })?;
1296
1297    let binding_id = args.binding;
1298    let op = args.operation;
1299
1300    // Parse the binding id `<mem>/<stem>`; refuse a malformed shape (or a half
1301    // that is not a single plain path component) before touching disk. Own the
1302    // halves so `binding_id` is free to move into JSON payloads later.
1303    let (mem, stem) = binding_id
1304        .split_once('/')
1305        .filter(|(m, n)| !m.is_empty() && !n.is_empty())
1306        .filter(|(m, n)| is_single_component(m) && is_single_component(n))
1307        .ok_or_else(|| invalid_binding_id(&binding_id))?;
1308    let mem = mem.to_string();
1309    let stem = stem.to_string();
1310
1311    // Missing binding file → PROJECTION_NOT_FOUND (NotFound exit). A present-
1312    // but-unparseable file is kept apart (→ PROJECTION_ENABLE_FAILED) by this
1313    // existence pre-check.
1314    let binding_path = root
1315        .join(".memstead")
1316        .join("projections")
1317        .join(&mem)
1318        .join(format!("{stem}.json"));
1319    if !binding_path.exists() {
1320        return Err(CliError::new(
1321            ExitKind::NotFound,
1322            "PROJECTION_NOT_FOUND",
1323            format!(
1324                "no binding `{binding_id}` at .memstead/projections/{mem}/{stem}.json — \
1325                 scaffold one with `projection init` or migrate a legacy workspace with \
1326                 `projection migrate`"
1327            ),
1328        )
1329        .with_details(json!({ "binding": binding_id }))
1330        .into());
1331    }
1332    // Quarantine consult before the raw read: a legacy/corrupt file
1333    // refuses with its typed reason (naming `projection migrate` for
1334    // the legacy generations) rather than a generic enable failure.
1335    if let Ok(configs) = load_pipeline_configs(&root)
1336        && configs
1337            .quarantined
1338            .iter()
1339            .any(|q| format!("{}/{}", q.mem, q.name) == binding_id)
1340    {
1341        return Err(binding_miss_error(&configs, &binding_id).into());
1342    }
1343    let mut binding =
1344        read_binding(&root, &mem, &stem).map_err(|e| enable_failed(&binding_id, e))?;
1345
1346    // Already present? Refuse without a partial write. Every operation block is
1347    // optional now (D1/AC4), so `build` is enableable too (the remedy a
1348    // build-less binding's brief refusal cites).
1349    let already = match op {
1350        EnableOperationArg::Build => binding.operations.build.is_some(),
1351        EnableOperationArg::Sync => binding.operations.sync.is_some(),
1352        EnableOperationArg::Verify => binding.operations.verify.is_some(),
1353    };
1354    if already {
1355        return Err(CliError::new(
1356            ExitKind::Validation,
1357            "PROJECTION_OP_ALREADY_ENABLED",
1358            format!(
1359                "operation `{}` is already enabled on binding `{binding_id}` — nothing to do",
1360                op.name()
1361            ),
1362        )
1363        .with_details(json!({ "binding": binding_id, "operation": op.name() }))
1364        .into());
1365    }
1366
1367    // Add the operation block with sensible defaults: `batch_size` mirrors the
1368    // build op's when present, else 20. Sync/verify default `trigger: manual`;
1369    // build defaults to a discovery/loop schedule (the common obligation shape).
1370    let batch_size = binding
1371        .operations
1372        .build
1373        .as_ref()
1374        .map_or(20, |b| b.batch_size);
1375    match op {
1376        EnableOperationArg::Build => {
1377            binding.operations.build = Some(BuildOperation {
1378                mode: BuildMode::Discovery,
1379                trigger: IngestTrigger::Loop,
1380                batch_size,
1381                post_actions: None,
1382            });
1383        }
1384        EnableOperationArg::Sync => {
1385            binding.operations.sync = Some(SyncOperation {
1386                trigger: IngestTrigger::Manual,
1387                batch_size,
1388            });
1389        }
1390        EnableOperationArg::Verify => {
1391            binding.operations.verify = Some(VerifyOperation {
1392                trigger: IngestTrigger::Manual,
1393                batch_size,
1394                adjudication_cap: DEFAULT_ADJUDICATION_CAP,
1395                full_resync_every: DEFAULT_FULL_RESYNC_EVERY,
1396            });
1397        }
1398    }
1399
1400    // Matrix validation: the v2 record carries its sources inline, so the
1401    // candidate validates directly — refuse if a source's medium half cannot
1402    // support the operation being enabled (e.g. `sync`/`verify` over a `web`
1403    // source). Refusals about *other* operations reflect pre-existing config
1404    // and do not block this enable (mirrors `migrate`'s treat-as-warning
1405    // posture). No write on refusal — the file stays byte-identical.
1406    if let Err(refusals) = validate_binding(&binding)
1407        && let Some(err) = refusals.iter().find(|r| {
1408            matches!(
1409                r,
1410                CapabilityError::OperationOutOfScope { operation, .. } if *operation == op.name()
1411            )
1412        })
1413    {
1414        return Err(CliError::new(
1415            ExitKind::Validation,
1416            "PROJECTION_CAPABILITY_UNSUPPORTED",
1417            err.to_string(),
1418        )
1419        .with_details(json!({ "binding": binding_id, "operation": op.name() }))
1420        .into());
1421    }
1422
1423    write_binding(&root, &mem, &stem, &binding).map_err(|e| enable_failed(&binding_id, e))?;
1424
1425    let mut operations: Vec<&str> = Vec::new();
1426    if binding.operations.build.is_some() {
1427        operations.push("build");
1428    }
1429    if binding.operations.sync.is_some() {
1430        operations.push("sync");
1431    }
1432    if binding.operations.verify.is_some() {
1433        operations.push("verify");
1434    }
1435
1436    if ctx.json {
1437        print_json(&json!({
1438            "binding": binding_id,
1439            "enabled": op.name(),
1440            "operations": operations,
1441        }))?;
1442    } else {
1443        print_markdown(&format!(
1444            "# Projection enable\n\nEnabled `{}` on binding `{binding_id}`.\n\nOperations: {}\n",
1445            op.name(),
1446            operations.join(", ")
1447        ));
1448    }
1449    Ok(())
1450}
1451
1452/// Map a `resolve_binding_run` failure to a typed CLI error. With inline
1453/// sources the dangling facet/medium refusals are gone; a malformed id is the
1454/// Validation-shaped name error, everything else generic.
1455fn map_resolve_err(binding_id: &str, err: ResolveError) -> CliError {
1456    let message = err.to_string();
1457    let mapped = match err {
1458        ResolveError::MalformedProjectionRef { .. } => {
1459            CliError::new(ExitKind::Validation, "PROJECTION_INVALID_NAME", message)
1460        }
1461        _ => CliError::new(ExitKind::Generic, "PROJECTION_ADVANCE_FAILED", message),
1462    };
1463    mapped.with_details(json!({ "binding": binding_id }))
1464}
1465
1466/// Map an [`AdvanceError`] to a typed CLI error. The unknown-artifact refusal
1467/// is the D7 gate (Validation); a malformed id is a Validation-shaped name
1468/// error; store / engine failures are generic. Codes are spelled as literals at
1469/// each site so the generated error index picks them up.
1470fn map_advance_err(binding_id: &str, err: AdvanceError) -> CliError {
1471    let message = err.to_string();
1472    match &err {
1473        AdvanceError::MalformedId(_) => {
1474            CliError::new(ExitKind::Validation, "PROJECTION_INVALID_NAME", message)
1475                .with_details(json!({ "binding": binding_id }))
1476        }
1477        AdvanceError::UnknownArtifact {
1478            artifacts,
1479            suggestions,
1480            ..
1481        } => {
1482            // `corrected_artifacts` maps each medium-relative-looking id to
1483            // the workspace-relative id the slice actually presented — the
1484            // machine-readable half of the message's remedy.
1485            let corrected: serde_json::Map<String, serde_json::Value> = suggestions
1486                .iter()
1487                .map(|(supplied, corrected)| {
1488                    (
1489                        supplied.clone(),
1490                        serde_json::Value::String(corrected.clone()),
1491                    )
1492                })
1493                .collect();
1494            CliError::new(
1495                ExitKind::Validation,
1496                "PROJECTION_ADVANCE_UNKNOWN_ARTIFACT",
1497                message,
1498            )
1499            .with_details(json!({
1500                "binding": binding_id,
1501                "unknown_artifacts": artifacts,
1502                "corrected_artifacts": corrected,
1503            }))
1504        }
1505        AdvanceError::Store(_) | AdvanceError::Engine(_) => {
1506            CliError::new(ExitKind::Generic, "PROJECTION_ADVANCE_FAILED", message)
1507                .with_details(json!({ "binding": binding_id }))
1508        }
1509    }
1510}
1511
1512fn advance(ctx: &CliContext, args: AdvanceArgs) -> anyhow::Result<()> {
1513    let (_shape, root) = ctx.workspace_shape().ok_or_else(|| {
1514        workspace_not_initialised_error(
1515            "not inside a Memstead workspace (no `.memstead/workspace.toml` in any ancestor)",
1516        )
1517    })?;
1518
1519    let binding_id = args.binding;
1520
1521    // Parse the dispositions payload up front — a malformed `--dispositions`
1522    // refuses cheaply (before loading configs or an engine) with a typed code.
1523    let dispositions: std::collections::BTreeMap<String, DispositionInput> =
1524        serde_json::from_str(&args.dispositions).map_err(|e| {
1525            CliError::new(
1526                ExitKind::Validation,
1527                "PROJECTION_INVALID_DISPOSITIONS",
1528                format!(
1529                    "--dispositions must be a JSON object mapping artifact id → either a \
1530                     disposition string (e.g. \"worked\") or an object \
1531                     {{\"disposition\": \"excluded\", \"rationale\": \"...\"}}: {e}"
1532                ),
1533            )
1534            .with_details(json!({ "error": e.to_string() }))
1535        })?;
1536
1537    // Find the binding by canonical id in the v1 store.
1538    let configs = load_pipeline_configs(&root).map_err(|e| {
1539        CliError::new(
1540            ExitKind::Generic,
1541            "PROJECTION_ADVANCE_FAILED",
1542            format!("could not load pipeline config: {e}"),
1543        )
1544        .with_details(json!({ "error": e.to_string() }))
1545    })?;
1546    let record = configs
1547        .bindings
1548        .iter()
1549        .find(|r| format!("{}/{}", r.mem, r.name) == binding_id)
1550        .ok_or_else(|| binding_miss_error(&configs, &binding_id))?;
1551
1552    // D6/AC4: advance is the sync (maintenance-write) path — refuse when the
1553    // binding declares no `sync` operation, carrying the one-command remedy
1554    // `projection enable sync <binding>` (which, run verbatim, makes it succeed).
1555    if record.config.operations.sync.is_none() {
1556        return Err(CliError::new(
1557            ExitKind::Validation,
1558            "PROJECTION_SYNC_NOT_ENABLED",
1559            format!(
1560                "binding `{binding_id}` has no sync operation — enable it with \
1561                 `memstead projection enable sync {binding_id}`"
1562            ),
1563        )
1564        .with_details(json!({ "binding": binding_id }))
1565        .into());
1566    }
1567
1568    let resolved = resolve_binding_run(&binding_id, &record.config)
1569        .map_err(|e| map_resolve_err(&binding_id, e))?;
1570
1571    // The engine is mutable — a completing advance writes the `#synced`
1572    // baseline token through the sync-state writer.
1573    let mut cli_engine = ctx.cli_engine_at(&root)?;
1574    let engine = cli_engine.base_mut();
1575
1576    let outcome = advance_baseline(engine, &root, &resolved, &dispositions)
1577        .map_err(|e| map_advance_err(&binding_id, e))?;
1578
1579    if ctx.json {
1580        print_json(&json!({
1581            "binding": outcome.binding,
1582            "completed": outcome.completed,
1583            "disposed": outcome.disposed,
1584            "pending": outcome.pending,
1585            "remainder": outcome.remainder,
1586            "tokens_written": outcome.tokens_written,
1587            "warnings": outcome.warnings,
1588        }))?;
1589    } else {
1590        let mut out = format!(
1591            "# Projection advance\n\nBinding `{}`: {} artifact(s) disposed, {} remaining.\n",
1592            outcome.binding, outcome.disposed, outcome.pending
1593        );
1594        if outcome.completed {
1595            out.push_str("\nEvery presented artifact is disposed — the sync baseline advanced.\n");
1596            if !outcome.tokens_written.is_empty() {
1597                out.push_str("\nBaseline tokens written:\n");
1598                for key in &outcome.tokens_written {
1599                    out.push_str(&format!("- `{key}`\n"));
1600                }
1601            }
1602        } else {
1603            out.push_str(
1604                "\nRemainder still pending — re-run `projection advance` after judging the rest \
1605                 (a brief re-render shows what is left).\n",
1606            );
1607        }
1608        if !outcome.warnings.is_empty() {
1609            out.push_str("\n## Warnings\n\n");
1610            for w in &outcome.warnings {
1611                out.push_str(&format!("- {w}\n"));
1612            }
1613        }
1614        print_markdown(&out);
1615    }
1616    Ok(())
1617}
1618
1619/// Map an [`ExcludeError`] to a typed CLI error. The non-member refusal is the
1620/// S(D)-membership gate (Validation); a malformed id is a Validation-shaped name
1621/// error; store failures are generic. Codes are spelled as literals at each site
1622/// so the generated error index picks them up.
1623fn map_exclude_err(binding_id: &str, err: ExcludeError) -> CliError {
1624    let message = err.to_string();
1625    match &err {
1626        ExcludeError::MalformedId(_) => {
1627            CliError::new(ExitKind::Validation, "PROJECTION_INVALID_NAME", message)
1628                .with_details(json!({ "binding": binding_id }))
1629        }
1630        ExcludeError::NotSourceMember { artifacts, .. } => CliError::new(
1631            ExitKind::Validation,
1632            "PROJECTION_EXCLUDE_NOT_SOURCE_MEMBER",
1633            message,
1634        )
1635        .with_details(json!({ "binding": binding_id, "not_source_members": artifacts })),
1636        ExcludeError::Store(_) => {
1637            CliError::new(ExitKind::Generic, "PROJECTION_EXCLUDE_FAILED", message)
1638                .with_details(json!({ "binding": binding_id }))
1639        }
1640    }
1641}
1642
1643fn exclude(ctx: &CliContext, args: ExcludeArgs) -> anyhow::Result<()> {
1644    let (_shape, root) = ctx.workspace_shape().ok_or_else(|| {
1645        workspace_not_initialised_error(
1646            "not inside a Memstead workspace (no `.memstead/workspace.toml` in any ancestor)",
1647        )
1648    })?;
1649
1650    let binding_id = args.binding;
1651
1652    // Parse the exclusions payload up front — a malformed `--exclusions` refuses
1653    // cheaply (before loading configs) with a typed code.
1654    let exclusions: std::collections::BTreeMap<String, String> =
1655        serde_json::from_str(&args.exclusions).map_err(|e| {
1656            CliError::new(
1657                ExitKind::Validation,
1658                "PROJECTION_INVALID_EXCLUSIONS",
1659                format!(
1660                    "--exclusions must be a JSON object mapping in-scope artifact id → \
1661                     rationale string: {e}"
1662                ),
1663            )
1664            .with_details(json!({ "error": e.to_string() }))
1665        })?;
1666
1667    // Find the binding by canonical id in the v1 store.
1668    let configs = load_pipeline_configs(&root).map_err(|e| {
1669        CliError::new(
1670            ExitKind::Generic,
1671            "PROJECTION_EXCLUDE_FAILED",
1672            format!("could not load pipeline config: {e}"),
1673        )
1674        .with_details(json!({ "error": e.to_string() }))
1675    })?;
1676    let record = configs
1677        .bindings
1678        .iter()
1679        .find(|r| format!("{}/{}", r.mem, r.name) == binding_id)
1680        .ok_or_else(|| binding_miss_error(&configs, &binding_id))?;
1681
1682    let resolved = resolve_binding_run(&binding_id, &record.config)
1683        .map_err(|e| map_resolve_err(&binding_id, e))?;
1684
1685    let outcome = record_exclusions(&root, &resolved, &exclusions)
1686        .map_err(|e| map_exclude_err(&binding_id, e))?;
1687
1688    if ctx.json {
1689        print_json(&json!({
1690            "binding": outcome.binding,
1691            "excluded": outcome.excluded,
1692            "added": outcome.added,
1693        }))?;
1694    } else {
1695        print_markdown(&format!(
1696            "# Projection exclude\n\nBinding `{}`: {} artifact(s) newly excluded, \
1697             {} in the ledger.\n",
1698            outcome.binding, outcome.added, outcome.excluded
1699        ));
1700    }
1701    Ok(())
1702}
1703
1704/// Render a one-block human note for the full-enumeration scheduling decision
1705/// (D3), prepended to the verify report so the typed signal is never silent: a
1706/// scheduled full walk that fired, a not-yet-due countdown, disabled scheduling,
1707/// and — critically — any non-enumerable refusal. Empty for the quiet cases
1708/// keeps a rotating-sample run byte-clean.
1709fn render_full_resync_note(decision: &FullResyncDecision) -> String {
1710    match decision {
1711        FullResyncDecision::Disabled => String::new(),
1712        FullResyncDecision::NotDue { .. } => String::new(),
1713        // An explicit full measurement (`--full`): every facet walked in
1714        // full, scheduler bypassed, cap unlimited — stated up front so the
1715        // report below reads as computed, not sampled.
1716        FullResyncDecision::Forced { walked_facets } => {
1717            let facets = if walked_facets.is_empty() {
1718                "(no primary facets)".to_string()
1719            } else {
1720                walked_facets.join(", ")
1721            };
1722            format!(
1723                "> **Full measurement (`--full`)** — full-enumeration walk over: {facets}. \
1724                 Sampling scheduler bypassed; adjudication cap unlimited. Coverage and \
1725                 accuracy figures below are computed over the whole source, not sampled.\n\n"
1726            )
1727        }
1728        FullResyncDecision::Due {
1729            walked_facets,
1730            refused,
1731            ..
1732        } => {
1733            let mut s = String::from("> **Scheduled full resync (D3)** — ");
1734            if walked_facets.is_empty() {
1735                s.push_str("no enumerable facet to walk this run.");
1736            } else {
1737                s.push_str(&format!(
1738                    "full-enumeration coverage walk fired for: {}.",
1739                    walked_facets.join(", ")
1740                ));
1741            }
1742            for r in refused {
1743                s.push_str(&format!(
1744                    "\n> **Refused (non-enumerable):** `{}` ({}) — {}",
1745                    r.facet, r.medium_type, r.reason
1746                ));
1747            }
1748            s.push_str("\n\n");
1749            s
1750        }
1751    }
1752}
1753
1754/// `projection verify <binding>` — measure fidelity and record durable findings
1755/// (group A). Read-only on the destination mem's *entities*; a completed run
1756/// records its `#verified` baseline through the engine's sync-state writer
1757/// (the one sanctioned post-run write — an aborted or failed run never
1758/// advances the token).
1759fn verify(ctx: &CliContext, args: VerifyArgs) -> anyhow::Result<()> {
1760    let (_shape, root) = ctx.workspace_shape().ok_or_else(|| {
1761        workspace_not_initialised_error(
1762            "not inside a Memstead workspace (no `.memstead/workspace.toml` in any ancestor)",
1763        )
1764    })?;
1765
1766    let binding_id = args.binding;
1767
1768    let configs = load_pipeline_configs(&root).map_err(|e| {
1769        CliError::new(
1770            ExitKind::Generic,
1771            "PROJECTION_VERIFY_FAILED",
1772            format!("could not load pipeline config: {e}"),
1773        )
1774        .with_details(json!({ "error": e.to_string() }))
1775    })?;
1776    let record = configs
1777        .bindings
1778        .iter()
1779        .find(|r| format!("{}/{}", r.mem, r.name) == binding_id)
1780        .ok_or_else(|| binding_miss_error(&configs, &binding_id))?;
1781
1782    let resolved = resolve_binding_run(&binding_id, &record.config)
1783        .map_err(|e| map_resolve_err(&binding_id, e))?;
1784
1785    // The measurement pass takes a shared engine borrow (A5 — structurally
1786    // incapable of a mem mutation); the mutable binding exists only for the
1787    // completed-run baseline write below.
1788    let mut cli_engine = ctx.cli_engine_at(&root)?;
1789    let engine = cli_engine.base_mut();
1790
1791    let run = if args.full {
1792        verify_binding_full
1793    } else {
1794        verify_binding
1795    };
1796    let outcome = run(engine, &root, &record.config, &resolved).map_err(|e| match &e {
1797        // A vanished/unmounted source is a typed refusal, not a failed
1798        // measurement: nothing was observed, no findings were recorded,
1799        // and the `#verified` baseline is deliberately left untouched
1800        // (a transient unmount must never clobber real recorded state).
1801        FindingsError::SourceUnreachable { source_name, path } => CliError::new(
1802            ExitKind::Validation,
1803            "SOURCE_UNREACHABLE",
1804            format!(
1805                "verify refused for `{binding_id}`: source '{source_name}' resolves to \
1806                 `{path}`, which does not exist — restore or remount the source (or \
1807                 repoint its pointer); the recorded `#verified` baseline was left \
1808                 untouched"
1809            ),
1810        )
1811        .with_details(json!({
1812            "binding": binding_id,
1813            "source": source_name,
1814            "path": path,
1815        })),
1816        // `--full` over a non-enumerable medium: the existing typed
1817        // capability refusal — a full measurement promises complete
1818        // figures, so the run refuses instead of rendering a report
1819        // with fabricated completeness. Nothing was observed or
1820        // recorded.
1821        FindingsError::FullWalkNonEnumerable(refusal) => CliError::new(
1822            ExitKind::Validation,
1823            "PROJECTION_CAPABILITY_UNSUPPORTED",
1824            format!("verify --full refused for `{binding_id}`: {e}"),
1825        )
1826        .with_details(json!({
1827            "binding": binding_id,
1828            "facet": refusal.facet,
1829            "medium_type": refusal.medium_type,
1830            "reason": refusal.reason,
1831        })),
1832        _ => CliError::new(
1833            ExitKind::Generic,
1834            "PROJECTION_VERIFY_FAILED",
1835            format!("verify failed for `{binding_id}`: {e}"),
1836        )
1837        .with_details(json!({ "binding": binding_id, "error": e.to_string() })),
1838    })?;
1839
1840    // The run completed — record its prepared-hash backfill: every hash the
1841    // pass observed for a hash-less hash-bearing anchor lands on that anchor
1842    // in the engine-owned anchors sidecar (measurement bookkeeping — no
1843    // entity content is touched). Before the report, so the rendered
1844    // anchor-resolution figures reflect the recorded hashes. Idempotent: a
1845    // pass over fully-backfilled anchors observes an empty worklist.
1846    let hashes_backfilled = record_anchor_hash_backfill(
1847        engine,
1848        &resolved.destination_mem,
1849        &outcome,
1850        Some("projection verify: prepared-hash backfill onto hash-less anchors"),
1851    )
1852    .map_err(|e| {
1853        CliError::new(
1854            ExitKind::Generic,
1855            "PROJECTION_VERIFY_BACKFILL_FAILED",
1856            format!(
1857                "verify completed and findings were recorded for `{binding_id}`, but \
1858                 recording the prepared-hash backfill onto the anchors sidecar failed: {e}"
1859            ),
1860        )
1861        .with_details(json!({ "binding": binding_id, "error": e.to_string() }))
1862    })?;
1863
1864    // Assemble + render the tier-1 fidelity report (group B) over the findings
1865    // the pass just recorded. Read-only — no destination-mem mutation.
1866    let budget = args.budget.unwrap_or(DEFAULT_REPORT_BUDGET);
1867    let report = compute_fidelity_report(engine, &root, &record.config, &resolved, &outcome.key);
1868    let rendered = render_fidelity_report(&report, budget, &args.include);
1869
1870    // The run completed — record its `#verified` baseline per observed facet
1871    // head through the engine's sync-state writer (the backlog-prescribed
1872    // writer; a failed run returned above and never reaches this).
1873    let verified_baseline = record_verified_baseline(
1874        engine,
1875        &resolved.destination_mem,
1876        &outcome,
1877        Some("projection verify: completed-run #verified baseline"),
1878    )
1879    .map_err(|e| {
1880        CliError::new(
1881            ExitKind::Generic,
1882            "PROJECTION_VERIFY_BASELINE_FAILED",
1883            format!(
1884                "verify completed and findings were recorded for `{binding_id}`, but writing \
1885                 the `#verified` baseline failed: {e}"
1886            ),
1887        )
1888        .with_details(json!({ "binding": binding_id, "error": e.to_string() }))
1889    })?;
1890
1891    if ctx.json {
1892        print_json(&json!({
1893            "binding": outcome.binding,
1894            "key": {
1895                "binding_hash": outcome.key.binding_hash,
1896                "source_head": outcome.key.source_head,
1897            },
1898            "recorded": outcome.recorded,
1899            "superseded": outcome.superseded,
1900            "backlog": outcome.backlog,
1901            // The tier-3 full-enumeration scheduling decision (D3) — surfaced
1902            // (never a silent skip): whether a scheduled full walk fired, is not
1903            // yet due, is disabled, and any typed non-enumerable refusals.
1904            "full_resync": outcome.full_resync,
1905            // The completed run's `#verified` baseline keys, written through
1906            // the engine's sync-state writer.
1907            "verified_baseline": verified_baseline,
1908            // How many hash-less hash-bearing anchors gained a recorded
1909            // prepared-content hash this run (the completed-run backfill
1910            // write into the engine-owned anchors sidecar). 0 once every
1911            // anchor carries its hash — the backfill is idempotent.
1912            "hash_backfilled": hashes_backfilled,
1913            "report": report,
1914            "report_mode": rendered.mode,
1915            "report_markdown": rendered.markdown,
1916        }))?;
1917    } else {
1918        // The rendered report IS the stdout content (agent-consumable brief);
1919        // prepend the scheduled full-walk decision so D3's typed signal (a full
1920        // sweep, or a non-enumerable refusal) is never silent in human mode,
1921        // and append the recorded `#verified` baseline so the completed-run
1922        // write is visible.
1923        let baseline_note = if verified_baseline.is_empty() {
1924            String::new()
1925        } else {
1926            format!(
1927                "\n> **Verified baseline recorded** — {}\n",
1928                verified_baseline
1929                    .iter()
1930                    .map(|k| format!("`{k}`"))
1931                    .collect::<Vec<_>>()
1932                    .join(", ")
1933            )
1934        };
1935        let backfill_note = if hashes_backfilled == 0 {
1936            String::new()
1937        } else {
1938            format!(
1939                "\n> **Prepared-hash backfill recorded** — {hashes_backfilled} hash-less \
1940                 anchor(s) now carry their observed prepared-content hash; subsequent \
1941                 verifies adjudicate them deterministically.\n"
1942            )
1943        };
1944        print_markdown(&format!(
1945            "{}{}{}{}",
1946            render_full_resync_note(&outcome.full_resync),
1947            rendered.markdown,
1948            backfill_note,
1949            baseline_note
1950        ));
1951    }
1952    Ok(())
1953}