Skip to main content

amiss_wire/
report.rs

1use std::collections::BTreeSet;
2
3use strum::{EnumIter, IntoEnumIterator, IntoStaticStr};
4
5use crate::digest::{Digest, hj};
6use crate::json::{Scratch, Sink, Value, canonical, canonical_length};
7use crate::model::Adapter;
8
9pub const ENGINE_CONTRACT: &str = "amiss/scanner";
10
11/// The exact `machine-json-bytes` reservation: the report wire, canonical
12/// envelope plus the trailing newline, never exceeds this.
13pub const MACHINE_JSON_BYTES: u64 = 67_108_864;
14
15/// The evaluator-managed memory ceiling asserted by the sandbox descriptor.
16pub const EVALUATOR_MANAGED_MEMORY_BYTES: u64 = 1_073_741_824;
17
18/// The private temporary-storage ceiling asserted by the sandbox descriptor.
19pub const PRIVATE_TEMPORARY_STORAGE_BYTES: u64 = 67_108_864;
20
21/// The watchdog ceiling asserted by the sandbox descriptor.
22pub const WATCHDOG_MILLISECONDS: u64 = 120_000;
23
24/// The fatal serializer's fixed scratch allowance: the staging buffer it
25/// reserves up front plus every transient allocation one streaming emission
26/// may make. The E0 maximal golden proves emission stays inside it.
27pub const FATAL_SCRATCH_BYTES: usize = 65_536;
28
29/// The streaming fatal-envelope serializer and its fixed scratch space. A
30/// binary reserves one before evaluator allocation accounting begins, so a
31/// fatal projection is always emittable: emission streams `JCS(envelope)`
32/// and the trailing newline through the reserved staging buffer without
33/// materializing the wire.
34pub struct FatalSerializer {
35    staging: Vec<u8>,
36    scratch: Scratch,
37}
38
39impl FatalSerializer {
40    /// Reserves the staging buffer and serializer scratch.
41    #[must_use]
42    pub fn new() -> Self {
43        Self {
44            staging: Vec::with_capacity(FATAL_SCRATCH_BYTES),
45            scratch: Scratch::new(),
46        }
47    }
48
49    /// Streams the envelope's wire (`JCS(envelope) || LF`) into the writer
50    /// through the reserved scratch and returns the byte count.
51    ///
52    /// # Errors
53    ///
54    /// The first writer error; the wire is incomplete in that case and the
55    /// caller treats the emission as failed.
56    pub fn emit(&mut self, envelope: &Value, out: &mut dyn std::io::Write) -> std::io::Result<u64> {
57        self.staging.clear();
58        let mut sink = StagedSink {
59            staging: &mut self.staging,
60            out,
61            written: 0,
62            error: None,
63        };
64        self.scratch.stream(envelope, &mut sink);
65        sink.write("\n");
66        let written = sink.flush();
67        self.staging.clear();
68        written
69    }
70
71    /// The materialized wire for a caller that must inspect the bytes (the
72    /// wrapper's acceptance): one counting pass sizes the allocation
73    /// exactly, then one streaming pass fills it.
74    #[must_use]
75    pub fn wire_bytes(&mut self, envelope: &Value) -> Vec<u8> {
76        let exact = canonical_length(envelope).saturating_add(1);
77        let mut wire = Vec::with_capacity(usize::try_from(exact).unwrap_or(0));
78        if self.emit(envelope, &mut wire).is_err() {
79            wire.clear();
80        }
81        wire
82    }
83}
84
85impl Default for FatalSerializer {
86    fn default() -> Self {
87        Self::new()
88    }
89}
90
91struct StagedSink<'a> {
92    staging: &'a mut Vec<u8>,
93    out: &'a mut dyn std::io::Write,
94    written: u64,
95    error: Option<std::io::Error>,
96}
97
98impl StagedSink<'_> {
99    fn drain(&mut self) {
100        if self.error.is_none() {
101            match self.out.write_all(self.staging) {
102                Ok(()) => {
103                    self.written = self
104                        .written
105                        .saturating_add(u64::try_from(self.staging.len()).unwrap_or(u64::MAX));
106                }
107                Err(defect) => self.error = Some(defect),
108            }
109        }
110        self.staging.clear();
111    }
112
113    fn flush(&mut self) -> std::io::Result<u64> {
114        self.drain();
115        match self.error.take() {
116            Some(defect) => Err(defect),
117            None => Ok(self.written),
118        }
119    }
120}
121
122impl Sink for StagedSink<'_> {
123    fn write(&mut self, piece: &str) {
124        if piece.len() >= FATAL_SCRATCH_BYTES {
125            self.drain();
126            if self.error.is_none() {
127                match self.out.write_all(piece.as_bytes()) {
128                    Ok(()) => {
129                        self.written = self
130                            .written
131                            .saturating_add(u64::try_from(piece.len()).unwrap_or(u64::MAX));
132                    }
133                    Err(defect) => self.error = Some(defect),
134                }
135            }
136            return;
137        }
138        if self.staging.len().saturating_add(piece.len()) > FATAL_SCRATCH_BYTES {
139            self.drain();
140        }
141        self.staging.extend_from_slice(piece.as_bytes());
142    }
143}
144pub const ENGINE_DOMAIN: &str = "amiss/scanner-engine";
145pub const ENVELOPE_SCHEMA: &str = "amiss/scanner-report-envelope";
146pub const PAYLOAD_SCHEMA: &str = "amiss/scanner-report-payload";
147pub const ADAPTER_CONTRACT_SCHEMA: &str = "amiss/scanner-adapter-contract";
148pub const BUILT_IN_POLICY: &str = "scanner-policy-defaults";
149
150/// The closed analysis-error codes in schema declaration order.
151#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, EnumIter)]
152pub enum AnalysisErrorCode {
153    InvalidInvocation,
154    InvalidEvent,
155    InvalidProfile,
156    RequestUnreadable,
157    ConfigurationInvalid,
158    DuplicateJsonKey,
159    InvalidUtf8,
160    InvalidJson,
161    UnknownSchema,
162    UnknownField,
163    NoncanonicalArray,
164    DigestMismatch,
165    ControlBindingMismatch,
166    ExceptionOverlap,
167    UnsupportedCapability,
168    GitRepositoryUnavailable,
169    GitObjectMissing,
170    GitObjectWrongKind,
171    GitObjectUnreadable,
172    GitIndexInvalid,
173    GitIndexUnmerged,
174    GitIntentToAdd,
175    GitSnapshotChanged,
176    UnrepresentablePath,
177    DocumentInvalid,
178    ParserError,
179    ParserPanic,
180    InvalidSourceSpan,
181    ResolutionError,
182    ResourceLimitExceeded,
183    OutputLimitExceeded,
184    TooManyErrors,
185    ReportConstructionFailed,
186    SandboxViolation,
187    TrustedTimeInvalid,
188    InternalError,
189}
190
191impl AnalysisErrorCode {
192    /// Every analysis-error code in schema declaration order.
193    #[must_use]
194    pub fn all() -> impl ExactSizeIterator<Item = Self> {
195        Self::iter()
196    }
197
198    /// One fixed engine-owned sentence per code: what blocked the run and how
199    /// to unblock it. Printed by the human projection as a `note` line and
200    /// rendered into the documentation from this same text.
201    #[must_use]
202    pub const fn meaning(self) -> &'static str {
203        match self {
204            Self::InvalidInvocation => {
205                "the command line does not match the closed grammar; each documented option appears at most once and nothing else is accepted"
206            }
207            Self::InvalidEvent => {
208                "the declared repository, ref, or default-branch identity is not in canonical form; pass a lowercase owner and name and full refs/heads/ references"
209            }
210            Self::InvalidProfile => "the profile is not one of observe or enforce",
211            Self::RequestUnreadable => {
212                "the machine evaluation request bytes could not be read; nothing was evaluated"
213            }
214            Self::ConfigurationInvalid => {
215                "a policy or control input violates its schema; one unknown field or malformed value makes the whole file invalid rather than partly honored"
216            }
217            Self::DuplicateJsonKey => {
218                "a JSON input repeats an object key; strict parsing refuses the file instead of choosing one of the values"
219            }
220            Self::InvalidUtf8 => "a JSON input carries bytes that are not UTF-8",
221            Self::InvalidJson => "an input that must be JSON does not parse as strict JSON",
222            Self::UnknownSchema => {
223                "a JSON input declares a schema identifier this engine does not recognize"
224            }
225            Self::UnknownField => {
226                "a JSON input carries a field its closed schema does not define; unknown fields refuse rather than pass through unread"
227            }
228            Self::NoncanonicalArray => {
229                "a JSON input array violates its required canonical ordering or uniqueness"
230            }
231            Self::DigestMismatch => {
232                "a digest carried by an input does not match the bytes it names; the input is stale or altered"
233            }
234            Self::ControlBindingMismatch => {
235                "an external control is bound to a different repository, ref, or run identity than this evaluation; nothing is applied and the run ends incomplete"
236            }
237            Self::ExceptionOverlap => {
238                "accepted exception items select the same finding more than once; overlap ends evaluation incomplete instead of double-suppressing"
239            }
240            Self::UnsupportedCapability => {
241                "a candidate document declares a reserved amiss: capability this engine does not implement; the run ends incomplete rather than guessing at the claim"
242            }
243            Self::GitRepositoryUnavailable => {
244                "the --repo path does not open as a Git repository of the declared object format"
245            }
246            Self::GitObjectMissing => {
247                "a commit, tree, or blob the run needs is absent from the object store; fetch full history or name commits the store holds"
248            }
249            Self::GitObjectWrongKind => {
250                "a Git object is not the kind its use requires, as when a named commit resolves to another type"
251            }
252            Self::GitObjectUnreadable => "a Git object exists but its bytes cannot be decoded",
253            Self::GitIndexInvalid => "the staged index file does not parse under the index grammar",
254            Self::GitIndexUnmerged => {
255                "the index holds unmerged conflict entries, so no single staged state exists; finish or abort the merge before checking the index"
256            }
257            Self::GitIntentToAdd => {
258                "the index holds an intent-to-add entry whose content is not staged; stage the file or drop the intent entry before checking the index"
259            }
260            Self::GitSnapshotChanged => {
261                "the staged index changed while the run was reading it; rerun when the repository is quiet"
262            }
263            Self::UnrepresentablePath => {
264                "a tree or index name is outside the path grammar, a backslash, a NUL, or a dot segment; the exact bytes are disclosed as hex"
265            }
266            Self::DocumentInvalid => {
267                "a discovered document's bytes cannot be decoded as its format requires; the run refuses instead of skipping the file and passing"
268            }
269            Self::ParserError => {
270                "the pinned parser failed on a document; the document is named and the run is incomplete rather than the file silently dropped"
271            }
272            Self::ParserPanic => {
273                "the pinned parser panicked on a document; the panic is caught and reported, and the run is incomplete"
274            }
275            Self::InvalidSourceSpan => {
276                "the parser returned a node whose byte span does not address the document; the parse is not trusted"
277            }
278            Self::ResolutionError => {
279                "reference resolution failed internally; the run ends incomplete rather than reporting around the gap"
280            }
281            Self::ResourceLimitExceeded => {
282                "a named resource crossed its ceiling; the row carries the resource, the configured limit, and the observed lower bound"
283            }
284            Self::OutputLimitExceeded => {
285                "the serialized report would cross the machine-json-bytes ceiling; the run ends incomplete instead of shortening the findings"
286            }
287            Self::TooManyErrors => {
288                "more distinct analysis errors accumulated than the retention ceiling; the lowest-keyed rows are kept and this sentinel stands for the rest"
289            }
290            Self::ReportConstructionFailed => {
291                "the report could not be constructed or emitted; the run has no trustworthy output"
292            }
293            Self::SandboxViolation => {
294                "the run breached its sandbox descriptor; the result is not trustworthy"
295            }
296            Self::TrustedTimeInvalid => {
297                "a control that needs trusted time has no statement that verifies, absent or failing its binding; the run will not act on an unverified clock"
298            }
299            Self::InternalError => {
300                "an engine invariant failed; this is a defect in Amiss, not in the input, and the run has no trustworthy result"
301            }
302        }
303    }
304
305    #[must_use]
306    pub const fn as_str(self) -> &'static str {
307        match self {
308            Self::InvalidInvocation => "INVALID_INVOCATION",
309            Self::InvalidEvent => "INVALID_EVENT",
310            Self::InvalidProfile => "INVALID_PROFILE",
311            Self::RequestUnreadable => "REQUEST_UNREADABLE",
312            Self::ConfigurationInvalid => "CONFIGURATION_INVALID",
313            Self::DuplicateJsonKey => "DUPLICATE_JSON_KEY",
314            Self::InvalidUtf8 => "INVALID_UTF8",
315            Self::InvalidJson => "INVALID_JSON",
316            Self::UnknownSchema => "UNKNOWN_SCHEMA",
317            Self::UnknownField => "UNKNOWN_FIELD",
318            Self::NoncanonicalArray => "NONCANONICAL_ARRAY",
319            Self::DigestMismatch => "DIGEST_MISMATCH",
320            Self::ControlBindingMismatch => "CONTROL_BINDING_MISMATCH",
321            Self::ExceptionOverlap => "EXCEPTION_OVERLAP",
322            Self::UnsupportedCapability => "UNSUPPORTED_CAPABILITY",
323            Self::GitRepositoryUnavailable => "GIT_REPOSITORY_UNAVAILABLE",
324            Self::GitObjectMissing => "GIT_OBJECT_MISSING",
325            Self::GitObjectWrongKind => "GIT_OBJECT_WRONG_KIND",
326            Self::GitObjectUnreadable => "GIT_OBJECT_UNREADABLE",
327            Self::GitIndexInvalid => "GIT_INDEX_INVALID",
328            Self::GitIndexUnmerged => "GIT_INDEX_UNMERGED",
329            Self::GitIntentToAdd => "GIT_INTENT_TO_ADD",
330            Self::GitSnapshotChanged => "GIT_SNAPSHOT_CHANGED",
331            Self::UnrepresentablePath => "UNREPRESENTABLE_PATH",
332            Self::DocumentInvalid => "DOCUMENT_INVALID",
333            Self::ParserError => "PARSER_ERROR",
334            Self::ParserPanic => "PARSER_PANIC",
335            Self::InvalidSourceSpan => "INVALID_SOURCE_SPAN",
336            Self::ResolutionError => "RESOLUTION_ERROR",
337            Self::ResourceLimitExceeded => "RESOURCE_LIMIT_EXCEEDED",
338            Self::OutputLimitExceeded => "OUTPUT_LIMIT_EXCEEDED",
339            Self::TooManyErrors => "TOO_MANY_ERRORS",
340            Self::ReportConstructionFailed => "REPORT_CONSTRUCTION_FAILED",
341            Self::SandboxViolation => "SANDBOX_VIOLATION",
342            Self::TrustedTimeInvalid => "TRUSTED_TIME_INVALID",
343            Self::InternalError => "INTERNAL_ERROR",
344        }
345    }
346
347    /// Fixed phase for non-resource codes; `RESOURCE_LIMIT_EXCEEDED` takes its
348    /// phase from the resource partition and has none here.
349    #[must_use]
350    pub const fn fixed_phase(self) -> Option<&'static str> {
351        match self {
352            Self::InvalidInvocation
353            | Self::InvalidEvent
354            | Self::InvalidProfile
355            | Self::RequestUnreadable => Some("invocation"),
356            Self::ConfigurationInvalid
357            | Self::DuplicateJsonKey
358            | Self::InvalidUtf8
359            | Self::InvalidJson
360            | Self::UnknownSchema
361            | Self::UnknownField
362            | Self::NoncanonicalArray
363            | Self::DigestMismatch
364            | Self::ControlBindingMismatch
365            | Self::ExceptionOverlap
366            | Self::TrustedTimeInvalid => Some("configuration"),
367            Self::GitRepositoryUnavailable
368            | Self::GitObjectMissing
369            | Self::GitObjectWrongKind
370            | Self::GitObjectUnreadable
371            | Self::GitIndexInvalid
372            | Self::GitIndexUnmerged
373            | Self::GitIntentToAdd
374            | Self::GitSnapshotChanged
375            | Self::UnrepresentablePath => Some("git"),
376            Self::DocumentInvalid
377            | Self::ParserError
378            | Self::ParserPanic
379            | Self::InvalidSourceSpan => Some("parse"),
380            Self::ResolutionError => Some("resolution"),
381            Self::UnsupportedCapability => Some("policy"),
382            Self::OutputLimitExceeded | Self::ReportConstructionFailed => Some("output"),
383            Self::SandboxViolation | Self::TooManyErrors | Self::InternalError => Some("internal"),
384            Self::ResourceLimitExceeded => None,
385        }
386    }
387
388    const fn evaluation_reason(self) -> Option<&'static str> {
389        match self {
390            Self::InvalidInvocation => Some("invalid-invocation"),
391            Self::InvalidEvent => Some("invalid-event"),
392            Self::InvalidProfile => Some("invalid-profile"),
393            Self::RequestUnreadable => Some("request-unreadable"),
394            Self::ConfigurationInvalid
395            | Self::DuplicateJsonKey
396            | Self::InvalidUtf8
397            | Self::InvalidJson
398            | Self::UnknownSchema
399            | Self::UnknownField
400            | Self::NoncanonicalArray
401            | Self::DigestMismatch
402            | Self::ControlBindingMismatch
403            | Self::ExceptionOverlap
404            | Self::UnsupportedCapability
405            | Self::GitRepositoryUnavailable
406            | Self::GitObjectMissing
407            | Self::GitObjectWrongKind
408            | Self::GitObjectUnreadable
409            | Self::GitIndexInvalid
410            | Self::GitIndexUnmerged
411            | Self::GitIntentToAdd
412            | Self::GitSnapshotChanged
413            | Self::UnrepresentablePath
414            | Self::DocumentInvalid
415            | Self::ParserError
416            | Self::ParserPanic
417            | Self::InvalidSourceSpan
418            | Self::ResolutionError
419            | Self::ResourceLimitExceeded
420            | Self::OutputLimitExceeded
421            | Self::TooManyErrors
422            | Self::ReportConstructionFailed
423            | Self::SandboxViolation
424            | Self::TrustedTimeInvalid
425            | Self::InternalError => None,
426        }
427    }
428}
429
430#[derive(Clone, Debug, PartialEq, Eq)]
431pub struct EngineProvenance {
432    pub version: String,
433    pub digest: Digest,
434}
435
436/// Builds the canonical fatal-incomplete wire (`JCS(envelope) || LF`) for an
437/// invocation rejection: every detail array empty, every count zero, unavailable
438/// evaluation and controls with their reason sets, exit class 2.
439///
440/// Returns `None` when `codes` is empty or contains a non-invocation code.
441#[must_use]
442pub fn invocation_failure_wire(
443    engine: &EngineProvenance,
444    codes: &BTreeSet<AnalysisErrorCode>,
445) -> Option<Vec<u8>> {
446    unavailable_evaluation_wire(engine, codes, None, None)
447}
448
449/// The envelope value behind [`invocation_failure_wire`], for emission
450/// through the reserved fatal serializer.
451#[must_use]
452pub fn invocation_failure_envelope(
453    engine: &EngineProvenance,
454    codes: &BTreeSet<AnalysisErrorCode>,
455) -> Option<Value> {
456    unavailable_evaluation_envelope(engine, codes, None, None)
457}
458
459/// The fatal unavailable-evaluation envelope for the request-wire lane: the
460/// same closed projection, carrying each request's diagnostic digest where
461/// its byte stream was completely captured.
462///
463/// Returns `None` when no code is supplied or a code has no evaluation
464/// reason, exactly as the invocation form.
465#[must_use]
466pub fn unavailable_evaluation_wire(
467    engine: &EngineProvenance,
468    codes: &BTreeSet<AnalysisErrorCode>,
469    evaluation_request_digest: Option<Digest>,
470    controls_request_digest: Option<Digest>,
471) -> Option<Vec<u8>> {
472    let envelope = unavailable_evaluation_envelope(
473        engine,
474        codes,
475        evaluation_request_digest,
476        controls_request_digest,
477    )?;
478    let mut wire = canonical(&envelope);
479    wire.push(b'\n');
480    Some(wire)
481}
482
483/// The envelope value behind [`unavailable_evaluation_wire`], for emission
484/// through the reserved fatal serializer.
485#[must_use]
486pub fn unavailable_evaluation_envelope(
487    engine: &EngineProvenance,
488    codes: &BTreeSet<AnalysisErrorCode>,
489    evaluation_request_digest: Option<Digest>,
490    controls_request_digest: Option<Digest>,
491) -> Option<Value> {
492    if codes.is_empty() {
493        return None;
494    }
495    let mut reasons = Vec::new();
496    for code in codes {
497        reasons.push(Value::String(code.evaluation_reason()?.to_owned()));
498    }
499
500    let mut errors: Vec<(AnalysisErrorCode, &'static str)> = Vec::new();
501    for code in codes {
502        errors.push((*code, code.fixed_phase()?));
503    }
504    errors.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()));
505    let error_rows: Vec<Value> = errors
506        .iter()
507        .map(|(code, phase)| {
508            error_row(
509                &ErrorDetail {
510                    code: *code,
511                    path: None,
512                    path_bytes: None,
513                    resource: None,
514                },
515                phase,
516            )
517        })
518        .collect();
519    let error_count = i64::try_from(error_rows.len()).ok()?;
520
521    let payload = object(vec![
522        ("schema", string(PAYLOAD_SCHEMA)),
523        ("compatibility", string("experimental")),
524        ("engine", engine_block(engine)),
525        (
526            "evaluation",
527            object(vec![
528                ("status", string("unavailable")),
529                (
530                    "request_digest",
531                    evaluation_request_digest
532                        .map_or(Value::Null, |digest| string(&digest.to_string())),
533                ),
534                ("reasons", Value::Array(reasons)),
535            ]),
536        ),
537        (
538            "controls",
539            object(vec![
540                ("status", string("unavailable")),
541                (
542                    "request_digest",
543                    controls_request_digest
544                        .map_or(Value::Null, |digest| string(&digest.to_string())),
545                ),
546                ("reasons", Value::Array(vec![string("not-parsed")])),
547            ]),
548        ),
549        (
550            "result",
551            object(vec![
552                ("complete", Value::Bool(false)),
553                ("status", string("incomplete")),
554                ("exit_code", Value::Integer(2)),
555                ("finding_count", Value::Integer(0)),
556                ("error_count", Value::Integer(error_count)),
557            ]),
558        ),
559        ("summary", zero_summary()),
560        ("documents", Value::Array(Vec::new())),
561        ("observations", Value::Array(Vec::new())),
562        ("findings", Value::Array(Vec::new())),
563        ("errors", Value::Array(error_rows)),
564    ]);
565
566    let payload_digest = hj(PAYLOAD_SCHEMA, &payload);
567    Some(object(vec![
568        ("schema", string(ENVELOPE_SCHEMA)),
569        ("payload", payload),
570        ("payload_digest", string(&payload_digest.to_string())),
571    ]))
572}
573
574/// One adapter's complete contract descriptor and its digest, which every
575/// occurrence embeds through its observation-identity input.
576#[must_use]
577pub fn adapter_contract(engine: &EngineProvenance, adapter: Adapter) -> (Value, Digest) {
578    let descriptor = object(vec![
579        ("schema", string(ADAPTER_CONTRACT_SCHEMA)),
580        ("adapter_id", string(adapter.adapter_id())),
581        ("parser_name", string(adapter.parser_name())),
582        ("parser_version", string(&engine.version)),
583        ("grammar_profile", string(adapter.grammar_profile())),
584        (
585            "frontmatter_contract",
586            string(adapter.frontmatter_contract()),
587        ),
588        ("source_projection", string(adapter.source_projection())),
589        ("structural_address", string(adapter.structural_address())),
590    ]);
591    let digest = hj(ADAPTER_CONTRACT_SCHEMA, &descriptor);
592    (descriptor, digest)
593}
594
595/// The complete engine block: contract, version, digest, provenance, policy
596/// version, and the three adapter descriptors with their digests.
597#[must_use]
598pub fn engine_block(engine: &EngineProvenance) -> Value {
599    let adapter_rows: Vec<Value> = Adapter::all()
600        .map(|adapter| {
601            let (descriptor, digest) = adapter_contract(engine, adapter);
602            object(vec![
603                ("adapter_id", string(adapter.adapter_id())),
604                ("contract_descriptor", descriptor),
605                ("contract_digest", string(&digest.to_string())),
606            ])
607        })
608        .collect();
609    object(vec![
610        ("engine_contract", string(ENGINE_CONTRACT)),
611        ("engine_version", string(&engine.version)),
612        ("engine_digest", string(&engine.digest.to_string())),
613        ("action_provenance", object(vec![("kind", string("local"))])),
614        ("built_in_policy", string(BUILT_IN_POLICY)),
615        ("adapters", Value::Array(adapter_rows)),
616    ])
617}
618
619fn zero_summary() -> Value {
620    let documents = [
621        "discovered",
622        "outside_document_set",
623        "scanned",
624        "unsupported",
625        "excluded_builtin",
626        "unlinked",
627        "frontmatter_documents",
628        "opaque_mdx_documents",
629        "opaque_html_documents",
630        "opaque_mdx_regions",
631        "opaque_mdx_bytes",
632        "opaque_html_regions",
633        "opaque_html_bytes",
634        "frontmatter_regions",
635        "frontmatter_bytes",
636    ];
637    let references = [
638        "extracted",
639        "explicit_local",
640        "same_repository",
641        "external_out_of_scope",
642        "unsupported",
643        "resolved",
644        "missing",
645    ];
646    let findings = [
647        "total",
648        "record",
649        "warn",
650        "fail",
651        "introduced",
652        "pre_existing",
653        "resolved",
654        "unknown",
655        "not_applicable",
656        "debt_tolerated",
657        "waived",
658        "analysis_errors",
659        "unsupported_capabilities",
660    ];
661    object(vec![
662        ("counts_complete", Value::Bool(false)),
663        ("documents", zero_counts(&documents)),
664        ("references", zero_counts(&references)),
665        ("findings", zero_counts(&findings)),
666        ("human_details_truncated", Value::Integer(0)),
667        ("governed_claims", Value::Integer(0)),
668        ("unattested_claims", Value::Integer(0)),
669    ])
670}
671
672fn zero_counts(fields: &[&str]) -> Value {
673    Value::Object(
674        fields
675            .iter()
676            .map(|field| ((*field).to_owned(), Value::Integer(0)))
677            .collect(),
678    )
679}
680
681fn object(members: Vec<(&str, Value)>) -> Value {
682    Value::Object(
683        members
684            .into_iter()
685            .map(|(key, value)| (key.to_owned(), value))
686            .collect(),
687    )
688}
689
690fn string(value: &str) -> Value {
691    Value::String(value.to_owned())
692}
693
694/// The target-intent variants an occurrence can carry, in schema
695/// declaration order.
696#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
697pub enum IntentKind {
698    RepositoryPath,
699    SameRepositoryGithub,
700    SameRepositoryGitlab,
701    SameRepositoryGitea,
702    ExternalUrl,
703    SiteRoute,
704    Unsupported,
705}
706
707impl IntentKind {
708    #[must_use]
709    pub const fn as_str(self) -> &'static str {
710        match self {
711            Self::RepositoryPath => "repository-path",
712            Self::SameRepositoryGithub => "same-repository-github",
713            Self::SameRepositoryGitlab => "same-repository-gitlab",
714            Self::SameRepositoryGitea => "same-repository-gitea",
715            Self::ExternalUrl => "external-url",
716            Self::SiteRoute => "site-route",
717            Self::Unsupported => "unsupported",
718        }
719    }
720}
721
722/// The four finding scopes.
723#[derive(Clone, Copy, Debug, PartialEq, Eq)]
724pub enum FindingScope {
725    Reference,
726    Observation,
727    Document,
728    Control,
729}
730
731/// The closed disposition values a policy step can produce.
732#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
733pub enum Disposition {
734    Record,
735    Warn,
736    Fail,
737}
738
739impl Disposition {
740    #[must_use]
741    pub const fn as_str(self) -> &'static str {
742        match self {
743            Self::Record => "record",
744            Self::Warn => "warn",
745            Self::Fail => "fail",
746        }
747    }
748}
749
750/// The complete closed finding taxonomy, in schema declaration order.
751#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, EnumIter, IntoStaticStr)]
752#[strum(serialize_all = "kebab-case")]
753pub enum FindingKind {
754    ExplicitTargetMissing,
755    ExplicitTargetTypeMismatch,
756    InvalidReference,
757    UnsupportedReferenceSemantics,
758    UnsupportedDocumentFormat,
759    UnsupportedTargetKind,
760    UnsupportedVersionScope,
761    UnsupportedCapability,
762    DependencyChangedSubjectUnchanged,
763    DependencyAndSubjectCochanged,
764    SubjectChanged,
765    ExplicitReferenceRemoved,
766    DocumentRemoved,
767    ExternalOutOfScope,
768    OpaqueMdxRegion,
769    OpaqueHtmlRegion,
770    ObservationCorrelationAmbiguous,
771    UnlinkedDocument,
772    PolicyWeakened,
773    CoverageReduced,
774    ControlPlaneChanged,
775    DebtWorsened,
776    DebtExpired,
777    WaiverInvalid,
778}
779
780impl FindingKind {
781    /// Every finding kind in schema declaration order.
782    #[must_use]
783    pub fn all() -> impl ExactSizeIterator<Item = Self> {
784        Self::iter()
785    }
786
787    #[must_use]
788    pub fn as_str(self) -> &'static str {
789        self.into()
790    }
791
792    /// The closed key-scope assignment.
793    #[must_use]
794    pub const fn scope(self) -> FindingScope {
795        match self {
796            Self::ExplicitTargetMissing | Self::ExplicitTargetTypeMismatch => {
797                FindingScope::Reference
798            }
799            Self::InvalidReference
800            | Self::UnsupportedReferenceSemantics
801            | Self::UnsupportedTargetKind
802            | Self::UnsupportedVersionScope
803            | Self::DependencyChangedSubjectUnchanged
804            | Self::DependencyAndSubjectCochanged
805            | Self::SubjectChanged
806            | Self::ExplicitReferenceRemoved
807            | Self::ExternalOutOfScope
808            | Self::ObservationCorrelationAmbiguous => FindingScope::Observation,
809            Self::UnsupportedDocumentFormat
810            | Self::DocumentRemoved
811            | Self::OpaqueMdxRegion
812            | Self::OpaqueHtmlRegion
813            | Self::UnlinkedDocument => FindingScope::Document,
814            Self::UnsupportedCapability
815            | Self::PolicyWeakened
816            | Self::CoverageReduced
817            | Self::ControlPlaneChanged
818            | Self::DebtWorsened
819            | Self::DebtExpired
820            | Self::WaiverInvalid => FindingScope::Control,
821        }
822    }
823
824    /// The first policy-step result for a candidate fact under
825    /// `scanner-policy-defaults`, per profile.
826    #[must_use]
827    pub const fn built_in_disposition(self, enforce: bool) -> Disposition {
828        match self {
829            Self::ExplicitTargetMissing
830            | Self::ExplicitTargetTypeMismatch
831            | Self::InvalidReference => {
832                if enforce {
833                    Disposition::Fail
834                } else {
835                    Disposition::Warn
836                }
837            }
838            Self::UnsupportedCapability
839            | Self::PolicyWeakened
840            | Self::CoverageReduced
841            | Self::ControlPlaneChanged
842            | Self::DebtWorsened
843            | Self::DebtExpired
844            | Self::WaiverInvalid => Disposition::Fail,
845            Self::DependencyChangedSubjectUnchanged | Self::ExplicitReferenceRemoved => {
846                Disposition::Warn
847            }
848            Self::UnsupportedReferenceSemantics
849            | Self::UnsupportedDocumentFormat
850            | Self::UnsupportedTargetKind
851            | Self::UnsupportedVersionScope
852            | Self::DependencyAndSubjectCochanged
853            | Self::SubjectChanged
854            | Self::DocumentRemoved
855            | Self::ExternalOutOfScope
856            | Self::OpaqueMdxRegion
857            | Self::OpaqueHtmlRegion
858            | Self::ObservationCorrelationAmbiguous
859            | Self::UnlinkedDocument => Disposition::Record,
860        }
861    }
862}
863
864pub const SANDBOX_SCHEMA: &str = "amiss/scanner-sandbox-profile";
865
866/// The zero-capability sandbox descriptor the engine asserts for itself, and
867/// its digest. A future wrapper verifies rather than asserts it.
868#[must_use]
869pub fn sandbox_descriptor() -> (Value, Digest) {
870    let descriptor = object(vec![
871        ("schema", string(SANDBOX_SCHEMA)),
872        ("profile", string("scanner-zero-capability")),
873        ("isolation", string("process")),
874        ("network", string("denied")),
875        ("child_processes", string("denied")),
876        ("repository_processes", string("denied")),
877        ("credentials", string("absent")),
878        ("secrets", string("absent")),
879        ("shared_cache", string("denied")),
880        ("workspace", string("read-only")),
881        ("environment", string("scanner-process-env")),
882        (
883            "physical_memory",
884            object(vec![(
885                "maximum_bytes",
886                Value::Integer(i64::try_from(EVALUATOR_MANAGED_MEMORY_BYTES).unwrap_or(i64::MAX)),
887            )]),
888        ),
889        (
890            "temporary_storage",
891            object(vec![
892                ("kind", string("private-bounded")),
893                (
894                    "maximum_bytes",
895                    Value::Integer(
896                        i64::try_from(PRIVATE_TEMPORARY_STORAGE_BYTES).unwrap_or(i64::MAX),
897                    ),
898                ),
899            ]),
900        ),
901        (
902            "watchdog",
903            object(vec![(
904                "maximum_milliseconds",
905                Value::Integer(i64::try_from(WATCHDOG_MILLISECONDS).unwrap_or(i64::MAX)),
906            )]),
907        ),
908    ]);
909    let digest = hj(SANDBOX_SCHEMA, &descriptor);
910    (descriptor, digest)
911}
912
913impl FindingKind {
914    #[must_use]
915    pub const fn evidence_class(self) -> &'static str {
916        match self {
917            Self::ExplicitTargetMissing
918            | Self::ExplicitTargetTypeMismatch
919            | Self::InvalidReference => "deterministic-structural",
920            Self::UnsupportedCapability
921            | Self::UnsupportedReferenceSemantics
922            | Self::UnsupportedDocumentFormat
923            | Self::UnsupportedTargetKind
924            | Self::UnsupportedVersionScope => "unsupported",
925            Self::DependencyChangedSubjectUnchanged
926            | Self::DependencyAndSubjectCochanged
927            | Self::SubjectChanged => "impact-observation",
928            Self::ExplicitReferenceRemoved
929            | Self::DocumentRemoved
930            | Self::ExternalOutOfScope
931            | Self::OpaqueMdxRegion
932            | Self::OpaqueHtmlRegion
933            | Self::ObservationCorrelationAmbiguous
934            | Self::UnlinkedDocument => "coverage-boundary",
935            Self::PolicyWeakened
936            | Self::CoverageReduced
937            | Self::ControlPlaneChanged
938            | Self::DebtWorsened
939            | Self::DebtExpired
940            | Self::WaiverInvalid => "control-plane",
941        }
942    }
943
944    /// One fixed engine-owned sentence per kind: what the finding means and
945    /// what to do about it. The human projection prints it as a `note` line
946    /// and the documentation renders the same text, so the sentence a reader
947    /// meets in a CI log is the sentence the book teaches.
948    #[must_use]
949    pub const fn meaning(self) -> &'static str {
950        match self {
951            Self::ExplicitTargetMissing => {
952                "a reference names a repository path, or a line range inside one, that the named tree does not hold; restore the target or correct the link"
953            }
954            Self::ExplicitTargetTypeMismatch => {
955                "the referenced path exists as a different kind than the reference promises, as when a trailing slash names a regular file; make the spelling match the target"
956            }
957            Self::InvalidReference => {
958                "the destination cannot name a repository target: it escapes the repository or carries a backslash, an encoded separator, or control bytes; fix the destination"
959            }
960            Self::UnsupportedReferenceSemantics => {
961                "the reference uses semantics Amiss does not evaluate, a heading fragment or a leading-slash site route; the unchecked part is declared instead of guessed"
962            }
963            Self::UnsupportedDocumentFormat => {
964                "a policy-included document has no parser in this engine; it is discovered and counted, and its content is never scanned"
965            }
966            Self::UnsupportedTargetKind => {
967                "the reference resolves to a symlink or submodule, which Amiss does not follow; the boundary is declared instead of crossed"
968            }
969            Self::UnsupportedVersionScope => {
970                "a forge URL names this repository at another version, a different branch, tag, or commit; only the candidate version is read, so the link is recognized and left unresolved"
971            }
972            Self::UnsupportedCapability => {
973                "a candidate document declares a reserved amiss: capability this engine does not implement; the run ends incomplete rather than guessing at the claim"
974            }
975            Self::DependencyChangedSubjectUnchanged => {
976                "the referenced content changed and the block citing it did not; a reason for a person to reread the prose, never a machine verdict that it is wrong"
977            }
978            Self::DependencyAndSubjectCochanged => {
979                "the referenced content and the block citing it changed together, the shape of a maintained page; recorded with nothing to act on"
980            }
981            Self::SubjectChanged => {
982                "the block holding the reference changed while its target did not; recorded so prose moving over an unchanged dependency stays visible"
983            }
984            Self::ExplicitReferenceRemoved => {
985                "a reference that existed in the base is gone from the candidate; removal may be deliberate, so this warns for review instead of blocking"
986            }
987            Self::DocumentRemoved => {
988                "a scanned document left the tree; recorded so the disappearance is a stated fact rather than a silent one"
989            }
990            Self::ExternalOutOfScope => {
991                "the destination is an external URL Amiss never fetches; counted, reported, and left alone"
992            }
993            Self::OpaqueMdxRegion => {
994                "an MDX expression region the parser cannot see into; a reference inside it is a stated blind spot, reported with size and place"
995            }
996            Self::OpaqueHtmlRegion => {
997                "a raw HTML region the parser cannot see into; a reference inside it is a stated blind spot, reported with size and place"
998            }
999            Self::ObservationCorrelationAmbiguous => {
1000                "an occurrence has more than one plausible counterpart across the comparison; Amiss never chooses by input order, so the match is recorded as undecided"
1001            }
1002            Self::UnlinkedDocument => {
1003                "a scanned document from which zero references were extracted; despite the name, it claims nothing about inbound links from other pages"
1004            }
1005            Self::PolicyWeakened => {
1006                "the candidate loosens its own repository policy, dropping an include, a protected path, or a raised disposition; loosening the rules is reported under the rules being loosened"
1007            }
1008            Self::CoverageReduced => {
1009                "a protected path is gone or not a scannable document while its protection stands; restore it or amend the protection in a reviewed change"
1010            }
1011            Self::ControlPlaneChanged => {
1012                "a floor-protected control path is not the identical present blob on both sides, in mode and content; the floor exists so control edits are always visible"
1013            }
1014            Self::DebtWorsened => {
1015                "the finding an accepted debt item names no longer matches the recorded fact; debt tolerates exactly the recorded state, so any drift fails"
1016            }
1017            Self::DebtExpired => {
1018                "trusted time reached a debt item's expiry while its finding persists; fix the finding or renew the debt in a reviewed change"
1019            }
1020            Self::WaiverInvalid => {
1021                "a waiver item cannot apply, expired against trusted time or issued outside the floor's authority; an invalid waiver suppresses nothing"
1022            }
1023        }
1024    }
1025
1026    #[must_use]
1027    pub const fn invariant_class(self) -> &'static str {
1028        match self {
1029            Self::ExplicitTargetMissing
1030            | Self::ExplicitTargetTypeMismatch
1031            | Self::InvalidReference => "ratcheted",
1032            Self::UnsupportedCapability => "analysis-integrity",
1033            Self::UnsupportedReferenceSemantics
1034            | Self::UnsupportedDocumentFormat
1035            | Self::UnsupportedTargetKind
1036            | Self::UnsupportedVersionScope
1037            | Self::DependencyChangedSubjectUnchanged
1038            | Self::DependencyAndSubjectCochanged
1039            | Self::SubjectChanged
1040            | Self::ExplicitReferenceRemoved
1041            | Self::DocumentRemoved
1042            | Self::ExternalOutOfScope
1043            | Self::OpaqueMdxRegion
1044            | Self::OpaqueHtmlRegion
1045            | Self::ObservationCorrelationAmbiguous
1046            | Self::UnlinkedDocument => "advisory",
1047            Self::PolicyWeakened
1048            | Self::CoverageReduced
1049            | Self::ControlPlaneChanged
1050            | Self::DebtWorsened
1051            | Self::DebtExpired
1052            | Self::WaiverInvalid => "absolute",
1053        }
1054    }
1055}
1056
1057/// One typed analysis error's reportable detail: the code, the exact path
1058/// where the partition names one, the raw bytes of a name the report cannot
1059/// hold as text, and the crossing triple for a resource error. Field order
1060/// is the canonical error key, so the derived ordering is the wire's.
1061#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
1062pub struct ErrorDetail {
1063    pub code: AnalysisErrorCode,
1064    pub path: Option<crate::model::RepoPath>,
1065    pub path_bytes: Option<Vec<u8>>,
1066    pub resource: Option<(crate::controls::ResourceName, u64, u64)>,
1067}
1068
1069impl ErrorDetail {
1070    #[must_use]
1071    pub fn phase(&self) -> &'static str {
1072        self.resource.map_or_else(
1073            || self.code.fixed_phase().unwrap_or("internal"),
1074            |(name, _limit, _observed)| name.phase(),
1075        )
1076    }
1077}
1078
1079/// One wire error row with its partition phase.
1080#[must_use]
1081pub fn error_row_value(detail: &ErrorDetail) -> Value {
1082    error_row(detail, detail.phase())
1083}
1084
1085fn error_row(detail: &ErrorDetail, phase: &str) -> Value {
1086    let (resource, limit, observed) = detail.resource.map_or(
1087        (Value::Null, Value::Null, Value::Null),
1088        |(name, limit, observed)| {
1089            (
1090                string(name.as_str()),
1091                Value::Integer(i64::try_from(limit).unwrap_or(i64::MAX)),
1092                Value::Integer(i64::try_from(observed).unwrap_or(i64::MAX)),
1093            )
1094        },
1095    );
1096    object(vec![
1097        ("phase", string(phase)),
1098        ("code", string(detail.code.as_str())),
1099        ("description", string(detail.code.meaning())),
1100        (
1101            "path",
1102            detail
1103                .path
1104                .as_ref()
1105                .map_or(Value::Null, crate::model::RepoPath::to_value),
1106        ),
1107        (
1108            "path_bytes_hex",
1109            detail.path_bytes.as_deref().map_or(Value::Null, |bytes| {
1110                Value::String(crate::model::hex_lower(bytes))
1111            }),
1112        ),
1113        ("resource", resource),
1114        ("configured_limit", limit),
1115        ("observed_lower_bound", observed),
1116    ])
1117}