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