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