rask × Dapr — what we use, what we refused, and how to decide
The generic sources do not answer rask's questions: diagrid-labs/dapr-skills is workflow+agent
AUTHORING (its own README title; its prereqs even assume Docker, which rask forbids), and the Dapr
docs list the 12 building blocks without tradeoffs. This skill is the estate-grounded layer: every
claim below was verified against the render or the code (2026-08-28), not inherited from docs.
The inventory — 7 of 12 blocks in use
| Block |
Used |
Where (verified) |
| Service invocation |
✅ |
gateway _target_base → /v1.0/invoke/{app_id}/method (direct-httpx fallback when Dapr off); invoke/lineage, invoke/compute |
| State |
✅ |
state.postgresql ×1 (the actor state store; scopes are load-bearing — see traps) |
| Pub/sub |
✅ |
pubsub.jetstream ×6; DaprApp.subscribe incl. a DLQ route; queueGroupName makes replicas competing consumers |
| Bindings |
✅ |
bindings.cron ×7 — measured on the deployed estate 2026-09-05: maintenance-cron (the sweep), maintenance-reconcile-cron, lineage-reconcile-cron, notifications-reconcile-cron, catalog-control-relay-cron, compute-prune-jobs-cron, medallion-cascade-lag-cron. ingest-cron renders from the chart but is not enabled here, so the CHART count is 8 and the RUNNING count is 7 — read the cluster, not the templates. Plus bindings.smtp, bindings.http — all delivered to POST /<component-name> at the pod ROOT |
| Actors |
✅ |
DaprActor ×17, ActorProxy ×28 (notifications inbox, project tasks) |
| Secrets |
✅ |
secretstores.hashicorp.vault → OpenBao. THE estate rule: sole source, fail-closed, never env fallback |
| Workflow |
✅ |
DaprWorkflowClient ×49, WorkflowRuntime ×16 (medallion cascade, flows, ingest) |
| Configuration |
❌ |
Ruled out — see refusals |
| Middleware |
❌ |
Ruled out — see refusals |
| Distributed lock |
❌ |
Not yet — the ONE open candidate (see below) |
| Jobs |
❌ |
Cron bindings instead — revisit trigger recorded below |
| Crypto / Conversation |
❌ |
No use case; Conversation would put an LLM key in a Component |
The rubric — component vs DIY
Reach for the Dapr component when ALL of these hold:
- The surface has a sidecar. Non-negotiable precondition — see the sidecar map below.
- The need is cross-cutting (every service wants it the same way) rather than one route's semantics.
- Declarative Component YAML + rollout-restart is an acceptable change cadence — this estate runs
HotReload: false (measured: ~540 ERROR lines/hour from a reconcile loop that cannot converge), so
a Component edit reaches sidecars only via kubectl rollout restart.
- The block's guarantee matches the need as SPECIFIED, not approximately — e.g. Jobs is
"at-least-once, no ceiling on lateness"; a lock is acquire-or-fail. If you must wrap the block to
correct its semantics, that wrapper IS the DIY and the block bought you a dependency.
DIY is justified when ANY of these hold (and the justification is written at the site):
- No sidecar on that hop. The two standing examples: the PUBLIC edge (Ingress → gateway:8888 is
app-port direct, so no Dapr middleware/ratelimit can ever see a client request — rate limiting and
body caps therefore live in
service_kit.middleware / slowapi, by owner ruling 2026-08-26), and
Ray pods (no daprd; a job cannot call /v1.0/secrets/*).
- Per-request semantics the block cannot express. notifications' reconcile guard must SKIP an
overlapping tick, not queue it — an actor (turn-based) queues, so the estate keeps a process-local
asyncio.Lock and a single replica until a cross-pod guard that skips exists.
- The render is the gate. rask's invariant tests read config out of
helm template; state moved
behind a runtime API (the Configuration block) is state those gates can no longer see.
- The block is Alpha and the need is load-bearing. Alpha components (lock, most middleware) may
churn; a correctness-critical seam should not pivot on one without a recorded owner decision.
Never DIY these, ever: secret distribution (OpenBao via the Dapr secret store is the sole source —
no env fallback, no "graceful degradation"; scope the Component to every consuming app-id), pub/sub
delivery/retry (JetStream + dapr-resiliency.yaml, never a hand retry loop around a publish), and
actor placement/turn semantics.
Recorded refusals (do not re-litigate without new facts)
HTTP middleware pipeline (2026-08-28, three independent reasons): public traffic never traverses
daprd; middleware.http.ratelimit is per-sidecar and keyed on remote IP, which on the only hop it
could see is the gateway pod for every request; one dapr.io/config per sidecar + HotReload-off
makes a pipeline fleet-global and rollout-gated. The gateway→backend invoke hop is real but is the
wrong place for authn (it would validate the gateway's outbound call, not the client's inbound one).
Configuration API (2026-08-28): the fleet's config is ONE ConfigMap via envFrom, validated by
pydantic-settings at boot, and the render-driven invariant tests depend on seeing it; change
cadence is deliberately rollout-shaped. It solves a problem this estate chose not to have.
Jobs API (2026-08-28; reasons rewritten 2026-09-03 after four adversarial re-tests — the
CONCLUSION held every time, most of the original reasoning did not). The six crons stay
bindings.cron. This entry is deliberately shorter than it was: three of the four counts it used
to give are falsified, and a refusal defended by dead reasons invites the re-litigation it exists to
prevent.
The count that carries it, and it was never stated before: the selection function is
WHOLE-ESTATE and a per-table wakeup cannot compute it. sweep.py::_protected_roots must open every
discovered dataset in every bucket before one is compacted — a shallow clone in bucket B is the only
thing that knows bucket A's dataset must not be rewritten, and base_refs.py states why no
per-dataset check can find it ("the evidence lives only on the referring side"). That pre-pass IS
the scan's dominant cost, so a per-target Job avoids a datetime comparison and avoids nothing that
is expensive — while either re-running the pre-pass per job (catastrophically worse) or skipping it
(re-opening the measured #114/#128d data-loss defect). work_queue.py already carries the
consequence: a worker "cannot recompute the whole-estate protection verdict", which is why
DatasetWorkItem ships the reduced protected_by.
Secondary, and still true: the loop must exist anyway for datasets carrying no policy — they
have no _policies/state/ record at all and are maintained on every tick — so Jobs would add a
second mechanism beside the first rather than replacing it.
What was falsified, so nobody re-derives it from this file: the medallion tiers CAN carry a
policy (a project record matches at bucket level, maintenance_policies.py:18,156); the "60x finer
resolution" was demo-only arithmetic (prod is 0 */30 * * * *, i.e. 2x); "per-target wakeups queue
on the same lock" stopped describing the queue lane when N4 landed (41f29fa8 — routes.on_cron
holds _sweep_lock for PLANNING only and api/work.py::handle_unit takes no lock); and "creates a
second stateful store" names the wrong thing — the Scheduler etcd is already deployed and
load-bearing for Dapr Workflow and actor reminders, so what per-policy Jobs would create is a second
authoritative HOME for policy cadence that every create/update/delete/drop/undrop path must mirror,
invisible to the render gates and to the drift report's three store categories.
The event-driven redesign (2026-09-03) shrinks the question rather than reopening it. With the
primary trigger becoming a lineage.events.v1 subscription and the cron demoted to an hourly
backstop, the scheduling mechanism stops driving maintenance at all; the components that need
throughput (the event subscriber, the N4 executor) scale on queueGroupName competing consumers,
which needs no Jobs. AUDITED ESTATE-WIDE 2026-08-28 (10 time-shaped clusters,
adversarial, fable agents): zero SHOULD_USE_JOBS. Every hypothesised consumer dissolved on
inspection — the candidates below are REFUTED and must not be re-proposed without new facts:
- auto-purge at trash deadline → already shipped as a sweep flag (
MAINTENANCE_TRASH_PURGE_ENABLED,
report-only default is a recorded destruction posture). A purge tolerates a tick's lateness and
must re-verify state at execution anyway (purge.due_records is the one shared rule), so the
sweep is the CORRECT mechanism, not a stand-in.
- TTL grants → already live as an FGA CEL condition (
non_expired_grant, model.fga:481) —
check-time evaluation, strictly better than revoke-at-T (no window a crashed revoker leaves open).
- per-subject digest one-shots → actor reminders, correctly (the state lives in the actor's turn;
reminders ride the same Scheduler Jobs would). The audit's ONE finding here was a repairability
defect, not a mechanism error — fixed 2026-08-28 (
_digest_orphaned read-path repair; Jobs has
the identical two-store split and would not have helped).
The real revisit trigger: a one-shot that is scoped to NOTHING that already holds state — no
actor to remind, no workflow to time, no record a scan can evaluate. Example: an estate-wide digest
at a fixed local time per timezone cohort. None exists. Calendar semantics in a per-entity policy
("02:00 in the project's timezone") are NOT it: still computable from data at each tick.
Jobs vs Workflow vs reminder — where a DELAY belongs
One question decides it: where does the state live?
| The scheduled thing is… |
Use |
Why |
| one step of a stateful multi-step process |
Workflow timer |
the process already holds the context (run record, idempotency key, what to emit on outcome); resumes mid-process after a crash |
| a wakeup for state living in an actor |
actor reminder |
runs under the actor's turn lock — the plane's own concurrency control; Jobs would land at the app and have to re-invoke the actor anyway |
| a bare future trigger with no surrounding state |
Jobs |
one etcd row + a callback; a workflow here would be an orchestrator used as an alarm clock (history rows, replay, determinism rules, zero orchestration) |
| recurring scan-and-converge |
cron binding |
as ruled above |
Two failure smells, one per direction: a workflow whose body is ONLY create_timer → should be
Jobs; Jobs callbacks that CHAIN (A schedules B schedules C, payload accumulating state) → a
hand-built workflow without history, replay or per-step retry — should be Workflow.
The one open candidate
Distributed lock for the notifications reconciler. The values-prod note + the invariant test
record the exit condition: a cross-pod guard that SKIPS. A lock component IS try-acquire-or-fail
(skip on failure — the right shape, where an actor queues), but the API is Alpha. Adopting it is an
owner decision; until then notifications stays at 1 replica and the constraint is enforced by
test_notifications_stays_single_replica_while_its_single_flight_lock_is_process_local.
Sidecar map — who can call Dapr APIs at all
Sidecars are injected for the fleet + lakehouse services and stage runners (rask.daprAnnotations; the
injector webhook is fail-closed via the paired label — a pod with the annotation but no label is the
silent no-sidecar failure). No sidecar: the 7 web zones, Ray head/workers, and every runner.
Anything running there gets its secrets by other means — today the Ray lane's are injected by the
submitting service (which DOES fetch them from the Dapr store first); the ESO path
(externalSecrets) is the sanctioned k8s-native alternative when a pod must hold a secret and has no
sidecar. The Jobs-API echo of runtime_env is exactly why "inject at submit" is under review.
Traps already paid for (don't rediscover)
- An actor state store the app-id is not SCOPED to ⇒ sidecar logs "Workflow engine started" and
actor hosting silently disables — healthy pod, permanently empty feature. Probe
/v1.0/metadata
for the ACTOR capability, not the log line.
- daprd APPENDS its caller-identity headers; FastAPI binds the FIRST duplicate ⇒ the gateway must
strip client-supplied
dapr-* headers at the edge (_CLIENT_SPOOFABLE) or every door's caller
check reads the client's own claim.
- A cron/input binding is delivered to
POST /<component-name> at the pod ROOT — never under
RASK_API_PREFIX. Component name, env var and served path are one string, pinned by invariants.
bindings.cron FIRES ON EVERY REPLICA. The component is stateless and uncoordinated — Diagrid,
verbatim: "No coordination – each replica runs the schedule independently, causing duplicate
triggers" and "when the target app is scaled to multiple replicas, the schedule will fire on every
instance". There is no lease anywhere in the path, so every multi-replica service must buy the
guarantee itself, and the estate answers it FOUR different ways: lineage takes a Postgres advisory
lock (RECONCILE_LOCK_KEY); notifications and maintenance are pinned to replicas: 1; catalog's
control relay accepts duplicates because a duplicate publish dedupes on event_id; and compute's
prune accepts them because the operation is convergent (9489d5e1 — until then a job another
replica had already reclaimed was miscounted as a retention FAILURE, one false alarm per job
reclaimed). A new cron on a service that can scale needs its answer chosen and written down, or its
absence reads as an oversight rather than a decision.
ActorProxy dispatches @actormethod WIRE names, not Python names; mocks cannot catch a mismatch.
- Missing
dapr.io/app-token-secret on a bus-subscribing app ⇒ assert_app_token_configured
crash-loops the pod. Missing row in dapr-resiliency.yaml ⇒ no sidecar retry AND no dead-letter.
1---2name: rask-dapr3description: Dapr in rask: which of the 12 building blocks this estate uses (7) and why, which it deliberately does not (5) with the recorded rulings, and the component-vs-DIY rubric — when a Dapr component is the answer and when hand-rolling is justified. Use when adding a capability that smells like queuing/scheduling/locking/config/secrets/middleware, when tempted to hand-roll one of those, when a Dapr API seems to fit a new feature, or when touching sidecar annotations, Components, or the dapr-resiliency map.4---56# rask × Dapr — what we use, what we refused, and how to decide78The generic sources do not answer rask's questions: `diagrid-labs/dapr-skills` is workflow+agent9AUTHORING (its own README title; its prereqs even assume Docker, which rask forbids), and the Dapr10docs list the 12 building blocks without tradeoffs. This skill is the estate-grounded layer: every11claim below was verified against the render or the code (2026-08-28), not inherited from docs.1213## The inventory — 7 of 12 blocks in use1415| Block | Used | Where (verified) |16| --- | --- | --- |17| Service invocation | ✅ | gateway `_target_base` → `/v1.0/invoke/{app_id}/method` (direct-httpx fallback when Dapr off); `invoke/lineage`, `invoke/compute` |18| State | ✅ | `state.postgresql` ×1 (the actor state store; scopes are load-bearing — see traps) |19| Pub/sub | ✅ | `pubsub.jetstream` ×6; `DaprApp.subscribe` incl. a DLQ route; `queueGroupName` makes replicas competing consumers |20| Bindings | ✅ | `bindings.cron` ×7 — measured on the deployed estate 2026-09-05: `maintenance-cron` (the sweep), `maintenance-reconcile-cron`, `lineage-reconcile-cron`, `notifications-reconcile-cron`, `catalog-control-relay-cron`, `compute-prune-jobs-cron`, `medallion-cascade-lag-cron`. `ingest-cron` renders from the chart but is not enabled here, so the CHART count is 8 and the RUNNING count is 7 — read the cluster, not the templates. Plus `bindings.smtp`, `bindings.http` — all delivered to `POST /<component-name>` at the pod ROOT |21| Actors | ✅ | `DaprActor` ×17, `ActorProxy` ×28 (notifications inbox, project tasks) |22| Secrets | ✅ | `secretstores.hashicorp.vault` → OpenBao. THE estate rule: sole source, fail-closed, never env fallback |23| Workflow | ✅ | `DaprWorkflowClient` ×49, `WorkflowRuntime` ×16 (medallion cascade, flows, ingest) |24| Configuration | ❌ | Ruled out — see refusals |25| Middleware | ❌ | Ruled out — see refusals |26| Distributed lock | ❌ | Not yet — the ONE open candidate (see below) |27| Jobs | ❌ | Cron bindings instead — revisit trigger recorded below |28| Crypto / Conversation | ❌ | No use case; Conversation would put an LLM key in a Component |2930## The rubric — component vs DIY3132**Reach for the Dapr component when ALL of these hold:**331. **The surface has a sidecar.** Non-negotiable precondition — see the sidecar map below.342. **The need is cross-cutting** (every service wants it the same way) rather than one route's semantics.353. **Declarative Component YAML + rollout-restart is an acceptable change cadence** — this estate runs36 `HotReload: false` (measured: ~540 ERROR lines/hour from a reconcile loop that cannot converge), so37 a Component edit reaches sidecars only via `kubectl rollout restart`.384. **The block's guarantee matches the need as SPECIFIED, not approximately** — e.g. Jobs is39 "at-least-once, no ceiling on lateness"; a lock is acquire-or-fail. If you must wrap the block to40 correct its semantics, that wrapper IS the DIY and the block bought you a dependency.4142**DIY is justified when ANY of these hold (and the justification is written at the site):**43- **No sidecar on that hop.** The two standing examples: the PUBLIC edge (Ingress → gateway:8888 is44 app-port direct, so no Dapr middleware/ratelimit can ever see a client request — rate limiting and45 body caps therefore live in `service_kit.middleware` / slowapi, by owner ruling 2026-08-26), and46 **Ray pods** (no daprd; a job cannot call `/v1.0/secrets/*`).47- **Per-request semantics the block cannot express.** notifications' reconcile guard must SKIP an48 overlapping tick, not queue it — an actor (turn-based) queues, so the estate keeps a process-local49 `asyncio.Lock` and a single replica until a cross-pod guard that skips exists.50- **The render is the gate.** rask's invariant tests read config out of `helm template`; state moved51 behind a runtime API (the Configuration block) is state those gates can no longer see.52- **The block is Alpha and the need is load-bearing.** Alpha components (lock, most middleware) may53 churn; a correctness-critical seam should not pivot on one without a recorded owner decision.5455**Never DIY these, ever:** secret distribution (OpenBao via the Dapr secret store is the sole source —56no env fallback, no "graceful degradation"; scope the Component to every consuming app-id), pub/sub57delivery/retry (JetStream + `dapr-resiliency.yaml`, never a hand retry loop around a publish), and58actor placement/turn semantics.5960## Recorded refusals (do not re-litigate without new facts)6162- **HTTP middleware pipeline** (2026-08-28, three independent reasons): public traffic never traverses63 daprd; `middleware.http.ratelimit` is per-sidecar and keyed on remote IP, which on the only hop it64 could see is the gateway pod for every request; one `dapr.io/config` per sidecar + HotReload-off65 makes a pipeline fleet-global and rollout-gated. The gateway→backend invoke hop is real but is the66 wrong place for authn (it would validate the gateway's outbound call, not the client's inbound one).67- **Configuration API** (2026-08-28): the fleet's config is ONE ConfigMap via `envFrom`, validated by68 pydantic-settings at boot, and the render-driven invariant tests depend on seeing it; change69 cadence is deliberately rollout-shaped. It solves a problem this estate chose not to have.70- **Jobs API** (2026-08-28; reasons rewritten 2026-09-03 after four adversarial re-tests — the71 CONCLUSION held every time, most of the original reasoning did not). The six crons stay72 `bindings.cron`. **This entry is deliberately shorter than it was: three of the four counts it used73 to give are falsified, and a refusal defended by dead reasons invites the re-litigation it exists to74 prevent.**7576 **The count that carries it, and it was never stated before:** the selection function is77 WHOLE-ESTATE and a per-table wakeup cannot compute it. `sweep.py::_protected_roots` must open every78 discovered dataset in every bucket before one is compacted — a shallow clone in bucket B is the only79 thing that knows bucket A's dataset must not be rewritten, and `base_refs.py` states why no80 per-dataset check can find it ("the evidence lives only on the referring side"). That pre-pass IS81 the scan's dominant cost, so a per-target Job avoids a datetime comparison and avoids nothing that82 is expensive — while either re-running the pre-pass per job (catastrophically worse) or skipping it83 (re-opening the measured #114/#128d data-loss defect). `work_queue.py` already carries the84 consequence: a worker "cannot recompute the whole-estate protection verdict", which is why85 `DatasetWorkItem` ships the reduced `protected_by`.8687 Secondary, and still true: **the loop must exist anyway** for datasets carrying no policy — they88 have no `_policies/state/` record at all and are maintained on every tick — so Jobs would add a89 second mechanism beside the first rather than replacing it.9091 **What was falsified, so nobody re-derives it from this file:** the medallion tiers CAN carry a92 policy (a project record matches at bucket level, `maintenance_policies.py:18,156`); the "60x finer93 resolution" was demo-only arithmetic (prod is `0 */30 * * * *`, i.e. 2x); "per-target wakeups queue94 on the same lock" stopped describing the queue lane when N4 landed (`41f29fa8` — `routes.on_cron`95 holds `_sweep_lock` for PLANNING only and `api/work.py::handle_unit` takes no lock); and "creates a96 second stateful store" names the wrong thing — the Scheduler etcd is already deployed and97 load-bearing for Dapr Workflow and actor reminders, so what per-policy Jobs would create is a second98 authoritative HOME for policy cadence that every create/update/delete/drop/undrop path must mirror,99 invisible to the render gates and to the drift report's three store categories.100101 **The event-driven redesign (2026-09-03) shrinks the question rather than reopening it.** With the102 primary trigger becoming a `lineage.events.v1` subscription and the cron demoted to an hourly103 backstop, the scheduling mechanism stops driving maintenance at all; the components that need104 throughput (the event subscriber, the N4 executor) scale on `queueGroupName` competing consumers,105 which needs no Jobs. **AUDITED ESTATE-WIDE 2026-08-28** (10 time-shaped clusters,106 adversarial, fable agents): **zero SHOULD_USE_JOBS.** Every hypothesised consumer dissolved on107 inspection — the candidates below are REFUTED and must not be re-proposed without new facts:108 * *auto-purge at trash deadline* → already shipped as a sweep flag (`MAINTENANCE_TRASH_PURGE_ENABLED`,109 report-only default is a recorded destruction posture). A purge tolerates a tick's lateness and110 must re-verify state at execution anyway (`purge.due_records` is the one shared rule), so the111 sweep is the CORRECT mechanism, not a stand-in.112 * *TTL grants* → already live as an FGA CEL condition (`non_expired_grant`, model.fga:481) —113 check-time evaluation, strictly better than revoke-at-T (no window a crashed revoker leaves open).114 * *per-subject digest one-shots* → actor reminders, correctly (the state lives in the actor's turn;115 reminders ride the same Scheduler Jobs would). The audit's ONE finding here was a repairability116 defect, not a mechanism error — fixed 2026-08-28 (`_digest_orphaned` read-path repair; Jobs has117 the identical two-store split and would not have helped).118 **The real revisit trigger:** a one-shot that is scoped to NOTHING that already holds state — no119 actor to remind, no workflow to time, no record a scan can evaluate. Example: an estate-wide digest120 at a fixed local time per timezone cohort. None exists. Calendar semantics in a per-entity policy121 ("02:00 in the project's timezone") are NOT it: still computable from data at each tick.122123## Jobs vs Workflow vs reminder — where a DELAY belongs124125One question decides it: **where does the state live?**126127| The scheduled thing is… | Use | Why |128| --- | --- | --- |129| one step of a stateful multi-step process | **Workflow timer** | the process already holds the context (run record, idempotency key, what to emit on outcome); resumes mid-process after a crash |130| a wakeup for state living in an actor | **actor reminder** | runs under the actor's turn lock — the plane's own concurrency control; Jobs would land at the app and have to re-invoke the actor anyway |131| a bare future trigger with no surrounding state | **Jobs** | one etcd row + a callback; a workflow here would be an orchestrator used as an alarm clock (history rows, replay, determinism rules, zero orchestration) |132| recurring scan-and-converge | **cron binding** | as ruled above |133134Two failure smells, one per direction: a workflow whose body is ONLY `create_timer` → should be135Jobs; Jobs callbacks that CHAIN (A schedules B schedules C, payload accumulating state) → a136hand-built workflow without history, replay or per-step retry — should be Workflow.137138## The one open candidate139140**Distributed lock** for the notifications reconciler. The values-prod note + the invariant test141record the exit condition: a cross-pod guard that SKIPS. A lock component IS try-acquire-or-fail142(skip on failure — the right shape, where an actor queues), but the API is Alpha. Adopting it is an143owner decision; until then notifications stays at 1 replica and the constraint is enforced by144`test_notifications_stays_single_replica_while_its_single_flight_lock_is_process_local`.145146## Sidecar map — who can call Dapr APIs at all147148Sidecars are injected for the fleet + lakehouse services and stage runners (`rask.daprAnnotations`; the149injector webhook is fail-closed via the paired label — a pod with the annotation but no label is the150silent no-sidecar failure). **No sidecar:** the 7 web zones, Ray head/workers, and every runner.151Anything running there gets its secrets by other means — today the Ray lane's are injected by the152submitting service (which DOES fetch them from the Dapr store first); the ESO path153(`externalSecrets`) is the sanctioned k8s-native alternative when a pod must hold a secret and has no154sidecar. The Jobs-API echo of `runtime_env` is exactly why "inject at submit" is under review.155156## Traps already paid for (don't rediscover)157158- An actor state store the app-id is not SCOPED to ⇒ sidecar logs "Workflow engine started" and159 actor hosting silently disables — healthy pod, permanently empty feature. Probe `/v1.0/metadata`160 for the ACTOR capability, not the log line.161- daprd APPENDS its caller-identity headers; FastAPI binds the FIRST duplicate ⇒ the gateway must162 strip client-supplied `dapr-*` headers at the edge (`_CLIENT_SPOOFABLE`) or every door's caller163 check reads the client's own claim.164- A cron/input binding is delivered to `POST /<component-name>` at the pod ROOT — never under165 `RASK_API_PREFIX`. Component name, env var and served path are one string, pinned by invariants.166- **`bindings.cron` FIRES ON EVERY REPLICA.** The component is stateless and uncoordinated — Diagrid,167 verbatim: *"No coordination – each replica runs the schedule independently, causing duplicate168 triggers"* and *"when the target app is scaled to multiple replicas, the schedule will fire on every169 instance"*. There is no lease anywhere in the path, so every multi-replica service must buy the170 guarantee itself, and the estate answers it FOUR different ways: lineage takes a Postgres advisory171 lock (`RECONCILE_LOCK_KEY`); notifications and maintenance are pinned to `replicas: 1`; catalog's172 control relay accepts duplicates because a duplicate publish dedupes on `event_id`; and compute's173 prune accepts them because the operation is convergent (`9489d5e1` — until then a job another174 replica had already reclaimed was miscounted as a retention FAILURE, one false alarm per job175 reclaimed). A new cron on a service that can scale needs its answer chosen and written down, or its176 absence reads as an oversight rather than a decision.177- `ActorProxy` dispatches @actormethod WIRE names, not Python names; mocks cannot catch a mismatch.178- Missing `dapr.io/app-token-secret` on a bus-subscribing app ⇒ `assert_app_token_configured`179 crash-loops the pod. Missing row in `dapr-resiliency.yaml` ⇒ no sidecar retry AND no dead-letter.