Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion crates/registry-evidence/src/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ pub enum AuditDecision {
Released,
NoMatch,
Ambiguous,
Unresolved,
FactMissing,
DependencyFailure,
EvaluationFailure,
Expand Down Expand Up @@ -610,7 +611,10 @@ impl EvidenceAuditEvent {
| (AuditPhase::DisclosureRelease, AuditDecision::Released)
| (
AuditPhase::Denial,
AuditDecision::NoMatch | AuditDecision::Ambiguous | AuditDecision::FactMissing
AuditDecision::NoMatch
| AuditDecision::Ambiguous
| AuditDecision::Unresolved
| AuditDecision::FactMissing
)
| (
AuditPhase::TransientFailure,
Expand Down Expand Up @@ -1875,6 +1879,15 @@ mod tests {
);
}

let mut unresolved = fixture["access_attempt"].clone();
unresolved["phase"] = serde_json::json!("denial");
unresolved["decision"] = serde_json::json!("unresolved");
unresolved["safeErrorCategory"] = serde_json::json!("unresolved");
assert!(
validator.is_valid(&unresolved),
"schema rejects the neutral declared-unresolved denial"
);

let mut refusal_with_full_schema = fixture["authorization_refusal"].clone();
refusal_with_full_schema["schema"] = serde_json::json!(AUDIT_SCHEMA);

Expand Down
70 changes: 63 additions & 7 deletions crates/registry-evidence/src/bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2127,25 +2127,37 @@ fn load_fixtures(
if fixtures.contains_key(path) {
continue;
}
let fixture = load_fixture(path, files).map_err(|error| error.in_artifact(path))?;
let declared_unresolved = config
.sources
.get(requirement.initial_source())
.is_some_and(|source| source.unresolved_problem().is_some());
Comment on lines +2130 to +2133

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate unresolved fixtures for every requirement

When two requirements reference the same fixture path, this requirement-specific flag is computed only for the first one because the existing cache check at lines 2127-2129 skips every later reference. If the first requirement's initial source declares unresolvedProblem but a later one's does not, Bundle::load accepts the fixture's declaredUnresolved case without ever validating it against the later source; configuration validation does not prohibit shared fixture paths, so a directly loaded runtime bundle can bypass this new startup invariant depending on requirement order. Validate the marker for every referencing requirement before reusing the parsed fixture, or reject duplicate references at bundle load.

AGENTS.md reference: products/evidence/AGENTS.md:L85-L94

Useful? React with 👍 / 👎.

let fixture = load_fixture(path, files, declared_unresolved)
.map_err(|error| error.in_artifact(path))?;
fixtures.insert(path.to_owned(), fixture);
}
Ok(fixtures)
}

fn load_fixture(path: &str, files: &BTreeMap<String, Vec<u8>>) -> Result<YamlValue, BundleError> {
fn load_fixture(
path: &str,
files: &BTreeMap<String, Vec<u8>>,
declared_unresolved: bool,
) -> Result<YamlValue, BundleError> {
let bytes = files
.get(path)
.ok_or(invalid_artifact("fixture file is missing"))?;
let text =
std::str::from_utf8(bytes).map_err(|_| invalid_artifact("fixture file is not UTF-8"))?;
let fixture: YamlValue =
serde_norway::from_str(text).map_err(|_| invalid_artifact("fixture YAML is invalid"))?;
validate_fixture_coverage(&fixture)?;
validate_fixture_coverage(&fixture, declared_unresolved)?;
Ok(fixture)
}

fn validate_fixture_coverage(fixture: &YamlValue) -> Result<(), BundleError> {
fn validate_fixture_coverage(
fixture: &YamlValue,
declared_unresolved: bool,
) -> Result<(), BundleError> {
let root = fixture
.as_mapping()
.ok_or(invalid_artifact("fixture root must be a mapping"))?;
Expand All @@ -2170,7 +2182,24 @@ fn validate_fixture_coverage(fixture: &YamlValue) -> Result<(), BundleError> {
if id.is_empty() || id.len() > 128 || !ids.insert(id) {
return Err(invalid_artifact("fixture case id is invalid or duplicated"));
}
categories.observe(id);
let mapping = case
.as_mapping()
.ok_or(invalid_artifact("fixture case must be a mapping"))?;
let fixture_declares_unresolved = match mapping.get("declaredUnresolved") {
Some(YamlValue::Bool(true)) if declared_unresolved => true,
Some(YamlValue::Bool(true)) => {
return Err(invalid_artifact(
"fixture declared unresolved without a source declaration",
));
}
Some(_) => {
return Err(invalid_artifact(
"fixture declared-unresolved marker must be true",
));
}
None => false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require a fixture case for each unresolved declaration

When an initial source declares unresolvedProblem but its fixture retains the old no-match and ambiguous cases, None => false leaves those existing IDs sufficient for FixtureCategories::complete(), so the production/evidence-grade bundle loads without any declaredUnresolved case. This lets a project claim complete fixture coverage without exercising the newly governed data-free outcome that the fixture contract says every declaring source must include; track whether the marker was observed and reject its absence when declared_unresolved is true, with a focused negative test.

AGENTS.md reference: products/evidence/AGENTS.md:L96-L102

Useful? React with 👍 / 👎.

};
categories.observe(id, fixture_declares_unresolved);
}
if !categories.complete() {
return Err(invalid_artifact("fixture category coverage is incomplete"));
Expand All @@ -2191,13 +2220,19 @@ struct FixtureCategories {
}

impl FixtureCategories {
fn observe(&mut self, id: &str) {
fn observe(&mut self, id: &str, declared_unresolved: bool) {
self.positive |= id == "positive";
self.negative |= id.starts_with("negative");
self.boundary |= id.starts_with("boundary");
self.missing |= id.starts_with("missing");
self.no_match |= id == "no-match";
self.ambiguous |= id.starts_with("ambiguous");
// The configured source has already collapsed its hidden no-match and
// ambiguous states into one exact transport outcome. Evidence cannot
// truthfully label the fixture as either branch, so the neutral case
// proves the public behavior shared by both completeness categories.
self.no_match |= declared_unresolved;
self.ambiguous |= declared_unresolved;
self.source_failure |= id == "source-failure";
self.anti_reconstruction |= id == "anti-reconstruction";
}
Expand Down Expand Up @@ -3317,7 +3352,28 @@ mod tests {
"synthetic_only: true\ncases:\n - {id: positive}\n - {id: negative-a}\n - {id: boundary-a}\n - {id: missing-a}\n - {id: no-match}\n - {id: ambiguous}\n - {id: source-failure}\n - {id: anti-reconstruction}\n",
)
.expect("fixture parses");
assert!(validate_fixture_coverage(&fixture).is_ok());
assert!(validate_fixture_coverage(&fixture, false).is_ok());
}

/// A provider that deliberately collapses hidden no-match and ambiguity
/// into one configured wire outcome leaves Evidence no truthful basis for
/// inventing two extraction responses. The one neutral, data-free case is
/// therefore sufficient for both public-collapse coverage categories.
#[test]
fn declared_unresolved_fixture_neutrally_covers_hidden_lookup_categories() {
let fixture: YamlValue = serde_norway::from_str(
"synthetic_only: true\ncases:\n - {id: positive}\n - {id: negative-a}\n - {id: boundary-a}\n - {id: missing-a}\n - {id: unresolved, declaredUnresolved: true}\n - {id: source-failure}\n - {id: anti-reconstruction}\n",
)
.expect("fixture parses");

assert!(validate_fixture_coverage(&fixture, true).is_ok());
assert!(validate_fixture_coverage(&fixture, false).is_err());

let false_marker: YamlValue = serde_norway::from_str(
"synthetic_only: true\ncases:\n - {id: positive}\n - {id: negative-a}\n - {id: boundary-a}\n - {id: missing-a}\n - {id: unresolved, declaredUnresolved: false}\n - {id: source-failure}\n - {id: anti-reconstruction}\n",
)
.expect("fixture parses");
assert!(validate_fixture_coverage(&false_marker, true).is_err());
}

#[cfg(unix)]
Expand Down
160 changes: 160 additions & 0 deletions crates/registry-evidence/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1985,6 +1985,11 @@ pub enum SourceConfig {
HttpJson {
base_url: String,
posture: AcquisitionPosture,
/// Optional exact upstream Problem Details tuple which means that the

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add the missing DCO sign-off

For any PR containing this commit, the DCO job will fail because its message has no Signed-off-by: Name <email> trailer; .github/workflows/dco.yml:27-38 checks every non-merge commit and exits unsuccessfully when that trailer is absent. Recreate this commit with git commit -s before submitting it.

AGENTS.md reference: AGENTS.md:L274-L274

Useful? React with 👍 / 👎.

/// source deliberately did not resolve this lookup. The transport
/// remains source-neutral: no provider code is built in.
#[serde(default, skip_serializing_if = "Option::is_none")]
unresolved_problem: Option<DeclaredUnresolvedProblem>,
#[serde(default, skip_serializing_if = "Option::is_none")]
tls_trust_profile: Option<String>,
authentication: Box<SourceAuthentication>,
Expand Down Expand Up @@ -2029,6 +2034,7 @@ impl SourceConfig {
authentication,
request,
batch,
unresolved_problem,
..
} => {
validate_source_origin(base_url)?;
Expand All @@ -2053,6 +2059,14 @@ impl SourceConfig {
}
authentication.validate()?;
request.validate()?;
if let Some(problem) = unresolved_problem {
problem.validate()?;
if batch.is_some() {
return invalid(
"declared unresolved problems are not supported by source batching",
);
}
}
if let Some(batch) = batch {
if request.path.is_none() || request.path_template.is_some() {
return invalid("source batch optimization requires a fixed request path");
Expand Down Expand Up @@ -2170,6 +2184,19 @@ impl SourceConfig {
}
}

/// The exact source-neutral unresolved tuple this HTTP source recognizes.
///
/// Callers receive only the governed declaration, never an upstream
/// Problem Details body. Statement sources cannot declare this outcome.
pub fn unresolved_problem(&self) -> Option<&DeclaredUnresolvedProblem> {
match self {
Self::HttpJson {
unresolved_problem, ..
} => unresolved_problem.as_ref(),
Self::SqliteExtract { .. } => None,
}
}

/// The reviewed statement artifact, where the transport runs one.
pub fn statement(&self) -> Option<&ArtifactPath> {
match self {
Expand Down Expand Up @@ -2301,6 +2328,49 @@ impl SourceConfig {
}
}

/// Exact, source-neutral Problem Details tuple an HTTP source may declare as
/// an explicit unresolved lookup outcome.
#[derive(Debug, Clone, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct DeclaredUnresolvedProblem {
pub status: u16,
#[serde(rename = "type")]
pub type_uri: String,
pub code: String,
}

impl DeclaredUnresolvedProblem {
fn validate(&self) -> Result<(), ConfigError> {
if self.status != 404 {
return invalid("declared unresolved problem status must be 404");
}
if self.type_uri.chars().count() > 512 {
return invalid("declared unresolved problem type is too long");
}
let uri = Url::parse(&self.type_uri)
.map_err(|_| ConfigError::Invalid("declared unresolved problem type is invalid"))?;
if uri.scheme() != "https"
|| uri.host().is_none()
|| !uri.username().is_empty()
|| uri.password().is_some()
{
return invalid("declared unresolved problem type must be an absolute HTTPS URI");
}
let bytes = self.code.as_bytes();
if !(1..=64).contains(&bytes.len())
|| !matches!(bytes.first(), Some(b'a'..=b'z'))
|| !bytes.iter().all(|byte| {
byte.is_ascii_lowercase()
|| byte.is_ascii_digit()
|| matches!(byte, b'.' | b'_' | b'-')
})
{
return invalid("declared unresolved problem code is invalid");
}
Ok(())
}
}

/// One request value filled from a named field of a named selector profile.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub struct SelectorBinding<'a> {
Expand Down Expand Up @@ -7558,6 +7628,96 @@ outboundTls:
)),
"the batch adapter identity emitted to audit must use the closed local grammar"
);

let unresolved_batch = edited(
&document,
" batch:\n",
" unresolvedProblem: {status: 404, type: https://id.example.invalid/problems/unresolved, code: consultation.unresolved}\n batch:\n",
);
assert_eq!(
EvidenceConfig::parse_yaml(unresolved_batch.as_bytes()).err(),
Some(ConfigError::Invalid(
"declared unresolved problems are not supported by source batching"
)),
"one physical response cannot resolve one logical batch item"
);
}

#[test]
fn declared_unresolved_problem_tuple_is_closed_and_bounded() {
DeclaredUnresolvedProblem {
status: 404,
type_uri: "https://id.example.invalid/problems/unresolved".to_owned(),
code: "consultation.unresolved".to_owned(),
}
.validate()
.expect("the exact source-neutral tuple validates");

for (label, problem) in [
(
"status",
DeclaredUnresolvedProblem {
status: 403,
type_uri: "https://id.example.invalid/problems/unresolved".to_owned(),
code: "consultation.unresolved".to_owned(),
},
),
(
"type",
DeclaredUnresolvedProblem {
status: 404,
type_uri: "http://id.example.invalid/problems/unresolved".to_owned(),
code: "consultation.unresolved".to_owned(),
},
),
(
"code",
DeclaredUnresolvedProblem {
status: 404,
type_uri: "https://id.example.invalid/problems/unresolved".to_owned(),
code: "Consultation Unresolved".to_owned(),
},
),
] {
assert!(problem.validate().is_err(), "{label}");
}

let overlong_type = format!("https://id.example.invalid/problems/{}", "a".repeat(513));
let candidate = acceptance_fixture().replacen(
" posture: field-projected\n",
&format!(
" posture: field-projected\n unresolvedProblem: {{status: 404, type: {overlong_type}, code: consultation.unresolved}}\n"
),
1,
);
assert_eq!(
EvidenceConfig::parse_yaml(candidate.as_bytes()).err(),
Some(ConfigError::Invalid(
"declared unresolved problem type is too long"
)),
"runtime parsing must enforce the schema's 512-character type ceiling"
);

assert!(
DeclaredUnresolvedProblem {
status: 404,
type_uri: "https://id.example.invalid/problems/unresolved".to_owned(),
code: format!("a{}", "x".repeat(63)),
}
.validate()
.is_ok(),
"the bounded ASCII code grammar admits exactly 64 characters"
);
assert!(
DeclaredUnresolvedProblem {
status: 404,
type_uri: "https://id.example.invalid/problems/unresolved".to_owned(),
code: format!("a{}", "x".repeat(64)),
}
.validate()
.is_err(),
"the bounded ASCII code grammar rejects 65 characters"
);
}

#[test]
Expand Down
Loading
Loading