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 = 268_435_456;
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        ("feedback", object(vec![("status", string("unavailable"))])),
550        (
551            "result",
552            object(vec![
553                ("complete", Value::Bool(false)),
554                ("status", string("incomplete")),
555                ("exit_code", Value::Integer(2)),
556                ("finding_count", Value::Integer(0)),
557                ("error_count", Value::Integer(error_count)),
558            ]),
559        ),
560        ("summary", zero_summary()),
561        ("documents", Value::Array(Vec::new())),
562        ("observations", Value::Array(Vec::new())),
563        ("findings", Value::Array(Vec::new())),
564        ("errors", Value::Array(error_rows)),
565    ]);
566
567    let payload_digest = hj(PAYLOAD_SCHEMA, &payload);
568    Some(object(vec![
569        ("schema", string(ENVELOPE_SCHEMA)),
570        ("payload", payload),
571        ("payload_digest", string(&payload_digest.to_string())),
572    ]))
573}
574
575/// One adapter's complete contract descriptor and its digest, which every
576/// occurrence embeds through its observation-identity input.
577#[must_use]
578pub fn adapter_contract(engine: &EngineProvenance, adapter: Adapter) -> (Value, Digest) {
579    let descriptor = object(vec![
580        ("schema", string(ADAPTER_CONTRACT_SCHEMA)),
581        ("adapter_id", string(adapter.adapter_id())),
582        ("parser_name", string(adapter.parser_name())),
583        ("parser_version", string(&engine.version)),
584        ("grammar_profile", string(adapter.grammar_profile())),
585        (
586            "frontmatter_contract",
587            string(adapter.frontmatter_contract()),
588        ),
589        ("source_projection", string(adapter.source_projection())),
590        ("structural_address", string(adapter.structural_address())),
591    ]);
592    let digest = hj(ADAPTER_CONTRACT_SCHEMA, &descriptor);
593    (descriptor, digest)
594}
595
596/// The complete engine block: contract, version, digest, provenance, policy
597/// version, and the three adapter descriptors with their digests.
598#[must_use]
599pub fn engine_block(engine: &EngineProvenance) -> Value {
600    let adapter_rows: Vec<Value> = Adapter::all()
601        .map(|adapter| {
602            let (descriptor, digest) = adapter_contract(engine, adapter);
603            object(vec![
604                ("adapter_id", string(adapter.adapter_id())),
605                ("contract_descriptor", descriptor),
606                ("contract_digest", string(&digest.to_string())),
607            ])
608        })
609        .collect();
610    object(vec![
611        ("engine_contract", string(ENGINE_CONTRACT)),
612        ("engine_version", string(&engine.version)),
613        ("engine_digest", string(&engine.digest.to_string())),
614        ("action_provenance", object(vec![("kind", string("local"))])),
615        ("built_in_policy", string(BUILT_IN_POLICY)),
616        ("adapters", Value::Array(adapter_rows)),
617    ])
618}
619
620fn zero_summary() -> Value {
621    let documents = [
622        "discovered",
623        "outside_document_set",
624        "scanned",
625        "unsupported",
626        "excluded_builtin",
627        "unlinked",
628        "frontmatter_documents",
629        "opaque_mdx_documents",
630        "opaque_html_documents",
631        "opaque_mdx_regions",
632        "opaque_mdx_bytes",
633        "opaque_html_regions",
634        "opaque_html_bytes",
635        "frontmatter_regions",
636        "frontmatter_bytes",
637    ];
638    let references = [
639        "extracted",
640        "explicit_local",
641        "same_repository",
642        "external_out_of_scope",
643        "unsupported",
644        "resolved",
645        "missing",
646    ];
647    let findings = [
648        "total",
649        "record",
650        "warn",
651        "fail",
652        "introduced",
653        "pre_existing",
654        "resolved",
655        "unknown",
656        "not_applicable",
657        "debt_tolerated",
658        "waived",
659        "analysis_errors",
660        "unsupported_capabilities",
661    ];
662    object(vec![
663        ("counts_complete", Value::Bool(false)),
664        ("documents", zero_counts(&documents)),
665        ("references", zero_counts(&references)),
666        ("findings", zero_counts(&findings)),
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    OpaqueMdxRegion,
768    OpaqueHtmlRegion,
769    ObservationCorrelationAmbiguous,
770    UnlinkedDocument,
771    PolicyWeakened,
772    CoverageReduced,
773    ControlPlaneChanged,
774    DebtWorsened,
775    DebtExpired,
776    WaiverInvalid,
777}
778
779impl FindingKind {
780    /// Every finding kind in schema declaration order.
781    #[must_use]
782    pub fn all() -> impl ExactSizeIterator<Item = Self> {
783        Self::iter()
784    }
785
786    #[must_use]
787    pub fn as_str(self) -> &'static str {
788        self.into()
789    }
790
791    /// The closed key-scope assignment.
792    #[must_use]
793    pub const fn scope(self) -> FindingScope {
794        match self {
795            Self::ExplicitTargetMissing | Self::ExplicitTargetTypeMismatch => {
796                FindingScope::Reference
797            }
798            Self::InvalidReference
799            | Self::UnsupportedReferenceSemantics
800            | Self::UnsupportedTargetKind
801            | Self::UnsupportedVersionScope
802            | Self::DependencyChangedSubjectUnchanged
803            | Self::DependencyAndSubjectCochanged
804            | Self::SubjectChanged
805            | Self::ExplicitReferenceRemoved
806            | Self::ObservationCorrelationAmbiguous => FindingScope::Observation,
807            Self::UnsupportedDocumentFormat
808            | Self::DocumentRemoved
809            | Self::OpaqueMdxRegion
810            | Self::OpaqueHtmlRegion
811            | Self::UnlinkedDocument => FindingScope::Document,
812            Self::UnsupportedCapability
813            | Self::PolicyWeakened
814            | Self::CoverageReduced
815            | Self::ControlPlaneChanged
816            | Self::DebtWorsened
817            | Self::DebtExpired
818            | Self::WaiverInvalid => FindingScope::Control,
819        }
820    }
821
822    /// The first policy-step result for a candidate fact under
823    /// `scanner-policy-defaults`, per profile.
824    #[must_use]
825    pub const fn built_in_disposition(self, enforce: bool) -> Disposition {
826        match self {
827            Self::ExplicitTargetMissing
828            | Self::ExplicitTargetTypeMismatch
829            | Self::InvalidReference => {
830                if enforce {
831                    Disposition::Fail
832                } else {
833                    Disposition::Warn
834                }
835            }
836            Self::UnsupportedCapability
837            | Self::PolicyWeakened
838            | Self::CoverageReduced
839            | Self::ControlPlaneChanged
840            | Self::DebtWorsened
841            | Self::DebtExpired
842            | Self::WaiverInvalid => Disposition::Fail,
843            Self::DependencyChangedSubjectUnchanged => Disposition::Warn,
844            Self::UnsupportedReferenceSemantics
845            | Self::UnsupportedDocumentFormat
846            | Self::UnsupportedTargetKind
847            | Self::UnsupportedVersionScope
848            | Self::DependencyAndSubjectCochanged
849            | Self::SubjectChanged
850            | Self::ExplicitReferenceRemoved
851            | Self::DocumentRemoved
852            | Self::OpaqueMdxRegion
853            | Self::OpaqueHtmlRegion
854            | Self::ObservationCorrelationAmbiguous
855            | Self::UnlinkedDocument => Disposition::Record,
856        }
857    }
858}
859
860pub const SANDBOX_SCHEMA: &str = "amiss/scanner-sandbox-profile";
861
862/// The zero-capability sandbox descriptor the engine asserts for itself, and
863/// its digest. A future wrapper verifies rather than asserts it.
864#[must_use]
865pub fn sandbox_descriptor() -> (Value, Digest) {
866    let descriptor = object(vec![
867        ("schema", string(SANDBOX_SCHEMA)),
868        ("profile", string("scanner-zero-capability")),
869        ("isolation", string("process")),
870        ("network", string("denied")),
871        ("child_processes", string("denied")),
872        ("repository_processes", string("denied")),
873        ("credentials", string("absent")),
874        ("secrets", string("absent")),
875        ("shared_cache", string("denied")),
876        ("workspace", string("read-only")),
877        ("environment", string("scanner-process-env")),
878        (
879            "physical_memory",
880            object(vec![(
881                "maximum_bytes",
882                Value::Integer(i64::try_from(EVALUATOR_MANAGED_MEMORY_BYTES).unwrap_or(i64::MAX)),
883            )]),
884        ),
885        (
886            "temporary_storage",
887            object(vec![
888                ("kind", string("private-bounded")),
889                (
890                    "maximum_bytes",
891                    Value::Integer(
892                        i64::try_from(PRIVATE_TEMPORARY_STORAGE_BYTES).unwrap_or(i64::MAX),
893                    ),
894                ),
895            ]),
896        ),
897        (
898            "watchdog",
899            object(vec![(
900                "maximum_milliseconds",
901                Value::Integer(i64::try_from(WATCHDOG_MILLISECONDS).unwrap_or(i64::MAX)),
902            )]),
903        ),
904    ]);
905    let digest = hj(SANDBOX_SCHEMA, &descriptor);
906    (descriptor, digest)
907}
908
909impl FindingKind {
910    #[must_use]
911    pub const fn evidence_class(self) -> &'static str {
912        match self {
913            Self::ExplicitTargetMissing
914            | Self::ExplicitTargetTypeMismatch
915            | Self::InvalidReference => "deterministic-structural",
916            Self::UnsupportedCapability
917            | Self::UnsupportedReferenceSemantics
918            | Self::UnsupportedDocumentFormat
919            | Self::UnsupportedTargetKind
920            | Self::UnsupportedVersionScope => "unsupported",
921            Self::DependencyChangedSubjectUnchanged
922            | Self::DependencyAndSubjectCochanged
923            | Self::SubjectChanged => "impact-observation",
924            Self::ExplicitReferenceRemoved
925            | Self::DocumentRemoved
926            | Self::OpaqueMdxRegion
927            | Self::OpaqueHtmlRegion
928            | Self::ObservationCorrelationAmbiguous
929            | Self::UnlinkedDocument => "coverage-boundary",
930            Self::PolicyWeakened
931            | Self::CoverageReduced
932            | Self::ControlPlaneChanged
933            | Self::DebtWorsened
934            | Self::DebtExpired
935            | Self::WaiverInvalid => "control-plane",
936        }
937    }
938
939    /// One fixed engine-owned sentence per kind: what the finding means and
940    /// what to do about it. The human projection prints it as a `note` line
941    /// and the documentation renders the same text, so the sentence a reader
942    /// meets in a CI log is the sentence the book teaches.
943    #[must_use]
944    pub const fn meaning(self) -> &'static str {
945        match self {
946            Self::ExplicitTargetMissing => {
947                "a reference names a repository path, a line range inside one, or a heading anchor no known renderer publishes; restore the target or correct the link"
948            }
949            Self::ExplicitTargetTypeMismatch => {
950                "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"
951            }
952            Self::InvalidReference => {
953                "the destination cannot name a repository target: it escapes the repository or carries a backslash, an encoded separator, or control bytes; fix the destination"
954            }
955            Self::UnsupportedReferenceSemantics => {
956                "the reference uses semantics this run did not evaluate: a site route, a protocol-relative destination, a query string, or a fragment on a target it cannot parse; the unchecked part is declared instead of guessed"
957            }
958            Self::UnsupportedDocumentFormat => {
959                "a policy-included document has no parser in this engine; it is discovered and counted, and its content is never scanned"
960            }
961            Self::UnsupportedTargetKind => {
962                "the reference resolves to a symlink or submodule, which Amiss does not follow; the boundary is declared instead of crossed"
963            }
964            Self::UnsupportedVersionScope => {
965                "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"
966            }
967            Self::UnsupportedCapability => {
968                "a candidate document declares a reserved amiss: capability this engine does not implement; the run ends incomplete rather than guessing at the claim"
969            }
970            Self::DependencyChangedSubjectUnchanged => {
971                "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"
972            }
973            Self::DependencyAndSubjectCochanged => {
974                "the referenced content and the block citing it changed together, the shape of a maintained page; recorded with nothing to act on"
975            }
976            Self::SubjectChanged => {
977                "the block holding the reference changed while its target did not; recorded so prose moving over an unchanged dependency stays visible"
978            }
979            Self::ExplicitReferenceRemoved => {
980                "a reference that existed in the base is gone from the candidate; the removal is recorded as a fact, never treated as evidence that the edit was wrong"
981            }
982            Self::DocumentRemoved => {
983                "a scanned document left the tree; recorded so the disappearance is a stated fact rather than a silent one"
984            }
985            Self::OpaqueMdxRegion => {
986                "an MDX expression region the parser cannot see into; a reference inside it is a stated blind spot, reported with size and place"
987            }
988            Self::OpaqueHtmlRegion => {
989                "a raw HTML region the parser cannot see into; a reference inside it is a stated blind spot, reported with size and place"
990            }
991            Self::ObservationCorrelationAmbiguous => {
992                "an occurrence has more than one plausible counterpart across the comparison; Amiss never chooses by input order, so the match is recorded as undecided"
993            }
994            Self::UnlinkedDocument => {
995                "a scanned document from which zero references were extracted; despite the name, it claims nothing about inbound links from other pages"
996            }
997            Self::PolicyWeakened => {
998                "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"
999            }
1000            Self::CoverageReduced => {
1001                "a protected path is gone or not a scannable document while its protection stands; restore it or amend the protection in a reviewed change"
1002            }
1003            Self::ControlPlaneChanged => {
1004                "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"
1005            }
1006            Self::DebtWorsened => {
1007                "the finding an accepted debt item names no longer matches the recorded fact; debt tolerates exactly the recorded state, so any drift fails"
1008            }
1009            Self::DebtExpired => {
1010                "trusted time reached a debt item's expiry while its finding persists; fix the finding or renew the debt in a reviewed change"
1011            }
1012            Self::WaiverInvalid => {
1013                "a waiver item cannot apply, expired against trusted time or issued outside the floor's authority; an invalid waiver suppresses nothing"
1014            }
1015        }
1016    }
1017
1018    #[must_use]
1019    pub const fn invariant_class(self) -> &'static str {
1020        match self {
1021            Self::ExplicitTargetMissing
1022            | Self::ExplicitTargetTypeMismatch
1023            | Self::InvalidReference => "ratcheted",
1024            Self::UnsupportedCapability => "analysis-integrity",
1025            Self::UnsupportedReferenceSemantics
1026            | Self::UnsupportedDocumentFormat
1027            | Self::UnsupportedTargetKind
1028            | Self::UnsupportedVersionScope
1029            | Self::DependencyChangedSubjectUnchanged
1030            | Self::DependencyAndSubjectCochanged
1031            | Self::SubjectChanged
1032            | Self::ExplicitReferenceRemoved
1033            | Self::DocumentRemoved
1034            | Self::OpaqueMdxRegion
1035            | Self::OpaqueHtmlRegion
1036            | Self::ObservationCorrelationAmbiguous
1037            | Self::UnlinkedDocument => "advisory",
1038            Self::PolicyWeakened
1039            | Self::CoverageReduced
1040            | Self::ControlPlaneChanged
1041            | Self::DebtWorsened
1042            | Self::DebtExpired
1043            | Self::WaiverInvalid => "absolute",
1044        }
1045    }
1046}
1047
1048/// One typed analysis error's reportable detail: the code, the exact path
1049/// where the partition names one, the raw bytes of a name the report cannot
1050/// hold as text, and the crossing triple for a resource error. Field order
1051/// is the canonical error key, so the derived ordering is the wire's.
1052#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
1053pub struct ErrorDetail {
1054    pub code: AnalysisErrorCode,
1055    pub path: Option<crate::model::RepoPath>,
1056    pub path_bytes: Option<Vec<u8>>,
1057    pub resource: Option<(crate::controls::ResourceName, u64, u64)>,
1058}
1059
1060impl ErrorDetail {
1061    #[must_use]
1062    pub fn phase(&self) -> &'static str {
1063        self.resource.map_or_else(
1064            || self.code.fixed_phase().unwrap_or("internal"),
1065            |(name, _limit, _observed)| name.phase(),
1066        )
1067    }
1068}
1069
1070/// One wire error row with its partition phase.
1071#[must_use]
1072pub fn error_row_value(detail: &ErrorDetail) -> Value {
1073    error_row(detail, detail.phase())
1074}
1075
1076fn error_row(detail: &ErrorDetail, phase: &str) -> Value {
1077    let (resource, limit, observed) = detail.resource.map_or(
1078        (Value::Null, Value::Null, Value::Null),
1079        |(name, limit, observed)| {
1080            (
1081                string(name.as_str()),
1082                Value::Integer(i64::try_from(limit).unwrap_or(i64::MAX)),
1083                Value::Integer(i64::try_from(observed).unwrap_or(i64::MAX)),
1084            )
1085        },
1086    );
1087    object(vec![
1088        ("phase", string(phase)),
1089        ("code", string(detail.code.as_str())),
1090        ("description", string(detail.code.meaning())),
1091        (
1092            "path",
1093            detail
1094                .path
1095                .as_ref()
1096                .map_or(Value::Null, crate::model::RepoPath::to_value),
1097        ),
1098        (
1099            "path_bytes_hex",
1100            detail.path_bytes.as_deref().map_or(Value::Null, |bytes| {
1101                Value::String(crate::model::hex_lower(bytes))
1102            }),
1103        ),
1104        ("resource", resource),
1105        ("configured_limit", limit),
1106        ("observed_lower_bound", observed),
1107    ])
1108}