Skip to main content

memstead_cli/commands/
projection.rs

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