AB#294553 Data egress to LDS - #441
ajs-uk-dev wants to merge 53 commits into
Conversation
…r (AB#294553) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013qgFBkyvmGoAxeA4jfmmY5
…553) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013qgFBkyvmGoAxeA4jfmmY5
… (AB#294553) Deviation: LdsSpecValidator.Failures.OneOf takes IReadOnlySet<string>, which C# collection expressions cannot construct at a target-typed call site (CS9174). Used named HashSet<string> fields (RemoveSexes, NewLearnerSexes) instead of inline literals for the two Sex checks; behaviour is identical to the plan. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013qgFBkyvmGoAxeA4jfmmY5
…AddDataEgress migration (AB#294553) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013qgFBkyvmGoAxeA4jfmmY5
…e (AB#294553) Deviation: AuditEntry lives in namespace DfE.CheckPerformance.Persistence.Entities (missing "Data" — a pre-existing quirk elsewhere in this codebase), not DfE.CheckPerformanceData.Persistence.Entities as the plan's using list implied; added the correct using, no behavioural change. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013qgFBkyvmGoAxeA4jfmmY5
… outbox fake (AB#294553)
Deviations from the plan's test code (implementation matches the plan exactly):
- Polly's RetryStrategyOptions requires MaxRetryAttempts >= 1; the test's
PollySettings used 0, which fails Polly's options validation before the SUT
can even run. Changed to 1.
- The four-id pull test mocked ShowManyTickets("1,2,3") but the SUT batches all
requested ids into one call ("1,2,3,4") and lets Zendesk's response simply
omit the untracked ticket. Fixed the mock's expected argument to match.
- Moved DevOutboxEgressTicketSourceTests to the integration test project per
the plan's own fallback: the unit-test project has no in-memory-EF pattern
and must not gain a MockQueryable dependency, so it runs against real
Postgres via PostgresFixture instead.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qgFBkyvmGoAxeA4jfmmY5
…into a saved run (AB#294553) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013qgFBkyvmGoAxeA4jfmmY5
…thing save (AB#294553) Deviation: Microsoft.Extensions.TimeProvider.Testing (FakeTimeProvider) is not referenced by this project, so used the plan's own fallback: a private FakeTimeProvider inside the test file instead of the package. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013qgFBkyvmGoAxeA4jfmmY5
…udit (AB#294553) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013qgFBkyvmGoAxeA4jfmmY5
…Azurite account (AB#294553) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013qgFBkyvmGoAxeA4jfmmY5
…y, transfer, complete (AB#294553) Deviation: this controller declares an action named Results, which shadows Microsoft.AspNetCore.Http.Results at the unqualified Results.ServerSentEvents(...) call site (CS0119, method group vs static class). Fully qualified the call. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013qgFBkyvmGoAxeA4jfmmY5
… summary, preview, complete (AB#294553) Deviations found by walking the live Pull page in the running stack (not caught by the view-source unit tests, which only pin literal source substrings): - The h1-count test's formula double-counted <h1 class="govuk-panel__title"> (matching both the "<h1 " and "govuk-panel__title" terms for one element). Simplified the formula to count "<h1 " only. - GovUk.Frontend.AspNetCore's govuk-checkboxes-error-message tag helper unconditionally auto-renders its own page-level error summary at the top of <main>, producing a second "There is a problem" region alongside this page's own consolidated govuk-error-summary (which also needs to list refusals and pull failures that have no single field to attach to). Moved the output-types field error to plain markup outside the checkboxes fieldset instead, and switched the consolidated summary to the govuk-error-summary tag helper. Verified live: exactly one error summary renders, listing both field errors. Verified live in the docker-compose stack: the "Data egress" nav group and "Start a new egress" tile render on /admin, the Pull form renders with seeded windows and both checkboxes, and submitting with nothing selected shows the single consolidated error summary. Screenshot at .superpowers/evidence/294553/01-pull.png. Full unit suite: 5355/0. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013qgFBkyvmGoAxeA4jfmmY5
…sfer and refusal (AB#294553)
Deviations found running the E2E class in the container and by walking the JS
path live:
- ASP.NET Core does not merge a plain "OutputTypes" key with an indexed
"OutputTypes[1]" key into one bound list — only genuinely repeated identical
keys bind as a List<T> (verified against the running app: the indexed form
silently bound only the first value, so a conflicting second output type was
never checked). Changed PostFormAsync to take IEnumerable<KeyValuePair> so a
caller can supply two literal "OutputTypes" entries; fixed every call site.
- The JS-driven progress test asserted step 1 (then step 8) showed "Done"
before checking the terminal redirect. A single record against the local
database preprocesses fast enough that the browser can navigate to Summary
before ever rendering an intermediate step state, so the element the
assertion polled for had already been removed from the DOM. Simplified to
assert only the terminal outcome (the Summary heading).
- The container's working_dir is the bind-mounted repo root, not the test
project directory, so `dotnet test --filter ...` with no project path found
nothing ("Nothing to do"). Ran with the explicit project path, matching the
default command already in docker-compose.e2e.yml.
Verified: all 4 DataEgressTests pass in the e2e-tests container (including the
real Playwright browser fact, not skipped). Walked the JS path live end-to-end
(pull → results → preprocessing → summary → transfer → complete) against the
real Azurite egress account; cleanup afterwards reported the run, 3 requests
and the 1 uploaded blob it removed, confirming the transfer really reached
blob storage. Screenshots at .superpowers/evidence/294553/02..05-*.png. Full
unit suite: 5357/0. No stray E2E snapshot PNGs were touched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qgFBkyvmGoAxeA4jfmmY5
Adds docs/data-egress.md as the reference for the shipped feature; points docs/spikes/data-egress-spike.md at it while keeping the spike's Zendesk background and Remove-learners column list; removes the stale root data-egress-spike.md duplicate (it carried one extra sentence about writing Zendesk status back to ChangeRequests, already covered by the existing rules-engine/Zendesk consumers, not by egress); updates the C4 container status table to split Data Egress Processing (now Implemented) out of the combined Ingress/Egress "Planned" row; notes in add-pupil-journey.md's LDS bound values section that EgressRecordBuilder.Build now consumes them. PR notes at .superpowers/plans/2026-09-14-294553-pr-notes.md record every design decision, every deviation from the plan (with reasons), the exact verification figures, and every FLAGGED copy/content item for sign-off. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013qgFBkyvmGoAxeA4jfmmY5
…fuse the fake in Production The web host selected the dev-outbox Zendesk fake by default (Zendesk:UseFake had no configured value, only a code-level default of true), so a Production deployment with no override would silently read DevZendeskTickets instead of real Zendesk decisions. The worker avoids this by pinning Zendesk:UseFake=false in its own appsettings.json; the web project had no Zendesk section at all. - Web/appsettings.json now pins Zendesk:UseFake=false, matching the worker. - EgressExtensions.AddCpdEgress's own GetValue default also changes from true to false, so an environment with no Zendesk:UseFake key anywhere gets the real client, not the fake — proven by the fact that it then fails fast on missing ZendeskSettings rather than silently succeeding against the dev outbox. - AddCpdEgress now takes IHostEnvironment and throws at startup if Zendesk:UseFake=true is set while the environment is Production, so the dev outbox can never be reached there regardless of configuration (mirroring DevEgressController's own guard). - docker-compose.yaml (web, used for both local dev and the e2e profile) and docker-compose.sandbox.yaml (a separate isolated stack with no Zendesk settings of its own) now opt in explicitly with Zendesk__UseFake=true, since neither has real Zendesk credentials to fall back on. Replaced EgressExtensionsTests.Default_is_the_dev_outbox_source_with_no_zendesk_settings_needed, which pinned the dangerous default as intended, with three facts: no configuration selects the real client (and fails fast without settings), UseFake=true in a non-Production environment selects the dev outbox, and UseFake=true in Production throws at startup. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013qgFBkyvmGoAxeA4jfmmY5
… sweep on Abandon Once a run flips to Transferring, EgressTransferService now runs the upload loop, MarkTransferredAsync and all compensation with CancellationToken.None instead of the request's RequestAborted token — a closed browser tab could previously throw an uncompensated OperationCanceledException past the `when (ex is not OperationCanceledException)` filter, leaving files in LDS and the run stuck in Transferring forever with no audit row. Every exception (including cancellation) around the upload loop and around MarkTransferredAsync is now caught and compensated. S3: on any upload failure, the failing file itself (not just previously-uploaded ones) is now also swept via a new metadata-checked delete — IEgressBlobClient. DeleteIfOwnedByRunAsync — covering the case where the PUT actually succeeded server-side but the response was lost to the client, without ever touching a blob stamped with a different run's id. M1 also fixes Abandon of a Transferring run: it moves from IEgressRunService (no blob access) to IEgressTransferService, sweeps this run's own files via the same ownership-checked delete before marking Abandoned, and the controller's banner now says what was removed rather than always claiming nothing was transferred. The fuller status-guard rework for every terminal write (rows-affected races, banner-per-outcome) is M4, not this commit. Tests: cancellation after the Transferring flip still compensates (asserts CancellationToken.None reaches the delete and MarkTransferFailedAsync calls); MarkTransferredAsync throwing after every upload succeeds compensates; a failing compensation delete appends the manual-cleanup reason (previously untested); the S3 metadata-matched delete on a failed upload; three Abandon facts (sweeps only owned files during Transferring, never touches blob storage otherwise, refuses when already Transferred, reports not-found); three new EgressBlobClientTests against real Azurite for DeleteIfOwnedByRunAsync (owned/other-run/missing). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013qgFBkyvmGoAxeA4jfmmY5
…g the Failed page copy
A PreprocessingFailed run releases its (window, output type) pair (IsActive=false on
its outputs), so a colleague's fresh run for the same pair is admitted alongside it —
but EgressPreprocessor.RunAsync still accepted PreprocessingFailed as a runnable status.
Since the raw snapshot is deterministic, re-running the same failed run fails
identically today, but the moment the LDS spec correction lands (EgressColumnSets/
LdsSpecValidator), a re-run could succeed and reach Transferred while holding no lock —
both the re-run and the fresh run could then transfer the same pair.
Dropped PreprocessingFailed from the preprocessor's admitted statuses; it now gets the
same terminal refusal shape as any other non-runnable status ("This run is
PreprocessingFailed and cannot be preprocessed."), which the existing no-JS POST and SSE
stream both already bounce home/to-summary cleanly on. This also matches Failed.cshtml's
own copy, "Correct the source data, then start a new run." Results.cshtml no longer
offers "Proceed to preprocessing" for a PreprocessingFailed run.
Tests: replaced EgressPreprocessorTests.A_re_run_after_failure_is_allowed (which pinned
the bypass) with a refusal fact; a Results.cshtml view-source pin for the hidden link;
an EgressRunRepositoryTests fact that a failed run's outputs stay inactive and a fresh
run for the same pair is admitted.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qgFBkyvmGoAxeA4jfmmY5
A run whose preprocessing produced zero saved rows for every output (everything rejected, undecided, or lost to a misconfigured ticket source per B1) preprocessed to Preprocessed with Summary still offering "Confirm and transfer" — reproduced live in the review: uploads a 163-byte header-only CSV, writes a Succeeded audit row, and locks the pair Transferred forever with nothing meaningful sent to LDS. EgressTransferService.TransferAsync now returns a new NothingToTransfer outcome when every output's saved row count (OutputRecordCount) is zero, checked before touching storage or flipping status to Transferring — so a retry of a TransferFailed run with no rows also refuses rather than reactivating for nothing. Summary.cshtml replaces the Confirm button with a plain message in that case (Abandon still works); the Transfer controller action redirects such a result to Summary rather than Complete. Also folds in the related nit: EgressPreprocessor derived the file-name stage from the first pulled record and fell back to a hard-coded KS4June when a run had no records at all (a window with zero candidate requests) — now takes IWindowService and derives the stage from the window itself, which is always correct and needs no guess. Q1 (whether LDS ever wants an empty file) stays open; refusing is the safe default. Tests: TransferAsync refuses before any upload/status-change when every output is empty, and still transfers when at least one output has rows; a preprocessor fact that the file name uses the window's own stage even with zero records; a controller fact that NothingToTransfer redirects to Summary, not Complete; Summary.cshtml view-source pins for the message and the hidden button. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013qgFBkyvmGoAxeA4jfmmY5
…llow Abandon during Preprocessing
Every terminal status update in EgressRunRepository (MarkPreprocessingFailedAsync,
SavePreprocessedAsync, MarkTransferredAsync, MarkTransferFailedAsync) updated
unconditionally on runId alone and ignored the row count. Two admins racing (one
clicking Transfer, one clicking Abandon on the same Summary; or an Abandon landing
mid-SSE-preprocessing) could resurrect an abandoned run: the losing write would still
land and overwrite Abandoned back to Transferred/Preprocessed/PreprocessingFailed, with
a Succeeded audit row for a transfer that should never have completed.
Each of these repository methods now takes the status it expects the run to currently
be in, guards its update with that predicate, and returns the rows affected instead of
void. When the guard fails (0 rows), nothing else in that method runs — no learner rows,
no file-name update, no audit entry — so a run that has moved on is never partially
touched.
Callers treat 0 as "lost the race":
- EgressTransferService: a 0 from MarkTransferredAsync (no exception, just guarded out)
compensates the uploads and reports Failed, exactly like an exception would, and
writes no Succeeded audit row.
- EgressPreprocessor: a 0 from SavePreprocessedAsync or MarkPreprocessingFailedAsync
yields a terminal failure event ("This run was abandoned while preprocessing.") with
no FinalStatus, rather than claiming Preprocessed or PreprocessingFailed for a run
that is actually Abandoned.
S8: EgressTransferService.AbandonAsync now picks its outcome from AbandonAsync's actual
rows-affected return (0 means a concurrent transfer won the race) instead of assuming
success; the controller's banner already followed from this result since the M1 commit.
AbandonAsync's own guard is unchanged in shape (excludes only Transferred) but now
explicitly documented and tested as admitting Preprocessing — a run stuck there after a
pod restart had no way to be released from the UI, since Preprocessing.cshtml carried
only the Run form. Added the same Abandon form Results/Summary already have.
PortalDbContext gains a generic ExecuteInTransactionAsync<T> overload (same re-entrant/
execution-strategy shape as the existing void one) so these guarded writes can return
their row count from inside one transaction.
Tests: three integration facts pinning the race directly — MarkTransferred after Abandon
is a no-op (run stays Abandoned, no Transfer audit row), Abandon after Transferred is a
no-op (run stays Transferred), SavePreprocessed after Abandon is a no-op (run stays
Abandoned, no learner rows) — plus one that Abandon admits a run stuck in Preprocessing;
unit tests for both "lost the race" caller paths (transfer service and preprocessor);
a Preprocessing.cshtml view-source pin for the new Abandon form.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qgFBkyvmGoAxeA4jfmmY5
EgressRunOutput.RawRecordsJson is the raw pulled payload — names, DOB, sex, UPN, every journey answer for every candidate request — and PortalDbContext.CollectAuditEntries already exempted the two learner-row entities on this exact rationale (derived data from one deliberate admin action, the run-level Transfer/TransferFailed AuditEntry is the record this feature asks for) but stopped one entity short. Verified live in the review: EntityType='EgressRunOutput', Action='Insert' produced a 4,147-byte NewValues blob containing pupil first/last names. AuditEntries has no retention purge of its own. No dedicated exemption test existed for the two learner rows to add this alongside, so this commit's new integration test covers all three exempted entities together. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013qgFBkyvmGoAxeA4jfmmY5
…delegate SavePreprocessedAsync and MarkTransferredAsync run inside PortalDbContext's execution-strategy delegate (EnableRetryOnFailure), which re-runs the whole delegate on a transient fault. A prior attempt's AddRange/Add calls leave their entities tracked as Added even though nothing was persisted (ExecuteDeleteAsync is a bulk operation that bypasses the change tracker, so it cannot remove them); if the retry's own AddRange/Add runs again without clearing first, both attempts' entities get saved together — 2N learner rows for an N-row preprocessing run, which is what ends up in the file LDS receives, or a duplicated audit row for a transfer. Added db.ChangeTracker.Clear() as the first line of each method's delegate. Exposed ChangeTracker on IPortalDbContext (PortalDbContext already has it via DbContext) so the repository can reach it through the abstraction it already depends on. Reproduced the exact mechanism directly rather than forcing a real transient Postgres fault (which would be flaky): manually track a stale, never-persisted entity on the same context instance before calling SavePreprocessedAsync, the way a retried attempt's own AddRange would leave one behind, and assert only the real row survives. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013qgFBkyvmGoAxeA4jfmmY5
…dent one SplitEstablishment and StandardiseDates (steps 3-4) early-returned on HasFailed even though they check facts entirely independent of step 2 (DeriveCodes) and of each other — a bad LAESTAB has nothing to do with an unmapped correction reason. A record with two unrelated faults reported only the first, so ops discovered the second only after fixing the first and re-running. Only Build/Trim (which need the outputs of the earlier steps) and Validate (already gated via EgressPreprocessor's `.Where(x => !x.HasFailed)`) need to skip a failed item. Removed the `if (item.HasFailed) return;` guards from SplitEstablishment and StandardiseDates. The existing One_failing_record_fails_the_batch... test only ever supplied a single fault and could not have caught this. Test: a record with both an unmapped reason and no LAESTAB anywhere now lists both failures. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013qgFBkyvmGoAxeA4jfmmY5
The Task 12 workaround for GovUk.Frontend.AspNetCore's duplicate-error-summary bug moved the output-types field error entirely outside <govuk-checkboxes-fieldset>, which fixed the duplicate summary but broke the field-level association the GOV.UK pattern and WCAG 3.3.1/1.3.1 require: the error rendered after the closing </div>, with no aria-describedby reference from the fieldset and no govuk-form-group--error on it, so a screen-reader user landing on the first checkbox from the summary link heard no error. Tried <govuk-checkboxes-error-message> again first as the review suggested, now that the page's own summary uses <govuk-error-summary> — it binds only via asp-validation-for against ModelState, which OutputTypesError (a plain string, not model-bound) does not use, so adopting it would mean wiring ModelState validation for this field, which overlaps with S9's separate fix. Kept the hand-rendered error, moved inside the fieldset. <govuk-checkboxes-fieldset> only allows tag-helper children (a bare <p> there is RZ2009, confirmed by trying it), so the error now renders inside <govuk-checkboxes-before-inputs> — the fieldset's slot for exactly this — landing between the hint and the checkbox items. The fieldset's aria-describedby now includes OutputTypes-error when there is one, and the wrapping form group already carried govuk-form-group--error. Tests: a view-source pin for the structure (error between hint and items, referenced from aria-describedby); a new E2E fact posting the Pull form empty and asserting exactly one "There is a problem" summary still renders and the fieldset's aria-describedby includes both OutputTypes-hint and OutputTypes-error (will run with the final E2E suite pass). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013qgFBkyvmGoAxeA4jfmmY5
…sbar role="progressbar" only permits presentational children per the ARIA spec, so the aria-live="polite" status paragraph nested inside it may never be announced by assistive tech. Nothing visual is drawn inside the bar itself (there is no GOV.UK progress-bar component), so the paragraph is now a sibling of the progressbar div instead of a child — the JS already selects it with root.querySelector, unscoped to the bar, so no script change was needed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013qgFBkyvmGoAxeA4jfmmY5
…readyExistsException EgressBlobClient.UploadAsync caught any 409 from the create-only upload and reported it as "a file named X already exists in the LDS container — remove it by hand", but a lease held on the blob or the container being deleted mid-request also return 409 with a different ErrorCode and mean something else entirely. Narrowed the catch to ex.ErrorCode == BlobErrorCode.BlobAlreadyExists; any other 409 now propagates unchanged. New unit tests mock BlobClient directly (Azure SDK clients are designed for this) rather than depending on the integration project's real Azurite, since Azurite's own conditional -create failure is always BlobAlreadyExists and cannot exercise the other branch: BlobAlreadyExists still wraps, any other 409 (ContainerBeingDeleted) now propagates with its original ErrorCode intact. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013qgFBkyvmGoAxeA4jfmmY5
… as valid An unbindable OutputTypes value (e.g. OutputTypes=garbage) bound as default(EgressOutputType) — NewLearners — with a ModelState error nobody read, so a malformed request silently proceeded as if NewLearners had been ticked instead of being rejected. Start now also redisplays the Pull page with an output-types error when ModelState marks that field invalid, without mislabelling an unrelated WindowId binding failure (already handled separately) as an output-types error. Test: an unbindable OutputTypes value (simulated via a ModelState error on that key, with a non-empty OutputTypes list standing in for the silently-defaulted value) redisplays Index with an error instead of calling StartAsync. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013qgFBkyvmGoAxeA4jfmmY5
…: unit/integration) Coverage gaps the review named as letting the Blocker and Must-fixes through unnoticed: - EgressControllerTests: PreprocessingRun's three outcomes (Summary/Failed/Home), Preview (success and the 404 when the run has no file for that output yet), Results/Failed/ Complete's status guards (both the allowed and redirected-to-Resume paths), Transfer's Refused branch, Start's WindowNotFound and PullFailed branches. - Rewrote Abandon_marks_the_run_and_returns_home into four facts asserting the actual banner text per EgressAbandonResult branch (swept files named, "nothing was transferred", already-transferred refusal, not-found) instead of only that the mock was called. - DevEgressControllerTests (unit project) covered only the two 404 cases, which pass even with IsAllowed hard-coded false. Added an integration-project positive case (Seed writes committed requests + dev tickets, Cleanup removes them) for the same reason DevOutboxEgressTicketSourceTests lives there: no in-memory-EF pattern exists in the unit project for these EF-LINQ-heavy actions. - EgressRunServiceTests: only a journey blob returning null was tested; added a fact for one that throws, proving the per-record try/catch already treats it the same as not-found rather than failing the whole pull. The "non-409 upload exception" gap is closed by the S7 commit's own new unit tests. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013qgFBkyvmGoAxeA4jfmmY5
… refusal, resume) Rebuilding the web image and walking S5's fix live (per the executing-plans discipline of verifying beyond automated tests) found two real bugs the unit-level view-source pin could not see, both only visible from the rendered page: - Explicitly setting id="OutputTypes-hint" on <govuk-checkboxes-hint> collided with the library's own auto-generated id for the same element — GovUk.Frontend.AspNetCore throws System.ArgumentException: "An attribute with the name 'id' already exists" while generating the hint, which took down the entire Pull page (500) the moment OutputTypes had any checkboxes to render. Removed the explicit id; the library already auto-generates "OutputTypes-hint" (confirmed by the review's own rendered-HTML quote). - The fieldset's aria-describedby also merges with the library's own auto-generated value rather than replacing it, so supplying "OutputTypes-hint OutputTypes-error" produced "OutputTypes-hint OutputTypes-hint OutputTypes-error". Now supplies only "OutputTypes-error" (or empty), which the library correctly prefixes with its own hint id to produce "OutputTypes-hint OutputTypes-error". Verified live via curl (impersonated admin, scraped antiforgery token): Pull renders 200 with and without an error, exactly one "There is a problem" summary, and the fieldset's aria-describedby is exactly "OutputTypes-hint OutputTypes-error" when there is one. S10 E2E additions, run against the rebuilt image in the e2e-tests container: - Abandon releases the pair for a fresh pull (the banner text itself is TempData-cookie driven, which this harness's UseCookies=false no-redirect client cannot observe across a redirect+GET — confirmed live with a real cookie jar instead — so the test asserts the state change, not the banner). - A second pull for the same pair is refused with "already being processed" while the first is still open (only the post-transfer refusal had E2E coverage before). - A saved run is listed on the Pull page and Resume reopens it at the page its status implies. All 8 facts in DataEgressTests pass against the rebuilt image (docker compose --profile e2e run --rm e2e-tests, filtered to this class); dev fixture data cleaned up afterward. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013qgFBkyvmGoAxeA4jfmmY5
…used, Download, Cleanup Six of the review's nits, each small: - CreateRunAsync's catch now matches ConstraintName == ix_egress_run_outputs_active_window_output specifically, not any 23505, so an unrelated unique violation on the same table is never misreported as "another run holds this pair" (the schema test already pins the constraint's name). - Preview builds its table from the saved rows via IEgressTransferService.GetPreviewAsync and EgressColumnSets directly, replacing the CSV-text-splitting SplitCsv helper, which shifted every column after a quoted value containing a comma (a real surname is free text). - EgressTransferResult.Refused now carries the specific EgressOutputType the retry lost (IEgressRunRepository.TryReactivateAsync returns it alongside the blocker), so the controller no longer hard-codes RemoveLearners into Describe() and string-replaces the result — it uses the real type. - TransferAsync for a missing run returns a new EgressTransferResult.NotFound instead of NotTransferable(Abandoned), a guessed status the run was never actually in; the controller returns 404 for it. - Download now returns text/csv; charset=utf-8. - DevEgressController.Cleanup deletes a blob only via the M1 ownership-checked delete (egressRunId metadata matching the specific run being removed), not by file name alone — a plain by-name delete could remove another run's blob if two windows' files happened to collide on name (Q3). Tests: a Preview fact building from saved rows with a comma-containing surname, proving BuildFileAsync (the CSV path) is never called; a Refused fact using NewLearners to prove the real type flows through, not the old RemoveLearners placeholder; a Transfer-of-an- unknown-run fact expecting 404; a Cleanup fact planting an EgressRunOutput row and asserting the delete call carries that row's own RunId. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013qgFBkyvmGoAxeA4jfmmY5
… caption, copy
- Preprocessing GET now redirects a Transferred/Abandoned run to Resume instead of
rendering a live "Run preprocessing" button for a run that is already finished.
- Summary no longer offers Confirm for a Transferring run either (only for
NothingToTransfer before this) — the transfer already in flight is guarded
server-side by the CAS status flip regardless, but the button was misleading.
- Index.cshtml's notification banner is role="region" (GOV.UK's default), not
role="alert" — every banner this feature shows (abandoned, already transferred,
refused) is neutral, and "alert" is reserved for a success banner this feature
doesn't have.
- The saved-runs list gives PreprocessingFailed and TransferFailed the red tag instead
of the same blue tag every other stage gets.
- _RunHeader's caption is govuk-caption-xl, matched to the govuk-heading-xl every page
using it has (was govuk-caption-l).
- WindowId's aria-describedby no longer carries a trailing space when there is no error.
- Results.cshtml's explanatory paragraph is rewritten from developer-note phrasing
("discarded by the preprocessing filter, not here") into plain user-facing language.
FLAGGED copy — recorded in the PR notes.
Verified live against the rebuilt image: Preview renders from the saved rows correctly,
Summary shows the caption at xl and the Confirm button under normal conditions, Index
renders role="region" and both tag colours. Full DataEgressTests E2E class (8 facts)
still passes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013qgFBkyvmGoAxeA4jfmmY5
…n both files (AB#294553) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…r_0-2 for 16-19 per LDS spec v2.4 (AB#294553) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…553) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…4 column set (AB#294553) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…s (AB#294553) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
LDS spec v2.4 alignment pushed — see docs/data-egress.md §6 and the Q7–Q14 questions for LDS/BA |
Resolves the RequestService.SubmitResultsEnquiry conflict with PR #442 (AB#301974): keep main's restructured initializer and re-apply this branch's OrganisationLaestab stamping (AB#294553). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ehind Every deploy of PR #441's review app after the first timed out waiting for the rollout, and a re-run reported "No changes" while the health check passed against the previous pod. The new pod was crashing before it listened: the dev seeder wipes CheckingWindows at start-up, egress_runs references it with a RESTRICT key, and rows from an earlier E2E run were still there because the dev cleanup had thrown out of its blob sweep. The sweep failed because appsettings.json shipped a local-Azurite EgressStorage default, so an environment with no egress account believed it had one at 127.0.0.1 inside the pod. - SeedCheckingWindows deletes EgressRuns before wiping the windows (outputs and learner rows cascade from the run). - DevEgressController.Cleanup treats the blob sweep as best effort, reports failures as blobErrors, and always deletes its database rows. - appsettings.json no longer carries an EgressStorage default; both launch profiles supply it for local dotnet run, docker-compose already does for the container stack. Pinned by CheckingWindowSeedWithEgressHistoryTests (real Postgres), DevEgressControllerTests.Cleanup_still_removes_the_runs_when_the_blob_sweep_fails and AppSettingsEgressStorageTests. Docs §8/§9 and PR notes §11 updated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The two DataEgressTests failures on the PR review app were both environment, not code. The tests seed their decisions into the DevZendeskTickets outbox via /dev/egress/seed, but review.yml never set Zendesk__UseFake, so AddCpdEgress selected the real ZendeskEgressTicketSource against esfa-preprod, where the invented ticket ids do not exist. Every seeded row rendered as "Ticket not found": the first fact could not find "Auto approved", and the second sailed to /summary because nothing was approved for preprocessing to reject. - review.yml: opt in to the dev outbox (Zendesk__UseFake: true), the same opt-in docker-compose.yaml makes. Refused in Production by AddCpdEgress. - application.tf: give review apps ConnectionStrings__EgressStorage (the per-PR "lds" account) so the transfer step of the first fact runs end to end; other environments stay unconfigured until the real LDS account is known. - docs/data-egress.md: config table and gaps list updated to match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
81d0951 bumped the E2E project to ImageSharp 4.1.2, whose build target (SixLabors_ValidateLicense) fails the build with "No Six Labors license found" unless a licence key is present. The task is ContinueOnError in Debug, so a Visual Studio build only warned, but CI and make test-e2e-fast build Release, where it is a hard error — the review-app E2E job never got past dotnet build. 3.1.12 is the last line without the licence gate and is what the suite used until this morning; the other package bumps in that commit are unaffected. Verified in Release on the local stack: Unit 5607/5607, Integration 870/870, E2E 199 passed / 0 failed / 47 skipped (host-only skips). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
81d0951 committed the 34 reviewer screenshots that every full E2E run rewrites into the bind-mounted Snapshots directory. They are not part of the egress change and only inflate the PR diff. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… is unconfigured Two of the three Should items from the 18 Sep review. - LdsSpecValidator: Surname and Forename — the only free-text cells, typed by the school in the Add journey — must not begin with = + - or @. The file leaves for LDS and may be opened in a spreadsheet, where such a cell is evaluated as a formula (OWASP CSV injection). Refused with a named reason rather than prefixed with an apostrophe: LDS expects raw values and no real name begins with one of these. A hyphen inside a name still passes. - Pull page and Summary carry a govuk-warning-text when ConnectionStrings:EgressStorage is absent, so ops learn on the first screen rather than after Confirm. Pull, preprocess, preview and download are not blocked — they are still useful without a target account. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Response to the 18 Sep reviewFixed in 1. CSV formula injection — fixed, differently from the suggestion. 2. Abandon-vs-transfer race — consciously accepted, and the mechanism is not quite as described. 3. Early config gating — fixed. The Pull page and the Summary show a 4. Sex mapping — verified, no change. All three 5. Batch semantics — kept as is. A Zendesk failure (after the Polly retries) throws 6. Header-only file for one empty output — open question for LDS. Already in the documented gaps ( 7. Diff hygiene — half done. The 34 Verified on |
Summary
/admin/egress) where a CYPMD ops user pulls the scrutiny decisionsfor one checking window's Add and Remove amendment requests, preprocesses the approved ones into
LDS-spec CSV files, persists them, and transfers them to the LDS storage account.
Runs are persisted at pull time (so "save" is just leaving the page) with a database-enforced
one-active-run-per-window-and-output-type rule, an eight-step server-reported preprocessing
pipeline that is all-or-nothing, and a create-only, compensating, audited transfer.
docs/data-egress.mdand the PR notes for every other FLAGGED item.Test plan
dotnet ef migrations has-pending-model-changes: nonestreamed and no-JS paths, and Abandon from that page), Summary, Complete, the "already being
processed" and "already transferred" refusals, resume-from-list, and — in the closing pass —
the Abandon-ordering fix in both directions (owned vs. foreign-stamped blob) and the
PreprocessingFailedredirect, against a real Azurite account with the image rebuilt fromthis branch's final tip
git status --porcelainclean after discarding rewritten E2E snapshotsAn independent Opus review (
.superpowers/plans/2026-09-14-294553-opus-review.md) found oneBlocker (B1: the web host defaulted to the dev Zendesk fake) and four Must-fixes in the transfer/
lock state machine (M1-M4), plus ten Should-fixes, a handful of nits, and six questions for BA/LDS.
All Blocker/Must-fix/Should-fix findings and the named nits are fixed in 17 follow-up commits on
this branch (
770eeea3..8565cb49). A second-pass review of those fixes found one furtherMust-fix (R1:
AbandonAsyncswept a run's blobs before writing itsAbandonedstatus, which couldlet a concurrent transfer's just-uploaded files be deleted, or — the more serious direction — let
the run end up
Transferredwith aSucceededaudit row and no files actually in LDS; fixed bywriting the status first and sweeping only once that write has landed) plus seven optional nits
(one of which, the
PreprocessingFailedrefusal message, is user-visible — it no longer leaks theraw C# status name, e.g. "This run cannot be preprocessed because its stage is Preprocessing
failed." instead of "...is PreprocessingFailed..."). A final whole-branch review of that closing
work found no blocking issues and two small follow-ups, both logged in PR notes §7 item 9 rather
than fixed here (a narrower crash-window orphan-blob case the R1 fix's own trade-off introduces,
and a pre-existing ownership-check asymmetry in the transfer's own failure-compensation path) —
see PR notes §9 for the full finding-by-finding record across all three review passes. The
PreprocessingStreamstate-mutating-GET pattern was left as-is (an accepted precedent elsewhere inthis codebase) but is worth calling out in review: the JS closes the
EventSourceon a terminal/error event, so the browser's automatic reconnect never restarts the server-side pipeline.
Closes #244