Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
94 commits
Select commit Hold shift + click to select a range
f857437
docs(roadmap): close merged P02d-6 and record next packet
cemililik Oct 10, 2026
3160714
docs(decisions): accept P02d-7 demo and audit contracts
cemililik Oct 10, 2026
82459cb
feat(dev): add the shared seeded demo lifecycle
cemililik Oct 11, 2026
4966dca
fix(dev): close demo lifecycle review findings
cemililik Oct 11, 2026
6395d85
fix(dev): isolate demo profiles and Compose controls
cemililik Oct 11, 2026
eb3f2da
test(dev): prove demo profile refusal is causal
cemililik Oct 11, 2026
69363fe
test(architecture): enforce the P7 demo identity boundary
cemililik Oct 11, 2026
f54c2f0
fix(architecture): close demo identity scan omissions
cemililik Oct 11, 2026
e70e204
fix(architecture): preserve conditional and runtime census semantics
cemililik Oct 11, 2026
69ce613
fix(architecture): refuse unmodeled resolver options
cemililik Oct 11, 2026
d99d10b
feat(dev): enforce seeded browser and Lighthouse demo proofs
cemililik Oct 11, 2026
38c15d9
fix(dev): refuse incomplete or invisible demo proof results
cemililik Oct 11, 2026
39cee01
fix(dev): join audit output and reject hidden product markers
cemililik Oct 11, 2026
e1486de
fix(dev): preserve the total owned shutdown deadline
cemililik Oct 11, 2026
06d46ad
docs(roadmap): close P7 browser proof review rounds
cemililik Oct 11, 2026
bf5bc9c
feat(ci): enforce P7 public demo and reconcile phase exit
cemililik Oct 11, 2026
100b022
fix(ci): complete P7 exit proofs and reserve owned cleanup
cemililik Oct 11, 2026
8605fc9
docs(roadmap): close both P7 phase-exit review rounds
cemililik Oct 11, 2026
36cd6e0
docs: record P02d-7 CI and phase-exit verification
cemililik Oct 11, 2026
e00395a
docs(roadmap): align locale exit rows with verified CI
cemililik Oct 11, 2026
d47ef17
test(architecture): handle optional demo curricula consistently
cemililik Oct 11, 2026
dca3a7b
fix(dev): align demo tool versions and review evidence
cemililik Oct 11, 2026
aebefee
docs(roadmap): record P7 review remediation verification
cemililik Oct 11, 2026
cd6affd
fix(dev): preserve independent demo cleanup and seed launch
cemililik Oct 11, 2026
db6bcdf
test(architecture): close demo source census admission gaps
cemililik Oct 11, 2026
90114f0
test(architecture): refuse ambiguous demo census resolution
cemililik Oct 11, 2026
c6cd69f
test(web): inspect every inherited demo census config candidate
cemililik Oct 11, 2026
9a0d285
test(dev): verify demo reconciliation and align delivery guidance
cemililik Oct 11, 2026
e3a32d2
docs(roadmap): close systematic P7 remediation review rounds
cemililik Oct 11, 2026
4541d63
docs(roadmap): clarify proof helper closeout wording
cemililik Oct 11, 2026
d16d2d7
test(architecture): admit ignored local machine artifacts
cemililik Oct 11, 2026
9343e99
test(architecture): refuse ignored MSBuild user hooks
cemililik Oct 11, 2026
ec86884
docs(roadmap): close ignored-machine-file review follow-up
cemililik Oct 11, 2026
32a7951
test(cache): coordinate the noncooperative factory timeout proof
cemililik Oct 11, 2026
8d9d219
docs(roadmap): record cache timeout proof reviews
cemililik Oct 11, 2026
3e86041
docs(roadmap): close Phase 02d after PR 28 merge
cemililik Oct 11, 2026
6baeffb
docs(roadmap): complete P02b-0 entry verification
cemililik Oct 12, 2026
fffbd4d
docs(decisions): propose P02b-1 event producer contracts
cemililik Oct 12, 2026
907ca35
docs(decisions): clarify P02b-1 producer decision boundaries
cemililik Oct 12, 2026
b157650
docs(decisions): accept P02b-1 producer contracts
cemililik Oct 12, 2026
cbab31a
feat(kernel): share producer and audit execution lifecycle
cemililik Oct 12, 2026
ab2c37d
fix(kernel): fence producer ownership and prove all ancestors
cemililik Oct 12, 2026
3fd8e8a
docs(events): close shared lifecycle review rounds
cemililik Oct 12, 2026
7541b73
docs(events): align producer packet review status
cemililik Oct 12, 2026
db882e0
feat(kernel): persist outbox events in the admitted transaction
cemililik Oct 12, 2026
37d95bd
docs(events): reconcile the delivered outbox entry baseline
cemililik Oct 12, 2026
937e36a
test(kernel): prove enqueue and completion never publish
cemililik Oct 12, 2026
d6fbd85
feat(kernel): complete saved domain facts before commit
cemililik Oct 12, 2026
3473843
docs(events): close domain completion review rounds
cemililik Oct 12, 2026
b6e3932
test(events): enforce producer contracts across production code
cemililik Oct 12, 2026
98c22a8
test(events): cover abstract and virtual helper dispatch
cemililik Oct 12, 2026
1e799a8
docs(roadmap): close P02b-1 implementation and review rounds
cemililik Oct 12, 2026
11871a2
test(web): expose bounded browser admission failure phases
cemililik Oct 12, 2026
471d0a0
docs(events): reconcile producer and pending delivery guarantees
cemililik Oct 12, 2026
d5afc66
docs(architecture): distinguish local event invocation ordering
cemililik Oct 12, 2026
d38b0c5
docs(events): align transport comments with ordering ownership
cemililik Oct 12, 2026
a903b53
test(web): preserve the first admission proof failure
cemililik Oct 12, 2026
cb2bb6d
docs(roadmap): record P02b-1 CI follow-up verification
cemililik Oct 12, 2026
d8fd919
test(kernel): strengthen producer and audit proof controls
cemililik Oct 12, 2026
75fc79b
docs(roadmap): record external producer proof review
cemililik Oct 12, 2026
c7dae0c
fix(kernel): distinguish frame refusal from indeterminate commit
cemililik Oct 12, 2026
3f4c121
fix(kernel): bound shared execution proofs and reconcile review records
cemililik Oct 12, 2026
9b2fcf6
fix(web): authenticate sanitized browser proof failures
cemililik Oct 12, 2026
b561c8f
docs(roadmap): record verified PR29 remediation and reviews
cemililik Oct 12, 2026
96e6f4d
fix(kernel): poison the unit after a failed outbox insert
cemililik Oct 12, 2026
8a97cd2
docs(roadmap): record outbox failure and ownership verification
cemililik Oct 12, 2026
b1f6df3
docs(roadmap): close P02b-1 after PR 29 merge
cemililik Oct 12, 2026
cc5de26
docs(decisions): propose P02b-2 delivery and recovery
cemililik Oct 12, 2026
4b010ef
docs(decisions): tighten P02b-2 recovery and audit contracts
cemililik Oct 12, 2026
192810b
docs(decisions): accept P02b-2 durable delivery contracts
cemililik Oct 12, 2026
d9bc77b
feat(kernel): add confined outbox dispatch foundation
cemililik Oct 12, 2026
6a13455
fix(docs): reconcile remaining shared inbox guidance
cemililik Oct 12, 2026
b6fc714
fix(kernel): refuse open generic active outbox handlers
cemililik Oct 12, 2026
c6ceaa9
docs(roadmap): close P02b-2 foundation review rounds
cemililik Oct 12, 2026
7976113
feat(kernel): add fenced outbox transitions and terminal audit
cemililik Oct 12, 2026
ffce05f
fix(kernel): validate persisted outbox routing fields
cemililik Oct 12, 2026
d4c06a4
docs(roadmap): close P02b-2 transition review rounds
cemililik Oct 12, 2026
756c852
feat(kernel): host bounded outbox dispatch and observation
cemililik Oct 12, 2026
da637e3
fix(dev): confine dispatcher credentials to the API host
cemililik Oct 12, 2026
02540d0
fix(kernel): clarify dispatcher boot refusal and close step 3
cemililik Oct 12, 2026
d2531f2
test(kernel): enforce confined outbox dispatch boundaries
cemililik Oct 12, 2026
2f8693e
fix(kernel): close dispatcher census mutation gaps
cemililik Oct 12, 2026
606362b
fix(kernel): pin dispatcher authority and observation bindings
cemililik Oct 12, 2026
7bf14a0
docs(roadmap): close P02b-2 implementation and review rounds
cemililik Oct 12, 2026
a80e06d
docs(roadmap): link P02b-2 PR and live verification ownership
cemililik Oct 12, 2026
a851b7c
fix(dev): reject paired missing demo role passwords
cemililik Oct 12, 2026
4408970
docs(roadmap): record demo password correction reviews
cemililik Oct 12, 2026
13cd632
fix(kernel): align recovery selection and preserve commit faults
cemililik Oct 12, 2026
41ac292
docs(kernel): reconcile integration contract delivery remarks
cemililik Oct 12, 2026
3531a2e
docs(roadmap): record external correction reviews and verification
cemililik Oct 12, 2026
a5b1e72
fix(kernel): close dispatcher membership and credential census gaps
cemililik Oct 13, 2026
3620b42
docs(roadmap): record dispatcher guard follow-up verification
cemililik Oct 13, 2026
3baf3a2
fix(kernel): respect the outbox snapshot refresh interval
cemililik Oct 13, 2026
4b4e3e4
docs(roadmap): record snapshot cadence correction reviews
cemililik Oct 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .claude/skills/add-architecture-test/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,12 @@ Add yours to the file whose subject it shares:
| `ApiConventionTests.cs` | Live majors, forwarded headers, required `Deployment:Mode`, unversioned route prefixes. |
| `CrossCuttingFoundationTests.cs` | Pipeline order, `Result<T>` returns, topic naming, and the direct-reference bans (Sentry, `DeploymentMode`, `IEventBus`, the cache clients, the Hub, provider SDK exceptions). |
| `RequestSurfaceTests.cs` | What the step-4 authority ceiling admits: the two request markers, their permitted sets, the shape of the attributes themselves, and the ban on request shapes MediatR runs with no pipeline. |
| `PlatformAdminScopeConventionTests.cs` | The single sanctioned `BYPASSRLS` path: who may resolve the keyed platform data source, where connection strings are read, the entry gate, and what the scope must not touch. |
| `PlatformAdminScopeConventionTests.cs` | The platform-admin `BYPASSRLS` path: who may resolve its keyed data source, where connection strings are read, the entry gate, and what the scope must not touch. ADR-0046 separately confines the dispatcher credential. |
| `RepositoryLayoutTests.cs` | `No_Source_Folder_Named_Verticals`, the single-frontend-app rule, and the commit-subject grammar — checked by running the `commit-msg` hook. |
| `DomainEventHandlerTests.cs` | Complete domain-handler/helper/interface census; outbox-only reactions with planted forbidden-capability controls. |
| `IntegrationEventContractTests.cs` | Production event inheritance, explicit identifier partition derivation and identifier-only payload/serializer shape. |
| `OutboxProducerConventionTests.cs` | Producer/helper fence against extra connections, commit ownership and direct publication. |
| `OutboxDispatchConventionTests` (partial files) | Whole-production dispatcher credentials/publication, exact literal-SQL writers and memory-only observation/registration censuses, with compiled clean/dirty controls. |
| `AggregateWriteTests.cs` | The one sanctioned cross-aggregate write, and the count of aggregate roots a handler's ports can write that keeps it at one. |
| `CustomizationRegistryTests.cs` | The closed renderer registries that exist twice — in C# and in TypeScript — and stop the two drifting. |
| `AuditConventionTests.cs` | The audit aggregate's shape, the closed-set columns against their `CHECK`s, append-only enforcement at the source level, the closed set of `audit_log` writers, and the `OperationType` table. |
Expand Down
22 changes: 15 additions & 7 deletions .claude/skills/add-audit-coverage/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,15 @@ parsed from the other, and three rules in `AuditCoverageTests` join them —
whose command has since shipped **fails**.
- **`(off-path)`** marks an operation that is not a MediatR request at all. Those rows
sit outside the request-type join in both directions and register by slug rather than
by type. Seven rows carry it: Tenancy's `platform.admin_scope.enter`,
by type. Nine rows carry it: Tenancy's `platform.admin_scope.enter`,
`tenancy.killswitch.toggle`, `tenancy.entitlement.refresh`,
`tenancy.tenant_assertion.reject` and `tenancy.tenant_assertion.anonymous_burst`, and
Audit's `audit.redaction.apply` and `audit.purge.apply`. The three whose writer ships are
registered through `DeclareOffPath`; the other four also carry `(planned)` and register
when their writer lands.
`tenancy.tenant_assertion.reject`, `tenancy.tenant_assertion.anonymous_burst`,
`platform.outbox.dead_letter` and `platform.delivery.dead_letter`, and
Audit's `audit.redaction.apply` and `audit.purge.apply`. The four whose writer
ships are registered through `DeclareOffPath`; the other five also carry
`(planned)` and register when their writer lands. ADR-0046 reserves the
Tenancy-owned producer terminal operation for P02b-2 and subscriber terminal
operation for P02b-3.
- **A row carries both markers where both are true** — off the request path *and* ahead
of the code that will write it.

Expand Down Expand Up @@ -529,8 +532,13 @@ passes even when every policy is inert. See
aggregate into another module's context inverts the dependency direction.
- **`UPDATE`ing an audit row to add detail after the fact.** There is no second phase.
The row is composed complete at the commit boundary, `IAuditStore` has **no update
method** — it has four *write* methods, `WritePendingAsync`, `WriteStandaloneAsync`,
`WriteBestEffortAsync` and `WritePlatformScopeAsync`, and that is a different thing —
method**. Its original four write methods are `WritePendingAsync`,
`WriteStandaloneAsync`, `WriteBestEffortAsync` and `WritePlatformScopeAsync`.
ADR-0046 adds the fifth, implemented in P02b-2 Step 2,
`WriteEventTerminalAsync(AuditEntryDraft, EventTerminalTransition, DbConnection,
DbTransaction, CancellationToken)` for confined terminal writers. Its restricted
witness binds audit identity to the actual fenced UPDATE; handlers cannot call
this seam. None is an update method,
and `learnstack_app` holds no `UPDATE` privilege on `audit_log`.
- **Truncating snapshots silently.** Over `JsonValue.MaxRowBytes` the value becomes the
elision record of Step 4b, in the column's own JSON type. Never an empty object, never
Expand Down
160 changes: 69 additions & 91 deletions .claude/skills/add-integration-event/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ name: add-integration-event
description: >
Publish or consume an integration event across modules using the outbox pattern,
`IEventBus` (`InProcessEventBus` today; Dapr pub/sub → Kafka on the Phase 11
trigger), and per-module inbox idempotency.
trigger), and shared transport-owned inbox idempotency.
USE FOR: declaring a new versioned `<Something>IntegrationEventVN`, wiring a
module to publish it via `IOutbox.EnqueueAsync`, and wiring another module to
consume it via `IIntegrationEventHandler<T>` + `IInboxGuard`. DO NOT USE FOR:
consume it via `IIntegrationEventHandler<T>` under the transport-owned inbox
boundary. DO NOT USE FOR:
intra-module domain events (those use plain MediatR `INotification`,
in-process), provider webhook receivers (they have their own pattern), or
Hub-side events (those live in the `learnstack-hub` repo).
Expand All @@ -31,7 +32,10 @@ collection and the completed-provisioning publisher. Step 4 implements the
production censuses and catalogue enforcement; the
[delivery record](../../../docs/roadmap/phase-02b-events-auth.md#p02b-1-step-4--producer-enforcement-and-closeout-2026-10-12)
owns final verification and review status.
P02b-2/P02b-3 retain dispatcher/inbox decisions.
Accepted [ADR-0046](../../../docs/decisions/0046-durable-event-delivery-and-recovery.md)
owns dispatcher recovery, subscription readiness and the shared inbox contract.
P02b-2 implements producer dispatch; P02b-3 still owns durable acceptance,
independent execution, canonical inbox/ledger DDL and real subscriptions.

## When to use

Expand Down Expand Up @@ -80,7 +84,7 @@ public sealed record EnrollmentCreatedIntegrationEventV1 : IntegrationEventBase
public override string Topic => "learnstack.enrollment.enrollment";

// Declare EnrollmentId as the partition source. ADR-0057 accepts canonical
// identifier derivation; G3 durable publish ordering remains P02b-2.
// identifier derivation; ADR-0046 bounds eligible selection, not publish order.
public override string PartitionKey => EnrollmentId.ToString("D");
}
```
Expand Down Expand Up @@ -153,7 +157,8 @@ Rules:
causation and optional causal actor come from trusted execution. That boundary
guarantees W3C Activity without a listener. Domain translation mints a new
integration EventId and uses its domain EventId as causation. A non-marked
event does not erase an announced organization; G1 delivery scope stays open.
event does not erase an announced organization. ADR-0046 preserves that envelope
organization during delivery; a non-marked type never widens its scope.
- The owner drains successful-save events FIFO before invariants/MUST audit and
commit, at most 256 per transaction. Domain handlers only translate to outbox:
no mutation/write stores, nested sends, further raises, publication or providers.
Expand Down Expand Up @@ -186,94 +191,65 @@ neither derives a second answer. The architecture test
`Integration_Event_TopicNames_FollowConvention` enforces the pattern; deviation
fails the build.

### Step 4: Consumer — handler + inbox guard

**Inherited target, pending P02b-2 G1 and P02b-3 delivery implementation.** This
example does not decide inbox ownership, deduplication identity or delivery scope.
The accepted first flow uses independent Customization `card@1`/`plain@1`
subscriptions for `TenantCreatedIntegrationEventV1`, not an Audit mirror. G1 must
prevent one subscription from suppressing its sibling before those handlers ship.
P02b-3 owns create-only preservation, per-effect MUST audit and the semantic
key/version Seeder handoff described in ADR-0057.

In `<Consumer>.Application/IntegrationEvents/<HandlerName>.cs`:

```csharp
public sealed class QueueWelcomeNotificationOnEnrollmentCreated(
IInboxGuard inbox,
NotificationsDbContext db,
ILogger<QueueWelcomeNotificationOnEnrollmentCreated> logger)
: IIntegrationEventHandler<EnrollmentCreatedIntegrationEventV1>
{
public async Task HandleAsync(
EnrollmentCreatedIntegrationEventV1 @event, CancellationToken ct)
{
if (await inbox.IsAlreadyProcessedAsync(@event.EventId, ct))
{
logger.LogDebug("Duplicate event {EventId} skipped", @event.EventId);
return;
}

// ... business logic ...
var dispatch = NotificationDispatch.QueueWelcome(
@event.LearnerId, @event.EnrollmentId);
db.NotificationDispatches.Add(dispatch);

inbox.MarkAsProcessed(@event.EventId, @event.GetType().Name);
await db.SaveChangesAsync(ct); // atomic business write + inbox marker
}
}
```

Rules:

- Under the inherited consumer target, **every** `IIntegrationEventHandler` invokes
`IInboxGuard.IsAlreadyProcessedAsync`
before any business logic. The architecture test
`Integration_Event_Handlers_Use_InboxGuard` holds every handler to it from
[Phase 02b](../../../docs/roadmap/phase-02b-events-auth.md), which ships the guard; it
is registered, not running, until then.
- `MarkAsProcessed` writes on the same **transaction** as the business write — the ambient
one the transport opens per delivery through `IUnitOfWork`
([ADR-0040](../../../docs/decisions/0040-ambient-unit-of-work.md)) — so the inbox marker
and the business write commit atomically.
- The **transport** — not middleware — restores tenant context from
`@event.TenantId` before your handler runs, and puts the publisher's own back
afterwards. Read it through `ITenantContext` as usual; don't read it from
anywhere else.
- **Organization comes from the envelope, and it has to.** An earlier version of
this skill said it was deliberately not restored, reasoning that inventing an
organization scope would narrow queries the producer never narrowed. Under the
canonical Row Level Security policy the reasoning inverts: with
`app.organization_id` unset, an organization-scoped row evaluates
`false OR NULL OR NULL`, and a NULL policy result is false — so an absent
organization *hides* every organization-scoped row and `WITH CHECK` rejects
writing one. Widening is the `app.scope = 'tenant'` hatch, not an absent value.
- **The effective actor is always `UserId.SystemActor`.** A human named by the
envelope remains separate causal audit metadata (`CausalActorUserId`); an
asynchronous consumer never impersonates that human.
- **Your handler's constructor must do nothing but assign fields.** Each
subscription gets its own async DI scope and exactly one handler construction.
Constructor, handler and disposal failures are contained to that subscription,
so healthy siblings still run.
### Step 4: Consumer — transport-owned inbox boundary

**Accepted target, pending P02b-3 implementation.** The transport owns the shared
`inbox_messages` guard; business handlers do not inject or call `IInboxGuard`.
Deduplication is `(tenant_id, subscription_id, event_id)`, with a stable versioned
subscription ID independent of handler type names. The separate delivery ledger
owns attempts, leases, terminal state and targeted replay.

Before constructing a handler, the executor opens an app-role unit of work,
announces envelope tenant and organization, and acquires the uncommitted inbox
claim. Completed duplicates skip construction. The completed inbox marker,
business changes, produced outbox, MUST audit and fenced ledger completion commit
on that one transaction; rollback removes a new claim. A replay never resets a
completed inbox row or republishes to healthy siblings.

The effective actor is `UserId.SystemActor`; an envelope's human actor remains
separate trusted causal metadata. Envelope organization is preserved even for a
non-marked event type. Restore context before resolving any handler, store or
module `DbContext`, and restore the caller's context afterwards.

A handler constructor only assigns fields. Construction, execution and disposal
failures belong to that subscription's own retry budget. Timeouts poison the
transaction owner; late work cannot commit success. An uncertain physical COMMIT
is Indeterminate and recovers from atomic ledger/inbox state. Ordinary app-role
delivery refuses privileged erasure work; Phase 03 owns that separate audited
boundary. External effects need idempotency keyed by subscription/event, or an
outbox intent; database rollback alone cannot undo them.

The first real flow has two independent Customization subscriptions:
`customization.initialize-content-type-default.v1` installs `card@1` content type;
`customization.initialize-taxonomy-default.v1` installs `plain@1` taxonomy. Both
remain Pending until P02b-3 supplies durable acceptance, create-only preservation,
per-effect MUST audit and the semantic key/version Seeder handoff. Neither may
suppress its sibling or overwrite authored, retired or deleted definitions.

### Step 5: Subscription registration

Today, expose the consumer assembly to the composition root so the
construction-free registry discovers and registers its concrete handlers:
The current baseline exposes the consumer assembly to the composition root so the
construction-free registry discovers and registers its concrete handlers. This
abridged assembly-registration example uses the shipped public module marker;
retain the other module assemblies in the actual composition root:

```csharp
builder.AddLearnStackCrossCuttingFoundation(
deploymentMode,
typeof(QueueWelcomeNotificationOnEnrollmentCreated).Assembly);
typeof(LearnStack.Modules.Customization.Application.AssemblyMarker).Assembly);
```

Today `InProcessEventBus` resolves the concrete
`IIntegrationEventHandler<T>` directly from DI in that subscription's async
scope. MediatR is not involved. There is no shipped
`AddDaprSubscription<T>` helper; do not invent one. Phase 11's Dapr adapter
invokes the same event-declared topic and handler contract, so the handler code
remains identical across transports.
`AddDaprSubscription<T>` helper; do not invent one. P02b-2's construction-free
manifest implements stable subscription IDs, compatible
type aliases and required Pending/Active readiness. Pending holds the producer row
before acquisition or payload validation. Only explicit intentional absence may
acknowledge a known type without publication. P02b-3 makes Active publication mean
durable acceptance of all matching deliveries, independently of business handling.
Do not invent a registration API before its implementation ships. Phase 11 must
preserve these contracts when it adds Dapr.

### Step 6: Tests

Expand All @@ -287,31 +263,33 @@ byte boundaries, SQL NULL metadata and terminal reuse. For delivered flows:

1. **Producer test** — exercise the handler, assert an `outbox_messages` row exists
with the right `type`, `topic`, `payload`.
2. **Consumer test** — feed the event into the handler twice; assert the business
write happens exactly once (idempotency via `IInboxGuard`).
2. **Consumer test, P02b-3** — deliver duplicates through the real transport and
ledger, including concurrent duplicates and sibling subscriptions. Assert one
completed inbox/business effect per subscription/event, atomic rollback,
independent retry budgets and recovery after an uncertain final-claim COMMIT.

## Validation

- `dotnet build` and `dotnet test` pass.
- `LearnStack.Tests.Architecture` is green — `Integration_Event_TopicNames_FollowConvention`
and `Integration_Events_Inherit_From_IntegrationEventBase` run today.
`Integration_Event_Handlers_Use_InboxGuard` remains Registered for P02b-3's
consumer delivery after G1. Check
transport-owned consumer delivery. Check
[the catalogue](../../../docs/standards/21-architecture-tests-catalogue.md) for their
status rather than assuming a net is under you.
- An integration test confirms the round-trip: handler enqueues → outbox row
created → outbox processor dispatches → consumer handles + writes business
state + inbox row.
- Sending the same event twice writes the consumer's business state exactly once.
- P02b-2 proves producer recovery and terminal audit independently of Pending
subscriptions. P02b-3 proves the round-trip through durable acceptance and the
shared inbox; do not claim that proof before those components ship.
- P02b-3 duplicate delivery yields one committed business effect per subscription.

## Common pitfalls

- **Crossing modules with a domain event.** Domain events stay inside the module
that owns the aggregate. The moment another module needs to react, it must be an
integration event.
- **Forgetting `IInboxGuard`.** The consumer rule remains Registered until
P02b-3; do not infer enforcement from its name. Consumer delivery must prove
redelivery cannot duplicate its business effect.
- **Moving the inbox guard into a handler.** The transport must claim before
construction. The rule remains Registered until P02b-3; its name alone proves
no enforcement. Redelivery must preserve independent sibling effects.
- **Writing the outbox row in a separate transaction.** Use `IOutbox.EnqueueAsync`
through the enrolled ambient `IUnitOfWork`; never `new TransactionScope`.
- **Supplying a second topic at enqueue or subscription time.** The concrete
Expand Down
Loading
Loading