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