Skip to main content

amiss_wire/
report.rs

1use std::collections::BTreeSet;
2
3use crate::digest::{Digest, hj};
4use crate::json::{Scratch, Sink, Value, canonical, canonical_length};
5use crate::model::Adapter;
6
7pub const ENGINE_CONTRACT: &str = "amiss/scanner-v0";
8
9/// The exact `machine-json-bytes` reservation: the report wire, canonical
10/// envelope plus the trailing newline, never exceeds this.
11pub const MACHINE_JSON_BYTES: u64 = 67_108_864;
12
13/// The fatal serializer's fixed scratch allowance: the staging buffer it
14/// reserves up front plus every transient allocation one streaming emission
15/// may make. The E0 maximal golden proves emission stays inside it.
16pub const FATAL_SCRATCH_BYTES: usize = 65_536;
17
18/// The streaming fatal-envelope serializer and its fixed scratch space. A
19/// binary reserves one before evaluator allocation accounting begins, so a
20/// fatal projection is always emittable: emission streams `JCS(envelope)`
21/// and the trailing newline through the reserved staging buffer without
22/// materializing the wire.
23pub struct FatalSerializer {
24    staging: Vec<u8>,
25    scratch: Scratch,
26}
27
28impl FatalSerializer {
29    /// Reserves the staging buffer and serializer scratch.
30    #[must_use]
31    pub fn new() -> Self {
32        Self {
33            staging: Vec::with_capacity(FATAL_SCRATCH_BYTES),
34            scratch: Scratch::new(),
35        }
36    }
37
38    /// Streams the envelope's wire (`JCS(envelope) || LF`) into the writer
39    /// through the reserved scratch and returns the byte count.
40    ///
41    /// # Errors
42    ///
43    /// The first writer error; the wire is incomplete in that case and the
44    /// caller treats the emission as failed.
45    pub fn emit(&mut self, envelope: &Value, out: &mut dyn std::io::Write) -> std::io::Result<u64> {
46        self.staging.clear();
47        let mut sink = StagedSink {
48            staging: &mut self.staging,
49            out,
50            written: 0,
51            error: None,
52        };
53        self.scratch.stream(envelope, &mut sink);
54        sink.write("\n");
55        let written = sink.flush();
56        self.staging.clear();
57        written
58    }
59
60    /// The materialized wire for a caller that must inspect the bytes (the
61    /// wrapper's acceptance): one counting pass sizes the allocation
62    /// exactly, then one streaming pass fills it.
63    #[must_use]
64    pub fn wire_bytes(&mut self, envelope: &Value) -> Vec<u8> {
65        let exact = canonical_length(envelope).saturating_add(1);
66        let mut wire = Vec::with_capacity(usize::try_from(exact).unwrap_or(0));
67        if self.emit(envelope, &mut wire).is_err() {
68            wire.clear();
69        }
70        wire
71    }
72}
73
74impl Default for FatalSerializer {
75    fn default() -> Self {
76        Self::new()
77    }
78}
79
80struct StagedSink<'a> {
81    staging: &'a mut Vec<u8>,
82    out: &'a mut dyn std::io::Write,
83    written: u64,
84    error: Option<std::io::Error>,
85}
86
87impl StagedSink<'_> {
88    fn drain(&mut self) {
89        if self.error.is_none() {
90            match self.out.write_all(self.staging) {
91                Ok(()) => {
92                    self.written = self
93                        .written
94                        .saturating_add(u64::try_from(self.staging.len()).unwrap_or(u64::MAX));
95                }
96                Err(defect) => self.error = Some(defect),
97            }
98        }
99        self.staging.clear();
100    }
101
102    fn flush(&mut self) -> std::io::Result<u64> {
103        self.drain();
104        match self.error.take() {
105            Some(defect) => Err(defect),
106            None => Ok(self.written),
107        }
108    }
109}
110
111impl Sink for StagedSink<'_> {
112    fn write(&mut self, piece: &str) {
113        if piece.len() >= FATAL_SCRATCH_BYTES {
114            self.drain();
115            if self.error.is_none() {
116                match self.out.write_all(piece.as_bytes()) {
117                    Ok(()) => {
118                        self.written = self
119                            .written
120                            .saturating_add(u64::try_from(piece.len()).unwrap_or(u64::MAX));
121                    }
122                    Err(defect) => self.error = Some(defect),
123                }
124            }
125            return;
126        }
127        if self.staging.len().saturating_add(piece.len()) > FATAL_SCRATCH_BYTES {
128            self.drain();
129        }
130        self.staging.extend_from_slice(piece.as_bytes());
131    }
132}
133pub const ENGINE_DOMAIN: &str = "amiss/scanner-engine/v1";
134pub const ENVELOPE_SCHEMA: &str = "amiss/scanner-report-envelope/v3";
135pub const PAYLOAD_SCHEMA: &str = "amiss/scanner-report-payload/v3";
136pub const ADAPTER_CONTRACT_SCHEMA: &str = "amiss/scanner-adapter-contract/v1";
137pub const BUILT_IN_POLICY_VERSION: &str = "scanner-policy-defaults-v1";
138
139/// The closed analysis-error codes in schema declaration order.
140#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
141pub enum AnalysisErrorCode {
142    InvalidInvocation,
143    InvalidEvent,
144    InvalidProfile,
145    RequestUnreadable,
146    ConfigurationInvalid,
147    DuplicateJsonKey,
148    InvalidUtf8,
149    InvalidJson,
150    UnknownSchema,
151    UnknownField,
152    NoncanonicalArray,
153    DigestMismatch,
154    ControlBindingMismatch,
155    ExceptionOverlap,
156    UnsupportedCapability,
157    GitRepositoryUnavailable,
158    GitObjectMissing,
159    GitObjectWrongKind,
160    GitObjectUnreadable,
161    GitIndexInvalid,
162    GitIndexUnmerged,
163    GitIntentToAdd,
164    GitSnapshotChanged,
165    UnrepresentablePath,
166    DocumentInvalid,
167    ParserError,
168    ParserPanic,
169    InvalidSourceSpan,
170    ResolutionError,
171    ResourceLimitExceeded,
172    OutputLimitExceeded,
173    TooManyErrors,
174    ReportConstructionFailed,
175    SandboxViolation,
176    TrustedTimeInvalid,
177    InternalError,
178}
179
180impl AnalysisErrorCode {
181    #[must_use]
182    pub const fn as_str(self) -> &'static str {
183        match self {
184            Self::InvalidInvocation => "INVALID_INVOCATION",
185            Self::InvalidEvent => "INVALID_EVENT",
186            Self::InvalidProfile => "INVALID_PROFILE",
187            Self::RequestUnreadable => "REQUEST_UNREADABLE",
188            Self::ConfigurationInvalid => "CONFIGURATION_INVALID",
189            Self::DuplicateJsonKey => "DUPLICATE_JSON_KEY",
190            Self::InvalidUtf8 => "INVALID_UTF8",
191            Self::InvalidJson => "INVALID_JSON",
192            Self::UnknownSchema => "UNKNOWN_SCHEMA",
193            Self::UnknownField => "UNKNOWN_FIELD",
194            Self::NoncanonicalArray => "NONCANONICAL_ARRAY",
195            Self::DigestMismatch => "DIGEST_MISMATCH",
196            Self::ControlBindingMismatch => "CONTROL_BINDING_MISMATCH",
197            Self::ExceptionOverlap => "EXCEPTION_OVERLAP",
198            Self::UnsupportedCapability => "UNSUPPORTED_CAPABILITY",
199            Self::GitRepositoryUnavailable => "GIT_REPOSITORY_UNAVAILABLE",
200            Self::GitObjectMissing => "GIT_OBJECT_MISSING",
201            Self::GitObjectWrongKind => "GIT_OBJECT_WRONG_KIND",
202            Self::GitObjectUnreadable => "GIT_OBJECT_UNREADABLE",
203            Self::GitIndexInvalid => "GIT_INDEX_INVALID",
204            Self::GitIndexUnmerged => "GIT_INDEX_UNMERGED",
205            Self::GitIntentToAdd => "GIT_INTENT_TO_ADD",
206            Self::GitSnapshotChanged => "GIT_SNAPSHOT_CHANGED",
207            Self::UnrepresentablePath => "UNREPRESENTABLE_PATH",
208            Self::DocumentInvalid => "DOCUMENT_INVALID",
209            Self::ParserError => "PARSER_ERROR",
210            Self::ParserPanic => "PARSER_PANIC",
211            Self::InvalidSourceSpan => "INVALID_SOURCE_SPAN",
212            Self::ResolutionError => "RESOLUTION_ERROR",
213            Self::ResourceLimitExceeded => "RESOURCE_LIMIT_EXCEEDED",
214            Self::OutputLimitExceeded => "OUTPUT_LIMIT_EXCEEDED",
215            Self::TooManyErrors => "TOO_MANY_ERRORS",
216            Self::ReportConstructionFailed => "REPORT_CONSTRUCTION_FAILED",
217            Self::SandboxViolation => "SANDBOX_VIOLATION",
218            Self::TrustedTimeInvalid => "TRUSTED_TIME_INVALID",
219            Self::InternalError => "INTERNAL_ERROR",
220        }
221    }
222
223    /// Fixed phase for non-resource codes; `RESOURCE_LIMIT_EXCEEDED` takes its
224    /// phase from the resource partition and has none here.
225    #[must_use]
226    pub const fn fixed_phase(self) -> Option<&'static str> {
227        match self {
228            Self::InvalidInvocation
229            | Self::InvalidEvent
230            | Self::InvalidProfile
231            | Self::RequestUnreadable => Some("invocation"),
232            Self::ConfigurationInvalid
233            | Self::DuplicateJsonKey
234            | Self::InvalidUtf8
235            | Self::InvalidJson
236            | Self::UnknownSchema
237            | Self::UnknownField
238            | Self::NoncanonicalArray
239            | Self::DigestMismatch
240            | Self::ControlBindingMismatch
241            | Self::ExceptionOverlap
242            | Self::TrustedTimeInvalid => Some("configuration"),
243            Self::GitRepositoryUnavailable
244            | Self::GitObjectMissing
245            | Self::GitObjectWrongKind
246            | Self::GitObjectUnreadable
247            | Self::GitIndexInvalid
248            | Self::GitIndexUnmerged
249            | Self::GitIntentToAdd
250            | Self::GitSnapshotChanged
251            | Self::UnrepresentablePath => Some("git"),
252            Self::DocumentInvalid
253            | Self::ParserError
254            | Self::ParserPanic
255            | Self::InvalidSourceSpan => Some("parse"),
256            Self::ResolutionError => Some("resolution"),
257            Self::UnsupportedCapability => Some("policy"),
258            Self::OutputLimitExceeded | Self::ReportConstructionFailed => Some("output"),
259            Self::SandboxViolation | Self::TooManyErrors | Self::InternalError => Some("internal"),
260            Self::ResourceLimitExceeded => None,
261        }
262    }
263
264    const fn evaluation_reason(self) -> Option<&'static str> {
265        match self {
266            Self::InvalidInvocation => Some("invalid-invocation"),
267            Self::InvalidEvent => Some("invalid-event"),
268            Self::InvalidProfile => Some("invalid-profile"),
269            Self::RequestUnreadable => Some("request-unreadable"),
270            Self::ConfigurationInvalid
271            | Self::DuplicateJsonKey
272            | Self::InvalidUtf8
273            | Self::InvalidJson
274            | Self::UnknownSchema
275            | Self::UnknownField
276            | Self::NoncanonicalArray
277            | Self::DigestMismatch
278            | Self::ControlBindingMismatch
279            | Self::ExceptionOverlap
280            | Self::UnsupportedCapability
281            | Self::GitRepositoryUnavailable
282            | Self::GitObjectMissing
283            | Self::GitObjectWrongKind
284            | Self::GitObjectUnreadable
285            | Self::GitIndexInvalid
286            | Self::GitIndexUnmerged
287            | Self::GitIntentToAdd
288            | Self::GitSnapshotChanged
289            | Self::UnrepresentablePath
290            | Self::DocumentInvalid
291            | Self::ParserError
292            | Self::ParserPanic
293            | Self::InvalidSourceSpan
294            | Self::ResolutionError
295            | Self::ResourceLimitExceeded
296            | Self::OutputLimitExceeded
297            | Self::TooManyErrors
298            | Self::ReportConstructionFailed
299            | Self::SandboxViolation
300            | Self::TrustedTimeInvalid
301            | Self::InternalError => None,
302        }
303    }
304}
305
306#[derive(Clone, Debug, PartialEq, Eq)]
307pub struct EngineProvenance {
308    pub version: String,
309    pub digest: Digest,
310}
311
312/// Builds the canonical fatal-incomplete wire (`JCS(envelope) || LF`) for an
313/// invocation rejection: every detail array empty, every count zero, unavailable
314/// evaluation and controls with their reason sets, exit class 2.
315///
316/// Returns `None` when `codes` is empty or contains a non-invocation code.
317#[must_use]
318pub fn invocation_failure_wire(
319    engine: &EngineProvenance,
320    codes: &BTreeSet<AnalysisErrorCode>,
321) -> Option<Vec<u8>> {
322    unavailable_evaluation_wire(engine, codes, None, None)
323}
324
325/// The envelope value behind [`invocation_failure_wire`], for emission
326/// through the reserved fatal serializer.
327#[must_use]
328pub fn invocation_failure_envelope(
329    engine: &EngineProvenance,
330    codes: &BTreeSet<AnalysisErrorCode>,
331) -> Option<Value> {
332    unavailable_evaluation_envelope(engine, codes, None, None)
333}
334
335/// The fatal unavailable-evaluation envelope for the request-wire lane: the
336/// same closed projection, carrying each request's diagnostic digest where
337/// its byte stream was completely captured.
338///
339/// Returns `None` when no code is supplied or a code has no evaluation
340/// reason, exactly as the invocation form.
341#[must_use]
342pub fn unavailable_evaluation_wire(
343    engine: &EngineProvenance,
344    codes: &BTreeSet<AnalysisErrorCode>,
345    evaluation_request_digest: Option<Digest>,
346    controls_request_digest: Option<Digest>,
347) -> Option<Vec<u8>> {
348    let envelope = unavailable_evaluation_envelope(
349        engine,
350        codes,
351        evaluation_request_digest,
352        controls_request_digest,
353    )?;
354    let mut wire = canonical(&envelope);
355    wire.push(b'\n');
356    Some(wire)
357}
358
359/// The envelope value behind [`unavailable_evaluation_wire`], for emission
360/// through the reserved fatal serializer.
361#[must_use]
362pub fn unavailable_evaluation_envelope(
363    engine: &EngineProvenance,
364    codes: &BTreeSet<AnalysisErrorCode>,
365    evaluation_request_digest: Option<Digest>,
366    controls_request_digest: Option<Digest>,
367) -> Option<Value> {
368    if codes.is_empty() {
369        return None;
370    }
371    let mut reasons = Vec::new();
372    for code in codes {
373        reasons.push(Value::String(code.evaluation_reason()?.to_owned()));
374    }
375
376    let mut errors: Vec<(AnalysisErrorCode, &'static str)> = Vec::new();
377    for code in codes {
378        errors.push((*code, code.fixed_phase()?));
379    }
380    errors.sort_by(|a, b| a.0.as_str().cmp(b.0.as_str()));
381    let error_rows: Vec<Value> = errors
382        .iter()
383        .map(|(code, phase)| {
384            error_row(
385                &ErrorDetail {
386                    code: *code,
387                    path: None,
388                    path_bytes: None,
389                    resource: None,
390                },
391                phase,
392            )
393        })
394        .collect();
395    let error_count = i64::try_from(error_rows.len()).ok()?;
396
397    let payload = object(vec![
398        ("schema", string(PAYLOAD_SCHEMA)),
399        ("compatibility", string("experimental")),
400        ("engine", engine_block(engine)),
401        (
402            "evaluation",
403            object(vec![
404                ("status", string("unavailable")),
405                (
406                    "request_digest",
407                    evaluation_request_digest
408                        .map_or(Value::Null, |digest| string(&digest.to_string())),
409                ),
410                ("reasons", Value::Array(reasons)),
411            ]),
412        ),
413        (
414            "controls",
415            object(vec![
416                ("status", string("unavailable")),
417                (
418                    "request_digest",
419                    controls_request_digest
420                        .map_or(Value::Null, |digest| string(&digest.to_string())),
421                ),
422                ("reasons", Value::Array(vec![string("not-parsed")])),
423            ]),
424        ),
425        (
426            "result",
427            object(vec![
428                ("complete", Value::Bool(false)),
429                ("status", string("incomplete")),
430                ("exit_code", Value::Integer(2)),
431                ("finding_count", Value::Integer(0)),
432                ("error_count", Value::Integer(error_count)),
433            ]),
434        ),
435        ("summary", zero_summary()),
436        ("documents", Value::Array(Vec::new())),
437        ("observations", Value::Array(Vec::new())),
438        ("findings", Value::Array(Vec::new())),
439        ("errors", Value::Array(error_rows)),
440    ]);
441
442    let payload_digest = hj(PAYLOAD_SCHEMA, &payload);
443    Some(object(vec![
444        ("schema", string(ENVELOPE_SCHEMA)),
445        ("payload", payload),
446        ("payload_digest", string(&payload_digest.to_string())),
447    ]))
448}
449
450/// One adapter's complete contract descriptor and its digest, which every
451/// occurrence embeds through its observation-identity input.
452#[must_use]
453pub fn adapter_contract(engine: &EngineProvenance, adapter: Adapter) -> (Value, Digest) {
454    let descriptor = object(vec![
455        ("schema", string(ADAPTER_CONTRACT_SCHEMA)),
456        ("adapter_id", string(adapter.adapter_id())),
457        ("parser_name", string(adapter.parser_name())),
458        ("parser_version", string(&engine.version)),
459        ("grammar_profile", string(adapter.grammar_profile())),
460        (
461            "frontmatter_contract",
462            string(adapter.frontmatter_contract()),
463        ),
464        ("source_projection", string(adapter.source_projection())),
465        ("structural_address", string(adapter.structural_address())),
466    ]);
467    let digest = hj(ADAPTER_CONTRACT_SCHEMA, &descriptor);
468    (descriptor, digest)
469}
470
471/// The complete engine block: contract, version, digest, provenance, policy
472/// version, and the three adapter descriptors with their digests.
473#[must_use]
474pub fn engine_block(engine: &EngineProvenance) -> Value {
475    let adapter_rows: Vec<Value> = Adapter::ALL
476        .iter()
477        .map(|adapter| {
478            let (descriptor, digest) = adapter_contract(engine, *adapter);
479            object(vec![
480                ("adapter_id", string(adapter.adapter_id())),
481                ("contract_descriptor", descriptor),
482                ("contract_digest", string(&digest.to_string())),
483            ])
484        })
485        .collect();
486    object(vec![
487        ("engine_contract", string(ENGINE_CONTRACT)),
488        ("engine_version", string(&engine.version)),
489        ("engine_digest", string(&engine.digest.to_string())),
490        ("action_provenance", object(vec![("kind", string("local"))])),
491        ("built_in_policy_version", string(BUILT_IN_POLICY_VERSION)),
492        ("adapters", Value::Array(adapter_rows)),
493    ])
494}
495
496fn zero_summary() -> Value {
497    let documents = [
498        "discovered",
499        "outside_document_set",
500        "scanned",
501        "unsupported",
502        "excluded_builtin",
503        "unlinked",
504        "frontmatter_documents",
505        "opaque_mdx_documents",
506        "opaque_html_documents",
507        "opaque_mdx_regions",
508        "opaque_mdx_bytes",
509        "opaque_html_regions",
510        "opaque_html_bytes",
511        "frontmatter_regions",
512        "frontmatter_bytes",
513    ];
514    let references = [
515        "extracted",
516        "explicit_local",
517        "same_repository",
518        "external_out_of_scope",
519        "unsupported",
520        "resolved",
521        "missing",
522    ];
523    let findings = [
524        "total",
525        "record",
526        "warn",
527        "fail",
528        "introduced",
529        "pre_existing",
530        "resolved",
531        "unknown",
532        "not_applicable",
533        "debt_tolerated",
534        "waived",
535        "analysis_errors",
536        "unsupported_capabilities",
537    ];
538    object(vec![
539        ("counts_complete", Value::Bool(false)),
540        ("documents", zero_counts(&documents)),
541        ("references", zero_counts(&references)),
542        ("findings", zero_counts(&findings)),
543        ("human_details_truncated", Value::Integer(0)),
544        ("governed_claims", Value::Integer(0)),
545        ("unattested_claims", Value::Integer(0)),
546    ])
547}
548
549fn zero_counts(fields: &[&str]) -> Value {
550    Value::Object(
551        fields
552            .iter()
553            .map(|field| ((*field).to_owned(), Value::Integer(0)))
554            .collect(),
555    )
556}
557
558fn object(members: Vec<(&str, Value)>) -> Value {
559    Value::Object(
560        members
561            .into_iter()
562            .map(|(key, value)| (key.to_owned(), value))
563            .collect(),
564    )
565}
566
567fn string(value: &str) -> Value {
568    Value::String(value.to_owned())
569}
570
571/// The six resolution statuses. The status is total in the code: no other
572/// pairing exists.
573#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
574pub enum ResolutionStatus {
575    Resolved,
576    Missing,
577    TypeMismatch,
578    Unsupported,
579    Invalid,
580    ExternalOutOfScope,
581}
582
583impl ResolutionStatus {
584    #[must_use]
585    pub const fn as_str(self) -> &'static str {
586        match self {
587            Self::Resolved => "resolved",
588            Self::Missing => "missing",
589            Self::TypeMismatch => "type-mismatch",
590            Self::Unsupported => "unsupported",
591            Self::Invalid => "invalid",
592            Self::ExternalOutOfScope => "external-out-of-scope",
593        }
594    }
595}
596
597/// The closed resolution codes in schema declaration order.
598#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
599pub enum ResolutionCode {
600    ExactPath,
601    PathNotFound,
602    TargetTypeMismatch,
603    SymlinkEntry,
604    GitlinkEntry,
605    UnsupportedQuerySemantics,
606    UnsupportedFragmentSemantics,
607    UnsupportedVersionScope,
608    SiteRouteUnsupported,
609    NetworkPathUnsupported,
610    CodeFragmentUnevaluated,
611    InvalidUri,
612    InvalidPercentEncoding,
613    DecodedPathControl,
614    PathTraversal,
615    BackslashSeparator,
616    EncodedSlash,
617    InvalidFragmentEncoding,
618    InvalidReference,
619    ExternalUrl,
620    ForeignRepository,
621}
622
623impl ResolutionCode {
624    #[must_use]
625    pub const fn as_str(self) -> &'static str {
626        match self {
627            Self::ExactPath => "exact-path",
628            Self::PathNotFound => "path-not-found",
629            Self::TargetTypeMismatch => "target-type-mismatch",
630            Self::SymlinkEntry => "symlink-entry",
631            Self::GitlinkEntry => "gitlink-entry",
632            Self::UnsupportedQuerySemantics => "unsupported-query-semantics",
633            Self::UnsupportedFragmentSemantics => "unsupported-fragment-semantics",
634            Self::UnsupportedVersionScope => "unsupported-version-scope",
635            Self::SiteRouteUnsupported => "site-route-unsupported",
636            Self::NetworkPathUnsupported => "network-path-unsupported",
637            Self::CodeFragmentUnevaluated => "code-fragment-unevaluated",
638            Self::InvalidUri => "invalid-uri",
639            Self::InvalidPercentEncoding => "invalid-percent-encoding",
640            Self::DecodedPathControl => "decoded-path-control",
641            Self::PathTraversal => "path-traversal",
642            Self::BackslashSeparator => "backslash-separator",
643            Self::EncodedSlash => "encoded-slash",
644            Self::InvalidFragmentEncoding => "invalid-fragment-encoding",
645            Self::InvalidReference => "invalid-reference",
646            Self::ExternalUrl => "external-url",
647            Self::ForeignRepository => "foreign-repository",
648        }
649    }
650
651    #[must_use]
652    pub const fn status(self) -> ResolutionStatus {
653        match self {
654            Self::ExactPath => ResolutionStatus::Resolved,
655            Self::PathNotFound => ResolutionStatus::Missing,
656            Self::TargetTypeMismatch => ResolutionStatus::TypeMismatch,
657            Self::SymlinkEntry
658            | Self::GitlinkEntry
659            | Self::UnsupportedQuerySemantics
660            | Self::UnsupportedFragmentSemantics
661            | Self::UnsupportedVersionScope
662            | Self::SiteRouteUnsupported
663            | Self::NetworkPathUnsupported
664            | Self::CodeFragmentUnevaluated => ResolutionStatus::Unsupported,
665            Self::InvalidUri
666            | Self::InvalidPercentEncoding
667            | Self::DecodedPathControl
668            | Self::PathTraversal
669            | Self::BackslashSeparator
670            | Self::EncodedSlash
671            | Self::InvalidFragmentEncoding
672            | Self::InvalidReference => ResolutionStatus::Invalid,
673            Self::ExternalUrl | Self::ForeignRepository => ResolutionStatus::ExternalOutOfScope,
674        }
675    }
676}
677
678/// The target-intent variants an occurrence can carry, in schema
679/// declaration order.
680#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
681pub enum IntentKind {
682    RepositoryPath,
683    SameRepositoryGithub,
684    SameRepositoryGitlab,
685    SameRepositoryGitea,
686    ExternalUrl,
687    SiteRoute,
688    Unsupported,
689}
690
691impl IntentKind {
692    #[must_use]
693    pub const fn as_str(self) -> &'static str {
694        match self {
695            Self::RepositoryPath => "repository-path",
696            Self::SameRepositoryGithub => "same-repository-github",
697            Self::SameRepositoryGitlab => "same-repository-gitlab",
698            Self::SameRepositoryGitea => "same-repository-gitea",
699            Self::ExternalUrl => "external-url",
700            Self::SiteRoute => "site-route",
701            Self::Unsupported => "unsupported",
702        }
703    }
704}
705
706/// The four finding scopes.
707#[derive(Clone, Copy, Debug, PartialEq, Eq)]
708pub enum FindingScope {
709    Reference,
710    Observation,
711    Document,
712    Control,
713}
714
715/// The closed disposition values a policy step can produce.
716#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
717pub enum Disposition {
718    Record,
719    Warn,
720    Fail,
721}
722
723impl Disposition {
724    #[must_use]
725    pub const fn as_str(self) -> &'static str {
726        match self {
727            Self::Record => "record",
728            Self::Warn => "warn",
729            Self::Fail => "fail",
730        }
731    }
732}
733
734/// The complete closed v0 finding taxonomy, in schema declaration order.
735#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
736pub enum FindingKind {
737    ExplicitTargetMissing,
738    ExplicitTargetTypeMismatch,
739    InvalidReference,
740    UnsupportedReferenceSemantics,
741    UnsupportedDocumentFormat,
742    UnsupportedTargetKind,
743    UnsupportedVersionScope,
744    UnsupportedCapability,
745    DependencyChangedSubjectUnchanged,
746    DependencyAndSubjectCochanged,
747    SubjectChanged,
748    ExplicitReferenceRemoved,
749    DocumentRemoved,
750    ExternalOutOfScope,
751    OpaqueMdxRegion,
752    OpaqueHtmlRegion,
753    ObservationCorrelationAmbiguous,
754    UnlinkedDocument,
755    PolicyWeakened,
756    CoverageReduced,
757    ControlPlaneChanged,
758    DebtWorsened,
759    DebtExpired,
760    WaiverInvalid,
761}
762
763impl FindingKind {
764    #[must_use]
765    pub const fn as_str(self) -> &'static str {
766        match self {
767            Self::ExplicitTargetMissing => "explicit-target-missing",
768            Self::ExplicitTargetTypeMismatch => "explicit-target-type-mismatch",
769            Self::InvalidReference => "invalid-reference",
770            Self::UnsupportedReferenceSemantics => "unsupported-reference-semantics",
771            Self::UnsupportedDocumentFormat => "unsupported-document-format",
772            Self::UnsupportedTargetKind => "unsupported-target-kind",
773            Self::UnsupportedVersionScope => "unsupported-version-scope",
774            Self::UnsupportedCapability => "unsupported-capability",
775            Self::DependencyChangedSubjectUnchanged => "dependency-changed-subject-unchanged",
776            Self::DependencyAndSubjectCochanged => "dependency-and-subject-cochanged",
777            Self::SubjectChanged => "subject-changed",
778            Self::ExplicitReferenceRemoved => "explicit-reference-removed",
779            Self::DocumentRemoved => "document-removed",
780            Self::ExternalOutOfScope => "external-out-of-scope",
781            Self::OpaqueMdxRegion => "opaque-mdx-region",
782            Self::OpaqueHtmlRegion => "opaque-html-region",
783            Self::ObservationCorrelationAmbiguous => "observation-correlation-ambiguous",
784            Self::UnlinkedDocument => "unlinked-document",
785            Self::PolicyWeakened => "policy-weakened",
786            Self::CoverageReduced => "coverage-reduced",
787            Self::ControlPlaneChanged => "control-plane-changed",
788            Self::DebtWorsened => "debt-worsened",
789            Self::DebtExpired => "debt-expired",
790            Self::WaiverInvalid => "waiver-invalid",
791        }
792    }
793
794    /// The closed key-scope assignment.
795    #[must_use]
796    pub const fn scope(self) -> FindingScope {
797        match self {
798            Self::ExplicitTargetMissing | Self::ExplicitTargetTypeMismatch => {
799                FindingScope::Reference
800            }
801            Self::InvalidReference
802            | Self::UnsupportedReferenceSemantics
803            | Self::UnsupportedTargetKind
804            | Self::UnsupportedVersionScope
805            | Self::DependencyChangedSubjectUnchanged
806            | Self::DependencyAndSubjectCochanged
807            | Self::SubjectChanged
808            | Self::ExplicitReferenceRemoved
809            | Self::ExternalOutOfScope
810            | Self::ObservationCorrelationAmbiguous => FindingScope::Observation,
811            Self::UnsupportedDocumentFormat
812            | Self::DocumentRemoved
813            | Self::OpaqueMdxRegion
814            | Self::OpaqueHtmlRegion
815            | Self::UnlinkedDocument => FindingScope::Document,
816            Self::UnsupportedCapability
817            | Self::PolicyWeakened
818            | Self::CoverageReduced
819            | Self::ControlPlaneChanged
820            | Self::DebtWorsened
821            | Self::DebtExpired
822            | Self::WaiverInvalid => FindingScope::Control,
823        }
824    }
825
826    /// The first policy-step result for a candidate fact under
827    /// `scanner-policy-defaults-v1`, per profile.
828    #[must_use]
829    pub const fn built_in_disposition(self, enforce: bool) -> Disposition {
830        match self {
831            Self::ExplicitTargetMissing
832            | Self::ExplicitTargetTypeMismatch
833            | Self::InvalidReference => {
834                if enforce {
835                    Disposition::Fail
836                } else {
837                    Disposition::Warn
838                }
839            }
840            Self::UnsupportedCapability
841            | Self::PolicyWeakened
842            | Self::CoverageReduced
843            | Self::ControlPlaneChanged
844            | Self::DebtWorsened
845            | Self::DebtExpired
846            | Self::WaiverInvalid => Disposition::Fail,
847            Self::DependencyChangedSubjectUnchanged | Self::ExplicitReferenceRemoved => {
848                Disposition::Warn
849            }
850            Self::UnsupportedReferenceSemantics
851            | Self::UnsupportedDocumentFormat
852            | Self::UnsupportedTargetKind
853            | Self::UnsupportedVersionScope
854            | Self::DependencyAndSubjectCochanged
855            | Self::SubjectChanged
856            | Self::DocumentRemoved
857            | Self::ExternalOutOfScope
858            | Self::OpaqueMdxRegion
859            | Self::OpaqueHtmlRegion
860            | Self::ObservationCorrelationAmbiguous
861            | Self::UnlinkedDocument => Disposition::Record,
862        }
863    }
864}
865
866pub const SANDBOX_SCHEMA: &str = "amiss/scanner-sandbox-profile/v1";
867
868/// The zero-capability sandbox descriptor the engine asserts for itself, and
869/// its digest. A future wrapper verifies rather than asserts it.
870#[must_use]
871pub fn sandbox_descriptor() -> (Value, Digest) {
872    let descriptor = object(vec![
873        ("schema", string(SANDBOX_SCHEMA)),
874        ("profile", string("scanner-v0-zero-capability-v1")),
875        ("isolation", string("process")),
876        ("network", string("denied")),
877        ("child_processes", string("denied")),
878        ("repository_processes", string("denied")),
879        ("credentials", string("absent")),
880        ("secrets", string("absent")),
881        ("shared_cache", string("denied")),
882        ("workspace", string("read-only")),
883        ("environment", string("scanner-process-env-v1")),
884        (
885            "physical_memory",
886            object(vec![("maximum_bytes", Value::Integer(1_073_741_824))]),
887        ),
888        (
889            "temporary_storage",
890            object(vec![
891                ("kind", string("private-bounded")),
892                ("maximum_bytes", Value::Integer(67_108_864)),
893            ]),
894        ),
895        (
896            "watchdog",
897            object(vec![("maximum_milliseconds", Value::Integer(120_000))]),
898        ),
899    ]);
900    let digest = hj(SANDBOX_SCHEMA, &descriptor);
901    (descriptor, digest)
902}
903
904impl FindingKind {
905    #[must_use]
906    pub const fn evidence_class(self) -> &'static str {
907        match self {
908            Self::ExplicitTargetMissing
909            | Self::ExplicitTargetTypeMismatch
910            | Self::InvalidReference => "deterministic-structural",
911            Self::UnsupportedCapability
912            | Self::UnsupportedReferenceSemantics
913            | Self::UnsupportedDocumentFormat
914            | Self::UnsupportedTargetKind
915            | Self::UnsupportedVersionScope => "unsupported",
916            Self::DependencyChangedSubjectUnchanged
917            | Self::DependencyAndSubjectCochanged
918            | Self::SubjectChanged => "impact-observation",
919            Self::ExplicitReferenceRemoved
920            | Self::DocumentRemoved
921            | Self::ExternalOutOfScope
922            | Self::OpaqueMdxRegion
923            | Self::OpaqueHtmlRegion
924            | Self::ObservationCorrelationAmbiguous
925            | Self::UnlinkedDocument => "coverage-boundary",
926            Self::PolicyWeakened
927            | Self::CoverageReduced
928            | Self::ControlPlaneChanged
929            | Self::DebtWorsened
930            | Self::DebtExpired
931            | Self::WaiverInvalid => "control-plane",
932        }
933    }
934
935    #[must_use]
936    pub const fn invariant_class(self) -> &'static str {
937        match self {
938            Self::ExplicitTargetMissing
939            | Self::ExplicitTargetTypeMismatch
940            | Self::InvalidReference => "ratcheted",
941            Self::UnsupportedCapability => "analysis-integrity",
942            Self::UnsupportedReferenceSemantics
943            | Self::UnsupportedDocumentFormat
944            | Self::UnsupportedTargetKind
945            | Self::UnsupportedVersionScope
946            | Self::DependencyChangedSubjectUnchanged
947            | Self::DependencyAndSubjectCochanged
948            | Self::SubjectChanged
949            | Self::ExplicitReferenceRemoved
950            | Self::DocumentRemoved
951            | Self::ExternalOutOfScope
952            | Self::OpaqueMdxRegion
953            | Self::OpaqueHtmlRegion
954            | Self::ObservationCorrelationAmbiguous
955            | Self::UnlinkedDocument => "advisory",
956            Self::PolicyWeakened
957            | Self::CoverageReduced
958            | Self::ControlPlaneChanged
959            | Self::DebtWorsened
960            | Self::DebtExpired
961            | Self::WaiverInvalid => "absolute",
962        }
963    }
964}
965
966/// One typed analysis error's reportable detail: the code, the exact path
967/// where the partition names one, the raw bytes of a name the report cannot
968/// hold as text, and the crossing triple for a resource error. Field order
969/// is the canonical error key, so the derived ordering is the wire's.
970#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
971pub struct ErrorDetail {
972    pub code: AnalysisErrorCode,
973    pub path: Option<crate::model::RepoPath>,
974    pub path_bytes: Option<Vec<u8>>,
975    pub resource: Option<(crate::controls::ResourceName, u64, u64)>,
976}
977
978impl ErrorDetail {
979    #[must_use]
980    pub fn phase(&self) -> &'static str {
981        self.resource.map_or_else(
982            || self.code.fixed_phase().unwrap_or("internal"),
983            |(name, _limit, _observed)| name.phase(),
984        )
985    }
986}
987
988/// One wire error row with its partition phase.
989#[must_use]
990pub fn error_row_value(detail: &ErrorDetail) -> Value {
991    error_row(detail, detail.phase())
992}
993
994fn error_row(detail: &ErrorDetail, phase: &str) -> Value {
995    let (resource, limit, observed) = detail.resource.map_or(
996        (Value::Null, Value::Null, Value::Null),
997        |(name, limit, observed)| {
998            (
999                string(name.as_str()),
1000                Value::Integer(i64::try_from(limit).unwrap_or(i64::MAX)),
1001                Value::Integer(i64::try_from(observed).unwrap_or(i64::MAX)),
1002            )
1003        },
1004    );
1005    object(vec![
1006        ("phase", string(phase)),
1007        ("code", string(detail.code.as_str())),
1008        (
1009            "path",
1010            detail
1011                .path
1012                .as_ref()
1013                .map_or(Value::Null, crate::model::RepoPath::to_value),
1014        ),
1015        (
1016            "path_bytes_hex",
1017            detail.path_bytes.as_deref().map_or(Value::Null, |bytes| {
1018                Value::String(crate::model::hex_lower(bytes))
1019            }),
1020        ),
1021        ("resource", resource),
1022        ("configured_limit", limit),
1023        ("observed_lower_bound", observed),
1024    ])
1025}