plecto-control 0.3.6

Plecto's control plane: declarative manifest, OCI artifact loading, filter-chain dispatch, and atomic hot reload.
Documentation
//! PLECTO-E stable diagnostic codes (ADR 000065 decision 5): a rejection or fault gets a
//! four-part diagnostic — code, one-line cause, a fix suggestion, and a docs pointer — when the
//! plain error message alone would leave a newcomer guessing. Not every `ControlError` /
//! `x-plecto-fault` gets a code: reserve them for the ones that need more than the message
//! (the same selective-assignment principle rustc's own diagnostics use). This module is the
//! single place the code/cause/suggestion/docs TEXT lives; `plecto-control` and `plecto-server`
//! attach a `Diagnostic` at whichever layer first turns the underlying error into something a
//! human reads — `plecto-host` itself stays unaware of PLECTO-E (see host/CONTEXT.md
//! "Conformant (component)" and control/CONTEXT.md "Dev key" for the surrounding vocabulary).
//!
//! Initial coverage is deliberately the three walls a newcomer hits first (ADR 000065 decision
//! 5): signature verification failure, quota exceeded, path normalization rejection.

use plecto_host::LoadError;

use crate::ControlError;

/// A stable four-part diagnostic. `docs` is a repo-relative path (ADR 000065: "docs 参照は当面
/// リポジトリ内相対" — no dedicated docs domain yet), printed as a hint, not resolved from disk.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Diagnostic {
    pub code: &'static str,
    pub cause: &'static str,
    pub suggestion: &'static str,
    pub docs: &'static str,
}

impl std::fmt::Display for Diagnostic {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            f,
            "{}: {}\n  suggestion: {}\n  docs: {}",
            self.code, self.cause, self.suggestion, self.docs
        )
    }
}

/// Signature verification failure — the first wall (ADR 000006 provenance gate).
pub const SIGNATURE_VERIFICATION_FAILED: Diagnostic = Diagnostic {
    code: "PLECTO-E0001",
    cause: "the component or SBOM signature does not verify against any key in the manifest's [trust]",
    suggestion: "sign with a key listed under [trust] (cosign sign-blob or your CI's signer); for \
        local dev, run `plecto dev`, which signs with your project's .plecto/dev-key automatically",
    docs: "docs/ADR/000006.md",
};

/// Host-native rate-limit bucket exhausted — the second wall.
pub const QUOTA_EXCEEDED: Diagnostic = Diagnostic {
    code: "PLECTO-E0002",
    cause: "the request exceeded the filter's host-native rate-limit bucket",
    suggestion: "raise [filter.ratelimit] capacity/refill_per_sec in the manifest, or have the \
        client back off using the retry-after-ms header on the 429",
    docs: "docs/ADR/000026.md",
};

/// Request path failed normalization — the third wall.
pub const PATH_NORMALIZATION_REJECTED: Diagnostic = Diagnostic {
    code: "PLECTO-E0003",
    cause: "the request path failed normalization (e.g. `..` traversal, invalid percent-encoding, \
        or a raw control byte)",
    suggestion: "this rejects the client's request, not the manifest; if a legitimate client hits \
        this, check what it is sending for the path",
    docs: "docs/ADR/000013.md",
};

/// A manifest `[trust]` key is marked as a dev key (ADR 000065 decision 2) — warned, not
/// rejected, because a `plecto dev`-generated manifest is SUPPOSED to reference one; this only
/// flags the case for a human to double check when it shows up somewhere they did not expect.
pub const DEV_KEY_IN_TRUST: Diagnostic = Diagnostic {
    code: "PLECTO-E0004",
    cause: "a [trust] key file carries the plecto-dev-key marker — it was generated by `plecto \
        dev`/`plecto new-filter`, not a production signing key",
    suggestion: "if this manifest is meant for production, replace the key with one from your \
        real signing pipeline; if it's a dev manifest, this warning is expected",
    docs: "docs/ADR/000065.md",
};

/// Map a `ControlError` to its `Diagnostic`, if the plain error message needs one. `None` means
/// the thiserror message already stands on its own — most variants do.
pub fn diagnose(err: &ControlError) -> Option<Diagnostic> {
    match err {
        ControlError::Load { err, .. } => match err.downcast_ref::<LoadError>() {
            Some(LoadError::UnverifiedComponentSignature | LoadError::UnverifiedSbomSignature) => {
                Some(SIGNATURE_VERIFICATION_FAILED)
            }
            _ => None,
        },
        _ => None,
    }
}

/// Render `err` for a human-facing surface (startup, `plecto dev`), appending the four-part
/// diagnostic when one is registered — the piece that actually puts PLECTO-E0001 in front of a
/// newcomer whose filter failed the signature gate (ADR 000065 decision 5).
pub fn diagnosed_message(err: &ControlError) -> String {
    match diagnose(err) {
        Some(diagnostic) => format!("{err}\n{diagnostic}"),
        None => err.to_string(),
    }
}

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

    #[test]
    fn unverified_component_signature_maps_to_e0001() {
        let err = ControlError::Load {
            id: "f".to_string(),
            err: anyhow::Error::from(LoadError::UnverifiedComponentSignature),
        };
        assert_eq!(diagnose(&err), Some(SIGNATURE_VERIFICATION_FAILED));
    }

    #[test]
    fn unrelated_control_errors_get_no_diagnostic() {
        let err = ControlError::DuplicateFilterId("f".to_string());
        assert_eq!(diagnose(&err), None);
    }

    #[test]
    fn display_renders_all_four_parts() {
        let rendered = SIGNATURE_VERIFICATION_FAILED.to_string();
        assert!(rendered.contains("PLECTO-E0001"));
        assert!(rendered.contains("suggestion:"));
        assert!(rendered.contains("docs:"));
    }

    #[test]
    fn diagnosed_message_appends_the_diagnostic_only_when_one_is_registered() {
        let signature = ControlError::Load {
            id: "f".to_string(),
            err: anyhow::Error::from(LoadError::UnverifiedComponentSignature),
        };
        let rendered = diagnosed_message(&signature);
        assert!(rendered.contains("failed the load gate"));
        assert!(rendered.contains("PLECTO-E0001"));
        assert!(rendered.contains("suggestion:"));

        let plain = ControlError::DuplicateFilterId("f".to_string());
        assert_eq!(diagnosed_message(&plain), plain.to_string());
    }
}