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

File diff suppressed because one or more lines are too long

View File

@@ -294,6 +294,23 @@ pub enum BundleTrigger {
#[serde(default)]
shared: bool,
},
/// §11.6 B2: a 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 (dead-lettered). An app-owned or non-shared
/// `dead_letter` bundle trigger is rejected in `validate_bundle_for`.
DeadLetter {
script: String,
/// Match only dead-letters filed under this source (`"queue"` for a
/// shared-queue exhaustion). `None` matches any source.
#[serde(default)]
source_filter: Option<String>,
#[serde(default)]
dispatch_mode: Option<TriggerDispatchMode>,
#[serde(default)]
retry_max_attempts: Option<u32>,
#[serde(default)]
shared: bool,
},
}
fn default_timezone() -> String {
@@ -311,7 +328,8 @@ impl BundleTrigger {
| Self::Cron { script, .. }
| Self::Pubsub { script, .. }
| Self::Email { script, .. }
| Self::Queue { script, .. } => script,
| Self::Queue { script, .. }
| Self::DeadLetter { script, .. } => script,
}
}
@@ -332,7 +350,10 @@ impl BundleTrigger {
| Self::Docs { sealed, .. }
| Self::Files { sealed, .. }
| Self::Pubsub { sealed, .. } => *sealed,
Self::Cron { .. } | Self::Email { .. } | Self::Queue { .. } => false,
Self::Cron { .. }
| Self::Email { .. }
| Self::Queue { .. }
| Self::DeadLetter { .. } => false,
}
}
@@ -347,7 +368,8 @@ impl BundleTrigger {
| Self::Docs { shared, .. }
| Self::Files { shared, .. }
| Self::Pubsub { shared, .. }
| Self::Queue { shared, .. } => *shared,
| Self::Queue { shared, .. }
| Self::DeadLetter { shared, .. } => *shared,
Self::Cron { .. } | Self::Email { .. } => false,
}
}
@@ -427,6 +449,17 @@ impl BundleTrigger {
Self::Queue {
queue_name, shared, ..
} => format!("queue|{queue_name}|{shared}"),
// §11.6 B2: mirrored by `current_trigger_identity` for a group-owned
// shared dead_letter, so a re-apply diffs as NoOp.
Self::DeadLetter {
script,
source_filter,
shared,
..
} => format!(
"dead_letter|{script}|{}|{shared}",
source_filter.as_deref().unwrap_or("")
),
}
}
@@ -440,6 +473,7 @@ impl BundleTrigger {
Self::Pubsub { .. } => "pubsub",
Self::Email { .. } => "email",
Self::Queue { .. } => "queue",
Self::DeadLetter { .. } => "dead_letter",
}
}
}
@@ -1117,6 +1151,11 @@ impl ApplyService {
(topic_pattern.split('.').next().unwrap_or(""), "topic")
}
BundleTrigger::Queue { queue_name, .. } => (queue_name.as_str(), "queue"),
// §11.6 B2: a shared dead_letter watches the group's
// shared queue exhaustion, not a named collection store —
// no declared-collection requirement. The group/shared
// gate for it lives below.
BundleTrigger::DeadLetter { .. } => continue,
_ => {
return Err(ApplyError::Invalid(format!(
"a `shared` trigger must be a kv/docs/files/pubsub/queue kind; \
@@ -1138,6 +1177,17 @@ impl ApplyService {
)));
}
}
// §11.6 B2: a group `dead_letter` template is only meaningful as
// `shared = true` — it fires on the group's SHARED queue
// exhaustion. A non-shared group dead_letter would never fire (no
// per-app queue to watch at the group level), so reject it.
if matches!(t, BundleTrigger::DeadLetter { .. }) && !t.shared() {
return Err(ApplyError::Invalid(
"a group `dead_letter` trigger must be `shared = true` — it \
fires on the group's shared-queue exhaustion"
.into(),
));
}
}
}
// §11.6: shared collections are owned by GROUPS. Reject them on an app
@@ -1182,6 +1232,21 @@ impl ApplyService {
declares the collection",
));
}
// §11.6 B2: a `dead_letter` trigger is authored only as a group-owned
// shared template (it watches the group's shared-queue exhaustion).
// An app-owned dead_letter handler is created via `pic triggers`, not
// the declarative manifest.
if bundle
.triggers
.iter()
.any(|t| matches!(t, BundleTrigger::DeadLetter { .. }))
{
return Err(app_only_reject(
"trigger cannot be a `dead_letter` kind",
"a declarative dead_letter is a group-owned shared template; \
create an app dead-letter handler with `pic triggers`",
));
}
}
// §11 tail M1: both an app and a group may declare suppressions — an
// app declines an inherited template for itself, a group for its whole
@@ -3313,6 +3378,11 @@ impl ApplyService {
dispatch_mode,
retry_max_attempts,
..
}
| BundleTrigger::DeadLetter {
dispatch_mode,
retry_max_attempts,
..
} => (
dispatch_mode.unwrap_or(TriggerDispatchMode::Async),
retry_max_attempts.unwrap_or(self.trigger_config.retry_max_attempts),
@@ -5347,7 +5417,22 @@ fn current_trigger_identity(t: &Trigger, name_by_id: &HashMap<ScriptId, String>)
}
TriggerDetails::Email { .. } => Some(format!("email|{script}")),
TriggerDetails::Queue { queue_name, .. } => Some(format!("queue|{queue_name}|{shared}")),
TriggerDetails::DeadLetter { .. } => None,
// §11.6 B2: a GROUP-owned shared dead_letter is declarative (a `[group]`
// template), so it participates in the diff like the other group
// templates — its identity mirrors `BundleTrigger::identity`. An
// APP-owned dead_letter (interactive API, not manifest-representable)
// stays `None`, so the diff neither matches nor prunes it (same as
// email).
TriggerDetails::DeadLetter { source_filter, .. } => {
if t.group_id.is_some() && shared {
Some(format!(
"dead_letter|{script}|{}|{shared}",
source_filter.as_deref().unwrap_or("")
))
} else {
None
}
}
}
}
@@ -5794,6 +5879,11 @@ fn bundle_trigger_details(bt: &BundleTrigger, default_visibility: u32) -> Trigge
visibility_timeout_secs: visibility_timeout_secs.unwrap_or(default_visibility),
last_fired_at: None,
},
BundleTrigger::DeadLetter { source_filter, .. } => TriggerDetails::DeadLetter {
source_filter: source_filter.clone(),
trigger_id_filter: None,
script_id_filter: None,
},
BundleTrigger::Email { .. } => unreachable!("email handled separately"),
}
}

View File

@@ -456,10 +456,14 @@ impl Dispatcher {
// §11.6 D3: persist the exhausted message to the group dead-letter
// store instead of dropping it. We return None (not the dl id) so
// the per-app `fan_out_dead_letter` below is SKIPPED — firing the
// consuming app's per-app dead_letter handlers on a shared-queue
// consuming app's *per-app* dead_letter handlers on a shared-queue
// message (competing consumers → nondeterministic app) would be
// wrong. Fan-out to a *shared* dead_letter trigger is deferred; the
// row is operator-visible via the group dead-letters admin API.
// wrong.
//
// §11.6 B2: instead, fan out to the group's *shared* dead_letter
// handlers (`shared = true` on the owning group). Each runs under
// the WRITER app (`claimed.app_id`, the consumer that exhausted
// the message) — the M2 shared-write model.
match self
.group_queue
.dead_letter(
@@ -475,12 +479,42 @@ impl Dispatcher {
)
.await
{
Ok(dl_id) => tracing::warn!(
reason,
queue = %claimed.queue_name,
dead_letter_id = %dl_id.into_inner(),
"shared-queue message dead-lettered"
),
Ok(dl_id) => {
tracing::warn!(
reason,
queue = %claimed.queue_name,
dead_letter_id = %dl_id.into_inner(),
"shared-queue message dead-lettered"
);
let original = TriggerEvent::Queue {
queue_name: claimed.queue_name.clone(),
message: claimed.payload.clone(),
enqueued_at: claimed.enqueued_at,
attempt: claimed.attempt,
message_id: claimed.id.to_string(),
};
self.fan_out_shared_dead_letter(
group_id,
DeadLetterFanOutCtx {
// Writer app: the consumer that exhausted the msg.
app_id: claimed.app_id,
original,
source: "queue".to_string(),
dead_letter_id: dl_id,
attempts: claimed.attempt,
last_error: reason.to_string(),
trigger_id,
script_id,
first_attempt_at: claimed.enqueued_at,
last_attempt_at: Utc::now(),
// Shared-queue messages root a depth-1 chain (the
// queue is depth 0; a DL handler ticks up).
trigger_depth: 1,
root_execution_id: None,
},
)
.await;
}
Err(e) => tracing::error!(?e, "shared-queue dead-letter write failed"),
}
None
@@ -1499,6 +1533,83 @@ impl Dispatcher {
}
}
/// §11.6 B2: the shared analogue of `fan_out_dead_letter`. When a message in
/// a group's SHARED queue is exhausted, fire the group's `shared = true`
/// `dead_letter` handlers. Each outbox row is stamped `ctx.app_id` (the
/// writer/consumer that exhausted the message) so the handler runs under
/// that app's `SdkCallCx` — the M2 shared-write model. Best-effort, mirroring
/// the per-app path (the group dead-letter row is already durably written).
async fn fan_out_shared_dead_letter(
&self,
owning_group: picloud_shared::GroupId,
ctx: DeadLetterFanOutCtx,
) {
let DeadLetterFanOutCtx {
app_id,
original,
source,
dead_letter_id,
attempts,
last_error,
trigger_id,
script_id,
first_attempt_at,
last_attempt_at,
trigger_depth,
root_execution_id,
} = ctx;
let matches = match self
.triggers
.list_matching_shared_dead_letter(owning_group, &source, trigger_id, script_id)
.await
{
Ok(m) => m,
Err(e) => {
tracing::error!(?e, "shared dead-letter trigger lookup failed");
return;
}
};
for m in matches {
let event = TriggerEvent::DeadLetter {
dead_letter_id,
original: Box::new(original.clone()),
attempts,
last_error: last_error.clone(),
trigger_id,
script_id,
first_attempt_at,
last_attempt_at,
};
let payload = match serde_json::to_value(&event) {
Ok(p) => p,
Err(e) => {
tracing::error!(?e, "failed to serialize shared dead-letter event");
continue;
}
};
if let Err(e) = self
.outbox
.insert(NewOutboxRow {
// Writer app — the consumer that exhausted the message.
app_id,
source_kind: OutboxSourceKind::DeadLetter,
trigger_id: Some(m.trigger_id),
script_id: Some(m.script_id),
reply_to: None,
payload,
origin_principal: Some(m.registered_by_principal),
trigger_depth: trigger_depth.saturating_add(1),
root_execution_id,
})
.await
{
tracing::error!(?e, "failed to enqueue shared dead-letter handler delivery");
}
}
}
async fn deliver_inbox(&self, row: &OutboxRow, inbox_id: Uuid, result: InboxResult) {
match self.inbox.deliver(inbox_id, result.clone()).await {
InboxDeliveryOutcome::Delivered => {}

View File

@@ -563,6 +563,23 @@ pub trait TriggerRepo: Send + Sync {
script_id: Option<ScriptId>,
) -> Result<Vec<DeadLetterTriggerMatch>, TriggerRepoError>;
/// §11.6 B2 shared dead-letter fan-out: enabled `shared = true`
/// `dead_letter` triggers on the OWNING group — fired when a message in
/// that group's SHARED queue is dead-lettered. Same source/trigger/script
/// filter logic as `list_matching_dead_letter`, but keyed on the owning
/// group instead of a chain walk. Default empty so non-Postgres impls
/// degrade to "no shared dead-letter triggers".
async fn list_matching_shared_dead_letter(
&self,
owning_group: GroupId,
source: &str,
trigger_id: Option<TriggerId>,
script_id: Option<ScriptId>,
) -> Result<Vec<DeadLetterTriggerMatch>, TriggerRepoError> {
let _ = (owning_group, source, trigger_id, script_id);
Ok(Vec::new())
}
/// v1.1.9. Create a queue:receive trigger. Enforces exactly one
/// consumer per `(app_id, queue_name)` via `pg_advisory_xact_lock`
/// + SELECT-then-INSERT (a partial unique index across the parent
@@ -725,7 +742,9 @@ pub(crate) async fn insert_trigger_tx(
TriggerDetails::Cron { .. } => "cron",
TriggerDetails::Pubsub { .. } => "pubsub",
TriggerDetails::Queue { .. } => "queue",
TriggerDetails::DeadLetter { .. } | TriggerDetails::Email { .. } => {
// §11.6 B2: a group-owned, shared dead_letter template is declarative.
TriggerDetails::DeadLetter { .. } => "dead_letter",
TriggerDetails::Email { .. } => {
return Err(TriggerRepoError::Invalid(
"trigger kind not supported by declarative apply".into(),
));
@@ -870,7 +889,27 @@ pub(crate) async fn insert_trigger_tx(
.execute(&mut **tx)
.await?;
}
TriggerDetails::DeadLetter { .. } | TriggerDetails::Email { .. } => {
// §11.6 B2: mirror `create_dead_letter_trigger`'s detail insert. For the
// declarative (bundle) path the trigger_id/script_id filters are always
// None — a group shared dead_letter matches by source only.
TriggerDetails::DeadLetter {
source_filter,
trigger_id_filter,
script_id_filter,
} => {
sqlx::query(
"INSERT INTO dead_letter_trigger_details \
(trigger_id, source_filter, trigger_id_filter, script_id_filter) \
VALUES ($1, $2, $3, $4)",
)
.bind(tid)
.bind(source_filter.as_deref())
.bind(trigger_id_filter.map(TriggerId::into_inner))
.bind(script_id_filter.map(ScriptId::into_inner))
.execute(&mut **tx)
.await?;
}
TriggerDetails::Email { .. } => {
unreachable!("guarded above")
}
}
@@ -1654,6 +1693,7 @@ impl TriggerRepo for PostgresTriggerRepo {
FROM triggers t \
JOIN dead_letter_trigger_details d ON d.trigger_id = t.id \
WHERE t.app_id = $1 AND t.kind = 'dead_letter' AND t.enabled = TRUE \
AND t.shared = FALSE \
AND (d.source_filter IS NULL OR d.source_filter = $2) \
AND (d.trigger_id_filter IS NULL OR d.trigger_id_filter = $3) \
AND (d.script_id_filter IS NULL OR d.script_id_filter = $4)",
@@ -1676,6 +1716,42 @@ impl TriggerRepo for PostgresTriggerRepo {
.collect())
}
async fn list_matching_shared_dead_letter(
&self,
owning_group: GroupId,
source: &str,
trigger_id: Option<TriggerId>,
script_id: Option<ScriptId>,
) -> Result<Vec<DeadLetterTriggerMatch>, TriggerRepoError> {
let rows: Vec<DlMatchRow> = sqlx::query_as(
"SELECT t.id, t.script_id, t.dispatch_mode, t.registered_by_principal, \
d.source_filter, d.trigger_id_filter, d.script_id_filter \
FROM triggers t \
JOIN dead_letter_trigger_details d ON d.trigger_id = t.id \
WHERE t.group_id = $1 AND t.kind = 'dead_letter' AND t.enabled = TRUE \
AND t.shared = TRUE \
AND (d.source_filter IS NULL OR d.source_filter = $2) \
AND (d.trigger_id_filter IS NULL OR d.trigger_id_filter = $3) \
AND (d.script_id_filter IS NULL OR d.script_id_filter = $4)",
)
.bind(owning_group.into_inner())
.bind(source)
.bind(trigger_id.map(TriggerId::into_inner))
.bind(script_id.map(ScriptId::into_inner))
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.map(|r| DeadLetterTriggerMatch {
trigger_id: r.id.into(),
script_id: r.script_id.into(),
dispatch_mode: dispatch_from_str(&r.dispatch_mode),
registered_by_principal: r.registered_by_principal.into(),
})
.collect())
}
async fn create_queue_trigger(
&self,
app_id: AppId,

View File

@@ -0,0 +1,177 @@
//! §11.6 B2 integration test: SHARED dead-letter triggers.
//! A group-owned `dead_letter` trigger marked `shared = true` fires when a
//! message in that group's SHARED queue is exhausted. It matches via the
//! OWNING-group query (`list_matching_shared_dead_letter`); a descendant app's
//! per-app dead-letter query (`list_matching_dead_letter`) does NOT match it
//! (the `shared` flag is the namespace boundary), and a foreign sibling group
//! does NOT match it (the owning-group filter is the isolation boundary).
//!
//! Deterministic: drives the repo match queries directly (no async dispatcher).
//! Skips when `DATABASE_URL` is unset.
#![allow(clippy::needless_pass_by_value, clippy::too_many_lines)]
use picloud_manager_core::trigger_repo::{PostgresTriggerRepo, TriggerRepo};
use picloud_shared::{AppId, GroupId};
use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use uuid::Uuid;
async fn pool_or_skip() -> Option<PgPool> {
let Ok(url) = std::env::var("DATABASE_URL") else {
picloud_test_support::abort_if_db_required("shared_dead_letter");
eprintln!("shared_dead_letter: DATABASE_URL unset — skipping");
return None;
};
let pool = PgPoolOptions::new()
.max_connections(2)
.connect(&url)
.await
.expect("connect");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("migrate");
Some(pool)
}
/// Insert a group-owned dead_letter trigger (`shared` flag) + its details.
async fn dead_letter_trigger(
pool: &PgPool,
group_id: Uuid,
script: Uuid,
admin: Uuid,
shared: bool,
) -> Uuid {
let row: (Uuid,) = sqlx::query_as(
"INSERT INTO triggers \
(app_id, group_id, script_id, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal, name, shared) \
VALUES (NULL, $1, $2, 'dead_letter', TRUE, 'async', 1, 'constant', 0, $3, $4, $5) \
RETURNING id",
)
.bind(group_id)
.bind(script)
.bind(admin)
.bind(Uuid::new_v4().simple().to_string())
.bind(shared)
.fetch_one(pool)
.await
.expect("trigger");
sqlx::query(
"INSERT INTO dead_letter_trigger_details \
(trigger_id, source_filter, trigger_id_filter, script_id_filter) \
VALUES ($1, NULL, NULL, NULL)",
)
.bind(row.0)
.execute(pool)
.await
.expect("details");
row.0
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn shared_dead_letter_matches_only_owning_group() {
let Some(pool) = pool_or_skip().await else {
return;
};
let sfx = Uuid::new_v4().simple().to_string();
let admin = {
let r: (Uuid,) = sqlx::query_as(
"INSERT INTO admin_users (username, password_hash) VALUES ($1, 'x') RETURNING id",
)
.bind(format!("dl-{sfx}"))
.fetch_one(&pool)
.await
.unwrap();
r.0
};
// Group G with a group-owned handler + a SHARED dead_letter trigger.
let g: (Uuid,) = sqlx::query_as("INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id")
.bind(format!("dl-g-{sfx}"))
.fetch_one(&pool)
.await
.unwrap();
let handler: (Uuid,) = sqlx::query_as(
"INSERT INTO scripts (name, source, group_id) VALUES ($1, 'x', $2) RETURNING id",
)
.bind(format!("on-dl-{sfx}"))
.bind(g.0)
.fetch_one(&pool)
.await
.unwrap();
let shared_trig = dead_letter_trigger(&pool, g.0, handler.0, admin, true).await;
// Descendant app A under G.
let a: (Uuid,) =
sqlx::query_as("INSERT INTO apps (slug, name, group_id) VALUES ($1, $1, $2) RETURNING id")
.bind(format!("dl-a-{sfx}"))
.bind(g.0)
.fetch_one(&pool)
.await
.unwrap();
// Sibling group S (foreign — not an ancestor of A).
let s: (Uuid,) = sqlx::query_as("INSERT INTO groups (slug, name) VALUES ($1, $1) RETURNING id")
.bind(format!("dl-s-{sfx}"))
.fetch_one(&pool)
.await
.unwrap();
let trig = PostgresTriggerRepo::new(pool.clone());
// 1. The owning group G's shared query returns the trigger.
let g_matches = trig
.list_matching_shared_dead_letter(GroupId::from(g.0), "queue", None, None)
.await
.expect("shared match");
assert!(
g_matches.iter().any(|m| m.trigger_id == shared_trig.into()),
"the owning group's shared dead-letter query must match its shared trigger"
);
// 2. The per-app query on descendant A returns NOTHING — a shared group
// template must not match the per-app (`shared = FALSE`) path.
let a_matches = trig
.list_matching_dead_letter(AppId::from(a.0), "queue", None, None)
.await
.expect("app match");
assert!(
!a_matches.iter().any(|m| m.trigger_id == shared_trig.into()),
"a per-app dead-letter query must NOT match the group's shared trigger"
);
// 3. A foreign sibling group S returns NOTHING (owning-group isolation).
let s_matches = trig
.list_matching_shared_dead_letter(GroupId::from(s.0), "queue", None, None)
.await
.expect("foreign match");
assert!(
!s_matches.iter().any(|m| m.trigger_id == shared_trig.into()),
"a foreign group's shared dead-letter query must NOT match another group's trigger"
);
// Cleanup.
let _ = sqlx::query("DELETE FROM triggers WHERE id = $1")
.bind(shared_trig)
.execute(&pool)
.await;
let _ = sqlx::query("DELETE FROM apps WHERE id = $1")
.bind(a.0)
.execute(&pool)
.await;
let _ = sqlx::query("DELETE FROM scripts WHERE id = $1")
.bind(handler.0)
.execute(&pool)
.await;
let _ = sqlx::query("DELETE FROM groups WHERE id = ANY($1)")
.bind(vec![g.0, s.0])
.execute(&pool)
.await;
let _ = sqlx::query("DELETE FROM admin_users WHERE id = $1")
.bind(admin)
.execute(&pool)
.await;
}

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)]