feat: execute durable standalone Jobs through Host-owned Internal Workers (T-708)

This commit is contained in:
2026-10-07 00:08:01 +09:00
parent 3c424fd17d
commit 13055d7ee1
30 changed files with 5108 additions and 153 deletions
+1
View File
@@ -18,6 +18,7 @@ futures.workspace = true
config-source.workspace = true
flow = { path = "../flow" }
manifest.workspace = true
job.workspace = true
protocol = { workspace = true }
runtime-api.workspace = true
server-api.workspace = true
+387 -152
View File
@@ -7,26 +7,28 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use worker_runtime::identity::RuntimeWorkerRef;
use crate::{Error, Result};
pub const BACKEND_JOB_RUNTIME_ID: &str = crate::hosts::EMBEDDED_RUNTIME_ID;
pub const DEFAULT_MAX_CONCURRENT_JOBS: u16 = 8;
pub const ABSOLUTE_MAX_CONCURRENT_JOBS: u16 = 64;
pub const DEFAULT_JOB_TIMEOUT_SECONDS: u32 = 120;
pub const MAX_JOB_TIMEOUT_SECONDS: u32 = 600;
pub const DEFAULT_MAX_RESULT_BYTES: u32 = 16 * 1024;
pub const ABSOLUTE_MAX_RESULT_BYTES: u32 = 64 * 1024;
pub const DEFAULT_MAX_ATTEMPTS: u8 = 2;
pub const ABSOLUTE_MAX_ATTEMPTS: u8 = 3;
pub const MAX_JOB_INPUT_BYTES: usize = 128 * 1024;
pub const MAX_JOB_INSTRUCTION_BYTES: usize = 16 * 1024;
pub const MAX_JOB_PURPOSE_BYTES: usize = 120;
pub const MAX_JOB_REFERENCE_BYTES: usize = 512;
pub const MAX_JOB_FAILURE_BYTES: usize = 1024;
pub const MAX_JOB_DELIVERY_BYTES: usize = 16 * 1024;
pub use job::{
ABSOLUTE_MAX_ATTEMPTS, ABSOLUTE_MAX_CONCURRENT_JOBS, ABSOLUTE_MAX_RESULT_BYTES,
DEFAULT_JOB_TIMEOUT_SECONDS, DEFAULT_MAX_ATTEMPTS, DEFAULT_MAX_CONCURRENT_JOBS,
DEFAULT_MAX_RESULT_BYTES, JobResultSubmission as BackendJobResultSubmission,
MAX_JOB_DELIVERY_BYTES, MAX_JOB_FAILURE_BYTES, MAX_JOB_INPUT_BYTES, MAX_JOB_INSTRUCTION_BYTES,
MAX_JOB_PURPOSE_BYTES, MAX_JOB_REFERENCE_BYTES, MAX_JOB_TIMEOUT_SECONDS, attempt_id,
bounded_failure_detail,
};
// Preserve the Backend InvalidInput surface and its exact historical messages.
fn backend_error(error: job::JobError) -> Error {
match error {
job::JobError::InvalidInput(detail) => {
Error::InvalidInput(detail.replacen("Job ", "Backend Job ", 1))
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
@@ -37,6 +39,17 @@ pub enum BackendJobState {
Unknown,
}
impl From<BackendJobState> for job::JobState {
fn from(state: BackendJobState) -> Self {
match state {
BackendJobState::Pending => Self::Pending,
BackendJobState::Completed => Self::Completed,
BackendJobState::Failed => Self::Failed,
BackendJobState::Unknown => Self::Unknown,
}
}
}
impl BackendJobState {
pub(crate) fn as_str(self) -> &'static str {
match self {
@@ -69,6 +82,19 @@ pub enum BackendJobAttemptState {
Unknown,
}
impl From<BackendJobAttemptState> for job::JobAttemptState {
fn from(state: BackendJobAttemptState) -> Self {
match state {
BackendJobAttemptState::Reserved => Self::Reserved,
BackendJobAttemptState::Dispatching => Self::Dispatching,
BackendJobAttemptState::Dispatched => Self::Dispatched,
BackendJobAttemptState::Completed => Self::Completed,
BackendJobAttemptState::Failed => Self::Failed,
BackendJobAttemptState::Unknown => Self::Unknown,
}
}
}
impl BackendJobAttemptState {
pub(crate) fn as_str(self) -> &'static str {
match self {
@@ -161,28 +187,29 @@ impl Default for BackendJobLimits {
impl BackendJobLimits {
pub fn validate(&self) -> Result<()> {
if self.max_concurrent_jobs == 0 || self.max_concurrent_jobs > ABSOLUTE_MAX_CONCURRENT_JOBS
{
return Err(Error::InvalidInput(format!(
"Backend Job max_concurrent_jobs must be in 1..={ABSOLUTE_MAX_CONCURRENT_JOBS}"
)));
job::JobLimits::from(self).validate().map_err(backend_error)
}
}
impl From<&BackendJobLimits> for job::JobLimits {
fn from(limits: &BackendJobLimits) -> Self {
Self {
max_concurrent_jobs: limits.max_concurrent_jobs,
timeout_seconds: limits.timeout_seconds,
max_result_bytes: limits.max_result_bytes,
max_attempts: limits.max_attempts,
}
if self.timeout_seconds == 0 || self.timeout_seconds > MAX_JOB_TIMEOUT_SECONDS {
return Err(Error::InvalidInput(format!(
"Backend Job timeout_seconds must be in 1..={MAX_JOB_TIMEOUT_SECONDS}"
)));
}
}
impl From<job::JobLimits> for BackendJobLimits {
fn from(limits: job::JobLimits) -> Self {
Self {
max_concurrent_jobs: limits.max_concurrent_jobs,
timeout_seconds: limits.timeout_seconds,
max_result_bytes: limits.max_result_bytes,
max_attempts: limits.max_attempts,
}
if self.max_result_bytes == 0 || self.max_result_bytes > ABSOLUTE_MAX_RESULT_BYTES {
return Err(Error::InvalidInput(format!(
"Backend Job max_result_bytes must be in 1..={ABSOLUTE_MAX_RESULT_BYTES}"
)));
}
if self.max_attempts == 0 || self.max_attempts > ABSOLUTE_MAX_ATTEMPTS {
return Err(Error::InvalidInput(format!(
"Backend Job max_attempts must be in 1..={ABSOLUTE_MAX_ATTEMPTS}"
)));
}
Ok(())
}
}
@@ -261,17 +288,29 @@ pub struct BackendJobRequest {
}
impl BackendJobRequest {
pub fn validate(&self) -> Result<()> {
validate_identifier("job_id", &self.job_id, 256)?;
validate_identifier("purpose", &self.purpose, MAX_JOB_PURPOSE_BYTES)?;
validate_identifier("input_revision", &self.input_revision, 256)?;
validate_bounded_text("input_ref", &self.input_ref, MAX_JOB_REFERENCE_BYTES)?;
validate_bounded_text("instruction", &self.instruction, MAX_JOB_INSTRUCTION_BYTES)?;
validate_identifier("profile", &self.profile, 256)?;
self.profile_selector()?;
if let Some(key) = &self.serialization_key {
validate_identifier("serialization_key", key, MAX_JOB_REFERENCE_BYTES)?;
/// Adapt neutral intent without granting domain access or writer/notification
/// authority. Validation remains the Backend submission boundary's job;
/// the caller-selected profile is retained exactly, with no fallback.
pub fn from_job_request(request: job::JobRequest) -> Self {
Self {
job_id: request.job_id,
purpose: request.purpose,
input_revision: request.input_revision,
input_ref: request.input_ref,
input: request.input,
instruction: request.instruction,
profile: request.profile,
grants: BackendJobGrants::default(),
serialization_key: request.serialization_key,
source_worker: None,
notification_target: None,
limits: request.limits.into(),
}
}
pub fn validate(&self) -> Result<()> {
let request = self.to_job_request();
request.validate_fields().map_err(backend_error)?;
if let Some(grant) = &self.grants.subjektiv_consolidation {
validate_identifier("grant subject_id", &grant.subject_id, 256)?;
if grant.candidate_ids.len() > 256 {
@@ -297,44 +336,30 @@ impl BackendJobRequest {
));
}
}
let input = serde_json::to_vec(&self.input).map_err(|error| {
Error::InvalidInput(format!("serialize Backend Job input: {error}"))
})?;
if input.len() > MAX_JOB_INPUT_BYTES {
return Err(Error::InvalidInput(format!(
"Backend Job input exceeds {MAX_JOB_INPUT_BYTES} bytes"
)));
}
request.validate_input().map_err(backend_error)?;
self.limits.validate()
}
/// Transport-neutral projection only. Domain grants, writer authority and
/// derived resource locks remain Backend-owned; never fingerprint this
/// projection as a substitute for the full Backend intent.
pub fn to_job_request(&self) -> job::JobRequest {
job::JobRequest {
job_id: self.job_id.clone(),
purpose: self.purpose.clone(),
input_revision: self.input_revision.clone(),
input_ref: self.input_ref.clone(),
input: self.input.clone(),
instruction: self.instruction.clone(),
profile: self.profile.clone(),
serialization_key: self.serialization_key.clone(),
limits: (&self.limits).into(),
}
}
/// Validate selector syntax only. Registry existence and resolution are dispatch authority.
pub fn profile_selector(&self) -> Result<manifest::ProfileSelector> {
use manifest::ProfileSelector;
validate_identifier("profile", &self.profile, 256)?;
let selector = ProfileSelector::parse_cli(&self.profile);
let valid = match &selector {
ProfileSelector::Default => true,
ProfileSelector::Path { .. } => false,
ProfileSelector::Named { source, name } => {
!name.is_empty()
&& !name.starts_with('.')
&& !name.contains(['/', '\\', ':'])
&& name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
&& ![".toml", ".json", ".dcdl", ".nix"]
.iter()
.any(|suffix| name.ends_with(suffix))
&& (source.is_some() || name != "inherit")
}
};
if !valid {
return Err(Error::InvalidInput(
"Backend Job profile must be a manifest registry selector, not raw source or a path".into(),
));
}
Ok(selector)
job::profile_selector(&self.profile).map_err(backend_error)
}
/// Effective single-flight key. Typed grants cannot bypass their subject fence.
@@ -348,20 +373,12 @@ impl BackendJobRequest {
pub fn fingerprint(&self) -> Result<String> {
self.validate()?;
let encoded = serde_json::to_vec(self).map_err(|error| {
Error::InvalidInput(format!("serialize Backend Job intent: {error}"))
})?;
Ok(sha256(&encoded))
job::fingerprint(self).map_err(backend_error)
}
pub fn worker_input(&self, attempt_id: &str) -> Result<String> {
let input = serde_json::to_string(&self.input).map_err(|error| {
Error::InvalidInput(format!("serialize Backend Job input: {error}"))
})?;
Ok(format!(
"{}\n\nBackend Job envelope (immutable):\njob_id: {}\nattempt_id: {}\ninput_revision: {}\ninput_ref: {}\ninput_json: {}\n\nReturn success only through the structured Backend Job result capability. Final prose and Worker Idle/Stopped state are not result authority.",
self.instruction, self.job_id, attempt_id, self.input_revision, self.input_ref, input
))
job::worker_input_with_label(&self.to_job_request(), attempt_id, "Backend Job")
.map_err(backend_error)
}
}
@@ -451,13 +468,10 @@ pub struct BackendJobReservation {
pub resource_reused: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct BackendJobResultSubmission {
pub job_id: String,
pub attempt_id: String,
pub input_revision: String,
pub result: Value,
impl BackendJobReservation {
pub fn outcome(&self) -> job::JobOutcome {
job_outcome(&self.job, &self.attempt)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -467,6 +481,28 @@ pub struct BackendJobResultAcceptance {
pub replayed: bool,
}
impl BackendJobResultAcceptance {
pub fn outcome(&self) -> job::JobOutcome {
job_outcome(&self.job, &self.attempt)
}
}
// Project the durable Job outcome, not Worker lifecycle, cleanup or delivery.
// The paired attempt supplies its own identity, number and execution state.
fn job_outcome(job: &BackendJobRecord, attempt: &BackendJobAttemptRecord) -> job::JobOutcome {
job::JobOutcome {
job_id: job.request.job_id.clone(),
input_revision: job.request.input_revision.clone(),
attempt_id: attempt.attempt_id.clone(),
attempt: attempt.attempt,
state: job.state.into(),
attempt_state: attempt.state.into(),
result: job.result.clone(),
failure_category: job.failure_category.clone(),
failure_detail: job.failure_detail.clone(),
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BackendJobWorkerBinding {
pub job_id: String,
@@ -489,10 +525,6 @@ pub struct BackendJobDeliveryRecord {
pub delivered_at: Option<String>,
}
pub fn attempt_id(job_id: &str, attempt: u8) -> String {
format!("{job_id}:attempt:{attempt}")
}
pub fn allocation_key(attempt_id: &str) -> String {
format!("backend-job:{attempt_id}")
}
@@ -515,64 +547,15 @@ pub fn delivery_id(job_id: &str, attempt_id: &str) -> String {
}
fn tracked_request_id(kind: &str, identity: &[u8]) -> String {
format!("backend-job-{kind}:{}", sha256(identity))
format!("backend-job-{kind}:{}", job::sha256(identity))
}
pub fn result_digest(result: &Value, max_bytes: u32) -> Result<(String, String)> {
let encoded = serde_json::to_string(result)
.map_err(|error| Error::InvalidInput(format!("serialize Backend Job result: {error}")))?;
if encoded.len() > max_bytes as usize {
return Err(Error::InvalidInput(format!(
"Backend Job result exceeds {max_bytes} bytes"
)));
}
Ok((sha256(encoded.as_bytes()), encoded))
}
pub(crate) fn bounded_failure_detail(detail: &str) -> String {
if detail.len() <= MAX_JOB_FAILURE_BYTES {
return detail.to_string();
}
let mut end = MAX_JOB_FAILURE_BYTES;
while !detail.is_char_boundary(end) {
end -= 1;
}
detail[..end].to_string()
job::result_digest(result, max_bytes).map_err(backend_error)
}
fn validate_identifier(field: &str, value: &str, max_bytes: usize) -> Result<()> {
validate_bounded_text(field, value, max_bytes)?;
if value.chars().any(char::is_control) {
return Err(Error::InvalidInput(format!(
"Backend Job {field} must not contain control characters"
)));
}
Ok(())
}
fn validate_bounded_text(field: &str, value: &str, max_bytes: usize) -> Result<()> {
if value.is_empty() || value.trim() != value {
return Err(Error::InvalidInput(format!(
"Backend Job {field} must be non-empty and trimmed"
)));
}
if value.len() > max_bytes {
return Err(Error::InvalidInput(format!(
"Backend Job {field} exceeds {max_bytes} bytes"
)));
}
Ok(())
}
fn sha256(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
let mut output = String::with_capacity(7 + digest.len() * 2);
output.push_str("sha256:");
for byte in digest {
use std::fmt::Write as _;
let _ = write!(output, "{byte:02x}");
}
output
job::validate_identifier(field, value, max_bytes).map_err(backend_error)
}
#[cfg(test)]
@@ -596,6 +579,258 @@ mod tests {
}
}
fn reservation() -> BackendJobReservation {
serde_json::from_value(serde_json::json!({
"job": {
"workspace_id": "workspace-a",
"request": request(),
"intent_fingerprint": request().fingerprint().unwrap(),
"state": "pending",
"current_attempt": 2,
"created_at": "created",
"updated_at": "updated"
},
"attempt": {
"workspace_id": "workspace-a",
"job_id": "ticket-check:T-1:r7",
"attempt_id": "ticket-check:T-1:r7:attempt:2",
"attempt": 2,
"input_revision": "7",
"state": "reserved",
"worker": {"runtime_id": "runtime-a", "worker_id": "worker-a"},
"runtime_run_id": "run-a",
"deadline_at": "deadline",
"created_at": "created",
"updated_at": "updated"
},
"replayed": false,
"resource_reused": false
}))
.unwrap()
}
#[test]
fn backend_state_projections_preserve_existing_wire_vocabulary() {
for state in [
BackendJobState::Pending,
BackendJobState::Completed,
BackendJobState::Failed,
BackendJobState::Unknown,
] {
let shared = job::JobState::from(state);
assert_eq!(
serde_json::to_value(state).unwrap(),
serde_json::to_value(shared).unwrap()
);
}
for state in [
BackendJobAttemptState::Reserved,
BackendJobAttemptState::Dispatching,
BackendJobAttemptState::Dispatched,
BackendJobAttemptState::Completed,
BackendJobAttemptState::Failed,
BackendJobAttemptState::Unknown,
] {
let shared = job::JobAttemptState::from(state);
assert_eq!(
serde_json::to_value(state).unwrap(),
serde_json::to_value(shared).unwrap()
);
}
// The shared read vocabulary does not add Backend cancellation support.
assert!(BackendJobState::parse("cancelled").is_err());
assert!(BackendJobAttemptState::parse("cancelled").is_err());
assert!(serde_json::from_str::<BackendJobState>("\"cancelled\"").is_err());
assert!(serde_json::from_str::<BackendJobAttemptState>("\"cancelled\"").is_err());
}
#[test]
fn neutral_request_round_trip_does_not_infer_authority_or_replace_profile() {
for profile in ["builtin:backend-job", "builtin:job", "project:custom-job"] {
for key in [None, Some("resource:7".to_string())] {
let mut neutral = request().to_job_request();
neutral.profile = profile.into();
neutral.serialization_key = key;
neutral.limits = job::JobLimits {
max_concurrent_jobs: 3,
timeout_seconds: 17,
max_result_bytes: 777,
max_attempts: 1,
};
let backend = BackendJobRequest::from_job_request(neutral.clone());
backend.validate().unwrap();
assert_eq!(backend.to_job_request(), neutral);
assert!(backend.grants.is_empty());
assert!(backend.source_worker.is_none());
assert!(backend.notification_target.is_none());
let encoded = serde_json::to_value(&backend).unwrap();
for field in ["grants", "source_worker", "notification_target"] {
assert!(encoded.get(field).is_none());
}
assert_eq!(encoded["profile"], profile);
}
}
// Conversion is not validation and must not normalize an invalid request.
let mut neutral = request().to_job_request();
neutral.limits.timeout_seconds = 0;
neutral.profile = "inherit".into();
let backend = BackendJobRequest::from_job_request(neutral.clone());
assert_eq!(backend.to_job_request(), neutral);
assert!(backend.validate().is_err());
}
#[test]
fn reservation_and_acceptance_outcomes_expose_durable_job_and_attempt_not_host_lifecycle() {
let mut reservation = reservation();
let encoded_before = serde_json::to_value(&reservation).unwrap();
assert_eq!(
reservation.outcome(),
job::JobOutcome {
job_id: reservation.job.request.job_id.clone(),
input_revision: "7".into(),
attempt_id: "ticket-check:T-1:r7:attempt:2".into(),
attempt: 2,
state: job::JobState::Pending,
attempt_state: job::JobAttemptState::Reserved,
result: None,
failure_category: None,
failure_detail: None,
}
);
assert_eq!(serde_json::to_value(&reservation).unwrap(), encoded_before);
for (state, attempt_state) in [
(
BackendJobState::Pending,
BackendJobAttemptState::Dispatching,
),
(BackendJobState::Pending, BackendJobAttemptState::Dispatched),
(BackendJobState::Failed, BackendJobAttemptState::Failed),
(BackendJobState::Unknown, BackendJobAttemptState::Unknown),
] {
reservation.job.state = state;
reservation.attempt.state = attempt_state;
reservation.job.failure_category = Some("job-category".into());
reservation.job.failure_detail = Some("job-detail".into());
reservation.attempt.failure_category = Some("attempt-category".into());
let outcome = reservation.outcome();
assert_eq!(outcome.state, state.into());
assert_eq!(outcome.attempt_state, attempt_state.into());
assert_eq!(outcome.failure_category.as_deref(), Some("job-category"));
assert_eq!(outcome.failure_detail.as_deref(), Some("job-detail"));
assert!(outcome.result.is_none());
let accepted = BackendJobResultAcceptance {
job: reservation.job.clone(),
attempt: reservation.attempt.clone(),
replayed: true,
};
assert_eq!(accepted.outcome(), outcome);
}
reservation.job.state = BackendJobState::Completed;
reservation.attempt.state = BackendJobAttemptState::Completed;
reservation.job.result = Some(serde_json::json!({"ok": true}));
reservation.job.failure_category = None;
reservation.job.failure_detail = None;
reservation.attempt.worker_cleanup_state = Some(BackendJobWorkerCleanupState::Failed);
reservation.attempt.worker_cleanup_failure_detail =
Some("cleanup is not model failure".into());
reservation.replayed = true;
reservation.resource_reused = true;
let acceptance = BackendJobResultAcceptance {
job: reservation.job.clone(),
attempt: reservation.attempt.clone(),
replayed: false,
};
let outcome = acceptance.outcome();
assert_eq!(outcome, reservation.outcome());
assert_eq!(outcome.state, job::JobState::Completed);
assert_eq!(outcome.result, Some(serde_json::json!({"ok": true})));
assert!(outcome.failure_category.is_none());
assert!(outcome.failure_detail.is_none());
let encoded = serde_json::to_value(outcome).unwrap();
assert_eq!(encoded.as_object().unwrap().len(), 9);
for field in [
"workspace_id",
"worker",
"runtime_run_id",
"request",
"replayed",
"resource_reused",
"worker_cleanup_state",
] {
assert!(encoded.get(field).is_none());
}
}
#[test]
fn extraction_preserves_backend_wire_fingerprint_and_worker_envelope() {
let request = request();
let encoded = serde_json::to_string(&request).unwrap();
assert_eq!(
encoded,
r#"{"job_id":"ticket-check:T-1:r7","purpose":"ticket_item_check","input_revision":"7","input_ref":"ticket://T-1/revisions/7","input":{"title":"Check me"},"instruction":"Check this immutable Ticket snapshot.","profile":"builtin:backend-job","source_worker":{"runtime_id":"runtime-a","worker_id":"worker-a"},"notification_target":{"runtime_id":"runtime-a","worker_id":"worker-a"},"limits":{"max_concurrent_jobs":8,"timeout_seconds":120,"max_result_bytes":16384,"max_attempts":2}}"#
);
assert_eq!(
request.fingerprint().unwrap(),
"sha256:920187fa9efa111a97c4d6869d134943e45b52edb2861cad19a0a173233c8f60"
);
let neutral = request.to_job_request();
neutral.validate().unwrap();
assert_ne!(
request.fingerprint().unwrap(),
neutral.fingerprint().unwrap()
);
assert_eq!(
serde_json::to_value(&request.limits).unwrap(),
serde_json::to_value(&neutral.limits).unwrap()
);
assert_eq!(
request.worker_input("attempt-1").unwrap(),
"Check this immutable Ticket snapshot.\n\nBackend Job envelope (immutable):\njob_id: ticket-check:T-1:r7\nattempt_id: attempt-1\ninput_revision: 7\ninput_ref: ticket://T-1/revisions/7\ninput_json: {\"title\":\"Check me\"}\n\nReturn success only through the structured Backend Job result capability. Final prose and Worker Idle/Stopped state are not result authority."
);
}
#[test]
fn shared_result_contract_preserves_backend_wire_and_errors() {
let submission = BackendJobResultSubmission {
job_id: "job-1".into(),
attempt_id: "job-1:attempt:1".into(),
input_revision: "7".into(),
result: serde_json::json!({"ok": true}),
};
let encoded = serde_json::to_string(&submission).unwrap();
assert_eq!(
encoded,
r#"{"job_id":"job-1","attempt_id":"job-1:attempt:1","input_revision":"7","result":{"ok":true}}"#
);
let neutral: job::JobResultSubmission = serde_json::from_str(&encoded).unwrap();
assert_eq!(neutral, submission);
assert_eq!(
result_digest(&submission.result, 128).unwrap(),
job::result_digest(&submission.result, 128).unwrap()
);
assert!(
matches!(result_digest(&submission.result, 4), Err(Error::InvalidInput(detail)) if detail == "Backend Job result exceeds 4 bytes")
);
let mut request = request();
request.job_id = "".into();
assert!(
matches!(request.validate(), Err(Error::InvalidInput(detail)) if detail == "Backend Job job_id must be non-empty and trimmed")
);
request.job_id = "job-1".into();
request.limits.timeout_seconds = 0;
assert!(
matches!(request.validate(), Err(Error::InvalidInput(detail)) if detail == "Backend Job timeout_seconds must be in 1..=600")
);
// Preserve domain-before-input/limits error ordering as well.
request.grants.subjektiv_consolidation = Some(SubjektivConsolidationGrant {
subject_id: "".into(),
candidate_ids: vec![],
});
assert!(
matches!(request.validate(), Err(Error::InvalidInput(detail)) if detail == "Backend Job grant subject_id must be non-empty and trimmed")
);
}
#[test]
fn fingerprint_covers_revision_profile_limits_and_notification_provenance() {
let request = request();