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.