Skip to content

AB#294553 Data egress to LDS - #441

Open
ajs-uk-dev wants to merge 53 commits into
mainfrom
feature/294553-admin_implementation
Open

ajs-uk-dev wants to merge 53 commits into
mainfrom
feature/294553-admin_implementation

Conversation

@ajs-uk-dev

@ajs-uk-dev ajs-uk-dev commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds a new admin section (/admin/egress) where a CYPMD ops user pulls the scrutiny decisions
    for 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.
  • Zendesk supplies only the per-ticket decision; every record value comes from CYPMD's own data.
    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.
  • New learners' column set is FLAGGED pending the LDS spec v2.4 workbook — see
    docs/data-egress.md and the PR notes for every other FLAGGED item.

Test plan

  • Unit: 5,415 passed, 0 failed
  • Integration (Testcontainers Postgres + Azurite): 838 passed, 0 failed
  • E2E (full suite, in container): 218 passed, 0 failed, 2 skipped (pre-existing, unrelated)
  • dotnet ef migrations has-pending-model-changes: none
  • Walked the live app: Pull (including the S5 error markup), Results, Preprocessing (both the
    streamed 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
    PreprocessingFailed redirect, against a real Azurite account with the image rebuilt from
    this branch's final tip
  • git status --porcelain clean after discarding rewritten E2E snapshots

An independent Opus review (.superpowers/plans/2026-09-14-294553-opus-review.md) found one
Blocker (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 further
Must-fix (R1: AbandonAsync swept a run's blobs before writing its Abandoned status, which could
let a concurrent transfer's just-uploaded files be deleted, or — the more serious direction — let
the run end up Transferred with a Succeeded audit row and no files actually in LDS; fixed by
writing the status first and sweeping only once that write has landed) plus seven optional nits
(one of which, the PreprocessingFailed refusal message, is user-visible — it no longer leaks the
raw 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
PreprocessingStream state-mutating-GET pattern was left as-is (an accepted precedent elsewhere in
this codebase) but is worth calling out in review: the JS closes the EventSource on a terminal/
error event, so the browser's automatic reconnect never restarts the server-side pipeline.

Closes #244

Andy Smith and others added 30 commits September 14, 2026 16:52
…r (AB#294553)

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
Andy Smith and others added 5 commits September 16, 2026 11:34
…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>
@ajs-uk-dev

Copy link
Copy Markdown
Contributor Author

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>
Andy Smith and others added 3 commits September 18, 2026 16:40
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>
@ajs-uk-dev

ajs-uk-dev commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

Response to the 18 Sep review

Fixed in 6503e28a, restored diff in e812ee00, main merged in 4a101cb4.

1. CSV formula injection — fixed, differently from the suggestion. LdsSpecValidator now refuses a Surname or Forename beginning with =, +, - or @ (the only free-text cells; typed by the school in the Add journey) with the reason "must not begin with =, +, - or @". A hyphen inside a name still passes. Rejecting at preprocessing keeps the file spec-pure — LDS expects raw values and no real name begins with one of these — and the ops user sees which record to look at, instead of an apostrophe being silently written into a name. Pinned by three new LdsSpecValidatorTests facts; docs/data-egress.md step 7 updated.

2. Abandon-vs-transfer race — consciously accepted, and the mechanism is not quite as described. AbandonAsync is a compare-and-set (Status != Transferred → Abandoned), and the service sweeps blobs only after that write reports a row changed (R1). If Abandon wins mid-upload, TransferAsync's final MarkTransferredAsync(expected: Transferring) returns 0 rows, it compensates its own uploads, and the run ends Abandoned, no files, no Succeeded audit — a consistent end state, covered by EgressTransferServiceTests. The only exposure is a verified file being visible for the few seconds before it is swept, and the existing failed-transfer compensation path has exactly the same window. Refusing Abandon on Transferring would bring back the stuck-run problem M1 exists for (the pair lock has no expiry; a pod restart mid-upload would need a DBA). An age-based guard ("refuse Abandon unless Transferring for more than N minutes") is the narrowing I would take if wanted — it needs a TransferringAtUtc column and a migration, so it is a follow-up rather than a pre-merge change.

3. Early config gating — fixed. The Pull page and the Summary show a govuk-warning-text (egress-storage-not-configured) when ConnectionStrings:EgressStorage is absent. Pull, preprocess, preview and download are deliberately not blocked — they are useful without a target account; only Transfer refuses, as before. Pinned in EgressControllerTests and EgressViewSourceTests.

4. Sex mapping — verified, no change. All three Add_*.json flows store the radio option value (F/M/U), not the label; the journey answer that reaches EgressRecordBuilder is that value, and the dev seeder mirrors it. Female cannot arrive.

5. Batch semantics — kept as is. A Zendesk failure (after the Polly retries) throws EgressTicketSourceException and the pull is refused before any run is created (EgressStartResult.PullFailed); a single missing ticket is not a failure — that record reads as "Ticket not found" and is discarded at the approval filter. Partial-with-NotFound on a transient blip would let an operator transfer a set that silently lacks approved records, which is worse than a one-click retry of the pull.

6. Header-only file for one empty output — open question for LDS. Already in the documented gaps (docs/data-egress.md §10). Today the operator can untick the empty output type before pulling; skipping empty outputs at transfer is a small change if LDS says header-only files are unwelcome.

7. Diff hygiene — half done. The 34 search-ux PNGs are gone from the diff (e812ee00) — they were reviewer screenshots that every full E2E run regenerates, not an intended change. I have not rebased: the branch carries five merges from main and the repo allows squash merge, so squashing at merge time gives the same single-commit view without rewriting shared history.

Verified on 4a101cb4: .slnx Release build 0 errors / 117 warnings (unchanged), unit 5,619/0, integration 870/0, no pending EF model changes. Review-app E2E is running now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Data Egress - Admin Implementation AB#294553

2 participants