feat(triggers): shared dead-letter trigger fan-out (B2)

A group declares a declaratively-authored [[triggers.dead_letter]] shared=true
handler; when a message in the group's SHARED queue is exhausted, the
dispatcher's q_terminal group branch (after persisting to group_dead_letters)
fans out to it via list_matching_shared_dead_letter(owning_group, "queue", …),
each outbox row stamped the WRITER app_id (the consuming app — the M2 shared
-write model), so the handler runs under the consumer. The per-app
list_matching_dead_letter gained AND t.shared = FALSE (the shared flag is the
namespace boundary); the owning-group filter is the isolation boundary.

Adds BundleTrigger::DeadLetter + a DeadLetterTriggerSpec manifest kind (group
+shared only — validate_bundle_for rejects app-owned or non-shared, and exempts
it from the shared-requires-a-collection rule); insert_trigger_tx now accepts
dead_letter and writes dead_letter_trigger_details; current_trigger_identity
matches a group-shared dead_letter so re-apply is a NoOp (app-owned ones stay
diff-invisible). Pinned by tests/shared_dead_letter.rs (owning group matches,
per-app query does not, foreign group does not).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-15 22:20:24 +02:00
parent 6b2dcd41a6
commit eae2ee08f1
7 changed files with 496 additions and 16 deletions

View File

@@ -223,6 +223,9 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
for s in &t.queue {
triggers.push(tagged("queue", s)?);
}
for s in &t.dead_letter {
triggers.push(tagged("dead_letter", s)?);
}
// Vars: key → JSON value. TOML values round-trip to JSON via serde so the
// wire shape matches the server's `serde_json::Value`.

View File

@@ -468,6 +468,8 @@ pub struct ManifestTriggers {
pub email: Vec<EmailTriggerSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub queue: Vec<QueueTriggerSpec>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dead_letter: Vec<DeadLetterTriggerSpec>,
}
impl ManifestTriggers {
@@ -480,6 +482,7 @@ impl ManifestTriggers {
&& self.pubsub.is_empty()
&& self.email.is_empty()
&& self.queue.is_empty()
&& self.dead_letter.is_empty()
}
}
@@ -606,6 +609,26 @@ pub struct QueueTriggerSpec {
pub shared: bool,
}
/// §11.6 B2: a `[[triggers.dead_letter]]` handler. Declarative authoring is
/// restricted to GROUP-owned + `shared = true` — it fires when a message in the
/// group's SHARED queue is exhausted. `source_filter` narrows to a dead-letter
/// source (`"queue"`); omitted matches any.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DeadLetterTriggerSpec {
pub script: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_filter: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dispatch_mode: Option<DispatchMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retry_max_attempts: Option<u32>,
/// Must be `true` — a declarative dead_letter is a group-owned shared
/// template. The server rejects a non-shared or app-owned one.
#[serde(default, skip_serializing_if = "is_false")]
pub shared: bool,
}
/// `[secrets] names = [...]` — declares which secrets the app expects.
/// Values are never in the manifest; `pic secret set` pushes them.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]