diff --git a/backend/src/Taskdeck.Application/Processing/Policy/ProcessingPolicySnapshot.cs b/backend/src/Taskdeck.Application/Processing/Policy/ProcessingPolicySnapshot.cs
new file mode 100644
index 000000000..0157e79e6
--- /dev/null
+++ b/backend/src/Taskdeck.Application/Processing/Policy/ProcessingPolicySnapshot.cs
@@ -0,0 +1,182 @@
+using System.Buffers;
+using System.Collections.Immutable;
+using System.Globalization;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Text.RegularExpressions;
+using Taskdeck.Domain.Enums;
+
+namespace Taskdeck.Application.Processing.Policy;
+
+///
+/// The immutable constraints in force when a processing job is created.
+/// CF-10 will produce snapshots; CF-03 job persistence will retain only this snapshot's digest.
+///
+public sealed class ProcessingPolicySnapshot
+{
+ public const int SchemaVersion = 1;
+ public const int MaxProcessorIdLength = 120;
+
+ private static readonly Regex ProcessorIdPattern = new("^[a-z0-9]+(?:[._-][a-z0-9]+)*$", RegexOptions.Compiled);
+
+ public ProcessingPolicySnapshot(
+ ProcessingEgressClass egressClass,
+ IEnumerable allowedProcessorIds,
+ bool allowDiarisation,
+ bool allowAlignment,
+ DateTimeOffset? deadlineUtc,
+ ProcessingCostCeiling? costCeiling)
+ {
+ if (!Enum.IsDefined(egressClass))
+ throw new ArgumentOutOfRangeException(nameof(egressClass), egressClass, "The egress class must be a defined ProcessingEgressClass value.");
+
+ ArgumentNullException.ThrowIfNull(allowedProcessorIds);
+ EgressClass = egressClass;
+ AllowedProcessorIds = CanonicalizeProcessorIds(allowedProcessorIds);
+ AllowDiarisation = allowDiarisation;
+ AllowAlignment = allowAlignment;
+
+ if (deadlineUtc is { Offset: var offset } && offset != TimeSpan.Zero)
+ throw new ArgumentException("The deadline must use a UTC offset.", nameof(deadlineUtc));
+
+ DeadlineUtc = deadlineUtc;
+ CostCeiling = costCeiling;
+ }
+
+ public ProcessingEgressClass EgressClass { get; }
+
+ ///
+ /// An allowlist set. Input order and repeated entries do not change the policy or its digest.
+ /// Ordered route preferences are a future, distinct field.
+ ///
+ public ImmutableArray AllowedProcessorIds { get; }
+
+ public bool AllowDiarisation { get; }
+
+ public bool AllowAlignment { get; }
+
+ ///
+ /// The absolute UTC deadline for a job, or when the policy sets no deadline.
+ ///
+ public DateTimeOffset? DeadlineUtc { get; }
+
+ ///
+ /// The maximum charge the policy permits, or when the policy sets no cost limit.
+ ///
+ public ProcessingCostCeiling? CostCeiling { get; }
+
+ public string ToCanonicalJson() => ProcessingPolicySnapshotCanonicalizer.Serialize(this);
+
+ public string Digest() => ProcessingPolicySnapshotCanonicalizer.Digest(this);
+
+ private static ImmutableArray CanonicalizeProcessorIds(IEnumerable allowedProcessorIds)
+ {
+ var values = new HashSet(StringComparer.Ordinal);
+ foreach (var processorId in allowedProcessorIds)
+ {
+ if (processorId is null || processorId.Length == 0 || processorId.Length > MaxProcessorIdLength || !ProcessorIdPattern.IsMatch(processorId))
+ throw new ArgumentException($"Processor IDs must be non-empty, at most {MaxProcessorIdLength} characters, and match {ProcessorIdPattern}.", nameof(allowedProcessorIds));
+
+ values.Add(processorId);
+ }
+
+ return values.Order(StringComparer.Ordinal).ToImmutableArray();
+ }
+}
+
+///
+/// A currency-qualified upper bound. A bare number is deliberately not a policy value because a
+/// later reader could otherwise interpret the same digest as different currencies.
+///
+public sealed class ProcessingCostCeiling
+{
+ private static readonly Regex CurrencyPattern = new("^[A-Z]{3}$", RegexOptions.Compiled);
+
+ public ProcessingCostCeiling(decimal amount, string currency)
+ {
+ if (amount < 0)
+ throw new ArgumentOutOfRangeException(nameof(amount), amount, "The cost ceiling cannot be negative.");
+ if (currency is null || !CurrencyPattern.IsMatch(currency))
+ throw new ArgumentException("The currency must be a three-letter uppercase ISO 4217 code.", nameof(currency));
+
+ Amount = amount;
+ Currency = currency;
+ }
+
+ public decimal Amount { get; }
+
+ public string Currency { get; }
+}
+
+///
+/// Writes the ProcessingPolicySnapshot v1 byte contract. Do not replace this writer with ordinary
+/// serializer options: a changed property order or date/decimal formatting changes the digest.
+///
+public static class ProcessingPolicySnapshotCanonicalizer
+{
+ private const string DigestPrefix = "sha256:";
+ private const string UtcTimestampFormat = "yyyy-MM-dd'T'HH:mm:ss.fffffff'Z'";
+ private const string DecimalFormat = "0.#############################";
+
+ public static string Serialize(ProcessingPolicySnapshot snapshot)
+ {
+ ArgumentNullException.ThrowIfNull(snapshot);
+
+ var buffer = new ArrayBufferWriter();
+ using (var writer = new Utf8JsonWriter(buffer))
+ {
+ writer.WriteStartObject();
+ writer.WriteNumber("schemaVersion", ProcessingPolicySnapshot.SchemaVersion);
+ writer.WriteString("egressClass", StrictKebabCaseEnumConverterFactory.ToKebabCase(snapshot.EgressClass.ToString()));
+ writer.WritePropertyName("allowedProcessorIds");
+ writer.WriteStartArray();
+ foreach (var processorId in snapshot.AllowedProcessorIds)
+ writer.WriteStringValue(processorId);
+ writer.WriteEndArray();
+ writer.WriteBoolean("allowDiarisation", snapshot.AllowDiarisation);
+ writer.WriteBoolean("allowAlignment", snapshot.AllowAlignment);
+ WriteDeadline(writer, snapshot.DeadlineUtc);
+ WriteCostCeiling(writer, snapshot.CostCeiling);
+ writer.WriteEndObject();
+ writer.Flush();
+ }
+
+ return Encoding.UTF8.GetString(buffer.WrittenSpan);
+ }
+
+ public static string Digest(ProcessingPolicySnapshot snapshot)
+ {
+ var json = Serialize(snapshot);
+ var hash = SHA256.HashData(Encoding.UTF8.GetBytes(json));
+ return DigestPrefix + Convert.ToHexString(hash).ToLowerInvariant();
+ }
+
+ private static void WriteDeadline(Utf8JsonWriter writer, DateTimeOffset? deadlineUtc)
+ {
+ writer.WritePropertyName("deadlineUtc");
+ if (deadlineUtc is null)
+ {
+ writer.WriteNullValue();
+ return;
+ }
+
+ writer.WriteStringValue(deadlineUtc.Value.ToString(UtcTimestampFormat, CultureInfo.InvariantCulture));
+ }
+
+ private static void WriteCostCeiling(Utf8JsonWriter writer, ProcessingCostCeiling? costCeiling)
+ {
+ writer.WritePropertyName("costCeiling");
+ if (costCeiling is null)
+ {
+ writer.WriteNullValue();
+ return;
+ }
+
+ writer.WriteStartObject();
+ writer.WritePropertyName("amount");
+ writer.WriteRawValue(costCeiling.Amount.ToString(DecimalFormat, CultureInfo.InvariantCulture), skipInputValidation: true);
+ writer.WriteString("currency", costCeiling.Currency);
+ writer.WriteEndObject();
+ }
+}
diff --git a/backend/tests/Taskdeck.Application.Tests/Processing/ProcessingPolicySnapshotTests.cs b/backend/tests/Taskdeck.Application.Tests/Processing/ProcessingPolicySnapshotTests.cs
new file mode 100644
index 000000000..849730b10
--- /dev/null
+++ b/backend/tests/Taskdeck.Application.Tests/Processing/ProcessingPolicySnapshotTests.cs
@@ -0,0 +1,159 @@
+using System.Text.Json;
+using FluentAssertions;
+using Taskdeck.Application.Processing.Policy;
+using Taskdeck.Domain.Enums;
+using Xunit;
+
+namespace Taskdeck.Application.Tests.Processing;
+
+public sealed class ProcessingPolicySnapshotTests
+{
+ private const string ExpectedCanonicalJson = """
+ {"schemaVersion":1,"egressClass":"approved-destinations","allowedProcessorIds":["provider.cloud-speech","taskdeck.whisperx"],"allowDiarisation":true,"allowAlignment":false,"deadlineUtc":"2026-09-08T12:34:56.1230000Z","costCeiling":{"amount":1.5,"currency":"GBP"}}
+ """;
+
+ [Fact]
+ public void CanonicalJsonAndDigest_ShouldMatchTheV1GoldenBytes()
+ {
+ var snapshot = CreateBaseline();
+
+ snapshot.ToCanonicalJson().Should().Be(ExpectedCanonicalJson);
+ snapshot.Digest().Should().Be("sha256:2f874cb048ee092d6a78e3075f37fef8254827a0cc1b3e6fa7bdfc80f0d4c9b5");
+ }
+
+ [Fact]
+ public void Allowlist_ShouldBeOrdinalSortedAndDeduplicatedBeforeHashing()
+ {
+ var baseline = CreateBaseline();
+ var reorderedAndRepeated = new ProcessingPolicySnapshot(
+ ProcessingEgressClass.ApprovedDestinations,
+ ["taskdeck.whisperx", "provider.cloud-speech", "taskdeck.whisperx"],
+ allowDiarisation: true,
+ allowAlignment: false,
+ deadlineUtc: new DateTimeOffset(2026, 9, 8, 12, 34, 56, 123, TimeSpan.Zero),
+ costCeiling: new ProcessingCostCeiling(1.5000m, "GBP"));
+
+ reorderedAndRepeated.AllowedProcessorIds.Should().Equal("provider.cloud-speech", "taskdeck.whisperx");
+ reorderedAndRepeated.ToCanonicalJson().Should().Be(baseline.ToCanonicalJson());
+ reorderedAndRepeated.Digest().Should().Be(baseline.Digest());
+ }
+
+ [Fact]
+ public void Constructor_ShouldCopyTheCallerAllowlist()
+ {
+ var source = new[] { "taskdeck.whisperx" };
+ var snapshot = new ProcessingPolicySnapshot(
+ ProcessingEgressClass.LocalOnly,
+ source,
+ allowDiarisation: false,
+ allowAlignment: false,
+ deadlineUtc: null,
+ costCeiling: null);
+
+ source[0] = "provider.cloud-speech";
+
+ snapshot.AllowedProcessorIds.Should().Equal("taskdeck.whisperx");
+ }
+
+ [Fact]
+ public void CanonicalJson_ShouldWriteExplicitNullLimits()
+ {
+ var snapshot = new ProcessingPolicySnapshot(
+ ProcessingEgressClass.LocalOnly,
+ Array.Empty(),
+ allowDiarisation: false,
+ allowAlignment: false,
+ deadlineUtc: null,
+ costCeiling: null);
+
+ snapshot.ToCanonicalJson().Should().Be("""
+ {"schemaVersion":1,"egressClass":"local-only","allowedProcessorIds":[],"allowDiarisation":false,"allowAlignment":false,"deadlineUtc":null,"costCeiling":null}
+ """);
+ }
+
+ [Fact]
+ public void Digest_ShouldChangeWhenAnyPolicyFieldChanges()
+ {
+ var baseline = CreateBaseline();
+ var variants = new[]
+ {
+ new ProcessingPolicySnapshot(ProcessingEgressClass.LocalOnly, baseline.AllowedProcessorIds, true, false, baseline.DeadlineUtc, baseline.CostCeiling),
+ new ProcessingPolicySnapshot(baseline.EgressClass, ["taskdeck.whisperx"], true, false, baseline.DeadlineUtc, baseline.CostCeiling),
+ new ProcessingPolicySnapshot(baseline.EgressClass, baseline.AllowedProcessorIds, false, false, baseline.DeadlineUtc, baseline.CostCeiling),
+ new ProcessingPolicySnapshot(baseline.EgressClass, baseline.AllowedProcessorIds, true, true, baseline.DeadlineUtc, baseline.CostCeiling),
+ new ProcessingPolicySnapshot(baseline.EgressClass, baseline.AllowedProcessorIds, true, false, baseline.DeadlineUtc!.Value.AddTicks(1), baseline.CostCeiling),
+ new ProcessingPolicySnapshot(baseline.EgressClass, baseline.AllowedProcessorIds, true, false, baseline.DeadlineUtc, new ProcessingCostCeiling(1.6m, "GBP"))
+ };
+
+ variants.Should().OnlyContain(snapshot => snapshot.Digest() != baseline.Digest());
+ }
+
+ [Fact]
+ public void CanonicalDigest_ShouldNotDependOnAnAmbientJsonSerializerOptionsInstance()
+ {
+ var snapshot = CreateBaseline();
+ var ambient = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower };
+
+ JsonSerializer.Serialize(snapshot, ambient).Should().NotBe(snapshot.ToCanonicalJson());
+ snapshot.Digest().Should().Be(ProcessingPolicySnapshotCanonicalizer.Digest(snapshot));
+ snapshot.ToCanonicalJson().Should().Be(ProcessingPolicySnapshotCanonicalizer.Serialize(snapshot));
+ }
+
+ [Theory]
+ [InlineData("Taskdeck.WhisperX")]
+ [InlineData("taskdeck..whisperx")]
+ [InlineData(" taskdeck.whisperx")]
+ [InlineData("")]
+ public void Constructor_ShouldRejectAmbiguousProcessorIds(string processorId)
+ {
+ var action = () => new ProcessingPolicySnapshot(
+ ProcessingEgressClass.LocalOnly,
+ [processorId],
+ allowDiarisation: false,
+ allowAlignment: false,
+ deadlineUtc: null,
+ costCeiling: null);
+
+ action.Should().Throw();
+ }
+
+ [Fact]
+ public void Constructor_ShouldRejectUndefinedEgressAndNonUtcDeadline()
+ {
+ var undefinedEgress = () => new ProcessingPolicySnapshot(
+ (ProcessingEgressClass)99,
+ Array.Empty(),
+ false,
+ false,
+ null,
+ null);
+ var nonUtcDeadline = () => new ProcessingPolicySnapshot(
+ ProcessingEgressClass.LocalOnly,
+ Array.Empty(),
+ false,
+ false,
+ new DateTimeOffset(2026, 9, 8, 12, 0, 0, TimeSpan.FromHours(1)),
+ null);
+
+ undefinedEgress.Should().Throw();
+ nonUtcDeadline.Should().Throw();
+ }
+
+ [Fact]
+ public void CostCeiling_ShouldRejectNegativeAmountAndNonCanonicalCurrency()
+ {
+ var negativeAmount = () => new ProcessingCostCeiling(-0.01m, "GBP");
+ var lowerCaseCurrency = () => new ProcessingCostCeiling(1m, "gbp");
+
+ negativeAmount.Should().Throw();
+ lowerCaseCurrency.Should().Throw();
+ }
+
+ private static ProcessingPolicySnapshot CreateBaseline() => new(
+ ProcessingEgressClass.ApprovedDestinations,
+ ["taskdeck.whisperx", "provider.cloud-speech"],
+ allowDiarisation: true,
+ allowAlignment: false,
+ deadlineUtc: new DateTimeOffset(2026, 9, 8, 12, 34, 56, 123, TimeSpan.Zero),
+ costCeiling: new ProcessingCostCeiling(1.5000m, "GBP"));
+}
diff --git a/docs/architecture/PROCESSING_POLICY_SNAPSHOT_V1.md b/docs/architecture/PROCESSING_POLICY_SNAPSHOT_V1.md
new file mode 100644
index 000000000..b7c3a8f36
--- /dev/null
+++ b/docs/architecture/PROCESSING_POLICY_SNAPSHOT_V1.md
@@ -0,0 +1,59 @@
+# Processing Policy Snapshot v1
+
+**Status:** accepted contract for CF03-1 (`#2257`); no job, queue, runner, persistence, or policy-profile writer is added by this document.
+
+A processing job will retain the SHA-256 digest of the immutable policy that applied when it was
+created. This contract deliberately has no profile id or profile version: a future profile is an
+input used to create a snapshot, not an indirect reference that could reinterpret a historical job.
+The v0.6 draft's profile wrapper remains planning input; it is not this job-policy byte contract.
+
+## Fields
+
+The v1 snapshot contains only the routing and execution constraints needed before the job schema is
+designed:
+
+| Field | Meaning |
+| --- | --- |
+| `egressClass` | `ProcessingEgressClass` written as its exact kebab-case name. |
+| `allowedProcessorIds` | Processor-id allowlist set. It is ordinal-sorted and deduplicated before serialization. An ordered route preference is a separate future field. |
+| `allowDiarisation` | Whether the policy permits diarisation. |
+| `allowAlignment` | Whether the policy permits alignment. |
+| `deadlineUtc` | Absolute UTC deadline, or `null` when this policy does not set one. |
+| `costCeiling` | Currency-qualified maximum charge, or `null` when this policy does not set one. A runtime job still applies its own limits; an absent policy constraint grants no authority. |
+
+`costCeiling.currency` is an ISO-4217-shaped three-letter uppercase string. The contract validates
+the shape only; it does not claim to validate a current currency registry. `amount` is a
+non-negative .NET decimal. It has no provider billing-unit field: it caps the monetary amount in
+the stated currency.
+
+## Canonical UTF-8 JSON
+
+The digest source is the following compact UTF-8 JSON, with fields in exactly this order:
+
+```text
+schemaVersion, egressClass, allowedProcessorIds, allowDiarisation, allowAlignment, deadlineUtc, costCeiling
+```
+
+`schemaVersion` is the literal integer `1`. All fields are always present, including `null` limits.
+Dates are UTC only and use `yyyy-MM-ddTHH:mm:ss.fffffffZ`. Decimals use invariant fixed-point form
+with no exponent and no insignificant trailing fractional zeroes. The nested `costCeiling` object
+orders `amount` before `currency`.
+
+For example, the pinned golden bytes are:
+
+```json
+{"schemaVersion":1,"egressClass":"approved-destinations","allowedProcessorIds":["provider.cloud-speech","taskdeck.whisperx"],"allowDiarisation":true,"allowAlignment":false,"deadlineUtc":"2026-09-08T12:34:56.1230000Z","costCeiling":{"amount":1.5,"currency":"GBP"}}
+```
+
+Their digest is `sha256:2f874cb048ee092d6a78e3075f37fef8254827a0cc1b3e6fa7bdfc80f0d4c9b5`.
+
+The digest is `sha256:` followed by lowercase hexadecimal SHA-256 over those exact UTF-8 bytes.
+Ordinary `JsonSerializerOptions` must not serialize the digest source because property ordering,
+null handling, date formatting, and decimal formatting are part of this contract.
+
+## Validation
+
+Construction rejects undefined egress enum values, non-UTC deadlines, invalid processor identifiers,
+negative ceilings, and noncanonical currency text. Processor identifiers use the existing manifest
+identifier grammar: lowercase ASCII segments separated by `.`, `_`, or `-`, up to 120 characters.
+An empty allowlist is valid and fail-closed: it authorizes no processor.