Lab — Replay Protection and Bounded-Use Authority
Learning objective: Reproduce a check-then-act replay race, repair it with atomic bounded-use consumption, prove the concurrency invariant with executable tests, and explain which guarantees remain outside the replay store.
Difficulty: Intermediate
Prerequisites: Complete Replay Protection and Bounded-Use Authority, run the Replay Protection and Bounded-Use Authority sample, and be comfortable with the execution boundary from Scoped Capability and Host-Owned Execution.
This lab begins with the sample's intentionally unsafe implementation so you can observe the failure before repairing it.
The invariant is not “the token looked valid.” The invariant is “only one consumer claimed the final permitted use.”
Starting Architecture
The companion sample separates static validation from stateful consumption:
Capability presented
↓
Static validation
↓
Atomic TryConsume
↓
Accepted or rejected
↓
Protected executor or stop
For the one-time case:
MaximumUses = 1
↓
Two concurrent consumers
↓
Exactly one accepted
Exactly one rejected
↓
Protected execution count = 1
The sample also includes:
DeliberatelyUnsafeCheckThenActCapabilityUseStore
which forces two callers to observe the same pre-consumption state before either writes it.
That type exists only for this learning exercise.
Prepare the Lab
Work on a temporary branch or disposable copy of the repository.
git switch -c lab/replay-protection-concurrency
Run the sample and focused tests before making changes:
dotnet run --project samples/replay-protection-and-bounded-use/ReplayProtectionAndBoundedUse/ReplayProtectionAndBoundedUse.csproj
dotnet test samples/replay-protection-and-bounded-use/ReplayProtectionAndBoundedUse.Tests/ReplayProtectionAndBoundedUse.Tests.csproj
Locate these types in ReplayProtection.cs:
ExecutionCapabilityExecutionCapabilityValidatorICapabilityUseStoreAtomicInMemoryCapabilityUseStoreDeliberatelyUnsafeCheckThenActCapabilityUseStoreProtectedOperationGatewayInMemoryReplayEvidenceSink
Locate these tests in ReplayProtectionBoundaryTests.cs:
TwoConcurrentConsumersOfOneTimeCapabilityExactlyOneExecutes
DeliberatelyUnsafeCheckThenActAllowsBothConcurrentConsumers
ExecutorFailureAfterConsumptionDoesNotRestoreAuthority
Before changing code, explain which test represents the intended invariant and which test intentionally demonstrates a broken design.
Part 1 — Observe the Check-Then-Act Failure
Start with DeliberatelyUnsafeCheckThenActCapabilityUseStore.
Its logic is intentionally equivalent to:
Read use count
↓
If count < MaximumUses, continue
↓
Another consumer may read the same count
↓
Write count + 1
Run:
dotnet test samples/replay-protection-and-bounded-use/ReplayProtectionAndBoundedUse.Tests/ReplayProtectionAndBoundedUse.Tests.csproj --filter DeliberatelyUnsafeCheckThenActAllowsBothConcurrentConsumers
The test should pass because it is asserting the known teaching failure:
MaximumUses = 1
Concurrent consumers = 2
Accepted consumptions = 2
Protected executions = 2
It may also show a lost update:
Observed store count = 1
Actual accepted executions = 2
Explain the Race
Answer:
- Which value did both consumers observe before either wrote state?
- Why does a thread-safe dictionary not make the compound check-and-write atomic?
- Why would separate
HasBeenUsedandMarkUsedmethods have the same architectural problem? - Which state transition must become indivisible?
Write the transition in one sentence before continuing.
A good answer has the shape:
Check whether another use remains and claim that use as one atomic operation.
Part 2 — Repair the Consumption Boundary
Create a temporary learner implementation named something like:
LearnerAtomicCapabilityUseStore
Do not change the gateway contract.
Keep the semantic boundary:
ValueTask<CapabilityUseResult> TryConsumeAsync(
string capabilityId,
int maximumUses,
DateTimeOffset usedUtc,
CancellationToken cancellationToken);
Choose one in-process atomic technique, for example:
- A per-capability
SemaphoreSlim. - A lock around the full check-and-increment transition.
- A compare-and-update loop whose semantics you can explain.
The exercise is not to reproduce one exact implementation.
The exercise is to preserve this invariant:
Current use count
+
MaximumUses
↓
One atomic decision
↓
Accepted with new count
or
Use limit exceeded
Wire your learner store into a copy of the concurrent test.
Your repaired test must prove:
MaximumUses = 1
↓
Two concurrent consumers
↓
Accepted count = 1
Rejected count = 1
Executor invocation count = 1
After your test passes, compare your approach with AtomicInMemoryCapabilityUseStore.
Review the Atomicity Scope
State precisely what you proved.
For an in-memory per-process gate, the strongest justified claim is:
Competing consumers using this store instance cannot both claim the same final permitted use.
Do not write:
The capability can never be replayed anywhere.
That broader claim requires deployment and persistence evidence you have not yet implemented.
Part 3 — Race for the Final Use of a Bounded Grant
Change the scenario to:
MaximumUses = 2
First consume one use sequentially.
Then start two concurrent attempts for the one remaining use:
Initial count = 1
MaximumUses = 2
↓
Two concurrent consumers
↓
One claims use 2
One is rejected
↓
Total protected executions = 2
Add a focused test for this case.
The purpose is to show that one-time authority is not a separate mechanism.
It is the smallest bounded-use case.
Optional Stress Extension
Start a larger number of concurrent consumers against:
MaximumUses = 3
Assert only three reach protected execution.
Do not interpret a local stress test as proof of distributed correctness.
Use it only to exercise the implementation more aggressively inside the process.
Part 4 — Keep Static Validation Separate from Consumption
Use an expired capability or change one binding such as:
ResourceId
Audience
SubjectId
OperationName
The expected path is:
Static validation rejects
↓
TryConsume is not granted a use
↓
Observed use count remains 0
↓
Executor invocation count = 0
Add or extend a test that proves both no consumption and no execution.
Explain Why Order Matters
Suppose the host consumed authority before checking whether the capability targeted the correct resource.
A malformed or attacker-controlled request could spend legitimate authority without ever being eligible to execute.
That may be a denial-of-service path even if the protected side effect remains blocked.
The exact validation sequence can vary by host, but invalid authority should not casually consume a legitimate remaining use.
Part 5 — Preserve Evidence for the Rejected Replay
Inspect InMemoryReplayEvidenceSink.
For a replay that loses the race, preserve evidence with the shape:
Stage = capability-consumption
Outcome = rejected
ReasonCode = capability.use-limit-exceeded
CapabilityId = stable safe identifier
ObservedUseCount = 1
MaximumUses = 1
ExecutionAttempted = false
Add an assertion for every field you think is necessary to distinguish:
Replay rejected before execution
from:
Execution attempted and failed
Do not add the entire raw authority artifact merely to make debugging easier.
Correlation Extension
Optionally add a separate CorrelationId to the evidence.
Keep it distinct from CapabilityId and explain why:
CorrelationId
≠
CapabilityId
A single broader operation may issue more than one capability, and retries may share one correlation flow.
Part 6 — Exercise Cancellation and Store Failure
The teaching atomic store accepts a cancellation token while waiting for its per-capability gate.
Run or extend:
CancellationBeforeConsumptionDoesNotSpendAuthority
Verify:
Cancellation observed before transition
↓
Use count remains 0
Then inspect:
UnavailableUseStoreDoesNotBecomePermission
The sample maps a known store-unavailable failure to:
capability.use-store-unavailable
with:
Executor invocation count = 0
Choose a Production Failure Posture
For one replay-sensitive mutation, choose one:
Deny
Defer
Queue
Escalate
Reduced-capability mode
Explain why your choice is safer than:
Store unavailable
↓
Assume unused
↓
Execute
Then answer:
- Can your production
TryConsumeAsynctime out after the state transition commits? - If yes, how would a retry determine whether the original consume committed?
- Does the store support a stable operation identity for retrying an ambiguous consume?
- Which failure result is safe to expose to the caller without inventing certainty?
Part 7 — Observe the Consume-Before-Execute Failure Window
Run:
ExecutorFailureAfterConsumptionDoesNotRestoreAuthority
The test demonstrates:
TryConsume accepted
↓
Use count = 1
↓
Executor invoked
↓
Executor throws
↓
Replay of same capability rejected
Now answer the most important question in this lab:
Did the external side effect happen exactly once?
The correct answer is:
The replay layer cannot establish that from consumption state alone.
The executor may have:
- Failed before changing anything.
- Changed local state and then thrown.
- Sent a remote request that succeeded before the response was lost.
- Started a long-running operation whose final state is unknown.
Do not automatically refund a use after an execution exception. Doing so can reopen authority after a side effect that may actually have occurred.
Design Recovery State
Sketch a separate operation-state model such as:
Authority consumed
Execution not started
Execution started
Execution outcome unknown
Execution completed
Execution failed
Recovery required
Explain which component owns that state.
Part 8 — Separate Replay Protection from Idempotency
Create two distinct identifiers on paper or in code:
CapabilityId = cap-123
OperationId = op-900
Now consider two different one-time capabilities:
cap-123 → op-900
cap-456 → op-900
Each capability can be consumed exactly once.
The logical operation can still be attempted twice.
Answer:
- Which identifier controls reuse of authority?
- Which identifier could support request/operation idempotency?
- If the downstream provider accepts an idempotency key, which identity should be stable across an ambiguous retry?
- Why does replay protection not eliminate the need for downstream idempotency or reconciliation?
Your final explanation should distinguish:
Capability replay protection
Request / command idempotency
Downstream operation idempotency
Exactly-once claims
Part 9 — Design a Durable TryConsumeAsync
Do not implement a full production database unless you want the extension.
Instead, use Data Access Boundaries and Transaction Reasoning and design a durable store contract whose persistence operation preserves TryConsumeAsync semantics.
Choose one strategy:
One-Time Unique Row
Insert use row keyed by CapabilityId
↓
Unique constraint wins once
↓
Competing insert rejected
Conditional Counter Update
UPDATE capability_use
SET use_count = use_count + 1
WHERE capability_id = @id
AND use_count < maximum_uses
Then interpret affected-row count as the consume result.
Serializable / Locked Transaction
Begin transaction
↓
Read current use state under appropriate lock/isolation
↓
Check + increment
↓
Commit
For your chosen design, document:
- Atomicity mechanism.
- Unique key or concurrency token.
- Timeout behavior.
- Retry behavior.
- Persistence after process restart.
- Multi-instance coordination.
- Retention window.
- Regional consistency scope.
The persistence abstraction should expose the semantic operation TryConsumeAsync, not merely generic CRUD methods that force every caller to reconstruct the race-prone sequence.
Part 10 — Test the Boundary You Actually Claim
Your final local test suite should include at least:
Valid unused capability
↓
Execution count = 1
Second sequential use
↓
Rejected
↓
Execution count remains 1
Two concurrent consumers of final use
↓
One accepted
One rejected
↓
Execution count increases by only 1
Expired or mismatched capability
↓
No consumption
↓
Execution count = 0
Replay store unavailable
↓
No fallback execution
If you implement a durable provider, add tests that match the stronger guarantee you now claim:
Process restart
Multi-instance race
Ambiguous timeout / retry
A process-local unit test cannot prove those distributed properties.
Final Validation
Run the complete sample suite:
dotnet test samples/Samples.slnx
Confirm that you can explain all of these statements without treating them as synonyms:
The capability is statically valid.
The capability still has a permitted use.
This consumer atomically claimed that use.
The host invoked the protected executor.
The protected executor reported success or failure.
The downstream side effect may require its own idempotency/recovery semantics.
The final architecture should preserve:
Static validation
↓
Atomic capability consumption
↓
Host-owned execution
↓
Separate operation/recovery semantics where required
not:
Token valid
↓
Check unused
↓
Execute
↓
Mark used
Completion Criteria
You have completed the lab when you can:
- Reproduce the check-then-act race deliberately.
- Explain why individually thread-safe reads and writes do not make the compound transition atomic.
- Implement or defend an atomic
TryConsumeAsyncboundary. - Prove that two concurrent consumers cannot both claim one final use inside the tested consistency scope.
- Demonstrate one-time and bounded-use variants.
- Reject expired or mismatched authority without spending a use.
- Preserve useful evidence for a rejected replay without logging raw authority.
- Explain cancellation and replay-store failure behavior.
- Explain what happens when execution fails after consumption.
- Distinguish replay protection from request and downstream idempotency.
- State why the sample's in-memory store does not survive restart or coordinate multiple processes.
- Describe what a durable provider must add before stronger production claims are justified.
- Avoid claiming exactly-once external execution from successful capability consumption.
Resetting the Sample
If you created a temporary branch only for the exercise, inspect your changes before discarding them:
git status
git diff
To restore the canonical sample:
git restore samples/replay-protection-and-bounded-use
Use git status first so you understand which local work will be affected.
Related Content
- Replay Protection and Bounded-Use Authority — canonical explanation of replay state, atomic consumption, failure windows, idempotency, and distributed scope.
- Replay Protection and Bounded-Use Authority sample — runnable safe and deliberately unsafe implementations used by this lab.
- Scoped Capability and Host-Owned Execution — review how narrow authority is validated where it becomes action.
- Scoped Capability and Host-Owned Execution lab — revisit the broader capability boundary and its introductory single-use exercise.
- Data Access Boundaries and Transaction Reasoning — bridge
TryConsumeAsyncsemantics into durable transaction and persistence design. - Governed AI Tool Gateway — see bounded authority inside a larger AI-assisted execution boundary.
ICapabilityGrantUseStore— compare the lab contract with the working framework seam.InMemoryCapabilityGrantUseStore— inspect the working local reference provider and its limitations.InMemoryCapabilityGrantUseStoreTests— compare local concurrency invariant coverage.
Read it. Run it. Question it. Improve it.