From 50d4a76be3f5eeb65395c9995a2183235902d4b4 Mon Sep 17 00:00:00 2001 From: Oleksandr Bezdieniezhnykh Date: Fri, 26 Jun 2026 13:34:35 +0300 Subject: [PATCH] [AZ-1126] Migrate capturedAt to DateTimeOffset Co-authored-by: Cursor --- .../Validators/UavTileMetadataValidator.cs | 4 +- .../Validators/UavUploadValidationFilter.cs | 5 ++- .../DTO/UavTileMetadata.cs | 10 +++-- ...tcOffsetRequiredDateTimeOffsetConverter.cs | 43 ++++++++++++++++++ .../UavUploadValidationTests.cs | 30 +++++++++++++ .../UavTileQualityGate.cs | 4 +- .../UavTileUploadHandler.cs | 4 +- ...setRequiredDateTimeOffsetConverterTests.cs | 45 +++++++++++++++++++ .../UavTileQualityGateTests.cs | 8 ++-- .../UavTileUploadHandlerTests.cs | 2 +- ...vTileBatchMetadataPayloadValidatorTests.cs | 10 ++--- .../UavTileMetadataValidatorTests.cs | 12 ++--- .../contracts/api/uav-tile-upload.md | 5 ++- _docs/02_tasks/_dependencies_table.md | 2 +- .../AZ-1126_captured_at_datetimeoffset.md | 0 .../batch_01_cycle13_report.md | 37 +++++++++++++++ ...lementation_completeness_cycle13_report.md | 16 +++++++ ...port_captured_at_datetimeoffset_cycle13.md | 26 +++++++++++ _docs/_autodev_state.md | 22 ++++----- 19 files changed, 242 insertions(+), 43 deletions(-) create mode 100644 SatelliteProvider.Common/Json/UtcOffsetRequiredDateTimeOffsetConverter.cs create mode 100644 SatelliteProvider.Tests/Json/UtcOffsetRequiredDateTimeOffsetConverterTests.cs rename _docs/02_tasks/{todo => done}/AZ-1126_captured_at_datetimeoffset.md (100%) create mode 100644 _docs/03_implementation/batch_01_cycle13_report.md create mode 100644 _docs/03_implementation/implementation_completeness_cycle13_report.md create mode 100644 _docs/03_implementation/implementation_report_captured_at_datetimeoffset_cycle13.md diff --git a/SatelliteProvider.Api/Validators/UavTileMetadataValidator.cs b/SatelliteProvider.Api/Validators/UavTileMetadataValidator.cs index 3758e9c..d03db48 100644 --- a/SatelliteProvider.Api/Validators/UavTileMetadataValidator.cs +++ b/SatelliteProvider.Api/Validators/UavTileMetadataValidator.cs @@ -54,9 +54,9 @@ public sealed class UavTileMetadataValidator : AbstractValidator m.CapturedAt) - .Must(capturedAt => capturedAt.ToUniversalTime() <= tp.GetUtcNow().UtcDateTime.AddSeconds(futureSkewSeconds)) + .Must(capturedAt => capturedAt.UtcDateTime <= tp.GetUtcNow().UtcDateTime.AddSeconds(futureSkewSeconds)) .WithMessage($"`capturedAt` must be within {futureSkewSeconds}s of the current time (no future-dated tiles).") - .Must(capturedAt => capturedAt.ToUniversalTime() >= tp.GetUtcNow().UtcDateTime.AddDays(-maxAgeDays)) + .Must(capturedAt => capturedAt.UtcDateTime >= tp.GetUtcNow().UtcDateTime.AddDays(-maxAgeDays)) .WithMessage($"`capturedAt` must be within the last {maxAgeDays} days."); // `FlightId` is intentionally not validated beyond JSON shape — AZ-503 diff --git a/SatelliteProvider.Api/Validators/UavUploadValidationFilter.cs b/SatelliteProvider.Api/Validators/UavUploadValidationFilter.cs index 34608f3..99022be 100644 --- a/SatelliteProvider.Api/Validators/UavUploadValidationFilter.cs +++ b/SatelliteProvider.Api/Validators/UavUploadValidationFilter.cs @@ -73,11 +73,12 @@ public sealed class UavUploadValidationFilter : IEndpointFilter { payload = JsonSerializer.Deserialize(metadataField, _jsonOptions); } - catch (JsonException) + catch (JsonException ex) { + var message = string.IsNullOrWhiteSpace(ex.Message) ? MetadataJsonParseError : ex.Message; return Results.ValidationProblem(new Dictionary { - [MetadataField] = new[] { MetadataJsonParseError }, + [MetadataField] = new[] { message }, }); } diff --git a/SatelliteProvider.Common/DTO/UavTileMetadata.cs b/SatelliteProvider.Common/DTO/UavTileMetadata.cs index 4e84e6b..7a95ed1 100644 --- a/SatelliteProvider.Common/DTO/UavTileMetadata.cs +++ b/SatelliteProvider.Common/DTO/UavTileMetadata.cs @@ -1,10 +1,11 @@ using System.Text.Json.Serialization; +using SatelliteProvider.Common.Json; namespace SatelliteProvider.Common.DTO; -// AZ-488 / `uav-tile-upload.md` v1.0.0 — per-tile metadata supplied with each -// batch item. `CapturedAt` is normalized to UTC by the upload handler before -// reaching the persistence layer. +// AZ-488 / `uav-tile-upload.md` — per-tile metadata supplied with each batch +// item. `CapturedAt` is UTC-aware (`DateTimeOffset`) and normalized to UTC +// before persistence (AZ-1126 / F-AZ810-2). // // AZ-503: `FlightId` is optional. When provided, two UAVs uploading the same // (z, x, y) cell from different flights coexist as distinct DB rows and write @@ -27,7 +28,8 @@ public record UavTileMetadata [JsonRequired] public double TileSizeMeters { get; init; } [JsonRequired] - public DateTime CapturedAt { get; init; } + [JsonConverter(typeof(UtcOffsetRequiredDateTimeOffsetConverter))] + public DateTimeOffset CapturedAt { get; init; } public Guid? FlightId { get; init; } } diff --git a/SatelliteProvider.Common/Json/UtcOffsetRequiredDateTimeOffsetConverter.cs b/SatelliteProvider.Common/Json/UtcOffsetRequiredDateTimeOffsetConverter.cs new file mode 100644 index 0000000..c38f4f1 --- /dev/null +++ b/SatelliteProvider.Common/Json/UtcOffsetRequiredDateTimeOffsetConverter.cs @@ -0,0 +1,43 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +namespace SatelliteProvider.Common.Json; + +public sealed partial class UtcOffsetRequiredDateTimeOffsetConverter : JsonConverter +{ + [GeneratedRegex(@"T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2}|[+-]\d{4})$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)] + private static partial Regex ExplicitOffsetPattern(); + + public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.String) + { + throw new JsonException("`capturedAt` must be an ISO-8601 string with an explicit UTC offset."); + } + + var raw = reader.GetString(); + if (string.IsNullOrWhiteSpace(raw)) + { + throw new JsonException("`capturedAt` must be an ISO-8601 string with an explicit UTC offset."); + } + + if (!ExplicitOffsetPattern().IsMatch(raw)) + { + throw new JsonException("`capturedAt` must include an explicit UTC offset (for example `Z` or `+00:00`)."); + } + + if (!DateTimeOffset.TryParse(raw, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsed)) + { + throw new JsonException("`capturedAt` could not be parsed as an ISO-8601 timestamp."); + } + + return parsed.ToUniversalTime(); + } + + public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture)); + } +} diff --git a/SatelliteProvider.IntegrationTests/UavUploadValidationTests.cs b/SatelliteProvider.IntegrationTests/UavUploadValidationTests.cs index 2f9e0ac..9a0b0bc 100644 --- a/SatelliteProvider.IntegrationTests/UavUploadValidationTests.cs +++ b/SatelliteProvider.IntegrationTests/UavUploadValidationTests.cs @@ -91,6 +91,9 @@ public static class UavUploadValidationTests // Rule 14: type mismatch (lat as string) await ItemLatTypeMismatch_Returns400(apiUrl, secret); + // AZ-1126: offset-less capturedAt rejected at deserializer + await ItemCapturedAtOffsetLess_Returns400(apiUrl, secret); + Console.WriteLine("✓ UAV upload metadata validation tests: PASSED"); } @@ -429,6 +432,33 @@ public static class UavUploadValidationTests Console.WriteLine(" ✓ items[0].capturedAt = now-60d rejected with indexed errors path"); } + private static async Task ItemCapturedAtOffsetLess_Returns400(string apiUrl, string secret) + { + Console.WriteLine(); + Console.WriteLine("AZ-1126 AC-2: per-item capturedAt without explicit UTC offset → HTTP 400"); + + // Arrange + var coord = NextTestCoordinate(); + var metadata = new + { + items = new[] + { + new { latitude = coord.Latitude, longitude = coord.Longitude, tileZoom = 18, tileSizeMeters = 200.0, capturedAt = "2026-06-26T12:00:00" }, + }, + }; + using var client = CreateClientWithGpsToken(apiUrl, secret); + + // Act + using var response = await PostBatch(client, metadata, new[] { CreateValidJpeg() }); + var problem = await ProblemDetailsAssertions.ReadProblemDetailsAsync(response, "AZ-1126 item capturedAt offset-less"); + + // Assert + ProblemDetailsAssertions.AssertValidationProblem(problem, expectedStatus: 400, label: "AZ-1126 item capturedAt offset-less"); + ProblemDetailsAssertions.AssertErrorsContainsMention(problem, expectedMention: "capturedAt", label: "AZ-1126 item capturedAt offset-less"); + + Console.WriteLine(" ✓ items[0].capturedAt without offset rejected"); + } + private static async Task ItemFlightIdMalformed_Returns400(string apiUrl, string secret) { Console.WriteLine(); diff --git a/SatelliteProvider.Services.TileDownloader/UavTileQualityGate.cs b/SatelliteProvider.Services.TileDownloader/UavTileQualityGate.cs index 0a9aaf5..6d6a3a6 100644 --- a/SatelliteProvider.Services.TileDownloader/UavTileQualityGate.cs +++ b/SatelliteProvider.Services.TileDownloader/UavTileQualityGate.cs @@ -80,9 +80,7 @@ public sealed class UavTileQualityGate : IUavTileQualityGate // Rule 4 (Captured-at age): forbid future timestamps beyond clock skew and // reject anything older than the configured max age. var now = _timeProvider.GetUtcNow().UtcDateTime; - var capturedAt = metadata.CapturedAt.Kind == DateTimeKind.Utc - ? metadata.CapturedAt - : metadata.CapturedAt.ToUniversalTime(); + var capturedAt = metadata.CapturedAt.UtcDateTime; if (capturedAt > now.AddSeconds(_qualityConfig.CapturedAtFutureSkewSeconds)) { diff --git a/SatelliteProvider.Services.TileDownloader/UavTileUploadHandler.cs b/SatelliteProvider.Services.TileDownloader/UavTileUploadHandler.cs index 507f6e9..3fc1df4 100644 --- a/SatelliteProvider.Services.TileDownloader/UavTileUploadHandler.cs +++ b/SatelliteProvider.Services.TileDownloader/UavTileUploadHandler.cs @@ -157,9 +157,7 @@ public sealed class UavTileUploadHandler : IUavTileUploadHandler var imageArray = imageBytes.ToArray(); await File.WriteAllBytesAsync(filePath, imageArray, cancellationToken); - var capturedAtUtc = metadata.CapturedAt.Kind == DateTimeKind.Utc - ? metadata.CapturedAt - : metadata.CapturedAt.ToUniversalTime(); + var capturedAtUtc = metadata.CapturedAt.UtcDateTime; var now = _timeProvider.GetUtcNow().UtcDateTime; // AZ-503: deterministic id from (z, x, y, source, flight_id-or-zero) and diff --git a/SatelliteProvider.Tests/Json/UtcOffsetRequiredDateTimeOffsetConverterTests.cs b/SatelliteProvider.Tests/Json/UtcOffsetRequiredDateTimeOffsetConverterTests.cs new file mode 100644 index 0000000..9637764 --- /dev/null +++ b/SatelliteProvider.Tests/Json/UtcOffsetRequiredDateTimeOffsetConverterTests.cs @@ -0,0 +1,45 @@ +using System.Text.Json; +using FluentAssertions; +using SatelliteProvider.Common.DTO; +using SatelliteProvider.Common.Json; + +namespace SatelliteProvider.Tests.Json; + +public class UtcOffsetRequiredDateTimeOffsetConverterTests +{ + private static readonly JsonSerializerOptions Options = new() + { + PropertyNameCaseInsensitive = true, + Converters = { new UtcOffsetRequiredDateTimeOffsetConverter() }, + }; + + [Theory] + [InlineData("\"2026-06-26T12:00:00Z\"")] + [InlineData("\"2026-06-26T12:00:00+00:00\"")] + [InlineData("\"2026-06-26T12:00:00.1234567Z\"")] + public void Deserialize_ExplicitUtcOffset_Parses(string capturedAtJson) + { + // Arrange + var json = $$"""{"latitude":50.1,"longitude":36.1,"tileZoom":18,"tileSizeMeters":200,"capturedAt":{{capturedAtJson}}}"""; + + // Act + var metadata = JsonSerializer.Deserialize(json, Options); + + // Assert + metadata.Should().NotBeNull(); + metadata!.CapturedAt.Offset.Should().Be(TimeSpan.Zero); + } + + [Fact] + public void Deserialize_OffsetLessIsoString_ThrowsJsonException() + { + // Arrange + const string json = """{"latitude":50.1,"longitude":36.1,"tileZoom":18,"tileSizeMeters":200,"capturedAt":"2026-06-26T12:00:00"}"""; + + // Act + var act = () => JsonSerializer.Deserialize(json, Options); + + // Assert + act.Should().Throw().WithMessage("*explicit UTC offset*"); + } +} diff --git a/SatelliteProvider.Tests/UavTileQualityGateTests.cs b/SatelliteProvider.Tests/UavTileQualityGateTests.cs index 9cd558c..2973ade 100644 --- a/SatelliteProvider.Tests/UavTileQualityGateTests.cs +++ b/SatelliteProvider.Tests/UavTileQualityGateTests.cs @@ -114,7 +114,7 @@ public class UavTileQualityGateTests var now = new DateTime(2026, 5, 11, 12, 0, 0, DateTimeKind.Utc); var gate = BuildGate(timeProvider: new FixedTimeProvider(now)); var bytes = UavTileImageFactory.CreateRandomJpeg(); - var metadata = ValidMetadata() with { CapturedAt = now.AddHours(1) }; + var metadata = ValidMetadata() with { CapturedAt = new DateTimeOffset(now.AddHours(1), TimeSpan.Zero) }; // Act var result = gate.Validate(bytes, JpegContentType, metadata); @@ -131,7 +131,7 @@ public class UavTileQualityGateTests var now = new DateTime(2026, 5, 11, 12, 0, 0, DateTimeKind.Utc); var gate = BuildGate(timeProvider: new FixedTimeProvider(now)); var bytes = UavTileImageFactory.CreateRandomJpeg(); - var metadata = ValidMetadata() with { CapturedAt = now.AddDays(-8) }; + var metadata = ValidMetadata() with { CapturedAt = new DateTimeOffset(now.AddDays(-8), TimeSpan.Zero) }; // Act var result = gate.Validate(bytes, JpegContentType, metadata); @@ -148,7 +148,7 @@ public class UavTileQualityGateTests var now = new DateTime(2026, 5, 11, 12, 0, 0, DateTimeKind.Utc); var gate = BuildGate(timeProvider: new FixedTimeProvider(now)); var bytes = UavTileImageFactory.CreateRandomJpeg(); - var metadata = ValidMetadata() with { CapturedAt = now.AddSeconds(20) }; + var metadata = ValidMetadata() with { CapturedAt = new DateTimeOffset(now.AddSeconds(20), TimeSpan.Zero) }; // Act var result = gate.Validate(bytes, JpegContentType, metadata); @@ -216,7 +216,7 @@ public class UavTileQualityGateTests Longitude = 37.647063, TileZoom = 18, TileSizeMeters = 200.0, - CapturedAt = new DateTime(2026, 5, 11, 11, 30, 0, DateTimeKind.Utc), + CapturedAt = new DateTimeOffset(2026, 5, 11, 11, 30, 0, TimeSpan.Zero), }; private sealed class FixedTimeProvider : TimeProvider diff --git a/SatelliteProvider.Tests/UavTileUploadHandlerTests.cs b/SatelliteProvider.Tests/UavTileUploadHandlerTests.cs index 03f9043..28ef227 100644 --- a/SatelliteProvider.Tests/UavTileUploadHandlerTests.cs +++ b/SatelliteProvider.Tests/UavTileUploadHandlerTests.cs @@ -275,7 +275,7 @@ public class UavTileUploadHandlerTests : IDisposable Longitude = 37.647063, TileZoom = 18, TileSizeMeters = 200.0, - CapturedAt = new DateTime(2026, 5, 11, 11, 30, 0, DateTimeKind.Utc), + CapturedAt = new DateTimeOffset(2026, 5, 11, 11, 30, 0, TimeSpan.Zero), }; private sealed class FixedTimeProvider : TimeProvider diff --git a/SatelliteProvider.Tests/Validators/UavTileBatchMetadataPayloadValidatorTests.cs b/SatelliteProvider.Tests/Validators/UavTileBatchMetadataPayloadValidatorTests.cs index cc82acc..4010bba 100644 --- a/SatelliteProvider.Tests/Validators/UavTileBatchMetadataPayloadValidatorTests.cs +++ b/SatelliteProvider.Tests/Validators/UavTileBatchMetadataPayloadValidatorTests.cs @@ -11,7 +11,7 @@ namespace SatelliteProvider.Tests.Validators; public class UavTileBatchMetadataPayloadValidatorTests { private readonly UavTileBatchMetadataPayloadValidator _validator; - private readonly DateTime _now; + private readonly DateTimeOffset _now; public UavTileBatchMetadataPayloadValidatorTests() { @@ -22,7 +22,7 @@ public class UavTileBatchMetadataPayloadValidatorTests MaxAgeDays = 7, CapturedAtFutureSkewSeconds = 30, }); - _now = new DateTime(2026, 5, 22, 12, 0, 0, DateTimeKind.Utc); + _now = new DateTimeOffset(2026, 5, 22, 12, 0, 0, TimeSpan.Zero); _validator = new UavTileBatchMetadataPayloadValidator(config, new FixedTimeProvider(_now)); } @@ -96,8 +96,8 @@ public class UavTileBatchMetadataPayloadValidatorTests private sealed class FixedTimeProvider : TimeProvider { - private readonly DateTime _utcNow; - public FixedTimeProvider(DateTime utcNow) => _utcNow = utcNow; - public override DateTimeOffset GetUtcNow() => new(_utcNow, TimeSpan.Zero); + private readonly DateTimeOffset _utcNow; + public FixedTimeProvider(DateTimeOffset utcNow) => _utcNow = utcNow; + public override DateTimeOffset GetUtcNow() => _utcNow; } } diff --git a/SatelliteProvider.Tests/Validators/UavTileMetadataValidatorTests.cs b/SatelliteProvider.Tests/Validators/UavTileMetadataValidatorTests.cs index 48918c4..66369e8 100644 --- a/SatelliteProvider.Tests/Validators/UavTileMetadataValidatorTests.cs +++ b/SatelliteProvider.Tests/Validators/UavTileMetadataValidatorTests.cs @@ -13,7 +13,7 @@ namespace SatelliteProvider.Tests.Validators; public class UavTileMetadataValidatorTests { private readonly UavTileMetadataValidator _validator; - private readonly DateTime _now; + private readonly DateTimeOffset _now; public UavTileMetadataValidatorTests() { @@ -23,7 +23,7 @@ public class UavTileMetadataValidatorTests MaxAgeDays = 7, CapturedAtFutureSkewSeconds = 30, }); - _now = new DateTime(2026, 5, 22, 12, 0, 0, DateTimeKind.Utc); + _now = new DateTimeOffset(2026, 5, 22, 12, 0, 0, TimeSpan.Zero); _validator = new UavTileMetadataValidator(config, new FixedTimeProvider(_now)); } @@ -32,12 +32,12 @@ public class UavTileMetadataValidatorTests // consumer appears, promote to SatelliteProvider.TestSupport. private sealed class FixedTimeProvider : TimeProvider { - private readonly DateTime _utcNow; - public FixedTimeProvider(DateTime utcNow) => _utcNow = utcNow; - public override DateTimeOffset GetUtcNow() => new(_utcNow, TimeSpan.Zero); + private readonly DateTimeOffset _utcNow; + public FixedTimeProvider(DateTimeOffset utcNow) => _utcNow = utcNow; + public override DateTimeOffset GetUtcNow() => _utcNow; } - private static UavTileMetadata ValidMetadata(DateTime capturedAt) => new() + private static UavTileMetadata ValidMetadata(DateTimeOffset capturedAt) => new() { Latitude = 50.10, Longitude = 36.10, diff --git a/_docs/02_document/contracts/api/uav-tile-upload.md b/_docs/02_document/contracts/api/uav-tile-upload.md index 7417e6f..2bb90e0 100644 --- a/_docs/02_document/contracts/api/uav-tile-upload.md +++ b/_docs/02_document/contracts/api/uav-tile-upload.md @@ -4,7 +4,7 @@ **Producer task**: AZ-488 — `_docs/02_tasks/done/AZ-488_uav_tile_upload.md` **Extended by**: AZ-503 — `_docs/02_tasks/done/AZ-503_tile_identity_uuidv5_bulk_list.md` (added optional `flightId` per-item field; per-flight on-disk path; deterministic `tileId`) **Consumer tasks**: `gps-denied-onboard`, mission planner UI, any future UAV-equipped client -**Version**: 1.2.0 +**Version**: 1.2.1 **Status**: frozen **Last Updated**: 2026-05-23 @@ -39,7 +39,7 @@ Multipart form fields (case-sensitive part names): | `longitude` | number | yes | Geographic longitude of the tile center | WGS-84 decimal degrees | | `tileZoom` | integer | yes | Slippy Map zoom level | Must satisfy the same zoom-level policy as the existing tile pipeline (see `MapConfig.AllowedZoomLevels`) | | `tileSizeMeters` | number | yes | Tile size in meters at the captured latitude | Producer-supplied | -| `capturedAt` | string (ISO-8601 UTC) | yes | Moment of UAV image capture | Must satisfy the captured-at rule (see Quality Gate, Rule 4) | +| `capturedAt` | string (ISO-8601 with explicit UTC offset) | yes | Moment of UAV image capture | Must include an explicit offset (`Z` or `+00:00`); offset-less timestamps are rejected. Must satisfy the captured-at rule (see Quality Gate, Rule 4) | | `flightId` | string (UUID) | no | AZ-503: optional flight identifier. When present, two flights uploading the same cell coexist as separate rows; absent uploads share a single anonymous row per cell. Omitting the field is fully backward-compatible with v1.0.0 clients. | RFC 4122 UUID. Backward-compatible default: `null` | Field names are camelCase. Property-name matching is case-insensitive on read. @@ -238,3 +238,4 @@ Each version bump requires updating the Change Log and notifying every consumer | 1.0.0 | 2026-05-11 | Initial contract — batch UAV upload endpoint, 5-rule quality gate, per-source UPSERT, closed reject-reason enum, GPS-permission requirement. Produced by AZ-488. | autodev (cycle 2 step 10) | | 1.1.0 | 2026-05-12 | Minor bump for AZ-503: added optional `flightId` per-item metadata field (backward-compatible default `null`); `tileId` in the response is now a deterministic UUIDv5 derived from `(z, x, y, source, flightId)` instead of a random Guid; on-disk path adds a `{flightId or 'none'}` segment for per-flight evidence isolation. No reject-reason changes, no envelope changes, no permission changes. v1.0.0 clients omitting `flightId` keep working unchanged. | autodev (cycle 5 step 13) | | 1.2.0 | 2026-05-23 | Minor bump for AZ-810: added the **metadata validation** layer (14 rules) wired through a new `UavUploadValidationFilter` that runs BEFORE the per-item Quality Gate. Marks every non-optional metadata axis with `[JsonRequired]`; uses `UnmappedMemberHandling.Disallow` so unknown root/nested fields are rejected. HTTP 400 envelope-error body now matches the shared `ValidationProblemDetails` shape per `error-shape.md` v1.0.0. **Behavior change**: callers previously sending malformed metadata that silently coerced (e.g. `latitude: 0` for a missing field) now receive HTTP 400 instead of HTTP 200 + per-item rejection. No wire-format renames, no reject-reason changes, no permission changes. The Quality Gate (5 rules) is unchanged and continues as defence-in-depth. | autodev (cycle 8 batch 4) | +| 1.2.1 | 2026-06-26 | Patch for AZ-1126 / F-AZ810-2: `capturedAt` is typed as `DateTimeOffset` at the metadata DTO layer. Offset-less ISO-8601 strings (e.g. `"2026-06-26T12:00:00"` without `Z` or `+00:00`) are rejected at deserialization with HTTP 400. Compliant clients already sending `Z`-suffixed timestamps are unchanged. Closes security finding F-AZ810-2. | autodev (cycle 13) | diff --git a/_docs/02_tasks/_dependencies_table.md b/_docs/02_tasks/_dependencies_table.md index b168658..dfbff06 100644 --- a/_docs/02_tasks/_dependencies_table.md +++ b/_docs/02_tasks/_dependencies_table.md @@ -269,7 +269,7 @@ Step 9 cycle 13: 1 task created (AZ-1126 = 2 pts) — `DateTime` → `DateTimeOf | Task | Depends On | Points | Status | |------|-----------|--------|--------| -| AZ-1126 capturedAt DateTimeOffset (F-AZ810-2) | AZ-810, AZ-488 | 2 | Todo | +| AZ-1126 capturedAt DateTimeOffset (F-AZ810-2) | AZ-810, AZ-488 | 2 | Done (In Testing) | ## Coverage Verification diff --git a/_docs/02_tasks/todo/AZ-1126_captured_at_datetimeoffset.md b/_docs/02_tasks/done/AZ-1126_captured_at_datetimeoffset.md similarity index 100% rename from _docs/02_tasks/todo/AZ-1126_captured_at_datetimeoffset.md rename to _docs/02_tasks/done/AZ-1126_captured_at_datetimeoffset.md diff --git a/_docs/03_implementation/batch_01_cycle13_report.md b/_docs/03_implementation/batch_01_cycle13_report.md new file mode 100644 index 0000000..520773a --- /dev/null +++ b/_docs/03_implementation/batch_01_cycle13_report.md @@ -0,0 +1,37 @@ +# Batch Report + +**Batch**: 1 +**Tasks**: AZ-1126_captured_at_datetimeoffset +**Date**: 2026-06-26 +**Cycle**: 13 + +## Task Results + +| Task | Status | Files Modified | Tests | AC Coverage | Issues | +|------|--------|---------------|-------|-------------|--------| +| AZ-1126 | Done | 12 files | pass | 4/4 ACs covered | None | + +## AC Test Coverage: All covered + +| AC | Evidence | +|----|----------| +| AC-1 | `UavTileMetadataValidatorTests`, `UavTileQualityGateTests`, `UavTileUploadHandlerTests` | +| AC-2 | `UtcOffsetRequiredDateTimeOffsetConverterTests`, `UavUploadValidationTests.ItemCapturedAtOffsetLess_Returns400` | +| AC-3 | `UavUploadValidationTests.HappyPath_Returns200`, existing `UavUploadTests` suite | +| AC-4 | `uav-tile-upload.md` v1.2.1 change log | + +## Code Review Verdict: PASS_WITH_WARNINGS + +Self-review: type migration is localized; filter now surfaces `JsonException.Message` for metadata parse failures (improves flightId and capturedAt diagnostics). No architecture drift. + +## Auto-Fix Attempts: 1 + +Integration test initially failed because `UavUploadValidationFilter` replaced all `JsonException` messages with a generic parse error; fixed by propagating the converter message. + +## Stuck Agents: None + +## Full Test Run + +`./scripts/run-tests.sh` (mode=full) — all unit + integration tests passed after filter fix. + +## Next Batch: All tasks complete diff --git a/_docs/03_implementation/implementation_completeness_cycle13_report.md b/_docs/03_implementation/implementation_completeness_cycle13_report.md new file mode 100644 index 0000000..0c649d8 --- /dev/null +++ b/_docs/03_implementation/implementation_completeness_cycle13_report.md @@ -0,0 +1,16 @@ +# Product Implementation Completeness — Cycle 13 + +**Date**: 2026-06-26 +**Cycle**: 13 + +## Per-Task Classification + +| Task | Verdict | Evidence | +|------|---------|----------| +| AZ-1126 | PASS | `UavTileMetadata.CapturedAt` is `DateTimeOffset` with `UtcOffsetRequiredDateTimeOffsetConverter`; validator, quality gate, and upload handler compare via `UtcDateTime`; contract patched to 1.2.1; integration + unit tests green | + +## System Pipeline Audit + +No new end-to-end pipelines introduced. UAV upload pipeline remains WIRED (filter → handler → quality gate → repository). + +## Gate Verdict: PASS diff --git a/_docs/03_implementation/implementation_report_captured_at_datetimeoffset_cycle13.md b/_docs/03_implementation/implementation_report_captured_at_datetimeoffset_cycle13.md new file mode 100644 index 0000000..85e2885 --- /dev/null +++ b/_docs/03_implementation/implementation_report_captured_at_datetimeoffset_cycle13.md @@ -0,0 +1,26 @@ +# Implementation Report — capturedAt DateTimeOffset (Cycle 13) + +**Cycle**: 13 +**Tasks**: AZ-1126 +**Date**: 2026-06-26 + +## Summary + +Migrated `UavTileMetadata.CapturedAt` from `DateTime` to `DateTimeOffset` with a strict JSON converter requiring explicit UTC offsets. Closes security finding F-AZ810-2. + +## Changes + +- `UtcOffsetRequiredDateTimeOffsetConverter` rejects offset-less ISO-8601 strings at deserialization +- FluentValidation freshness rules and quality gate use `UtcDateTime` without `DateTimeKind` branching +- `UavUploadValidationFilter` propagates `JsonException.Message` for metadata parse failures +- Contract `uav-tile-upload.md` 1.2.0 → 1.2.1 + +## Test Evidence + +- Unit: `UtcOffsetRequiredDateTimeOffsetConverterTests`, updated UAV validator/gate/handler tests +- Integration: `UavUploadValidationTests.ItemCapturedAtOffsetLess_Returns400` +- Full suite: `./scripts/run-tests.sh` passed (mode=full) + +## Handoff + +Full test suite already executed in Step 10; Step 11 (Run Tests) may treat this as pre-verified per implement skill Step 16 handoff. diff --git a/_docs/_autodev_state.md b/_docs/_autodev_state.md index f172e1b..b431a6d 100644 --- a/_docs/_autodev_state.md +++ b/_docs/_autodev_state.md @@ -2,21 +2,23 @@ ## Current Step flow: existing-code -step: 10 -name: Implement -status: in_progress +step: 11 +name: Run Tests +status: not_started sub_step: - phase: 1 - name: parse + phase: 0 + name: awaiting-invocation detail: "" retry_count: 0 -cycle: 12 +cycle: 13 tracker: jira auto_push: true ## Last Completed Cycle -cycle: 11 -step_16_deploy: completed -step_16_5_release: skipped (no release harness) +cycle: 12 +step_14_security: skipped +step_15_perf: completed +step_16_deploy: skipped +step_16_5_release: skipped step_17_retrospective: completed -verdict: cycle_complete_operator_deploy +verdict: cycle_complete