Skip to content

Commit 7927d7b

Browse files
committed
Merge: digest status groups and tags, and module events that target one flow are admitted only to that flow
# Conflicts: # crates/basal-core/src/manifest.rs # mutations.toml
2 parents 0a5195f + 3e36aa7 commit 7927d7b

16 files changed

Lines changed: 944 additions & 17 deletions

File tree

‎crates/basal-core/src/authorize.rs‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,32 @@ pub fn check(
268268
}
269269
Primitive::SinkDigest => {
270270
let agent = agent_arg(args, *p)?;
271+
if let Some(tag) = args.get("options").and_then(|o| o.get("status_tag")) {
272+
if !tag
273+
.as_str()
274+
.is_some_and(crate::manifest::valid_status_label)
275+
{
276+
return Err(Refusal::new(
277+
"status_tag_invalid",
278+
"status_tag must match [A-Z]{1,4}",
279+
));
280+
}
281+
let target = if manifest.audience.is_some() {
282+
crate::manifest::AUDIENCE_TARGET
283+
} else {
284+
agent
285+
};
286+
if !manifest
287+
.sinks
288+
.iter()
289+
.any(|s| s.agent == target && s.status_group.is_some())
290+
{
291+
return Err(Refusal::new(
292+
"status_tag_without_group",
293+
"a status_tag requires the sink grant's status_group",
294+
));
295+
}
296+
}
271297
if manifest.audience.is_some() {
272298
return if manifest.sinks.is_empty() {
273299
Err(Refusal::denied("the manifest grants no digest sink"))

‎crates/basal-core/src/events.rs‎

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,25 @@ pub struct Notice {
3131
pub published_at_ms: i64,
3232
}
3333

34+
// EventDeclaration currently has no flow-targeting field. Keep the known
35+
// flow-targeted event contracts here so ingress, fan-out and body replay use
36+
// the same rule without inventing a declaration wire field.
37+
const FLOW_TARGETED_EVENTS: &[(&str, &str)] = &[("prefrontal-core", "digest_delivered")];
38+
3439
impl Notice {
40+
pub fn target_flow(&self) -> Result<Option<&str>> {
41+
let (module, name, _) = self.identity()?;
42+
if !FLOW_TARGETED_EVENTS.contains(&(module, name)) {
43+
return Ok(None);
44+
}
45+
self.headers
46+
.get("flow_id")
47+
.map(String::as_str)
48+
.filter(|flow| !flow.is_empty())
49+
.map(Some)
50+
.ok_or_else(|| CoreError::Invalid("flow-targeted event lacks flow_id header".into()))
51+
}
52+
3553
pub fn identity(&self) -> Result<(&str, &str, u32)> {
3654
let parts: Vec<_> = self.subject.split('.').collect();
3755
if parts.len() != 6
@@ -77,7 +95,13 @@ pub struct Report {
7795
pub overflow: usize,
7896
}
7997

80-
fn matches(manifest: &str, notice: &Notice) -> Result<bool> {
98+
fn matches(manifest: &str, notice: &Notice, flow_id: &str) -> Result<bool> {
99+
if notice
100+
.target_flow()?
101+
.is_some_and(|target| target != flow_id)
102+
{
103+
return Ok(false);
104+
}
81105
let (module, name, version) = notice.identity()?;
82106
let manifest = Manifest::parse(manifest).map_err(|e| CoreError::Corrupt(e.to_string()))?;
83107
Ok(manifest.trigger.events.as_ref().is_some_and(|events| {
@@ -183,10 +207,10 @@ impl Runtime {
183207
.collect::<Result<Vec<_>>>()?;
184208
let mut report = Report::default();
185209
for notice in notices {
186-
notice.identity()?;
210+
notice.target_flow()?;
187211
for (flow, first_approved, approved) in &approved {
188212
if let Some(approved) = approved
189-
&& matches(&approved.manifest, notice)?
213+
&& matches(&approved.manifest, notice, flow)?
190214
{
191215
// Upgrades retain the first approval point, so events
192216
// during an upgrade remain eligible for the current code.
@@ -222,7 +246,7 @@ impl Runtime {
222246
continue;
223247
}
224248
let approved = install::approved(tx, &flow)?;
225-
if !approved.as_ref().map(|a| matches(&a.manifest, &notice)).transpose()?.unwrap_or(false) {
249+
if !approved.as_ref().map(|a| matches(&a.manifest, &notice, &flow)).transpose()?.unwrap_or(false) {
226250
tx.execute("UPDATE event_receipts SET state='refused' WHERE rowid=?1", [rowid])?;
227251
continue;
228252
}
@@ -319,6 +343,18 @@ fn script_trigger(notice: &Notice, body: &[u8]) -> Result<JsonText> {
319343
.map_err(|e| CoreError::Invalid(e.to_string()))
320344
}
321345

346+
fn body_targets_run(notice: &Notice, body: &[u8], flow_id: &str) -> bool {
347+
match notice.target_flow() {
348+
Ok(None) => true,
349+
Ok(Some(target)) => {
350+
target == flow_id
351+
&& serde_json::from_slice::<serde_json::Value>(body)
352+
.is_ok_and(|body| body["flow_id"].as_str() == Some(flow_id))
353+
}
354+
Err(_) => false,
355+
}
356+
}
357+
322358
impl Runtime {
323359
pub(crate) fn event_preamble(&self, run: &crate::Run, manifest: &Manifest) -> Result<Preamble> {
324360
use basal_host::{CallClass, CallRequest, Dispatched, TransportError};
@@ -350,6 +386,9 @@ impl Runtime {
350386
.optional()?)
351387
})?;
352388
if let Some((Some(body), _, _)) = &saved {
389+
if !body_targets_run(&notice, body, &run.flow_id) {
390+
return Ok(mismatch("flow-targeted body does not name the run's flow"));
391+
}
353392
return Ok(Preamble::Ready(script_trigger(&notice, body)?));
354393
}
355394
if saved
@@ -424,6 +463,9 @@ impl Runtime {
424463
Ok(body) => body,
425464
Err(kind) => return Ok(mismatch(kind)),
426465
};
466+
if !body_targets_run(&notice, &body, &run.flow_id) {
467+
return Ok(mismatch("flow-targeted body does not name the run's flow"));
468+
}
427469
let trigger = script_trigger(&notice, &body)?;
428470
self.store().write(|tx| {
429471
tx.execute("UPDATE event_body_journal SET body=?2,digest=?3,retry_at=NULL WHERE run_id=?1", params![run.run_id, body, notice.digest])?;

‎crates/basal-core/src/events_tests.rs‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,3 +643,72 @@ fn queued_history_before_approval_is_refused_without_a_run() {
643643
.unwrap();
644644
assert_eq!(runs, 0);
645645
}
646+
647+
#[test]
648+
fn flow_targeted_notice_missing_header_is_refused_without_flows() {
649+
let f = Fixture::new(60);
650+
let mut notice = notice(0);
651+
notice.subject = "ck.account.event.prefrontal-core.digest_delivered.v1".into();
652+
let error = f.rt.admit_events(&[notice]).unwrap_err();
653+
assert!(error.to_string().contains("flow_id"), "{error}");
654+
}
655+
656+
#[test]
657+
fn journaled_flow_targeted_body_is_checked_against_run() {
658+
let f = Fixture::new(60);
659+
f.catalog.set_flow_capable("prefrontal-core", true);
660+
f.catalog.set_event(
661+
"prefrontal-core",
662+
"digest_delivered",
663+
1,
664+
basal_host::EventDecl {
665+
origin: basal_host::EventOrigin::External,
666+
body: basal_host::EventBody::Resolved {
667+
resolve_op: basal_host::catalog::EVENT_BODY_OP.into(),
668+
},
669+
},
670+
);
671+
f.catalog.set_op(
672+
"prefrontal-core",
673+
basal_host::catalog::EVENT_BODY_OP,
674+
OpDecl {
675+
kind: Some(OpKind::Query),
676+
cause_echo: false,
677+
shell_capable: false,
678+
},
679+
);
680+
let request = InstallRequest {
681+
script: "return trigger.event.body.flow_id;".into(), author: "operator".into(), loop_override: false,
682+
manifest: json!({"id":"flow","version":1,"purpose":"Replay delivered events",
683+
"trigger":{"events":[{"module":"prefrontal-core","name":"digest_delivered","version":1}]},
684+
"ops":[{"module":"prefrontal-core","op":basal_host::catalog::EVENT_BODY_OP}]}).to_string(),
685+
};
686+
let installed = f.rt.install(&request).unwrap();
687+
f.rt.approve("flow", 1, &installed.code_hash, "test")
688+
.unwrap();
689+
let mut notice = notice(0);
690+
notice.subject = "ck.account.event.prefrontal-core.digest_delivered.v1".into();
691+
notice.headers.insert("flow_id".into(), "flow".into());
692+
f.rt.admit_events(&[notice]).unwrap();
693+
let (id, _) = f.rt.startable().unwrap().remove(0);
694+
let run = f.rt.run(&id).unwrap();
695+
let manifest = Manifest::parse(&request.manifest).unwrap();
696+
for flow in ["other", "flow"] {
697+
let body = json!({"flow_id":flow}).to_string().into_bytes();
698+
f.rt.store().write(|tx| {
699+
tx.execute("INSERT INTO event_body_journal(run_id,attempt,body) VALUES (?1,1,?2) ON CONFLICT(run_id) DO UPDATE SET body=excluded.body", params![id, body])?;
700+
Ok(())
701+
}).unwrap();
702+
match f.rt.event_preamble(&run, &manifest).unwrap() {
703+
Preamble::Failed { kind, .. } if flow == "other" => {
704+
assert_eq!(kind, "event_body_mismatch")
705+
}
706+
Preamble::Ready(_) if flow == "flow" => {}
707+
_ => panic!("unexpected preamble for {flow}"),
708+
}
709+
}
710+
assert!(
711+
f.source.calls.lock().unwrap().is_empty(),
712+
"saved bodies require no live fetch"
713+
);
714+
}

‎crates/basal-core/src/manifest.rs‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,9 @@ impl DigestAction {
144144
pub struct SinkGrant {
145145
pub agent: String,
146146
pub digest_max: DigestAction,
147+
/// Groups undelivered tagged items in the agent's status segment.
148+
#[serde(default, skip_serializing_if = "Option::is_none")]
149+
pub status_group: Option<String>,
147150
/// Shown on the card as requested; only the operator grants it, through
148151
/// policy.
149152
#[serde(default)]
@@ -465,6 +468,10 @@ fn agent_byte(b: u8) -> bool {
465468
b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-')
466469
}
467470

471+
pub(crate) fn valid_status_label(value: &str) -> bool {
472+
(1..=4).contains(&value.len()) && value.bytes().all(|b| b.is_ascii_uppercase())
473+
}
474+
468475
fn check_list<T: Ord + Clone + std::fmt::Debug>(
469476
field: &str,
470477
items: impl Iterator<Item = T>,
@@ -594,6 +601,11 @@ impl Manifest {
594601
};
595602
for s in &self.sinks {
596603
check_sink("sinks.agent", &s.agent)?;
604+
if let Some(group) = &s.status_group
605+
&& !valid_status_label(group)
606+
{
607+
return Err(invalid("sinks.status_group", "must match [A-Z]{1,4}"));
608+
}
597609
}
598610
check_list("sinks", self.sinks.iter().map(|s| s.agent.clone()))?;
599611
for a in &self.status {
@@ -896,6 +908,33 @@ mod tests {
896908
}
897909
}
898910

911+
#[test]
912+
fn status_group_is_one_to_four_uppercase_ascii_letters() {
913+
let mut value = serde_json::json!({
914+
"id":"counts", "version":1, "purpose":"Report counts",
915+
"trigger":{"schedule":{"interval":"1h"}},
916+
"sinks":[{"agent":"SYNAPSE","digest_max":"silent"}]
917+
});
918+
assert!(Manifest::parse(&value.to_string()).is_ok());
919+
for group in ["A", "GH", "ABCD"] {
920+
value["sinks"][0]["status_group"] = group.into();
921+
let manifest = Manifest::parse(&value.to_string()).unwrap();
922+
assert_eq!(manifest.sinks[0].status_group.as_deref(), Some(group));
923+
assert_eq!(
924+
serde_json::to_value(manifest).unwrap()["sinks"][0]["status_group"],
925+
group
926+
);
927+
}
928+
for group in ["", "ABCDE", "gh", "Gh", "G1", "G_", "É", " GH", "GH\n"] {
929+
value["sinks"][0]["status_group"] = group.into();
930+
assert!(
931+
matches!(Manifest::parse(&value.to_string()),
932+
Err(ManifestError::Invalid { field, .. }) if field == "sinks.status_group"),
933+
"{group:?}"
934+
);
935+
}
936+
}
937+
899938
/// The platform's own grammar is the one a manifest is parsed with.
900939
#[test]
901940
fn manifest_roots_follow_this_platforms_spelling() {

‎crates/basal-host/src/core_host.rs‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,13 @@ pub fn intent(kind: Primitive, args: &Value, ctx: IntentContext<'_>) -> Value {
3232
} = ctx;
3333
match kind {
3434
Primitive::SinkDigest => {
35-
json!({"flow_id":flow_id,"flow_version":version,"run_id":run_id,"call_position":position,
35+
let mut request = json!({"flow_id":flow_id,"flow_version":version,"run_id":run_id,"call_position":position,
3636
"agent":args["agent"],"action":args["action"],
37-
"due_at":due_at,"created_at":created_at,"item":args["item"],"claim":null})
37+
"due_at":due_at,"created_at":created_at,"item":args["item"],"claim":null});
38+
if let Some(tag) = args.get("options").and_then(|o| o.get("status_tag")) {
39+
request["status_tag"] = tag.clone();
40+
}
41+
request
3842
}
3943
Primitive::SinkStatus => {
4044
json!({"flow_id":flow_id,"flow_version":version,"agent":args["agent"],

‎crates/basal-module/src/events.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,7 @@ pub fn decode(message: &jetstream::Message) -> Result<Notice, String> {
284284
headers: labels,
285285
published_at_ms,
286286
};
287-
notice.identity().map_err(|e| e.to_string())?;
287+
notice.target_flow().map_err(|e| e.to_string())?;
288288
Ok(notice)
289289
}
290290

‎crates/basal-module/tests/dry_run.rs‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,41 @@ fn calls(run: &Value) -> Vec<Value> {
2424
run["calls"].as_array().cloned().unwrap_or_default()
2525
}
2626

27+
#[test]
28+
fn status_group_is_validated_at_install_and_dry_run() {
29+
use basal_module::dryrun::{DryRunRequest, DryTrigger, Mode};
30+
let f = fixture("status-group-validation", Options::default());
31+
for group in ["", "ABCDE", "gh", "G1", "É"] {
32+
let mut manifest = schedule_manifest("flow-group", json!({"interval":"1h"}));
33+
manifest["sinks"][0]["status_group"] = json!(group);
34+
let manifest = manifest.to_string();
35+
let error = f
36+
.module
37+
.handle(
38+
&Caller::Operator,
39+
"flow.install",
40+
json!({"script":"return 1;","manifest":manifest}),
41+
)
42+
.unwrap_err();
43+
assert!(error.message.contains("sinks.status_group"), "{error:?}");
44+
let error = f
45+
.module
46+
.dry
47+
.run(&DryRunRequest {
48+
script: "return 1;".into(),
49+
manifest,
50+
author: "operator".into(),
51+
loop_override: false,
52+
mode: Mode::Capture,
53+
trigger: DryTrigger::Synthetic(json!({})),
54+
now_ms: common::T0,
55+
})
56+
.unwrap_err();
57+
assert!(error.to_string().contains("sinks.status_group"), "{error}");
58+
}
59+
assert_eq!(f.mock.total_sends(), 0);
60+
}
61+
2762
#[test]
2863
fn capture_mode_executes_no_host_call_and_replays_the_schedule_window() {
2964
let f = fixture("dry-capture", Options::default());

0 commit comments

Comments
 (0)