crtx 0.1.1

CLI for the Cortex supervisory memory substrate.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
//! Shared witness-assembly logic for `release readiness --from-store` and
//! `compliance evidence --from-store`.
//!
//! ## Doctrine
//!
//! Per ADR 0041, the trusted-evidence verifier requires four disjoint-authority
//! witnesses cross-confirming the producer-supplied evidence digest. The
//! `--from-store` mode is the **producer side** of that contract: it reads a
//! local Cortex store and attempts to assemble whatever it can find for each
//! of the four witness axes. Critically, this module **never** manufactures
//! third-party witnesses out of local data — if a third-party receipt (Rekor,
//! OpenTimestamps) is missing, the axis is reported as missing rather than
//! synthesised from local state.
//!
//! ## Stable invariants
//!
//! Each missing witness axis produces a fail-closed invariant string that
//! downstream tooling can match on:
//!
//! - `release.readiness.from_store.witness_axis_missing.signed_chain`
//! - `release.readiness.from_store.witness_axis_missing.ado_build`
//! - `release.readiness.from_store.witness_axis_missing.rekor`
//! - `release.readiness.from_store.witness_axis_missing.ots`
//!
//! (and the corresponding `compliance.evidence.from_store.witness_axis_missing.*`
//! mirrors.)
//!
//! When **all four** axes are present and pass the ADR 0041 verifier, the
//! report sets `trusted_artifact_emitted = true` and the producer surface emits
//! the composed evidence. Until third-party receipt integration lands
//! (Rekor / OTS / ADO build evidence pipeline) this module's `--from-store`
//! reporting is fail-closed by construction: it surfaces the gap honestly
//! rather than overclaiming.

use std::path::PathBuf;

use clap::Args;
use cortex_core::{Attestor, InMemoryAttestor};
use cortex_ledger::audit::{verify_signed_chain, FailureReason};
use ed25519_dalek::VerifyingKey;
use serde::Serialize;

use crate::exit::Exit;
use crate::output::{self, Envelope};
use crate::paths::DataLayout;

/// CLI flags that gate witness assembly from a local Cortex store.
///
/// These flags are additive — the default (without `--from-store`) preserves
/// the byte-for-byte original behaviour of `release readiness` /
/// `compliance evidence` (producer-supplied evidence JSON, advisory-only at
/// runtime preflight).
#[derive(Debug, Args)]
pub struct FromStoreArgs {
    /// Assemble release/compliance evidence by walking the local Cortex
    /// store. When set, the four-witness chain is built from local state
    /// instead of being verified against producer-supplied JSON.
    #[arg(long = "from-store")]
    pub from_store: bool,

    /// Optional override for the SQLite database path. Defaults to
    /// `<data_dir>/cortex.db`.
    #[arg(long, value_name = "PATH", requires = "from_store")]
    pub db: Option<PathBuf>,

    /// Optional override for the JSONL event log path. Defaults to
    /// `<data_dir>/events.jsonl`.
    #[arg(long, value_name = "PATH", requires = "from_store")]
    pub event_log: Option<PathBuf>,

    /// Path to an Ed25519 verifying key (32 raw bytes). Required for the
    /// signed-chain axis. Mutually exclusive with `--attestation`.
    #[arg(
        long,
        value_name = "PATH",
        requires = "from_store",
        conflicts_with = "attestation"
    )]
    pub verification_key: Option<PathBuf>,

    /// Path to a local development attestor seed (32 raw bytes). Used to
    /// derive the public key for the signed-chain axis. Mutually exclusive
    /// with `--verification-key`.
    #[arg(
        long,
        value_name = "PATH",
        requires = "from_store",
        conflicts_with = "verification_key"
    )]
    pub attestation: Option<PathBuf>,
}

/// Status of one witness axis during assembly from the local store.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum WitnessAxis {
    /// The axis was satisfied locally; carries the typed evidence found.
    Present {
        /// Wire-stable name of the witness axis (e.g. `signed_chain`).
        axis: String,
        /// Operator-readable detail describing what was found.
        detail: String,
        /// Optional structured payload echoed into the report.
        payload: serde_json::Value,
    },
    /// The axis was not satisfied; carries the stable invariant string.
    Missing {
        /// Wire-stable name of the witness axis (e.g. `signed_chain`).
        axis: String,
        /// Stable invariant string for downstream matching.
        invariant: String,
        /// Operator-readable detail describing why the axis was missing.
        detail: String,
    },
}

impl WitnessAxis {
    /// Wire-stable axis name regardless of variant.
    #[must_use]
    #[allow(dead_code)]
    pub fn axis(&self) -> &str {
        match self {
            Self::Present { axis, .. } | Self::Missing { axis, .. } => axis,
        }
    }

    /// True iff the axis is `Present`.
    #[must_use]
    pub const fn is_present(&self) -> bool {
        matches!(self, Self::Present { .. })
    }
}

/// Result of assembling all four witness axes from a local store.
#[derive(Debug, Clone, Serialize)]
pub struct FromStoreAssembly {
    /// Signed Cortex ledger chain head (ADR 0022 envelope + Ed25519 head signature).
    pub signed_chain: WitnessAxis,
    /// ADO / remote CI build provenance witness (third-party signer required).
    pub ado_build: WitnessAxis,
    /// Rekor inclusion receipt covering the chain head (Mechanism C anchor).
    pub rekor: WitnessAxis,
    /// OpenTimestamps receipt covering the chain head (Mechanism C anchor).
    pub ots: WitnessAxis,
    /// True iff every axis above is `Present`.
    pub all_present: bool,
    /// The data layout the assembler used.
    pub data_dir: String,
}

impl FromStoreAssembly {
    /// JSON-shaped view suitable for embedding in the envelope `evidence_input`
    /// block.
    #[must_use]
    pub fn report(&self) -> serde_json::Value {
        serde_json::json!({
            "signed_chain": self.signed_chain,
            "ado_build": self.ado_build,
            "rekor": self.rekor,
            "ots": self.ots,
            "all_witness_axes_present": self.all_present,
            "data_dir": self.data_dir,
        })
    }
}

/// Stable invariant string for a missing witness axis on the `release` surface.
pub fn release_witness_axis_missing(axis: &str) -> String {
    format!("release.readiness.from_store.witness_axis_missing.{axis}")
}

/// Stable invariant string for a missing witness axis on the `compliance` surface.
pub fn compliance_witness_axis_missing(axis: &str) -> String {
    format!("compliance.evidence.from_store.witness_axis_missing.{axis}")
}

/// Surface the `--from-store` advisory is being assembled for.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Surface {
    /// `release readiness`.
    Release,
    /// `compliance evidence`.
    Compliance,
}

impl Surface {
    fn missing_invariant(self, axis: &str) -> String {
        match self {
            Self::Release => release_witness_axis_missing(axis),
            Self::Compliance => compliance_witness_axis_missing(axis),
        }
    }

    fn command_name(self) -> &'static str {
        match self {
            Self::Release => "release readiness",
            Self::Compliance => "compliance evidence",
        }
    }
}

/// Resolve a verifying key from either `--verification-key` or `--attestation`.
/// Returns the typed key plus the lowercase hex `key_id` the ledger uses.
fn resolve_signed_chain_key(
    args: &FromStoreArgs,
    surface: Surface,
) -> Result<Option<(VerifyingKey, String)>, Exit> {
    match (args.verification_key.as_ref(), args.attestation.as_ref()) {
        (Some(path), None) => {
            let bytes = std::fs::read(path).map_err(|err| {
                eprintln!(
                    "cortex {}: cannot read --verification-key file `{}`: {err}",
                    surface.command_name(),
                    path.display()
                );
                Exit::PreconditionUnmet
            })?;
            let key_bytes: [u8; 32] = match bytes.as_slice().try_into() {
                Ok(b) => b,
                Err(_) => {
                    eprintln!(
                        "cortex {}: --verification-key file `{}` must be exactly 32 raw bytes (Ed25519 public key); got {} bytes",
                        surface.command_name(),
                        path.display(),
                        bytes.len()
                    );
                    return Err(Exit::PreconditionUnmet);
                }
            };
            let key = VerifyingKey::from_bytes(&key_bytes).map_err(|err| {
                eprintln!(
                    "cortex {}: --verification-key file `{}` is not a valid Ed25519 public key: {err}",
                    surface.command_name(),
                    path.display()
                );
                Exit::PreconditionUnmet
            })?;
            Ok(Some((key, hex_lower(&key_bytes))))
        }
        (None, Some(path)) => {
            let bytes = std::fs::read(path).map_err(|err| {
                eprintln!(
                    "cortex {}: cannot read --attestation key file `{}`: {err}",
                    surface.command_name(),
                    path.display()
                );
                Exit::PreconditionUnmet
            })?;
            if bytes.len() != 32 {
                eprintln!(
                    "cortex {}: --attestation key file `{}` must be exactly 32 raw bytes (Ed25519 seed); got {} bytes",
                    surface.command_name(),
                    path.display(),
                    bytes.len()
                );
                return Err(Exit::PreconditionUnmet);
            }
            let mut seed = [0u8; 32];
            seed.copy_from_slice(&bytes);
            let attestor = InMemoryAttestor::from_seed(&seed);
            let key = attestor.verifying_key();
            Ok(Some((key, attestor.key_id().to_string())))
        }
        (None, None) => Ok(None),
        (Some(_), Some(_)) => unreachable!("clap conflicts_with prevents both"),
    }
}

/// Assemble all four witness axes from the local store and report what was
/// found vs missing. Fail-closed by construction: missing axes are surfaced
/// with stable invariant strings rather than silently filled.
pub fn assemble(args: &FromStoreArgs, surface: Surface) -> Result<FromStoreAssembly, Exit> {
    let layout = DataLayout::resolve(args.db.clone(), args.event_log.clone())?;
    let data_dir = layout.data_dir.display().to_string();

    let signed_chain = assemble_signed_chain_axis(args, &layout, surface)?;
    let ado_build = assemble_ado_build_axis(surface);
    let rekor = assemble_rekor_axis(surface);
    let ots = assemble_ots_axis(surface);

    let all_present = signed_chain.is_present()
        && ado_build.is_present()
        && rekor.is_present()
        && ots.is_present();

    Ok(FromStoreAssembly {
        signed_chain,
        ado_build,
        rekor,
        ots,
        all_present,
        data_dir,
    })
}

fn assemble_signed_chain_axis(
    args: &FromStoreArgs,
    layout: &DataLayout,
    surface: Surface,
) -> Result<WitnessAxis, Exit> {
    let axis_name = "signed_chain";

    if !layout.event_log_path.exists() {
        return Ok(WitnessAxis::Missing {
            axis: axis_name.into(),
            invariant: surface.missing_invariant(axis_name),
            detail: format!(
                "no JSONL event log at `{}`; cannot derive a signed chain head",
                layout.event_log_path.display()
            ),
        });
    }

    let key = match resolve_signed_chain_key(args, surface)? {
        Some(key) => key,
        None => {
            return Ok(WitnessAxis::Missing {
                axis: axis_name.into(),
                invariant: surface.missing_invariant(axis_name),
                detail: "no --verification-key or --attestation supplied; cannot verify the signed chain head".into(),
            });
        }
    };

    let (verifying_key, key_id) = key;
    match verify_signed_chain(&layout.event_log_path, &verifying_key, &key_id) {
        Ok(outcome) => {
            if outcome.report.ok() {
                let (chain_head_hash, event_count) = chain_head_from_report(&outcome.report);
                Ok(WitnessAxis::Present {
                    axis: axis_name.into(),
                    detail: format!(
                        "signed chain verified end-to-end under key_id={key_id}, {event_count} rows"
                    ),
                    payload: serde_json::json!({
                        "key_id": key_id,
                        "rows_scanned": outcome.report.rows_scanned,
                        "chain_head_hash": chain_head_hash,
                        "event_count": event_count,
                    }),
                })
            } else {
                let detail = signed_chain_failure_detail(&outcome.report);
                Ok(WitnessAxis::Missing {
                    axis: axis_name.into(),
                    invariant: surface.missing_invariant(axis_name),
                    detail,
                })
            }
        }
        Err(err) => Ok(WitnessAxis::Missing {
            axis: axis_name.into(),
            invariant: surface.missing_invariant(axis_name),
            detail: format!(
                "signed chain at `{}` could not be scanned: {err}",
                layout.event_log_path.display()
            ),
        }),
    }
}

fn chain_head_from_report(report: &cortex_ledger::Report) -> (String, u64) {
    // The report does not directly expose the head row; rows_scanned is the
    // 1-based count. We synthesise a deterministic placeholder when the chain
    // is empty (rows_scanned == 0) — the axis is still `Present` only when
    // `report.ok()` is true, which implies at least one row was scanned and
    // verified successfully.
    let event_count = report.rows_scanned as u64;
    // ADR 0041 requires the actual chain head hash; we surface the
    // last-observed event hash via `chain_head` if available. The Report
    // type itself does not carry the head hash field today, so we report
    // an empty marker — the downstream verifier will reject this anyway,
    // since the witness payload would not match a real chain head until the
    // operator supplies an external anchor. The axis is recorded as
    // `Present` (the chain walk succeeded) but the verifier composition
    // below still rejects unless a real RemoteCi + Anchor + Reproducible
    // witness lands alongside it.
    (String::new(), event_count)
}

fn signed_chain_failure_detail(report: &cortex_ledger::Report) -> String {
    let summarized: Vec<String> = report
        .failures
        .iter()
        .take(3)
        .map(|f| {
            let kind = match &f.reason {
                FailureReason::Decode { .. } => "decode",
                FailureReason::UnknownEventSchemaVersion { .. } => "unknown_event_schema_version",
                FailureReason::PostCutoverV2AuditDispatchUnsupported { .. } => {
                    "post_cutover_v2_audit_dispatch_unsupported"
                }
                FailureReason::Orphan { .. } => "orphan",
                FailureReason::HashBreak { .. } => "hash_break",
                FailureReason::OrdinalGap { .. } => "ordinal_gap",
                FailureReason::MissingSignature => "missing_signature",
                FailureReason::BadSignature { .. } => "bad_signature",
                FailureReason::UnknownAttestationSchemaVersion { .. } => {
                    "unknown_attestation_schema_version"
                }
                FailureReason::RotationEnvelopeRejected { .. } => "rotation_envelope_rejected",
            };
            format!("line {}: {kind}", f.line)
        })
        .collect();
    let suffix = if report.failures.len() > 3 {
        format!(" (+{} more)", report.failures.len() - 3)
    } else {
        String::new()
    };
    format!(
        "signed chain verification produced {} per-row failure(s): {}{}",
        report.failures.len(),
        summarized.join(", "),
        suffix
    )
}

fn assemble_ado_build_axis(surface: Surface) -> WitnessAxis {
    let axis_name = "ado_build";
    // ADO build evidence integration is not in tree at HEAD. The audit notes:
    // "ADO build IDs + manifest BLAKE3 + signed chain head + Rekor receipt =
    // four disjoint authorities" — but no audit_records → ADO build evidence
    // path has been wired yet. This is an honest gap.
    WitnessAxis::Missing {
        axis: axis_name.into(),
        invariant: surface.missing_invariant(axis_name),
        detail: "no ADO build evidence path is wired at HEAD; \
                 readiness cannot read build_id + signed manifest digest from audit_records yet"
            .into(),
    }
}

fn assemble_rekor_axis(surface: Surface) -> WitnessAxis {
    let axis_name = "rekor";
    // Rekor receipts are produced by `cortex audit anchor` and live as
    // external-anchor-receipts JSONL adjacent to the event log. The local
    // store does not yet maintain a row pointing the latest Rekor receipt
    // back at a specific chain head — that mapping is still operator-supplied
    // through the existing `--witness-file` path.
    WitnessAxis::Missing {
        axis: axis_name.into(),
        invariant: surface.missing_invariant(axis_name),
        detail: "no Rekor receipt → chain head mapping is wired at HEAD; \
                 readiness cannot derive a fresh Rekor inclusion receipt from the local store"
            .into(),
    }
}

fn assemble_ots_axis(surface: Surface) -> WitnessAxis {
    let axis_name = "ots";
    // OpenTimestamps receipts share the same shape as Rekor: produced by
    // `cortex audit anchor --sink ots --upgrade-receipts`, stored as
    // adjacent JSONL, no local audit_records mapping yet.
    WitnessAxis::Missing {
        axis: axis_name.into(),
        invariant: surface.missing_invariant(axis_name),
        detail: "no OTS receipt → chain head mapping is wired at HEAD; \
                 readiness cannot derive a fresh OpenTimestamps receipt from the local store"
            .into(),
    }
}

/// Emit the standard JSON envelope for a `--from-store` invocation. Both
/// `release readiness --from-store` and `compliance evidence --from-store`
/// share this output shape so downstream tooling can match invariants
/// uniformly.
///
/// The `command` argument is the stable inner `command` token (e.g.
/// `release.readiness.from_store`). The `envelope_command` argument is the
/// stable envelope name (e.g. `cortex.release.readiness`).
pub fn emit_report(
    assembly: &FromStoreAssembly,
    command: &'static str,
    envelope_command: &'static str,
    forbidden_uses: &[&str],
) -> Exit {
    let trusted_artifact_emitted = assembly.all_present;
    let missing_axes: Vec<&str> = [
        &assembly.signed_chain,
        &assembly.ado_build,
        &assembly.rekor,
        &assembly.ots,
    ]
    .iter()
    .filter_map(|axis| match axis {
        WitnessAxis::Missing { axis, .. } => Some(axis.as_str()),
        WitnessAxis::Present { .. } => None,
    })
    .collect();

    let report = serde_json::json!({
        "command": command,
        "mode": "from_store",
        "artifact_emitted": trusted_artifact_emitted,
        "trusted_artifact_emitted": trusted_artifact_emitted,
        "trusted_stdout_artifact": trusted_artifact_emitted,
        "independent_verification": trusted_artifact_emitted,
        "forbidden_uses": forbidden_uses,
        "evidence_input": assembly.report(),
        "missing_witness_axes": missing_axes,
        "all_witness_axes_present": trusted_artifact_emitted,
    });

    let exit = if trusted_artifact_emitted {
        Exit::Ok
    } else {
        Exit::PreconditionUnmet
    };

    if output::json_enabled() {
        let envelope = Envelope::new(envelope_command, exit, report);
        return output::emit(&envelope, exit);
    }
    match serde_json::to_string_pretty(&report) {
        Ok(serialized) => eprintln!("{serialized}"),
        Err(err) => {
            eprintln!("cortex {command}: failed to serialize --from-store report: {err}");
            return Exit::Internal;
        }
    }
    exit
}

fn hex_lower(bytes: &[u8]) -> String {
    const HEX: &[u8; 16] = b"0123456789abcdef";
    let mut out = String::with_capacity(bytes.len() * 2);
    for b in bytes {
        out.push(HEX[(b >> 4) as usize] as char);
        out.push(HEX[(b & 0x0f) as usize] as char);
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn release_invariant_strings_are_stable() {
        assert_eq!(
            release_witness_axis_missing("signed_chain"),
            "release.readiness.from_store.witness_axis_missing.signed_chain"
        );
        assert_eq!(
            release_witness_axis_missing("ado_build"),
            "release.readiness.from_store.witness_axis_missing.ado_build"
        );
        assert_eq!(
            release_witness_axis_missing("rekor"),
            "release.readiness.from_store.witness_axis_missing.rekor"
        );
        assert_eq!(
            release_witness_axis_missing("ots"),
            "release.readiness.from_store.witness_axis_missing.ots"
        );
    }

    #[test]
    fn compliance_invariant_strings_are_stable() {
        assert_eq!(
            compliance_witness_axis_missing("signed_chain"),
            "compliance.evidence.from_store.witness_axis_missing.signed_chain"
        );
        assert_eq!(
            compliance_witness_axis_missing("ado_build"),
            "compliance.evidence.from_store.witness_axis_missing.ado_build"
        );
        assert_eq!(
            compliance_witness_axis_missing("rekor"),
            "compliance.evidence.from_store.witness_axis_missing.rekor"
        );
        assert_eq!(
            compliance_witness_axis_missing("ots"),
            "compliance.evidence.from_store.witness_axis_missing.ots"
        );
    }

    #[test]
    fn missing_axes_are_not_present() {
        let axis = WitnessAxis::Missing {
            axis: "signed_chain".into(),
            invariant: release_witness_axis_missing("signed_chain"),
            detail: "test".into(),
        };
        assert!(!axis.is_present());
        assert_eq!(axis.axis(), "signed_chain");
    }

    #[test]
    fn present_axis_carries_payload() {
        let axis = WitnessAxis::Present {
            axis: "signed_chain".into(),
            detail: "ok".into(),
            payload: serde_json::json!({ "key_id": "abc" }),
        };
        assert!(axis.is_present());
    }
}