Skip to content

fix(web): correct idempotent replay payload and serialize concurrent duplicates - #1378

Open
marcelo-maciel wants to merge 24 commits into
fullstackhero:mainfrom
marcelo-maciel:fix/idempotency-correctness
Open

marcelo-maciel wants to merge 24 commits into
fullstackhero:mainfrom
marcelo-maciel:fix/idempotency-correctness

Conversation

@marcelo-maciel

@marcelo-maciel marcelo-maciel commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Carries two infrastructure fixes that are not this PR's topic. Without them CI cannot even reach this PR's code.

  • Dependency bump: Microsoft.SourceLink.GitHub to 10.0.401 and Testcontainers to 4.14.0, clearing NU1902/NU1903 so restore succeeds. Those are the versions #1375 (SourceLink) and #1369 (Testcontainers) carry: this hunk is the union of the two, plus one comment per pin naming the advisory it answers.
  • MinIO (c37820bc): minio/minio is gone from Docker Hub, so every Testcontainers-backed integration test dies on the image pull. Pulls from quay.io on a pinned tag instead. Same fix as #1388.

The MinIO hunk is byte-identical to #1388. The dependency hunk is not byte-identical to #1375 or #1369, which each carry half of it without the comments, but it is identical across all twelve PRs in this series: src/Directory.Packages.props resolves to the same blob (854deb95) at every head. Either way they merge in any order, and these copies can be dropped once the PRs that own them land.

Reopened from #1333. That PR was closed automatically on 2026-09-14, when the head fork
was deleted. It reopened at 7c2dcbaf, and review has added commits on top since then (the
commit list above is the current one). The earlier review history stays on #1333.


Fixes the idempotency filter. It started as three defects (audit findings API-01 and CONC-01, plus a replay path that never engaged) and grew after review: re-reading the whole filter rather than the delta surfaced the rest, listed at the bottom.

API-03 (root): replay never engaged, even in production

The write went through HybridCache.SetAsync while the probe read IDistributedCache by the raw key. HybridCache keys its L2 entries under its own scheme, so the raw-key probe never found the entry and idempotency replay silently never engaged, not just in tests. The repo's ChatSendMessageTests replay test was skipped with this exact symptom attributed to a "test-env cache split"; un-skipping it proves it was the filter.

Fix: write to the same IDistributedCache, key and serializer the probe uses. Idempotency entries are short-lived (TTL) and their HybridCache tag-purge path was unused, so dropping HybridCache here loses nothing.

API-01: replay served the wrong shape and status

The filter cached SerializeToUtf8Bytes(result) where result is the wrapped IResult (Ok<T>/Created<T>), storing {"value":{...},"statusCode":200} instead of the wire DTO, and read Response.StatusCode before the IResult executed, so a 201 replayed as 200. The handler result is now executed into a buffer to capture the real wire body and status.

CONC-01: concurrent duplicates both executed

probe -> execute -> write had no atomic reservation, so two concurrent same-key requests both ran the handler. An atomic in-flight reservation now serializes duplicates: Redis SET NX when an IConnectionMultiplexer is registered (the multi-instance case, where this stack already requires Redis for the shared Data Protection key ring), an in-process set otherwise (single instance). A duplicate in flight gets 409 Conflict. Redis stays optional.

What review added

  • The response is stored before the body reaches the client and on CancellationToken.None, so the timeout-then-retry that idempotency exists to absorb replays instead of re-executing.
  • Replay carries the allow-listed headers Location and ETag, set while the IResult executes and previously dropped.
  • Only 2xx is stored. A failure is not a record of a committed side effect, and caching it locked the caller out of that key for the full TTL.
  • The key is scoped to the resolved tenant, the caller, the HTTP method, the route pattern and the resolved route values. Tenant alone let one key replay another endpoint's response, and a root operator acting on two tenants shared one bucket.
  • The reservation is sound under the races it exists for: re-probe once the lock is held, lock: prefix instead of a suffix on the entry key, compare-and-delete release against the reservation's own token, and the in-process fallback expires on ReservationTtl.
  • The handler runs with HttpContext.RequestAborted detached. A client hanging up after the side effect committed used to cancel the handler's next await, leaving nothing to store. The trade-off, that a disconnect no longer aborts an idempotent handler, is called out in the review thread and in the docs.
  • Probe, reservation and store all fail open; a handler that writes to Response itself is passed through; the 409 carries Retry-After: 1; IdempotencyOptions is validated on start.

Tests

IdempotencyEndpointFilterReplayTests (38 cases) covers each branch: replay preserves 201 and the plain DTO body, allow-listed headers only, concurrent duplicates execute once with the loser getting 409, probe and store failures fall through to execution, reservation release under a thrown handler. OptionsDefaultsTests pins the startup validation. ChatSendMessageTests.SendMessage_Should_Replay_Same_Response_When_Idempotency_Key_Reused is un-skipped and passes — against MemoryDistributedCache, not Redis. FshWebApplicationFactory sets CachingOptions:Redis to an empty string and registers no Redis container, so the integration harness never touches one; an earlier version of this line said "against real Redis" and was wrong. The Redis branches are covered at the unit level instead, including the reservation refusal (Conflict_Should_BeRefusedByRedis_When_ADuplicateIsStillInFlight, added after review: every other concurrency test ran in-process, so flipping the reservation to When.Always used to leave the suite green). Every fix was mutation-tested: 22 mutations, each reverting one fix, each required to turn its pinning test red, 22 of 22 red.

Docs

fullstackhero/docs#240, including the changelog entry and the documented ceilings (buffered response has no size cap, so not for streaming endpoints; no lease renewal; cross-instance dedup depends on the cache being on Valkey).

Unrelated: the dependency bumps, so CI can run at all

The Testcontainers bump to 4.14.0 and the SourceLink bump are not part of this fix: without them dotnet restore src/FSH.Starter.slnx fails under TreatWarningsAsErrors on main and on every open PR. This branch also carried an explicit SSH.NET pin; review showed it pinned nothing once Testcontainers moved to 4.14.0, and it has been removed (see the note at the end).


Infra carve-outs, corrected after review. Two things in the out-of-topic hunks were wrong and
are fixed on the branch:

  • The MinIO carve-out only moved minio/minio to quay.io. minio/mc is gone from Docker Hub too
    (hub.docker.com/v2/repositories/minio/mc/ answers 404) and it is what minio-init runs, so both
    dotnet run --project src/Host/FSH.Starter.AppHost and docker compose up died on the pull and the
    fsh bucket was never created. Now pinned to the same quay tag #1388 uses.
  • The SSH.NET pin is gone: it pinned nothing. Its own comment claimed bumping Testcontainers
    does not help, but 4.14.0 — which this branch also carries — declares SSH.NET >= 2026.0.0.
    Measured rather than argued: with the pin removed, dotnet restore src/FSH.Starter.slnx --force
    reports zero NU1902/NU1903 and exits 0. (The MessagePack pin next to it stays; removing that one
    does bring its advisory straight back.)

With both applied, deploy/docker/docker-compose.yml and src/Directory.Packages.props are now
genuinely byte-identical to #1388 (git diff --exit-code, checked today), which the earlier claim
was not.


Two things this PR changed without saying so, and a second review round.

Not declared until now: SelfRegisterUserEndpoint lost its .WithIdempotency() (430122aa).
It is AllowAnonymous, and the entry key scopes by caller, which is "anon" for every
unauthenticated request: two people picking the same low-entropy key ("1", "retry") collided, and
the second got the first's response instead of an account. IdempotencyWiringTests now fails the
build if any anonymous endpoint carries the filter, so this cannot come back quietly. b2770010
is a drive-by in TenantThemeTests: the status assertions carry the response body now, so a
failure says what the server answered instead of only which code it was not.

From the second round:

  • A replay could out-live what it replayed. RequestUploadUrl returns a presigned URL good
    for 15 minutes and the entry lived for the 24-hour default, so a retry an hour later got a 200
    carrying a dead URL. Dropping idempotency from that endpoint would have been worse (the handler
    INSERTs a pending FileAsset, so the common case, a retry seconds later, would start
    duplicating rows). WithIdempotency(TimeSpan) lets the endpoint cap its own window, read at map
    time from the same option the handler mints the URL with so the two cannot drift. A non-positive
    TTL throws.
  • The client-abort test never aborted. It slept 20ms on its own token, so deleting
    DetachBoundCancellationTokens left it green. A middleware now publishes a token the test
    cancels mid-handler as RequestAborted, and what gets asserted is the point of the detach: the
    retry replays instead of committing the side effect twice. The first response never reaching the
    client is asserted rather than swallowed, since the store is deliberately sequenced ahead of the
    body write for exactly that case. With the detach reverted both tests in the class go red.
  • A string return was captured as JSON. Minimal APIs write it as text/plain, so the first
    response through the filter differed from the same handler without it, quoted and with a
    different content type, and the replay repeated the difference.
  • The filter comment and .agents/rules/security.md described anonymous endpoints as a live
    case
    of the shared bucket, while the gate above fails the build for exactly that. Both now
    describe the floor the code has rather than a configuration it forbids.

…duplicates

Two defects in IdempotencyEndpointFilter:

API-01 — the filter cached JsonSerializer.SerializeToUtf8Bytes(result) where result
is the wrapped IResult (Ok<T>/Created<T>), so it stored {"value":...,"statusCode":200}
instead of the wire DTO, and it read Response.StatusCode before the IResult executed,
so a 201 Created replayed as 200. The handler result is now executed into a buffer to
capture the real wire body + status, which is what gets served and cached.

CONC-01 — probe->execute->write had no atomic reservation, so two concurrent requests
with the same key both missed the probe and both executed the handler. An atomic in-flight
reservation now serializes duplicates: Redis SET NX when an IConnectionMultiplexer is
registered (the multi-instance case — this stack already requires Redis there for the
shared Data Protection key ring), an in-process set otherwise (single instance). A
duplicate that arrives while the original is still running gets 409 Conflict.

Redis stays optional: without it the app falls back to the in-memory reservation, correct
for a single instance where a cross-container race cannot occur.
…ore)

The write went through HybridCache.SetAsync while the probe read IDistributedCache by
the raw key. HybridCache keys its L2 entries under its own scheme, so the probe never
found the entry and replay silently never engaged — even in production. Proven by
un-skipping ChatSendMessageTests.SendMessage_Should_Replay_Same_Response_When_Idempotency_Key_Reused,
which now passes.

Write to the same IDistributedCache, key and serializer the probe uses. Idempotency
entries are short-lived (TTL) and their HybridCache tag-purge path was unused, so dropping
HybridCache here loses nothing.
Address review on fullstackhero#1333:

- Reservation used the 24h response TTL, so a crash between reserving
  and the finally-release stranded the Redis lock for a day (every retry
  409s). Add IdempotencyOptions.ReservationTtl (default 1m), decoupled
  from DefaultTtl.
- Reserve now fails open on a transient Redis error instead of 500ing
  the request, matching the best-effort stance of the response write.
- Guard the release KeyDeleteAsync so a Redis fault can't throw out of
  the finally.

Tests: reservation uses ReservationTtl not DefaultTtl; a faulting Redis
on reserve/release proceeds without throwing (exercises the Redis NX
branch the prior tests skipped).
Three defects surfaced by re-reading the whole filter rather than the delta.

The response store was tied to the client's connection, so the retry that
idempotency exists to serve re-executed the handler. Two paths caused it, not
one: the body write to the client ran before the store, so a closed socket threw
and skipped it entirely; and the capture itself ran under RequestAborted, where
WriteAsJsonAsync swallows the cancellation and hands back an EMPTY body — which
was then cached and replayed as a 200 for the full 24h TTL. The capture now runs
with the abort token detached (it writes to an in-memory buffer, never the
socket), the store runs before the client write and on CancellationToken.None,
and only then does the body go out.

Replay dropped every response header, so a replayed 201 arrived without
Location: a client that follows the header worked on the first call and broke on
the retry. The captured response now carries an allow-listed set (Location,
ETag) and replays it. Transport and host-owned headers stay out — a stale
Content-Length would corrupt the response.

Non-2xx is no longer stored. Faithful status capture made the pre-existing
behaviour bite: a transient downstream failure locked the caller out of that key
for 24h. A failure is not a record of a committed side effect.

CachedIdempotentResponse is no longer a HybridCache type, so its
[ImmutableObject(true)] contract (and the CachedTypeContractTests entry
asserting it) described a store this filter stopped using. Both dropped. The new
Headers property defaults to empty so entries written before it deserialize.

Tests: replayed 201 carries Location; a first call whose client disconnects
still replays the real DTO body; a non-2xx first response lets the retry run.
All three fail on the previous commit and pass here.
…t alone

The entry was keyed on tenant + caller key, with nothing identifying the
operation. One key reused against a second idempotent endpoint replayed the
first endpoint's response and the second request silently never ran. Thirty-one
endpoints across eight modules share that namespace, and one of them
(self-registration) is anonymous: it resolves no tenant claim, so every caller
of it lands in the same "global" bucket. This was latent only for as long as
replay never engaged — the fix that makes replay work is what would have put it
on the wire. The key now folds in the HTTP method and the route pattern.

Two smaller things in the same area. The 409 for an in-flight duplicate and the
400 for an over-long key emitted a bare JSON string, where every other error
these endpoints produce is RFC 9457 ProblemDetails; both now match. And an
unreadable cache entry (written by another version, or another writer at the
same key) let JsonException escape as a 500 — that path only became reachable
once replay started engaging at all. It now degrades to a miss and logs.

ReleaseReservationAsync also swallows cancellation now, not just faults: it runs
in a finally after the response body has already gone to the client, so anything
thrown there can only reset the connection on a request that succeeded.

Tests: a key reused across two route patterns runs the second handler; an
unreadable entry runs the handler (with a valid entry seeded at the same key
first, so the assertion can't pass as a plain cache miss); a 204 replays without
a fabricated content type. Each fails on a mutated implementation.
The reservation guarded the handler against concurrent duplicates but four
holes let one through anyway, or locked a caller out of a key:

- The entry was keyed on the caller's `tenant` claim. A root operator scoping
  requests to different tenants shares one "root" bucket, so one key reused
  across two targets replays the first tenant's body to the second. Key off the
  resolved tenant context instead — the one BaseDbContext scopes the side effect
  to — with the claim as the fallback for a JWT-only request (Finbuckle's claim
  strategy runs pre-authentication and resolves nothing for those). The raw
  `tenant` header is deliberately not a fallback: an unresolved header is one
  Finbuckle refused, and an unvalidated value has no business in a shared key.

- The cache was probed once, before the reservation. The original request can
  store its response and release the lock inside that window; the duplicate then
  takes the free lock and executes the handler again. Probe once more with the
  lock held.

- The lock was a `:inflight` suffix on the entry key, so a caller key ending in
  that suffix put its 24h entry exactly where another key's lock goes — every
  later request with that key 409s for the full response TTL. Give the lock its
  own prefix.

- Release was an unconditional delete. A request that failed open on a Redis
  blip, or one whose reservation had already expired, freed a lock another
  request was holding. Release via compare-and-delete against the token the
  reservation was taken with; failing open carries no token and deletes nothing.

The in-process fallback also gains the TTL takeover the Redis branch gets for
free: without it a handler that never returns strands the key until the process
restarts and every retry 409s forever.

Each fix is pinned by a test that was verified to fail when the fix is reverted.
Follow-up from adversarial passes over the whole filter. Each item below is
pinned by a test verified to fail when the fix is reverted.

- The handler ran under the client's abort token. A disconnect after the side
  effect committed cancelled the next await inside the handler (an EF read, an
  outbox write, a Mediator behaviour), so the filter had nothing to store and
  the client's retry re-executed the side effect — the duplicate this filter
  exists to absorb. The handler now runs with the token detached; the trade is
  that a disconnect no longer aborts an idempotent handler.

- The probe was the one link that hard-failed. Reserve and store both degrade to
  a warning when the cache is down, so a `RedisConnectionException` on the probe
  took every idempotent endpoint down for exactly the clients that send a key.
  It now fails open as a miss.

- A handler that writes the response itself had already started it, so the buffer
  swap captured nothing and setting the captured status threw. That case now
  passes through untouched and stores nothing.

- The key covered the route pattern but not its values, so `PUT /tickets/1` and
  `PUT /tickets/2` were one operation: the second replayed the first ticket's
  response and never ran. It now folds in the resolved route values.

- The key was not scoped to the caller, so two users of one tenant reusing a
  low-entropy key on the same endpoint received each other's response bodies
  while their own request was silently suppressed.

- The 409 said "retry shortly" with no `Retry-After`. It now sends 1 second: the
  original is normally about to store its response, and the reservation TTL is
  the worst case, not the hint.

Also: options are validated at startup like every other block here (a zero TTL
failed silently inside the best-effort write, so nothing was ever stored),
`CacheKeys.Tags.Idempotency` no longer claims to be applied, and the cached
headers dictionary documents that its comparer does not survive deserialization.
Ceilings that stay: no size cap on the buffered response (do not put
`.WithIdempotency()` on a streaming endpoint), no lease renewal, and a lock whose
Redis may not be the cache's Redis — all three now carry `ponytail:` notes.
A test-quality pass over the suite found assertions that survive the mutation
they exist to catch, and branches with no test at all. Each case below now fails
when the behaviour it pins is reverted.

Assertions that could not fail:
- The Lua release script was matched with Arg.Any<string>() while the fake
  hardcoded compare-and-delete, so swapping the script for an unconditional
  `del` kept the suite green — the exact bug the script's comment warns about.
  The script text is asserted now.
- `(result as IStatusCodeHttpResult)?.StatusCode.ShouldBe(409)` skips the whole
  assertion for a result that isn't one, which is precisely the mutation it
  guards. Cast instead.
- The concurrency test relied on the default one-minute ReservationTtl outliving
  the test; a CI freeze past it hands the key over and fails a correct filter.
  It pins the TTL explicitly.

Branches with no coverage: a handler that throws (the release has to stay in the
finally, or one exception strands the key until the TTL), the tenant-claim
fallback (collapsing it to "global" puts every JWT-only caller in one bucket and
replays across tenants), the refused duplicate's re-probe, the restrictive half
of the header allow-list (Set-Cookie must not come back on a replay), the
best-effort store, a faulting release, the no-header pass-through, the
MaxKeyLength rejection, and an entry stored without the Headers member — the
shape a previous version wrote, which has to keep replaying through a rolling
deploy.

Also drops a stale comment claiming body capture is out of reach; this PR is
what made it possible, and the integration suite asserts it end to end.
…mapping

The idempotency filter scopes its cache key by ClaimsPrincipal.GetUserId(), which
reads ClaimTypes.NameIdentifier only. Until now nothing proved that claim type is
present after JwtBearer validates a real issued token: every existing test built the
principal by hand, so caller scoping could have been inert in production (every
caller collapsing into one bucket) with a green suite.

Round-trips a token from TokenService through JsonWebTokenHandler configured with
JwtBearerOptions' own MapInboundClaims default, then asserts GetUserId() resolves.
Verified with the claim removed from the token as well: the short-form `sub` maps to
it, so both shapes IdentityService emits resolve.
…changes

The rule described the reservation work from the previous round but not what
landed after it, so an agent reading it would still believe the probe hard-fails
and the key ignores route values and the caller. Adds the abort-token detachment
together with the constraint it implies (no streaming endpoints), the HasStarted
pass-through, Retry-After and the startup validation.
…lazily

The startup-validation tests resolved IOptions<IdempotencyOptions>.Value, which
validates on first access with or without .ValidateOnStart(). Deleting that call
left all six green while moving the failure from boot to the first request that
carries an Idempotency-Key — a suite that could not see the difference between
"rejected at startup" and "rejected once, in production, per process".

They now go through IStartupValidator, which is what .ValidateOnStart() registers
and what the host runs before serving traffic. Verified: with .ValidateOnStart()
removed, 6 of the 10 tests fail.
…sage

Asserting only OptionsValidationException let a clause be deleted with every row
still green: a zero DefaultTtl also trips "ReservationTtl must not exceed
DefaultTtl", so the row aimed at DefaultTtl passed on the wrong clause. Each row
now names the failure it expects.

Per-clause mutation run: removing any one of the five clauses fails exactly the
one row that targets it; removing .ValidateOnStart() fails all six cases, since
IStartupValidator is then unregistered.
…ymous endpoints

The cache key scopes by caller, and ResolveCaller returns "anon" for every
unauthenticated request, so on an anonymous endpoint all callers share one bucket.
Two people registering on the same tenant with the same low-entropy key ("1",
"retry") built the identical key: the second replayed the first registrant's 201
with the first registrant's UserId, and their own account was silently never
created. /self-register was the only anonymous idempotent endpoint. It was
unreachable until replay started engaging.

A retry there is already safe without the filter — the unique-email constraint
rejects the duplicate — so the endpoint drops .WithIdempotency() rather than
gaining a body fingerprint.

Deleting one call would leave nothing stopping the next one, so WithIdempotency()
now attaches IdempotentEndpointMetadata: an endpoint filter is invisible in
metadata, and the marker makes the wiring inspectable. IdempotencyWiringTests
walks the endpoint map and fails when an AllowAnonymous() endpoint carries it,
plus a second test that fails if the marker stops being attached, so the first
cannot pass over an empty set.
…ertions

UpdateTheme_Should_NotLeakAcrossTenants_When_RootOperatorTargetsTenantA was seen
returning 401 instead of 204 twice on a loaded machine, then passed four runs in a
row (including two with 14 of 16 cores saturated) and passes in isolation and in
CI. A bare status assertion gives nothing to work with: the reason JwtBearer
rejected the token is in the ProblemDetails body, which the test discarded.

The assertions now report method, URL and body on mismatch. Exercised by
expecting the wrong status on purpose: the failure message carries the body.

This is diagnosis, not a fix. The cause is still unidentified, and this test can
still go red.
The Testcontainers packages pull SSH.NET 2025.1.0 transitively, which carries
GHSA-q939-rpr3-3284 (CVE-2026-48798, high): ScpClient recursive download writes
files outside the target directory. Under TreatWarningsAsErrors that advisory is
NU1903 as an error, so `dotnet restore src/FSH.Starter.slnx` fails for the whole
solution — Backend CI, CodeQL and the template smoke build all die at restore.

Testcontainers 4.11.0 and 4.13.0 both depend on 2025.1.0, so bumping
Testcontainers does not clear it. 2026.0.0 is the first patched release, and
transitive pinning is already enabled, so this entry alone bumps it — same shape
as the MessagePack, Microsoft.OpenApi and SQLitePCLRaw pins next to it.
CodeQL flagged the `Response.HasStarted` pass-through warning (alert 28,
cs/log-forging): it logged `operation`, which folds in `Request.Method`, the
resolved route values and the raw request path, so three caller-controlled
sources reached a log line. Every other log in the filter already passes
`HashKey(...)`, which is why this was the only one.

The warning now logs the route pattern read off the endpoint's `RoutePattern`,
a literal from the route table, which identifies the endpoint just as well.
`operation` is unchanged for the cache key, where the route values have to stay:
`PUT /tickets/1` and `PUT /tickets/2` are different operations.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7c2dcbaf8c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/BuildingBlocks/Web/Idempotency/IdempotencyEndpointFilter.cs
…stAborted

Minimal-API parameter binding resolves a handler's CancellationToken from
HttpContext.RequestAborted before endpoint filters run, so the handler already
holds a copy of the original token. Reassigning the property inside the filter
never reached it: a client disconnect after the side effect committed could
still cancel an await inside the handler, leaving the filter with nothing to
store and letting the retry execute the side effect a second time.

Replace the bound argument as well. RequestAborted keeps being reassigned for
code that reads it directly instead of taking it as a parameter.

The regression test runs a real host over the real filter; a hand-built
EndpointFilterInvocationContext skips binding entirely and cannot see this.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

…advisories

`dotnet restore` fails for the whole solution under `TreatWarningsAsErrors`, on
`main` and on every open PR alike. Advisory-database drift, not a regression from
any change: a commit green on 2026-08-10 is red today with no edits.

- `Testcontainers.PostgreSql` / `.Redis` / `.Minio` 4.11.0 -> 4.14.0 (NU1903,
  GHSA-q939-rpr3-3284). 4.11.0 depends on `SSH.NET` 2025.1.0; 4.14.0 already
  depends on the patched 2026.0.0, so the advisory clears with no transitive pin
  to remember to remove later. Same fix as fullstackhero#1369, so the two do not conflict.
- `Microsoft.SourceLink.GitHub` 8.0.0 -> 10.0.401 (NU1902,
  GHSA-23fw-v26w-5fgq). 8.0.0 drags in `Microsoft.Build.Tasks.Git` 8.0.0 and the
  8.x line has no patched release, so a transitive pin cannot fix it; the package
  itself has to move. 10.0.401 depends on `Microsoft.Build.Tasks.Git` 10.0.401,
  past the patched 10.0.303. Build-time only (`PrivateAssets="all"`), referenced
  only where `IsPackable == true`, which is the CLI alone - and `src/Tools/**` is
  excluded from the template, so the scaffold never sees it.

Verified: `dotnet restore src/FSH.Starter.slnx` exits 0 with no NU19xx, and
`dotnet build src/FSH.Starter.slnx -c Release -warnaserror` reports 0 warnings
and 0 errors.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

MinIO withdrew `minio/minio` from Docker Hub. Docker Hub's API now answers
`object not found` for the repository, and a pull fails with:

    pull access denied for minio/minio, repository does not exist or may
    require 'docker login'

That takes down every Testcontainers-backed integration test (the harness boots
a MinIO container per fixture, so all 724 tests in `Integration.Tests` fail at
container start), the Aspire AppHost, and the Docker Compose deployment. The
image is still published at `quay.io/minio/minio`:

- `Integration.Tests` and `Integration.Middleware.Tests` harnesses
- `AppHost.cs`, via Aspire's `WithImageRegistry` / `WithImageTag`
- `deploy/docker/docker-compose.yml` and the image table in its README

The tag is pinned to `RELEASE.2025-09-07T16-13-09Z` rather than `:latest`. quay
has not moved `:latest` since 2025-09-07, so the two resolve to the same digest
today; pinning only removes the surprise of a silent move later, and keeps the
test harness off a floating tag. Whether to track a newer release, or a different
S3-compatible image, is a separate call.

While in the README's image table: `postgres` and `redis` rows had drifted from
what compose actually ships (`postgres:18-alpine`, `valkey/valkey:9.1.0-alpine`).

Verified: `docker pull minio/minio:latest` fails with the error above;
`docker pull quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z` succeeds
(`sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e`, the
same digest `:latest` resolves to). `dotnet test Integration.Tests -c Release`
passes against the pinned image, and the Aspire manifest renders the container
as `quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z`.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

The MinIO carve-out this branch carries only moved `minio/minio`. `minio/mc` is
gone from Docker Hub as well (`hub.docker.com/v2/repositories/minio/mc/` answers
404), and it is what `minio-init` runs: without it `dotnet run --project
src/Host/FSH.Starter.AppHost` and `docker compose up` both die on the image pull,
and the `fsh` bucket is never created, so the first upload fails with
NoSuchBucket.

Same pinned tag as fullstackhero#1388, which owns the fix, so the copy stays byte-identical
to it and can be dropped once that lands.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

The pin's own comment says "Testcontainers 4.11.0 and 4.13.0 both depend on
2025.1.0, so bumping Testcontainers does not help", but the branch also bumps
Testcontainers to 4.14.0, whose nuspec declares `SSH.NET >= 2026.0.0`. The two
statements cannot both be true, and the bump is the one that is: with the pin
removed, `dotnet restore src/FSH.Starter.slnx --force` reports zero NU1902/NU1903
and exits 0. It was carrying a transitive pin that no longer pins anything.

The MessagePack pin above it stays: that one is still load-bearing (removing it
brings GHSA-hv8m-jj95-wg3x straight back, verified in the same probe).
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

…s one

Every concurrency test ran with `multiplexer: null`, so the branch that actually
refuses a duplicate in a multi-instance deployment — `StringSetAsync(...,
When.NotExists)` coming back false — was never executed. The one test that does
share a keyspace asserts the opposite case (a key that must NOT be blocked).

Verified by mutation: flipping the reservation to `When.Always` turns the new
test red and leaves the rest of the suite green, which is exactly the regression
that used to be invisible.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

`RequestUploadUrl` returns a presigned URL good for `FilesOptions.UploadUrlTtlMinutes`
(15 by default) and cached the response for `DefaultTtl`, 24 hours. A client
retrying with the same key an hour later got a 200 carrying a URL that had
expired 45 minutes earlier, with no way out except inventing a new key. Dropping
`.WithIdempotency()` from the endpoint was the other option and is worse: the
handler INSERTs a pending `FileAsset` and pre-checks quota, so the common case (a
network retry seconds later) would start duplicating rows to fix the rare one.

`WithIdempotency(TimeSpan)` puts the window on the endpoint that knows it.
`IdempotentEndpointMetadata` carries it, the filter prefers it over the
configured default, and the endpoint reads the value at map time from the same
option the handler mints the URL with, so the two cannot drift. A non-positive
TTL throws rather than expiring every entry on write, which would leave the
endpoint advertising an idempotency it no longer has.

Two more from the same review:

- A handler returning a bare `string` was captured through `WriteAsJsonAsync`,
  which quotes it and sends `application/json`. Minimal APIs write `text/plain`,
  so the first response through this filter differed from the same handler's
  response without it, and the replay then repeated the difference.
- The filter comment and `.agents/rules/security.md` both described anonymous
  endpoints as a live case of the shared bucket while `IdempotencyWiringTests`
  fails the build for exactly that. They now describe the floor the code has,
  not a configuration it forbids.
`IdempotentHandler_Should_RunToCompletion_When_ClientAbortIsSignalled` signalled
nothing: the handler slept 20ms on its own token and the assertion passed whether
or not the filter detached anything. Deleting `DetachBoundCancellationTokens` left
it green.

The abort is real now. A middleware ahead of the endpoint publishes a token the
test controls as `RequestAborted` (TestServer cannot hang up a live request from
the client side), and the handler cancels it mid-flight. The first response then
never reaches the client, which is correct and is asserted rather than swallowed:
the store is deliberately sequenced ahead of the body write for this case. What
the test asserts is the point of the detach, that the retry replays instead of
committing the side effect twice.

With the detach reverted, both tests in the class go red.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant