Dead-Letter Queue Architecture for Failed Webhook Deliveries

A dead-letter queue is where the pipeline described in Resilient Delivery & Retry Strategies gives up on a specific event, and the way it gives up determines whether that event is recoverable or merely lost more slowly. Done well, the queue is a durable, queryable record of every delivery the system could not complete, carrying enough context that an engineer can classify a thousand envelopes in a minute and replay them safely once the cause is fixed. Done badly, it is a write-only bucket that fills during an incident, alerts nobody, and gets purged six weeks later because nobody can work out what is in it. This guide covers the routing rules, the envelope format, the capacity arithmetic, the alerting signals, and the replay workflow that separate the two.

Core Principles of Dead-Letter Queue Architecture

A Dead-Letter Queue (DLQ) is a deterministic routing destination for messages that exceed configured retry thresholds or fail structural validation, and it is the terminal state of the delivery lifecycle described in Resilient Delivery & Retry Strategies. By isolating poison messages, a DLQ prevents consumer thread exhaustion, blocks cascading backpressure, and maintains baseline system throughput. Treating the queue as a terminal state — rather than a slow retry loop — is what guarantees that persistent failures are quarantined instead of continuously reprocessed.

Failed delivery to DLQ to replay pipeline A webhook that exhausts retries is routed to the dead-letter queue, inspected during triage, then replayed idempotently to the target endpoint. Dispatcher retries exhausted Dead-Letter Queue envelope + metadata Triage / inspect classify failure Replay worker idempotent re-send Target endpoint route replay
The DLQ pipeline: exhausted deliveries are routed to the queue with full failure metadata, triaged, then replayed idempotently to the target endpoint.

Architectural Directives:

Routing Logic & Retry Integration

DLQ routing is triggered by deterministic thresholds, not arbitrary timeouts. When a consumer fails to process a message, the broker increments a retry_count header. Once this value exceeds max_retries, or when a permanent HTTP 4xx error is detected, the message is immediately routed to the DLQ. This transition must align with delay calculations to prevent premature exhaustion. Implementing Exponential Backoff Algorithms ensures that transient network latency is absorbed before final DLQ handoff, reducing unnecessary downstream load.

Retry schedule before dead-letter handoff Five delivery attempts spaced by exponential backoff consume the retry budget before the envelope is written to the dead-letter queue. Retry schedule before dead-letter handoff attempt 1 attempt 2 attempt 3 attempt 4 attempt 5 t = 0s +2s +4s +8s +16s Dead-letter queue envelope written Each failure increments x-retry-count; a permanent 4xx short-circuits the schedule.
Roughly thirty seconds of backoff plus five request timeouts must elapse before a transient failure is allowed to become a dead letter.

Configuration Example: Broker Routing Rules

# RabbitMQ / AWS SQS equivalent routing policy
queue:
  primary_delivery:
    max_retries: 5
    dead_letter_queue: "dlq.webhook.primary"
    retry_delay_strategy: exponential
    max_delay_seconds: 300
    routing_headers:
      - "x-retry-count"
      - "x-failure-reason"
      - "x-original-timestamp"

Header-Enriched Routing Payload

{
  "message_id": "evt_9f8a7b6c",
  "original_payload": { "event": "subscription.created", "user_id": "usr_123" },
  "failure_metadata": {
    "error_code": "HTTP_502",
    "retry_count": 5,
    "original_timestamp": "2024-05-12T14:32:01Z",
    "consumer_group": "webhook-dispatch-v2"
  }
}

The Envelope Lifecycle as an Explicit State Machine

Treating the dead-letter queue as a bucket of messages is the root of most operational pain with it. An envelope is not a message sitting in a queue; it is a record moving through a small, closed set of states, and modelling those states explicitly is what makes the backlog reportable. Without it, the only question you can answer is “how many”, which is precisely the question that never helps during an incident. With it, you can answer “how many are waiting on a fix”, “how many have already been replayed and failed again”, and “how many are about to expire unreplayed” — and those three numbers drive completely different actions.

Dead-letter envelope lifecycle An envelope enters quarantine, is grouped during triage, is replayed after the root cause is fixed, and then either resolves, returns to quarantine after a failed replay, or expires at the retention boundary. Replay failed non-2xx on re-send Quarantined full failure context Triaged grouped by signature Replaying throttled re-dispatch Expired purged at retention Resolved delivered, archived classified cause fixed 2xx TTL reached rejected re-quarantine Every envelope holds exactly one state, and every transition is attributable. Envelopes that keep returning to Quarantined are a product decision, not a queue problem.
Modelling the envelope as a state machine turns "how many dead letters" into the three questions that actually drive action: awaiting a fix, replayed and failed again, or about to expire.

The re-quarantine edge is the one most implementations omit, and it is the one that matters most during a long incident. An envelope that has been replayed and failed again is qualitatively different from one that has never been attempted: it carries evidence that the fix did not work, and it must not be swept into the next bulk replay run. Stamp replay_attempts and last_replay_error on the envelope, and have the replay tool default to excluding anything with replay_attempts > 0 unless the operator explicitly opts in. Without that rule, a bulk replay against a still-broken endpoint produces a loop that looks like progress on the dashboard — depth falls, then rises to the same number twenty minutes later — while quietly generating duplicate load on a service in recovery.

Persist the state alongside the envelope rather than inferring it from queue membership. Broker queues can express “present” and “absent” and very little else; the states above need a row in a database, with the queue acting only as the work-notification channel for the triage and replay workers. That also gives you the query that every triage session starts with: group by failure signature, count, and order by first-seen.

Sizing the Dead-Letter Store and Choosing Retention

Dead-letter capacity is easy to under-provision because engineers size it from the steady-state rate, which is by design tiny. Take the same fleet used elsewhere in this section: 2,000 events per minute, or 2.88 million events per day. A healthy dead-letter rate is at most 0.05% of events created, which is 1,440 envelopes a day. Budget 16 KB per envelope — a 4 KB median payload, the original headers, the failure metadata, and up to 8 KB of the last response body — and steady state is 23 MB a day, or 690 MB across a 30-day retention window. That number persuades nobody to think carefully about capacity, which is exactly the problem.

Now size the incident instead. A single endpoint carrying 8% of traffic that stays down for a full 4-hour-25-minute retry budget contributes 0.08 × 2,000 × 265 = 42,400 envelopes, and they arrive in a concentrated burst as the retry budgets expire together. That is 680 MB written over a few minutes, roughly thirty times the entire daily steady-state volume, from one customer. Provision for at least three times that burst, and — more importantly — make sure the metrics and the triage tooling degrade gracefully at that scale. A triage dashboard that renders every envelope individually is unusable at 42,000 rows, which is the moment you need it.

The burst arithmetic also explains why queue topology matters more than queue size. If those 42,400 envelopes share a queue with every other consumer group’s failures, the ten envelopes from a genuinely broken integration elsewhere are invisible. Per-consumer-group queues, and per-endpoint depth as a labelled metric rather than a single global gauge, are what keep a large incident from masking a small one.

Retention is usually discussed as a compliance question, but the binding constraint is normally semantic: how long does replaying this event remain correct? Replaying a three-week-old cart.updated snapshot is actively worse than dropping it, because it overwrites current state with stale state. Classify events on that axis and set retention per class.

Event class Example Replay validity Suggested retention
State transition, immutable fact invoice.paid, subscription.created Indefinite; the fact remains true 30 days, extendable on request
Snapshot of current state cart.updated, profile.changed Only until superseded by a newer event for the same entity 7 days, suppress if superseded
Time-sensitive notification otp.issued, delivery.eta_changed Minutes to hours 24 hours, never auto-replay
Aggregate or analytics daily.rollup Until the reporting window closes 7 days
Regulated financial record payout.settled Indefinite, and required for audit 400 days, write-once storage

Stamp replay_valid_until on the envelope at write time from that classification, and have the replay tool refuse anything past it without an explicit override. This turns a judgement call that an on-call engineer would otherwise make at 3 a.m. into a property of the event contract, decided once by the people who understand the semantics. It also gives the superseded-snapshot rule somewhere to live: for snapshot classes, the replay tool checks whether a newer event for the same entity has since been delivered and skips the envelope if so.

One storage note. Payloads above roughly 64 KB should be stored by reference — the envelope keeps a pointer to object storage under the same encryption keys and lifecycle policy — because inline storage collides with broker message-size limits (256 KB on SQS) and makes the triage query expensive. Keep the failure metadata inline regardless, since that is what every triage query filters on.

Failure Mode Analysis & Isolation Strategies

Effective DLQ architecture requires precise failure classification. Transient failures (e.g., TCP timeouts, HTTP 503, temporary DNS resolution failures) warrant retries. Permanent failures (e.g., schema drift, invalid cryptographic signatures, HTTP 400/401/404) require immediate DLQ routing. Consumer-side OOM crashes or unhandled exceptions must trigger negative acknowledgments (NACK) with requeue=false to prevent infinite processing loops.

Failure classification decision tree A failed delivery is classified as transient, permanent, or a consumer crash, and each class takes a different route into retry or the dead-letter queue. Delivery attempt failed classify the response Timeout or 5xx transient, may recover 400 / 401 / 404 permanent, payload or auth Consumer crash or OOM no ACK, redelivered Requeue with backoff until max_retries Route to DLQ now skip remaining retries NACK, requeue false then DLQ on redelivery Only the transient branch consumes retry budget; the other two route straight to isolation.
Classification happens before the retry counter is consulted, so a permanent 401 never burns five attempts against an endpoint that will keep rejecting it.

For sustained downstream degradation, integrating Circuit Breaker Patterns allows the system to preemptively route traffic to the DLQ when error rates breach defined thresholds. This isolates failing endpoints before retry storms consume broker resources.

Failure Signatures: The Query That Starts Every Triage

Classification at write time decides where an envelope goes; classification at read time decides how quickly a human understands a backlog. The tool for the second job is a failure signature — a stable fingerprint computed from the endpoint identity, the HTTP status, the exception class, and a normalized prefix of the response body. Normalization is what makes it stable: strip UUIDs, numeric ids, timestamps, and request ids from the body before hashing, or every envelope produces a unique signature and the grouping is worthless.

The payoff is large and immediate. A 42,000-envelope incident typically collapses to two or three signatures: perhaps 41,600 envelopes reading 503 upstream connect error against one endpoint, 380 reading 400 missing field: currency spread across a dozen endpoints, and 20 assorted timeouts. That first number is one customer’s outage and needs no engineering work at all. The second is a schema regression that will keep producing failures until someone ships a fix, and it is the one worth waking up for even though it is a hundred times smaller. Depth alone ranks these exactly backwards.

Compute the signature at ingestion and store it as an indexed column, not at query time. Triage sessions run GROUP BY signature ORDER BY count DESC against a table that may hold millions of rows during an incident, and computing a normalized hash across response bodies on the fly makes that query slow at precisely the moment it must be fast. Storing it also lets the alerting layer count distinct signatures per minute as a separate series, which is a sharper incident detector than raw write rate: a hundred envelopes carrying one signature is routine, while a hundred carrying forty signatures means something structural changed in the last deploy.

Troubleshooting Matrix

Symptom Root Cause Remediation Action
DLQ depth spikes >1000/min Schema drift in downstream API Update consumer deserializer, purge invalid payloads, notify API owner
Messages stuck in in-flight state Consumer process crash without ACK Force broker visibility timeout, requeue with retry_count increment
Cross-region DLQ duplication Split-brain routing during partition Enable idempotency keys, implement deduplication window on DLQ consumers
High CPU on DLQ consumer Unbounded batch replay concurrency Apply semaphore limits, implement exponential backoff on replay workers
DLQ stays empty during a known incident Routing policy never attached, or max_receive_count set far above the retry budget Inject a synthetic poison message daily and alert if it does not land within the expected window
Depth falls and then returns to the same value Bulk replay ran against an endpoint whose root cause was not actually fixed Exclude envelopes with replay_attempts > 0 from bulk runs and require a successful canary replay first
Envelopes present but payload unreadable Payload stored by reference and the object lifecycle expired before the queue retention did Align object-storage lifecycle rules to the queue retention window and validate on write

Alerting on Dead-Letter Growth

Depth is the wrong thing to page on, and it is what almost everyone pages on first. Depth is a stock, not a flow: once an incident has filled the queue, depth stays high until somebody drains it, so the alert keeps firing about a problem that is already known and cannot be silenced without also silencing the next one. Worse, depth says nothing about whether the situation is getting better or worse. Alert on three signals instead, each of which detects something the others cannot.

Signal What it detects Suggested threshold Why the others miss it
Write rate (envelopes per minute) An incident starting right now Page above 20/min for 5 minutes against a 1/min baseline Depth rises slowly at first and lags the onset by minutes
Distinct endpoints affected in 5 minutes Whether the fault is yours or one consumer’s Page above 10 distinct endpoints A single-tenant outage and a fleet-wide signing bug produce identical write rates
Age of the oldest unresolved envelope A backlog nobody is draining Warn at 24 hours, page at 72 hours Both rate and depth read healthy while old envelopes quietly approach expiry
Replay failure ratio A fix that did not work Warn above 10% of replayed envelopes failing Depth falls during the replay run, so the dashboard looks like recovery
Envelopes expiring unresolved per day Silent data loss Page on any non-zero value for regulated event classes Nothing else fires; the envelopes simply stop existing

The distinct-endpoint count is the discriminator worth building first, because it decides who gets paged. Forty thousand envelopes from one endpoint is a customer’s outage: the correct action is to let the breaker hold, notify the integration owner, and wait. Four hundred envelopes spread across two hundred endpoints is your outage, and the usual causes are a bad dispatcher release, an expired signing key, or clock skew producing fleet-wide signature rejections. Those two situations have nearly identical write rates and completely opposite responses, and no amount of staring at a depth graph will tell them apart.

The last row deserves particular attention because it is the only alert here that detects data loss rather than degradation. An envelope reaching its retention boundary unresolved is an event your system accepted, failed to deliver, and then deleted. For most notification traffic that is an acceptable outcome and should not page. For anything a customer might reconcile against — payments, entitlements, ledger updates — it should page every single time, and the alert should carry the event ids so that the follow-up is a specific conversation rather than an apology.

Finally, alert on the absence of dead letters. A dead-letter path that is misconfigured is invisible precisely because it produces no signal: routing policies get detached during infrastructure changes, and max_receive_count set above the retry budget means the broker never dead-letters at all. Publish one synthetic poison event per day through the real dispatch path, and alert if it fails to appear in the queue within the expected window. It is a few lines of code and it is the only way to distinguish “nothing is failing” from “failures are going nowhere”.

Security Controls & Data Governance

DLQs frequently contain payloads that failed due to validation errors, making them high-value targets for data leakage. Storage must enforce AES-256 encryption at rest with KMS-managed keys. Access requires strict IAM role separation: only authorized triage services and security auditors may read DLQ contents. Implement payload redaction at the ingestion layer to mask PII/PCI data in monitoring dashboards. All DLQ operations (read, purge, replay) must generate immutable audit trails to satisfy compliance requirements.

Anatomy of a stored DLQ envelope A dead-letter envelope broken into fields, marking which stay in the clear for triage and which are masked or encrypted, with callouts for the controls that enforce it. DLQ envelope at rest (SSE-KMS) message_id — clear, indexed failure_metadata — clear headers.authorization — masked payload.customer_email — masked original_payload — encrypted Field masking at ingest PII never reaches dashboards Least-privilege IAM read and delete only Immutable audit log every read attributable Retention: seven days minimum for forensics, auto-purge at thirty.
Triage only ever needs the identifiers and the failure context; the payload itself stays encrypted and the credential headers never leave the ingestion layer in the clear.

IAM Policy: Least-Privilege DLQ Access

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DLQReadAccess",
      "Effect": "Allow",
      "Action": [
        "sqs:ReceiveMessage",
        "sqs:DeleteMessage",
        "sqs:GetQueueAttributes"
      ],
      "Resource": "arn:aws:sqs:us-east-1:123456789012:dlq.webhook.primary",
      "Condition": {
        "StringEquals": {
          "aws:PrincipalTag/Role": "TriageService"
        }
      }
    },
    {
      "Sid": "DenyDLQWrite",
      "Effect": "Deny",
      "Action": ["sqs:SendMessage"],
      "Resource": "arn:aws:sqs:us-east-1:123456789012:dlq.webhook.primary"
    }
  ]
}

Compliance Checklist:

Operational Recovery & Replay Workflows

Recovery follows a structured pipeline: automated alerting on depth thresholds (>50 messages/minute), payload inspection, root-cause remediation, and controlled batch replay. Before anything is re-sent, the backlog has to be understood — triaging dead-letter queue growth covers grouping envelopes by failure signature so you know whether you are looking at one broken endpoint or a hundred. The mechanics of safely draining a backlog — concurrency caps, dry-run validation, and partial-batch failure handling — are detailed in replaying events from a dead-letter queue. Replay operations must enforce strict idempotency validation using original X-Idempotency-Key headers to prevent duplicate side effects. For webhook-specific implementations, follow the standardized procedures in Building a dead-letter queue for failed webhooks to align signature verification and delivery guarantees.

Recovery sequence from alert to closure An alarm pages the on-call engineer, who fixes the root cause and approves a throttled replay run before the incident is closed. CloudWatch alarm depth > 50 per min Triage engineer classify and fix Replay worker 10 to 20 threads Target endpoint recovered page on DLQ depth approve replay run POST with idempotency key 200 OK report drain metrics close incident Replay never starts before the root cause is confirmed fixed.
Approval sits between the alarm and the replay worker on purpose: an automatic drain into an unfixed endpoint simply refills the queue.

Idempotent Replay Script (Python)

import requests

def replay_dlq_message(message: dict, target_url: str) -> dict:
    """
    Re-dispatches a DLQ message to the target endpoint with its original
    idempotency key, skipping messages that have already been processed.
    """
    idempotency_key = message.get("headers", {}).get("x-idempotency-key")
    if not idempotency_key:
        raise ValueError("Missing idempotency key. Aborting replay.")

    # Check idempotency cache (Redis/DynamoDB) — replace with real implementation
    if is_already_processed(idempotency_key):
        return {"status": "skipped", "reason": "idempotent_match"}

    response = requests.post(
        url=target_url,
        json=message["original_payload"],
        headers={
            "X-Idempotency-Key": idempotency_key,
            "X-Replay-Source": "dlq-recovery",
        },
        timeout=10,
    )

    if response.status_code < 300:
        mark_processed(idempotency_key)
        return {"status": "success"}
    return {"status": "failed", "code": response.status_code}


def is_already_processed(key: str) -> bool:
    """Stub: replace with Redis SETNX or DB unique-constraint check."""
    return False


def mark_processed(key: str) -> None:
    """Stub: persist idempotency key to cache or DB."""
    pass

Replay Execution Rules:

Implementation Pathway & Validation Checklist

Deploy DLQ infrastructure using a phased, infrastructure-as-code approach. Provision separate DLQs per consumer group to enable granular triage and prevent cross-service failure contamination. Configure TTL-based auto-purge with retention windows aligned to compliance SLAs (typically 7–30 days). Validate capacity planning against peak failure rates, and implement cross-region replication for disaster recovery.

DLQ granularity comparison Three dead-letter queue topologies scored against triage granularity, blast radius, queue count and operational overhead. Choosing DLQ granularity per consumer group Triage detail Blast radius Queue count Ops overhead One shared DLQ all consumer groups Low Wide One queue Minimal DLQ per group recommended default High Contained One per group Moderate DLQ per tenant full isolation Highest One tenant Groups x tenants High Separate queues per consumer group stop one bad integration from hiding another.
Per-consumer-group queues are the pragmatic middle: triage stays specific without multiplying queue count by your tenant list.

Step 1: Infrastructure Provisioning

Deploy DLQ queues, KMS keys, and IAM roles via Terraform or CloudFormation.

Step 2: Consumer Configuration

Attach dead-letter routing policies to primary queues. Set max_receive_count thresholds.

Step 3: Monitoring Integration

Configure CloudWatch/Prometheus alerts for ApproximateNumberOfMessagesVisible and AgeOfOldestMessage.

Step 4: Load Testing

Simulate sustained 4xx/5xx spikes using chaos engineering tools. Verify routing accuracy, consumer isolation, and alerting thresholds.

Step 5: Production Promotion

Enable DLQ routing in staging, validate replay workflows, then promote to production with canary deployment.

Validation Checklist

Rollout Sequencing, Purges and Irreversible Operations

The order in which dead-letter infrastructure is switched on determines whether its first real incident is useful or wasted. Create the queue, the encryption key, the retention policy, and — critically — the alarms before attaching any routing policy. A queue that exists without alarms will silently absorb the first outage, and the team will discover it days later when someone notices a storage bill or a customer asks where their events went. Alarms first, routing second, is not bureaucracy; it is the difference between an incident you detected and an incident you were told about.

Enable routing per consumer group behind a flag rather than fleet-wide. The first group should be one with meaningful volume but tolerant semantics, so that a mistake in the classification rules costs a replay rather than a customer escalation. Watch two things during the soak: the write rate against the predicted steady-state figure, and the composition of what arrives. If the queue is filling with 429 responses or timeouts that should have been retried, the retryable status-code list is wrong and the classification logic needs fixing before the next group is enabled.

Changing max_receive_count or the retry budget carries the same hazard as any threshold reduction, and it bites hard here. Lowering the receive count from 12 to 5 while messages are in flight means every message already on its sixth receive dead-letters on its next delivery attempt. The observable symptom is a dead-letter spike a few minutes after a configuration change that touched no application code, and the envelopes look like a mass consumer failure that never happened. Raise thresholds freely; lower them only by applying the new value to messages created after a cutover timestamp, and stamp the policy version on each envelope so historical comparisons stay meaningful.

Purging deserves controls out of proportion to how simple the operation looks. A purge is irreversible and it deletes evidence, so treat it as a two-person operation: export the envelopes to object storage under the existing encryption key, record the export location and the reason in the audit log, and only then delete. The common temptation — purging a large backlog because it is noisy and “those events are old anyway” — is exactly how a reconciliation discrepancy becomes unexplainable six months later. If the backlog is noisy, fix the alert threshold rather than the data.

Finally, prove that the replay path and the live dispatch path are the same code. The single most common defect found during a dead-letter game day is that replay builds its request slightly differently from normal dispatch — a missing signature header, a different user agent that trips an allowlist, a timestamp taken at replay time instead of event time — so replays fail against a consumer that is working perfectly. Run a scheduled exercise that dead-letters a synthetic event, replays it, and asserts on the exact bytes the consumer received. Anything less tests the queue but not the recovery.

Frequently Asked Questions

Should a dead-letter queue retry automatically once the endpoint recovers?

No, not without a human or an explicit policy deciding the root cause is fixed. Automatic drains are how a backlog refills: the envelopes go out, fail for the same reason, and return, generating duplicate load on a service that may still be recovering. If you want automation, gate it on a successful canary replay of a small sample plus a sustained window of healthy live traffic to that endpoint.

How is a dead-letter queue different from a parking queue for an open circuit?

Parked events still hold live retry budget and are expected to be delivered automatically once the breaker closes; dead-lettered events have exhausted their budget or hit a permanent rejection and require a deliberate operator action. Conflating them is a common design error — it either dead-letters thousands of perfectly recoverable events during a brief outage, or leaves genuinely broken events cycling forever in a queue nobody triages.

How much of the original request should the envelope store?

Enough to reconstruct the attempt byte for byte, minus the secrets: the original payload, the request headers with credentials masked, the resolved destination URL, the attempt count, and a truncated copy of the last response body. The response body is the field engineers most often omit and most often need, because the status code alone rarely explains a rejection. Cap it at a few kilobytes and store oversized payloads by reference.

Is one queue per tenant a good idea?

Rarely. It gives perfect isolation and multiplies your queue count by your customer count, which turns alarm management and IAM policy into a permanent tax. Per-consumer-group queues plus a tenant label on every envelope gives you the same triage precision through a query rather than through infrastructure, and only genuinely regulated or contractually isolated tenants justify their own queue.

What is a safe replay concurrency?

Start at roughly a tenth of the destination's normal steady-state throughput and increase only while its error rate stays flat. A backlog is by definition larger than normal traffic, so replaying at full speed delivers a burst several times bigger than anything the consumer normally handles — against a service that was recently broken. Jitter the requests and honour any Retry-After exactly as live dispatch would.

Do envelopes need to be replayed in their original order?

Only if the consumer depends on order for the affected entities, and if it does, replay must be grouped by entity key and issued sequentially within each group. For everything else, ordering the drain by original event timestamp is a courtesy rather than a requirement, and a sequence number on the payload lets the consumer discard anything it has already superseded.

Who should be allowed to read dead-letter payloads?

A small, audited group, because the queue concentrates exactly the payloads that failed validation and is therefore a high-value target. Grant read access to a triage role rather than to individuals, mask credentials and personal data at ingestion so dashboards never render them, and log every read with the identity attached. Replay permission should be separate from read permission, since replay causes side effects in a customer's system.