Skip to content
Open
18 changes: 15 additions & 3 deletions crates/registry-coordinator/src/authoring.rs
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,7 @@ enum AuthoredStep {
on_cancel: Option<LocalId>,
},
/// Calls one operation through a connection, then continues with its
/// output available to later steps.
/// successful output available to later steps.
Call {
/// The connection the call goes through, declared under `connections`.
connection: LocalId,
Expand All @@ -403,6 +403,12 @@ enum AuthoredStep {
input: AuthoredInput,
/// The step that follows a completed call.
next: LocalId,
/// The step that follows a product-declared, no-effect business
/// refusal. Only operations whose reference declares this capability
/// accept the member; other refusals still stop the run. Omit it to
/// fail the run with `product-refused` on a declared refusal too.
#[serde(default)]
on_declared_refusal: Option<LocalId>,
},
/// Selects a label and continues with the step its case names.
Choose {
Expand Down Expand Up @@ -514,6 +520,7 @@ impl TryFrom<AuthoredStep> for Step {
operation,
input,
next,
on_declared_refusal,
} => Self::Call {
call: Call {
operation: Operation::parse(&operation.0)
Expand All @@ -522,6 +529,7 @@ impl TryFrom<AuthoredStep> for Step {
},
input: input.into(),
next: next.into_string(),
on_declared_refusal: on_declared_refusal.map(LocalId::into_string),
},
AuthoredStep::Choose { choose, cases } => Self::Choose {
choose: choose.into(),
Expand Down Expand Up @@ -799,13 +807,17 @@ fn operation_input_members() -> serde_json::Value {
if let Some(required) = input.get("required") {
fields["required"] = required.clone();
}
serde_json::json!({
let mut rule = serde_json::json!({
"if": {
"properties": {"operation": {"const": descriptor.id}},
"required": ["operation"]
},
"then": {"properties": {"input": {"properties": {"fields": fields}}}}
})
});
if !descriptor.declared_refusal {
rule["then"]["not"] = serde_json::json!({"required": ["onDeclaredRefusal"]});
}
rule
})
.collect()
}
Expand Down
56 changes: 55 additions & 1 deletion crates/registry-coordinator/src/breg_action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use serde_json::{Map, Value};

use crate::{
adapters::{refused, retryable, token_failure},
protocol::{CallOutcome, CallRequest, Operation},
protocol::{CallOutcome, CallRequest, DeclaredRefusal, Operation},
};

#[derive(Deserialize)]
Expand All @@ -40,6 +40,18 @@ fn input(request: &CallRequest) -> Result<(ActionInput, BRegIdempotencyKey), Cal
Ok((input, key))
}

fn declared_refusal(
mutation: bool,
status: Option<u16>,
problem: Option<BRegProblemCode>,
refusal_code: Option<&str>,
) -> Option<DeclaredRefusal> {
(mutation && status == Some(422) && problem == Some(BRegProblemCode::ActionRefused))
.then_some(refusal_code)
.flatten()
.and_then(DeclaredRefusal::new)
}

fn failure(error: BaseRegistryClientError, mutation: bool) -> CallOutcome {
if mutation && error.problem_code() == Some(BRegProblemCode::IdempotencyExpired) {
return CallOutcome::ReceiptExpired;
Expand All @@ -49,6 +61,14 @@ fn failure(error: BaseRegistryClientError, mutation: bool) -> CallOutcome {
code: "transport-uncertain".into(),
};
}
if let Some(refusal) = declared_refusal(
mutation,
error.status(),
error.problem_code(),
error.refusal_code().map(|code| code.as_str()),
) {
return CallOutcome::DeclaredRefusal(refusal);
}
match error {
BaseRegistryClientError::Token(error) => token_failure(&error),
BaseRegistryClientError::Transport { .. } => retryable("transport-unavailable"),
Expand Down Expand Up @@ -165,3 +185,37 @@ pub async fn execute(
Err(error) => failure(error, true),
}
}

#[cfg(test)]
mod tests {
use super::declared_refusal;
use registry_breg_client::BRegProblemCode;

#[test]
fn declared_refusal_classification_requires_every_typed_execution_guard() {
let classify = |mutation, status, problem, code| {
declared_refusal(mutation, status, problem, code)
.map(|refusal| refusal.refusal_code().to_owned())
};
let refused = Some(BRegProblemCode::ActionRefused);
assert_eq!(
classify(true, Some(422), refused, Some("not-eligible")),
Some("not-eligible".to_owned())
);
for refusal in [
classify(false, Some(422), refused, Some("not-eligible")),
classify(true, Some(409), refused, Some("not-eligible")),
classify(
true,
Some(422),
Some(BRegProblemCode::PreconditionFailed),
Some("not-eligible"),
),
classify(true, Some(422), refused, None),
classify(true, Some(422), refused, Some("line\nbreak")),
classify(true, Some(422), refused, Some(&"x".repeat(129))),
] {
assert_eq!(refusal, None);
}
}
}
101 changes: 91 additions & 10 deletions crates/registry-coordinator/src/contracts.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// SPDX-License-Identifier: Apache-2.0
//! Operation contracts: the JSON Schema of the input each catalog operation's
//! adapter decodes and of the output it returns. The committed generated files
//! are embedded, so the default build derives no schemas.
//! adapter decodes, the success output it returns, and any declared-refusal
//! output it exposes to an authored branch. The committed generated files are
//! embedded, so the default build derives no schemas.

use std::{collections::BTreeMap, sync::OnceLock};

Expand All @@ -13,10 +14,12 @@ use serde_json::Value;

use crate::protocol::Operation;

/// One operation's input and output schemas, as committed.
/// One operation's input and route-specific output schemas, as committed.
pub struct OperationContract {
pub input: Value,
pub output: Value,
/// Output made available only on an authored `onDeclaredRefusal` route.
pub declared_refusal: Option<Value>,
input_validator: JSONSchema,
output_validator: JSONSchema,
}
Expand Down Expand Up @@ -165,6 +168,13 @@ static EMBEDDED: &[(&str, &str, &str)] = embedded!(
"submit-review-request",
);

static DECLARED_REFUSALS: &[(&str, &str)] = &[(
"invoke-breg-action",
include_str!(
"../../../products/coordinator/generated/operations/invoke-breg-action.declared-refusal.schema.json"
),
)];

/// The committed contract of a wait event: the value a wait-for step that
/// continues with `next` holds as its output.
pub struct EventContract {
Expand Down Expand Up @@ -222,13 +232,20 @@ pub fn contract(operation: Operation) -> &'static OperationContract {
};
let (input, input_validator) = parse(input);
let (output, output_validator) = parse(output);
let declared_refusal = DECLARED_REFUSALS
.iter()
.find(|(operation, _)| operation == id)
.map(|(_, schema)| {
serde_json::from_str(schema).expect("committed operation contract is JSON")
});
(
*id,
OperationContract {
input,
output,
input_validator,
output_validator,
declared_refusal,
},
)
})
Expand Down Expand Up @@ -342,6 +359,14 @@ pub fn generate() -> Result<BTreeMap<String, String>, serde_json::Error> {
let mut text = serde_json::to_string_pretty(&event)?;
text.push('\n');
files.insert("review-result.event.schema.json".into(), text);
let mut refusal = derived::output::<crate::protocol::DeclaredRefusal>();
derived::finish(&mut refusal, "invoke-breg-action declared-refusal output");
let mut text = serde_json::to_string_pretty(&refusal)?;
text.push('\n');
files.insert(
"invoke-breg-action.declared-refusal.schema.json".into(),
text,
);
files.insert("README.md".into(), derived::reference(&files)?);
Ok(files)
}
Expand Down Expand Up @@ -519,16 +544,18 @@ mod derived {
let mut text = String::from(
"<!-- Generated by the operation-schemas example of registry-coordinator. Do not edit. -->\n\
# Coordinator operation reference\n\n\
Each catalog operation has an input contract, the type its adapter decodes, and an \
output contract, the type its adapter returns as the step output. A mapping that \
Each catalog operation has an input contract, the type its adapter decodes, and a \
success output contract, the type its adapter returns on `next`. A mapping that \
feeds a call must produce a value its input contract accepts; a reference into a \
call step's output must name a path its output contract declares.\n\n\
| Operation | Version | Product | Effect | Key | Preparation | Recovery | Receipt |\n\
|---|---|---|---|---|---|---|---|\n",
call step's output must name a path the contract for that route declares. Only the \
operations marked below accept `onDeclaredRefusal`; that route receives the closed \
declared-refusal output and no product detail or call input.\n\n\
| Operation | Version | Product | Effect | Key | Preparation | Recovery | Receipt | Declared refusal |\n\
|---|---|---|---|---|---|---|---|---|\n",
);
for descriptor in crate::operations::descriptors() {
text.push_str(&format!(
"| [`{id}`](#{id}) | {version} | `{product}` | {effect} | {key} | {preparation} | {recovery} | {receipt} |\n",
"| [`{id}`](#{id}) | {version} | `{product}` | {effect} | {key} | {preparation} | {recovery} | {receipt} | {declared_refusal} |\n",
id = descriptor.id,
version = descriptor.version,
product = descriptor.product,
Expand All @@ -537,6 +564,14 @@ mod derived {
preparation = if descriptor.requires_preparation { "required" } else { "none" },
recovery = word(&descriptor.recovery)?,
receipt = if descriptor.read_receipt { "read" } else { "none" },
declared_refusal = if descriptor.declared_refusal {
format!(
"[`onDeclaredRefusal`]({}.declared-refusal.schema.json)",
descriptor.id
)
} else {
"none".into()
},
));
}
for descriptor in crate::operations::descriptors() {
Expand All @@ -550,6 +585,14 @@ mod derived {
));
text.push_str(&members(&schema));
}
if descriptor.declared_refusal {
let file = format!("{}.declared-refusal.schema.json", descriptor.id);
let schema: Value = serde_json::from_str(&files[&file])?;
text.push_str(&format!(
"\nDeclared-refusal output ([`{file}`]({file})):\n\n"
));
text.push_str(&members(&schema));
}
}
text.push_str(
"\n## Wait events\n\n\
Expand Down Expand Up @@ -716,7 +759,10 @@ mod tests {
for descriptor in crate::operations::descriptors() {
let operation = Operation::parse(descriptor.id).unwrap();
let contract = contract(operation);
for schema in [&contract.input, &contract.output] {
for schema in [&contract.input, &contract.output]
.into_iter()
.chain(contract.declared_refusal.iter())
{
assert!(schema.get("$id").is_none());
crate::definition::compile_schema(schema).unwrap_or_else(|error| {
panic!("{} contract does not compile: {error}", descriptor.id)
Expand Down Expand Up @@ -788,6 +834,35 @@ mod tests {
);
}

#[test]
fn only_the_breg_action_contract_exposes_a_closed_declared_refusal() {
for descriptor in crate::operations::descriptors() {
let operation = Operation::parse(descriptor.id).unwrap();
let contract = contract(operation);
assert_eq!(
contract.declared_refusal.is_some(),
descriptor.declared_refusal,
"{}",
descriptor.id
);
}
let refusal = contract(Operation::InvokeBregAction)
.declared_refusal
.as_ref()
.unwrap();
let validator = crate::definition::compile_schema(refusal)
.unwrap_or_else(|error| panic!("declared-refusal contract does not compile: {error}"));
assert_eq!(
first_violation(&validator, &json!({"refusalCode":"not-eligible"})),
Ok(())
);
assert!(first_violation(
&validator,
&json!({"refusalCode":"not-eligible", "detail":"private-canary"})
)
.is_err());
}

#[test]
fn the_declared_path_leaves_out_an_unexpected_member_name() {
let read = contract(Operation::ReadRecord);
Expand Down Expand Up @@ -1148,5 +1223,11 @@ mod tests {
for (id, event) in EVENTS {
assert_eq!(generated[&format!("{id}.event.schema.json")], *event);
}
for (id, refusal) in DECLARED_REFUSALS {
assert_eq!(
generated[&format!("{id}.declared-refusal.schema.json")],
*refusal
);
}
}
}
Loading