Rekor Transparency Log Submission & Retry Policy
Last Updated: 2026-05-30 Owner: Attestor Team Audience: Operators running the Stella Ops Attestor Sprint: SPRINT_3000_0001_0002_rekor_retry_queue_metrics
Current architecture note (2026-07-17). New Attestor submissions do not enqueue
attestor.rekor_submission_queue. Attestor commits its authoritative local transparency entry first; optional connected/manual publication usesITransparencySyncRunnerand durable external mirror receipts. The queue, metrics, andRekorRetryWorkersections below document legacy drain compatibility for rows created by older releases, not the current submission path. Do not use this page to design a new queue producer.
Overview
This document retains the legacy remote-Rekor queue lifecycle and operator recovery details. The compatibility transport remains implemented for draining pre-existing rows, but current submission durability belongs to the local-first transparency and external-sync architecture; the tier/budget policy described in earlier drafts of this page is not implemented and is tracked as roadmap (see Roadmap: tiers & budgets).
Reconciliation note (2026-05-30). A prior version of this page documented “submission tiers”, per-tenant hourly/burst/daily budget limits, edge-bundle escalation triggers,
attestor.rekor_quotas/attestor.rekor_offline_queuetables, and a family ofattestor_rekor_budget_*/attestor_rekor_submissions_*metrics. None of those exist in the codebase. The real implementation is a durable submission retry queue (attestor.rekor_submission_queue,IRekorSubmissionQueue/PostgresRekorSubmissionQueue) plus a verification job. This page has been corrected to match the source. The aspirational tier/budget model is retained, clearly marked, under Roadmap.
Ground-truth source symbols:
StellaOps.Attestor.Core.Queue.IRekorSubmissionQueue/RekorQueueItem/RekorSubmissionStatus/QueueDepthSnapshotStellaOps.Attestor.Core.Options.RekorQueueOptionsStellaOps.Attestor.Infrastructure.Queue.PostgresRekorSubmissionQueueStellaOps.Attestor.Infrastructure.Workers.RekorRetryWorkerStellaOps.Attestor.Core.Observability.AttestorMetricsStellaOps.Attestor.Core.Options.AttestorOptions(RekorOptions,RekorBackendOptions,RekorCircuitBreakerOptions,QuotaOptions)StellaOps.Attestor.Core.Options.RekorVerificationOptionsStellaOps.Attestor.Core.Verification.RekorVerificationJob/RekorVerificationService/RekorVerificationMetrics(implemented; not registered/hosted inAttestorWebServiceComposition.cs— see Periodic Verification)StellaOps.Attestor.WebService.AttestorWebServiceComposition(the only hosted services areRekorRetryWorkerand conditionallyTransparencySyncWorker)
Durable Submission Queue
Rekor submissions are persisted to a durable PostgreSQL-backed queue so that a transient Rekor outage, network failure, or process restart never loses an attestation. Submission happens asynchronously: a caller enqueues a DSSE payload, and RekorRetryWorker drains the queue in the background.
Queue lifecycle
Each queue item moves through the RekorSubmissionStatus states:
pending -> submitting -> submitted
\-> retrying -> ... -> dead_letter
- pending — enqueued, waiting for first submission attempt.
- submitting — atomically claimed by a worker for the current attempt (
FOR UPDATE SKIP LOCKED, so multiple workers are concurrency-safe). - submitted — accepted by Rekor;
rekor_uuidandrekor_indexare recorded. - retrying — last attempt failed;
next_retry_atis set using exponential backoff and the item will be re-dequeued when due. - dead_letter —
attempt_countreachedmax_attempts; the item is parked for manual inspection / requeue and is purged after the retention window.
Backends
Each item targets a Rekor backend — either primary or mirror (IRekorSubmissionQueue.EnqueueAsync(..., string backend, ...)). Backend resolution and failover (including circuit-breaker behavior) is handled by RekorBackendResolver / ResilientRekorClient. There is no per-tenant “submission tier” concept in the queue; tenant_id is stored only for scoping and lookups.
Retry & backoff
Retry behavior comes entirely from RekorQueueOptions. There is no rate budget, quota, or 429-style admission control on Rekor submissions; the only limits are retry count, backoff, and batch size:
Option (RekorQueueOptions) | Default | Meaning |
|---|---|---|
Enabled | true | Enable the durable retry queue/worker. |
MaxAttempts | 5 | Attempts before dead-lettering. Stored per-item as max_attempts. |
InitialDelayMs | 1000 | Base delay for the first retry. |
MaxDelayMs | 60000 | Delay ceiling (1 minute). |
BackoffMultiplier | 2.0 | Exponential multiplier: InitialDelayMs * multiplier^attempt, capped at MaxDelayMs. |
BatchSize | 10 | Items dequeued per worker pass. |
PollIntervalMs | 5000 | Worker poll interval. |
DeadLetterRetentionDays | 30 | Dead-letter items older than this are purged (0 = indefinite). |
RekorQueueOptions binds from the configuration section Attestor:rekorQueue (see AttestorWebServiceComposition.cs).
Dead-letter management
PostgresRekorSubmissionQueue exposes operational entry points for the dead-letter set: GetDeadLetterItemsAsync, GetDeadLetterCountAsync, RequeueDeadLetterAsync (resets an item back to pending, attempt_count = 0), and PurgeDeadLetterAsync(retentionDays). Successfully-submitted rows can be trimmed with PurgeSubmittedAsync(olderThan).
Queue Schema
The actual table is attestor.rekor_submission_queue. It is the only Rekor queue table; there is no rekor_quotas table and no separate rekor_offline_queue table.
Active migration (deployed authority):
001_initial_schema.sqlundersrc/Attestor/__Libraries/StellaOps.Attestor.Persistence/Migrations/, applied on startup byAddStartupMigrations(schemaName: "attestor", moduleName: "Attestor.Persistence", ...)inAttestorWebServiceComposition.cs. That file is a 1.0.0 compaction that consolidated the original20251216_001_create_rekor_submission_queue.sql(SprintSPRINT_3000_0001_0002). The original now lives only underStellaOps.Attestor.Infrastructure/Migrations/_archived/pre_1.0/and is not the migration that runs — treat001_initial_schema.sqlas authoritative.
CREATE SCHEMA IF NOT EXISTS attestor;
CREATE TABLE IF NOT EXISTS attestor.rekor_submission_queue (
id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL,
bundle_sha256 TEXT NOT NULL,
dsse_payload BYTEA NOT NULL,
backend TEXT NOT NULL DEFAULT 'primary',
-- Status lifecycle: pending -> submitting -> submitted | retrying -> dead_letter
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'submitting', 'retrying', 'submitted', 'dead_letter')),
attempt_count INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 5,
next_retry_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Populated on success
rekor_uuid TEXT,
rekor_index BIGINT,
-- Populated on failure
last_error TEXT
);
Indexes (from the migration): a partial dequeue index on (status, next_retry_at) WHERE status IN ('pending','retrying'), a tenant index on (tenant_id), a bundle-dedupe index on (tenant_id, bundle_sha256), a dead-letter index on (status, updated_at) WHERE status = 'dead_letter', and a completed-cleanup index on (status, updated_at) WHERE status = 'submitted'.
The active 001_initial_schema.sql migration also installs a BEFORE UPDATE trigger (update_rekor_queue_updated_at) that calls proofchain.update_updated_at_column() to keep updated_at current on every row mutation. (Application code also sets updated_at explicitly, so the trigger is a backstop; the standalone archived DDL above does not include it.)
Monitoring
Metrics
All Attestor metrics are emitted on the meter StellaOps.Attestor (AttestorMetrics.MeterName). The Rekor queue metrics actually defined are:
| Metric (instrument name) | Type | Description | Tags |
|---|---|---|---|
attestor.rekor_queue_depth | ObservableGauge | Current queue depth (pending + retrying). | — |
attestor.rekor_submission_status_total | Counter | Submission status transitions. | status, backend |
attestor.rekor_retry_attempts_total | Counter | Retry attempts. | backend, attempt |
attestor.rekor_queue_wait_seconds | Histogram | Time items spend waiting in the queue. | — |
attestor.rekor_dead_letter_total | Counter | Items moved to dead letter. | backend |
A second set of inclusion/checkpoint instruments is declared on the same StellaOps.Attestor meter in AttestorMetrics: attestor.rekor_inclusion_verify_total, attestor.rekor_inclusion_verify_latency_seconds, attestor.rekor_checkpoint_verify_total, attestor.rekor_checkpoint_verify_latency_seconds, attestor.rekor_offline_verify_total, attestor.rekor_checkpoint_cache_hits, attestor.rekor_checkpoint_cache_misses.
Declared but not emitted (verify against source before relying on these). As of this revision these seven instruments are created in
AttestorMetricsbut have no.Add()/.Record()call site anywhere insrc/— they will register on the meter yet always read zero. They are not the metrics the periodic verification job produces; see the next subsection.
The periodic verification job (RekorVerificationJob) does not use AttestorMetrics. It emits its own counters/histograms via RekorVerificationMetrics on a separate meter StellaOps.Attestor.RekorVerification, with underscore-style names, e.g.: attestor_rekor_verification_runs_total, attestor_rekor_entries_verified_total, attestor_rekor_entries_failed_total, attestor_rekor_entries_skipped_total, attestor_rekor_time_skew_violations_total, attestor_rekor_signature_failures_total, attestor_rekor_inclusion_proof_failures_total, attestor_rekor_root_consistency_checks_total, attestor_rekor_root_inconsistencies_total, attestor_rekor_verification_run_failures_total, attestor_rekor_entry_verification_duration_seconds, attestor_rekor_batch_verification_duration_seconds, and attestor_rekor_verification_failure_rate. These are recorded via .Add()/.Record() inside RekorVerificationMetrics (so they emit when the job runs), but note the caveat in Periodic Verification: the RekorVerificationJob is not registered as a hosted service and the StellaOps.Attestor.RekorVerification meter is not added to the Attestor’s OpenTelemetry WithMetrics pipeline. To observe periodic-verification health you must first host the job and add this meter to your exporter; until then these instruments will not be scraped by the default pipeline.
Not implemented. Earlier drafts referenced
attestor_rekor_submissions_total,attestor_rekor_submission_latency_seconds,attestor_rekor_submissions_rejected,attestor_rekor_budget_utilization,attestor_rekor_budget_remaining,attestor_rekor_submission_errors, andattestor_rekor_edge_bundle_triggers. None of these instruments exist. Use the names in the table above. Note instruments use a dotted form (attestor.rekor_*); depending on your exporter, OpenTelemetry/Prometheus may normalize dots to underscores at scrape time.
Suggested alerts
These map to instruments that actually exist:
| Signal | Suggested condition | Action |
|---|---|---|
attestor.rekor_queue_depth | sustained high depth | Investigate Rekor reachability / worker health. |
attestor.rekor_dead_letter_total | rate increases | Inspect dead-letter items; fix root cause; requeue. |
attestor.rekor_retry_attempts_total | elevated for a backend | Backend degraded; check circuit breaker / failover. |
The previously-claimed Grafana dashboard ID
stellaops-attestor-rekorcould not be verified against any dashboard asset in the repo. Treat it as unverified until a dashboard JSON is added underdevops/.
Periodic Verification (related)
Separately from submission, the Attestor ships a scheduled-job implementation (RekorVerificationJob, a Cronos-driven BackgroundService configured by StellaOps.Attestor.Core.Options.RekorVerificationOptions, section Attestor:RekorVerification) that re-verifies logged entries to detect tampering, time-skew, and root-consistency issues. Its metrics are emitted on the separate StellaOps.Attestor.RekorVerification meter (see the Monitoring section above).
Implemented but not wired into the running host (verify before relying on it). As of this revision the Attestor WebService composition (
AttestorWebServiceComposition.cs) registers only two hosted services —RekorRetryWorkerand (conditionally)TransparencySyncWorker. It does notAddHostedService<RekorVerificationJob>, and there is no production DI registration ofIRekorVerificationServiceorIRekorVerificationStatusProvider(they appear only in tests, theRekorVerificationHealthCheck, and the Doctor plugin). The Doctor checkRekorVerificationJobCheckis written to Skip with “Rekor verification service not registered” precisely because the service is absent at runtime. Likewise, the OpenTelemetryWithMetricsblock only callsAddMeter(AttestorMetrics.MeterName); theStellaOps.Attestor.RekorVerificationmeter is not added to the exporter, so its instruments are not scraped by the default pipeline even though they are emitted when the job runs. Treat periodic verification as a code-complete capability that an operator must wire up (host the job + register the service
- add the meter) before it produces signal. The defaults table below describes the options class as written.
Name-collision gotcha. The codebase has a second class also named
RekorVerificationOptions—StellaOps.Attestor.Core.Configuration.RekorVerificationOptions, bound to sectionAttestor:Rekor— that holds Rekor public-key / checkpoint verification settings (PublicKeyPath,RekorServerUrl,EnableOfflineMode, etc.). It is not the periodic-job options class. The defaults table below is theCore.Optionsclass.
| Option | Default | Meaning |
|---|---|---|
Enabled | true | Enable periodic verification. |
CronSchedule | "0 3 * * *" | Daily at 03:00 UTC. |
MaxEntriesPerRun | 1000 | Batch cap per run. |
SampleRate | 0.1 | Fraction of eligible entries verified (1.0 = all). |
MaxTimeSkewSeconds | 300 | Allowed skew between build time and integratedTime. |
LookbackDays | 90 | How far back to consider entries (0 = all). |
RekorUrl | https://rekor.sigstore.dev | Server used for verification. |
EnableOfflineVerification | false | Use stored inclusion proofs without contacting Rekor. |
RequireOfflineProofVerification | true | Recompute Merkle roots for offline proofs. |
RequireOfflineCheckpointConsistency | true | Bind the proof root to the configured log key. |
Air-Gap Considerations
There is no dedicated attestor.rekor_offline_queue table. Air-gap behavior is achieved through two existing mechanisms:
- Durable queue holds submissions. When the configured Rekor backend is unreachable, items remain in
attestor.rekor_submission_queueinpending/retryingstate and drain when connectivity returns. With sufficiently largeMaxAttempts, this is the offline buffer. (Note: with the defaultMaxAttempts = 5andMaxDelayMs = 60000, items dead-letter after a few minutes of continuous failure — raiseMaxAttemptsfor long disconnections, or requeue dead-lettered items.) - Local transparency log. For fully offline deployments, the Attestor can target a local transparency backend instead of public Rekor; see
AttestorOptions.TransparencyOptions.Local(Attestor:Transparency:Local, default originstellaops.local-transparency.v1, default log URLstellaops://attestor/local-transparency) andLocalTransparencyRekorClient. - Offline verification.
RekorVerificationOptions.EnableOfflineVerificationverifies stored inclusion proofs without network access. This is an option on the periodicRekorVerificationJob; because that job is not hosted by default (see Periodic Verification), the offline re-verification path only runs once an operator wires the job up — it is not exercised automatically by the running Attestor.
Troubleshooting
Q: Submissions are stuck in retrying / never reach submitted.
- Check Rekor backend reachability and the circuit-breaker state (
AttestorOptions.Rekor.CircuitBreaker). - Inspect
last_erroron the affectedattestor.rekor_submission_queuerows. - Watch
attestor.rekor_retry_attempts_total{backend=...}andattestor.rekor_queue_depth.
Q: Items are landing in dead_letter.
- They exceeded
MaxAttempts. Readlast_error, fix the root cause, thenRequeueDeadLetterAsync(resets topending). - Increase
RekorQueueOptions.MaxAttempts/MaxDelayMsfor environments with long Rekor outages.
Q: Queue depth grows unbounded.
- Confirm the
RekorRetryWorkeris running (RekorQueueOptions.Enabled = true) and thatBatchSize/PollIntervalMskeep up with enqueue rate. - Confirm the backend is actually accepting submissions.
Not applicable / not implemented. There is no 429 / budget-rejection path for Rekor submissions and no edge-bundle escalation triggers. Troubleshooting guidance referencing
429,budget_remaining, or “edge bundles not submitted” from older drafts does not apply.
Configuration Reference
The Attestor binds AttestorOptions from the Attestor configuration section. Rekor settings live under Attestor:Rekor with per-backend sub-sections (Primary / Mirror) — there is no flat attestor.rekor.serverUrl and no top-level attestor.rekor.enabled/tier/budget. The durable queue binds separately from Attestor:rekorQueue.
Attestor:
Rekor:
Primary:
# Rekor server URL (default: local transparency log,
# "stellaops://attestor/local-transparency"; set to a real Rekor URL
# such as https://rekor.sigstore.dev for online use)
Url: "https://rekor.sigstore.dev"
# Log version: Auto or V2 (V2 uses tile-based Sunlight format). Default: Auto.
Version: Auto
# Tile base URL for v2 (optional; defaults to {Url}/tile/)
TileBaseUrl: ""
# Log ID for multi-log environments (hex-encoded SHA-256 of the log key).
# Production public Rekor:
LogId: "c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d"
ProofTimeoutMs: 15000
PollIntervalMs: 250
MaxAttempts: 60
Mirror:
Enabled: false
# ...same RekorBackendOptions fields as Primary...
# Circuit breaker for resilient Rekor calls
CircuitBreaker:
Enabled: true
FailureThreshold: 5
SuccessThreshold: 2
OpenDurationSeconds: 30
FailureWindowSeconds: 60
HalfOpenMaxRequests: 3
UseCacheWhenOpen: true
FailoverToMirrorWhenOpen: true
# Durable Rekor submission retry queue (RekorQueueOptions)
rekorQueue:
Enabled: true
MaxAttempts: 5
InitialDelayMs: 1000
MaxDelayMs: 60000
BackoffMultiplier: 2.0
BatchSize: 10
PollIntervalMs: 5000
DeadLetterRetentionDays: 30
# Periodic transparency-log re-verification (RekorVerificationOptions)
RekorVerification:
Enabled: true
CronSchedule: "0 3 * * *"
MaxEntriesPerRun: 1000
SampleRate: 0.1
MaxTimeSkewSeconds: 300
LookbackDays: 90
RekorUrl: "https://rekor.sigstore.dev"
EnableOfflineVerification: false
RequireOfflineProofVerification: true
RequireOfflineCheckpointConsistency: true
Attestor request quotas (separate from Rekor submission)
AttestorOptions.Quotas (section Attestor:Quotas) configures API request throttling for the Attestor service itself — not Rekor submission budgets:
Attestor:
Quotas:
PerCaller:
Qps: 50
Burst: 100
Bulk:
RequestsPerMinute: 6
MaxItemsPerJob: 100
MaxQueuedJobs: 20
MaxConcurrentJobs: 1
Roadmap: Submission Tiers & Budgets
Status: NOT IMPLEMENTED (roadmap / draft). The following model — graph-only vs. edge-bundle tiers, per-tenant hourly/burst/daily submission budgets, and CVSS/policy-gate escalation triggers — has no corresponding code, options, schema, or metrics in the repository as of this revision. It is kept here as a design sketch only. Do not configure these keys; they are ignored.
The envisioned future model:
- Tier 1 — graph-level attestations (default): one DSSE envelope per scan carrying the call-graph digest, submitted for every completed scan, governed by an hourly + burst budget per tenant.
- Tier 2 — edge-bundle attestations (on escalation): detailed edge bundles submitted only on escalation (e.g. reachable CVE with high CVSS, security-team request, or a policy gate requiring proof), governed by a daily budget.
If/when this is built, it would require: a tier/budget options class, a quota store (the proposed attestor.rekor_quotas table), admission control with a 429/backpressure path on enqueue, and budget/rejected metrics — none of which exist today.
Related Documentation
- Rekor Verification Technical Design — full technical design including v2 tile support
- Rekor Checkpoint Sync Guide — checkpoint/tile sync engine and air-gap exchange
- Attestor module guide (AGENTS)
- Scanner Score Proofs API
- Offline Kit Specification
- Sigstore Rekor Documentation
- C2SP tlog-tiles Specification — tile-based transparency log format (v2)
