Skip to main content

memstead_cli/commands/
projection.rs

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