diff --git a/.claude/skills/add-architecture-test/SKILL.md b/.claude/skills/add-architecture-test/SKILL.md index 8bf910ad..5ca3049e 100644 --- a/.claude/skills/add-architecture-test/SKILL.md +++ b/.claude/skills/add-architecture-test/SKILL.md @@ -115,8 +115,9 @@ Patterns to follow: ### Step 4: Common architecture-test families -**The shipped set is eighteen files, not a family per topic.** Add yours to the one whose -subject it shares: +**Choose by subject, not by ADR number.** These common families are starting +points; inspect the test directory and catalogue for the complete shipped set. +Add yours to the file whose subject it shares: | File | What it covers | |------|----------------| @@ -130,6 +131,9 @@ subject it shares: | `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. | | `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. | | `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. | @@ -144,7 +148,7 @@ Rules for surfaces no file covers yet — permissions, the Hub adapters and endp [the catalogue](../../../docs/standards/21-architecture-tests-catalogue.md) against the phase that ships the code they inspect. Check its Status line before assuming a net is under you, and create a new file only when your rule's subject is not one of -the eighteen above. +the listed families. > **The tenancy rules live in three files, and the split is by subject, not by ADR.** > All three cite ADR-0036, so "put it with the other ADR-0036 rules" is not a usable diff --git a/.claude/skills/add-integration-event/SKILL.md b/.claude/skills/add-integration-event/SKILL.md index dba775f6..73c4cfbb 100644 --- a/.claude/skills/add-integration-event/SKILL.md +++ b/.claude/skills/add-integration-event/SKILL.md @@ -24,6 +24,14 @@ Amendment 1 (Dapr pub/sub dispatch), and [15-event-and-outbox.md](../../../docs/architecture/15-event-and-outbox.md). The binding port and envelope contract is [ADR-0038](../../../docs/decisions/0038-cross-cutting-port-and-event-contracts.md). +Accepted [ADR-0057](../../../docs/decisions/0057-transactional-event-production-and-completion.md) +owns immediate producer admission, completion and payload policy. P02b-1 +Steps 2/3 implement immediate enqueue, payload admission, successful-save +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. ## When to use @@ -33,8 +41,10 @@ The binding port and envelope contract is ## When not to use -- Two aggregates in the **same** module need to coordinate — use MediatR - `INotification` (a domain event) and handle it in-process inside that module. +- A domain fact needs only in-module translation to outbox — use MediatR + `INotification` inside the owner transaction. Domain reactions cannot mutate + roots or coordinate additional writes; substantive reactions belong in an + integration consumer under ADR-0057. - The consumer can fetch the data on-demand from the producer's read API. - Provider webhooks (Stripe, LiveKit) — they enter via a webhook receiver that writes to the outbox itself, not via this skill. @@ -43,11 +53,11 @@ The binding port and envelope contract is | Input | Required | Description | |-------|----------|-------------| -| Event name | Yes | `IntegrationEventV`, PascalCase + version suffix. | +| Event name | Yes | `IntegrationEventV`, PascalCase + version suffix. | | Producing module | Yes | Owns the aggregate the event describes. | | Consuming module(s) | Yes | At least one; can be many. | | Topic | Declared | The event's `Topic` override, normally `learnstack.{module}.{aggregate}`. | -| Schema fields | Yes | `IntegrationEventBase` supplies `EventId`, `OccurredAt`, `TenantId` (all `required`) and demands `Topic` and `PartitionKey` overrides. Everything else is yours to declare. | +| Schema fields | Yes | `IntegrationEventBase` supplies `EventId`, `OccurredAt`, `TenantId` (all `required`) and demands `Topic` and `PartitionKey` overrides. Additional fields are GUIDs or approved strongly-typed identifiers, optionally nullable; no text, DTOs, collections, binary/raw JSON or serializer overrides. | ## Workflow @@ -56,25 +66,22 @@ The binding port and envelope contract is In `.Application.Contracts/IntegrationEvents/.cs`: ```csharp +[IntegrationEventPartition(nameof(EnrollmentId))] public sealed record EnrollmentCreatedIntegrationEventV1 : IntegrationEventBase { public required Guid EnrollmentId { get; init; } public required Guid LearnerId { get; init; } public required Guid CourseVersionId { get; init; } public Guid? CohortId { get; init; } - public required string Source { get; init; } // "manual" | "billing" | "invitation" // Both abstract on IntegrationEventBase, so this record does not compile // without them — deliberately, because each is a property of the event TYPE // and a value with two sources is a value that can disagree with itself. public override string Topic => "learnstack.enrollment.enrollment"; - // Ordering is guaranteed per partition key and nowhere else, and the - // aggregate this event is about is the ordering domain. Keying on TenantId - // instead would serialise the tenant's whole stream onto one partition — a - // real throughput cost, and one worth taking deliberately rather than by - // inheriting a default. - public override string PartitionKey => EnrollmentId.ToString(); + // Declare EnrollmentId as the partition source. ADR-0057 accepts canonical + // identifier derivation; G3 durable publish ordering remains P02b-2. + public override string PartitionKey => EnrollmentId.ToString("D"); } ``` @@ -87,6 +94,13 @@ five members, and every one of them is mandatory: `Integration_Event_TopicNames_FollowConvention` - `PartitionKey` — `abstract`; the ordering domain, declared by each event +The concrete record declares `[IntegrationEventPartition(nameof(EnrollmentId))]`. +Use `[IntegrationEventPartition(nameof(TenantId), tenantWide: true)]` only for an +explicitly tenant-wide fact. The named non-nullable source must match the actual +canonical lowercase `D` GUID key; inheriting a declaration is insufficient. +Approved typed payload identifiers are currently `TenantId`, `OrganizationId` +and `UserId`; module-local identifiers cross the contract as `Guid`. + It supplies **no** `OrganizationId`, `CorrelationId`, `CausationId` or `ActorUserId`: those describe the delivery and travel on `IntegrationEventEnvelope`, copied from the outbox row. An organization-owned @@ -117,17 +131,36 @@ await outbox.EnqueueAsync(new EnrollmentCreatedIntegrationEventV1 // one layer in, so that spelling needs `courseVersionId.Value`. CourseVersionId = request.CourseVersionId, CohortId = request.CohortId, - Source = request.Source.ToString().ToLowerInvariant(), }, cancellationToken); -await db.SaveChangesAsync(cancellationToken); // atomic +await db.SaveChangesAsync(cancellationToken); // flush; owner commits state + outbox + audit ``` Rules: -- `IOutbox.EnqueueAsync` enlists in the **ambient unit of work** — the one connection and - transaction `IUnitOfWork` owns ([ADR-0040](../../../docs/decisions/0040-ambient-unit-of-work.md)), - not a module's `DbContext`. Do not open a second transaction. +- `IOutbox.EnqueueAsync` validates and INSERTs immediately through the enrolled, + announced, writable ambient `IUnitOfWork.Connection`/`Transaction`. It opens + no connection, commits nothing and never calls `IEventBus`. The frame compares + the live announcement generation and event tenant, including unresolved + provisioning; caller snapshots are not authority. Raw transactions cannot + bypass producer enrollment. `id = EventId`; duplicates fail. +- Serialize the concrete runtime event through the existing frozen + `IntegrationEventBase.PayloadJsonOptions`. The complete canonical UTF-8 input + fits `OutboxPayloadLimits.MaxBytes` (262,144 bytes inclusive) before INSERT; + multibyte accounting is bytes, not characters. JSON `metadata` is SQL NULL; + `IOutbox` accepts no arbitrary metadata. No DDL size CHECK is claimed. +- Tenant/organization come from the transaction announcement; correlation, + 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. +- 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. + Successful enqueue/promotion leaves sticky receipts in every ancestor; + `Result.Fail` with such work poisons the unit before frame resolution, even if + absorbed. Event-free refusals retain ADR-0040 semantics. Terminal paths discard + source state; joiners neither drain nor clear the owner. - `SaveChangesAsync` **flushes** the context; it does not commit. `TransactionBehavior` owns the commit boundary, so the aggregate change and the outbox row land together because they share that transaction — not because one `SaveChanges` wrote both. @@ -155,13 +188,21 @@ 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 `.Application/IntegrationEvents/.cs`: ```csharp -public sealed class CreateAuditEntryOnEnrollmentCreated( +public sealed class QueueWelcomeNotificationOnEnrollmentCreated( IInboxGuard inbox, - AuditDbContext db, - ILogger logger) + NotificationsDbContext db, + ILogger logger) : IIntegrationEventHandler { public async Task HandleAsync( @@ -174,8 +215,9 @@ public sealed class CreateAuditEntryOnEnrollmentCreated( } // ... business logic ... - var entry = AuditEntry.FromIntegrationEvent(@event); - db.AuditEntries.Add(entry); + 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 @@ -185,7 +227,8 @@ public sealed class CreateAuditEntryOnEnrollmentCreated( Rules: -- **Every** `IIntegrationEventHandler` invokes `IInboxGuard.IsAlreadyProcessedAsync` +- 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 @@ -222,7 +265,7 @@ construction-free registry discovers and registers its concrete handlers: ```csharp builder.AddLearnStackCrossCuttingFoundation( deploymentMode, - typeof(CreateAuditEntryOnEnrollmentCreated).Assembly); + typeof(QueueWelcomeNotificationOnEnrollmentCreated).Assembly); ``` Today `InProcessEventBus` resolves the concrete @@ -234,7 +277,13 @@ remains identical across transports. ### Step 6: Tests -Two tests minimum: +Follow the canonical producer rules and remaining consumer obligations in +[Standards 21](../../../docs/standards/21-architecture-tests-catalogue.md#events-jobs-and-correlation): +announced transaction/admission, sticky failure receipts, owner completion, +outbox-only handlers and identifier-only payloads; extend existing partition and +row-context proofs. Use real production subjects and planted violations, including +no-listener trace context, failed-save attribution, absorbed descendant refusal, +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`. @@ -245,9 +294,9 @@ Two tests minimum: - `dotnet build` and `dotnet test` pass. - `LearnStack.Tests.Architecture` is green — `Integration_Event_TopicNames_FollowConvention` - runs today; `Integration_Events_Inherit_From_IntegrationEventBase` and - `Integration_Event_Handlers_Use_InboxGuard` are registered for - [Phase 02b](../../../docs/roadmap/phase-02b-events-auth.md) and run from there. Check + and `Integration_Events_Inherit_From_IntegrationEventBase` run today. + `Integration_Event_Handlers_Use_InboxGuard` remains Registered for P02b-3's + consumer delivery after G1. 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 @@ -260,10 +309,11 @@ Two tests minimum: - **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 architecture test catches it, but the symptom - in production (Kafka redelivery → duplicate work) is silent until then. +- **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. - **Writing the outbox row in a separate transaction.** Use `IOutbox.EnqueueAsync` - inside the ambient `DbContext`; never `new TransactionScope`. + through the enrolled ambient `IUnitOfWork`; never `new TransactionScope`. - **Supplying a second topic at enqueue or subscription time.** The concrete event's `Topic` override is the one source; persistence, envelope and transport forward it unchanged. diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md index 66a40489..af20dc97 100644 --- a/.claude/skills/code-review/SKILL.md +++ b/.claude/skills/code-review/SKILL.md @@ -108,7 +108,7 @@ Walk the diff for correctness issues. Common shapes: | **Race condition** | Read-modify-write without optimistic concurrency (`row_version`) or a transaction. Cross-instance state without `ICacheService` invalidation. | | **Resource leak** | `DbContext` / `HttpClient` / file stream not disposed; long-lived `DaprClient` reuse via DI, not `new`. | | **Idempotency** | Command with external side effects (LiveKit room, Stripe charge) missing `Idempotency-Key` handling. Integration-event consumer missing `IInboxGuard.IsAlreadyProcessedAsync`. | -| **Outbox atomicity** | Outbox row written in the same `DbContext` SaveChanges as the aggregate. No separate transaction. | +| **Outbox atomicity** | Immediate INSERT through the enrolled, announced ambient `IUnitOfWork`; source state/outbox/MUST audit share its transaction. SaveChanges flushes only. ADR-0057 generation, tenant, read-only, payload/metadata and duplicate refusals hold. | | **Tenant context restoration** | Background job + integration-event handler restores `TenantContext` before any work. | | **Edge cases** | Empty list, zero, negative, max-int, very-long string. Test or guard. | | **Time / clock** | Hard-coded UTC / DateTime.Now / DateTime.UtcNow vs `IClock` injection. | @@ -282,6 +282,26 @@ Author intent: — a separate rule per [ADR-0038](../../../docs/decisions/0038-cross-cutting-port-and-event-contracts.md) and [29-dapr-integration.md § 8](../../../docs/architecture/29-dapr-integration.md); architecture test `Dapr_SDK_Types_NotImportedOutsideInfrastructure`. +- **ADR-0057 producer boundaries.** Domain handlers only translate to outbox; + inspect helper/interface paths for mutations, write stores, nested sends, + further raises, notification publication and providers. Successful-save events + drain FIFO at one owner site before invariants/MUST audit/commit, with a total + 256 bound. Sticky produced-work receipts reach all ancestors; absorbed + `Result.Fail` poisons before frame resolution. Event-free control still commits. + Verify failed-save attribution and cleanup on every terminal/same-scope path. +- **ADR-0057 payload and execution.** Additional fields are identifiers only; + concrete-runtime frozen JSON fits the inclusive 262,144-byte UTF-8 cap; JSON + metadata is SQL NULL. Trusted execution supplies no-listener Activity and + causation/causal actor; transaction announcement supplies tenant/organization. + No caller header/snapshot establishes authority. Common non-MediatR audit + entry refuses unregistered direct mutation and reconciles only at the outer + frame; producer receipts are distinct. P02b-3 delivery/inbox remains open. +- **Real flow and evidence.** Completed provisioning emits + `TenantCreatedIntegrationEventV1`; P02b-1 claims pending rows only. P02b-3 owns + separate create-only `card@1`/`plain@1` subscriptions and Seeder identity + handoff after G1; no settings invalidation or Audit mirror is the sample. + Read current producer/consumer statuses in the catalogue; require production + censuses and planted offenders rather than zero-subject passes. - **No direct write to `audit_log` / `outbox_messages` / `platform_entitlement_cache`.** Use `IAuditStore` / `IOutbox` / `IEntitlementProvider.RefreshAsync`. diff --git a/CLAUDE.md b/CLAUDE.md index 9dbc7f19..aa206297 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,8 +64,8 @@ so a status header, a catalogue entry, a published count or a planted `Skip` fails the build rather than a reader. The whole .NET suite runs with **zero skips**, which the runner now refuses to let change. -**[Phase 02d](docs/roadmap/phase-02d-walking-skeleton.md) implementation and exit -verification are complete; PR #28 review and merge remain pending**. Its kickoff +**[Phase 02d](docs/roadmap/phase-02d-walking-skeleton.md) is complete and merged** +through PR #28 on 2026-10-11. Its kickoff shipped the packet table and the decision register, and every later packet opens with its decision pass. **P02d-1 is complete and merged** through [PR #22](https://github.com/HodeTech/LearnStack/pull/22) on 2026-09-14. Education's @@ -160,8 +160,28 @@ owns CI enforcement, completed reviews and individual exit reconciliation. Its [CI and exit closeout](docs/roadmap/phase-02d-walking-skeleton.md#p02d-7-ci-and-phase-exit-closeout-2026-10-11) verifies all seven real PR jobs, complete artifacts and live seventh-check activation with all other protection settings preserved. All four steps completed -both review rounds. [PR #28](https://github.com/HodeTech/LearnStack/pull/28) awaits -maintainer review and merge. P02b-0 re-verification/decision pass is next afterward; +both review rounds. [PR #28](https://github.com/HodeTech/LearnStack/pull/28) is merged; +the [merge closeout](docs/roadmap/phase-02d-walking-skeleton.md#p02d-7-merge-and-closeout-2026-10-11) +owns final verification. P02b-0's +[entry audit](docs/roadmap/phase-02b-events-auth.md#p02b-0-entry-verification-2026-10-12) +is complete; P02b-1's [producer decision package](docs/roadmap/phase-02b-events-auth.md#p02b-1-decision-package-2026-10-12) +and ADR-0057 are Accepted — 2026-10-12. The four implementation steps are +authorized. Step 1 implements the shared snapshot/producer/audit lifecycle; its +[delivery record](docs/roadmap/phase-02b-events-auth.md#p02b-1-step-1--shared-execution-lifecycle-2026-10-12) +records passing verification and both completed independent review rounds. +Step 2 implements immediate outbox production; its +[delivery record](docs/roadmap/phase-02b-events-auth.md#p02b-1-step-2--immediate-outbox-production-2026-10-12) +records passing verification and both completed review rounds. +Step 3 implements successful-save collection, owner-only completion and the +completed-provisioning publisher; its +[delivery record](docs/roadmap/phase-02b-events-auth.md#p02b-1-step-3--domain-completion-and-provisioning-publisher-2026-10-12) +records passing verification and both completed review rounds. Step 4 adds +production-census guards and catalogue enforcement; its +[delivery record](docs/roadmap/phase-02b-events-auth.md#p02b-1-step-4--producer-enforcement-and-closeout-2026-10-12) +records passing full backend verification and both completed review rounds. +**P02b-1 is complete; PR review and merge remain pending.** The +[packet closeout](docs/roadmap/phase-02b-events-auth.md#p02b-1-packet-closeout-2026-10-12) +owns delivery scope and the next P02b-2 decision pass. Phase 06 retains the full renderer/Studio expansion. **Phase 01** shipped the .NET 10 solution scaffold under `backend/` @@ -331,7 +351,7 @@ all recorded in the Phase 02a Status block: renders both of them in a browser. **[Phase 02d: Two-Tenant Walking Skeleton](docs/roadmap/phase-02d-walking-skeleton.md) -is the next user-visible milestone** — the first phase whose output +is the first delivered user-visible milestone** — the first phase whose output someone who does not read C# can evaluate: two hosts, two tenants, two education sites, one binary and one database. @@ -360,10 +380,16 @@ For any task, read in this order: Then read the phase that is live and the one it stands on: +- [docs/roadmap/phase-02b-events-auth.md](docs/roadmap/phase-02b-events-auth.md) + — **the live phase**. P02b-0 entry verification is complete; P02b-1's decision + package is Accepted — 2026-10-12; Steps 1–3 implement the producer runtime, + and Step 4 supplies production-census enforcement and packet closeout. + Each later packet accepts its own gates + before implementation. - [docs/roadmap/phase-02d-walking-skeleton.md](docs/roadmap/phase-02d-walking-skeleton.md) - — **the current phase**, and where new work belongs. `02d` sorts after - `02b`/`02c` but runs **before** them; the roadmap dependency map is - authoritative for order, filename order is not. + — complete and merged; the delivered public-site baseline and P02b-0 handoff. + `02d` sorts after `02b`/`02c` but runs **before** them; the roadmap dependency + map is authoritative for order, filename order is not. - [docs/roadmap/phase-02a-kernel-tenancy.md](docs/roadmap/phase-02a-kernel-tenancy.md) — complete, with a dated Status block and a delivery record per packet. It is the authority on what the kernel already guarantees, not on what to build next. diff --git a/README.md b/README.md index 46f59592..57c56b11 100644 --- a/README.md +++ b/README.md @@ -64,8 +64,13 @@ items, rules, custom fields and notification templates; their delivery is tracke ## Where it is today -**Phase 01 and Phase 02a are complete. Phase 02d implementation and exit -verification are complete; PR #28 awaits maintainer review and merge.** +**Phase 01, Phase 02a and Phase 02d are complete.** Phase 02d is merged through +PR #28 on 2026-10-11. P02b-0's +[entry audit](docs/roadmap/phase-02b-events-auth.md#p02b-0-entry-verification-2026-10-12) +is complete. **P02b-1 is complete; PR review and merge remain pending.** Its +[packet closeout](docs/roadmap/phase-02b-events-auth.md#p02b-1-packet-closeout-2026-10-12) +records transactional event production, enforcement and both review rounds per +step under Accepted ADR-0057. [P02d-1](docs/roadmap/phase-02d-walking-skeleton.md#merge-and-closeout-2026-09-14) is **complete and merged**: Education domain, schema and isolation proofs. **P02d-2's decision package and ADR-0050/0051 are Accepted** as of 2026-10-02, @@ -113,8 +118,10 @@ review rounds. The shared demo, genericity fences and seeded browser/Lighthouse proof pass real CI; the seventh required context is active. The [Step 4 record](docs/roadmap/phase-02d-walking-skeleton.md#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11) owns CI enforcement, reviews and the positive phase-exit verdict. -[PR #28](https://github.com/HodeTech/LearnStack/pull/28) awaits maintainer review -and merge; P02b-0 re-verification is next afterward. The +[PR #28](https://github.com/HodeTech/LearnStack/pull/28) is merged; the +[merge closeout](docs/roadmap/phase-02d-walking-skeleton.md#p02d-7-merge-and-closeout-2026-10-11) +owns final verification. P02b-0 and P02b-1 are complete; dispatcher and consumer +work remain P02b-2/P02b-3. The [P02d-4 decision package](docs/roadmap/phase-02d-walking-skeleton.md#p02d-4-decision-package-2026-10-03) is Accepted — 2026-10-03. All four implementation steps and their review loops are complete. P02d-5 supplies trusted server rendering; P02d-6 supplies public @@ -126,7 +133,7 @@ page consumers and P02d-7 the two-host browser demo. | **Customization** | Content types, level taxonomies, exact-definition and generation-cached batched display readers, text-card metadata validation and tenant-authored seeds | Remaining authoring capabilities across [Phases 04–08a](docs/roadmap/README.md) | | **Audit** | Classified write path and transactional durability for business changes | Operational hardening in [Phase 11](docs/roadmap/phase-11-production-hardening.md) | | **Education** | Course and Lesson aggregates, translations, protected-content policy, scoped authoring commands, complete demo seeds and isolated public GET/HEAD reads | Full versioned curriculum in [Phase 05](docs/roadmap/phase-05-education-learning-content.md) | -| **API foundation** | Error contracts, validation, tenancy, concurrency and observability infrastructure | Authentication and durable event processing in [Phase 02b](docs/roadmap/phase-02b-events-auth.md) | +| **API foundation** | Error contracts, validation, tenancy, concurrency, observability and transactional event production | Authentication and durable event dispatch/consumption in [Phase 02b](docs/roadmap/phase-02b-events-auth.md) | | **Frontend** | Native TLS ingress, live locale entry, trusted server caller, localized catalog/course/lesson pages, safe themes, accessibility controls/manual smoke and generated typed SDK | Full renderer/Studio and Playwright Test/axe in [Phase 06](docs/roadmap/phase-06-renderer-admin-studio.md) | **Four modules contain domain implementations:** Tenancy, Customization, Audit and diff --git a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs index 5e594b16..dde24e4c 100644 --- a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs +++ b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs @@ -9,6 +9,7 @@ using LearnStack.Infrastructure.Audit.Capture; using LearnStack.Infrastructure.MultiTenancy; using LearnStack.Infrastructure.Persistence; +using LearnStack.Infrastructure.Messaging; using LearnStack.Modules.Audit.Infrastructure.Persistence; using LearnStack.Modules.Customization.Application.Abstractions; using LearnStack.Modules.Tenancy.Application.Abstractions; @@ -19,6 +20,7 @@ using LearnStack.SharedKernel.Audit; using LearnStack.SharedKernel.Entitlements; using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Messaging; using LearnStack.SharedKernel.Tenancy; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.EntityFrameworkCore.Diagnostics; @@ -216,6 +218,8 @@ public static IServiceCollection AddLearnStackPersistence( // idempotence this actually wants. services.TryAddEnumerable( ServiceDescriptor.Scoped()); + services.TryAddEnumerable( + ServiceDescriptor.Scoped()); // PostgresAuditStore counts the durable-duplicate outcome, so it needs a meter // factory. AddMetrics is idempotent and the API host already calls it through @@ -263,6 +267,7 @@ public static IServiceCollection AddLearnStackPersistence( services.AddHealthChecks().AddCheck(AuditHealthCheck.Name); services.TryAddScoped(); + services.TryAddScoped(); // The catalogue, merged once from every module's source. A singleton: it is built // at composition time and read on every request, and rebuilding it per scope would diff --git a/backend/src/LearnStack.Application/Audit/AuditExecutionCoordinator.cs b/backend/src/LearnStack.Application/Audit/AuditExecutionCoordinator.cs new file mode 100644 index 00000000..e6630801 --- /dev/null +++ b/backend/src/LearnStack.Application/Audit/AuditExecutionCoordinator.cs @@ -0,0 +1,380 @@ +using System.Runtime.ExceptionServices; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.SharedKernel.Time; +using Microsoft.Extensions.Logging; + +namespace LearnStack.Application.Audit; + +/// +/// The shared audit execution lifecycle for MediatR requests and direct transaction +/// owners: classify, declare, record each invocation's result, then reconcile once at +/// the outermost audit frame. +/// +/// +/// +/// Scoped with . The capture's existing frame stack is +/// the ownership authority; another DI scope has its own capture even when it runs +/// inside the same async flow. Producer invocation frames and transaction ownership +/// remain separate from this audit lifecycle (ADR-0057). +/// +/// +/// The transaction owner writes pending MUST rows and reports the commit boundary. +/// This coordinator preserves ADR-0033's reconciliation, classification and outcome +/// rules; it introduces no additional writer or transaction. +/// +/// +public sealed class AuditExecutionCoordinator( + IAuditCatalog catalog, + IAuditConfigService classifier, + IAuditStateCapture capture, + IAuditStore store, + ITenantContext tenantContext, + IClock clock, + IGuidFactory guidFactory, + ILogger logger) +{ + /// + /// Classifies the declared operation before invoking its deferred work. A direct + /// owner constructs its handler inside , after classification + /// and after its transaction coordinator has opened the unit of work. + /// + public async Task ExecuteAsync( + Type operationType, + IProvisionsTenant? provisioning, + Func> next, + CancellationToken cancellationToken) + where TResponse : IResultBase + { + ArgumentNullException.ThrowIfNull(operationType); + ArgumentNullException.ThrowIfNull(next); + + // Unregistered is the rejection, and it happens BEFORE the handler runs: an + // operation nobody classified is a deployment defect, and letting it commit while + // the corpus cannot say whether it should have been audited is the failure the + // fail-closed rule exists to prevent. `Off` is a different answer — registered and + // silent — which is why TryGet and not a count of entries tells them apart. + if (!catalog.TryGet(operationType, out var registration)) + { + LogUnclassified(logger, operationType.FullName ?? operationType.Name, null); + + return Result.FailFor(AuditErrors.UnclassifiedOperation); + } + + var outermost = !capture.HasOpenFrame; + + // A frame per invocation, outermost or nested, so each intent belongs to the + // request that declared it and each captured change to the request whose handler + // wrote it (ADR-0044 Amendment 6 §§ 1, 3). + capture.OpenFrame(); + + // Thrown until the handler RETURNS: a behavior, the handler, a cancellation and a + // faulted COMMIT all leave by an exception, and every one of them is a result the + // row must not call a success. + var result = AuditIntentResult.Thrown; + + try + { + await DeclareAsync(provisioning, registration, cancellationToken).ConfigureAwait(false); + + var response = await next().ConfigureAwait(false); + + // Recorded rather than acted on. Every exit reconciles in one place below, so a + // refusal, an exception and a cancellation cannot drift apart — and the + // cancelled COMMIT, which reaches neither the catch nor this line, is covered + // by the same code as the other two (ADR-0033 Amendment 4 § 2). + result = response.IsFailure + ? AuditIntentResult.Refused(OutcomeOf(response.Error!), response.Error!.Message.Key) + : AuditIntentResult.Succeeded; + + return response; + } +#pragma warning disable CA1031 // ADR-0032 § Sub-decision 2 binds the audit-then-rethrow contract here. + // Cancellation is excluded deliberately, and the exclusion no longer costs a row: + // the reconcile moved into the finally, which a cancellation does run. + catch (Exception failure) when (failure is not OperationCanceledException) +#pragma warning restore CA1031 + { + ExceptionDispatchInfo.Capture(failure).Throw(); + throw; // unreachable; the line above is the rethrow. + } + finally + { + // EVERY frame records what its own request returned, on the intents it declared. + // Only the outermost frame used to keep an outcome at all, in a local, so an + // inner refusal an outer handler absorbed was written by the owner as success + // (ADR-0044 Amendment 6 § 3). + capture.CloseFrame(result); + + // Only the OUTERMOST frame reconciles and clears. A joiner that cleared would + // erase the outer request's intents and every snapshot before the owner had + // committed (ADR-0033 Amendment 2 § 2). + if (outermost) + { + try + { + await ReconcileAsync().ConfigureAwait(false); + } + finally + { + // The capture owns both attribution and frame ownership. Cleanup is + // scoped to this execution's capture, so another DI scope on the same + // async flow cannot suppress its own reconciliation. + capture.Clear(); + } + } + } + } + + /// + /// Writes whatever the business transaction did not. + /// + /// + /// + /// One place for every exit, because the exits are not distinguishable from inside the + /// pipeline: a refusal returns, an exception throws, and a cancelled COMMIT + /// does neither in a way this coordinator's catch can see. What separates them is + /// the state the owning unit-of-work frame recorded, which is a fact about the + /// database, and each intent's own result, which is a fact about its request — + /// holds the rule that combines them. + /// + /// + /// A MUST intent is re-written only when it is not already durable. + /// means the row went with the business + /// write; anything else means it did not, and the attempt is still the record. + /// prefers a duplicate to a loss — the + /// row goes out anyway, and the 23505 that may follow is positive evidence the + /// first one landed. + /// + /// + /// A SHOULD or MAY intent is never in-transaction. + /// WritePendingAsync flushes MUST alone, so these are written here, best effort, + /// with the outcome the request actually had. + /// + /// + /// It never throws, and one row's failure never costs another its attempt. This + /// runs from a finally, where an exception replaces whatever the request was + /// leaving with. Composition and the write are each guarded, per intent: a composer + /// refusal and a store that failed before issuing a statement both escaped the narrower + /// catch this had, replaced a 403 with a 500, and skipped every intent + /// after them. Everything reaching here is already failing, refused or in doubt, so + /// ADR-0033 Amendment 1's rule — a standalone failure changes the response only for an + /// operation that would otherwise have succeeded — leaves the response alone. + /// + /// + /// One alert per failure, from whoever saw it. The store reports a failed write + /// itself (IAuditStore), so that path logs a Warning saying the caller's status + /// stands; a composer refusal and a draft the store refused before writing are programmer + /// errors nothing else reports, and those log Critical here. + /// + /// + private async Task ReconcileAsync() + { + var state = capture.State; + + foreach (var intent in capture.Intents) + { + var durable = intent.OperationClass == OperationClass.Must + && state == AuditIntentState.Committed; + + if (durable) + { + continue; + } + + AuditEntryDraft draft; + +#pragma warning disable CA1031 // A finally that threw would replace the exception or refusal the caller is meant to see. + try + { + // The SAME composer the in-transaction write uses. A private one here is how + // the two diverged: only the happy-path row carried a snapshot, so every + // denied, failed and indeterminate row said nothing about what was attempted + // — which is the class of row ADR-0033 calls the common case it protects. + // + // A FRESH clock reading, not the intent's DeclaredAt: the in-transaction row + // that may already exist under this id carries that, and the composite + // primary key is what makes the pair legal rather than a 23505. + draft = AuditDraftComposer.Reconciled( + intent, capture.ChangesOf(intent), state, clock.UtcNow); + } + catch (Exception refused) + { + // Critical, and swallowed. A composer refusal — two instances of the declared + // aggregate and none designated — is a programmer error that nothing else + // reports, and it must not cost the next intent its attempt nor replace the + // caller's outcome. + LogReconcileRowRefused(logger, intent.Operation, refused); + + continue; + } + + try + { + // The class decides the posture, not the outcome. A MUST row that cannot + // be written is a failure worth shouting about; a SHOULD row that cannot is + // an accepted loss the module's matrix already records. + // CancellationToken.None, and it is the whole point. The paths this + // reconcile exists for are the ones where the request's token is ALREADY + // cancelled — a client that disconnected mid-COMMIT above all — so handing + // that token to the write abandons the row at + // OpenConnectionAsync, before a statement is issued. Measured: one row + // with a live token, zero with a cancelled one, and the cancelled case is + // the only one this code path exists for. + // + // TransactionBehavior applies the same rule one behavior in, passing + // CancellationToken.None to scope.FailAsync: the cleanup must never be + // abandoned by what it is cleaning up after. Unbounded only in the sense + // that this token is — Npgsql's own connection and command timeouts still + // bound the write, so a wedged database cannot hold the request forever. + if (intent.OperationClass == OperationClass.Must) + { + await store.WriteStandaloneAsync(draft, CancellationToken.None).ConfigureAwait(false); + } + else + { + await store.WriteBestEffortAsync(draft, CancellationToken.None).ConfigureAwait(false); + } + } + catch (ArgumentException refused) + { + // A draft the store refused before any write — the platform sentinel — is a + // caller error the store does not report (IAuditStore), so this is its alert. + LogReconcileRowRefused(logger, intent.Operation, refused); + } + catch (Exception lost) + { + // Swallowed, and NOT a second Critical. The store reports every failure of the + // write itself — Critical, counted, the `audit` health check unhealthy + // (IAuditStore) — and best effort logs and drops its own; the Critical this + // used to add was one alert twice (the fifth review of Packet 9). What this + // line adds is the one fact the store cannot know: ADR-0033 Amendment 1 + // changes the response only when the operation would otherwise have + // SUCCEEDED, and everything reaching here is already failing, refused or in + // doubt, so the caller keeps its status. + LogReconcileRowLost(logger, intent.Operation, lost); + } +#pragma warning restore CA1031 + } + } + + /// + /// Parks one intent per audited (resource, operation). + /// + /// + /// Plural, and that is the correction ADR-0033 Amendment 2 § 1 makes: + /// ProvisionTenantCommand writes two aggregate roots on one transaction and the + /// Tenancy matrix classifies both MUST, so a singular reading would simply never write + /// the Organization row it promises. + /// + private async Task DeclareAsync( + IProvisionsTenant? provisioning, AuditRegistration registration, CancellationToken cancellationToken) + { + if (registration.WritesNoRow) + { + return; + } + + // ADR-0044 § 2's four cases, decided HERE because step 3 is the only place all + // four are decidable — and because the store must never resolve a tenant of its + // own. The row's tenant is the one the transaction ANNOUNCES, which is the only + // value its own WITH CHECK accepts. + TenantId? tenantId; + OrganizationId? organizationId; + + if (tenantContext.IsResolved) + { + tenantId = tenantContext.TenantId; + organizationId = tenantContext.OrganizationId; + } + else if (provisioning is not null) + { + // The tenant does not exist yet, so the context cannot resolve it — and the + // transaction announces exactly this value through + // SetProvisioningTenantContextAsync. Reading the context instead gives the + // all-zero tenant, and the row is then refused by the policy it was written + // under. Measured: `make seed` failed here with 42501 before this branch + // existed, on the one command ADR-0042 sanctions. + tenantId = provisioning.ProvisioningTenantId; + + // NULL, and not by choice: SetProvisioningTenantContextAsync announces + // app.organization_id as the empty string, so any other value fails the + // org-scoped WITH CHECK. + organizationId = null; + } + else + { + // The fourth case: no row at all. There is no tenant whose admin could read + // it, and ADR-0036 forbids inventing one. + return; + } + + foreach (var entry in registration.Entries) + { + var classification = await classifier + .ClassifyAsync(tenantId, entry, cancellationToken) + .ConfigureAwait(false); + + if (classification == AuditClassification.Off) + { + continue; + } + + capture.DeclareIntent(new AuditIntent( + AuditEntryId.From(guidFactory.NewUuidV7()), + tenantId.Value, + organizationId, + + // Read HERE, with the tenant, and carried to both writers on the intent. The + // in-transaction write had no way to reach them and wrote both columns NULL + // on every successful row (ADR-0044 Amendment 6 § 2). The principal the + // request carried, or null — never a substituted SystemActor. + tenantContext.UserId, + tenantContext.CorrelationId, + entry.ModuleName, + entry.Operation, + entry.OperationType, + // The DECLARED tier, always. An override narrows to Off or leaves the tier + // alone (ADR-0033 Amendment 4 § 1), so a non-Off classification is the + // declared one and the intent and the catalogue cannot disagree. + entry.OperationClass, + entry.EntityType, + clock.UtcNow)); + } + } + + /// + /// Denied for a refusal the authorization layer would answer 403 to, Failed otherwise. + /// + /// + /// Keyed on the three codes HttpStatusMap answers 403 to, so the row's + /// outcome and the response's status cannot disagree. A validation failure is + /// Failed: it is a malformed request rather than a refused one, and counting it + /// as a denial would bury the probes the denied class exists to surface. + /// + private static AuditOutcome OutcomeOf(Error error) => error.Code switch + { + "forbidden" or "resource_scope_violation" or "feature_disabled" => AuditOutcome.Denied, + _ => AuditOutcome.Failed, + }; + + private static readonly Action LogUnclassified = + LoggerMessage.Define( + LogLevel.Error, + new EventId(1, nameof(LogUnclassified)), + "{RequestType} reached the audit behavior with no catalogue registration and was refused. Every request is registered, silence included: declare it in the module's IAuditCatalogSource, with Off if it audits nothing."); + + private static readonly Action LogReconcileRowLost = + LoggerMessage.Define( + LogLevel.Warning, + new EventId(2, nameof(LogReconcileRowLost)), + "The reconcile row for {Operation} could not be written, and the audit store has reported the failure. The caller keeps whatever status it already had: ADR-0033 Amendment 1 changes the response only for a write that would otherwise have SUCCEEDED, and turning a refusal into a 503 an anonymous caller can provoke tells them more, not less."); + + private static readonly Action LogReconcileRowRefused = + LoggerMessage.Define( + LogLevel.Critical, + new EventId(3, nameof(LogReconcileRowRefused)), + "The reconcile row for {Operation} was refused before any write — a programmer error no audit store reports, such as two instances of the declared aggregate with none designated. The row is not on the record, and the caller keeps whatever status it already had."); +} diff --git a/backend/src/LearnStack.Application/Messaging/DomainEventCollector.cs b/backend/src/LearnStack.Application/Messaging/DomainEventCollector.cs new file mode 100644 index 00000000..e521373f --- /dev/null +++ b/backend/src/LearnStack.Application/Messaging/DomainEventCollector.cs @@ -0,0 +1,184 @@ +using LearnStack.SharedKernel.Domain; +using MediatR; + +namespace LearnStack.Application.Messaging; + +/// +/// Stages saving-source snapshots, promotes successful saves, and retains facts until +/// the physical owner completes. Tracking retention serves cleanup, never save eligibility. +/// +public sealed class DomainEventCollector(ProducerExecution producer, IPublisher publisher) : IDisposable +{ + /// Total successful promotions allowed in one physical transaction. + public const int MaxEventsPerTransaction = 256; + + private readonly HashSet _sources = new(ReferenceEqualityComparer.Instance); + private readonly Dictionary> _stages = new(ReferenceEqualityComparer.Instance); + private readonly HashSet _promoted = new(ReferenceEqualityComparer.Instance); + private readonly Queue _queue = new(); + private readonly List _trackers = []; + private bool _draining; + private bool _disposed; + + /// Registers a context-lifetime observer, retained across transaction-state cleanup. + public void RegisterTracker(Action observeTrackedSources) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(observeTrackedSources); + _trackers.Add(observeTrackedSources); + } + + /// Reobserves already tracked sources at owner entry when a DI scope is reused. + public void RefreshTrackedSources() + { + ObjectDisposedException.ThrowIf(_disposed, this); + foreach (var observe in _trackers) observe(); + } + + /// Retains even an empty source so later uncollected raises are discarded at termination. + public void Observe(IHasDomainEvents source) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(source); + _sources.Add(source); + } + + /// + /// Snapshots only sources this context is actually about to save. Original event lists + /// stay intact until owner cleanup; failed saves can retry the same facts. + /// + /// + /// Event-bearing saves require an enrolled writable producer invocation, even when + /// SQL authority has already been announced. Background/admin/seed writers use the + /// shared execution coordinator; a raw transaction alone cannot own completion. + /// Event-free saves do not acquire producer authority through this collector. + /// + public void Stage(object context, IEnumerable savingSources) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(savingSources); + if (_draining) + { + producer.Poison(); + throw new InvalidOperationException("Domain saves cannot run during domain dispatch."); + } + + // EF can fail in another SavingChanges interceptor before its failure callback. + // A subsequent attempt replaces that unconfirmed snapshot, never promotes it. + _stages.Remove(context); + + var pending = new List(); + ProducerExecution.Origin? origin = null; + foreach (var source in savingSources) + { + Observe(source); + foreach (var domainEvent in source.DomainEvents) + { + if (_promoted.Contains(domainEvent)) continue; + origin ??= producer.CaptureOrigin(); + pending.Add(new PendingEvent(domainEvent, origin)); + } + } + + _stages.Add(context, pending); + } + + /// Promotes only a successful, nonzero save; any post-save contract fault poisons the unit. + public void Promote(object context, int savedCount) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(context); + if (!_stages.Remove(context, out var pending) || savedCount <= 0) return; + + try + { + if (_draining) + { + throw new InvalidOperationException("Domain dispatch cannot recursively promote events."); + } + + foreach (var item in pending) + { + producer.RequireOrigin(item.Origin); + if (_promoted.Contains(item.Event)) continue; + if (_promoted.Count == MaxEventsPerTransaction) + { + throw new InvalidOperationException("A transaction cannot produce more than 256 domain events."); + } + + producer.RecordProducedWork(item.Origin); + _promoted.Add(item.Event); + _queue.Enqueue(item); + } + } + catch + { + // Persistence already happened. Even a caller swallowing this exception + // must not commit business state whose fact could not be admitted. + producer.Poison(); + throw; + } + } + + /// Failed/canceled persistence discards only its stage, leaving source facts available to retry. + public void Abort(object context) + { + ArgumentNullException.ThrowIfNull(context); + _stages.Remove(context); + } + + /// The sole sequential notification dispatch site, called by the physical owner before audit. + public async Task DrainAsync(ProducerExecution.Invocation owner, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_disposed, this); + producer.RequireOwner(owner); + if (_draining) + { + producer.Poison(); + throw new InvalidOperationException("Domain drain cannot be reentered."); + } + + _draining = true; + try + { + while (_queue.TryDequeue(out var pending)) + { + cancellationToken.ThrowIfCancellationRequested(); + using var dispatch = producer.EnterDomainDispatch(pending.Origin, pending.Event.EventId); + await publisher.Publish(pending.Event, cancellationToken); + } + } + catch + { + producer.Poison(); + throw; + } + finally + { + _draining = false; + } + } + + /// Owner-terminal cleanup; joiners never invoke it. Safe to repeat at scoped disposal. + public void Clear() + { + foreach (var source in _sources) source.ClearDomainEvents(); + _sources.Clear(); + _stages.Clear(); + _queue.Clear(); + _promoted.Clear(); + _draining = false; + } + + /// + public void Dispose() + { + if (_disposed) return; + Clear(); + _trackers.Clear(); + _disposed = true; + } + + private sealed record PendingEvent(IDomainEvent Event, ProducerExecution.Origin Origin); +} diff --git a/backend/src/LearnStack.Application/Messaging/ProducerExecution.cs b/backend/src/LearnStack.Application/Messaging/ProducerExecution.cs new file mode 100644 index 00000000..3f69e420 --- /dev/null +++ b/backend/src/LearnStack.Application/Messaging/ProducerExecution.cs @@ -0,0 +1,294 @@ +using System.Diagnostics; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Messaging; +using LearnStack.SharedKernel.Persistence; + +namespace LearnStack.Application.Messaging; + +/// +/// Scoped producer ancestry and sticky successful-production receipts (ADR-0057). +/// One logical execution uses a scope; nested invocations run sequentially. +/// +public sealed class ProducerExecution(IUnitOfWork unitOfWork) +{ + private Invocation? _current; + private DomainDispatch? _dispatch; + + /// Whether an invocation is open, independently of tenant admission. + public bool HasInvocation => _current is not null; + + /// + /// Enrolls after announcement. An unresolved, event-free invocation is legal; + /// actual production must pass . + /// + internal Invocation BeginInvocation(bool isTransactionOwner, UserId? causalActor = null) + { + if (_dispatch is not null) + { + Poison(); + throw new InvalidOperationException("Domain translation cannot open another producer invocation."); + } + + if (isTransactionOwner && _current is not null) + { + throw new InvalidOperationException("A producer owner is already active in this scope."); + } + + var announcement = unitOfWork.GetTenantContextAnnouncement(unitOfWork.Transaction); + // An orphan UoW joiner is not a producer owner. It can finish event-free work, + // but cannot enlist production on a raw transaction nobody will complete here. + var hasOwner = isTransactionOwner + || (_current is { HasOwner: true, Announcement: not null } parent + && parent.Announcement == announcement); + // A retained invocation cannot lend ownership to a later raw transaction, + // even when Npgsql reuses the transaction object and the tenant is unchanged. + Activity? ownedActivity = null; + if (Activity.Current is not { IdFormat: ActivityIdFormat.W3C, Id: not null }) + { + // Synchronous in the caller's execution context. ActivitySource alone returns + // null when the Seeder has no telemetry listener. + ownedActivity = new Activity("learnstack.producer") + .SetIdFormat(ActivityIdFormat.W3C).Start(); + } + + var invocation = new Invocation(this, _current, announcement, hasOwner, isTransactionOwner, ownedActivity, + _current is null ? causalActor : _current.CausalActor); + _current = invocation; + return invocation; + } + + /// + /// Returns live authority for the enrolled, writable, unpoisoned frame only. + /// Retained snapshots and reused transaction objects cannot authorize a new unit. + /// + public TenantContextAnnouncement RequireAnnouncement() + { + var current = _current; + var live = unitOfWork.GetTenantContextAnnouncement(unitOfWork.Transaction); + if (current is null || !current.HasOwner || current.Announcement is null + || live is null || live != current.Announcement + || !unitOfWork.HasActiveTransaction || unitOfWork.Mode != TransactionMode.ReadWrite + || unitOfWork.IsRollbackOnly) + { + throw new InvalidOperationException( + "Production requires an enrolled writable frame with its live tenant announcement."); + } + + return live; + } + + /// Refuses a store paired with another unit, even if announcements match. + public TenantContextAnnouncement RequireAnnouncement(IUnitOfWork expectedUnit) + { + if (!ReferenceEquals(unitOfWork, expectedUnit)) + { + throw new InvalidOperationException("The outbox must share its producer's unit of work."); + } + + return RequireAnnouncement(); + } + + /// + /// Builds dispatch metadata from the admitted frame, never producer arguments. + /// Organization authority comes from the SQL announcement even for a tenant-wide fact. + /// + public IntegrationEventEnvelope CreateEnvelope(IIntegrationEvent integrationEvent) + { + ArgumentNullException.ThrowIfNull(integrationEvent); + var announcement = RequireAnnouncement(); + if (integrationEvent.TenantId == Guid.Empty + || integrationEvent.TenantId == TenantId.PlatformSentinel.Value + || integrationEvent.TenantId != announcement.TenantId.Value) + { + throw new InvalidOperationException("The event tenant must match the live announcement."); + } + + var traceparent = _dispatch?.Origin.Traceparent ?? Activity.Current?.Id; + if (traceparent is null || !ActivityContext.TryParse(traceparent, null, out _)) + { + throw new InvalidOperationException("An enrolled producer requires a W3C Activity."); + } + + return new IntegrationEventEnvelope(integrationEvent, traceparent, + announcement.OrganizationId?.Value, CausationId: _dispatch?.EventId, + ActorUserId: _dispatch is null ? _current!.CausalActor : _dispatch.Origin.CausalActor); + } + + /// Captures successful-save attribution without retaining ambient Activity state. + public Origin CaptureOrigin() + { + var announcement = RequireAnnouncement(); + if (_dispatch is not null) + { + Poison(); + throw new InvalidOperationException("Domain translation cannot produce more domain events."); + } + + if (Activity.Current is not { IdFormat: ActivityIdFormat.W3C, Id: { } traceparent }) + { + throw new InvalidOperationException("An enrolled producer requires a W3C Activity."); + } + + return new Origin(this, _current!, announcement, traceparent, _current!.CausalActor); + } + + /// Validates an origin against this physical transaction and its current owner. + internal void RequireOrigin(Origin origin) + { + ArgumentNullException.ThrowIfNull(origin); + var live = RequireAnnouncement(); + if (!ReferenceEquals(origin.Execution, this) || live != origin.Announcement + || !ReferenceEquals(OwnerOf(_current!), OwnerOf(origin.Invocation))) + { + throw new InvalidOperationException("The domain event origin no longer belongs to this transaction."); + } + } + + internal void RequireOwner(Invocation owner) + { + if (!ReferenceEquals(_current, owner) || !owner.IsTransactionOwner) + { + throw new InvalidOperationException("Only the active physical transaction owner can drain domain events."); + } + + EnsureCurrentInvocation(); + } + + internal void Poison() => unitOfWork.MarkRollbackOnly(); + + private static Invocation OwnerOf(Invocation invocation) + { + while (invocation.Parent is not null) invocation = invocation.Parent; + return invocation; + } + + /// + /// Installs dispatch metadata synchronously; does not reopen the originating child + /// or alter AsyncLocal/Activity state across an asynchronous helper boundary. + /// + public IDisposable EnterDomainDispatch(Origin origin, Guid domainEventId) + { + RequireOrigin(origin); + RequireOwner(_current!); + if (_dispatch is not null || domainEventId == Guid.Empty) + { + Poison(); + throw new InvalidOperationException("Domain dispatch must be sequential and carry an event identity."); + } + + _dispatch = new DomainDispatch(origin, domainEventId); + return new DispatchScope(this, _dispatch); + } + + /// Step 7 checks enrollment and authority without requiring a produced event. + internal void EnsureCurrentInvocation() + { + if (_current is null || !unitOfWork.HasActiveTransaction + || _current.Announcement != unitOfWork.GetTenantContextAnnouncement(unitOfWork.Transaction)) + { + throw new InvalidOperationException("The producer invocation no longer matches its transaction."); + } + } + + /// + /// Called after successful outbox INSERT or successful-save promotion only. + /// Receipts survive child disposal and queue drain; they are not pending counts. + /// + public void RecordProducedWork() + { + _ = RequireAnnouncement(); + RecordReceipt(_dispatch?.Origin.Invocation ?? _current!); + } + + /// Records a saved fact against its retained invocation, including disposed children. + public void RecordProducedWork(Origin origin) + { + RequireOrigin(origin); + RecordReceipt(origin.Invocation); + } + + private static void RecordReceipt(Invocation origin) + { + for (var invocation = origin; invocation is not null; invocation = invocation.Parent) + { + invocation.HasProducedWork = true; + } + } + + /// Opaque attribution captured at the saving boundary. + public sealed class Origin + { + internal Origin(ProducerExecution execution, Invocation invocation, + TenantContextAnnouncement announcement, string traceparent, UserId? causalActor) + { + Execution = execution; + Invocation = invocation; + Announcement = announcement; + Traceparent = traceparent; + CausalActor = causalActor; + } + + internal ProducerExecution Execution { get; } + internal Invocation Invocation { get; } + internal TenantContextAnnouncement Announcement { get; } + internal string Traceparent { get; } + internal UserId? CausalActor { get; } + } + + private sealed record DomainDispatch(Origin Origin, Guid EventId); + + // The collector owns the lexical scope token. ProducerExecution retains only + // immutable metadata, so it does not share disposal ownership with the caller. + private sealed class DispatchScope(ProducerExecution execution, DomainDispatch dispatch) : IDisposable + { + public void Dispose() + { + if (ReferenceEquals(execution._dispatch, dispatch)) execution._dispatch = null; + } + } + + /// One invocation, retained by its coordinator through owner completion. + public sealed class Invocation : IDisposable + { + private readonly ProducerExecution _execution; + private readonly Activity? _ownedActivity; + private bool _disposed; + + internal Invocation( + ProducerExecution execution, Invocation? parent, + TenantContextAnnouncement? announcement, bool hasOwner, bool isTransactionOwner, + Activity? ownedActivity, UserId? causalActor) + { + _execution = execution; + Parent = parent; + Announcement = announcement; + HasOwner = hasOwner; + IsTransactionOwner = isTransactionOwner; + _ownedActivity = ownedActivity; + CausalActor = causalActor; + } + + internal Invocation? Parent { get; } + internal TenantContextAnnouncement? Announcement { get; } + internal bool HasOwner { get; } + internal bool IsTransactionOwner { get; } + internal UserId? CausalActor { get; } + + /// Successful production by this invocation or any descendant. + public bool HasProducedWork { get; internal set; } + + /// + public void Dispose() + { + if (_disposed) return; + if (!ReferenceEquals(_execution._current, this)) + { + throw new InvalidOperationException("Producer invocations must close in LIFO order."); + } + + _disposed = true; + _execution._current = Parent; + _ownedActivity?.Dispose(); + } + } +} diff --git a/backend/src/LearnStack.Application/Persistence/TransactionExecutionCoordinator.cs b/backend/src/LearnStack.Application/Persistence/TransactionExecutionCoordinator.cs new file mode 100644 index 00000000..ff716e56 --- /dev/null +++ b/backend/src/LearnStack.Application/Persistence/TransactionExecutionCoordinator.cs @@ -0,0 +1,163 @@ +using LearnStack.Application.Messaging; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.Extensions.Logging; + +namespace LearnStack.Application.Persistence; + +/// +/// Shared transaction entry and owner completion for requests and direct work. +/// The outer audit coordinator retains classification/reconciliation ownership. +/// +public sealed class TransactionExecutionCoordinator( + IUnitOfWork unitOfWork, + ITenantContext tenantContext, + IAuditStore auditStore, + IAuditStateCapture capture, + ProducerExecution producer, + DomainEventCollector collector, + ILogger logger) +{ + /// + /// Announces before dispatch, retains producer ancestry through completion, + /// and reports the actual durability boundary (ADR-0057). + /// + public async Task ExecuteAsync( + IProvisionsTenant? provisioning, + TransactionMode mode, + string operationName, + Func> next, + CancellationToken cancellationToken = default) + where TResponse : IResultBase + { + ArgumentNullException.ThrowIfNull(next); + ArgumentException.ThrowIfNullOrWhiteSpace(operationName); + var openingOwner = !unitOfWork.HasActiveTransaction; + IUnitOfWorkScope scope; + try + { + if (openingOwner) collector.RefreshTrackedSources(); + scope = await unitOfWork.BeginTransactionAsync(mode, cancellationToken); + } + catch + { + if (openingOwner) collector.Clear(); + throw; + } + + await using var transactionScope = scope; + var committing = false; + + try + { + // Provisioning changes SQL authority only; the ambient accessor stays + // unresolved. A resolved caller cannot announce another tenant here. + if (!tenantContext.IsResolved && provisioning is not null) + { + await unitOfWork.SetProvisioningTenantContextAsync( + provisioning.ProvisioningTenantId, cancellationToken); + } + else + { + await unitOfWork.SetTenantContextAsync(tenantContext, cancellationToken); + } + + // Capture causal identity at the trusted entry. Nested invocations inherit it; + // asynchronous work keeps SystemActor effective without impersonating a human. + var causalActor = tenantContext.IsResolved + ? tenantContext.CausalActorUserId + ?? (tenantContext.UserId == UserId.SystemActor ? null : tenantContext.UserId) + : null; + using var invocation = producer.BeginInvocation(scope.IsOwner, causalActor); + var response = await next(); + if (response.IsFailure) + { + // Joiners are not savepoints. Successful production cannot escape + // merely because an ancestor absorbs this refusal. + if (invocation.HasProducedWork) unitOfWork.MarkRollbackOnly(); + await scope.FailAsync(CancellationToken.None); + if (scope.IsOwner) capture.MarkRolledBack(); + return response; + } + + if (scope.IsOwner) + { + // Refuse before MUST composition. Refused commit is known rollback; + // a failed COMMIT acknowledgement has an indeterminate outcome. + if (unitOfWork.IsRollbackOnly + || (unitOfWork.Mode == TransactionMode.ReadOnly && invocation.HasProducedWork)) + { + throw new InvalidOperationException( + "The transaction is rollback-only or contains produced work in read-only mode."); + } + + await collector.DrainAsync(invocation, cancellationToken); + if (invocation.HasProducedWork) _ = producer.RequireAnnouncement(); + await auditStore.WritePendingAsync(unitOfWork, cancellationToken); + } + + committing = true; + try + { + await scope.CompleteAsync(cancellationToken); + } + catch (Exception commitFailure) + { + if (unitOfWork.HasActiveTransaction) + { + // The adapter detaches its transaction before physical COMMIT. + // A live frame therefore refused completion before COMMIT (for + // example, a leaked joiner). Use the guarded rollback path, + // preserving the frame error even if cleanup also fails. + committing = false; + } + else if (scope.IsOwner) + { + if (unitOfWork.IsRollbackOnly) capture.MarkRolledBack(); + else capture.MarkIndeterminate(commitFailure); + } + + throw; + } + + if (scope.IsOwner) capture.MarkCommitted(); + return response; + } + catch when (!committing) + { + unitOfWork.MarkRollbackOnly(); + // Cleanup must neither be abandoned by cancellation nor replace the + // original dispatch/audit failure with a bookkeeping exception. + try + { + await scope.FailAsync(CancellationToken.None); + } + catch (Exception rollbackFailure) + { + LogRollbackFailure(logger, operationName, rollbackFailure); + } + + if (scope.IsOwner && capture.State != AuditIntentState.Indeterminate) + { + capture.MarkRolledBack(); + } + + throw; + } + finally + { + // Announcement/setup failures also reach here. Child completion never + // drains or clears sources belonging to the still-open physical owner. + if (scope.IsOwner) collector.Clear(); + } + } + + private static readonly Action LogRollbackFailure = + LoggerMessage.Define( + LogLevel.Error, + new EventId(1, nameof(LogRollbackFailure)), + "Rolling back the ambient transaction for {RequestName} failed. The original exception is rethrown; this one is recorded here only."); +} diff --git a/backend/src/LearnStack.Application/Pipeline/AuditLogBehavior.cs b/backend/src/LearnStack.Application/Pipeline/AuditLogBehavior.cs index adda388a..93449c04 100644 --- a/backend/src/LearnStack.Application/Pipeline/AuditLogBehavior.cs +++ b/backend/src/LearnStack.Application/Pipeline/AuditLogBehavior.cs @@ -1,436 +1,32 @@ -using System.Runtime.ExceptionServices; -using LearnStack.SharedKernel.Audit; -using LearnStack.SharedKernel.Identifiers; -using LearnStack.SharedKernel.Persistence; +using LearnStack.Application.Audit; using LearnStack.SharedKernel.Results; using LearnStack.SharedKernel.Tenancy; -using LearnStack.SharedKernel.Time; using MediatR; -using Microsoft.Extensions.Logging; namespace LearnStack.Application.Pipeline; /// -/// MediatR pipeline behavior — step 3 of the canonical eight-step order -/// (ADR-0032 -/// § Sub-decision 2). It classifies the request, parks one intent per audited -/// (resource, operation), and reports the outcome. +/// MediatR pipeline step 3: delegates classification, intent attribution, outcomes and +/// outermost reconciliation to the shared . /// /// -/// -/// It writes nothing on the success path. A MUST-class row belongs on the business -/// transaction, immediately before COMMIT, and TransactionBehavior — which -/// owns that boundary — is what flushes it -/// (ADR-0033). -/// What this behavior owns is the classification, the intents, and every path where there -/// is no business transaction to ride: a refusal, an exception, and a SHOULD or MAY row. -/// -/// -/// Two guarantees the Packet 3 shell established and this must not lose: the -/// rethrow goes through , so handlers and the L1 -/// boundary see the original stack; and the wrap order is AuditLog outside TenantContext, -/// Authorization, Transaction, OutboxFlush and the handler, which -/// MediatR_Pipeline_Order_Matches_Canonical_Sequence asserts. Being outside the -/// transaction is what lets the reconcile step run after the commit resolved. -/// -/// -/// It stays in LearnStack.Application. The Packet 3 remark said it would -/// move to LearnStack.Infrastructure.Audit; ADR-0044 § 11 settles the other way, -/// because that move inverts the Application → Infrastructure dependency -/// MediatRPipelineRegistration.CanonicalBehaviorOrder and its architecture test -/// depend on, and that project's csproj forbids it besides. Only the ports it names moved. -/// +/// It remains outside tenant admission, authorization and the transaction, preserving +/// ADR-0032's canonical pipeline order and ADR-0033's post-transaction reconciliation. +/// Direct owners enter the same coordinator before constructing their handlers. /// -public sealed class AuditLogBehavior( - IAuditCatalog catalog, - IAuditConfigService classifier, - IAuditStateCapture capture, - IAuditStore store, - ITenantContext tenantContext, - IClock clock, - IGuidFactory guidFactory, - ILogger> logger) +public sealed class AuditLogBehavior(AuditExecutionCoordinator coordinator) : IPipelineBehavior where TRequest : notnull where TResponse : IResultBase { - public async Task Handle( + public Task Handle( TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(next); - // Unregistered is the rejection, and it happens BEFORE the handler runs: an - // operation nobody classified is a deployment defect, and letting it commit while - // the corpus cannot say whether it should have been audited is the failure the - // fail-closed rule exists to prevent. `Off` is a different answer — registered and - // silent — which is why TryGet and not a count of entries tells them apart. - if (!catalog.TryGet(typeof(TRequest), out var registration)) - { - LogUnclassified(logger, typeof(TRequest).FullName ?? typeof(TRequest).Name, null); - - return Result.FailFor(AuditErrors.UnclassifiedOperation); - } - - var outermost = AuditFrame.Enter(); - - // A frame per invocation, outermost or nested, so each intent belongs to the - // request that declared it and each captured change to the request whose handler - // wrote it (ADR-0044 Amendment 6 §§ 1, 3). - capture.OpenFrame(); - - // Thrown until the handler RETURNS: a behavior, the handler, a cancellation and a - // faulted COMMIT all leave by an exception, and every one of them is a result the - // row must not call a success. - var result = AuditIntentResult.Thrown; - - try - { - await DeclareAsync(request, registration, cancellationToken).ConfigureAwait(false); - - var response = await next().ConfigureAwait(false); - - // Recorded rather than acted on. Every exit reconciles in one place below, so a - // refusal, an exception and a cancellation cannot drift apart — and the - // cancelled COMMIT, which reaches neither the catch nor this line, is covered - // by the same code as the other two (ADR-0033 Amendment 4 § 2). - result = response.IsFailure - ? AuditIntentResult.Refused(OutcomeOf(response.Error!), response.Error!.Message.Key) - : AuditIntentResult.Succeeded; - - return response; - } -#pragma warning disable CA1031 // ADR-0032 § Sub-decision 2 binds the audit-then-rethrow contract here. - // Cancellation is excluded deliberately, and the exclusion no longer costs a row: - // the reconcile moved into the finally, which a cancellation does run. - catch (Exception failure) when (failure is not OperationCanceledException) -#pragma warning restore CA1031 - { - ExceptionDispatchInfo.Capture(failure).Throw(); - throw; // unreachable; the line above is the rethrow. - } - finally - { - // EVERY frame records what its own request returned, on the intents it declared. - // Only the outermost frame used to keep an outcome at all, in a local, so an - // inner refusal an outer handler absorbed was written by the owner as success - // (ADR-0044 Amendment 6 § 3). - capture.CloseFrame(result); - - // Only the OUTERMOST frame reconciles and clears. A joiner that cleared would - // erase the outer request's intents and every snapshot before the owner had - // committed (ADR-0033 Amendment 2 § 2). - if (outermost) - { - try - { - await ReconcileAsync().ConfigureAwait(false); - } - finally - { - // Unconditional. A reconcile that threw used to skip both lines, which - // left the flow believing a frame was still open — so the NEXT request - // in it would never be outermost, never reconcile and never clear — and - // left this request's intents and snapshots in the scope's buffer. - // - // Not independently observable now, and better said than implied: the - // reconcile guards every intent, so it no longer throws, and the two - // cases that pin the cleanup — a store failing before it writes, a - // composer refusal — pass with or without this finally. It stays so the - // cleanup does not rest on the reconcile's own catch staying complete. - AuditFrame.Exit(); - capture.Clear(); - } - } - } - } - - /// - /// Writes whatever the business transaction did not. - /// - /// - /// - /// One place for every exit, because the exits are not distinguishable from inside the - /// pipeline: a refusal returns, an exception throws, and a cancelled COMMIT - /// does neither in a way this behavior's catch can see. What separates them is - /// the state the owning unit-of-work frame recorded, which is a fact about the - /// database, and each intent's own result, which is a fact about its request — - /// holds the rule that combines them. - /// - /// - /// A MUST intent is re-written only when it is not already durable. - /// means the row went with the business - /// write; anything else means it did not, and the attempt is still the record. - /// prefers a duplicate to a loss — the - /// row goes out anyway, and the 23505 that may follow is positive evidence the - /// first one landed. - /// - /// - /// A SHOULD or MAY intent is never in-transaction. - /// WritePendingAsync flushes MUST alone, so these are written here, best effort, - /// with the outcome the request actually had. - /// - /// - /// It never throws, and one row's failure never costs another its attempt. This - /// runs from a finally, where an exception replaces whatever the request was - /// leaving with. Composition and the write are each guarded, per intent: a composer - /// refusal and a store that failed before issuing a statement both escaped the narrower - /// catch this had, replaced a 403 with a 500, and skipped every intent - /// after them. Everything reaching here is already failing, refused or in doubt, so - /// ADR-0033 Amendment 1's rule — a standalone failure changes the response only for an - /// operation that would otherwise have succeeded — leaves the response alone. - /// - /// - /// One alert per failure, from whoever saw it. The store reports a failed write - /// itself (IAuditStore), so that path logs a Warning saying the caller's status - /// stands; a composer refusal and a draft the store refused before writing are programmer - /// errors nothing else reports, and those log Critical here. - /// - /// - private async Task ReconcileAsync() - { - var state = capture.State; - - foreach (var intent in capture.Intents) - { - var durable = intent.OperationClass == OperationClass.Must - && state == AuditIntentState.Committed; - - if (durable) - { - continue; - } - - AuditEntryDraft draft; - -#pragma warning disable CA1031 // A finally that threw would replace the exception or refusal the caller is meant to see. - try - { - // The SAME composer the in-transaction write uses. A private one here is how - // the two diverged: only the happy-path row carried a snapshot, so every - // denied, failed and indeterminate row said nothing about what was attempted - // — which is the class of row ADR-0033 calls the common case it protects. - // - // A FRESH clock reading, not the intent's DeclaredAt: the in-transaction row - // that may already exist under this id carries that, and the composite - // primary key is what makes the pair legal rather than a 23505. - draft = AuditDraftComposer.Reconciled( - intent, capture.ChangesOf(intent), state, clock.UtcNow); - } - catch (Exception refused) - { - // Critical, and swallowed. A composer refusal — two instances of the declared - // aggregate and none designated — is a programmer error that nothing else - // reports, and it must not cost the next intent its attempt nor replace the - // caller's outcome. - LogReconcileRowRefused(logger, intent.Operation, refused); - - continue; - } - - try - { - // The class decides the posture, not the outcome. A MUST row that cannot - // be written is a failure worth shouting about; a SHOULD row that cannot is - // an accepted loss the module's matrix already records. - // CancellationToken.None, and it is the whole point. The paths this - // reconcile exists for are the ones where the request's token is ALREADY - // cancelled — a client that disconnected mid-COMMIT above all — so handing - // that token to the write abandons the row at - // OpenConnectionAsync, before a statement is issued. Measured: one row - // with a live token, zero with a cancelled one, and the cancelled case is - // the only one this code path exists for. - // - // TransactionBehavior applies the same rule one behavior in, passing - // CancellationToken.None to scope.FailAsync: the cleanup must never be - // abandoned by what it is cleaning up after. Unbounded only in the sense - // that this token is — Npgsql's own connection and command timeouts still - // bound the write, so a wedged database cannot hold the request forever. - if (intent.OperationClass == OperationClass.Must) - { - await store.WriteStandaloneAsync(draft, CancellationToken.None).ConfigureAwait(false); - } - else - { - await store.WriteBestEffortAsync(draft, CancellationToken.None).ConfigureAwait(false); - } - } - catch (ArgumentException refused) - { - // A draft the store refused before any write — the platform sentinel — is a - // caller error the store does not report (IAuditStore), so this is its alert. - LogReconcileRowRefused(logger, intent.Operation, refused); - } - catch (Exception lost) - { - // Swallowed, and NOT a second Critical. The store reports every failure of the - // write itself — Critical, counted, the `audit` health check unhealthy - // (IAuditStore) — and best effort logs and drops its own; the Critical this - // used to add was one alert twice (the fifth review of Packet 9). What this - // line adds is the one fact the store cannot know: ADR-0033 Amendment 1 - // changes the response only when the operation would otherwise have - // SUCCEEDED, and everything reaching here is already failing, refused or in - // doubt, so the caller keeps its status. - LogReconcileRowLost(logger, intent.Operation, lost); - } -#pragma warning restore CA1031 - } - } - - /// - /// Parks one intent per audited (resource, operation). - /// - /// - /// Plural, and that is the correction ADR-0033 Amendment 2 § 1 makes: - /// ProvisionTenantCommand writes two aggregate roots on one transaction and the - /// Tenancy matrix classifies both MUST, so a singular reading would simply never write - /// the Organization row it promises. - /// - private async Task DeclareAsync( - TRequest request, AuditRegistration registration, CancellationToken cancellationToken) - { - if (registration.WritesNoRow) - { - return; - } - - // ADR-0044 § 2's four cases, decided HERE because step 3 is the only place all - // four are decidable — and because the store must never resolve a tenant of its - // own. The row's tenant is the one the transaction ANNOUNCES, which is the only - // value its own WITH CHECK accepts. - TenantId? tenantId; - OrganizationId? organizationId; - - if (tenantContext.IsResolved) - { - tenantId = tenantContext.TenantId; - organizationId = tenantContext.OrganizationId; - } - else if (request is IProvisionsTenant provisioning) - { - // The tenant does not exist yet, so the context cannot resolve it — and the - // transaction announces exactly this value through - // SetProvisioningTenantContextAsync. Reading the context instead gives the - // all-zero tenant, and the row is then refused by the policy it was written - // under. Measured: `make seed` failed here with 42501 before this branch - // existed, on the one command ADR-0042 sanctions. - tenantId = provisioning.ProvisioningTenantId; - - // NULL, and not by choice: SetProvisioningTenantContextAsync announces - // app.organization_id as the empty string, so any other value fails the - // org-scoped WITH CHECK. - organizationId = null; - } - else - { - // The fourth case: no row at all. There is no tenant whose admin could read - // it, and ADR-0036 forbids inventing one. - return; - } - - foreach (var entry in registration.Entries) - { - var classification = await classifier - .ClassifyAsync(tenantId, entry, cancellationToken) - .ConfigureAwait(false); - - if (classification == AuditClassification.Off) - { - continue; - } - - capture.DeclareIntent(new AuditIntent( - AuditEntryId.From(guidFactory.NewUuidV7()), - tenantId.Value, - organizationId, - - // Read HERE, with the tenant, and carried to both writers on the intent. The - // in-transaction write had no way to reach them and wrote both columns NULL - // on every successful row (ADR-0044 Amendment 6 § 2). The principal the - // request carried, or null — never a substituted SystemActor. - tenantContext.UserId, - tenantContext.CorrelationId, - entry.ModuleName, - entry.Operation, - entry.OperationType, - // The DECLARED tier, always. An override narrows to Off or leaves the tier - // alone (ADR-0033 Amendment 4 § 1), so a non-Off classification is the - // declared one and the intent and the catalogue cannot disagree. - entry.OperationClass, - entry.EntityType, - clock.UtcNow)); - } + return coordinator.ExecuteAsync( + typeof(TRequest), request as IProvisionsTenant, () => next(), cancellationToken); } - - /// - /// Denied for a refusal the authorization layer would answer 403 to, Failed otherwise. - /// - /// - /// Keyed on the three codes HttpStatusMap answers 403 to, so the row's - /// outcome and the response's status cannot disagree. A validation failure is - /// Failed: it is a malformed request rather than a refused one, and counting it - /// as a denial would bury the probes the denied class exists to surface. - /// - private static AuditOutcome OutcomeOf(Error error) => error.Code switch - { - "forbidden" or "resource_scope_violation" or "feature_disabled" => AuditOutcome.Denied, - _ => AuditOutcome.Failed, - }; - - private static readonly Action LogUnclassified = - LoggerMessage.Define( - LogLevel.Error, - new EventId(1, nameof(LogUnclassified)), - "{RequestType} reached the audit behavior with no catalogue registration and was refused. Every request is registered, silence included: declare it in the module's IAuditCatalogSource, with Off if it audits nothing."); - - private static readonly Action LogReconcileRowLost = - LoggerMessage.Define( - LogLevel.Warning, - new EventId(2, nameof(LogReconcileRowLost)), - "The reconcile row for {Operation} could not be written, and the audit store has reported the failure. The caller keeps whatever status it already had: ADR-0033 Amendment 1 changes the response only for a write that would otherwise have SUCCEEDED, and turning a refusal into a 503 an anonymous caller can provoke tells them more, not less."); - - private static readonly Action LogReconcileRowRefused = - LoggerMessage.Define( - LogLevel.Critical, - new EventId(3, nameof(LogReconcileRowRefused)), - "The reconcile row for {Operation} was refused before any write — a programmer error no audit store reports, such as two instances of the declared aggregate with none designated. The row is not on the record, and the caller keeps whatever status it already had."); -} - -/// -/// Which frame is the outermost one. -/// -/// -/// -/// Non-generic on purpose. A static inside the behavior would be per closed generic, so -/// two different request types in one nested flow would each believe they were outermost -/// — and both would clear the buffer, the second one erasing intents the first had not -/// written yet. -/// -/// -/// rather than the capture itself, because the question is -/// about the async flow rather than about the buffer: the buffer is per DI scope and a -/// handler that sends a second request shares it, which is exactly the case this exists to -/// get right. -/// -/// -internal static class AuditFrame -{ - private static readonly AsyncLocal Active = new(); - - /// Marks a frame open. true when this frame is the outermost. - public static bool Enter() - { - if (Active.Value) - { - return false; - } - - Active.Value = true; - - return true; - } - - /// Closes the outermost frame. - public static void Exit() => Active.Value = false; } diff --git a/backend/src/LearnStack.Application/Pipeline/MediatRPipelineRegistration.cs b/backend/src/LearnStack.Application/Pipeline/MediatRPipelineRegistration.cs index 8198a47c..f13e71d9 100644 --- a/backend/src/LearnStack.Application/Pipeline/MediatRPipelineRegistration.cs +++ b/backend/src/LearnStack.Application/Pipeline/MediatRPipelineRegistration.cs @@ -1,6 +1,10 @@ using FluentValidation; +using LearnStack.Application.Audit; +using LearnStack.Application.Messaging; +using LearnStack.Application.Persistence; using MediatR; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace LearnStack.Application.Pipeline; @@ -47,6 +51,13 @@ public static IServiceCollection AddLearnStackMediatRPipeline( ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(handlerAssemblies); + // One scoped lifecycle for every entry and both API/Seeder compositions. + // Behaviors adapt requests; direct owners reuse these same coordinators. + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + services.TryAddScoped(); + // MediatR 12.x throws if no assemblies are registered for handler // scanning. Fall back to the LearnStack.Application assembly itself — // it carries no handlers in Phase 02a, but the behaviors below register diff --git a/backend/src/LearnStack.Application/Pipeline/OutboxFlushBehavior.cs b/backend/src/LearnStack.Application/Pipeline/OutboxFlushBehavior.cs index e02fb8a7..497dbda5 100644 --- a/backend/src/LearnStack.Application/Pipeline/OutboxFlushBehavior.cs +++ b/backend/src/LearnStack.Application/Pipeline/OutboxFlushBehavior.cs @@ -1,41 +1,28 @@ +using LearnStack.Application.Messaging; using LearnStack.SharedKernel.Results; using MediatR; namespace LearnStack.Application.Pipeline; /// -/// MediatR pipeline behavior — step 7 of the canonical 8-step order -/// (ADR-0032 § Sub-decision 2). Enrols IOutbox messages in the -/// current unit-of-work transaction; the outbox processor publishes them -/// via IEventBus on commit (see -/// 15-event-and-outbox.md). +/// Step 7 retains its canonical position as the producer enrollment/invariant guard. +/// Enqueue inserts immediately; owner completion drains domain events (ADR-0057). +/// This historical behavior name does not imply a buffered flush. /// -/// -/// Phase 02a Packet 3 ships the shell: there is no -/// IOutbox contract yet (it lands in Phase 02b). The shell delegates -/// to the inner pipeline so the order is correct now; Phase 02b lights up -/// the enrolment without changing the eight-step registration. -/// -public sealed class OutboxFlushBehavior +public sealed class OutboxFlushBehavior(ProducerExecution producer) : IPipelineBehavior where TRequest : notnull where TResponse : IResultBase { - public Task Handle( + public async Task Handle( TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(next); - - // TODO(2026-05-21, @platform): Phase 02b — on a success-Result, flush - // IOutbox messages collected during the handler into outbox_messages via - // the unit-of-work seam, so the OutboxProcessor dispatches them through - // IEventBus after commit. The port is the contract; which transport is - // behind it — InProcessEventBus today, the Dapr adapter on its ADR-0035 - // trigger — is a composition-root decision this behavior never sees. - // Per ADR-0006 + ADR-0038 + ADR-0032 § Sub-decision 12. - - return next(); + producer.EnsureCurrentInvocation(); + var response = await next(); + producer.EnsureCurrentInvocation(); + return response; } } diff --git a/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs b/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs index 09e120f2..ff422a32 100644 --- a/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs +++ b/backend/src/LearnStack.Application/Pipeline/TransactionBehavior.cs @@ -1,73 +1,16 @@ -using LearnStack.SharedKernel.Audit; +using LearnStack.Application.Persistence; using LearnStack.SharedKernel.Persistence; using LearnStack.SharedKernel.Results; using LearnStack.SharedKernel.Tenancy; using MediatR; -using Microsoft.Extensions.Logging; namespace LearnStack.Application.Pipeline; /// -/// MediatR pipeline behavior — step 6 of the canonical 8-step order -/// (ADR-0032 § Sub-decision 2). Opens the ambient transaction, issues the Row -/// Level Security session variables after the bounded public read-mode setup, -/// then commits on a -/// success-Result and rolls back on a fail-Result or any exception -/// that bubbles through. +/// Step 6 selects request mode; the shared coordinator owns announcement, +/// producer enrollment and transaction completion for every entry (ADR-0057). /// -/// -/// -/// Through IUnitOfWork, never a DbContext. Per -/// ADR-0040 -/// the unit of work owns one connection per scope and every module -/// DbContext enlists on it. A behavior that reached for a context would -/// have to name a module — the thing this seam exists to avoid — and a context -/// on its own connection never sees the SET LOCAL and reads zero rows. -/// TransactionBehavior_Does_Not_Reference_A_Module_Assembly is the guard. -/// -/// -/// No gate. Everything reaching step 6 needs a transaction, because the -/// requests that must not open one have already short-circuited: a validation -/// failure at step 1, an unresolved tenant at step 4, an authorization denial at -/// step 5. A RequiresTransaction(request) predicate would be a fourth -/// exemption defined nowhere. -/// -/// -/// The commit is outside the catch, deliberately. The filter -/// when (!committing) is what stops the cleanup path from running after a -/// faulted COMMIT. Two things go wrong without it, both measured: the -/// rollback's own complaint replaces the database's exception, so a constraint -/// violation at commit time reaches the client as a bookkeeping error with no -/// inner exception; and because the replacement is not an -/// OperationCanceledException, a client that disconnects mid-commit is -/// audited as a failure, captured by IErrorTrackingProvider and answered -/// 500 instead of 499 — three ADR-0032 behaviours inverted at once. -/// A faulted commit also leaves the outcome genuinely unknown, which is -/// ADR-0033's -/// Indeterminate, not a failure to roll back. -/// -/// -/// The exception path marks the unit; the fail-Result path does not. -/// ADR-0040 § Nesting: an inner Result.Fail that an outer handler -/// deliberately absorbs is not a failure of the unit — the outer handler took -/// responsibility, and its own work still commits. An exception is, so it calls -/// MarkRollbackOnly before failing its frame. -/// -/// -/// The MUST-class audit write is the last statement before COMMIT, on the -/// owning frame only — IAuditStore.WritePendingAsync(unitOfWork, ct), per ADR-0033 — -/// so the state change and its record commit together or neither does. The -/// IAuditStateCapture transitions beside it make the commit the only place -/// durability is claimed. Packet 6 shipped the boundary with the line reserved; -/// Packet 9 wrote it. -/// -/// -public sealed class TransactionBehavior( - IUnitOfWork unitOfWork, - ITenantContext tenantContext, - IAuditStore auditStore, - IAuditStateCapture capture, - ILogger> logger) +public sealed class TransactionBehavior(TransactionExecutionCoordinator coordinator) : IPipelineBehavior where TRequest : notnull where TResponse : IResultBase @@ -77,216 +20,15 @@ public sealed class TransactionBehavior( ? TransactionMode.ReadOnly : TransactionMode.ReadWrite; - public async Task Handle( + /// + public Task Handle( TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(next); - - // Through the handle, not the frame-blind CommitAsync: the handle knows - // its own depth, so a nested frame nobody resolved is an exception here - // rather than a commit that quietly resolves the wrong frame, writes - // nothing, and returns success. - await using var scope = await unitOfWork.BeginTransactionAsync(Mode, cancellationToken); - - var committing = false; - - try - { - // Before data SQL; ReadOnly setup is the one bounded predecessor under - // ADR-0052. Default writable announcement remains first, and - // inside the try so a failure to issue it fails the frame rather than - // leaving it open for the scope to clean up later. For an unresolved - // context this writes the empty string, and that is correct: the policies - // read NULLIF(current_setting(...), '')::uuid, so an unresolved context is - // a NULL predicate and every tenant-owned table returns zero rows. - // - // One exception, and it is the one write whose tenant no context can carry. - // `tenants` is self-keyed and its policy is WITH CHECK (id = app.tenant_id), - // so creating a tenant means announcing the tenant being created — an id - // that names nothing resolvable, because it does not exist yet. Measured - // against the shipped policy on a throwaway container: unset and empty-string - // both fail 42501 — the empty-string half is the one a case pins, in - // A_request_that_does_not_provision_still_fails_closed_when_unresolved, since - // that is the state this pipeline can actually produce — and - // the new tenant's own id lets the whole provisioning sequence commit. - // - // The !IsResolved term is the load-bearing half, not a defensive one. A - // caller already authenticated for tenant A who sends a provisioning request - // naming tenant B falls through to the ordinary path, the transaction is - // announced with A, and B's insert is refused by the policy. The confused - // deputy is closed by the database rather than by a check somebody has to - // remember to write. - // - // Announced ONCE either way. A handler announcing a second time would leave - // a window inside this transaction where app.tenant_id is the empty string — - // every statement in it silently fail-closed — and would hand every handler - // in the solution the ability to move the ambient tenant. This stays the - // only caller, which is what keeps ADR-0040's setter set closed — at eight, since - // its Amendment 7. - // - // WRITE-ONLY, and the asymmetry is the reason. This announces the tenant to - // PostgreSQL; it does not touch ITenantContextAccessor, which is what the EF - // query filters read. So inside a provisioning transaction the policies see - // the new tenant and the filters see the all-zero one, and any EF READ here - // returns zero rows — silently, the way a context on its own connection would. - // Nothing reads today: ADR-0042 enumerates the operation as three writes, and - // inserts are not filtered. A read added here needs the accessor set too, - // which would make this a fifth writer of a member ADR-0036 Amendment 2 closes - // at four — so it is a decision, not an edit. - if (!tenantContext.IsResolved && request is IProvisionsTenant provisioning) - { - await unitOfWork.SetProvisioningTenantContextAsync( - provisioning.ProvisioningTenantId, cancellationToken); - } - else - { - await unitOfWork.SetTenantContextAsync(tenantContext, cancellationToken); - } - - var response = await next(); - - if (response.IsFailure) - { - // A business-rule failure is not an exception (ADR-0032 - // § Sub-decision 4), and it still must not commit: the handler - // may have written before deciding it could not finish. It does - // not mark the unit — see the class remarks. - await scope.FailAsync(CancellationToken.None); - - // The rows go with it, and AuditLogBehavior's reconcile writes the - // attempt standalone — a `denied` row is what makes a probe visible, and - // it cannot ride a transaction that is being discarded. - if (scope.IsOwner) - { - capture.MarkRolledBack(); - } - - return response; - } - - // The MUST-class write, immediately before the commit and ON THE OWNING FRAME - // ONLY. A joiner's CompleteAsync commits nothing, so a joiner writing here - // would insert a row and then report a durability it does not have - // (ADR-0044 § 4). The owner flushes every intent in the scope, not only its - // own. - // - // It THROWS on failure, which reaches the catch below, rolls the business - // write back and answers 503 audit_unavailable. That is what ADR-0033 means by - // fail-closed: the state change and the record of it commit together or - // neither does. - if (scope.IsOwner) - { - await auditStore.WritePendingAsync(unitOfWork, cancellationToken); - } - - committing = true; - - try - { - await scope.CompleteAsync(cancellationToken); - } - catch (Exception commitFailure) - { - // The COMMIT faulted, so the rows' fate is genuinely unknown — the server - // may have applied it and lost the acknowledgement. Indeterminate prefers - // a duplicate to a loss: the reconcile writes each row again with a fresh - // timestamp, and the 23505 that may follow is positive evidence the first - // one landed. - // - // A CANCELLATION is this case, not a separate one. ADR-0032 requires the - // OperationCanceledException to leave with its type intact, so the mark - // happens here rather than in a catch filter AuditLogBehavior would have - // to widen — and the exception is rethrown untouched - // (ADR-0033 Amendment 4 § 2). Before this, a client disconnecting - // mid-COMMIT skipped the reconcile entirely and dropped a MUST-class row - // for an operation that may well have committed. - if (scope.IsOwner) - { - // REFUSED is not FAULTED, and the unit is the only thing that knows - // which. CompleteAsync throws the same way for both — but a unit an - // inner frame marked rollback-only is rolled back for real before it - // throws, so the outcome is known with certainty and nothing - // committed. Labelling that Indeterminate would put a permanent row on - // an append-only table saying the COMMIT may have landed, and would - // tell the reconcile that a 23505 on its re-write is positive evidence - // it did. - if (unitOfWork.IsRollbackOnly) - { - capture.MarkRolledBack(); - } - else - { - capture.MarkIndeterminate(commitFailure); - } - } - - throw; - } - - if (scope.IsOwner) - { - // The only place durability is claimed, and only the owning frame may say - // it: CommitAsync returned. - capture.MarkCommitted(); - } - - return response; - } - catch when (!committing) - { - // An exception marks the unit, so an outer frame that absorbs it - // cannot commit a partial one (ADR-0040 § Nesting). - unitOfWork.MarkRollbackOnly(); - - // CancellationToken.None: the rollback is the cleanup path, and a - // cancelled rollback leaves the transaction open on a connection - // about to go back to the pool. - // - // Best-effort, for the same reason the commit sits outside this catch: - // the cleanup must never outrank what it is cleaning up after. A - // broken connection disposes the NpgsqlTransaction with it, so - // FailAsync throws ObjectDisposedException — measured, by terminating - // the backend mid-handler — and an unguarded await would hand the - // caller, the audit intent and the error tracker that bookkeeping - // exception with the handler's own nowhere in sight. During a database - // failover that is every in-flight request at once. Nothing is - // stranded by swallowing it: RollbackCoreAsync clears the transaction - // and the depth before it throws, and DisposeAsync still closes the - // connection in its finally. - try - { - await scope.FailAsync(CancellationToken.None); - } - catch (Exception rollbackFailure) - { - LogRollbackFailure(logger, typeof(TRequest).Name, rollbackFailure); - } - - // Rolled back, and the intents survive it: the capture lives in the DI scope - // rather than in the transaction, which is exactly what lets the reconcile see - // that the rows it wrote are gone. Not marked when the commit was already in - // doubt — Indeterminate is the stronger statement and this catch runs for the - // failures BEFORE the commit. - if (scope.IsOwner && capture.State != AuditIntentState.Indeterminate) - { - capture.MarkRolledBack(); - } - - // Rethrown, not swallowed: AuditLogBehavior — three behaviors out, - // at step 3 — catches it, audits the failure and rethrows through - // ExceptionDispatchInfo, and the L1 IExceptionHandler turns it into - // Problem Details. - throw; - } + return coordinator.ExecuteAsync( + request as IProvisionsTenant, Mode, typeof(TRequest).Name, + () => next(), cancellationToken); } - - // LoggerMessage source-generated delegate (CA1848), matching the house style - // in AuditLogBehavior and LoggingBehavior. - private static readonly Action LogRollbackFailure = - LoggerMessage.Define( - LogLevel.Error, - new EventId(1, nameof(LogRollbackFailure)), - "Rolling back the ambient transaction for {RequestName} failed. The original exception is rethrown; this one is recorded here only."); } diff --git a/backend/src/LearnStack.Infrastructure.Audit/AuditStateCapture.cs b/backend/src/LearnStack.Infrastructure.Audit/AuditStateCapture.cs index aee94e70..ff343881 100644 --- a/backend/src/LearnStack.Infrastructure.Audit/AuditStateCapture.cs +++ b/backend/src/LearnStack.Infrastructure.Audit/AuditStateCapture.cs @@ -9,7 +9,7 @@ namespace LearnStack.Infrastructure.Audit; /// /// /// -/// Scoped, and the lifetime is the request rather than the transaction. That is +/// Scoped, and the lifetime is the execution rather than the transaction. That is /// what makes a value someone sets and never one this type /// infers: a rollback leaves every field here intact, so a buffer that guessed its own /// state from what it held would report durability for rows the database discarded @@ -17,11 +17,12 @@ namespace LearnStack.Infrastructure.Audit; /// Amendment 2 § 2). /// /// -/// Not thread-safe, deliberately. One DI scope is one request and one logical +/// Not thread-safe, deliberately. One DI scope is one execution and one logical /// thread of execution; a lock here would buy nothing and would suggest a concurrency /// this object is not designed for. Two requests get two instances. Nested requests share /// this one and run one inside the other, which is what makes a stack of frames a correct -/// model of them. +/// model of them. The common audit coordinator also uses this stack to determine which +/// invocation owns reconciliation; no async-flow state is shared across captures. /// /// /// It lives in LearnStack.Infrastructure.Audit rather than in the Audit module, @@ -65,6 +66,9 @@ public sealed class AuditStateCapture : IAuditStateCapture, IAuditSubject /// public AuditIntentState State { get; private set; } = AuditIntentState.None; + /// + public bool HasOpenFrame => _openFrames.Count != 0; + /// /// What faulted the COMMIT, when is /// . @@ -127,7 +131,7 @@ public void CloseFrame(AuditIntentResult result) if (_openFrames.Count == 0) { throw new InvalidOperationException( - "CloseFrame was called with no frame open. Every AuditLogBehavior invocation " + "CloseFrame was called with no frame open. Every audit coordinator invocation " + "opens exactly one frame and closes it in its finally."); } diff --git a/backend/src/LearnStack.Infrastructure/Messaging/DomainEventSaveChangesInterceptor.cs b/backend/src/LearnStack.Infrastructure/Messaging/DomainEventSaveChangesInterceptor.cs new file mode 100644 index 00000000..7b328c6b --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Messaging/DomainEventSaveChangesInterceptor.cs @@ -0,0 +1,123 @@ +using System.Runtime.CompilerServices; +using LearnStack.Application.Messaging; +using LearnStack.SharedKernel.Domain; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; + +namespace LearnStack.Infrastructure.Messaging; + +/// Collects domain facts only from successful saves, while retaining tracked sources for terminal cleanup. +/// +/// Saving a domain fact requires the shared producer execution boundary. Staging refuses +/// event-bearing saves before SQL when a caller opens only a raw announced transaction; +/// the interceptor does not create an owner or dispatch on the caller's behalf. +/// +public sealed class DomainEventSaveChangesInterceptor(DomainEventCollector collector) : SaveChangesInterceptor +{ + private readonly ConditionalWeakTable _attached = new(); + + /// Attaches before a context begins tracking so empty and subsequently detached sources remain observable. + public void Attach(DbContext context) + { + ArgumentNullException.ThrowIfNull(context); + if (_attached.TryGetValue(context, out _)) return; + _attached.Add(context, new object()); + context.ChangeTracker.Tracked += OnTracked; + var reference = new WeakReference(context); + collector.RegisterTracker(() => + { + if (!reference.TryGetTarget(out var trackedContext)) return; + try + { + ObserveTrackedSources(trackedContext); + } + catch (ObjectDisposedException) + { + // Explicitly disposed contexts can outlive their transaction in a + // reused scope, but have no tracker to re-register for the next owner. + } + }); + ObserveTrackedSources(context); + } + + private void OnTracked(object? sender, EntityTrackedEventArgs args) + { + if (args.Entry.Entity is IHasDomainEvents source) collector.Observe(source); + } + + private void ObserveTrackedSources(DbContext context) + { + foreach (var entry in context.ChangeTracker.Entries()) + { + if (entry.Entity is IHasDomainEvents source) collector.Observe(source); + } + } + + private void Stage(DbContext? context, bool suppressed) + { + if (context is null) return; + Attach(context); + // SavingChanges precedes EF's automatic detection. Snapshot only actual changed + // entries; retained detached/unchanged sources must not hitchhike on this save. + context.ChangeTracker.DetectChanges(); + ObserveTrackedSources(context); + if (suppressed) + { + collector.Abort(context); + return; + } + + collector.Stage(context, context.ChangeTracker.Entries() + .Where(entry => entry.State is EntityState.Added or EntityState.Modified or EntityState.Deleted) + .Select(entry => entry.Entity).OfType()); + } + + public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) + { + Stage(eventData.Context, result.HasResult); + return result; + } + + public override ValueTask> SavingChangesAsync( + DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) + { + Stage(eventData.Context, result.HasResult); + return ValueTask.FromResult(result); + } + + public override int SavedChanges(SaveChangesCompletedEventData eventData, int result) + { + if (eventData.Context is { } context) collector.Promote(context, result); + return result; + } + + public override ValueTask SavedChangesAsync( + SaveChangesCompletedEventData eventData, int result, CancellationToken cancellationToken = default) + { + if (eventData.Context is { } context) collector.Promote(context, result); + return ValueTask.FromResult(result); + } + + public override void SaveChangesFailed(DbContextErrorEventData eventData) + { + if (eventData.Context is { } context) collector.Abort(context); + } + + public override Task SaveChangesFailedAsync(DbContextErrorEventData eventData, CancellationToken cancellationToken = default) + { + if (eventData.Context is { } context) collector.Abort(context); + return Task.CompletedTask; + } + + public override void SaveChangesCanceled(DbContextEventData eventData) + { + if (eventData.Context is { } context) collector.Abort(context); + } + + public override Task SaveChangesCanceledAsync(DbContextEventData eventData, CancellationToken cancellationToken = default) + { + if (eventData.Context is { } context) collector.Abort(context); + return Task.CompletedTask; + } +} diff --git a/backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs b/backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs index 461e9b81..aed831dc 100644 --- a/backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs +++ b/backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs @@ -10,8 +10,8 @@ namespace LearnStack.Infrastructure.Messaging; /// The process-local transport. /// -/// It preserves the durable transport's handler contract, tenant restoration, -/// per-partition ordering and per-subscription failure isolation. Each +/// It supplies the shared handler contract, ambient tenant restoration, +/// process-local per-key invocation serialization and subscription failure isolation. Each /// subscription is constructed once in its own async scope. Dapr becomes the /// adapter when ADR-0035's multi-process/replay trigger is met. /// diff --git a/backend/src/LearnStack.Infrastructure/Messaging/OutboxEventContract.cs b/backend/src/LearnStack.Infrastructure/Messaging/OutboxEventContract.cs new file mode 100644 index 00000000..966373c8 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Messaging/OutboxEventContract.cs @@ -0,0 +1,199 @@ +using System.Collections; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Messaging; + +namespace LearnStack.Infrastructure.Messaging; + +/// ADR-0057's closed payload shape and explicit identifier partition policy. +internal static partial class OutboxEventContract +{ + internal static SerializedEvent Serialize(IIntegrationEvent integrationEvent) + { + ArgumentNullException.ThrowIfNull(integrationEvent); + if (integrationEvent is not IntegrationEventBase fact) + { + throw Invalid("Integration events must inherit IntegrationEventBase."); + } + + var type = fact.GetType(); + ValidateShape(type); + var source = GetPartitionSource(type); + var sourceId = ReadIdentifier(source.GetValue(fact)); + var topic = fact.Topic; + var partitionKey = fact.PartitionKey; + if (topic is null || !TopicPattern().IsMatch(topic)) + { + throw Invalid("The topic must follow the learnstack.module.aggregate convention."); + } + + if (sourceId == Guid.Empty + || !string.Equals(partitionKey, sourceId.ToString("D"), StringComparison.Ordinal)) + { + throw Invalid("The partition key must equal its source identifier's canonical lowercase D GUID."); + } + + var eventId = fact.EventId; + var tenantId = fact.TenantId; + var occurredAt = fact.OccurredAt; + if (eventId == Guid.Empty || occurredAt == default) + { + throw Invalid("An integration event requires an event identity and occurrence timestamp."); + } + + var payload = fact.ToPayloadJson(); + EnsurePayloadFits(payload); + + // Getters are executable code. Compare the bytes we will persist to the captured + // values instead of trusting that a computed property returned the same value twice. + using var document = JsonDocument.Parse(payload); + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object + || root.GetProperty(nameof(fact.EventId)).GetGuid() != eventId + || root.GetProperty(nameof(fact.TenantId)).GetGuid() != tenantId + || root.GetProperty(nameof(fact.OccurredAt)).GetDateTimeOffset() != occurredAt + || root.GetProperty(nameof(fact.Topic)).GetString() != topic + || root.GetProperty(nameof(fact.PartitionKey)).GetString() != partitionKey + || root.GetProperty(source.Name).GetGuid() != sourceId + || fact.EventId != eventId || fact.TenantId != tenantId || fact.OccurredAt != occurredAt) + { + throw Invalid("An integration event must serialize the same identity and routing it declares."); + } + + return new SerializedEvent(eventId, tenantId, occurredAt, topic, partitionKey, payload, + type.AssemblyQualifiedName + ?? throw Invalid("An integration event requires an assembly-qualified type name.")); + } + + internal static void EnsurePayloadFits(string payload) + { + if (Encoding.UTF8.GetByteCount(payload) > OutboxPayloadLimits.MaxBytes) + { + throw Invalid($"The canonical UTF-8 outbox payload exceeds {OutboxPayloadLimits.MaxBytes} bytes."); + } + } + + private static void ValidateShape(Type type) + { + if (typeof(IEnumerable).IsAssignableFrom(type) + || typeof(IJsonOnSerializing).IsAssignableFrom(type) + || typeof(IJsonOnSerialized).IsAssignableFrom(type) + || typeof(IJsonOnDeserializing).IsAssignableFrom(type) + || typeof(IJsonOnDeserialized).IsAssignableFrom(type)) + { + throw Invalid("Integration events cannot override their object serialization contract."); + } + + var names = new HashSet(StringComparer.Ordinal) + { + nameof(IIntegrationEvent.EventId), nameof(IIntegrationEvent.TenantId), + nameof(IIntegrationEvent.OccurredAt), nameof(IIntegrationEvent.Topic), + nameof(IIntegrationEvent.PartitionKey), + }; + + for (var current = type; current != typeof(IntegrationEventBase); current = current.BaseType!) + { + RejectSerializerAttributes(current); + const BindingFlags flags = BindingFlags.Instance | BindingFlags.Public + | BindingFlags.NonPublic | BindingFlags.DeclaredOnly; + foreach (var member in current.GetMembers(flags)) + { + RejectSerializerAttributes(member); + if (member is FieldInfo field && !field.IsDefined(typeof(CompilerGeneratedAttribute))) + { + throw Invalid("Integration event payloads use identifier properties, not instance fields."); + } + + if (member is not PropertyInfo property || property.GetMethod?.IsPublic != true) + { + continue; + } + + if (property.GetIndexParameters().Length != 0) + { + throw Invalid("Integration event payloads cannot contain indexers."); + } + + if (IsRoutingOverride(property)) continue; + if (!names.Add(property.Name) || !IsIdentifierType(property.PropertyType)) + { + throw Invalid("Additional integration event payload properties must be GUID identifiers; base members cannot be hidden."); + } + } + } + + var map = type.GetInterfaceMap(typeof(IIntegrationEvent)); + if (map.TargetMethods.Any(method => method.GetBaseDefinition().DeclaringType != typeof(IntegrationEventBase))) + { + throw Invalid("An integration event cannot replace the base event's interface members."); + } + } + + private static bool IsRoutingOverride(PropertyInfo property) => + property.Name is nameof(IIntegrationEvent.Topic) or nameof(IIntegrationEvent.PartitionKey) + && property.GetMethod!.GetBaseDefinition().DeclaringType == typeof(IntegrationEventBase); + + private static void RejectSerializerAttributes(MemberInfo member) + { + if (member.GetCustomAttributesData().Any(attribute => typeof(JsonAttribute).IsAssignableFrom(attribute.AttributeType))) + { + throw Invalid("Integration events cannot declare JSON serializer attributes."); + } + } + + private static PropertyInfo GetPartitionSource(Type type) + { + var partition = type.GetCustomAttribute(inherit: false) + ?? throw Invalid("Every concrete integration event must declare its partition source."); + var source = string.IsNullOrWhiteSpace(partition.SourceProperty) + ? null + : type.GetProperty(partition.SourceProperty, BindingFlags.Instance | BindingFlags.Public); + if (source is null || source.GetMethod?.IsPublic != true + || source.GetIndexParameters().Length != 0 + || !IsIdentifierType(source.PropertyType) + || Nullable.GetUnderlyingType(source.PropertyType) is not null + || source.Name == nameof(IIntegrationEvent.EventId)) + { + throw Invalid("A partition source must name a non-nullable aggregate identifier or explicitly tenant-wide TenantId."); + } + + if ((source.Name == nameof(IIntegrationEvent.TenantId)) != partition.TenantWide) + { + throw Invalid("Partitioning on TenantId requires exactly an explicit tenant-wide declaration."); + } + + return source; + } + + private static bool IsIdentifierType(Type type) + { + type = Nullable.GetUnderlyingType(type) ?? type; + // Deliberately closed. Implementing IStronglyTypedId alone could smuggle a + // custom JSON converter into the supposedly identifier-only payload. + return type == typeof(Guid) || type == typeof(TenantId) + || type == typeof(OrganizationId) || type == typeof(UserId); + } + + private static Guid ReadIdentifier(object? value) => value switch + { + Guid id => id, + IStronglyTypedId id when id.IsInitialized() => id.Value, + _ => throw Invalid("A partition source must contain an initialized identifier."), + }; + + private static ArgumentException Invalid(string message) => new(message); + + // The same core three-segment and Hub-only four-segment grammar enforced by + // Integration_Event_TopicNames_FollowConvention; \z refuses a trailing newline. + [GeneratedRegex(@"\Alearnstack\.([a-z][a-z0-9-]*[a-z0-9]|[a-z])\.([a-z][a-z0-9-]*[a-z0-9]|[a-z])\z|\Alearnstack\.hub\.([a-z][a-z0-9-]*[a-z0-9]|[a-z])\.([a-z][a-z0-9-]*[a-z0-9]|[a-z])\z", RegexOptions.CultureInvariant, 1000)] + private static partial Regex TopicPattern(); + + internal sealed record SerializedEvent( + Guid EventId, Guid TenantId, DateTimeOffset OccurredAt, + string Topic, string PartitionKey, string Payload, string TypeName); +} diff --git a/backend/src/LearnStack.Infrastructure/Messaging/PostgresOutbox.cs b/backend/src/LearnStack.Infrastructure/Messaging/PostgresOutbox.cs new file mode 100644 index 00000000..ed10673c --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Messaging/PostgresOutbox.cs @@ -0,0 +1,78 @@ +using LearnStack.Application.Messaging; +using LearnStack.SharedKernel.Messaging; +using LearnStack.SharedKernel.Persistence; +using Npgsql; +using NpgsqlTypes; + +namespace LearnStack.Infrastructure.Messaging; + +/// Inserts immediately on the admitted producer's ambient transaction. +public sealed class PostgresOutbox(IUnitOfWork unitOfWork, ProducerExecution producer) : IOutbox +{ + private const string InsertSql = + """ + INSERT INTO outbox_messages ( + id, occurred_at, tenant_id, organization_id, correlation_id, causation_id, + actor_user_id, type, topic, partition_key, payload, metadata) + VALUES ( + @id, @occurred_at, @tenant_id, @organization_id, @correlation_id, @causation_id, + @actor_user_id, @type, @topic, @partition_key, @payload, NULL) + """; + + /// + public async Task EnqueueAsync(IIntegrationEvent integrationEvent, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(integrationEvent); + cancellationToken.ThrowIfCancellationRequested(); + // Refuse raw/unannounced/read-only/stale scopes before executing event getters + // or touching Connection (whose getter would otherwise open a connection). + var announcement = producer.RequireAnnouncement(unitOfWork); + var envelope = producer.CreateEnvelope(integrationEvent); + var serialized = OutboxEventContract.Serialize(integrationEvent); + if (serialized.TenantId != announcement.TenantId.Value) + { + throw new ArgumentException("The event tenant must equal the live announcement.", nameof(integrationEvent)); + } + + if (unitOfWork.Transaction is not NpgsqlTransaction transaction + || unitOfWork.Connection is not NpgsqlConnection connection + || !ReferenceEquals(transaction.Connection, connection)) + { + throw new InvalidOperationException("Outbox persistence requires the ambient Npgsql connection and transaction."); + } + + await using var command = new NpgsqlCommand(InsertSql, connection, transaction); + command.Parameters.AddWithValue("id", NpgsqlDbType.Uuid, serialized.EventId); + command.Parameters.AddWithValue("occurred_at", NpgsqlDbType.TimestampTz, serialized.OccurredAt.ToUniversalTime()); + command.Parameters.AddWithValue("tenant_id", NpgsqlDbType.Uuid, serialized.TenantId); + command.Parameters.AddWithValue("organization_id", NpgsqlDbType.Uuid, (object?)envelope.OrganizationId ?? DBNull.Value); + command.Parameters.AddWithValue("correlation_id", NpgsqlDbType.Text, envelope.CorrelationId); + command.Parameters.AddWithValue("causation_id", NpgsqlDbType.Uuid, (object?)envelope.CausationId ?? DBNull.Value); + command.Parameters.AddWithValue("actor_user_id", NpgsqlDbType.Uuid, (object?)envelope.ActorUserId?.Value ?? DBNull.Value); + command.Parameters.AddWithValue("type", NpgsqlDbType.Text, serialized.TypeName); + command.Parameters.AddWithValue("topic", NpgsqlDbType.Text, serialized.Topic); + command.Parameters.AddWithValue("partition_key", NpgsqlDbType.Text, serialized.PartitionKey); + command.Parameters.AddWithValue("payload", NpgsqlDbType.Jsonb, serialized.Payload); + + // Serialization is user code. It cannot replace this transaction or its + // announcement and then borrow the authority checked before serialization. + var live = producer.RequireAnnouncement(unitOfWork); + if (live != announcement || live.TenantId.Value != serialized.TenantId + || !ReferenceEquals(unitOfWork.Transaction, transaction)) + { + throw new InvalidOperationException("The producer announcement changed before outbox INSERT."); + } + + try + { + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + unitOfWork.MarkRollbackOnly(); + throw; + } + + producer.RecordProducedWork(); + } +} diff --git a/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs b/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs index bae24527..6558db3f 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/ModuleDbContextRegistration.cs @@ -1,4 +1,5 @@ using System.Collections.ObjectModel; +using LearnStack.Infrastructure.Messaging; using LearnStack.SharedKernel.Persistence; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; @@ -125,6 +126,7 @@ public static IServiceCollection AddModuleDbContext(this IServiceColle + "SET LOCAL app.tenant_id."); } + var saveInterceptors = provider.GetServices().ToArray(); var options = new DbContextOptionsBuilder() // The connection, not a connection string. contextOwnsConnection // is false by this overload's contract, so disposing the context @@ -152,7 +154,7 @@ public static IServiceCollection AddModuleDbContext(this IServiceColle // this request actually announced a tenant on. .AddInterceptors(new TenantContextGuardInterceptor(unitOfWork)) // And whatever else the composition root registered as an - // ISaveChangesInterceptor — today that is AuditChangeTrackerInterceptor, + // ISaveChangesInterceptor, including audit and domain-event collection, // which has to reach EVERY module's context because the capture is what // gives an audit row its before / after state, and no module may // reference the assembly it lives in (ADR-0044 § 7). @@ -164,7 +166,7 @@ public static IServiceCollection AddModuleDbContext(this IServiceColle // same measurement the UseApplicationServiceProvider comment above // records. A registration that looked wired and was not would produce // audit rows with empty snapshots and no error anywhere. - .AddInterceptors(provider.GetServices()) + .AddInterceptors(saveInterceptors) .Options; // ActivatorUtilities, not Activator: a module context takes its @@ -175,6 +177,11 @@ public static IServiceCollection AddModuleDbContext(this IServiceColle // options, so it would fail to construct any context with a second // parameter, which is now every tenant-scoped one. var context = ActivatorUtilities.CreateInstance(provider, options); + foreach (var interceptor in saveInterceptors.OfType()) + { + interceptor.Attach(context); + } + context.Database.UseTransaction(unitOfWork.Transaction); return context; diff --git a/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs b/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs index 65a2a8e3..370f4650 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/NpgsqlUnitOfWork.cs @@ -70,12 +70,13 @@ public sealed class NpgsqlUnitOfWork( /// route back to depth 0 through a *rollback* sets the sticky mark and is /// therefore already shut; this is the one that is not. /// - private int _generation; + private long _generation; private bool _rollbackOnly; private bool _commitRequested; private bool _tenantContextIssued; + private TenantContextAnnouncement? _tenantContextAnnouncement; private bool _disposed; public DbConnection Connection @@ -134,6 +135,8 @@ public async Task BeginTransactionAsync( if (_transaction is null) { + ClearTenantContextAnnouncement(); + try { _connection ??= _dataSource.CreateConnection(); @@ -147,6 +150,8 @@ public async Task BeginTransactionAsync( ? await _connection.BeginTransactionAsync(System.Data.IsolationLevel.ReadCommitted, cancellationToken) : await _connection.BeginTransactionAsync(cancellationToken); + checked { _generation++; } + if (mode == TransactionMode.ReadOnly) { // The sole permitted predecessor of tenant announcement. @@ -174,8 +179,6 @@ public async Task BeginTransactionAsync( throw; } - _generation++; - // Scoped to this transaction, like the generation above it. The flag // was never cleared, so a unit that committed and then opened a second // transaction carried the first one's request into the second one's @@ -184,13 +187,6 @@ public async Task BeginTransactionAsync( // frame nobody had opened — over whatever exception was already in // flight. _commitRequested = false; - - // Same reasoning, same block. This is the one place that runs exactly once - // per PHYSICAL transaction, so it is where per-transaction state is cleared — - // and nothing clears it on commit, rollback or dispose because those null - // _transaction, after which the reference check below fails for every - // argument. - _tenantContextIssued = false; } return new Frame(this, ++_depth, _generation); @@ -218,6 +214,10 @@ public async Task SetTenantContextAsync( return; } + // Clear before reading or sending a new announcement. A failed or canceled + // attempt must not retain authority from an earlier successful one (ADR-0057). + ClearTenantContextAnnouncement(); + // Written even when the context is unresolved, and written as the empty // string. The policies read NULLIF(current_setting(..., true), '')::uuid, // so '' is NULL is fail-closed — and setting it explicitly is what makes @@ -248,26 +248,31 @@ public async Task SetTenantContextAsync( // paths we happened to enumerate — and it fails the way the other two arms do, // to the empty string, so a context carrying it reads zero rows rather than the // platform's (ADR-0044 Amendment 5 § 1). - var tenant = context.IsResolved - && context.TenantId.IsInitialized() - && context.TenantId.Value != Guid.Empty - && context.TenantId != TenantId.PlatformSentinel - ? context.TenantId.Value.ToString() - : string.Empty; - - var organization = context.IsResolved - && context.OrganizationId is { } scoped + var resolved = context.IsResolved; + var candidateTenant = resolved ? context.TenantId : (TenantId?)null; + var tenantId = candidateTenant is { } candidate + && candidate.IsInitialized() + && candidate.Value != Guid.Empty + && candidate != TenantId.PlatformSentinel + ? candidate + : (TenantId?)null; + var candidateOrganization = resolved ? context.OrganizationId : null; + var organizationId = candidateOrganization is { } scoped && scoped.IsInitialized() && scoped.Value != Guid.Empty - ? scoped.Value.ToString() - : string.Empty; + ? scoped + : (OrganizationId?)null; + var tenant = tenantId is { } normalizedTenant ? normalizedTenant.Value.ToString() : string.Empty; + var organization = organizationId is { } normalizedOrganization + ? normalizedOrganization.Value.ToString() + : string.Empty; // A context that says it is resolved and yields no usable tenant is a // bug in whoever built it. The empty string keeps the request // fail-closed, but silence here is the worst possible diagnostic: every // query returns nothing, attributed to nobody, and the symptom looks // like missing data rather than a broken context. - if (context.IsResolved && tenant.Length == 0) + if (resolved && tenant.Length == 0) { LogResolvedWithoutTenant(_logger, context.GetType().Name, null); } @@ -282,14 +287,10 @@ await ExecuteAsync( // After the round trip, never before: the flag means the announcement actually // reached PostgreSQL, so a setter that threw leaves the transaction unmarked and // the guard refuses the commands that would have run under it. - // - // Not independently observable, and said so rather than implied. Making the - // announcement fail means breaking the connection under it, and this unit's - // disposal then throws on the dead transaction before any assertion is reached — - // a pre-existing shape, not this flag's. What the ordering buys is that a - // half-announced transaction is refused rather than trusted; no test here fails - // if the line moves, and none pretends to. _tenantContextIssued = true; + _tenantContextAnnouncement = tenantId is { } announcedTenant + ? new TenantContextAnnouncement(_generation, announcedTenant, organizationId) + : null; } /// @@ -327,6 +328,8 @@ public async Task SetProvisioningTenantContextAsync( + "mid-request, which is a wider capability than provisioning needs."); } + ClearTenantContextAnnouncement(); + // Value under an IsInitialized() gate, never ToString(): measured on Vogen 7, an // uninitialized id renders as "[UNINITIALIZED]" and reaches PostgreSQL as // '[UNINITIALIZED]'::uuid, which raises 22P02 rather than filtering. Guid.Empty is @@ -358,6 +361,7 @@ await ExecuteAsync( ("tenant", tenantId.Value.ToString())); _tenantContextIssued = true; + _tenantContextAnnouncement = new TenantContextAnnouncement(_generation, tenantId, null); } /// @@ -366,6 +370,16 @@ transaction is not null && ReferenceEquals(transaction, _transaction) && _tenantContextIssued; + /// + public TenantContextAnnouncement? GetTenantContextAnnouncement(DbTransaction? transaction) => + IsTenantContextIssuedOn(transaction) ? _tenantContextAnnouncement : null; + + private void ClearTenantContextAnnouncement() + { + _tenantContextIssued = false; + _tenantContextAnnouncement = null; + } + private static readonly Action LogResolvedWithoutTenant = LoggerMessage.Define( LogLevel.Error, @@ -417,6 +431,7 @@ public async ValueTask DisposeAsync() } _disposed = true; + ClearTenantContextAnnouncement(); // A scope that ended with a live transaction has failed: committing here // would commit work nobody claimed was finished. @@ -449,7 +464,7 @@ public async ValueTask DisposeAsync() // already been told it succeeded. Throwing from disposal is the last // remaining place to say otherwise, and a silent no-op on the success // path is the worse outcome. IUnitOfWorkScope.CompleteAsync catches - // this at the terminal call instead, which is why TransactionBehavior + // this at the terminal call instead, which is why the execution coordinator // uses it. throw new InvalidOperationException( "A commit was requested but the transaction was still open at disposal, so it " @@ -481,6 +496,7 @@ private async Task CommitFrameAsync(CancellationToken cancellationToken) var transaction = _transaction!; _transaction = null; _mode = null; + ClearTenantContextAnnouncement(); try { @@ -501,9 +517,9 @@ private async Task RollbackFrameAsync() if (--_depth > 0) { // A joiner declining its own frame. It does NOT mark the unit: - // ADR-0040 § Nesting reserves that for an exception or an explicit - // MarkRollbackOnly, because an inner Result.Fail the outer handler - // absorbs is the outer handler's to decide about. + // The execution coordinator applies MarkRollbackOnly for exceptions or + // refusals carrying produced work before resolving this frame. Ordinary + // event-free refusals remain absorbable (ADR-0040, ADR-0057). return; } @@ -517,6 +533,7 @@ private async Task RollbackCoreAsync() _transaction = null; _mode = null; _depth = 0; + ClearTenantContextAnnouncement(); if (transaction is null) { @@ -568,7 +585,7 @@ private async Task ExecuteAsync( /// opened after this one is still open would commit nothing and report /// success. /// - private sealed class Frame(NpgsqlUnitOfWork unitOfWork, int depth, int generation) + private sealed class Frame(NpgsqlUnitOfWork unitOfWork, int depth, long generation) : IUnitOfWorkScope { private bool _resolved; diff --git a/backend/src/LearnStack.SharedKernel/Audit/IAuditStateCapture.cs b/backend/src/LearnStack.SharedKernel/Audit/IAuditStateCapture.cs index 0d34b97b..15bed965 100644 --- a/backend/src/LearnStack.SharedKernel/Audit/IAuditStateCapture.cs +++ b/backend/src/LearnStack.SharedKernel/Audit/IAuditStateCapture.cs @@ -2,13 +2,13 @@ namespace LearnStack.SharedKernel.Audit; /// /// The scoped buffer the audit write path composes its rows from: the entity snapshots -/// the interceptor took, the intents the behavior declared, and the one signal that +/// the interceptor took, the intents the execution coordinator declared, and the signal that /// says whether they became durable. /// /// /// -/// Registered scoped — one per request — and cleared once, by the outermost -/// behavior, in its finally. A joiner frame must not clear: it would erase the +/// Registered scoped and cleared once, by the outermost audit execution +/// coordinator, in its finally. A joiner frame must not clear: it would erase the /// outer request's intents and every snapshot before the owner had committed /// (ADR-0033 /// Amendment 2 § 2). @@ -20,15 +20,17 @@ namespace LearnStack.SharedKernel.Audit; /// never inferred from what is in here. /// /// -/// Frames. Every AuditLogBehavior invocation — outermost or nested — opens a -/// frame before it declares and closes it on its way out. An intent belongs to the frame +/// Frames. Every classified invocation — a request or a direct handler, outermost +/// or nested — enters the common audit coordinator, which opens a frame before it +/// declares and closes it on its way out. An intent belongs to the frame /// that declared it, and a captured change to the frame that was innermost when the flush /// ran, which is the frame whose handler issued the write. That is what lets a nested /// request's row describe its own writes and not its parent's, and what lets each intent /// keep the result of its own request /// (ADR-0044 Amendment 6 /// §§ 1, 3). Anything declared or captured with no frame open belongs to one -/// request-level frame, so a caller that never opens one sees a single frame. +/// implicit frame, so a caller that never opens one sees a single attribution group. +/// That fallback is not an open execution frame and does not establish audit ownership. /// /// /// A SharedKernel abstraction: it names no EF Core type, because the interceptor that @@ -37,24 +39,32 @@ namespace LearnStack.SharedKernel.Audit; /// public interface IAuditStateCapture { - /// Every entity change captured so far, across every flush in the request. + /// Every entity change captured so far, across every flush in the execution. IReadOnlyList Changes { get; } /// Records one captured change. Called by the interceptor, never by a handler. void Add(CapturedEntityChange change); /// - /// The intents declared for this request, in declaration order — one per audited + /// The intents declared for this execution, in declaration order — one per audited /// (resource, operation). /// IReadOnlyList Intents { get; } /// - /// Where those intents stand. One state for the request, because they share one + /// Where those intents stand. One state for the execution, because they share one /// transaction and therefore one fate. /// AuditIntentState State { get; } + /// Whether this scoped capture has an open audit execution frame. + /// + /// The common coordinator reads this immediately before opening its frame to decide + /// whether it owns reconciliation. It says nothing about transaction or producer + /// ownership. An implicit attribution group with no opened frame returns false. + /// + bool HasOpenFrame { get; } + /// /// The changes captured in the frame that declared — the /// ones its own request wrote. What both writers compose a row from. @@ -62,17 +72,17 @@ public interface IAuditStateCapture /// The intent was not declared here. IReadOnlyList ChangesOf(AuditIntent intent); - /// Opens a frame. AuditLogBehavior, before it declares. + /// Opens a frame. The common audit coordinator, before it declares. void OpenFrame(); /// /// Closes the innermost frame and records on every intent it - /// declared. AuditLogBehavior, in its finally, on every way out. + /// declared. The common audit coordinator, in its finally, on every way out. /// /// No frame is open. void CloseFrame(AuditIntentResult result); - /// Declares an intent in the innermost frame. AuditLogBehavior, step 3. + /// Declares an intent in the common coordinator's innermost frame. void DeclareIntent(AuditIntent intent); /// @@ -93,6 +103,6 @@ public interface IAuditStateCapture /// COMMIT faulted; the server-side outcome is unknown. void MarkIndeterminate(Exception cause); - /// Empties the buffer. Once per request, by the outermost frame. + /// Empties the buffer. Once per execution, by the outermost audit frame. void Clear(); } diff --git a/backend/src/LearnStack.SharedKernel/Domain/IHasDomainEvents.cs b/backend/src/LearnStack.SharedKernel/Domain/IHasDomainEvents.cs index f4433913..334bd29c 100644 --- a/backend/src/LearnStack.SharedKernel/Domain/IHasDomainEvents.cs +++ b/backend/src/LearnStack.SharedKernel/Domain/IHasDomainEvents.cs @@ -2,8 +2,9 @@ namespace LearnStack.SharedKernel.Domain; /// /// Marker every entity that raises implements. -/// The unit-of-work walks tracked entities, drains the events, and hands -/// them to MediatR's in-process publisher on commit. +/// Successful saves promote the saving sources' facts into the scoped collector. +/// Only the transaction owner dispatches them sequentially before MUST audit and +/// commit; every owner terminal clears retained source lists, including detached ones. /// public interface IHasDomainEvents { diff --git a/backend/src/LearnStack.SharedKernel/Identifiers/IGuidFactory.cs b/backend/src/LearnStack.SharedKernel/Identifiers/IGuidFactory.cs index 8e915f06..1557c81a 100644 --- a/backend/src/LearnStack.SharedKernel/Identifiers/IGuidFactory.cs +++ b/backend/src/LearnStack.SharedKernel/Identifiers/IGuidFactory.cs @@ -7,9 +7,10 @@ namespace LearnStack.SharedKernel.Identifiers; /// /// Two minting paths exist per ADR-0031 (PostgreSQL 18) + ADR-0023: /// app-side for aggregates that need the ID before -/// SaveChangesAsync, and DB-side uuidv7() DEFAULT for -/// high-volume append-only tables (audit_log, outbox_messages). -/// This factory covers the app-side path only. +/// SaveChangesAsync, and DB-side uuidv7() DEFAULT where a caller +/// does not need the identity first. Audit entry IDs are supplied app-side for +/// reconciliation, and outbox IDs reuse the integration event's app-side EventId +/// under ADR-0057. This factory covers the app-side path only. /// public interface IGuidFactory { diff --git a/backend/src/LearnStack.SharedKernel/Messaging/IOutbox.cs b/backend/src/LearnStack.SharedKernel/Messaging/IOutbox.cs new file mode 100644 index 00000000..3f91b95e --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Messaging/IOutbox.cs @@ -0,0 +1,12 @@ +namespace LearnStack.SharedKernel.Messaging; + +/// Persists an integration fact on the enrolled producer's business transaction. +/// +/// Enqueue validates and inserts immediately. It opens no connection, commits nothing, +/// and does not publish to the transport. Successful inserts share the owner's outcome. +/// +public interface IOutbox +{ + /// Inserts one event, preserving its identity and declared routing. + Task EnqueueAsync(IIntegrationEvent integrationEvent, CancellationToken cancellationToken = default); +} diff --git a/backend/src/LearnStack.SharedKernel/Messaging/IPartitionSerializer.cs b/backend/src/LearnStack.SharedKernel/Messaging/IPartitionSerializer.cs index b6cfa8d1..e07ff46a 100644 --- a/backend/src/LearnStack.SharedKernel/Messaging/IPartitionSerializer.cs +++ b/backend/src/LearnStack.SharedKernel/Messaging/IPartitionSerializer.cs @@ -5,11 +5,9 @@ namespace LearnStack.SharedKernel.Messaging; /// different ones. /// /// -/// This is the in-process stand-in for what a broker gives you by assigning a -/// partition to one consumer. It exists so the development transport carries the -/// same ordering guarantee as the durable path rather than a weaker one: -/// ordering assumptions that hold only because everything happened to run on one -/// thread are discovered in production. +/// Serialization applies to invocation within this process. It does not +/// establish durable publish order across commits and retries; P02b-2 G3 owns +/// that decision. /// public interface IPartitionSerializer { diff --git a/backend/src/LearnStack.SharedKernel/Messaging/IntegrationEventPartitionAttribute.cs b/backend/src/LearnStack.SharedKernel/Messaging/IntegrationEventPartitionAttribute.cs new file mode 100644 index 00000000..a0ac447f --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Messaging/IntegrationEventPartitionAttribute.cs @@ -0,0 +1,14 @@ +namespace LearnStack.SharedKernel.Messaging; + +/// Names the identifier from which a concrete event derives its partition key. +/// Tenant-wide ordering requires an explicit declaration naming TenantId. +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class IntegrationEventPartitionAttribute(string sourceProperty, bool tenantWide = false) + : Attribute +{ + /// The public GUID or approved strongly-typed identifier property. + public string SourceProperty { get; } = sourceProperty; + + /// Whether this event deliberately partitions on its tenant identifier. + public bool TenantWide { get; } = tenantWide; +} diff --git a/backend/src/LearnStack.SharedKernel/Messaging/OutboxPayloadLimits.cs b/backend/src/LearnStack.SharedKernel/Messaging/OutboxPayloadLimits.cs new file mode 100644 index 00000000..acda3ed9 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Messaging/OutboxPayloadLimits.cs @@ -0,0 +1,8 @@ +namespace LearnStack.SharedKernel.Messaging; + +/// The producer's complete canonical UTF-8 payload budget (ADR-0057). +public static class OutboxPayloadLimits +{ + /// Inclusive; measured before INSERT, independently of JSONB storage size. + public const int MaxBytes = 256 * 1024; +} diff --git a/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs b/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs index b85d2e9c..08598c40 100644 --- a/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs +++ b/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs @@ -33,12 +33,10 @@ namespace LearnStack.SharedKernel.Persistence; /// its commit or rollback resolves its own frame and nothing else. /// /// -/// What escalates a frame's failure to the whole unit is -/// , and only two things call it: an exception — -/// TransactionBehavior marks the unit before rolling back on one — and a -/// caller doing so deliberately. An inner Result.Fail that an outer -/// handler absorbs is not one of them, per ADR-0040 § Nesting: the outer handler -/// took responsibility for it, and its own work still commits. +/// Exceptions, explicit calls and a refused producer +/// invocation with successfully produced work poison the unit. ADR-0057 includes +/// descendant produced-work receipts in that refusal rule. Ordinary event-free +/// Result.Fail outcomes remain absorbable under ADR-0040 § Nesting. /// /// /// One command at a time. One connection means the ambient transaction @@ -140,6 +138,16 @@ Task BeginTransactionAsync( /// bool IsTenantContextIssuedOn(DbTransaction? transaction); + /// Reads the successful tenant announcement for this unit's live transaction. + /// + /// The immutable snapshot records normalized values actually sent to PostgreSQL and a + /// generation that increases with every physical transaction. Returns null for an absent + /// or foreign transaction, an announcement without a usable tenant, failed or canceled + /// setup, and after commit, rollback or disposal. Joiners reuse the owner's snapshot; + /// the reader grants no context-setting capability (ADR-0057). + /// + TenantContextAnnouncement? GetTenantContextAnnouncement(DbTransaction? transaction); + /// /// Announces the tenant a provisioning request is about to create. /// @@ -155,9 +163,10 @@ Task BeginTransactionAsync( /// /// It does not widen the setter set. /// ADR-0040 - /// Amendment 7 closes that set at eight, and TransactionBehavior - /// remains the only caller of this as it is of its sibling — this is the same - /// setter announcing a different value for one request shape, not a ninth. + /// Amendment 7 closes that set at eight. The common + /// TransactionExecutionCoordinator calls both methods; + /// TransactionBehavior is its request adapter. This is the same logical + /// setter announcing a different value for provisioning, not a ninth path. /// /// /// Every way of misusing it throws rather than degrading. No open transaction, @@ -190,13 +199,10 @@ Task SetProvisioningTenantContextAsync( /// /// /// - /// A joiner's failure does not poison the unit. ADR-0040 § Nesting: - /// "an inner Result.Fail that the outer handler deliberately absorbs - /// is not a failure and does not mark it — only an exception, or an - /// explicit , does." So this resolves the - /// caller's frame and, on the outermost one, performs the actual - /// ROLLBACK; escalating to the whole unit is - /// 's job, and the exception path calls it. + /// A joiner's terminal call resolves its own frame. Escalating a failure to the + /// whole unit is 's job: the execution coordinator + /// marks exceptions and refusals carrying produced-work receipts before resolving + /// the frame. Ordinary event-free refusals remain absorbable (ADR-0057). /// /// /// It is cleanup, so it never throws over the thing it is cleaning up @@ -215,9 +221,8 @@ Task SetProvisioningTenantContextAsync( /// of work — not just of the current transaction. /// /// - /// An inner Result.Fail that an outer handler deliberately absorbs is - /// not a failure and does not mark it; an exception is, and - /// TransactionBehavior calls this before rolling back on one. Once + /// The execution coordinator calls this for exceptions and a refused producer + /// invocation with direct or descendant produced work (ADR-0057). Once /// marked, throws and /// refuses to open a new transaction on /// the same unit — a scope that needs a fresh one takes a fresh scope, which @@ -290,8 +295,8 @@ public interface IUnitOfWorkScope : IAsyncDisposable /// /// Resolves this frame by failing it. On the owning frame that is the /// ROLLBACK; on a joiner it declines this frame without making the - /// unit unable to commit, which is ADR-0040 § Nesting's rule about an - /// absorbed inner failure. + /// unit unable to commit. The execution coordinator marks exceptions and produced-work + /// refusals before resolving the frame; ordinary event-free refusals remain absorbable. /// Task FailAsync(CancellationToken cancellationToken = default); } diff --git a/backend/src/LearnStack.SharedKernel/Persistence/TenantContextAnnouncement.cs b/backend/src/LearnStack.SharedKernel/Persistence/TenantContextAnnouncement.cs new file mode 100644 index 00000000..0969a123 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Persistence/TenantContextAnnouncement.cs @@ -0,0 +1,13 @@ +using LearnStack.SharedKernel.Identifiers; + +namespace LearnStack.SharedKernel.Persistence; + +/// The normalized tenant scope successfully announced on one physical transaction. +/// +/// Read-only evidence, not authority supplied by a caller. Producers read the live snapshot +/// from their unit of work and compare its generation with their execution frame (ADR-0057). +/// +public sealed record TenantContextAnnouncement( + long Generation, + TenantId TenantId, + OrganizationId? OrganizationId); diff --git a/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs b/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs index c730e1e1..6851d07d 100644 --- a/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs +++ b/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs @@ -5,6 +5,7 @@ using LearnStack.Application.Pipeline; using LearnStack.Infrastructure.MultiTenancy; using LearnStack.Infrastructure.Persistence; +using LearnStack.Infrastructure.Messaging; using LearnStack.Infrastructure.Validation; using System.Diagnostics.Metrics; using LearnStack.Infrastructure.Audit; @@ -24,6 +25,7 @@ using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Caching; using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Messaging; using LearnStack.SharedKernel.Tenancy; using LearnStack.SharedKernel.Validation; using LearnStack.SharedKernel.Time; @@ -157,6 +159,8 @@ public static ServiceProvider Build( // idempotence this actually wants. services.TryAddEnumerable( ServiceDescriptor.Scoped()); + services.TryAddEnumerable( + ServiceDescriptor.Scoped()); // PostgresAuditStore counts the durable-duplicate outcome, so it needs a meter // factory. AddMetrics is idempotent and the API host already calls it through @@ -206,6 +210,7 @@ public static ServiceProvider Build( services.TryAddSingleton(); services.AddScoped(); + services.TryAddScoped(); // The catalogue, merged once from every module's source. A singleton: it is built // at composition time and read on every request, and rebuilding it per scope would diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/IntegrationEvents/TenantCreatedIntegrationEventV1.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/IntegrationEvents/TenantCreatedIntegrationEventV1.cs new file mode 100644 index 00000000..aa659fb8 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/IntegrationEvents/TenantCreatedIntegrationEventV1.cs @@ -0,0 +1,14 @@ +using LearnStack.SharedKernel.Messaging; + +namespace LearnStack.Modules.Tenancy.Application.Contracts.IntegrationEvents; + +/// A completed tenant provisioning, ordered deliberately within its tenant. +[IntegrationEventPartition(nameof(TenantId), tenantWide: true)] +public sealed record TenantCreatedIntegrationEventV1 : IntegrationEventBase +{ + /// + public override string Topic => "learnstack.tenancy.tenant"; + + /// + public override string PartitionKey => TenantId.ToString("D"); +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Tenant/ProvisionTenantCommand.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Tenant/ProvisionTenantCommand.cs index 84396367..96d0cb8b 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Tenant/ProvisionTenantCommand.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/Tenant/ProvisionTenantCommand.cs @@ -34,12 +34,11 @@ namespace LearnStack.Modules.Tenancy.Application.Contracts.Tenant; /// /// /// Anything added to that transaction inherits the assumption, so read this first. -/// Two things are already scheduled inside it: the MUST-class audit write ADR-0033 puts -/// immediately before the commit, and the outbox flush at pipeline step 7. Each would -/// execute under a caller-chosen tenant id, and neither is refused by pk_tenants. -/// Whatever lands there must not assume the announced tenant is new — or this command -/// must start refusing a tenant that already exists, which needs a read surface it does -/// not have today. +/// ADR-0057 collects the completed provisioning fact only after its final successful +/// save; owner-only translation inserts the outbox row before MUST audit and commit. +/// These writes execute under a caller-chosen tenant id. A duplicate tenant insert +/// therefore must fail before a creation fact can be saved; the announcement alone is +/// never evidence that a tenant is new. Pipeline step 7 guards enrollment, not a flush. /// /// /// [AllowsUnresolvedTenantContext] and not [PublicSurface]. The diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/ProvisionTenantCommandHandler.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/ProvisionTenantCommandHandler.cs index 8980f7c9..5cab2b4e 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/ProvisionTenantCommandHandler.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/ProvisionTenantCommandHandler.cs @@ -52,7 +52,8 @@ namespace LearnStack.Modules.Tenancy.Application.Tenant; internal sealed class ProvisionTenantCommandHandler( ITenantWriteStore tenants, IOrganizationWriteStore organizations, - IClock clock) + IClock clock, + IGuidFactory guids) : IRequestHandler> { public async Task> Handle( @@ -116,7 +117,7 @@ private async Task> ProvisionAsync( // at a row that does not exist when the tenant is inserted, and the foreign key // behind it is MATCH SIMPLE — which skips the check only while the column is // null. Setting it on the insert would defeat that and fail. - tenant.AssignDefaultOrganization(request.DefaultOrganizationId, clock, actor); + tenant.AssignDefaultOrganization(request.DefaultOrganizationId, clock, guids, actor); await tenants.UpdateAsync(tenant, cancellationToken); return Result.Ok(new ProvisionedTenantDto( diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/TenantCreatedDomainEventHandler.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/TenantCreatedDomainEventHandler.cs new file mode 100644 index 00000000..f3e8d41e --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/TenantCreatedDomainEventHandler.cs @@ -0,0 +1,24 @@ +using LearnStack.Modules.Tenancy.Application.Contracts.IntegrationEvents; +using LearnStack.Modules.Tenancy.Domain.Events; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Messaging; +using MediatR; + +namespace LearnStack.Modules.Tenancy.Application.Tenant; + +/// Translates the saved provisioning fact into one pending outbox row. +internal sealed class TenantCreatedDomainEventHandler(IOutbox outbox, IGuidFactory guids) + : INotificationHandler +{ + public Task Handle(TenantCreatedDomainEvent notification, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(notification); + + return outbox.EnqueueAsync(new TenantCreatedIntegrationEventV1 + { + EventId = guids.NewUuidV7(), + TenantId = notification.TenantId.Value, + OccurredAt = notification.OccurredAt, + }, cancellationToken); + } +} diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Events/TenantCreatedDomainEvent.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Events/TenantCreatedDomainEvent.cs new file mode 100644 index 00000000..7d50de16 --- /dev/null +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Events/TenantCreatedDomainEvent.cs @@ -0,0 +1,7 @@ +using LearnStack.SharedKernel.Domain; +using LearnStack.SharedKernel.Identifiers; + +namespace LearnStack.Modules.Tenancy.Domain.Events; + +/// The tenant's initial default-organization assignment completes provisioning. +public sealed record TenantCreatedDomainEvent(TenantId TenantId) : DomainEvent; diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs index c51c9f2f..03f851fd 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Tenant.cs @@ -4,6 +4,7 @@ using LearnStack.SharedKernel.Localization; using LearnStack.SharedKernel.Persistence; using LearnStack.SharedKernel.Time; +using LearnStack.Modules.Tenancy.Domain.Events; namespace LearnStack.Modules.Tenancy.Domain; @@ -124,11 +125,14 @@ public static Tenant Create( /// Separate from because the organization cannot exist /// before its tenant: the composite foreign key would have nothing to /// reference. Both statements run in one transaction, so a tenant is never - /// observable without a default organization. + /// observable without a default organization. Only the initial assignment raises + /// ; a later reassignment is not creation. /// - public void AssignDefaultOrganization(OrganizationId organizationId, IClock clock, UserId updatedBy) + public void AssignDefaultOrganization( + OrganizationId organizationId, IClock clock, IGuidFactory guids, UserId updatedBy) { ArgumentNullException.ThrowIfNull(clock); + ArgumentNullException.ThrowIfNull(guids); if (!organizationId.IsInitialized() || organizationId.Value == Guid.Empty) { @@ -136,8 +140,18 @@ public void AssignDefaultOrganization(OrganizationId organizationId, IClock cloc "The default organization must be a real organization.", nameof(organizationId)); } - MarkUpdated(clock.UtcNow, updatedBy); + var at = clock.UtcNow; + var created = DefaultOrganizationId is null + ? new TenantCreatedDomainEvent(Id) { EventId = guids.NewUuidV7(), OccurredAt = at } + : null; + + MarkUpdated(at, updatedBy); DefaultOrganizationId = organizationId; + + if (created is not null) + { + RaiseDomainEvent(created); + } } /// Moves the tenant to a new lifecycle state. diff --git a/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs index 0d7caeb5..70b96952 100644 --- a/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs @@ -723,9 +723,9 @@ public void Integration_Event_TopicNames_FollowConvention() // the envelope there was nothing to read, and this catalogued rule could // not be written at all. // - // No module declares an event yet, so this would be vacuous — hence the - // deliberate offenders below. A guard that cannot be shown to fire is - // not a guard. + // P02b-1 supplies the first real event. Require that production subject + // as well as deliberate offenders; neither an empty nor a blind sweep + // may satisfy the convention. FollowsTopicConvention("learnstack.enrollment.enrollment").Should().BeTrue(); FollowsTopicConvention("learnstack.hub.entitlement").Should().BeTrue(); FollowsTopicConvention("learnstack.hub.custom-domain.activated").Should().BeTrue(); @@ -741,24 +741,18 @@ public void Integration_Event_TopicNames_FollowConvention() FollowsTopicConvention("learnstack.hub.custom-domain.activated.extra").Should().BeFalse( "five segments"); - foreach (var name in ModuleAssemblyShapes) + FollowsTopicConvention("learnstack.tenancy.tenant\n").Should().BeFalse("a trailing newline is not part of a topic"); + var events = ModuleAssemblyShapes.Select(LoadAssembly).SelectMany(assembly => assembly.GetTypes()) + .Where(type => !type.IsAbstract && !type.IsInterface + && typeof(LearnStack.SharedKernel.Messaging.IIntegrationEvent).IsAssignableFrom(type)).ToArray(); + events.Should().NotBeEmpty("Fix: the module census must see P02b-1's completed-provisioning event"); + foreach (var type in events) { - var assembly = LoadAssembly(name); - - var events = assembly.GetTypes() - .Where(t => !t.IsAbstract - && typeof(LearnStack.SharedKernel.Messaging.IIntegrationEvent) - .IsAssignableFrom(t)); - - foreach (var type in events) - { - var topic = ((LearnStack.SharedKernel.Messaging.IIntegrationEvent) - System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject(type)).Topic; - - FollowsTopicConvention(topic).Should().BeTrue( - $"{type.FullName} declares topic '{topic}', which is not " - + "learnstack.{module}.{aggregate} (Standards 20 § IEventBus)"); - } + var topic = ((LearnStack.SharedKernel.Messaging.IIntegrationEvent) + System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject(type)).Topic; + FollowsTopicConvention(topic).Should().BeTrue( + $"{type.FullName} declares topic '{topic}', which is not " + + "learnstack.{module}.{aggregate} (Standards 20 § IEventBus)"); } } @@ -767,12 +761,12 @@ private static bool FollowsTopicConvention(string topic) const string segment = "[a-z][a-z0-9-]*[a-z0-9]|[a-z]"; return System.Text.RegularExpressions.Regex.IsMatch( topic, - $@"^learnstack\.({segment})\.({segment})$", + $@"\Alearnstack\.({segment})\.({segment})\z", System.Text.RegularExpressions.RegexOptions.None, TimeSpan.FromSeconds(1)) || System.Text.RegularExpressions.Regex.IsMatch( topic, - $@"^learnstack\.hub\.({segment})\.({segment})$", + $@"\Alearnstack\.hub\.({segment})\.({segment})\z", System.Text.RegularExpressions.RegexOptions.None, TimeSpan.FromSeconds(1)); } diff --git a/backend/tests/LearnStack.Tests.Architecture/DomainEventHandlerTests.cs b/backend/tests/LearnStack.Tests.Architecture/DomainEventHandlerTests.cs new file mode 100644 index 00000000..9c8f543e --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/DomainEventHandlerTests.cs @@ -0,0 +1,608 @@ +using FluentAssertions; +using LearnStack.SharedKernel.Caching; +using LearnStack.SharedKernel.Domain; +using LearnStack.SharedKernel.Entitlements; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Messaging; +using LearnStack.SharedKernel.Observability; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Resilience; +using LearnStack.SharedKernel.Secrets; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.SharedKernel.Time; +using MediatR; +using Mono.Cecil; +using Mono.Cecil.Cil; +using Xunit; + +namespace LearnStack.Tests.Architecture; + +public sealed class DomainEventHandlerTests +{ + private const string HandlerContract = "MediatR.INotificationHandler`1"; + private const string ProductionHandler = "LearnStack.Modules.Tenancy.Application.Tenant.TenantCreatedDomainEventHandler"; + private static readonly string[] ModuleLayers = ["Domain", "Application.Contracts", "Application", "Infrastructure"]; + + // These are the provider ports that exist today. A new provider contract must + // extend this set; this guard does not pretend to recognize future SDKs by name. + private static readonly Type[] ProviderPorts = + [ + typeof(ICacheService), typeof(ISecretProvider), typeof(IEntitlementProvider), + typeof(IFeatureFlags), typeof(IKillswitchOverlay), typeof(IErrorTrackingProvider), + typeof(IProviderResilience<>), typeof(IHostToTenantResolver), typeof(IHostResolutionInvalidator), + typeof(ITenantMembershipReader), typeof(IOrganizationScopeValidator), + typeof(LearnStack.SharedKernel.Audit.IAuditConfigService), + ]; + + /// + /// ADR-0057: + /// domain reactions translate facts to outbox messages; they cannot start another effect. + /// + [Fact] + public void Domain_Event_Handlers_Are_Outbox_Only() + { + var modules = ReadProductionModules(); + try + { + AssertProductionCoverage(modules); + var graph = new TypeGraph(modules); + var roots = graph.DomainHandlers(); + roots.Should().NotBeEmpty("Fix: classify production INotificationHandler implementations whose T is an IDomainEvent"); + Offenders(roots, graph).Should().BeEmpty( + "Fix: translate the saved domain fact through IOutbox only; move writes, requests, publication and provider work to commands or integration consumers (ADR-0057)"); + } + finally + { + foreach (var module in modules) module.Dispose(); + } + } + + [Fact] + public void Domain_handler_census_recognizes_inherited_generic_contracts_and_excludes_other_notifications() + { + using var module = ReadProbes(); + var kernel = ReadKernelForProbes(); + try + { + var graph = new TypeGraph([module, .. kernel]); + var names = graph.DomainHandlers().Select(type => type.FullName).ToArray(); + names.Should().Contain([ProbeName(typeof(CleanHandler)), ProbeName(typeof(InheritedHandler)), ProbeName(typeof(GenericHandler<>))]) + .And.NotContain([ProbeName(typeof(OtherNotificationHandler)), ProbeName(typeof(NotificationHandler<>))], + "ordinary notifications are outside this domain-event contract even when their handler has forbidden dependencies"); + } + finally + { + foreach (var assembly in kernel) assembly.Dispose(); + } + } + + public static TheoryData ForbiddenDependencies + { + get + { + var cases = new TheoryData + { + { typeof(ProbeEntity), "entity/root" }, + { typeof(IAggregateRoot), "entity/root" }, + { typeof(IHasDomainEvents), "entity/root" }, + { typeof(IAggregateWriteStore), "persistence" }, + { typeof(IUnitOfWork), "persistence" }, + { typeof(Microsoft.EntityFrameworkCore.DbContext), "persistence" }, + { typeof(Npgsql.NpgsqlConnection), "persistence" }, + { typeof(System.Data.Common.DbConnection), "persistence" }, + { typeof(LearnStack.SharedKernel.Audit.IAuditStore), "persistence" }, + { typeof(LearnStack.SharedKernel.Idempotency.IIdempotencyStore), "persistence" }, + { typeof(ISender), "nested request" }, + { typeof(IMediator), "nested request" }, + { typeof(IRequestHandler<>), "nested request" }, + { typeof(IRequestHandler<,>), "nested request" }, + { typeof(IPublisher), "notification publication" }, + { typeof(IEventBus), "event bus" }, + { typeof(HttpClient), "external provider" }, + { typeof(IServiceProvider), "service locator" }, + { typeof(Microsoft.Extensions.DependencyInjection.IServiceScopeFactory), "service locator" }, + }; + foreach (var port in ProviderPorts) cases.Add(port, "external provider"); + return cases; + } + } + + [Theory] + [MemberData(nameof(ForbiddenDependencies))] + public void Domain_handler_guard_rejects_each_forbidden_dependency(Type dependency, string category) + { + using var module = ReadProbes(); + var kernel = ReadKernelForProbes(); + try + { + var root = AddHandler(module, "DirtyHandler"); + root.Fields.Add(new FieldDefinition("Dependency", FieldAttributes.Public, module.ImportReference(dependency))); + var graph = new TypeGraph([module, .. kernel]); + Offenders(graph.DomainHandlers().Where(type => type == root), graph) + .Should().Contain(offender => offender.Contains($"[{category}]", StringComparison.Ordinal) + && offender.Contains(dependency.IsGenericType ? dependency.GetGenericTypeDefinition().FullName! : ProbeName(dependency), StringComparison.Ordinal), + $"Fix: the same production predicate must detect {dependency.FullName}"); + } + finally + { + foreach (var assembly in kernel) assembly.Dispose(); + } + } + + public static TheoryData HelperPaths => new() + { + { typeof(DirectHelper), true }, { typeof(IndirectHelper), true }, { typeof(IHelper), true }, + { typeof(InheritedHelper), true }, { typeof(InheritedDependencyHelper), true }, { typeof(IGenericHelper), true }, + { typeof(AsyncHelper), true }, { typeof(LambdaHelper), true }, + { typeof(AbstractDispatchHelper), true }, { typeof(VirtualDispatchHelper), true }, + { typeof(PureHelper), false }, { typeof(PureIndirectHelper), false }, { typeof(IPureHelper), false }, + { typeof(PureInheritedHelper), false }, { typeof(IGenericPureHelper), false }, + { typeof(PureAsyncHelper), false }, { typeof(PureLambdaHelper), false }, + { typeof(PureAbstractDispatchHelper), false }, { typeof(PureVirtualDispatchHelper), false }, + }; + + [Theory] + [MemberData(nameof(HelperPaths))] + public void Domain_handler_guard_follows_helpers_interfaces_inheritance_async_and_lambdas(Type helper, bool forbidden) + { + using var module = ReadProbes(); + var kernel = ReadKernelForProbes(); + try + { + var root = AddHandler(module, "HelperHandler"); + root.Fields.Add(new FieldDefinition("Helper", FieldAttributes.Public, module.ImportReference(helper))); + var graph = new TypeGraph([module, .. kernel]); + var classified = graph.DomainHandlers().Single(type => type == root); + var offenders = Offenders([classified], graph); + if (forbidden) + offenders.Should().Contain(offender => offender.Contains(typeof(IUnitOfWork).FullName!, StringComparison.Ordinal) + && offender.Contains("HelperHandler", StringComparison.Ordinal), + $"Fix: the production walk must follow the forbidden dependency through {helper.Name}"); + else + offenders.Should().BeEmpty($"Fix: pure {helper.Name} must remain legal through the same helper walk"); + } + finally + { + foreach (var assembly in kernel) assembly.Dispose(); + } + } + + [Fact] + public void Domain_handler_guard_accepts_translation_and_pure_helpers_without_traversing_outbox_implementations() + { + using var module = ReadProbes(); + var kernel = ReadKernelForProbes(); + try + { + var graph = new TypeGraph([module, .. kernel]); + var root = graph.DomainHandlers().Single(type => type.FullName == ProbeName(typeof(CleanHandler))); + Offenders([root], graph).Should().BeEmpty( + "event/identifier values, IClock, IGuidFactory, IOutbox and pure helper implementations are sanctioned leaves or paths; an outbox implementation's persistence is not a handler effect"); + } + finally + { + foreach (var assembly in kernel) assembly.Dispose(); + } + } + + [Theory] + [InlineData("RaiseDomainEvent", "further domain event")] + [InlineData("Handle", "notification publication")] + public void Domain_handler_guard_rejects_further_raise_and_direct_notification_dispatch(string methodName, string category) + { + using var module = ReadProbes(); + var kernel = ReadKernelForProbes(); + try + { + var root = AddHandler(module, "DispatchHandler"); + var owner = methodName == "Handle" + ? module.ImportReference(typeof(INotificationHandler)) + : module.ImportReference(typeof(ProbeEntity)); + var method = new MethodDefinition("Dispatch", MethodAttributes.Public | MethodAttributes.Static, module.TypeSystem.Void); + method.Body.Instructions.Add(Instruction.Create(OpCodes.Call, new MethodReference(methodName, module.TypeSystem.Void, owner))); + method.Body.Instructions.Add(Instruction.Create(OpCodes.Ret)); + root.Methods.Add(method); + var graph = new TypeGraph([module, .. kernel]); + Offenders(graph.DomainHandlers().Where(type => type == root), graph).Should() + .Contain(offender => offender.Contains($"[{category}]", StringComparison.Ordinal) + && offender.Contains(methodName, StringComparison.Ordinal), + "Fix: do not dispatch another notification or raise another domain event from a domain reaction"); + } + finally + { + foreach (var assembly in kernel) assembly.Dispose(); + } + } + + [Fact] + public void Domain_handler_guard_reaches_planted_roots_and_helpers_in_every_production_assembly() + { + var modules = ReadProductionModules(); + try + { + AssertProductionCoverage(modules); + var roots = new TypeGraph(modules).DomainHandlers(); + var productionRoot = roots.Single(type => type.FullName == ProductionHandler); + foreach (var owner in modules) + { + // Equal helper names in different assemblies deliberately exercise scope + // resolution. Only the referenced copy has a forbidden dependency. + var helper = new TypeDefinition("ReviewProbe", "ScopedHelper", TypeAttributes.Class | TypeAttributes.Public); + owner.Types.Add(helper); + helper.Fields.Add(new FieldDefinition("Unit", FieldAttributes.Public, owner.ImportReference(typeof(IUnitOfWork)))); + var dependency = new FieldDefinition("ReviewDependency", FieldAttributes.Private, productionRoot.Module.ImportReference(helper)); + productionRoot.Fields.Add(dependency); + var decoyOwner = modules.First(module => module != owner); + var decoy = new TypeDefinition("ReviewProbe", "ScopedHelper", TypeAttributes.Class | TypeAttributes.Public); + decoyOwner.Types.Add(decoy); + var plantedRoot = AddHandler(owner, "PlantedDomainHandler", productionEvent: true); + plantedRoot.Fields.Add(new FieldDefinition("Unit", FieldAttributes.Public, owner.ImportReference(typeof(IUnitOfWork)))); + try + { + var graph = new TypeGraph(modules); + var offenders = Offenders(graph.DomainHandlers(), graph); + offenders.Should().Contain(offender => offender.StartsWith(Label(productionRoot), StringComparison.Ordinal) + && offender.Contains(Label(helper), StringComparison.Ordinal), + $"Fix: preserve assembly scope and follow helpers in {owner.Name}, including Domain, Contracts and every core project"); + offenders.Should().Contain(offender => offender.StartsWith(Label(plantedRoot), StringComparison.Ordinal), + $"Fix: discover domain-event handlers in {owner.Name}; no production layer may be excluded from the root census"); + } + finally + { + productionRoot.Fields.Remove(dependency); + owner.Types.Remove(helper); + owner.Types.Remove(plantedRoot); + decoyOwner.Types.Remove(decoy); + } + } + } + finally + { + foreach (var module in modules) module.Dispose(); + } + } + + private static List ReadProductionModules() + { + var modules = new List(); + try + { + foreach (var assembly in ProductionAssemblies.All()) modules.Add(ModuleDefinition.ReadModule(assembly.Location)); + return modules; + } + catch + { + foreach (var module in modules) module.Dispose(); + throw; + } + } + + private static void AssertProductionCoverage(IEnumerable modules) + { + var names = modules.Select(module => module.Assembly.Name.Name).ToArray(); + names.Should().BeEquivalentTo(ProductionAssemblies.Names(), + "Fix: scan all production projects, including every core helper assembly"); + foreach (var module in names.Where(name => name.StartsWith("LearnStack.Modules.", StringComparison.Ordinal)) + .Select(name => name.Split('.')[2]).Distinct(StringComparer.Ordinal)) + { + names.Where(name => name.StartsWith($"LearnStack.Modules.{module}.", StringComparison.Ordinal)).Should() + .BeEquivalentTo(ModuleLayers + .Select(layer => $"LearnStack.Modules.{module}.{layer}"), + $"Fix: include all four {module} layers in the handler and helper census"); + } + } + + private static string[] Offenders(IEnumerable roots, TypeGraph graph) + { + var offenders = new SortedSet(StringComparer.Ordinal); + foreach (var root in roots.OrderBy(Label, StringComparer.Ordinal)) + { + var queue = new Queue<(TypeDefinition Type, string Path)>(); + queue.Enqueue((root, Label(root))); + var visited = new HashSet<(string Assembly, string Type)>(); + while (queue.TryDequeue(out var current)) + { + if (!visited.Add(Key(current.Type))) continue; + foreach (var method in Il.Methods(current.Type).Select(pair => pair.Definition).Where(method => method.HasBody)) + { + foreach (var call in method.Body.Instructions.Select(instruction => instruction.Operand).OfType()) + { + if (call.Name == "RaiseDomainEvent") + offenders.Add($"{current.Path} -> {call.FullName} [further domain event]"); + if (call.Name == "Handle" && graph.Hierarchy(call.DeclaringType).Any(IsNotificationHandler)) + offenders.Add($"{current.Path} -> {call.FullName} [notification publication]"); + } + } + + foreach (var reference in Il.ReferencedTypeReferences(current.Type) + .OrderBy(reference => Key(reference).Assembly, StringComparer.Ordinal) + .ThenBy(reference => reference.FullName, StringComparer.Ordinal)) + { + var forbidden = Forbidden(reference, graph); + if (forbidden is not null) + { + offenders.Add($"{current.Path} -> {Label(reference)} [{forbidden}]"); + continue; + } + + if (IsTerminal(reference, graph)) continue; + if (graph.Resolve(reference) is not { } helper) continue; + queue.Enqueue((helper, $"{current.Path} -> {Label(helper)}")); + if (!helper.IsSealed) + { + foreach (var implementation in graph.Implementations(helper)) + queue.Enqueue((implementation, $"{current.Path} -> {Label(helper)} -> {Label(implementation)}")); + } + } + } + } + + return [.. offenders]; + } + + private static string? Forbidden(TypeReference reference, TypeGraph graph) + { + var hierarchy = graph.Hierarchy(reference).ToArray(); + if (hierarchy.Any(type => Is(type, typeof(IAggregateWriteStore<,>)) || Is(type, typeof(IUnitOfWork)) + || Is(type, typeof(LearnStack.SharedKernel.Audit.IAuditStore)) + || Is(type, typeof(LearnStack.SharedKernel.Idempotency.IIdempotencyStore)) + || InNamespace(type, "Microsoft.EntityFrameworkCore") || InNamespace(type, "Npgsql") || InNamespace(type, "System.Data"))) + return "persistence"; + if (hierarchy.Any(type => Is(type, typeof(Entity<>)) || Is(type, typeof(IAggregateRoot<>)) || Is(type, typeof(IHasDomainEvents)))) + return "entity/root"; + if (hierarchy.Any(type => Is(type, typeof(ISender)) || Is(type, typeof(IMediator)) + || Is(type, typeof(IRequestHandler<>)) || Is(type, typeof(IRequestHandler<,>)))) return "nested request"; + if (hierarchy.Any(type => Is(type, typeof(IPublisher)))) return "notification publication"; + if (hierarchy.Any(type => Is(type, typeof(IEventBus)))) return "event bus"; + if (hierarchy.Any(type => type.FullName == typeof(IServiceProvider).FullName + || InNamespace(type, "Microsoft.Extensions.DependencyInjection"))) return "service locator"; + if (hierarchy.Any(type => ProviderPorts.Any(port => Is(type, port)) || InNamespace(type, "System.Net.Http"))) + return "external provider"; + return null; + } + + private static bool IsTerminal(TypeReference reference, TypeGraph graph) => + Is(reference, typeof(IOutbox)) || Is(reference, typeof(IGuidFactory)) || Is(reference, typeof(IClock)) + || IsNotificationHandler(reference) || Is(reference, typeof(INotification)) + || graph.Hierarchy(reference).Any(type => Is(type, typeof(IDomainEvent)) + || Is(type, typeof(IIntegrationEvent)) || Is(type, typeof(IStronglyTypedId<>))); + + private static bool IsNotificationHandler(TypeReference type) => + type.GetElementType().FullName == HandlerContract && Key(type).Assembly == typeof(INotificationHandler<>).Assembly.GetName().Name; + + private static bool Is(TypeReference reference, Type type) => + Key(reference) == (type.Assembly.GetName().Name!, type.FullName!); + + private static bool InNamespace(TypeReference type, string name) => + type.Namespace == name || type.Namespace.StartsWith(name + ".", StringComparison.Ordinal); + + private static (string Assembly, string Type) Key(TypeReference reference) + { + var element = reference.GetElementType(); + var assembly = element.Scope is AssemblyNameReference scope ? scope.Name + : element.Scope is ModuleDefinition module ? module.Assembly.Name.Name : element.Scope?.Name ?? ""; + return (assembly, element.FullName); + } + + private static string Label(TypeReference type) => $"{Key(type).Assembly}:{type.FullName}"; + + private sealed class TypeGraph + { + private readonly Dictionary<(string Assembly, string Type), TypeDefinition> _types; + private readonly Dictionary<(string Assembly, string Type), TypeDefinition[]> _implementations; + public TypeGraph(IEnumerable modules) + { + _types = modules.SelectMany(module => module.GetTypes()).ToDictionary(Key); + // Match interfaces and non-sealed base classes conservatively: every + // production implementation/subclass is a possible virtual dispatch target. + // Generic definitions and inherited implementations count as well. + _implementations = _types.Values.Where(type => !type.IsInterface) + .SelectMany(type => Hierarchy(type).Where(contract => Resolve(contract) is { IsSealed: false }) + .Select(contract => (Contract: Key(contract), Implementation: type))) + .GroupBy(pair => pair.Contract) + .ToDictionary(group => group.Key, group => group.Select(pair => pair.Implementation) + .Distinct().OrderBy(Label, StringComparer.Ordinal).ToArray()); + } + + public TypeDefinition? Resolve(TypeReference reference) => _types.GetValueOrDefault(Key(reference)); + + public TypeDefinition[] DomainHandlers() => _types.Values.Where(type => !type.IsInterface + && Hierarchy(type).OfType().Any(contract => IsNotificationHandler(contract) + && Hierarchy(contract.GenericArguments[0]).Any(argument => Is(argument, typeof(IDomainEvent))))) + .OrderBy(Label, StringComparer.Ordinal).ToArray(); + + public TypeDefinition[] Implementations(TypeReference contract) => + _implementations.GetValueOrDefault(Key(contract)) ?? []; + + public IEnumerable Hierarchy(TypeReference reference) + { + var queue = new Queue(); + var visited = new HashSet(StringComparer.Ordinal); + queue.Enqueue(reference); + while (queue.TryDequeue(out var current)) + { + if (!visited.Add(Label(current))) continue; + yield return current; + if (current is GenericParameter parameter) + { + foreach (var constraint in parameter.Constraints) queue.Enqueue(constraint.ConstraintType); + continue; + } + + if (Resolve(current) is not { } definition) continue; + var arguments = current is GenericInstanceType generic ? generic.GenericArguments.ToArray() : []; + if (definition.BaseType is { } parent) queue.Enqueue(Substitute(parent, arguments)); + foreach (var contract in definition.Interfaces) queue.Enqueue(Substitute(contract.InterfaceType, arguments)); + } + } + + private static TypeReference Substitute(TypeReference reference, TypeReference[] arguments) + { + if (reference is GenericParameter { Type: GenericParameterType.Type } parameter && parameter.Position < arguments.Length) + return arguments[parameter.Position]; + if (reference is not GenericInstanceType generic) return reference; + var result = new GenericInstanceType(generic.ElementType); + foreach (var argument in generic.GenericArguments) result.GenericArguments.Add(Substitute(argument, arguments)); + return result; + } + } + + private static ModuleDefinition ReadProbes() => ModuleDefinition.ReadModule(typeof(DomainEventHandlerTests).Assembly.Location); + + private static ModuleDefinition[] ReadKernelForProbes() => [ModuleDefinition.ReadModule(typeof(IDomainEvent).Assembly.Location)]; + + private static string ProbeName(Type type) => type.FullName!.Replace('+', '/'); + + private static TypeDefinition AddHandler(ModuleDefinition module, string name, bool productionEvent = false) + { + var root = new TypeDefinition("ReviewProbe", name, TypeAttributes.Class | TypeAttributes.Public); + // Use the shipped event in production plants: its full ancestry must be + // resolved from the production universe, not from this test assembly. + var contract = new GenericInstanceType(module.ImportReference(typeof(INotificationHandler<>))); + contract.GenericArguments.Add(module.ImportReference(productionEvent + ? typeof(LearnStack.Modules.Tenancy.Domain.Events.TenantCreatedDomainEvent) + : typeof(ProbeDomainEvent))); + root.Interfaces.Add(new InterfaceImplementation(contract)); + module.Types.Add(root); + return root; + } + + private sealed record ProbeDomainEvent : DomainEvent; + private sealed record OtherNotification : INotification; + + private abstract class GenericHandler : INotificationHandler where T : IDomainEvent + { + public Task Handle(T notification, CancellationToken cancellationToken) => Task.CompletedTask; + } + + private abstract class NotificationHandler : INotificationHandler where T : INotification + { + public Task Handle(T notification, CancellationToken cancellationToken) => Task.CompletedTask; + } + + private sealed class InheritedHandler : NotificationHandler; + + private sealed class OtherNotificationHandler : INotificationHandler + { + public IUnitOfWork? Unit { get; } + public Task Handle(OtherNotification notification, CancellationToken cancellationToken) => Task.CompletedTask; + } + + private sealed class CleanHandler(IOutbox outbox, IGuidFactory guids, IClock clock, IPureHelper helper) + : INotificationHandler + { + public Task Handle(ProbeDomainEvent notification, CancellationToken cancellationToken) => + outbox.EnqueueAsync(new LearnStack.Modules.Tenancy.Application.Contracts.IntegrationEvents.TenantCreatedIntegrationEventV1 + { + EventId = guids.NewUuidV7(), + TenantId = helper.Read(TenantId.From(notification.EventId)).Value, + OccurredAt = clock.UtcNow, + }, cancellationToken); + } + + private interface IPureHelper + { + TenantId Read(TenantId id); + } + + private class PureHelper : IPureHelper + { + public TenantId Read(TenantId id) => id; + } + + private sealed class PureIndirectHelper + { + public PureHelper? Value { get; } + } + + private sealed class PureInheritedHelper : PureHelper; + private interface IGenericPureHelper; + private abstract class GenericPureHelperBase : IGenericPureHelper; + private sealed class GenericPureHelper : GenericPureHelperBase; + + private static class PureAsyncHelper + { + public static async Task Read() + { + await Task.Yield(); + return "fact"; + } + } + + private static class PureLambdaHelper + { + public static Func Read() => () => "fact"; + } + + // Deliberately forbidden persistence behind the sanctioned terminal: following + // IOutbox implementations would make the clean handler fail this same predicate. + private sealed class OutboxProbe : IOutbox + { + public IUnitOfWork? Unit { get; } + public Task EnqueueAsync(IIntegrationEvent integrationEvent, CancellationToken cancellationToken = default) => Task.CompletedTask; + } + + private sealed class ProbeEntity : Entity, IAggregateRoot; + + private class DirectHelper + { + public IUnitOfWork? Unit { get; } + } + + private sealed class IndirectHelper + { + public DirectHelper? Value { get; } + } + + private interface IHelper; + private class HelperBase : IHelper; + + private sealed class InheritedHelper : HelperBase + { + public IUnitOfWork? Unit { get; } + } + + private sealed class InheritedDependencyHelper : DirectHelper; + private interface IGenericHelper; + private abstract class GenericHelperBase : IGenericHelper; + + private sealed class GenericHelper : GenericHelperBase + { + public IUnitOfWork? Unit { get; } + } + + private abstract class AbstractDispatchHelper { public abstract Type Read(); } + private sealed class DirtyAbstractDispatchHelper : AbstractDispatchHelper + { + public override Type Read() => typeof(IUnitOfWork); + } + private class VirtualDispatchHelper { public virtual Type Read() => typeof(Guid); } + private sealed class DirtyVirtualDispatchHelper : VirtualDispatchHelper + { + public override Type Read() => typeof(IUnitOfWork); + } + private abstract class PureAbstractDispatchHelper { public abstract Type Read(); } + private sealed class PureAbstractDispatchImplementation : PureAbstractDispatchHelper + { + public override Type Read() => typeof(Guid); + } + private class PureVirtualDispatchHelper { public virtual Type Read() => typeof(Guid); } + private sealed class PureVirtualDispatchImplementation : PureVirtualDispatchHelper + { + public override Type Read() => typeof(TenantId); + } + + private static class AsyncHelper + { + public static async Task Read() + { + await Task.Yield(); + return typeof(IUnitOfWork); + } + } + + private static class LambdaHelper + { + public static Func Read() => () => typeof(IUnitOfWork); + } +} diff --git a/backend/tests/LearnStack.Tests.Architecture/IntegrationEventContractTests.cs b/backend/tests/LearnStack.Tests.Architecture/IntegrationEventContractTests.cs new file mode 100644 index 00000000..5c309ca3 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/IntegrationEventContractTests.cs @@ -0,0 +1,503 @@ +using System.Collections; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Text.Json.Serialization; +using LearnStack.Modules.Tenancy.Application.Contracts.IntegrationEvents; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Messaging; +using Mono.Cecil; +using Mono.Cecil.Cil; +using Xunit; + +namespace LearnStack.Tests.Architecture; + +/// Production event contracts under ADR-0057, independent of enqueue's admission validator. +public sealed class IntegrationEventContractTests +{ + private const BindingFlags DeclaredMembers = BindingFlags.Instance | BindingFlags.Public + | BindingFlags.NonPublic | BindingFlags.DeclaredOnly; + private static readonly string[] BaseNames = + [ + nameof(IIntegrationEvent.EventId), nameof(IIntegrationEvent.TenantId), + nameof(IIntegrationEvent.OccurredAt), nameof(IIntegrationEvent.Topic), + nameof(IIntegrationEvent.PartitionKey), + ]; + private static readonly Type[] SerializationInterfaces = + [ + typeof(IEnumerable), typeof(IJsonOnSerializing), typeof(IJsonOnSerialized), + typeof(IJsonOnDeserializing), typeof(IJsonOnDeserialized), + ]; + + /// Standards 21 and ADR-0006: every production fact uses the common record contract. + [Fact] + public void Integration_Events_Inherit_From_IntegrationEventBase() + { + var violations = ProductionEvents().SelectMany(InheritanceViolations).ToList(); + var contract = typeof(IntegrationEventBase); + if (!contract.IsAbstract || !IsRecord(contract)) violations.Add("IntegrationEventBase must be an abstract record."); + foreach (var (name, expectedType) in new[] + { + (nameof(IIntegrationEvent.EventId), typeof(Guid)), + (nameof(IIntegrationEvent.TenantId), typeof(Guid)), + (nameof(IIntegrationEvent.OccurredAt), typeof(DateTimeOffset)), + }) + { + var property = contract.GetProperty(name); + if (property?.PropertyType != expectedType + || !property.IsDefined(typeof(RequiredMemberAttribute)) + || property.SetMethod?.ReturnParameter.GetRequiredCustomModifiers().Contains(typeof(IsExternalInit)) != true) + { + violations.Add($"IntegrationEventBase.{name} must remain a required {expectedType.Name} init property."); + } + } + + foreach (var name in new[] { nameof(IIntegrationEvent.Topic), nameof(IIntegrationEvent.PartitionKey) }) + { + var property = contract.GetProperty(name); + if (property?.PropertyType != typeof(string) || property.GetMethod?.IsAbstract != true) + violations.Add($"IntegrationEventBase.{name} must remain an abstract string getter."); + } + + var writer = contract.GetMethod(nameof(IntegrationEventBase.ToPayloadJson), Type.EmptyTypes); + if (writer is null || writer.IsVirtual || writer.ReturnType != typeof(string)) + violations.Add("IntegrationEventBase.ToPayloadJson must remain the non-virtual payload writer."); + if (!IntegrationEventBase.PayloadJsonOptions.IsReadOnly + || IntegrationEventBase.PayloadJsonOptions.PropertyNamingPolicy is not null) + violations.Add("The common payload options must remain frozen and preserve property names."); + + violations.AddRange(ControlViolations(InheritanceViolations, + [typeof(DirectInterfaceFact)], [typeof(ApprovedIdentifiersFact)])); + AssertClean(violations, "inherit IntegrationEventBase; keep its required identity, abstract routing and frozen writer contract"); + } + + /// ADR-0057: declare and derive canonical GUID partition keys for every concrete production event. + [Fact] + public void Integration_Event_Declares_PartitionKey() + { + var violations = ProductionEvents().Where(type => !type.IsAbstract) + .SelectMany(PartitionViolations).ToList(); + violations.AddRange(ControlViolations(PartitionViolations, + [typeof(ConstantPartitionFact), typeof(UnrelatedPartitionFact), typeof(MissingPartitionFact), + typeof(EventIdentityPartitionFact), typeof(NullablePartitionFact), typeof(ImplicitTenantFact), + typeof(MislabelledTenantFact), typeof(UnknownSourceFact), typeof(ComputedSourceFact), + typeof(EmptySourceFact), typeof(UppercasePartitionFact)], + [typeof(ApprovedIdentifiersFact), typeof(TenantWideFact), typeof(TypedPartitionFact)])); + AssertClean(violations, "declare [IntegrationEventPartition] on each concrete event and derive PartitionKey from its stored, non-nullable aggregate identifier using ToString(\"D\"); TenantId requires tenantWide: true"); + } + + /// ADR-0057: a closed identifier-only shape and concrete-runtime JSON round-trip across production. + [Fact] + public void Integration_Event_Payloads_Contain_Only_Identifiers() + { + var events = ProductionEvents(); + var violations = events.SelectMany(PayloadShapeViolations).ToList(); + violations.AddRange(ControlViolations(PayloadShapeViolations, + [typeof(PayloadFact), typeof(PayloadFact), typeof(PayloadFact>), + typeof(PayloadFact), typeof(PayloadFact), typeof(PayloadFact), + typeof(PayloadFact), typeof(InheritedTextFact), typeof(ExtraFieldFact), + typeof(HiddenRoutingFact), typeof(RemappedInterfaceFact), + typeof(IgnoredIdentifierFact), typeof(RenamedIdentifierFact), typeof(ConvertedFact), + typeof(SerializingFact), typeof(SerializedFact), typeof(DeserializingFact), + typeof(DeserializedFact), typeof(EnumerableFact), typeof(HiddenWriterFact)], + [typeof(ApprovedIdentifiersFact), typeof(TenantWideFact), typeof(TypedPartitionFact)])); + + // Real production coverage is mandatory; the clean fixture supplies extra fields + // the deliberately minimal TenantCreated fact does not need. Both use this writer. + violations.AddRange(events.Where(type => !type.IsAbstract) + .Append(typeof(ApprovedIdentifiersFact)).SelectMany(PayloadRoundTripViolations)); + AssertClean(violations, "carry only Guid, TenantId, OrganizationId or UserId properties (nullable is allowed); remove hidden members, fields and serialization overrides, and preserve every identifier through the concrete runtime payload"); + } + + private static Type[] ProductionEvents() + { + var assemblies = ProductionAssemblies.All().ToArray(); + var loaded = assemblies.Select(assembly => assembly.GetName().Name!).Order(StringComparer.Ordinal).ToArray(); + var expected = ProductionAssemblies.Names().ToArray(); + var moduleProjects = Modules.Names.SelectMany(module => new[] + { + $"LearnStack.Modules.{module}.Domain", $"LearnStack.Modules.{module}.Application.Contracts", + $"LearnStack.Modules.{module}.Application", $"LearnStack.Modules.{module}.Infrastructure", + }).Order(StringComparer.Ordinal).ToArray(); + Assert.True(expected.Length != 0 && Modules.Names.Count != 0 + && loaded.SequenceEqual(expected) + && loaded.Where(name => name.StartsWith("LearnStack.Modules.", StringComparison.Ordinal)).SequenceEqual(moduleProjects), + "The integration-event census must load every production project and all four layers of every discovered module. " + + $"Missing projects: {string.Join(", ", expected.Except(loaded).Concat(moduleProjects.Except(loaded)).Order(StringComparer.Ordinal))}. " + + $"Unexpected module projects: {string.Join(", ", loaded.Where(name => name.StartsWith("LearnStack.Modules.", StringComparison.Ordinal)).Except(moduleProjects).Order(StringComparer.Ordinal))}. " + + "Fix: reference all production projects from LearnStack.Tests.Architecture and preserve the four-layer module layout."); + + var events = assemblies.SelectMany(assembly => assembly.GetTypes()) + .Where(type => !type.IsInterface && type != typeof(IntegrationEventBase) + && typeof(IIntegrationEvent).IsAssignableFrom(type)) + .OrderBy(type => type.FullName, StringComparer.Ordinal).ToArray(); + Assert.True(events.Any(type => !type.IsAbstract) && events.Contains(typeof(TenantCreatedIntegrationEventV1)), + "The production integration-event census is empty or omitted TenantCreatedIntegrationEventV1. " + + "Fix: scan IIntegrationEvent implementations across ProductionAssemblies.All(), including Application.Contracts."); + return events; + } + + private static IEnumerable InheritanceViolations(Type type) + { + if (!typeof(IntegrationEventBase).IsAssignableFrom(type) || !IsRecord(type)) + yield return $"{type.FullName}: integration events must inherit the IntegrationEventBase record."; + } + + private static bool IsRecord(Type type) => + type.GetMethod("$", BindingFlags.Instance | BindingFlags.Public)?.ReturnType == type; + + private static IEnumerable EventHierarchy(Type type) + { + for (var current = type; current is not null && current != typeof(IntegrationEventBase); current = current.BaseType) + yield return current; + } + + private static IEnumerable PayloadShapeViolations(Type type) + { + if (!typeof(IntegrationEventBase).IsAssignableFrom(type)) + { + yield return $"{type.FullName}: payload does not use IntegrationEventBase."; + yield break; + } + + foreach (var contract in SerializationInterfaces.Where(contract => contract.IsAssignableFrom(type))) + yield return $"{type.FullName}: serialization interface {contract.Name} changes the object contract."; + + var names = new HashSet(BaseNames, StringComparer.Ordinal); + foreach (var current in EventHierarchy(type)) + { + foreach (var member in current.GetMembers(DeclaredMembers).Prepend(current)) + { + if (member.GetCustomAttributesData().Any(attribute => typeof(JsonAttribute).IsAssignableFrom(attribute.AttributeType))) + yield return $"{type.FullName}: JSON attribute on {current.Name}.{member.Name}."; + if (member is FieldInfo field && !field.IsDefined(typeof(CompilerGeneratedAttribute))) + yield return $"{type.FullName}: instance field {current.Name}.{field.Name}; use identifier properties."; + if (member is MethodInfo method && method.Name == nameof(IntegrationEventBase.ToPayloadJson) + && method.GetParameters().Length == 0) + yield return $"{type.FullName}: hides the common payload writer."; + if (member is not PropertyInfo property || property.GetMethod?.IsPublic != true) continue; + if (property.GetIndexParameters().Length != 0) + yield return $"{type.FullName}: indexer {current.Name}.{property.Name}."; + if (property.Name is nameof(IIntegrationEvent.Topic) or nameof(IIntegrationEvent.PartitionKey) + && property.GetMethod.GetBaseDefinition().DeclaringType == typeof(IntegrationEventBase)) continue; + if (!names.Add(property.Name)) + yield return $"{type.FullName}: hidden or duplicate payload member {current.Name}.{property.Name}."; + if (!IsIdentifier(property.PropertyType)) + yield return $"{type.FullName}: {current.Name}.{property.Name} has forbidden payload type {property.PropertyType}."; + } + } + + foreach (var target in type.GetInterfaceMap(typeof(IIntegrationEvent)).TargetMethods + .Where(target => target.GetBaseDefinition().DeclaringType != typeof(IntegrationEventBase))) + yield return $"{type.FullName}: replaces IIntegrationEvent member {target.Name}."; + } + + private static bool IsIdentifier(Type type) + { + type = Nullable.GetUnderlyingType(type) ?? type; + return type == typeof(Guid) || type == typeof(TenantId) + || type == typeof(OrganizationId) || type == typeof(UserId); + } + + private static IEnumerable PartitionViolations(Type type) + { + var prefix = $"{type.FullName}: "; + var declaration = type.GetCustomAttribute(inherit: false); + if (declaration is null) + { + yield return prefix + "missing its own [IntegrationEventPartition] declaration."; + yield break; + } + + var source = type.GetProperties(BindingFlags.Instance | BindingFlags.Public) + .FirstOrDefault(property => property.Name == declaration.SourceProperty); + if (source?.GetMethod?.IsPublic != true || source.GetIndexParameters().Length != 0 + || !IsIdentifier(source.PropertyType) || Nullable.GetUnderlyingType(source.PropertyType) is not null + || source.Name == nameof(IIntegrationEvent.EventId)) + { + yield return prefix + $"{declaration.SourceProperty} is not a public, non-nullable aggregate identifier source."; + yield break; + } + + if ((source.Name == nameof(IIntegrationEvent.TenantId)) != declaration.TenantWide) + yield return prefix + "TenantId partitioning requires exactly an explicit tenant-wide declaration."; + if (!IsStoredIdentifier(source)) + yield return prefix + $"{source.Name} must read its own stored identifier, rather than compute an unrelated identity."; + var partition = type.GetProperty(nameof(IIntegrationEvent.PartitionKey))?.GetMethod; + if (partition is null || !DerivesCanonicalKey(partition, source)) + yield return prefix + $"PartitionKey must derive directly from {source.Name}'s GUID using ToString(\"D\")."; + + // Change tenant, event and aggregate IDs independently. A coincidentally matching + // sample must not rescue a constant or another identifier's partition key. + for (var variation = 0; variation < 4; variation++) + { + string? failure; + try + { + var fact = Sample(type, variation); + var sourceId = IdentifierValue(source.GetValue(fact)); + var expected = SampleIdentifier(source.Name, variation); + failure = sourceId == Guid.Empty || sourceId != expected + || !string.Equals(fact.PartitionKey, sourceId.Value.ToString("D"), StringComparison.Ordinal) + ? prefix + $"source/key value probe {variation} did not preserve the initialized source's canonical GUID." + : null; + } + catch (Exception exception) when (exception is ArgumentException or InvalidOperationException + or NotSupportedException or JsonException or TargetInvocationException) + { + failure = prefix + $"source/key value probe {variation} could not create/read the concrete event ({exception.GetType().Name})."; + } + if (failure is not null) yield return failure; + } + } + + private static bool IsStoredIdentifier(PropertyInfo property) + { + var getter = property.GetMethod!; + using var module = ModuleDefinition.ReadModule(getter.Module.Assembly.Location); + var method = (MethodDefinition)module.LookupToken(getter.MetadataToken); + if (!method.HasBody) return false; + var instructions = method.Body.Instructions.Where(instruction => instruction.OpCode.Code != Code.Nop).ToArray(); + var backing = property.DeclaringType!.GetField($"<{property.Name}>k__BackingField", DeclaredMembers); + return backing?.IsDefined(typeof(CompilerGeneratedAttribute)) == true + && backing.FieldType == property.PropertyType + && instructions.Length == 3 && instructions[0].OpCode.Code == Code.Ldarg_0 + && instructions[1].OpCode.Code == Code.Ldfld && instructions[2].OpCode.Code == Code.Ret + && instructions[1].Operand is FieldReference field + && field.Name == backing.Name && ReflectionName(field.DeclaringType) == property.DeclaringType.FullName; + } + + private static bool DerivesCanonicalKey(MethodInfo getter, PropertyInfo source) + { + using var module = ModuleDefinition.ReadModule(getter.Module.Assembly.Location); + var method = (MethodDefinition)module.LookupToken(getter.MetadataToken); + if (!method.HasBody) return false; + var instructions = method.Body.Instructions.ToArray(); + // Accept compiler local spills in Debug/Release, but no constants other than D, + // conditional paths, arithmetic, other field reads or helper calls. Unsupported + // derivations fail loudly; add a proof before extending this deliberately small grammar. + if (instructions.Any(instruction => instruction.OpCode.Code is not + (Code.Nop or Code.Ldarg_0 or Code.Ldstr or Code.Call or Code.Callvirt or Code.Ret + or Code.Ldloc or Code.Ldloc_S or Code.Ldloc_0 or Code.Ldloc_1 or Code.Ldloc_2 or Code.Ldloc_3 + or Code.Ldloca or Code.Ldloca_S or Code.Stloc or Code.Stloc_S or Code.Stloc_0 + or Code.Stloc_1 or Code.Stloc_2 or Code.Stloc_3 or Code.Br or Code.Br_S))) return false; + if (!instructions.Where(instruction => instruction.OpCode.Code == Code.Ldstr) + .Select(instruction => instruction.Operand).SequenceEqual(new object[] { "D" })) return false; + var calls = instructions.Where(instruction => instruction.OpCode.Code is Code.Call or Code.Callvirt) + .Select(instruction => (MethodReference)instruction.Operand).ToArray(); + var typed = source.PropertyType != typeof(Guid); + return calls.Length == (typed ? 3 : 2) + && calls[0].Name == source.GetMethod!.Name && calls[0].Parameters.Count == 0 + && ReflectionName(calls[0].DeclaringType) == source.DeclaringType!.FullName + && (!typed || (calls[1].Name == "get_Value" && calls[1].Parameters.Count == 0 + && ReflectionName(calls[1].DeclaringType) == source.PropertyType.FullName)) + && calls[^1].DeclaringType.FullName == typeof(Guid).FullName && calls[^1].Name == nameof(Guid.ToString) + && calls[^1].Parameters.Count == 1 && calls[^1].Parameters[0].ParameterType.FullName == typeof(string).FullName; + } + + private static string ReflectionName(TypeReference type) => type.FullName.Replace('/', '+'); + + private static IntegrationEventBase Sample(Type type, int variation) + { + var values = type.GetProperties(BindingFlags.Instance | BindingFlags.Public) + .Where(property => property.GetIndexParameters().Length == 0 && IsIdentifier(property.PropertyType)) + .ToDictionary(property => property.Name, + property => (object?)(Nullable.GetUnderlyingType(property.PropertyType) is not null && variation == 1 + ? null : SampleIdentifier(property.Name, variation)), StringComparer.Ordinal); + values[nameof(IIntegrationEvent.OccurredAt)] = DateTimeOffset.UnixEpoch.AddHours(variation); + return (IntegrationEventBase)(JsonSerializer.Deserialize(JsonSerializer.Serialize(values), type, + IntegrationEventBase.PayloadJsonOptions) ?? throw new InvalidOperationException("The concrete event deserialized to null.")); + } + + private static Guid SampleIdentifier(string property, int variation) + { + // Ordinal character folding gives every property a repeatable distinct identifier; + // no randomized string hashes, clocks or random IDs enter an architecture failure. + var discriminator = property.Aggregate(0, (value, character) => (value * 31 + character) & 0x7fff); + var changed = property == nameof(IIntegrationEvent.TenantId) ? variation == 1 + : property == nameof(IIntegrationEvent.EventId) ? variation == 2 : variation == 3; + return new Guid(0x01988331 + (changed ? 1 : 0), (short)discriminator, 0x7abc, + 0x8d, 0xef, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff); + } + + private static Guid? IdentifierValue(object? value) => value switch + { + Guid id => id, + IStronglyTypedId id when id.IsInitialized() => id.Value, + _ => null, + }; + + private static IEnumerable PayloadRoundTripViolations(Type type) + { + if (PayloadShapeViolations(type).Any()) yield break; + for (var variation = 0; variation < 2; variation++) + { + var failures = new List(); + try + { + IntegrationEventBase fact = Sample(type, variation); + var json = fact.ToPayloadJson(); + using var document = JsonDocument.Parse(json); + var revived = JsonSerializer.Deserialize(json, fact.GetType(), IntegrationEventBase.PayloadJsonOptions) as IntegrationEventBase; + var properties = type.GetProperties(BindingFlags.Instance | BindingFlags.Public); + if (document.RootElement.ValueKind != JsonValueKind.Object + || !document.RootElement.EnumerateObject().Select(property => property.Name).Order(StringComparer.Ordinal) + .SequenceEqual(properties.Select(property => property.Name).Order(StringComparer.Ordinal))) + failures.Add($"{type.FullName}: concrete payload members differ from the reflected event shape."); + if (revived?.GetType() != type || revived.EventId != fact.EventId || revived.TenantId != fact.TenantId + || revived.OccurredAt != fact.OccurredAt || revived.Topic != fact.Topic || revived.PartitionKey != fact.PartitionKey) + failures.Add($"{type.FullName}: concrete runtime JSON did not round-trip the common event contract."); + foreach (var property in properties.Where(property => IsIdentifier(property.PropertyType))) + { + var expected = IdentifierValue(property.GetValue(fact)); + if (!document.RootElement.TryGetProperty(property.Name, out var serialized) + || (expected.HasValue ? serialized.ValueKind != JsonValueKind.String || serialized.GetGuid() != expected.Value + : serialized.ValueKind != JsonValueKind.Null) + || revived is null || IdentifierValue(property.GetValue(revived)) != expected) + failures.Add($"{type.FullName}: identifier {property.Name} was dropped or changed by concrete runtime JSON (probe {variation})."); + } + } + catch (Exception exception) when (exception is ArgumentException or InvalidOperationException + or NotSupportedException or JsonException or TargetInvocationException) + { + failures.Add($"{type.FullName}: concrete runtime JSON probe {variation} failed ({exception.GetType().Name})."); + } + foreach (var failure in failures) yield return failure; + } + } + + private static IEnumerable ControlViolations(Func> predicate, Type[] dirty, Type[] clean) + { + foreach (var type in dirty.Where(type => !predicate(type).Any())) + yield return $"Planted violation {type.FullName} escaped the production predicate."; + foreach (var type in clean) + foreach (var failure in predicate(type)) + yield return $"Clean control rejected: {failure}"; + } + + private static void AssertClean(IEnumerable violations, string fix) + { + var failures = violations.Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray(); + Assert.True(failures.Length == 0, string.Join(Environment.NewLine, failures) + $"{Environment.NewLine}Fix: {fix}."); + } + + // These fixtures are intentionally outside ProductionAssemblies.All(). The same + // predicates must distinguish them; enqueue's internal validator is never called. + private sealed class DirectInterfaceFact : IIntegrationEvent + { + public Guid EventId => Guid.Empty; + public Guid TenantId => Guid.Empty; + public DateTimeOffset OccurredAt => DateTimeOffset.UnixEpoch; + public string Topic => "learnstack.test.fact"; + public string PartitionKey => "constant"; + } + + private abstract record AggregateFact : IntegrationEventBase + { + public Guid AggregateId { get; init; } + public override string Topic => "learnstack.test.fact"; + public override string PartitionKey => AggregateId.ToString("D"); + } + + [IntegrationEventPartition(nameof(AggregateId))] + private sealed record ApprovedIdentifiersFact : AggregateFact + { + public Guid RelatedId { get; init; } + public Guid? OptionalId { get; init; } + public TenantId RelatedTenant { get; init; } + public TenantId? OptionalTenant { get; init; } + public OrganizationId RelatedOrganization { get; init; } + public OrganizationId? OptionalOrganization { get; init; } + public UserId RelatedUser { get; init; } + public UserId? OptionalUser { get; init; } + } + + [IntegrationEventPartition(nameof(TenantId), tenantWide: true)] + private sealed record TenantWideFact : AggregateFact + { + public override string PartitionKey => TenantId.ToString("D"); + } + + [IntegrationEventPartition(nameof(SubjectId))] + private sealed record TypedPartitionFact : AggregateFact + { + public OrganizationId SubjectId { get; init; } + public override string PartitionKey => SubjectId.Value.ToString("D"); + } + + [IntegrationEventPartition(nameof(AggregateId))] + private sealed record ConstantPartitionFact : AggregateFact + { + public override string PartitionKey => "01988331-aaaa-7abc-8def-aabbccddeeff"; + } + + [IntegrationEventPartition(nameof(AggregateId))] + private sealed record UnrelatedPartitionFact : AggregateFact + { + public override string PartitionKey => EventId.ToString("D"); + } + + private sealed record MissingPartitionFact : AggregateFact; + [IntegrationEventPartition(nameof(EventId))] private sealed record EventIdentityPartitionFact : AggregateFact; + [IntegrationEventPartition(nameof(TenantId))] private sealed record ImplicitTenantFact : AggregateFact; + [IntegrationEventPartition(nameof(AggregateId), tenantWide: true)] private sealed record MislabelledTenantFact : AggregateFact; + [IntegrationEventPartition("UnknownId")] private sealed record UnknownSourceFact : AggregateFact; + + [IntegrationEventPartition(nameof(SubjectId))] + private sealed record NullablePartitionFact : AggregateFact + { + public Guid? SubjectId { get; init; } + } + + [IntegrationEventPartition(nameof(SubjectId))] + private sealed record ComputedSourceFact : AggregateFact + { + public Guid SubjectId => EventId; + public override string PartitionKey => SubjectId.ToString("D"); + } + + [IntegrationEventPartition(nameof(SubjectId))] + private sealed record EmptySourceFact : AggregateFact + { + public Guid SubjectId { get; } = Guid.Empty; + public override string PartitionKey => SubjectId.ToString("D"); + } + + [IntegrationEventPartition(nameof(AggregateId))] + private sealed record UppercasePartitionFact : AggregateFact + { + public override string PartitionKey => AggregateId.ToString("D").ToUpperInvariant(); + } + + private record PayloadFact : AggregateFact + { + public T Data { get; init; } = default!; + } + private sealed record InheritedTextFact : PayloadFact; + private readonly record struct UnapprovedId(Guid Value) : IStronglyTypedId + { + public bool IsInitialized() => true; + } + private sealed record ExtraFieldFact : AggregateFact { public Guid ExtraId = Guid.Empty; } + private sealed record HiddenRoutingFact : AggregateFact { public new Guid Topic { get; init; } } + private sealed record RemappedInterfaceFact : AggregateFact, IIntegrationEvent { Guid IIntegrationEvent.TenantId => Guid.Empty; } + private sealed record IgnoredIdentifierFact : AggregateFact { [JsonIgnore] public Guid RelatedId { get; init; } } + private sealed record RenamedIdentifierFact : AggregateFact { [JsonPropertyName("OtherId")] public Guid RelatedId { get; init; } } + private sealed record HiddenWriterFact : AggregateFact { public new string ToPayloadJson() => EventId.ToString("D"); } + private sealed record SerializingFact : AggregateFact, IJsonOnSerializing { public void OnSerializing() { } } + private sealed record SerializedFact : AggregateFact, IJsonOnSerialized { public void OnSerialized() { } } + private sealed record DeserializingFact : AggregateFact, IJsonOnDeserializing { public void OnDeserializing() { } } + private sealed record DeserializedFact : AggregateFact, IJsonOnDeserialized { public void OnDeserialized() { } } + private sealed record EnumerableFact : AggregateFact, IEnumerable + { + public IEnumerator GetEnumerator() => Array.Empty().GetEnumerator(); + } + + [JsonConverter(typeof(FactConverter))] + private sealed record ConvertedFact : AggregateFact; + private sealed class FactConverter : JsonConverter + { + public override ConvertedFact? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => throw new NotSupportedException(); + public override void Write(Utf8JsonWriter writer, ConvertedFact value, JsonSerializerOptions options) => throw new NotSupportedException(); + } +} diff --git a/backend/tests/LearnStack.Tests.Architecture/OutboxProducerConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/OutboxProducerConventionTests.cs new file mode 100644 index 00000000..65b84a1a --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/OutboxProducerConventionTests.cs @@ -0,0 +1,219 @@ +using FluentAssertions; +using LearnStack.Infrastructure.Messaging; +using LearnStack.SharedKernel.Messaging; +using Mono.Cecil; +using Xunit; + +namespace LearnStack.Tests.Architecture; + +/// ADR-0057: enqueue borrows the admitted unit; only its owner commits. +public sealed class OutboxProducerConventionTests +{ + [Fact] + public void Outbox_Enqueue_Uses_The_Announced_Transaction() + { + var modules = ReadProductionModules(); + try + { + modules.Select(module => module.Assembly.Name.Name).Should().BeEquivalentTo(ProductionAssemblies.Names(), + "Fix: every production helper assembly must participate in the producer walk"); + var types = TypeMap(modules); + var roots = Roots(types); + roots.Select(type => type.FullName).Should().Contain(typeof(PostgresOutbox).FullName, + "Fix: classify the real registered enqueue implementation before scanning its helpers"); + Offenders(roots, types).Should().BeEmpty( + "Fix: enqueue must reuse IUnitOfWork.Connection/Transaction; no helper may open, commit, roll back or publish independently"); + } + finally + { + foreach (var module in modules) module.Dispose(); + } + } + + [Fact] + public void Producer_walk_detects_connection_commit_publication_and_interface_helpers() + { + using var module = ModuleDefinition.ReadModule(typeof(OutboxProducerConventionTests).Assembly.Location); + using var kernel = ModuleDefinition.ReadModule(typeof(IOutbox).Assembly.Location); + var types = TypeMap([module, kernel]); + Roots(types).Select(type => type.FullName).Should().Contain(typeof(InheritedOutboxProbe).FullName!.Replace('+', '/'), + "Fix: an inherited IOutbox implementation must remain in the production census"); + foreach (var probe in new[] { typeof(ConnectionProbe), typeof(CommitProbe), typeof(PublishProbe), + typeof(InterfaceProbe), typeof(AsyncProbe), typeof(LocatorProbe), + typeof(AbstractDispatchProbe), typeof(VirtualDispatchProbe) }) + { + Offenders([module.GetType(probe.FullName!.Replace('+', '/'))], types).Should().NotBeEmpty( + $"Fix: the same producer walk must reject {probe.Name}"); + } + foreach (var probe in new[] { typeof(CleanProbe), typeof(CleanAbstractDispatchProbe), typeof(CleanVirtualDispatchProbe) }) + Offenders([module.GetType(probe.FullName!.Replace('+', '/'))], types).Should().BeEmpty( + $"Fix: the same producer walk must preserve clean {probe.Name}"); + } + + [Fact] + public void Producer_walk_reaches_helpers_in_other_production_assemblies() + { + var modules = ReadProductionModules(); + try + { + var owner = modules.Single(module => module.Name == "LearnStack.Infrastructure.dll"); + var root = owner.GetType(typeof(PostgresOutbox).FullName!); + foreach (var name in new[] { "LearnStack.Application.dll", "LearnStack.Modules.Tenancy.Application.Contracts.dll" }) + { + var external = modules.Single(module => module.Name == name); + var helper = new TypeDefinition("ReviewProbe", "ProducerHelper", TypeAttributes.Public | TypeAttributes.Class); + helper.Fields.Add(new FieldDefinition("Bus", FieldAttributes.Public, external.ImportReference(typeof(IEventBus)))); + external.Types.Add(helper); + var field = new FieldDefinition("ReviewHelper", FieldAttributes.Private, owner.ImportReference(helper)); + root.Fields.Add(field); + Offenders([root], TypeMap(modules)).Should().Contain(item => item.StartsWith(helper.FullName, StringComparison.Ordinal), + "Fix: a producer helper in another layer must not escape the production walk"); + root.Fields.Remove(field); + external.Types.Remove(helper); + } + } + finally + { + foreach (var module in modules) module.Dispose(); + } + } + + private static List ReadProductionModules() + { + var modules = new List(); + try + { + foreach (var assembly in ProductionAssemblies.All()) modules.Add(ModuleDefinition.ReadModule(assembly.Location)); + return modules; + } + catch + { + foreach (var module in modules) module.Dispose(); + throw; + } + } + + private static Dictionary<(string Assembly, string Type), TypeDefinition> TypeMap(IEnumerable modules) => + modules.SelectMany(module => module.GetTypes()).ToDictionary(type => (type.Module.Assembly.Name.Name, type.FullName)); + + private static TypeDefinition[] Roots(Dictionary<(string Assembly, string Type), TypeDefinition> types) + { + var contract = types[(typeof(IOutbox).Assembly.GetName().Name!, typeof(IOutbox).FullName!)]; + return types.Values.Where(type => !type.IsInterface && !type.IsAbstract && Implements(type, contract, types)).ToArray(); + } + + private static (string Assembly, string Type) Key(TypeReference reference) => + (reference.Scope is AssemblyNameReference assembly ? assembly.Name + : reference.Scope is ModuleDefinition module ? module.Assembly.Name.Name : reference.Scope.Name, + reference.FullName); + + private static SortedSet Offenders(IEnumerable roots, + Dictionary<(string Assembly, string Type), TypeDefinition> types) + { + var visited = new HashSet<(string Assembly, string Type)>(); + var offenders = new SortedSet(StringComparer.Ordinal); + var queue = new Queue(roots); + while (queue.TryDequeue(out var type)) + { + if (!visited.Add(Key(type))) continue; + foreach (var reference in Il.ReferencedTypeReferences(type)) + { + if (ForbiddenCapability(reference.FullName)) offenders.Add(type.FullName + " -> " + reference.FullName); + if (reference.FullName == "LearnStack.SharedKernel.Persistence.IUnitOfWork") continue; + if (!types.TryGetValue(Key(reference), out var helper)) continue; + queue.Enqueue(helper); + if (!helper.IsSealed) + { + foreach (var target in types.Values.Where(candidate => !candidate.IsInterface && Implements(candidate, helper, types))) + queue.Enqueue(target); + } + } + foreach (var method in Il.Methods(type).Select(item => item.Definition).Where(method => method.HasBody)) + { + foreach (var instruction in method.Body.Instructions) + { + if (instruction.Operand is not MethodReference call) continue; + var owner = call.DeclaringType.FullName; + var connection = owner is "Npgsql.NpgsqlConnection" or "System.Data.Common.DbConnection" or "System.Data.IDbConnection"; + var transaction = owner is "Npgsql.NpgsqlTransaction" or "System.Data.Common.DbTransaction" + or "System.Data.IDbTransaction" or "LearnStack.SharedKernel.Persistence.IUnitOfWork"; + if ((connection && call.Name is ".ctor" or "Open" or "OpenAsync" or "BeginTransaction" or "BeginTransactionAsync") + || (transaction && call.Name is "BeginTransactionAsync" or "Commit" or "CommitAsync" or "Rollback" or "RollbackAsync") + || owner.StartsWith("Npgsql.NpgsqlDataSource", StringComparison.Ordinal) + || owner is "System.Data.Common.DbDataSource" or "System.Data.Common.DbProviderFactory") + offenders.Add(type.FullName + " -> " + call.FullName); + } + } + } + return offenders; + } + + private static bool Implements(TypeDefinition candidate, TypeDefinition contract, + Dictionary<(string Assembly, string Type), TypeDefinition> types) + { + var queue = new Queue(candidate.Interfaces.Select(item => item.InterfaceType)); + if (candidate.BaseType is { } parent) queue.Enqueue(parent); + var visited = new HashSet<(string Assembly, string Type)>(); + while (queue.TryDequeue(out var reference)) + { + if (reference is GenericInstanceType generic) reference = generic.ElementType; + if (!visited.Add(Key(reference))) continue; + if (Key(reference) == Key(contract)) return true; + if (!types.TryGetValue(Key(reference), out var definition)) continue; + foreach (var inherited in definition.Interfaces) queue.Enqueue(inherited.InterfaceType); + if (definition.BaseType is { } baseType) queue.Enqueue(baseType); + } + return false; + } + + private static bool ForbiddenCapability(string name) => name is + "LearnStack.SharedKernel.Messaging.IEventBus" or "MediatR.IPublisher" or "MediatR.ISender" or "MediatR.IMediator" + or "System.IServiceProvider" or "Microsoft.Extensions.DependencyInjection.IServiceScopeFactory" + || name.StartsWith("Npgsql.NpgsqlDataSource", StringComparison.Ordinal); + + private sealed class ConnectionProbe + { + public static void Run() { using var connection = new Npgsql.NpgsqlConnection(); connection.Open(); } + } + private sealed class CommitProbe(LearnStack.SharedKernel.Persistence.IUnitOfWork unit) + { + public Task Run() => unit.CommitAsync(); + } + private sealed class PublishProbe(IEventBus bus) + { + public Task Run(IntegrationEventEnvelope envelope) => bus.PublishAsync(envelope); + } + private abstract class AbstractOutboxProbe : IOutbox + { + public Task EnqueueAsync(IIntegrationEvent integrationEvent, CancellationToken cancellationToken = default) => Task.CompletedTask; + } + private sealed class InheritedOutboxProbe : AbstractOutboxProbe; + private interface IHelper { void Run(); } + private abstract class HelperBase : IHelper { public abstract void Run(); } + private sealed class DirtyHelper : HelperBase { public override void Run() => ConnectionProbe.Run(); } + private sealed class InterfaceProbe(IHelper helper) { public void Run() => helper.Run(); } + private abstract class AbstractDispatchHelper { public abstract void Run(); } + private sealed class DirtyAbstractDispatchHelper : AbstractDispatchHelper { public override void Run() => ConnectionProbe.Run(); } + private sealed class AbstractDispatchProbe(AbstractDispatchHelper helper) { public void Run() => helper.Run(); } + private class VirtualDispatchHelper { public virtual void Run() { } } + private sealed class DirtyVirtualDispatchHelper : VirtualDispatchHelper { public override void Run() => ConnectionProbe.Run(); } + private sealed class VirtualDispatchProbe(VirtualDispatchHelper helper) { public void Run() => helper.Run(); } + private abstract class CleanAbstractDispatchHelper { public abstract void Run(); } + private sealed class CleanAbstractDispatchImplementation : CleanAbstractDispatchHelper { public override void Run() { } } + private sealed class CleanAbstractDispatchProbe(CleanAbstractDispatchHelper helper) { public void Run() => helper.Run(); } + private class CleanVirtualDispatchHelper { public virtual void Run() { } } + private sealed class CleanVirtualDispatchImplementation : CleanVirtualDispatchHelper { public override void Run() { } } + private sealed class CleanVirtualDispatchProbe(CleanVirtualDispatchHelper helper) { public void Run() => helper.Run(); } + private sealed class AsyncProbe + { + public static async Task Run() { await Task.Yield(); ConnectionProbe.Run(); } + } + private sealed class LocatorProbe(IServiceProvider provider) + { + public object? Run() => provider.GetService(typeof(IEventBus)); + } + private sealed class CleanProbe(LearnStack.SharedKernel.Persistence.IUnitOfWork unit) + { + public System.Data.Common.DbTransaction? Read() => unit.Transaction; + } +} diff --git a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs index 96ad6cb8..d5bb70a7 100644 --- a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs @@ -3,6 +3,7 @@ using FluentAssertions; using LearnStack.Api.Composition; using LearnStack.Application.Pipeline; +using LearnStack.Application.Persistence; using LearnStack.Infrastructure.Persistence; using LearnStack.Modules.Tenancy.Infrastructure.Persistence; using LearnStack.SharedKernel.Persistence; @@ -234,8 +235,8 @@ public void TransactionBehavior_Does_Not_Reference_A_Module_Assembly() { // The seam exists so that the behavior owning the commit boundary never // has to name a module. Two assertions: the assembly takes no build-time - // reference to one, and the behavior's own surface names IUnitOfWork and - // no DbContext. + // reference to one, and the behavior delegates to a coordinator whose + // surface names IUnitOfWork and no DbContext. // The PROJECT file, not only the emitted assembly-reference table. The // compiler elides a reference whose types the IL never touches, so an // unused to a module would leave a reflection-only @@ -267,6 +268,9 @@ public void TransactionBehavior_Does_Not_Reference_A_Module_Assembly() var constructor = typeof(TransactionBehavior<,>).GetConstructors().Single(); constructor.GetParameters().Select(parameter => parameter.ParameterType) + .Should().Equal(typeof(TransactionExecutionCoordinator)); + typeof(TransactionExecutionCoordinator).GetConstructors().Single() + .GetParameters().Select(parameter => parameter.ParameterType) .Should().Contain(typeof(IUnitOfWork)) .And.NotContain(parameter => typeof(DbContext).IsAssignableFrom(parameter)); } @@ -281,22 +285,83 @@ public void Modules_Do_Not_Parallelize_Over_The_Ambient_Connection() // stricter than its ADR sentence: which operations are DbContext-bound cannot be seen // from source, and the honest rule is that module code does not fan out at all. A // fan-out that touches no connection is added to ParallelSites with its reason. - var modules = Path.Combine(RepositoryPaths.BackendSrc(), "Modules"); - var files = Directory.EnumerateFiles(modules, "*.cs", SearchOption.AllDirectories) - .Where(file => !file.Split(Path.DirectorySeparatorChar).Any(segment => segment is "bin" or "obj")) - .ToList(); + // Shared application/infrastructure code also owns the scoped producer, collector, + // audit capture and transaction lifecycle, so it participates in the same census. + var sourceRoot = RepositoryPaths.BackendSrc(); + var files = AmbientSourceFiles(sourceRoot); files.Should().Contain(file => file.EndsWith("CommandHandler.cs", StringComparison.Ordinal), "the premise: the scan reads the module handlers that open the unit of work's work"); + var relativeFiles = files.Select(file => Path.GetRelativePath(sourceRoot, file).Replace('\\', '/')).ToArray(); + relativeFiles.Should().Contain([ + "LearnStack.Application/Messaging/ProducerExecution.cs", + "LearnStack.Application/Messaging/DomainEventCollector.cs", + "LearnStack.Infrastructure.Audit/AuditStateCapture.cs", + ], "the census includes shared scoped execution state, not only module callers"); + ParallelSites.Keys.Should().BeSubsetOf(relativeFiles, "an exception must name a real scanned source file"); + + ParallelOffenders(sourceRoot, files).Should().BeEmpty( + "modules and their shared execution code run one operation at a time on the ambient connection " + + "(ADR-0040 § Nesting)"); + } - files.Where(file => FansOut(StripComments(File.ReadAllText(file)))) - .Select(file => Path.GetRelativePath(modules, file).Replace('\\', '/')) - .Where(file => !ParallelSites.Contains(file)) - .Should().BeEmpty( - "module code runs one operation at a time on the ambient connection " - + "(ADR-0040 § Nesting)"); + [Fact] + public void The_parallelism_census_reaches_shared_code_and_bounds_its_singleton_exceptions() + { + var sourceRoot = Path.Combine(Path.GetTempPath(), "learnstack-parallel-census-" + Guid.NewGuid().ToString("N")); + string[] forbidden = + [ + "Modules/Probe/Application/CommandHandler.cs", + "LearnStack.Application/Messaging/ProducerExecution.cs", + "LearnStack.Infrastructure/Messaging/DomainEventSaveChangesInterceptor.cs", + "LearnStack.Infrastructure.Audit/AuditStateCapture.cs", + "Modules/Probe/Infrastructure/Caching/InMemoryCacheService.cs", + "Modules/Probe/Infrastructure/MultiTenancy/CachedHostToTenantResolver.cs", + ]; + string[] ignored = + [ + "LearnStack.Application/obj/Generated.cs", + "LearnStack.Infrastructure/bin/Generated.cs", + ]; + try + { + foreach (var path in forbidden.Concat(ignored).Concat(ParallelSites.Keys)) + { + var file = Path.Combine(sourceRoot, path); + Directory.CreateDirectory(Path.GetDirectoryName(file)!); + File.WriteAllText(file, "await Task.WhenAll(first, second);"); + } + + var files = AmbientSourceFiles(sourceRoot); + ParallelOffenders(sourceRoot, files).Should().BeEquivalentTo(forbidden, + "the same production census rejects fan-out in every shared execution project and cannot lend singleton exceptions to another path"); + ParallelSites.Values.Should().OnlyContain(reason => !string.IsNullOrWhiteSpace(reason)); + File.WriteAllText(Path.Combine(sourceRoot, forbidden[1]), "await first; await second;"); + ParallelOffenders(sourceRoot, files).Should().BeEquivalentTo(forbidden.Where(path => path != forbidden[1]), + "sequential shared execution remains legal under the same predicate"); + } + finally + { + if (Directory.Exists(sourceRoot)) Directory.Delete(sourceRoot, recursive: true); + } } + private static readonly string[] AmbientSourceProjects = + ["Modules", "LearnStack.Application", "LearnStack.Infrastructure", "LearnStack.Infrastructure.Audit"]; + + private static string[] AmbientSourceFiles(string sourceRoot) => + AmbientSourceProjects + .SelectMany(project => Directory.EnumerateFiles(Path.Combine(sourceRoot, project), "*.cs", SearchOption.AllDirectories)) + .Where(file => !Path.GetRelativePath(sourceRoot, file).Split(Path.DirectorySeparatorChar) + .Any(segment => segment is "bin" or "obj")) + .ToArray(); + + private static string[] ParallelOffenders(string sourceRoot, IEnumerable files) => + files.Where(file => FansOut(StripComments(File.ReadAllText(file)))) + .Select(file => Path.GetRelativePath(sourceRoot, file).Replace('\\', '/')) + .Where(file => !ParallelSites.ContainsKey(file)) + .ToArray(); + [Fact] public void The_Parallelism_Scan_Can_Actually_Fail() { @@ -331,16 +396,24 @@ public void The_Parallelism_Scan_Can_Actually_Fail() } /// - /// Module files allowed to fan out, by path under backend/src/Modules, each with why - /// none of its concurrent work touches the ambient connection. Empty. + /// Files allowed to fan out, by exact path under backend/src, each with why + /// its concurrent work does not use the scoped ambient connection. /// - private static readonly HashSet ParallelSites = new(StringComparer.Ordinal); + private static readonly Dictionary ParallelSites = new(StringComparer.Ordinal) + { + ["LearnStack.Infrastructure/Caching/InMemoryCacheService.cs"] = + "Singleton cache flights race their service-owned deadline and retain abandoned factories until termination; " + + "they hold no scoped unit of work or module DbContext.", + ["LearnStack.Infrastructure/MultiTenancy/CachedHostToTenantResolver.cs"] = + "Singleton host lookup flights run before tenant admission on their own read-only connection/transaction " + + "and hold no scoped unit of work or module DbContext (ADR-0036 and ADR-0040).", + }; /// /// Two shapes. The named APIs, and the hand-rolled one: a call to an …Async method /// whose result is stored rather than awaited is a task already running, and two of those - /// are `Task.WhenAll` written out longhand. Nothing in backend/src does it today — - /// measured — so module code awaits at the call site, which is the rule. + /// are `Task.WhenAll` written out longhand. Scoped module and shared execution code + /// awaits at the call site; singleton flight exceptions are declared above. /// private static bool FansOut(string code) => FanOut().IsMatch(code) || StartedWithoutAwaiting().IsMatch(code); diff --git a/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs index 93a85fc5..e6a58822 100644 --- a/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs @@ -385,6 +385,8 @@ public Task SetTenantContextAsync( // context here anyway, so nothing asks — but the honest answer is the safe one. public bool IsTenantContextIssuedOn(System.Data.Common.DbTransaction? transaction) => false; + public TenantContextAnnouncement? GetTenantContextAnnouncement(System.Data.Common.DbTransaction? transaction) => null; + public Task SetProvisioningTenantContextAsync( LearnStack.SharedKernel.Identifiers.TenantId tenantId, CancellationToken cancellationToken = default) => Task.CompletedTask; diff --git a/backend/tests/LearnStack.Tests.Integration/Database/AuditCaptureWiringTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/AuditCaptureWiringTests.cs index 2cecfda4..978d0ca5 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/AuditCaptureWiringTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/AuditCaptureWiringTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using LearnStack.Application.Pipeline; using LearnStack.Infrastructure.Audit; using LearnStack.Infrastructure.Audit.Capture; using LearnStack.Infrastructure.Persistence; @@ -181,6 +182,7 @@ public async Task The_real_composition_root_keeps_the_audit_interceptor_behind_a services.AddScoped(); services.AddLogging(); + services.AddLearnStackMediatRPipeline(); services.AddSingleton( new StaticTenantContextAccessor(new WiringContext())); services.AddTransient(sp => diff --git a/backend/tests/LearnStack.Tests.Integration/Database/DomainEventProductionTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/DomainEventProductionTests.cs new file mode 100644 index 00000000..fdf073d3 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/DomainEventProductionTests.cs @@ -0,0 +1,1046 @@ +using System.Diagnostics; +using System.Reflection; +using System.Text.Json; +using FluentAssertions; +using LearnStack.Application.Audit; +using LearnStack.Application.Messaging; +using LearnStack.Application.Persistence; +using LearnStack.Infrastructure.Messaging; +using LearnStack.Modules.Customization.Domain; +using LearnStack.Modules.Customization.Infrastructure.Persistence; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Domain; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Messaging; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.SharedKernel.Time; +using LearnStack.Tools.Seeder; +using MediatR; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +/// +/// Successful-save production through the real interceptor, owner coordinator and +/// app-role outbox. Probe facts are raised on mapped roots; their notification +/// handler has only the production outbox and GUID factory. +/// +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +[Collection(SharedSchema.Name)] +public sealed class DomainEventProductionTests(SchemaFixture schema) +{ + private const string Operation = "customization.content_type.domain_probe"; + private static readonly DateTimeOffset Occurred = new(2026, 10, 12, 12, 0, 0, TimeSpan.Zero); + private static readonly Guid HumanActor = new("12345678-1234-7123-8123-123456789abc"); + private static readonly Error Refusal = new(new LocalizedMessage("lockey_business_rule_violation")); + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Both_compositions_refuse_event_bearing_saves_without_an_enrolled_producer(bool api) + { + await using var dataSource = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + await using var seed = SeedComposition.Build(dataSource, new ActingContext(), NullLoggerFactory.Instance); + var observation = new DispatchObservation(); + await using var host = Host(schema.Postgres.AppConnectionString, observation); + TenantContentType source; + TenantContentType control; + await using (var scope = (api ? host.Services : seed).CreateAsyncScope()) + { + var services = scope.ServiceProvider; + var unit = services.GetRequiredService(); + await using var owner = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(new ActingContext()); + await SchemaQueries.AssertApplicationRoleAsync(unit.Connection); + unit.GetTenantContextAnnouncement(unit.Transaction).Should().NotBeNull(); + services.GetRequiredService().HasInvocation.Should().BeFalse(); + var db = services.GetRequiredService(); + + control = Source(services); + db.Add(control); + (await db.SaveChangesAsync()).Should().Be(1, "event-free saves do not require producer enrollment"); + (await CountSourceInUnitAsync(unit, control.Id.Value)).Should().Be(1); + + source = Source(services); + Raise(source, Fact()); + db.Add(source); + var save = () => db.SaveChangesAsync(); + await save.Should().ThrowAsync().WithMessage("*enrolled writable frame*"); + (await CountSourceInUnitAsync(unit, source.Id.Value)).Should().Be(0, + "staging refuses the fact before its business INSERT"); + source.DomainEvents.Should().ContainSingle("an unsuccessful save cannot consume the fact"); + (await OutboxInUnitAsync(unit)).Should().BeEmpty(); + observation.Events.Should().BeEmpty(); + } + + source.DomainEvents.Should().BeEmpty("scoped cleanup discards the refused source fact"); + (await CountSourceAsync(schema.Postgres.AppConnectionString, source.Id.Value)).Should().Be(0); + (await CountSourceAsync(schema.Postgres.AppConnectionString, control.Id.Value)).Should().Be(0); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Both_compositions_automatically_promote_a_successful_save_and_clear_on_refusal(bool api) + { + await using var dataSource = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + await using var seed = SeedComposition.Build(dataSource, new ActingContext(), NullLoggerFactory.Instance); + await using var host = Host(schema.Postgres.AppConnectionString, new DispatchObservation()); + await using var scope = (api ? host.Services : seed).CreateAsyncScope(); + var services = scope.ServiceProvider; + TenantContentType? source = null; + + var result = await RunAsync(services, async () => + { + var unit = services.GetRequiredService(); + await SchemaQueries.AssertApplicationRoleAsync(unit.Connection); + var db = services.GetRequiredService(); + source = Source(services); + Raise(source, Fact()); + db.Add(source); + CurrentInvocation(services).HasProducedWork.Should().BeFalse(); + (await db.SaveChangesAsync()).Should().Be(1); + (await CountSourceInUnitAsync(unit, source.Id.Value)).Should().Be(1); + CurrentInvocation(services).HasProducedWork.Should().BeTrue( + "the registered SaveChanges interceptor records production without caller bookkeeping"); + source.DomainEvents.Should().ContainSingle("the owner has not reached its terminal boundary"); + (await OutboxInUnitAsync(unit)).Should().BeEmpty("a save collects; only successful owner completion dispatches"); + return Result.Fail(Refusal); + }); + + result.Error.Should().BeSameAs(Refusal); + source!.DomainEvents.Should().BeEmpty(); + (await CountSourceAsync(schema.Postgres.AppConnectionString, source.Id.Value)).Should().Be(0); + AssertClean(services); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(3)] + public async Task A_successful_save_poison_receipt_reaches_every_absorbing_ancestor(int descendantDepth) + { + var observation = new DispatchObservation(); + await using var host = Host(schema.Postgres.AppConnectionString, observation); + await using var scope = host.Services.CreateAsyncScope(); + var services = scope.ServiceProvider; + var coordinator = services.GetRequiredService(); + var unit = services.GetRequiredService(); + TenantContentType? source = null; + var ancestors = new List(); + var observed = false; + var absorbed = false; + + var run = () => RunAsync(services, async () => + { + ancestors.Add(CurrentInvocation(services)); + var refused = await coordinator.ExecuteAsync(null, TransactionMode.ReadWrite, "refusing-ancestor", async () => + { + ancestors.Add(CurrentInvocation(services)); + async Task> SaveDescendantAsync(int depth) + { + if (depth != 0) + { + return await coordinator.ExecuteAsync(null, TransactionMode.ReadWrite, "successful-descendant", async () => + { + ancestors.Add(CurrentInvocation(services)); + return await SaveDescendantAsync(depth - 1); + }); + } + + source = Source(services); + Raise(source, Fact()); + var db = services.GetRequiredService(); + db.Add(source); + await db.SaveChangesAsync(); + (await CountSourceInUnitAsync(unit, source.Id.Value)).Should().Be(1); + observed = true; + ancestors.Should().OnlyContain(frame => frame.HasProducedWork, + "save promotion records a sticky receipt in all ancestor invocations"); + return Result.Ok(None.Value); + } + + (await SaveDescendantAsync(descendantDepth)).IsSuccess.Should().BeTrue(); + source!.DomainEvents.Should().ContainSingle("joiners neither dispatch nor clear the owner's sources"); + observation.Events.Should().BeEmpty(); + return Result.Fail(Refusal); + }); + + refused.Error.Should().BeSameAs(Refusal); + unit.IsRollbackOnly.Should().BeTrue("the refusal resolves only after its descendant receipt poisons the unit"); + absorbed = true; + return Result.Ok(None.Value); + }); + + await run.Should().ThrowAsync().WithMessage("*rollback-only*"); + observed.Should().BeTrue(); + absorbed.Should().BeTrue(); + observation.Events.Should().BeEmpty("rollback-only owners refuse before domain dispatch"); + source!.DomainEvents.Should().BeEmpty(); + (await CountSourceAsync(schema.Postgres.AppConnectionString, source.Id.Value)).Should().Be(0); + AssertClean(services); + } + + [Fact] + public async Task The_owner_drains_FIFO_across_sources_saves_delete_and_detach_without_duplicates() + { + await using var database = await DatabaseAsync(); + var observation = new DispatchObservation(); + await using var host = Host(database.AppConnectionString, observation); + await using var scope = host.Services.CreateAsyncScope(); + var services = scope.ServiceProvider; + var facts = Enumerable.Range(0, 5).Select(_ => Fact()).ToArray(); + TenantContentType? first = null; + TenantContentType? second = null; + long returnedVersion = -1; + + var result = await RunAsync(services, async () => + { + var db = services.GetRequiredService(); + first = Source(services); + second = Source(services); + Raise(first, facts[0]); + Raise(first, facts[1]); + Raise(second, facts[2]); + db.Add(first); + db.Add(second); + (await db.SaveChangesAsync()).Should().Be(2); + (await db.SaveChangesAsync()).Should().Be(0); + await services.GetRequiredService().ExecuteAsync( + null, TransactionMode.ReadWrite, "event-free-joiner", () => Task.FromResult(Result.Ok(None.Value))); + first.DomainEvents.Should().HaveCount(2); + second.DomainEvents.Should().ContainSingle(); + observation.Events.Should().BeEmpty("the successful joiner cannot drain or clear the owner queue"); + + Raise(first, facts[3]); + Modify(first, services); + (await db.SaveChangesAsync()).Should().Be(1); + Modify(first, services); + (await db.SaveChangesAsync()).Should().Be(1, + "another real save of the retained source must not duplicate its original facts"); + returnedVersion = first.Version; + Raise(second, facts[4]); + db.Remove(second); + (await db.SaveChangesAsync()).Should().Be(1); + db.Entry(second).State.Should().Be(EntityState.Detached); + second.DomainEvents.Should().HaveCount(2, "delete cannot erase a successfully collected source"); + db.Entry(first).State = EntityState.Detached; + first.DomainEvents.Should().HaveCount(3); + return Result.Ok(returnedVersion); + }); + + result.IsSuccess.Should().BeTrue(); + result.Value.Should().Be(returnedVersion); + observation.Events.Select(e => e.SubjectId).Should().Equal(facts.Select(e => e.SubjectId)); + var rows = await OutboxAsync(database.AppConnectionString); + rows.Select(row => row.CausationId).Should().BeEquivalentTo(facts.Select(e => (Guid?)e.EventId)); + rows.Should().HaveCount(5); + first!.DomainEvents.Should().BeEmpty(); + second!.DomainEvents.Should().BeEmpty(); + first.Version.Should().Be(returnedVersion, "outbox-only reactions leave the returned version accurate"); + (await VersionAsync(database.AppConnectionString, first.Id.Value)).Should().Be(returnedVersion); + (await CountSourceAsync(database.AppConnectionString, second.Id.Value)).Should().Be(0); + AssertClean(services); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task A_detached_source_preserves_its_saved_fact_without_hitchhiking_a_later_unsaved_fact(bool reattach) + { + await using var database = await DatabaseAsync(); + var observation = new DispatchObservation(); + await using var host = Host(database.AppConnectionString, observation); + await using var scope = host.Services.CreateAsyncScope(); + var services = scope.ServiceProvider; + var saved = Fact(); + var detached = Fact(); + TenantContentType? source = null; + + (await RunAsync(services, async () => + { + var db = services.GetRequiredService(); + source = Source(services); + Raise(source, saved); + db.Add(source); + await db.SaveChangesAsync(); + db.Entry(source).State = EntityState.Detached; + Raise(source, detached); + var other = Source(services); + db.Add(other); + (await db.SaveChangesAsync()).Should().Be(1); + source.DomainEvents.Should().HaveCount(2, "pending facts stay on retained sources until owner completion"); + if (reattach) + { + db.Attach(source); + Modify(source, services); + (await db.SaveChangesAsync()).Should().Be(1); + } + return Result.Ok(None.Value); + })).IsSuccess.Should().BeTrue(); + + Guid[] expectedSubjects = reattach ? [saved.SubjectId, detached.SubjectId] : [saved.SubjectId]; + observation.Events.Select(e => e.SubjectId).Should().Equal(expectedSubjects); + (await OutboxAsync(database.AppConnectionString)).Select(row => row.CausationId).Should().BeEquivalentTo( + reattach ? new Guid?[] { saved.EventId, detached.EventId } : new Guid?[] { saved.EventId }); + source!.DomainEvents.Should().BeEmpty("cleanup retains detached sources even when their later fact was never promoted"); + AssertClean(services); + } + + [Fact] + public async Task An_unchanged_source_receives_nothing_from_a_zero_save_or_another_sources_save() + { + await using var database = await DatabaseAsync(); + var observation = new DispatchObservation(); + await using var host = Host(database.AppConnectionString, observation); + await using var scope = host.Services.CreateAsyncScope(); + var services = scope.ServiceProvider; + var fact = Fact(); + + (await RunAsync(services, async () => + { + var db = services.GetRequiredService(); + var source = Source(services); + db.Add(source); + await db.SaveChangesAsync(); + Raise(source, fact); + (await db.SaveChangesAsync()).Should().Be(0); + CurrentInvocation(services).HasProducedWork.Should().BeFalse( + "an unsaved raise is not a successful-save receipt"); + db.Add(Source(services)); + (await db.SaveChangesAsync()).Should().Be(1); + CurrentInvocation(services).HasProducedWork.Should().BeFalse( + "saving another source cannot promote events on an unchanged source"); + source.DomainEvents.Should().ContainSingle(); + Modify(source, services); + (await db.SaveChangesAsync()).Should().Be(1); + CurrentInvocation(services).HasProducedWork.Should().BeTrue(); + return Result.Ok(None.Value); + })).IsSuccess.Should().BeTrue(); + + observation.Events.Select(e => e.SubjectId).Should().Equal(fact.SubjectId); + (await OutboxAsync(database.AppConnectionString)).Should().ContainSingle() + .Which.CausationId.Should().Be(fact.EventId); + AssertClean(services); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task Failed_or_canceled_saves_promote_nothing_and_a_retry_belongs_to_its_saving_invocation(bool cancel, bool retry) + { + await using var database = await DatabaseAsync(); + var observation = new DispatchObservation(); + await using var host = Host(database.AppConnectionString, observation); + await using var scope = host.Services.CreateAsyncScope(); + var services = scope.ServiceProvider; + var unit = services.GetRequiredService(); + var coordinator = services.GetRequiredService(); + var fact = Fact(); + ProducerExecution.Invocation? failedInvocation = null; + string? retryTrace = null; + TenantContentType? source = null; + + (await RunAsync(services, async () => + { + var db = services.GetRequiredService(); + var incumbent = Source(services); + db.Add(incumbent); + await db.SaveChangesAsync(); + // Remove EF's alternate-key identity collision while keeping the real + // conflicting row in PostgreSQL: the failure below must reach SQL. + db.Entry(incumbent).State = EntityState.Detached; + source = Source(services, incumbent.Key); + Raise(source, fact); + db.Add(source); + var failed = await coordinator.ExecuteAsync(null, TransactionMode.ReadWrite, "failed-save", async () => + { + failedInvocation = CurrentInvocation(services); + if (cancel) + { + using var cancellation = new CancellationTokenSource(); + await cancellation.CancelAsync(); + var save = () => db.SaveChangesAsync(cancellation.Token); + await save.Should().ThrowAsync(); + } + else + { + var save = () => db.SaveChangesAsync(); + (await save.Should().ThrowAsync()).Which.InnerException + .Should().BeOfType().Which.SqlState.Should().Be(PostgresErrorCodes.UniqueViolation); + } + failedInvocation.HasProducedWork.Should().BeFalse(); + return Result.Fail(Refusal); + }); + failed.Error.Should().BeSameAs(Refusal); + unit.IsRollbackOnly.Should().BeFalse("a caught failed save with no promotion is an ordinary absorbable refusal"); + CurrentInvocation(services).HasProducedWork.Should().BeFalse(); + (await CountSourceInUnitAsync(unit, source.Id.Value)).Should().Be(0); + source.DomainEvents.Should().ContainSingle("failed save does not erase the aggregate's pending fact"); + + // A changed, unrelated source is a positive persistence control. The failed + // source stays retained for cleanup but is ineligible while detached. + db.Entry(source).State = EntityState.Detached; + db.Add(Source(services)); + (await db.SaveChangesAsync()).Should().Be(1); + CurrentInvocation(services).HasProducedWork.Should().BeFalse(); + if (retry) + { + using var activity = new Activity("successful-save-retry").SetIdFormat(ActivityIdFormat.W3C).Start(); + retryTrace = activity.Id; + var successful = await coordinator.ExecuteAsync(null, TransactionMode.ReadWrite, "successful-retry", async () => + { + db.Add(source); + db.Entry(source).Property(entity => entity.Key).CurrentValue = Key(source.Id); + (await db.SaveChangesAsync()).Should().Be(1); + CurrentInvocation(services).HasProducedWork.Should().BeTrue(); + return Result.Ok(None.Value); + }); + successful.IsSuccess.Should().BeTrue(); + failedInvocation!.HasProducedWork.Should().BeFalse("attribution belongs to the successful saving invocation"); + } + return Result.Ok(None.Value); + })).IsSuccess.Should().BeTrue(); + + source!.DomainEvents.Should().BeEmpty(); + var rows = await OutboxAsync(database.AppConnectionString); + if (retry) + { + observation.Events.Select(e => e.SubjectId).Should().Equal(fact.SubjectId); + rows.Should().ContainSingle().Which.CorrelationId.Should().Be(retryTrace); + rows[0].CausationId.Should().Be(fact.EventId); + } + else + { + observation.Events.Should().BeEmpty(); + rows.Should().BeEmpty("the unsuccessful event is not dispatched by an unrelated successful save"); + } + AssertClean(services); + } + + [Fact] + public async Task Exactly_256_successfully_saved_domain_events_can_complete_one_transaction() + { + await using var database = await DatabaseAsync(); + var observation = new DispatchObservation(); + await using var host = Host(database.AppConnectionString, observation); + await using var scope = host.Services.CreateAsyncScope(); + var services = scope.ServiceProvider; + var facts = Enumerable.Range(0, 256).Select(_ => Fact()).ToArray(); + + (await RunAsync(services, async () => + { + var source = Source(services); + foreach (var fact in facts) Raise(source, fact); + var db = services.GetRequiredService(); + db.Add(source); + (await db.SaveChangesAsync()).Should().Be(1); + return Result.Ok(None.Value); + })).IsSuccess.Should().BeTrue(); + + observation.Events.Select(e => e.SubjectId).Should().Equal(facts.Select(e => e.SubjectId)); + (await OutboxAsync(database.AppConnectionString)).Should().HaveCount(256); + AssertClean(services); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Event_257_rolls_back_the_whole_unit_even_when_its_overflow_is_absorbed(bool absorb) + { + var observation = new DispatchObservation(); + await using var host = Host(schema.Postgres.AppConnectionString, observation); + await using var scope = host.Services.CreateAsyncScope(); + var services = scope.ServiceProvider; + var unit = services.GetRequiredService(); + TenantContentType? source = null; + var observed = false; + var caught = false; + + var run = () => RunAsync(services, async () => + { + source = Source(services); + var db = services.GetRequiredService(); + foreach (var fact in Enumerable.Range(0, 256).Select(_ => Fact())) Raise(source, fact); + db.Add(source); + await db.SaveChangesAsync(); + (await CountSourceInUnitAsync(unit, source.Id.Value)).Should().Be(1); + observed = true; + + async Task> OverflowAsync() + { + Raise(source, Fact()); + Modify(source, services); + await db.SaveChangesAsync(); + return Result.Ok(None.Value); + } + + if (absorb) + { + // Catch inside the joined invocation: a coordinator catch alone + // cannot supply the overflow's rollback-only guarantee. + (await services.GetRequiredService().ExecuteAsync( + null, TransactionMode.ReadWrite, "absorbed-overflow", async () => + { + var overflow = OverflowAsync; + await overflow.Should().ThrowAsync(); + caught = true; + unit.IsRollbackOnly.Should().BeTrue(); + return Result.Ok(None.Value); + })).IsSuccess.Should().BeTrue(); + return Result.Ok(None.Value); + } + return await OverflowAsync(); + }); + + await run.Should().ThrowAsync(); + observed.Should().BeTrue(); + caught.Should().Be(absorb); + observation.Events.Should().BeEmpty(); + source!.DomainEvents.Should().BeEmpty(); + (await CountSourceAsync(schema.Postgres.AppConnectionString, source.Id.Value)).Should().Be(0); + AssertClean(services); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Owner_cancellation_or_a_dispatch_fault_clears_all_sources_and_refuses_poisoned_scope_reuse(bool cancel) + { + await using var database = await DatabaseAsync(); + var observation = new DispatchObservation(); + await using var host = Host(database.AppConnectionString, observation); + await using var scope = host.Services.CreateAsyncScope(); + var services = scope.ServiceProvider; + var unit = services.GetRequiredService(); + using var cancellation = new CancellationTokenSource(); + var first = Fact(); + var fault = Fact(fail: true); + var neverReached = Fact(); + TenantContentType? source = null; + CustomizationDbContext? db = null; + var observed = false; + + var run = () => RunAsync(services, async () => + { + db = services.GetRequiredService(); + source = Source(services); + Raise(source, first); + Raise(source, fault); + Raise(source, neverReached); + db.Add(source); + await db.SaveChangesAsync(); + (await CountSourceInUnitAsync(unit, source.Id.Value)).Should().Be(1); + observed = true; + if (cancel) await cancellation.CancelAsync(); + return Result.Ok(None.Value); + }, cancellationToken: cancellation.Token); + + if (cancel) await run.Should().ThrowAsync(); + else await run.Should().ThrowAsync().WithMessage("Deliberate domain dispatch failure."); + observed.Should().BeTrue(); + observation.Events.Should().HaveCount(cancel ? 0 : 1); + source!.DomainEvents.Should().BeEmpty(); + (await CountSourceAsync(database.AppConnectionString, source.Id.Value)).Should().Be(0); + (await OutboxAsync(database.AppConnectionString)).Should().BeEmpty(); + AssertClean(services); + + var reached = false; + var reopen = () => RunAsync(services, () => + { + reached = true; + return Task.FromResult(Result.Ok(None.Value)); + }); + await reopen.Should().ThrowAsync().WithMessage("*rollback-only*"); + reached.Should().BeFalse("ADR-0040 makes poison irreversible for this unit's lifetime"); + AssertClean(services); + } + + [Fact] + public async Task A_successful_owner_can_save_again_in_the_same_DI_scope_without_replaying_cleared_facts() + { + await using var database = await DatabaseAsync(); + var observation = new DispatchObservation(); + await using var host = Host(database.AppConnectionString, observation); + await using var scope = host.Services.CreateAsyncScope(); + var services = scope.ServiceProvider; + var unit = services.GetRequiredService(); + var first = Fact(); + var later = Fact(); + TenantContentType? source = null; + CustomizationDbContext? db = null; + long generation = 0; + + (await RunAsync(services, async () => + { + generation = unit.GetTenantContextAnnouncement(unit.Transaction)!.Generation; + db = services.GetRequiredService(); + source = Source(services); + Raise(source, first); + db.Add(source); + await db.SaveChangesAsync(); + return Result.Ok(None.Value); + })).IsSuccess.Should().BeTrue(); + source!.DomainEvents.Should().BeEmpty(); + AssertClean(services); + + (await RunAsync(services, async () => + { + unit.GetTenantContextAnnouncement(unit.Transaction)!.Generation.Should().BeGreaterThan(generation); + db!.Database.UseTransaction(unit.Transaction); + Raise(source, later); + Modify(source, services); + (await db.SaveChangesAsync()).Should().Be(1); + return Result.Ok(None.Value); + })).IsSuccess.Should().BeTrue(); + + observation.Events.Select(e => e.SubjectId).Should().Equal(first.SubjectId, later.SubjectId); + (await OutboxAsync(database.AppConnectionString)).Select(row => row.CausationId) + .Should().BeEquivalentTo(new Guid?[] { first.EventId, later.EventId }); + source.DomainEvents.Should().BeEmpty(); + AssertClean(services); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Source_domain_outbox_and_MUST_audit_commit_together_or_rollback_together(bool failAudit) + { + await using var database = await DatabaseAsync(); + if (failAudit) + { + await using var owner = (NpgsqlConnection)await PostgresFixture.OpenAsync(database.MigrationConnectionString); + await using var deny = new NpgsqlCommand( + "ALTER TABLE audit_log ADD CONSTRAINT reject_domain_probe CHECK (operation <> 'customization.content_type.domain_probe')", owner); + await deny.ExecuteNonQueryAsync(); + } + var observation = new DispatchObservation(); + await using var host = Host(database.AppConnectionString, observation); + await using var scope = host.Services.CreateAsyncScope(); + var services = scope.ServiceProvider; + var unit = services.GetRequiredService(); + TenantContentType? source = null; + var observed = false; + observation.AfterInsert = async () => + { + (await CountSourceInUnitAsync(unit, source!.Id.Value)).Should().Be(1); + (await OutboxInUnitAsync(unit)).Should().ContainSingle(); + (await ScalarInUnitAsync(unit, "SELECT count(*) FROM audit_log")).Should().Be(0L, + "domain completion precedes MUST audit composition and INSERT"); + observed = true; + }; + + var run = () => RunAsync(services, async () => + { + var db = services.GetRequiredService(); + source = Source(services); + Raise(source, Fact()); + db.Add(source); + await db.SaveChangesAsync(); + return Result.Ok(None.Value); + }, audited: true); + + if (failAudit) + { + (await run.Should().ThrowAsync()).Which.InnerException + .Should().BeOfType().Which.SqlState.Should().Be(PostgresErrorCodes.CheckViolation); + } + else + { + (await run()).IsSuccess.Should().BeTrue(); + } + observed.Should().BeTrue("both rows existed on the app-role business transaction before MUST audit INSERT"); + source!.DomainEvents.Should().BeEmpty(); + (await CountSourceAsync(database.AppConnectionString, source.Id.Value)).Should().Be(failAudit ? 0 : 1); + (await OutboxAsync(database.AppConnectionString)).Should().HaveCount(failAudit ? 0 : 1); + (await AuditCountAsync(database.AppConnectionString)).Should().Be(failAudit ? 0 : 1); + AssertClean(services); + } + + [Fact] + public async Task A_disposed_child_activity_preserves_exact_save_origin_causation_and_causal_actor_until_commit() + { + await using var database = await DatabaseAsync(); + var observation = new DispatchObservation(); + var context = new ActingContext { UserId = UserId.SystemActor, CausalActorUserId = UserId.From(HumanActor) }; + await using var host = Host(database.AppConnectionString, observation, context); + await using var scope = host.Services.CreateAsyncScope(); + var services = scope.ServiceProvider; + var fact = Fact(); + string? originTrace = null; + string? ownerTrace = null; + var previous = Activity.Current; + Activity.Current = null; + try + { + (await RunAsync(services, async () => + { + ownerTrace = Activity.Current!.Id; + using (var child = new Activity("domain-save-child").SetIdFormat(ActivityIdFormat.W3C).Start()) + { + originTrace = child.Id; + (await services.GetRequiredService().ExecuteAsync( + null, TransactionMode.ReadWrite, "child-producer", async () => + { + var source = Source(services); + Raise(source, fact); + var db = services.GetRequiredService(); + db.Add(source); + await db.SaveChangesAsync(); + return Result.Ok(None.Value); + })).IsSuccess.Should().BeTrue(); + } + Activity.Current!.Id.Should().Be(ownerTrace); + observation.Events.Should().BeEmpty(); + context.CausalActorUserId = UserId.From(Guid.CreateVersion7()); + return Result.Ok(None.Value); + })).IsSuccess.Should().BeTrue(); + Activity.Current.Should().BeNull(); + } + finally + { + Activity.Current = previous; + } + + var row = (await OutboxAsync(database.AppConnectionString)).Should().ContainSingle().Subject; + row.CorrelationId.Should().Be(originTrace).And.NotBe(ownerTrace); + ActivityContext.TryParse(row.CorrelationId, null, out _).Should().BeTrue(); + row.CausationId.Should().Be(fact.EventId); + row.ActorUserId.Should().Be(HumanActor, "the trusted entry captures the causal human while effective execution is SystemActor"); + row.EventId.Should().NotBe(fact.EventId, "translation allocates a distinct integration event identity"); + row.Type.Should().Be(typeof(ProbeIntegrationEventV1).AssemblyQualifiedName); + row.PartitionKey.Should().Be(fact.SubjectId.ToString()); + row.MetadataIsSqlNull.Should().BeTrue(); + JsonSerializer.Deserialize(row.Payload, IntegrationEventBase.PayloadJsonOptions)! + .SubjectId.Should().Be(fact.SubjectId); + AssertClean(services); + } + + [Fact] + public async Task Owner_drain_does_not_rescan_a_source_for_an_unsaved_recursive_fact() + { + await using var database = await DatabaseAsync(); + var observation = new DispatchObservation(); + await using var host = Host(database.AppConnectionString, observation); + await using var scope = host.Services.CreateAsyncScope(); + var services = scope.ServiceProvider; + var saved = Fact(); + var unsaved = Fact(); + TenantContentType? source = null; + observation.AfterInsert = () => + { + // The probe handler still only enqueues. The observing adapter injects + // an unsaved fact to falsify a drain that repeatedly scans source lists. + Raise(source!, unsaved); + return Task.CompletedTask; + }; + + (await RunAsync(services, async () => + { + source = Source(services); + Raise(source, saved); + var db = services.GetRequiredService(); + db.Add(source); + await db.SaveChangesAsync(); + return Result.Ok(None.Value); + })).IsSuccess.Should().BeTrue(); + + observation.Events.Select(e => e.SubjectId).Should().Equal(saved.SubjectId); + (await OutboxAsync(database.AppConnectionString)).Should().ContainSingle() + .Which.CausationId.Should().Be(saved.EventId); + source!.DomainEvents.Should().BeEmpty(); + AssertClean(services); + } + + [Fact] + public async Task Disposing_an_unfinished_scope_discards_collected_and_uncollected_source_facts() + { + var observation = new DispatchObservation(); + await using var host = Host(schema.Postgres.AppConnectionString, observation); + TenantContentType source; + await using (var scope = host.Services.CreateAsyncScope()) + { + var services = scope.ServiceProvider; + var unit = services.GetRequiredService(); + await using var transaction = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(new ActingContext()); + using var invocation = (ProducerExecution.Invocation)typeof(ProducerExecution) + .GetMethod("BeginInvocation", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(services.GetRequiredService(), [true, null])!; + source = Source(services); + var db = services.GetRequiredService(); + db.Add(source); + Raise(source, Fact()); + await db.SaveChangesAsync(); + CurrentInvocation(services).HasProducedWork.Should().BeTrue(); + db.Entry(source).State = EntityState.Detached; + Raise(source, Fact()); + source.DomainEvents.Should().HaveCount(2); + (await CountSourceInUnitAsync(unit, source.Id.Value)).Should().Be(1); + } + + source.DomainEvents.Should().BeEmpty("the scoped collector disposes retained sources even without coordinator completion"); + observation.Events.Should().BeEmpty(); + (await CountSourceAsync(schema.Postgres.AppConnectionString, source.Id.Value)).Should().Be(0); + } + + private static WebApplicationFactory Host( + string connectionString, DispatchObservation observation, ITenantContext? context = null) => + new WebApplicationFactory().WithWebHostBuilder(builder => + { + builder.UseSetting("ConnectionStrings:Default", connectionString); + builder.ConfigureServices(services => + { + services.AddSingleton(new StaticTenantContextAccessor(context ?? new ActingContext())); + services.AddScoped, ProbeNotificationHandler>(); + services.AddScoped(provider => new ObservedOutbox( + new PostgresOutbox(provider.GetRequiredService(), provider.GetRequiredService()), + observation)); + }); + }); + + private static Task RunAsync(IServiceProvider services, Func> body, + bool audited = false, CancellationToken cancellationToken = default) + where TResponse : IResultBase + { + var audit = new AuditExecutionCoordinator(new ProbeCatalog(services.GetRequiredService()), + services.GetRequiredService(), services.GetRequiredService(), + services.GetRequiredService(), services.GetRequiredService(), + services.GetRequiredService(), services.GetRequiredService(), + NullLogger.Instance); + return audit.ExecuteAsync(audited ? typeof(AuditedProbe) : typeof(SilentProbe), null, + () => services.GetRequiredService().ExecuteAsync( + null, TransactionMode.ReadWrite, "domain-production-proof", body, cancellationToken), cancellationToken); + } + + private static TenantContentType Source(IServiceProvider services, string? key = null) + { + var id = TenantContentTypeId.From(Guid.CreateVersion7()); + return TenantContentType.Create(id, TenantId.From(SchemaFixture.TenantA), key ?? Key(id), 1, + LocalizedText.From(("en", "Domain producer probe")), """{"type":"object"}""", "default-card", + services.GetRequiredService(), UserId.From(SchemaFixture.Actor)); + } + + private static string Key(TenantContentTypeId id) => $"domain-{id.Value:N}"[..24]; + + private static void Modify(TenantContentType source, IServiceProvider services) => + source.Rename(LocalizedText.From(("en", $"Revision {source.Version}")), + services.GetRequiredService(), UserId.From(SchemaFixture.Actor)); + + private static ProbeDomainEvent Fact(bool fail = false) => new() + { + EventId = Guid.CreateVersion7(), + OccurredAt = Occurred, + SubjectId = Guid.CreateVersion7(), + FailOnDispatch = fail, + }; + + private static void Raise(TenantContentType source, IDomainEvent fact) => + typeof(Entity).GetMethod("RaiseDomainEvent", BindingFlags.Instance | BindingFlags.NonPublic)! + .Invoke(source, [fact]); + + private static ProducerExecution.Invocation CurrentInvocation(IServiceProvider services) => + (ProducerExecution.Invocation)typeof(ProducerExecution).GetField("_current", BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(services.GetRequiredService())!; + + private static void AssertClean(IServiceProvider services) + { + var unit = services.GetRequiredService(); + unit.Transaction.Should().BeNull(); + unit.GetTenantContextAnnouncement(unit.Transaction).Should().BeNull(); + services.GetRequiredService().HasInvocation.Should().BeFalse(); + var capture = services.GetRequiredService(); + capture.HasOpenFrame.Should().BeFalse(); + capture.Intents.Should().BeEmpty(); + capture.Changes.Should().BeEmpty(); + } + + private async Task DatabaseAsync() + { + var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + try + { + await using var connection = (NpgsqlConnection)await PostgresFixture.OpenAsync(database.AppConnectionString); + await using var transaction = await connection.BeginTransactionAsync(); + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantA); + await using var command = new NpgsqlCommand( + """ + INSERT INTO tenants (id, slug, display_name, status, created_at, created_by, row_version) + VALUES (@id, 'domain-probe', 'Domain probe', 'Trial', now(), @actor, 0) + """, connection, transaction); + command.Parameters.AddWithValue("id", SchemaFixture.TenantA); + command.Parameters.AddWithValue("actor", SchemaFixture.Actor); + await command.ExecuteNonQueryAsync(); + await transaction.CommitAsync(); + return database; + } + catch + { + await database.DisposeAsync(); + throw; + } + } + + private static async Task ScalarInUnitAsync(IUnitOfWork unit, string sql) + { + await using var command = unit.Connection.CreateCommand(); + command.Transaction = unit.Transaction; + command.CommandText = sql; + return await command.ExecuteScalarAsync(); + } + + private static Task CountSourceInUnitAsync(IUnitOfWork unit, Guid id) => + SourceScalarAsync((NpgsqlConnection)unit.Connection, (NpgsqlTransaction?)unit.Transaction, + "SELECT count(*) FROM tenant_content_types WHERE id = @id", id); + + private static Task CountSourceAsync(string connectionString, Guid id) => + SourceScalarAsync(connectionString, "SELECT count(*) FROM tenant_content_types WHERE id = @id", id); + + private static Task VersionAsync(string connectionString, Guid id) => + SourceScalarAsync(connectionString, "SELECT row_version FROM tenant_content_types WHERE id = @id", id); + + private static Task AuditCountAsync(string connectionString) => + SourceScalarAsync(connectionString, "SELECT count(*) FROM audit_log WHERE operation = 'customization.content_type.domain_probe'", Guid.Empty); + + private static async Task SourceScalarAsync(string connectionString, string sql, Guid id) + { + await using var connection = (NpgsqlConnection)await PostgresFixture.OpenAsync(connectionString); + await SchemaQueries.AssertApplicationRoleAsync(connection); + await using var transaction = await connection.BeginTransactionAsync(); + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantA); + return await SourceScalarAsync(connection, transaction, sql, id); + } + + private static async Task SourceScalarAsync(NpgsqlConnection connection, NpgsqlTransaction? transaction, string sql, Guid id) + { + await using var command = new NpgsqlCommand(sql, connection, transaction); + command.Parameters.AddWithValue("id", id); + return (long)(await command.ExecuteScalarAsync())!; + } + + private static Task> OutboxInUnitAsync(IUnitOfWork unit) => + OutboxAsync((NpgsqlConnection)unit.Connection, (NpgsqlTransaction?)unit.Transaction); + + private static async Task> OutboxAsync(string connectionString) + { + await using var connection = (NpgsqlConnection)await PostgresFixture.OpenAsync(connectionString); + await SchemaQueries.AssertApplicationRoleAsync(connection); + await using var transaction = await connection.BeginTransactionAsync(); + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantA); + return await OutboxAsync(connection, transaction); + } + + private static async Task> OutboxAsync(NpgsqlConnection connection, NpgsqlTransaction? transaction) + { + await using var command = new NpgsqlCommand( + """ + SELECT id, correlation_id, causation_id, actor_user_id, type, partition_key, payload::text, metadata IS NULL + FROM outbox_messages WHERE type = @type + """, connection, transaction); + command.Parameters.AddWithValue("type", typeof(ProbeIntegrationEventV1).AssemblyQualifiedName!); + await using var reader = await command.ExecuteReaderAsync(); + var rows = new List(); + while (await reader.ReadAsync()) + { + rows.Add(new OutboxRow(reader.GetGuid(0), reader.GetString(1), reader.IsDBNull(2) ? null : reader.GetGuid(2), + reader.IsDBNull(3) ? null : reader.GetGuid(3), reader.GetString(4), reader.GetString(5), reader.GetString(6), reader.GetBoolean(7))); + } + return rows; + } + + private sealed record ProbeDomainEvent : DomainEvent + { + public required Guid SubjectId { get; init; } + public bool FailOnDispatch { get; init; } + } + + [IntegrationEventPartition(nameof(SubjectId))] + private sealed record ProbeIntegrationEventV1 : IntegrationEventBase + { + public required Guid SubjectId { get; init; } + public override string Topic => "learnstack.customization.contenttype"; + public override string PartitionKey => SubjectId.ToString(); + } + + private sealed class ProbeNotificationHandler(IOutbox outbox, IGuidFactory guids) : INotificationHandler + { + public Task Handle(ProbeDomainEvent notification, CancellationToken cancellationToken) + { + if (notification.FailOnDispatch) throw new InvalidOperationException("Deliberate domain dispatch failure."); + return outbox.EnqueueAsync(new ProbeIntegrationEventV1 + { + EventId = guids.NewUuidV7(), + TenantId = SchemaFixture.TenantA, + OccurredAt = notification.OccurredAt, + SubjectId = notification.SubjectId, + }, cancellationToken); + } + } + + private sealed class DispatchObservation + { + public List Events { get; } = []; + public Func? AfterInsert { get; set; } + } + + private sealed class ObservedOutbox(IOutbox actual, DispatchObservation observation) : IOutbox + { + public async Task EnqueueAsync(IIntegrationEvent integrationEvent, CancellationToken cancellationToken = default) + { + await actual.EnqueueAsync(integrationEvent, cancellationToken); + observation.Events.Add((ProbeIntegrationEventV1)integrationEvent); + if (observation.AfterInsert is not null) await observation.AfterInsert(); + } + } + + private sealed record OutboxRow(Guid EventId, string CorrelationId, Guid? CausationId, Guid? ActorUserId, + string Type, string PartitionKey, string Payload, bool MetadataIsSqlNull); + + private sealed class ActingContext : ITenantContext + { + public bool IsResolved => true; + public TenantContextOrigin? Origin => TenantContextOrigin.Ambient; + public TenantId TenantId => TenantId.From(SchemaFixture.TenantA); + public OrganizationId? OrganizationId => null; + public UserId? UserId { get; init; } = SharedKernel.Identifiers.UserId.From(HumanActor); + public UserId? CausalActorUserId { get; set; } + public string? CorrelationId => "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"; + public string? ModuleName => "customization"; + } + + private sealed class AuditedProbe; + private sealed class SilentProbe; + + private sealed class ProbeCatalog(IAuditCatalog actual) : IAuditCatalog + { + public bool TryGet(Type requestType, out AuditRegistration registration) + { + if (requestType == typeof(AuditedProbe)) + { + registration = new AuditRegistration(false, + [new AuditCatalogEntry("customization", Operation, OperationType.Create, OperationClass.Must, typeof(TenantContentType))]); + return true; + } + if (requestType == typeof(SilentProbe)) + { + registration = new AuditRegistration(true, []); + return true; + } + return actual.TryGet(requestType, out registration); + } + + public bool TryGetOffPath(string operation, out AuditCatalogEntry entry) => actual.TryGetOffPath(operation, out entry); + public IReadOnlyCollection All => actual.All; + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/EducationPersistenceTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/EducationPersistenceTests.cs index 6f9dda06..2d2ce07a 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/EducationPersistenceTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/EducationPersistenceTests.cs @@ -1,5 +1,6 @@ using System.Text.Json; using FluentAssertions; +using LearnStack.Application.Pipeline; using LearnStack.Api.Composition; using LearnStack.Modules.Education.Domain; using LearnStack.Modules.Education.Infrastructure.Persistence; @@ -286,6 +287,7 @@ private static ServiceProvider BuildProvider(NpgsqlDataSource dataSource, bool s var services = new ServiceCollection(); services.AddLogging(); + services.AddLearnStackMediatRPipeline(); services.AddSingleton(dataSource); services.AddSingleton(new StaticTenantContextAccessor(tenant)); services.AddTransient(provider => diff --git a/backend/tests/LearnStack.Tests.Integration/Database/OutboxProducerTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/OutboxProducerTests.cs new file mode 100644 index 00000000..549cb9fd --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/OutboxProducerTests.cs @@ -0,0 +1,1042 @@ +using System.Diagnostics; +using System.Reflection; +using System.Reflection.Emit; +using System.Text; +using System.Text.Json; +using FluentAssertions; +using LearnStack.Application.Audit; +using LearnStack.Application.Messaging; +using LearnStack.Application.Persistence; +using LearnStack.Infrastructure.Messaging; +using LearnStack.Modules.Customization.Domain; +using LearnStack.Modules.Customization.Infrastructure.Persistence; +using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Messaging; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.SharedKernel.Time; +using LearnStack.Tools.Seeder; +using MediatR; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +/// +/// Immediate production through the real application role and production DI roots. +/// Committing or deliberately breaking a database control uses an isolated database; +/// shared-schema cases roll back every successful INSERT. +/// +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +[Collection(SharedSchema.Name)] +public sealed class OutboxProducerTests(SchemaFixture schema) +{ + private const string DirectOperation = "customization.content_type.outbox_probe"; + private const string AmbientCorrelation = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"; + private static readonly DateTimeOffset Occurred = new(2026, 10, 12, 12, 0, 0, TimeSpan.Zero); + private static readonly Guid HumanActor = new("12345678-1234-7123-8123-123456789abc"); + private static readonly Error Refusal = new(new LocalizedMessage("lockey_business_rule_violation")); + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Both_roots_insert_the_complete_runtime_event_before_business_and_MUST_audit_commit(bool api) + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await SeedTenantAsync(database.AppConnectionString); + await using var dataSource = NpgsqlDataSource.Create(database.AppConnectionString); + var context = new ActingContext(OrganizationId.From(SchemaFixture.OrgA1)); + await using var seed = Compose(dataSource, context); + await using var host = new WebApplicationFactory().WithWebHostBuilder(builder => + { + builder.UseSetting("ConnectionStrings:Default", database.AppConnectionString); + builder.ConfigureServices(services => services.AddSingleton( + new StaticTenantContextAccessor(context))); + }); + var root = api ? host.Services : seed; + await using var scope = root.CreateAsyncScope(); + var services = scope.ServiceProvider; + var unit = services.GetRequiredService(); + var businessId = TenantContentTypeId.From(Guid.CreateVersion7()); + IIntegrationEvent integrationEvent = Event(); + var previousActivity = Activity.Current; + Activity.Current = null; + string? traceparent = null; + + try + { + using var source = new ActivitySource("outbox-proof.no-listener"); + using var control = source.StartActivity("control"); + control.Should().BeNull("the fallback trace is proved without an ActivitySource listener"); + + var result = await RunAsync(services, async () => + { + (await ScalarInUnitAsync(unit, "SELECT current_user")).Should().Be("learnstack_app"); + (await ScalarInUnitAsync(unit, + "SELECT (rolbypassrls OR rolsuper)::text FROM pg_roles WHERE rolname = current_user")) + .Should().Be("false"); + var connection = unit.Connection; + var transaction = unit.Transaction; + await WriteBusinessAsync(services, businessId); + traceparent = Activity.Current!.Id; + traceparent.Should().MatchRegex("^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$"); + traceparent.Should().NotBe(context.CorrelationId, + "an arbitrary ambient string does not supply the current execution span"); + + // Mutating the source context after admission cannot retarget delivery + // organization or the trusted causal actor captured for the invocation. + context.OrganizationId = OrganizationId.From(SchemaFixture.OrgA2); + context.UserId = UserId.From(Guid.CreateVersion7()); + await services.GetRequiredService().EnqueueAsync(integrationEvent); + + unit.Connection.Should().BeSameAs(connection); + unit.Transaction.Should().BeSameAs(transaction); + var pending = await RowInUnitAsync(unit, integrationEvent.EventId); + pending.Should().NotBeNull("EnqueueAsync INSERTs immediately, before any flush or commit"); + pending!.ProcessedAt.Should().BeNull(); + pending.Attempts.Should().Be(0); + pending.LastError.Should().BeNull(); + (await CountInUnitAsync(unit, "tenant_content_types", businessId.Value)).Should().Be(1); + (await ScalarInUnitAsync(unit, "SELECT count(*) FROM audit_log")).Should().Be(0L, + "the owner has not reached MUST audit composition yet"); + (await ReadRowAsync(database.AppConnectionString, integrationEvent.EventId)).Should().BeNull( + "the row is still uncommitted and invisible on an independent application connection"); + return Result.Ok(None.Value); + }, audited: true); + + result.IsSuccess.Should().BeTrue(); + Activity.Current.Should().BeNull(); + } + finally + { + Activity.Current = previousActivity; + } + + (await CountAsync(database.AppConnectionString, "tenant_content_types", businessId.Value)).Should().Be(1); + var row = (await ReadRowAsync(database.AppConnectionString, integrationEvent.EventId))!; + row.Id.Should().Be(integrationEvent.EventId); + row.TenantId.Should().Be(SchemaFixture.TenantA); + row.OrganizationId.Should().Be(SchemaFixture.OrgA1, + "a non-marked event preserves the organization actually announced to PostgreSQL"); + row.ActorUserId.Should().Be(HumanActor); + row.CausationId.Should().BeNull("normal command production invents no incoming event"); + row.CorrelationId.Should().Be(traceparent); + ActivityContext.TryParse(row.CorrelationId, null, out _).Should().BeTrue(); + row.OccurredAt.Should().Be(Occurred); + row.Type.Should().Be(integrationEvent.GetType().AssemblyQualifiedName); + row.Topic.Should().Be(integrationEvent.Topic); + row.PartitionKey.Should().Be(integrationEvent.PartitionKey); + row.MetadataIsSqlNull.Should().BeTrue("JSON null is not SQL NULL"); + var roundTrip = JsonSerializer.Deserialize( + row.Payload, IntegrationEventBase.PayloadJsonOptions)!; + roundTrip.EventId.Should().Be(integrationEvent.EventId); + roundTrip.SubjectId.Should().Be(((ProbeIntegrationEventV1)integrationEvent).SubjectId, + "serializing the interface alone loses concrete identifier fields"); + var audit = (await AuditRowsAsync(database.AppConnectionString)).Should().ContainSingle().Subject; + audit.Operation.Should().Be(DirectOperation); + audit.OperationClass.Should().Be("Must"); + audit.Outcome.Should().Be("success"); + audit.EntityId.Should().Be(businessId.Value.ToString()); + AssertClean(services); + } + + [Fact] + public async Task Enqueue_and_owner_completion_never_publish_to_the_transport() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + var transport = new CountingEventBus(); + await using var host = new WebApplicationFactory().WithWebHostBuilder(builder => + { + builder.UseSetting("ConnectionStrings:Default", database.AppConnectionString); + builder.ConfigureServices(services => + { + services.AddSingleton(new StaticTenantContextAccessor(new ActingContext())); + services.AddSingleton(transport); + }); + }); + await using var scope = host.Services.CreateAsyncScope(); + var services = scope.ServiceProvider; + services.GetRequiredService().Should().BeSameAs(transport); + services.GetRequiredService().Should().BeOfType(); + var integrationEvent = Event(); + + (await RunAsync(services, async () => + { + await services.GetRequiredService().EnqueueAsync(integrationEvent); + (await RowInUnitAsync(services.GetRequiredService(), integrationEvent.EventId)) + .Should().NotBeNull(); + transport.Calls.Should().Be(0, "enqueue persists a row without delivering it"); + return Result.Ok(None.Value); + })).IsSuccess.Should().BeTrue(); + + (await ReadRowAsync(database.AppConnectionString, integrationEvent.EventId)).Should().NotBeNull(); + transport.Calls.Should().Be(0, "owner completion commits without transport publication"); + await services.GetRequiredService().PublishAsync( + new IntegrationEventEnvelope(integrationEvent, AmbientCorrelation)); + transport.Calls.Should().Be(1, "the resolved transport spy must observe a real call"); + AssertClean(services); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task A_system_execution_preserves_only_its_separate_causal_human(bool hasCausalHuman) + { + await using var dataSource = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + var context = new ActingContext + { + UserId = UserId.SystemActor, + CausalActorUserId = hasCausalHuman ? UserId.From(HumanActor) : null, + }; + await using var provider = Compose(dataSource, context); + await using var scope = provider.CreateAsyncScope(); + var services = scope.ServiceProvider; + var integrationEvent = Event(); + + var result = await RunAsync(services, async () => + { + await services.GetRequiredService().EnqueueAsync(integrationEvent); + var row = (await RowInUnitAsync(services.GetRequiredService(), integrationEvent.EventId))!; + row.ActorUserId.Should().Be(hasCausalHuman ? HumanActor : null); + row.OrganizationId.Should().BeNull(); + return Result.Fail(Refusal); + }); + + result.IsFailure.Should().BeTrue(); + (await ReadRowAsync(schema.Postgres.AppConnectionString, integrationEvent.EventId)).Should().BeNull(); + AssertClean(services); + } + + [Fact] + public async Task A_real_nested_command_keeps_its_MUST_intent_until_the_outbox_owner_commits() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await SeedTenantAsync(database.AppConnectionString); + await using var dataSource = NpgsqlDataSource.Create(database.AppConnectionString); + await using var provider = Compose(dataSource); + await using var scope = provider.CreateAsyncScope(); + var services = scope.ServiceProvider; + var unit = services.GetRequiredService(); + var organizationId = OrganizationId.From(Guid.CreateVersion7()); + var integrationEvent = Event(); + + var result = await RunAsync(services, async () => + { + var nested = await services.GetRequiredService().Send( + new CreateOrganizationCommand(organizationId, "outbox-branch", "Outbox branch")); + nested.IsSuccess.Should().BeTrue("the actual eight-step pipeline admitted its joiner: {0}", nested.Error); + services.GetRequiredService().State.Should().Be(AuditIntentState.Pending); + services.GetRequiredService().HasInvocation.Should().BeTrue(); + await services.GetRequiredService().EnqueueAsync(integrationEvent); + (await CountInUnitAsync(unit, "organizations", organizationId.Value)).Should().Be(1); + return Result.Ok(None.Value); + }); + + result.IsSuccess.Should().BeTrue(); + (await ReadRowAsync(database.AppConnectionString, integrationEvent.EventId)).Should().NotBeNull(); + (await CountAsync(database.AppConnectionString, "organizations", organizationId.Value)).Should().Be(1); + var audit = (await AuditRowsAsync(database.AppConnectionString)).Should().ContainSingle().Subject; + audit.Operation.Should().Be("tenancy.organization.create"); + audit.EntityId.Should().Be(organizationId.Value.ToString()); + audit.Outcome.Should().Be("success"); + AssertClean(services); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + public async Task Actual_INSERT_receipts_prevent_an_absorbed_direct_or_descendant_refusal_from_committing(int descendantDepth) + { + await using var dataSource = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + await using var provider = Compose(dataSource); + await using var scope = provider.CreateAsyncScope(); + var services = scope.ServiceProvider; + var coordinator = services.GetRequiredService(); + var unit = services.GetRequiredService(); + var businessId = TenantContentTypeId.From(Guid.CreateVersion7()); + var integrationEvent = Event(); + var observed = false; + var absorbed = false; + + var run = () => RunAsync(services, async () => + { + var refused = await coordinator.ExecuteAsync(null, TransactionMode.ReadWrite, "refusing-producer", async () => + { + async Task> ProduceAsync() + { + await WriteBusinessAsync(services, businessId); + await services.GetRequiredService().EnqueueAsync(integrationEvent); + (await RowInUnitAsync(unit, integrationEvent.EventId)).Should().NotBeNull(); + (await CountInUnitAsync(unit, "tenant_content_types", businessId.Value)).Should().Be(1); + observed = true; + return Result.Ok(None.Value); + } + + var produced = descendantDepth == 0 + ? await ProduceAsync() + : await coordinator.ExecuteAsync(null, TransactionMode.ReadWrite, "successful-descendant", ProduceAsync); + produced.IsSuccess.Should().BeTrue(); + return Result.Fail(Refusal); + }); + + refused.Error.Should().BeSameAs(Refusal); + unit.IsRollbackOnly.Should().BeTrue("the real outbox records receipts in every ancestor"); + absorbed = true; + return Result.Ok(None.Value); + }); + + await run.Should().ThrowAsync().WithMessage("*rollback-only*"); + observed.Should().BeTrue(); + absorbed.Should().BeTrue(); + (await ReadRowAsync(schema.Postgres.AppConnectionString, integrationEvent.EventId)).Should().BeNull(); + (await CountAsync(schema.Postgres.AppConnectionString, "tenant_content_types", businessId.Value)).Should().Be(0); + AssertClean(services); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Cancellation_or_fault_after_observed_business_and_outbox_writes_rolls_both_back(bool cancel) + { + await using var dataSource = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + await using var provider = Compose(dataSource); + await using var scope = provider.CreateAsyncScope(); + var services = scope.ServiceProvider; + var unit = services.GetRequiredService(); + var businessId = TenantContentTypeId.From(Guid.CreateVersion7()); + var integrationEvent = Event(); + var observed = false; + using var cancellation = new CancellationTokenSource(); + var fault = new InvalidOperationException("Deliberate failure after production"); + + var run = () => RunAsync(services, async () => + { + await WriteBusinessAsync(services, businessId); + await services.GetRequiredService().EnqueueAsync(integrationEvent); + (await RowInUnitAsync(unit, integrationEvent.EventId)).Should().NotBeNull(); + (await CountInUnitAsync(unit, "tenant_content_types", businessId.Value)).Should().Be(1); + observed = true; + if (cancel) + { + await cancellation.CancelAsync(); + throw new OperationCanceledException(cancellation.Token); + } + throw fault; + }); + + if (cancel) + { + (await run.Should().ThrowAsync()).Which.CancellationToken + .Should().Be(cancellation.Token); + } + else + { + (await run.Should().ThrowAsync()).Which.Should().BeSameAs(fault); + } + observed.Should().BeTrue(); + (await CountAsync(schema.Postgres.AppConnectionString, "tenant_content_types", businessId.Value)).Should().Be(0); + (await ReadRowAsync(schema.Postgres.AppConnectionString, integrationEvent.EventId)).Should().BeNull(); + AssertClean(services); + } + + [Fact] + public async Task A_real_MUST_audit_INSERT_failure_rolls_back_positively_observed_business_and_outbox_rows() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await SeedTenantAsync(database.AppConnectionString); + await using (var owner = (NpgsqlConnection)await PostgresFixture.OpenAsync(database.MigrationConnectionString)) + await using (var deny = new NpgsqlCommand( + "ALTER TABLE audit_log ADD CONSTRAINT reject_outbox_probe CHECK (operation <> 'customization.content_type.outbox_probe')", owner)) + { + await deny.ExecuteNonQueryAsync(); + } + await using var dataSource = NpgsqlDataSource.Create(database.AppConnectionString); + await using var provider = Compose(dataSource); + await using var scope = provider.CreateAsyncScope(); + var services = scope.ServiceProvider; + var businessId = TenantContentTypeId.From(Guid.CreateVersion7()); + var integrationEvent = Event(); + var observed = false; + + var run = () => RunAsync(services, async () => + { + await WriteBusinessAsync(services, businessId); + await services.GetRequiredService().EnqueueAsync(integrationEvent); + var unit = services.GetRequiredService(); + (await CountInUnitAsync(unit, "tenant_content_types", businessId.Value)).Should().Be(1); + (await RowInUnitAsync(unit, integrationEvent.EventId)).Should().NotBeNull(); + observed = true; + return Result.Ok(None.Value); + }, audited: true); + + var failure = (await run.Should().ThrowAsync()).Which; + failure.InnerException.Should().BeOfType().Which.SqlState + .Should().Be(PostgresErrorCodes.CheckViolation); + observed.Should().BeTrue(); + (await CountAsync(database.AppConnectionString, "tenant_content_types", businessId.Value)).Should().Be(0); + (await ReadRowAsync(database.AppConnectionString, integrationEvent.EventId)).Should().BeNull(); + (await AuditRowsAsync(database.AppConnectionString)).Should().BeEmpty(); + AssertClean(services); + } + + [Theory] + [InlineData("empty-tenant")] + [InlineData("sentinel-tenant")] + [InlineData("foreign-tenant")] + [InlineData("read-only")] + [InlineData("unannounced")] + public async Task Enqueue_refuses_an_unusable_event_tenant_or_transaction_before_INSERT(string refusal) + { + await using var dataSource = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + ITenantContext context = refusal == "unannounced" ? UnresolvedTenantContext.Instance : new ActingContext(); + await using var provider = Compose(dataSource, context); + await using var scope = provider.CreateAsyncScope(); + var services = scope.ServiceProvider; + var integrationEvent = Event(refusal switch + { + "empty-tenant" => Guid.Empty, + "sentinel-tenant" => TenantId.PlatformSentinel.Value, + "foreign-tenant" => SchemaFixture.TenantB, + _ => SchemaFixture.TenantA, + }); + var reached = false; + + var result = await RunAsync(services, async () => + { + reached = true; + var enqueue = () => services.GetRequiredService().EnqueueAsync(integrationEvent); + await enqueue.Should().ThrowAsync(); + CurrentInvocation(services).HasProducedWork.Should().BeFalse(); + services.GetRequiredService().IsRollbackOnly.Should().BeFalse(); + return Result.Ok(None.Value); + }, mode: refusal == "read-only" ? TransactionMode.ReadOnly : TransactionMode.ReadWrite); + + result.IsSuccess.Should().BeTrue("refused enqueue attempts alone do not leave production receipts"); + reached.Should().BeTrue(); + (await ReadRowAsync(schema.Postgres.AppConnectionString, integrationEvent.EventId)).Should().BeNull(); + AssertClean(services); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task A_raw_transaction_cannot_authorize_outbox_production_even_when_announced_or_joined(bool announced, bool join) + { + await using var dataSource = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + await using var provider = Compose(dataSource); + await using var scope = provider.CreateAsyncScope(); + var services = scope.ServiceProvider; + var unit = services.GetRequiredService(); + var integrationEvent = Event(); + await using var owner = await unit.BeginTransactionAsync(); + if (announced) + { + await unit.SetTenantContextAsync(new ActingContext()); + unit.GetTenantContextAnnouncement(unit.Transaction).Should().NotBeNull(); + } + else + { + unit.IsTenantContextIssuedOn(unit.Transaction).Should().BeFalse(); + } + var reached = false; + + async Task> ProduceAsync() + { + reached = true; + var enqueue = () => services.GetRequiredService().EnqueueAsync(integrationEvent); + await enqueue.Should().ThrowAsync().WithMessage("*enrolled writable frame*"); + return Result.Ok(None.Value); + } + + var result = join + ? await services.GetRequiredService().ExecuteAsync( + null, TransactionMode.ReadWrite, "raw-joiner", ProduceAsync) + : await ProduceAsync(); + result.IsSuccess.Should().BeTrue(); + reached.Should().BeTrue(); + (await RowInUnitAsync(unit, integrationEvent.EventId)).Should().BeNull(); + await owner.FailAsync(); + } + + [Fact] + public async Task An_outbox_cannot_borrow_a_producer_enrolled_on_another_unit_of_work() + { + await using var dataSource = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + await using var provider = Compose(dataSource); + await using var scope = provider.CreateAsyncScope(); + await using var elsewhere = provider.CreateAsyncScope(); + var services = scope.ServiceProvider; + var foreignUnit = elsewhere.ServiceProvider.GetRequiredService(); + await using var foreignOwner = await foreignUnit.BeginTransactionAsync(); + await foreignUnit.SetTenantContextAsync(new ActingContext()); + var integrationEvent = Event(); + + var result = await RunAsync(services, async () => + { + var unit = services.GetRequiredService(); + var producer = services.GetRequiredService(); + producer.RequireAnnouncement().TenantId.Should().Be( + foreignUnit.GetTenantContextAnnouncement(foreignUnit.Transaction)!.TenantId, + "both units really are announced for the same tenant"); + var mismatched = new PostgresOutbox(foreignUnit, producer); + var enqueue = () => mismatched.EnqueueAsync(integrationEvent); + await enqueue.Should().ThrowAsync().WithMessage("*share its producer's unit of work*"); + CurrentInvocation(services).HasProducedWork.Should().BeFalse(); + (await RowInUnitAsync(foreignUnit, integrationEvent.EventId)).Should().BeNull(); + await services.GetRequiredService().EnqueueAsync(integrationEvent); + (await RowInUnitAsync(unit, integrationEvent.EventId)).Should().NotBeNull( + "the matched production registration is the positive control"); + return Result.Fail(Refusal); + }); + + result.IsFailure.Should().BeTrue(); + await foreignOwner.FailAsync(); + AssertClean(services); + } + + [Fact] + public async Task An_organization_marked_event_requires_the_announced_organization() + { + await using var dataSource = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + await using var provider = Compose(dataSource); + await using var scope = provider.CreateAsyncScope(); + var services = scope.ServiceProvider; + var integrationEvent = new OrganizationProbeIntegrationEventV1 + { + EventId = Guid.CreateVersion7(), + TenantId = SchemaFixture.TenantA, + OccurredAt = Occurred, + SubjectId = Guid.CreateVersion7(), + }; + var validControl = Event(); + + var result = await RunAsync(services, async () => + { + var enqueue = () => services.GetRequiredService().EnqueueAsync(integrationEvent); + await enqueue.Should().ThrowAsync().WithMessage("*OrganizationId*"); + CurrentInvocation(services).HasProducedWork.Should().BeFalse(); + await services.GetRequiredService().EnqueueAsync(validControl); + (await RowInUnitAsync(services.GetRequiredService(), validControl.EventId)).Should().NotBeNull(); + return Result.Fail(Refusal); + }); + + result.IsFailure.Should().BeTrue(); + (await ReadRowAsync(schema.Postgres.AppConnectionString, integrationEvent.EventId)).Should().BeNull(); + (await ReadRowAsync(schema.Postgres.AppConnectionString, validControl.EventId)).Should().BeNull(); + AssertClean(services); + } + + [Fact] + public async Task Provisioning_uses_the_SQL_announcement_while_the_ambient_context_stays_unresolved() + { + await using var dataSource = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + await using var provider = Compose(dataSource, UnresolvedTenantContext.Instance); + await using var scope = provider.CreateAsyncScope(); + var services = scope.ServiceProvider; + var tenantId = TenantId.From(Guid.CreateVersion7()); + var integrationEvent = Event(tenantId.Value); + + var result = await RunAsync(services, async () => + { + services.GetRequiredService().IsResolved.Should().BeFalse(); + services.GetRequiredService().Current!.IsResolved.Should().BeFalse(); + await services.GetRequiredService().EnqueueAsync(integrationEvent); + var row = (await RowInUnitAsync(services.GetRequiredService(), integrationEvent.EventId))!; + row.TenantId.Should().Be(tenantId.Value); + row.OrganizationId.Should().BeNull(); + row.ActorUserId.Should().BeNull(); + return Result.Fail(Refusal); + }, provisioning: new ProvisioningProbe(tenantId)); + + result.IsFailure.Should().BeTrue(); + AssertClean(services); + } + + [Fact] + public async Task A_retained_producer_frame_refuses_a_reused_transaction_object_with_a_later_generation() + { + var builder = new NpgsqlDataSourceBuilder(schema.Postgres.AppConnectionString); + builder.ConnectionStringBuilder.MaxPoolSize = 1; + builder.ConnectionStringBuilder.NoResetOnClose = true; + await using var dataSource = builder.Build(); + await using var provider = Compose(dataSource); + await using var scope = provider.CreateAsyncScope(); + var services = scope.ServiceProvider; + var unit = services.GetRequiredService(); + var integrationEvent = Event(); + var stop = new InvalidOperationException("End stale-generation proof"); + var reached = false; + + var run = () => RunAsync(services, async () => + { + var physicalTransaction = unit.Transaction; + var first = services.GetRequiredService().RequireAnnouncement(); + await unit.CommitAsync(); + await using var later = await unit.BeginTransactionAsync(); + unit.Transaction.Should().BeSameAs(physicalTransaction, + "the driver actually reuses this object; an identity-only control would be unsound"); + await unit.SetTenantContextAsync(new ActingContext()); + unit.GetTenantContextAnnouncement(unit.Transaction)!.Generation.Should().BeGreaterThan(first.Generation); + var enqueue = () => services.GetRequiredService().EnqueueAsync(integrationEvent); + await enqueue.Should().ThrowAsync().WithMessage("*enrolled writable frame*"); + (await services.GetRequiredService().ExecuteAsync( + null, TransactionMode.ReadWrite, "stale-ancestor-joiner", async () => + { + await enqueue.Should().ThrowAsync().WithMessage("*enrolled writable frame*"); + return Result.Ok(None.Value); + })).IsSuccess.Should().BeTrue(); + (await RowInUnitAsync(unit, integrationEvent.EventId)).Should().BeNull(); + reached = true; + throw stop; + }); + + (await run.Should().ThrowAsync()).Which.Should().BeSameAs(stop); + reached.Should().BeTrue(); + AssertClean(services); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task A_duplicate_EventId_poisons_the_unit_without_creating_a_child_receipt(bool absorb) + { + await using var dataSource = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + await using var provider = Compose(dataSource); + await using var scope = provider.CreateAsyncScope(); + var services = scope.ServiceProvider; + var unit = services.GetRequiredService(); + var integrationEvent = Event(); + var observed = false; + var duplicateAttempted = false; + PostgresException? insertFailure = null; + + var run = () => RunAsync(services, async () => + { + await services.GetRequiredService().EnqueueAsync(integrationEvent); + var parent = CurrentInvocation(services); + parent.HasProducedWork.Should().BeTrue(); + unit.IsRollbackOnly.Should().BeFalse("a successful INSERT does not poison the unit"); + (await RowInUnitAsync(unit, integrationEvent.EventId)).Should().NotBeNull(); + observed = true; + return await services.GetRequiredService().ExecuteAsync>( + null, TransactionMode.ReadWrite, "duplicate-child", async () => + { + CurrentInvocation(services).HasProducedWork.Should().BeFalse(); + var enqueue = () => services.GetRequiredService().EnqueueAsync(integrationEvent); + var failure = (await enqueue.Should().ThrowAsync()).Which; + insertFailure = failure; + failure.SqlState.Should().Be(PostgresErrorCodes.UniqueViolation); + unit.IsRollbackOnly.Should().BeTrue("the outbox must poison before its caller can absorb the INSERT error"); + CurrentInvocation(services).HasProducedWork.Should().BeFalse( + "the parent's successful INSERT cannot hide a receipt recorded before this failed INSERT"); + parent.HasProducedWork.Should().BeTrue("failed production cannot erase an earlier receipt"); + duplicateAttempted = true; + if (absorb) return Result.Ok(None.Value); + throw failure; + }); + }); + + if (absorb) + { + await run.Should().ThrowAsync().WithMessage("*rollback-only*"); + } + else + { + (await run.Should().ThrowAsync()).Which.Should().BeSameAs(insertFailure); + } + observed.Should().BeTrue(); + duplicateAttempted.Should().BeTrue(); + (await ReadRowAsync(schema.Postgres.AppConnectionString, integrationEvent.EventId)).Should().BeNull(); + AssertClean(services); + } + + [Fact] + public async Task A_successful_owner_can_produce_in_a_later_generation_of_the_same_DI_scope() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var dataSource = NpgsqlDataSource.Create(database.AppConnectionString); + await using var provider = Compose(dataSource); + await using var scope = provider.CreateAsyncScope(); + var services = scope.ServiceProvider; + var first = Event(); + var second = Event(); + long generation = 0; + + (await RunAsync(services, async () => + { + generation = services.GetRequiredService().RequireAnnouncement().Generation; + await services.GetRequiredService().EnqueueAsync(first); + return Result.Ok(None.Value); + })).IsSuccess.Should().BeTrue(); + AssertClean(services); + + (await RunAsync(services, async () => + { + CurrentInvocation(services).HasProducedWork.Should().BeFalse( + "a completed invocation cannot replay its receipt into the new unit"); + services.GetRequiredService().RequireAnnouncement().Generation.Should().BeGreaterThan(generation); + await services.GetRequiredService().EnqueueAsync(second); + return Result.Ok(None.Value); + })).IsSuccess.Should().BeTrue(); + + (await ReadRowAsync(database.AppConnectionString, first.EventId)).Should().NotBeNull(); + (await ReadRowAsync(database.AppConnectionString, second.EventId)).Should().NotBeNull(); + AssertClean(services); + } + + [Theory] + [InlineData(0, false)] + [InlineData(1, false)] + [InlineData(0, true)] + [InlineData(1, true)] + public async Task The_complete_canonical_UTF8_payload_has_an_inclusive_256_KiB_limit(int extraBytes, bool unicodeName) + { + // The only extra payload value is a GUID. A long declared member name + // reaches the bound without smuggling text values or serializer overrides. + var targetBytes = OutboxPayloadLimits.MaxBytes + extraBytes; + var prefix = unicodeName ? new string('é', 64) : "Identifier"; + var baseline = BoundaryEvent(prefix); + var baselineBytes = Encoding.UTF8.GetByteCount(baseline.ToPayloadJson()); + var integrationEvent = BoundaryEvent(prefix + new string('x', targetBytes - baselineBytes)); + var canonical = integrationEvent.ToPayloadJson(); + Encoding.UTF8.GetByteCount(canonical).Should().Be(targetBytes, + "the measured budget includes identity, routing, timestamp and the complete runtime member name"); + if (unicodeName) + { + canonical.Should().Contain("\\u00E9"); + canonical.Should().NotContain("é", "the frozen default encoder escapes non-ASCII names"); + Encoding.UTF8.GetByteCount(canonical.Replace("\\u00E9", "é", StringComparison.Ordinal)) + .Should().BeLessThan(OutboxPayloadLimits.MaxBytes, + "counting a hypothetical unescaped representation would understate canonical bytes"); + } + await using var dataSource = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + await using var provider = Compose(dataSource); + await using var scope = provider.CreateAsyncScope(); + var services = scope.ServiceProvider; + + var result = await RunAsync(services, async () => + { + var outbox = services.GetRequiredService(); + if (extraBytes == 0) + { + await outbox.EnqueueAsync(integrationEvent); + var row = (await RowInUnitAsync(services.GetRequiredService(), integrationEvent.EventId))!; + using var payload = JsonDocument.Parse(row.Payload); + payload.RootElement.GetProperty(prefix + new string('x', targetBytes - baselineBytes)) + .GetGuid().Should().Be(BoundaryIdentifier); + CurrentInvocation(services).HasProducedWork.Should().BeTrue(); + } + else + { + var enqueue = () => outbox.EnqueueAsync(integrationEvent); + await enqueue.Should().ThrowAsync().WithMessage("*262144*"); + CurrentInvocation(services).HasProducedWork.Should().BeFalse(); + (await RowInUnitAsync(services.GetRequiredService(), integrationEvent.EventId)).Should().BeNull(); + } + return Result.Fail(Refusal); + }); + + result.IsFailure.Should().BeTrue(); + (await ReadRowAsync(schema.Postgres.AppConnectionString, integrationEvent.EventId)).Should().BeNull(); + AssertClean(services); + } + + private static ServiceProvider Compose(NpgsqlDataSource dataSource, ITenantContext? context = null) => + SeedComposition.Build(dataSource, context ?? new ActingContext(), NullLoggerFactory.Instance); + + private static Task> RunAsync( + IServiceProvider services, Func>> body, bool audited = false, + TransactionMode mode = TransactionMode.ReadWrite, IProvisionsTenant? provisioning = null) + { + var audit = new AuditExecutionCoordinator( + new ProbeCatalog(services.GetRequiredService()), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + NullLogger.Instance); + return audit.ExecuteAsync(audited ? typeof(DirectHandler) : typeof(OffHandler), provisioning, + () => services.GetRequiredService().ExecuteAsync( + provisioning, mode, "outbox-proof", body), CancellationToken.None); + } + + private static ProbeIntegrationEventV1 Event(Guid? tenantId = null) => new() + { + EventId = Guid.CreateVersion7(), + TenantId = tenantId ?? SchemaFixture.TenantA, + OccurredAt = Occurred, + SubjectId = Guid.CreateVersion7(), + }; + + private static async Task WriteBusinessAsync(IServiceProvider services, TenantContentTypeId id) + { + var db = services.GetRequiredService(); + db.Add(TenantContentType.Create(id, TenantId.From(SchemaFixture.TenantA), $"outbox-{id.Value:N}"[..24], 1, + LocalizedText.From(("en", "Outbox probe")), """{"type":"object"}""", "default-card", + services.GetRequiredService(), UserId.From(SchemaFixture.Actor))); + await db.SaveChangesAsync(); + } + + private static void AssertClean(IServiceProvider services) + { + var unit = services.GetRequiredService(); + unit.Transaction.Should().BeNull(); + unit.GetTenantContextAnnouncement(unit.Transaction).Should().BeNull(); + services.GetRequiredService().HasInvocation.Should().BeFalse(); + var capture = services.GetRequiredService(); + capture.HasOpenFrame.Should().BeFalse(); + capture.Intents.Should().BeEmpty(); + capture.Changes.Should().BeEmpty(); + } + + // Read-only inspection is necessary here: a failed SQL statement aborts + // PostgreSQL's transaction regardless of receipt state, so a rollback alone + // cannot prove that the failed INSERT recorded no successful-production receipt. + private static ProducerExecution.Invocation CurrentInvocation(IServiceProvider services) => + (ProducerExecution.Invocation)typeof(ProducerExecution).GetField("_current", + BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue( + services.GetRequiredService())!; + + private static async Task ScalarInUnitAsync(IUnitOfWork unit, string sql) + { + await using var command = unit.Connection.CreateCommand(); + command.Transaction = unit.Transaction; + command.CommandText = sql; + return await command.ExecuteScalarAsync(); + } + + // Table names are closed fixture constants, never external input. + private static async Task CountInUnitAsync(IUnitOfWork unit, string table, Guid id) + { + await using var command = new NpgsqlCommand($"SELECT count(*) FROM {table} WHERE id = @id", + (NpgsqlConnection)unit.Connection, (NpgsqlTransaction?)unit.Transaction); + command.Parameters.AddWithValue("id", id); + return (long)(await command.ExecuteScalarAsync())!; + } + + private static async Task CountAsync(string connectionString, string table, Guid id) + { + await using var connection = (NpgsqlConnection)await PostgresFixture.OpenAsync(connectionString); + await using var transaction = await connection.BeginTransactionAsync(); + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantA); + await using var command = new NpgsqlCommand($"SELECT count(*) FROM {table} WHERE id = @id", connection, transaction); + command.Parameters.AddWithValue("id", id); + return (long)(await command.ExecuteScalarAsync())!; + } + + private static async Task RowInUnitAsync(IUnitOfWork unit, Guid id) => + await ReadRowAsync((NpgsqlConnection)unit.Connection, (NpgsqlTransaction?)unit.Transaction, id); + + private static async Task ReadRowAsync(string connectionString, Guid id) + { + await using var connection = (NpgsqlConnection)await PostgresFixture.OpenAsync(connectionString); + await using var transaction = await connection.BeginTransactionAsync(); + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantA); + return await ReadRowAsync(connection, transaction, id); + } + + private static async Task ReadRowAsync(NpgsqlConnection connection, NpgsqlTransaction? transaction, Guid id) + { + await using var command = new NpgsqlCommand( + """ + SELECT id, occurred_at, tenant_id, organization_id, correlation_id, causation_id, + actor_user_id, type, topic, partition_key, payload::text, metadata IS NULL, + processed_at, attempts, last_error + FROM outbox_messages WHERE id = @id + """, connection, transaction); + command.Parameters.AddWithValue("id", id); + await using var reader = await command.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return null; + return new OutboxRow(reader.GetGuid(0), reader.GetFieldValue(1), reader.GetGuid(2), + reader.IsDBNull(3) ? null : reader.GetGuid(3), reader.GetString(4), + reader.IsDBNull(5) ? null : reader.GetGuid(5), reader.IsDBNull(6) ? null : reader.GetGuid(6), + reader.GetString(7), reader.GetString(8), reader.GetString(9), reader.GetString(10), reader.GetBoolean(11), + reader.IsDBNull(12) ? null : reader.GetFieldValue(12), reader.GetInt32(13), + reader.IsDBNull(14) ? null : reader.GetString(14)); + } + + private static async Task> AuditRowsAsync(string connectionString) + { + await using var connection = (NpgsqlConnection)await PostgresFixture.OpenAsync(connectionString); + await using var transaction = await connection.BeginTransactionAsync(); + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantA); + await SchemaQueries.SetSettingAsync(connection, transaction, "app.organization_id", SchemaFixture.OrgA1.ToString()); + await using var command = new NpgsqlCommand( + "SELECT operation, operation_class, outcome, entity_id FROM audit_log", connection, transaction); + await using var reader = await command.ExecuteReaderAsync(); + var rows = new List(); + while (await reader.ReadAsync()) + { + rows.Add(new AuditRow(reader.GetString(0), reader.GetString(1), reader.GetString(2), + reader.IsDBNull(3) ? null : reader.GetString(3))); + } + return rows; + } + + private static async Task SeedTenantAsync(string connectionString) + { + await using var connection = (NpgsqlConnection)await PostgresFixture.OpenAsync(connectionString); + await using var transaction = await connection.BeginTransactionAsync(); + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantA); + await using var command = new NpgsqlCommand( + """ + INSERT INTO tenants (id, slug, display_name, status, created_at, created_by, row_version) + VALUES (@id, 'outbox-probe', 'Outbox probe', 'Trial', now(), @actor, 0) + """, connection, transaction); + command.Parameters.AddWithValue("id", SchemaFixture.TenantA); + command.Parameters.AddWithValue("actor", SchemaFixture.Actor); + await command.ExecuteNonQueryAsync(); + await transaction.CommitAsync(); + } + + private static readonly Guid BoundaryIdentifier = new("e95685d3-576c-4b48-9553-18f576670eb2"); + + /// + /// A runtime event contract with one GUID-valued extra property. Only the + /// declaration's name varies; serialization keeps the canonical frozen options. + /// + private static IntegrationEventBase BoundaryEvent(string identifierName) + { + var assembly = AssemblyBuilder.DefineDynamicAssembly( + new AssemblyName("OutboxBoundary" + Guid.NewGuid().ToString("N")), AssemblyBuilderAccess.RunAndCollect); + var type = assembly.DefineDynamicModule("Events").DefineType( + "BoundaryIntegrationEventV1", TypeAttributes.Public | TypeAttributes.Sealed, typeof(IntegrationEventBase)); + type.SetCustomAttribute(new CustomAttributeBuilder( + typeof(IntegrationEventPartitionAttribute).GetConstructor([typeof(string), typeof(bool)])!, + [nameof(IIntegrationEvent.TenantId), true])); + var constructor = type.DefineConstructor(MethodAttributes.Public, CallingConventions.Standard, Type.EmptyTypes); + var constructorIl = constructor.GetILGenerator(); + constructorIl.Emit(OpCodes.Ldarg_0); + constructorIl.Emit(OpCodes.Call, typeof(IntegrationEventBase).GetConstructor( + BindingFlags.Instance | BindingFlags.NonPublic, null, Type.EmptyTypes, null)!); + constructorIl.Emit(OpCodes.Ret); + + foreach (var abstractMethod in typeof(IntegrationEventBase).GetMethods( + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic).Where(method => method.IsAbstract)) + { + var implementation = type.DefineMethod(abstractMethod.Name, + MethodAttributes.Public | MethodAttributes.Virtual | MethodAttributes.HideBySig, + abstractMethod.ReturnType, Type.EmptyTypes); + var il = implementation.GetILGenerator(); + if (abstractMethod.Name == "get_Topic") + { + il.Emit(OpCodes.Ldstr, "learnstack.customization.contenttype"); + } + else if (abstractMethod.Name == "get_PartitionKey") + { + il.Emit(OpCodes.Ldarg_0); + il.Emit(OpCodes.Call, typeof(IntegrationEventBase).GetProperty(nameof(IIntegrationEvent.TenantId))!.GetMethod!); + il.Emit(OpCodes.Box, typeof(Guid)); + il.Emit(OpCodes.Callvirt, typeof(object).GetMethod(nameof(ToString))!); + } + else + { + // The abstract record clone is irrelevant to serialization; the + // emitted concrete record still implements its inherited contract. + abstractMethod.Name.Should().Be("$"); + il.Emit(OpCodes.Ldarg_0); + } + il.Emit(OpCodes.Ret); + type.DefineMethodOverride(implementation, abstractMethod); + } + + var property = type.DefineProperty(identifierName, PropertyAttributes.None, typeof(Guid), Type.EmptyTypes); + var getter = type.DefineMethod("get_Identifier", MethodAttributes.Public | MethodAttributes.SpecialName + | MethodAttributes.HideBySig, typeof(Guid), Type.EmptyTypes); + var getterIl = getter.GetILGenerator(); + getterIl.Emit(OpCodes.Ldstr, BoundaryIdentifier.ToString()); + getterIl.Emit(OpCodes.Newobj, typeof(Guid).GetConstructor([typeof(string)])!); + getterIl.Emit(OpCodes.Ret); + property.SetGetMethod(getter); + var integrationEvent = (IntegrationEventBase)Activator.CreateInstance(type.CreateType()!)!; + typeof(IntegrationEventBase).GetProperty(nameof(IIntegrationEvent.EventId))! + .SetValue(integrationEvent, Guid.CreateVersion7()); + typeof(IntegrationEventBase).GetProperty(nameof(IIntegrationEvent.TenantId))! + .SetValue(integrationEvent, SchemaFixture.TenantA); + typeof(IntegrationEventBase).GetProperty(nameof(IIntegrationEvent.OccurredAt))! + .SetValue(integrationEvent, Occurred); + return integrationEvent; + } + + private sealed class CountingEventBus : IEventBus + { + public int Calls { get; private set; } + + public Task PublishAsync(IntegrationEventEnvelope envelope, CancellationToken cancellationToken = default) + { + Calls++; + return Task.CompletedTask; + } + } + + private sealed record OutboxRow(Guid Id, DateTimeOffset OccurredAt, Guid TenantId, Guid? OrganizationId, + string CorrelationId, Guid? CausationId, Guid? ActorUserId, string Type, string Topic, + string PartitionKey, string Payload, bool MetadataIsSqlNull, DateTimeOffset? ProcessedAt, + int Attempts, string? LastError); + private sealed record AuditRow(string Operation, string OperationClass, string Outcome, string? EntityId); + private sealed record ProvisioningProbe(TenantId ProvisioningTenantId) : IProvisionsTenant; + private sealed class DirectHandler; + private sealed class OffHandler; + + [IntegrationEventPartition(nameof(SubjectId))] + private sealed record ProbeIntegrationEventV1 : IntegrationEventBase + { + public required Guid SubjectId { get; init; } + public override string Topic => "learnstack.customization.contenttype"; + public override string PartitionKey => SubjectId.ToString(); + } + + [IntegrationEventPartition(nameof(SubjectId))] + private sealed record OrganizationProbeIntegrationEventV1 : IntegrationEventBase, IOrganizationScopedIntegrationEvent + { + public required Guid SubjectId { get; init; } + public override string Topic => "learnstack.customization.contenttype"; + public override string PartitionKey => SubjectId.ToString(); + } + + private sealed class ActingContext(OrganizationId? organizationId = null) : ITenantContext + { + public bool IsResolved => true; + public TenantContextOrigin? Origin => TenantContextOrigin.Ambient; + public TenantId TenantId => TenantId.From(SchemaFixture.TenantA); + public OrganizationId? OrganizationId { get; set; } = organizationId; + public UserId? UserId { get; set; } = SharedKernel.Identifiers.UserId.From(HumanActor); + public UserId? CausalActorUserId { get; init; } + public string? CorrelationId => AmbientCorrelation; + public string? ModuleName => "customization"; + } + + private sealed class ProbeCatalog(IAuditCatalog actual) : IAuditCatalog + { + public bool TryGet(Type requestType, out AuditRegistration registration) + { + if (requestType == typeof(DirectHandler)) + { + registration = new AuditRegistration(false, + [new AuditCatalogEntry("customization", DirectOperation, OperationType.Create, + OperationClass.Must, typeof(TenantContentType))]); + return true; + } + if (requestType == typeof(OffHandler)) + { + registration = new AuditRegistration(true, []); + return true; + } + return actual.TryGet(requestType, out registration); + } + public bool TryGetOffPath(string operation, out AuditCatalogEntry entry) => actual.TryGetOffPath(operation, out entry); + public IReadOnlyCollection All => actual.All; + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/ProducerExecutionTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/ProducerExecutionTests.cs new file mode 100644 index 00000000..07914ece --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/ProducerExecutionTests.cs @@ -0,0 +1,770 @@ +using System.Diagnostics; +using System.Reflection; +using System.Text.Json; +using FluentAssertions; +using LearnStack.Application.Audit; +using LearnStack.Application.Messaging; +using LearnStack.Application.Persistence; +using LearnStack.Modules.Customization.Domain; +using LearnStack.Modules.Customization.Infrastructure.Persistence; +using LearnStack.Modules.Tenancy.Application.Contracts.IntegrationEvents; +using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Localization; +using LearnStack.SharedKernel.Messaging; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.SharedKernel.Time; +using LearnStack.Tools.Seeder; +using MediatR; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +/// +/// ADR-0057's shared execution boundary, including real outbox receipts across +/// direct and nested invocations. Domain save-promotion proofs live in +/// DomainEventProductionTests. +/// +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +[Collection(SharedSchema.Name)] +public sealed class ProducerExecutionTests(SchemaFixture schema) +{ + private const string DirectOperation = "customization.content_type.direct_probe"; + private const string Correlation = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"; + private static readonly Error Refusal = new(new LocalizedMessage("lockey_business_rule_violation")); + private static readonly ProbeContext TenantA = new(); + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task A_leaked_frame_refuses_completion_with_a_failed_not_indeterminate_audit(bool joiner) + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await SeedTenantAsync(database.AppConnectionString); + await using var dataSource = NpgsqlDataSource.Create(database.AppConnectionString); + await using var provider = Compose(dataSource); + await using var scope = provider.CreateAsyncScope(); + var services = scope.ServiceProvider; + var coordinator = services.GetRequiredService(); + var unit = services.GetRequiredService(); + var capture = services.GetRequiredService(); + var id = TenantContentTypeId.From(Guid.CreateVersion7()); + var integrationEvent = new TenantCreatedIntegrationEventV1 + { + EventId = Guid.CreateVersion7(), + OccurredAt = services.GetRequiredService().UtcNow, + TenantId = TenantA.TenantId.Value, + }; + IUnitOfWorkScope? leaked = null; + var observed = false; + + async Task> LeakAsync() + { + leaked = await unit.BeginTransactionAsync(); + return Result.Ok(None.Value); + } + + var run = () => DirectAudit(services).ExecuteAsync(typeof(DirectHandler), null, + () => coordinator.ExecuteAsync(null, TransactionMode.ReadWrite, "leaked-frame", async () => + { + await SchemaQueries.AssertApplicationRoleAsync(unit.Connection); + await WriteContentTypeAsync(services, id); + await services.GetRequiredService().EnqueueAsync(integrationEvent); + (await CountInUnitAsync(unit, "tenant_content_types", id.Value)).Should().Be(1); + (await CountInUnitAsync(unit, "outbox_messages", integrationEvent.EventId)).Should().Be(1); + observed = true; + if (!joiner) return await LeakAsync(); + + var nested = () => coordinator.ExecuteAsync( + null, TransactionMode.ReadWrite, "leaking-joiner", LeakAsync); + await nested.Should().ThrowAsync().WithMessage("*innermost-first*"); + unit.IsRollbackOnly.Should().BeTrue("absorbing the joiner's exception cannot save its transaction"); + unit.HasActiveTransaction.Should().BeFalse("the leaked stack collapses before the owner resumes"); + return Result.Ok(None.Value); + }), CancellationToken.None); + + try + { + await run.Should().ThrowAsync() + .WithMessage(joiner ? "*rollback-only*" : "*innermost-first*"); + observed.Should().BeTrue("the refusal must roll back an actual saved row"); + unit.IsRollbackOnly.Should().BeTrue(); + AssertClean(services); + capture.State.Should().Be(AuditIntentState.None, "the audit owner reconciled and cleared its frame"); + (await CountAsync(database.AppConnectionString, "tenant_content_types", id.Value)).Should().Be(0); + (await CountAsync(database.AppConnectionString, "outbox_messages", integrationEvent.EventId)).Should().Be(0); + var row = (await AuditRowsAsync(database.AppConnectionString)).Should().ContainSingle().Subject; + row.Operation.Should().Be(DirectOperation); + row.Outcome.Should().Be("failed", "frame bookkeeping refused before any physical COMMIT"); + row.After.Should().Contain(Key(id), "the failed row retains the positively observed attempted write"); + var reuse = () => unit.BeginTransactionAsync(); + await reuse.Should().ThrowAsync().WithMessage("*rollback-only*"); + } + finally + { + if (leaked is not null) await leaked.DisposeAsync(); + } + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public async Task An_absorbed_producer_refusal_rolls_back_direct_and_descendant_work(int descendantDepth) + { + await using var dataSource = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + await using var provider = Compose(dataSource); + await using var scope = provider.CreateAsyncScope(); + var services = scope.ServiceProvider; + var transaction = services.GetRequiredService(); + var producer = services.GetRequiredService(); + var unit = services.GetRequiredService(); + var id = TenantContentTypeId.From(Guid.CreateVersion7()); + var integrationEvent = new TenantCreatedIntegrationEventV1 + { + EventId = Guid.CreateVersion7(), + OccurredAt = services.GetRequiredService().UtcNow, + TenantId = TenantA.TenantId.Value, + }; + var observedWrite = false; + var absorbedRefusal = false; + + async Task> WriteAsync() + { + await WriteContentTypeAsync(services, id); + await services.GetRequiredService().EnqueueAsync(integrationEvent); + (await CountInUnitAsync(unit, "tenant_content_types", id.Value)).Should().Be(1); + (await CountInUnitAsync(unit, "outbox_messages", integrationEvent.EventId)).Should().Be(1); + observedWrite = true; + return Result.Ok(None.Value); + } + + var run = () => transaction.ExecuteAsync(null, TransactionMode.ReadWrite, "outer", async () => + { + var refused = await transaction.ExecuteAsync(null, TransactionMode.ReadWrite, "refused", async () => + { + async Task> DescendantAsync(int remainingDepth) + { + if (remainingDepth == 0) return await WriteAsync(); + var child = await transaction.ExecuteAsync( + null, TransactionMode.ReadWrite, "successful-descendant", + () => DescendantAsync(remainingDepth - 1)); + child.IsSuccess.Should().BeTrue(); + return child; + } + + (await DescendantAsync(descendantDepth)).IsSuccess.Should().BeTrue(); + + return Result.Fail(Refusal); + }); + + refused.Error.Should().BeSameAs(Refusal); + absorbedRefusal = true; + unit.IsRollbackOnly.Should().BeTrue("the refusing invocation resolves after poisoning its unit"); + return Result.Ok(None.Value); + }); + + await run.Should().ThrowAsync() + .WithMessage("*rollback-only*"); + observedWrite.Should().BeTrue("zero rows after an earlier failure would prove no rollback"); + absorbedRefusal.Should().BeTrue("the owner actually absorbed the nested Result.Fail"); + (await CountAsync(schema.Postgres.AppConnectionString, "tenant_content_types", id.Value)).Should().Be(0); + (await CountAsync(schema.Postgres.AppConnectionString, "outbox_messages", integrationEvent.EventId)).Should().Be(0); + producer.HasInvocation.Should().BeFalse(); + unit.Transaction.Should().BeNull(); + unit.GetTenantContextAnnouncement(unit.Transaction).Should().BeNull(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task A_raw_announced_transaction_and_its_joiner_cannot_authorize_production(bool join) + { + await using var dataSource = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + await using var provider = Compose(dataSource); + await using var scope = provider.CreateAsyncScope(); + var services = scope.ServiceProvider; + var unit = services.GetRequiredService(); + var producer = services.GetRequiredService(); + typeof(ProducerExecution).GetMethod("BeginInvocation", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)! + .IsAssembly.Should().BeTrue("only the core coordinator can enroll producer ownership"); + await using var owner = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(TenantA); + unit.GetTenantContextAnnouncement(unit.Transaction).Should().NotBeNull( + "missing SQL authority would be a weaker reason to refuse production"); + producer.HasInvocation.Should().BeFalse(); + var reached = false; + + Task> WorkAsync() + { + reached = true; + var produce = () => producer.RequireAnnouncement(); + produce.Should().Throw().WithMessage("*enrolled writable frame*"); + return Task.FromResult(Result.Ok(None.Value)); + } + + if (join) + { + (await services.GetRequiredService().ExecuteAsync( + null, TransactionMode.ReadWrite, "orphan-joiner", WorkAsync)).IsSuccess.Should().BeTrue(); + } + else + { + (await WorkAsync()).IsSuccess.Should().BeTrue(); + } + + reached.Should().BeTrue(); + producer.HasInvocation.Should().BeFalse(); + unit.HasActiveTransaction.Should().BeTrue("only the raw owner resolves its transaction"); + } + + [Fact] + public async Task A_stale_owner_cannot_confer_production_authority_on_a_new_transaction_joiner() + { + await using var dataSource = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + await using var provider = Compose(dataSource); + await using var scope = provider.CreateAsyncScope(); + var services = scope.ServiceProvider; + var unit = services.GetRequiredService(); + var producer = services.GetRequiredService(); + var coordinator = services.GetRequiredService(); + var stop = new InvalidOperationException("Stop after stale ancestry proof"); + var reached = false; + + var run = () => DirectAudit(services).ExecuteAsync(typeof(OffHandler), null, + () => coordinator.ExecuteAsync>( + null, TransactionMode.ReadWrite, "retained-owner", async () => + { + var previous = producer.RequireAnnouncement(); + await unit.CommitAsync(); + await using var later = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(TenantA); + unit.GetTenantContextAnnouncement(unit.Transaction)!.Generation.Should() + .BeGreaterThan(previous.Generation); + + (await coordinator.ExecuteAsync(null, TransactionMode.ReadWrite, "later-joiner", () => + { + reached = true; + var produce = () => producer.RequireAnnouncement(); + produce.Should().Throw() + .WithMessage("*enrolled writable frame*"); + return Task.FromResult(Result.Ok(None.Value)); + })).IsSuccess.Should().BeTrue(); + + throw stop; + }), CancellationToken.None); + + (await run.Should().ThrowAsync()).Which.Should().BeSameAs(stop); + reached.Should().BeTrue(); + AssertClean(services); + } + + [Fact] + public async Task An_event_free_absorbed_refusal_can_commit_its_observed_mutation() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await SeedTenantAsync(database.AppConnectionString); + await using var dataSource = NpgsqlDataSource.Create(database.AppConnectionString); + await using var provider = Compose(dataSource); + await using var scope = provider.CreateAsyncScope(); + var services = scope.ServiceProvider; + var transaction = services.GetRequiredService(); + var unit = services.GetRequiredService(); + var id = TenantContentTypeId.From(Guid.CreateVersion7()); + + var result = await transaction.ExecuteAsync(null, TransactionMode.ReadWrite, "outer-control", async () => + { + var refused = await transaction.ExecuteAsync(null, TransactionMode.ReadWrite, "event-free", async () => + { + await WriteContentTypeAsync(services, id); + (await CountInUnitAsync(unit, "tenant_content_types", id.Value)).Should().Be(1); + return Result.Fail(Refusal); + }); + refused.Error.Should().BeSameAs(Refusal); + unit.IsRollbackOnly.Should().BeFalse("ADR-0040 still permits an event-free refusal to be absorbed"); + return Result.Ok(None.Value); + }); + + result.IsSuccess.Should().BeTrue(); + (await CountAsync(database.AppConnectionString, "tenant_content_types", id.Value)).Should().Be(1); + } + + [Fact] + public async Task A_direct_owner_constructs_its_handler_after_admission_and_writes_one_correct_MUST_row() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await SeedTenantAsync(database.AppConnectionString); + await using var dataSource = NpgsqlDataSource.Create(database.AppConnectionString); + await using var provider = Compose(dataSource); + await using var scope = provider.CreateAsyncScope(); + var services = scope.ServiceProvider; + var capture = new ObservedCapture(services.GetRequiredService()); + var audit = DirectAudit(services, capture); + var id = TenantContentTypeId.From(Guid.CreateVersion7()); + var constructed = false; + + var result = await audit.ExecuteAsync(typeof(DirectHandler), null, + () => services.GetRequiredService().ExecuteAsync( + null, TransactionMode.ReadWrite, nameof(DirectHandler), () => + { + constructed = true; + return new DirectHandler(services).HandleAsync(id); + }), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + constructed.Should().BeTrue(); + capture.ClearCount.Should().Be(1); + AssertClean(services); + (await CountAsync(database.AppConnectionString, "tenant_content_types", id.Value)).Should().Be(1); + + var row = (await AuditRowsAsync(database.AppConnectionString)).Should().ContainSingle().Subject; + row.Operation.Should().Be(DirectOperation); + row.OperationClass.Should().Be("Must"); + row.Outcome.Should().Be("success"); + row.EntityType.Should().Be(nameof(TenantContentType)); + row.EntityId.Should().Be(id.Value.ToString()); + row.Actor.Should().Be(SchemaFixture.Actor); + row.Correlation.Should().Be(Correlation); + row.Before.Should().BeNull(); + using var after = JsonDocument.Parse(row.After!); + after.RootElement.GetProperty("Key").GetString().Should().Be(Key(id)); + } + + [Fact] + public async Task An_unregistered_direct_owner_is_refused_before_its_handler_factory_runs() + { + await using var dataSource = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + await using var provider = Compose(dataSource); + await using var scope = provider.CreateAsyncScope(); + var services = scope.ServiceProvider; + var constructed = false; + + var result = await DirectAudit(services).ExecuteAsync(typeof(UnregisteredHandler), null, + () => services.GetRequiredService().ExecuteAsync( + null, TransactionMode.ReadWrite, nameof(UnregisteredHandler), () => + { + constructed = true; + return Task.FromResult(Result.Ok(None.Value)); + }), CancellationToken.None); + + result.Error.Should().Be(AuditErrors.UnclassifiedOperation); + constructed.Should().BeFalse(); + services.GetRequiredService().HasActiveTransaction.Should().BeFalse(); + AssertClean(services); + } + + [Fact] + public async Task An_Off_direct_owner_preserves_nested_command_intents_and_clears_only_after_commit() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await SeedTenantAsync(database.AppConnectionString); + await using var dataSource = NpgsqlDataSource.Create(database.AppConnectionString); + await using var provider = Compose(dataSource); + await using var scope = provider.CreateAsyncScope(); + var services = scope.ServiceProvider; + var sharedCapture = services.GetRequiredService(); + var capture = new ObservedCapture(sharedCapture); + var id = OrganizationId.From(Guid.CreateVersion7()); + + var result = await DirectAudit(services, capture).ExecuteAsync(typeof(OffHandler), null, + () => services.GetRequiredService().ExecuteAsync( + null, TransactionMode.ReadWrite, nameof(OffHandler), async () => + { + var nested = await services.GetRequiredService().Send( + new CreateOrganizationCommand(id, "direct-branch", "Direct branch")); + nested.IsSuccess.Should().BeTrue("the nested command is admitted: {0}", nested.Error); + (await CountInUnitAsync(services.GetRequiredService(), + "organizations", id.Value)).Should().Be(1); + sharedCapture.Intents.Should().ContainSingle(); + sharedCapture.Changes.Should().NotBeEmpty(); + sharedCapture.HasOpenFrame.Should().BeTrue("the outer direct audit frame is still open"); + sharedCapture.State.Should().Be(AuditIntentState.Pending, + "the nested command joined and cannot claim commit ownership"); + capture.ClearCount.Should().Be(0); + return Result.Ok(None.Value); + }), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + capture.ClearCount.Should().Be(1); + AssertClean(services); + var row = (await AuditRowsAsync(database.AppConnectionString)).Should().ContainSingle().Subject; + row.Operation.Should().Be("tenancy.organization.create"); + row.EntityId.Should().Be(id.Value.ToString()); + row.Outcome.Should().Be("success"); + row.After.Should().Contain("Direct branch"); + (await CountAsync(database.AppConnectionString, "organizations", id.Value)).Should().Be(1); + } + + [Fact] + public async Task A_real_direct_MUST_audit_failure_rolls_back_a_positively_observed_business_write() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await SeedTenantAsync(database.AppConnectionString); + await using (var owner = (NpgsqlConnection)await PostgresFixture.OpenAsync(database.MigrationConnectionString)) + await using (var deny = new NpgsqlCommand( + "ALTER TABLE audit_log ADD CONSTRAINT reject_direct_probe CHECK (operation <> 'customization.content_type.direct_probe')", + owner)) + { + await deny.ExecuteNonQueryAsync(); + } + + await using var dataSource = NpgsqlDataSource.Create(database.AppConnectionString); + await using var provider = Compose(dataSource); + await using var scope = provider.CreateAsyncScope(); + var services = scope.ServiceProvider; + var id = TenantContentTypeId.From(Guid.CreateVersion7()); + var observed = false; + var run = () => DirectAudit(services).ExecuteAsync(typeof(DirectHandler), null, + () => services.GetRequiredService().ExecuteAsync( + null, TransactionMode.ReadWrite, nameof(DirectHandler), async () => + { + var result = await new DirectHandler(services).HandleAsync(id); + (await CountInUnitAsync(services.GetRequiredService(), + "tenant_content_types", id.Value)).Should().Be(1); + observed = true; + return result; + }), CancellationToken.None); + + var failure = (await run.Should().ThrowAsync()).Which; + failure.InnerException.Should().BeOfType() + .Which.SqlState.Should().Be(PostgresErrorCodes.CheckViolation); + observed.Should().BeTrue(); + (await CountAsync(database.AppConnectionString, "tenant_content_types", id.Value)).Should().Be(0); + (await AuditRowsAsync(database.AppConnectionString)).Should().BeEmpty(); + AssertClean(services); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Both_compositions_reconcile_an_independent_scope_inside_another_audit_execution(bool api) + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await SeedTenantAsync(database.AppConnectionString); + await using var dataSource = NpgsqlDataSource.Create(database.AppConnectionString); + await using var seed = Compose(dataSource); + await using var host = new WebApplicationFactory().WithWebHostBuilder(builder => + { + builder.UseSetting("ConnectionStrings:Default", database.AppConnectionString); + builder.ConfigureServices(services => services.AddSingleton( + new StaticTenantContextAccessor(TenantA))); + }); + var root = api ? host.Services : seed; + await using var outer = root.CreateAsyncScope(); + var outerServices = outer.ServiceProvider; + var outerCapture = new ObservedCapture(outerServices.GetRequiredService()); + var id = OrganizationId.From(Guid.CreateVersion7()); + + var result = await DirectAudit(outerServices, outerCapture).ExecuteAsync(typeof(OffHandler), null, async () => + { + await using var inner = root.CreateAsyncScope(); + var innerServices = inner.ServiceProvider; + innerServices.GetRequiredService() + .Should().NotBeSameAs(outerServices.GetRequiredService()); + innerServices.GetRequiredService() + .Should().NotBeSameAs(outerServices.GetRequiredService()); + innerServices.GetRequiredService() + .Should().NotBeSameAs(outerServices.GetRequiredService()); + innerServices.GetRequiredService() + .Should().NotBeSameAs(outerServices.GetRequiredService()); + + var nested = await innerServices.GetRequiredService().Send( + new CreateOrganizationCommand(id, "independent-branch", "Independent branch")); + nested.IsSuccess.Should().BeTrue("the independent command is admitted: {0}", nested.Error); + AssertClean(innerServices); + outerCapture.HasOpenFrame.Should().BeTrue(); + outerCapture.ClearCount.Should().Be(0); + return Result.Ok(None.Value); + }, CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + outerCapture.ClearCount.Should().Be(1); + AssertClean(outerServices); + var row = (await AuditRowsAsync(database.AppConnectionString)).Should().ContainSingle().Subject; + row.Operation.Should().Be("tenancy.organization.create"); + row.EntityId.Should().Be(id.Value.ToString()); + row.Outcome.Should().Be("success"); + } + + [Theory] + [InlineData("success")] + [InlineData("refusal")] + [InlineData("cancel")] + [InlineData("fault")] + public async Task A_no_listener_boundary_has_a_W3C_trace_and_cleans_every_exit(string exit) + { + await using var dataSource = NpgsqlDataSource.Create(schema.Postgres.AppConnectionString); + await using var provider = Compose(dataSource); + await using var scope = provider.CreateAsyncScope(); + var services = scope.ServiceProvider; + var parent = Activity.Current; + Activity.Current = null; + try + { + using var source = new ActivitySource("learnstack.producer.test.no-listener"); + using var control = source.StartActivity("control"); + control.Should().BeNull("this proof installs no telemetry listener"); + var reached = false; + var fault = new InvalidOperationException("deliberate handler fault"); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + long generation = 0; + + Task> WorkAsync() + { + reached = true; + Activity.Current.Should().NotBeNull(); + var activity = Activity.Current!; + activity.IdFormat.Should().Be(ActivityIdFormat.W3C); + activity.Id.Should().MatchRegex("^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$"); + activity.TraceId.Should().NotBe(default(ActivityTraceId)); + generation = services.GetRequiredService().RequireAnnouncement().Generation; + return exit switch + { + "refusal" => Task.FromResult(Result.Fail(Refusal)), + "cancel" => Task.FromCanceled>(cancellation.Token), + "fault" => Task.FromException>(fault), + _ => Task.FromResult(Result.Ok(None.Value)), + }; + } + + var run = () => DirectAudit(services).ExecuteAsync(typeof(OffHandler), null, + () => services.GetRequiredService().ExecuteAsync( + null, TransactionMode.ReadWrite, "trace-probe", WorkAsync), CancellationToken.None); + + if (exit == "fault") + { + (await run.Should().ThrowAsync()).Which.Should().BeSameAs(fault); + } + else if (exit == "cancel") + { + (await run.Should().ThrowAsync()).Which.CancellationToken + .Should().Be(cancellation.Token); + } + else + { + (await run()).IsSuccess.Should().Be(exit == "success"); + } + + reached.Should().BeTrue(); + generation.Should().BeGreaterThan(0); + Activity.Current.Should().BeNull(); + AssertClean(services); + + // Successful scopes may start another unit. Poisoned scopes deliberately + // cannot: cleanup must preserve ADR-0040's irreversible poison contract. + if (exit == "success") + { + await DirectAudit(services).ExecuteAsync(typeof(OffHandler), null, + () => services.GetRequiredService().ExecuteAsync( + null, TransactionMode.ReadWrite, "same-scope", () => + { + services.GetRequiredService().RequireAnnouncement().Generation + .Should().BeGreaterThan(generation); + return Task.FromResult(Result.Ok(None.Value)); + }), CancellationToken.None); + AssertClean(services); + Activity.Current.Should().BeNull(); + } + else + { + services.GetRequiredService().IsRollbackOnly.Should().BeTrue(); + } + } + finally + { + Activity.Current = parent; + } + } + + private static ServiceProvider Compose(NpgsqlDataSource dataSource) => + SeedComposition.Build(dataSource, TenantA, NullLoggerFactory.Instance); + + private static AuditExecutionCoordinator DirectAudit(IServiceProvider services, IAuditStateCapture? capture = null) => + new(new ProbeCatalog(services.GetRequiredService()), + services.GetRequiredService(), + capture ?? services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + services.GetRequiredService(), + NullLogger.Instance); + + private static void AssertClean(IServiceProvider services) + { + var capture = services.GetRequiredService(); + capture.HasOpenFrame.Should().BeFalse(); + capture.Intents.Should().BeEmpty(); + capture.Changes.Should().BeEmpty(); + capture.State.Should().Be(AuditIntentState.None); + services.GetRequiredService().HasInvocation.Should().BeFalse(); + var unit = services.GetRequiredService(); + unit.Transaction.Should().BeNull(); + unit.GetTenantContextAnnouncement(unit.Transaction).Should().BeNull(); + } + + private static async Task WriteContentTypeAsync(IServiceProvider services, TenantContentTypeId id) + { + var db = services.GetRequiredService(); + db.Add(TenantContentType.Create(id, TenantA.TenantId, Key(id), 1, + LocalizedText.From(("en", "Execution probe")), """{"type":"object"}""", "default-card", + services.GetRequiredService(), UserId.From(SchemaFixture.Actor))); + await db.SaveChangesAsync(); + } + + private static string Key(TenantContentTypeId id) => $"probe-{id.Value:N}"[..24]; + + // Table names below are closed fixture constants, never request input. + private static async Task CountInUnitAsync(IUnitOfWork unit, string table, Guid id) + { + await using var command = unit.Connection.CreateCommand(); + command.Transaction = unit.Transaction; + command.CommandText = $"SELECT count(*) FROM {table} WHERE id = @id"; + var parameter = command.CreateParameter(); + parameter.ParameterName = "id"; + parameter.Value = id; + command.Parameters.Add(parameter); + return (long)(await command.ExecuteScalarAsync())!; + } + + private static async Task CountAsync(string connectionString, string table, Guid id) + { + await using var connection = (NpgsqlConnection)await PostgresFixture.OpenAsync(connectionString); + await using var transaction = await connection.BeginTransactionAsync(); + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantA); + await using var command = new NpgsqlCommand($"SELECT count(*) FROM {table} WHERE id = @id", connection, transaction); + command.Parameters.AddWithValue("id", id); + return (long)(await command.ExecuteScalarAsync())!; + } + + private static async Task SeedTenantAsync(string connectionString) + { + await using var connection = (NpgsqlConnection)await PostgresFixture.OpenAsync(connectionString); + await using var transaction = await connection.BeginTransactionAsync(); + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantA); + await using var command = new NpgsqlCommand( + """ + INSERT INTO tenants (id, slug, display_name, status, created_at, created_by, row_version) + VALUES (@id, 'execution-probe', 'Execution probe', 'Trial', now(), @actor, 0) + """, connection, transaction); + command.Parameters.AddWithValue("id", SchemaFixture.TenantA); + command.Parameters.AddWithValue("actor", SchemaFixture.Actor); + await command.ExecuteNonQueryAsync(); + await transaction.CommitAsync(); + } + + private static async Task> AuditRowsAsync(string connectionString) + { + await using var connection = (NpgsqlConnection)await PostgresFixture.OpenAsync(connectionString); + await using var transaction = await connection.BeginTransactionAsync(); + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantA); + await using var command = new NpgsqlCommand( + """ + SELECT operation, operation_class, outcome, entity_type, entity_id, + actor_user_id, correlation_id, before_state::text, after_state::text + FROM audit_log ORDER BY timestamp + """, connection, transaction); + await using var reader = await command.ExecuteReaderAsync(); + var rows = new List(); + while (await reader.ReadAsync()) + { + rows.Add(new AuditRow(reader.GetString(0), reader.GetString(1), reader.GetString(2), + reader.IsDBNull(3) ? null : reader.GetString(3), reader.IsDBNull(4) ? null : reader.GetString(4), + reader.IsDBNull(5) ? null : reader.GetGuid(5), reader.IsDBNull(6) ? null : reader.GetString(6), + reader.IsDBNull(7) ? null : reader.GetString(7), reader.IsDBNull(8) ? null : reader.GetString(8))); + } + return rows; + } + + private sealed record AuditRow(string Operation, string OperationClass, string Outcome, + string? EntityType, string? EntityId, Guid? Actor, string? Correlation, string? Before, string? After); + + private sealed class DirectHandler + { + private readonly IServiceProvider _services; + + public DirectHandler(IServiceProvider services) + { + _services = services; + services.GetRequiredService().HasActiveTransaction.Should().BeTrue(); + services.GetRequiredService().HasOpenFrame.Should().BeTrue(); + services.GetRequiredService().RequireAnnouncement().TenantId.Should().Be(TenantA.TenantId); + } + + public async Task> HandleAsync(TenantContentTypeId id) + { + await WriteContentTypeAsync(_services, id); + return Result.Ok(None.Value); + } + } + + private sealed class OffHandler; + private sealed class UnregisteredHandler; + + /// The real request catalogue remains intact; only controlled direct types are added. + private sealed class ProbeCatalog(IAuditCatalog actual) : IAuditCatalog + { + public bool TryGet(Type requestType, out AuditRegistration registration) + { + if (requestType == typeof(DirectHandler)) + { + registration = new AuditRegistration(false, + [new AuditCatalogEntry("customization", DirectOperation, OperationType.Create, + OperationClass.Must, typeof(TenantContentType))]); + return true; + } + if (requestType == typeof(OffHandler)) + { + registration = new AuditRegistration(true, []); + return true; + } + return actual.TryGet(requestType, out registration); + } + + public bool TryGetOffPath(string operation, out AuditCatalogEntry entry) => actual.TryGetOffPath(operation, out entry); + public IReadOnlyCollection All => actual.All; + } + + private sealed class ObservedCapture(IAuditStateCapture actual) : IAuditStateCapture + { + public int ClearCount { get; private set; } + public IReadOnlyList Changes => actual.Changes; + public IReadOnlyList Intents => actual.Intents; + public AuditIntentState State => actual.State; + public bool HasOpenFrame => actual.HasOpenFrame; + public void Add(CapturedEntityChange change) => actual.Add(change); + public IReadOnlyList ChangesOf(AuditIntent intent) => actual.ChangesOf(intent); + public void OpenFrame() => actual.OpenFrame(); + public void CloseFrame(AuditIntentResult result) => actual.CloseFrame(result); + public void DeclareIntent(AuditIntent intent) => actual.DeclareIntent(intent); + public void MarkWrittenInTransaction() => actual.MarkWrittenInTransaction(); + public void MarkCommitted() => actual.MarkCommitted(); + public void MarkRolledBack() => actual.MarkRolledBack(); + public void MarkIndeterminate(Exception cause) => actual.MarkIndeterminate(cause); + public void Clear() + { + ClearCount++; + actual.Clear(); + } + } + + private sealed class ProbeContext : ITenantContext + { + public bool IsResolved => true; + public TenantContextOrigin? Origin => TenantContextOrigin.Ambient; + public TenantId TenantId => TenantId.From(SchemaFixture.TenantA); + public OrganizationId? OrganizationId => null; + public UserId? UserId => SharedKernel.Identifiers.UserId.From(SchemaFixture.Actor); + public string? CorrelationId => Correlation; + public string? ModuleName => "customization"; + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PublicReadTransactionTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PublicReadTransactionTests.cs index 97aadc70..136e2ca2 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PublicReadTransactionTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PublicReadTransactionTests.cs @@ -1,6 +1,8 @@ using System.Reflection; using FluentAssertions; using LearnStack.Application.Pipeline; +using LearnStack.Application.Persistence; +using LearnStack.Application.Messaging; using LearnStack.Infrastructure.Audit; using LearnStack.Infrastructure.Persistence; using LearnStack.Modules.Tenancy.Domain; @@ -47,9 +49,12 @@ public async Task PublicSurface_Transactions_Refuse_Writes() { var unit = scope.ServiceProvider.GetRequiredService(); var capture = new AuditStateCapture(); + var producer = new ProducerExecution(unit); var behavior = new TransactionBehavior>( - unit, Context, Store(scope.ServiceProvider, capture), capture, - NullLogger>>.Instance); + new TransactionExecutionCoordinator( + unit, Context, Store(scope.ServiceProvider, capture), capture, producer, + new DomainEventCollector(producer, new Mediator(scope.ServiceProvider)), + NullLogger.Instance)); var reached = false; var attempt = async () => await behavior.Handle(new PublicProbe(), async () => { @@ -139,14 +144,18 @@ public async Task Mixed_mode_refusal_poisons_an_outer_owner_even_when_absorbed( }; var capture = new AuditStateCapture(); var store = Store(scope.ServiceProvider, capture); + var producer = new ProducerExecution(unit); + var collector = new DomainEventCollector(producer, new Mediator(scope.ServiceProvider)); Func join = innerMode == TransactionMode.ReadOnly ? async () => await new TransactionBehavior>( - unit, Context, store, capture, - NullLogger>>.Instance) + new TransactionExecutionCoordinator( + unit, Context, store, capture, producer, collector, + NullLogger.Instance)) .Handle(new PublicProbe(), next, default) : async () => await new TransactionBehavior>( - unit, Context, store, capture, - NullLogger>>.Instance) + new TransactionExecutionCoordinator( + unit, Context, store, capture, producer, collector, + NullLogger.Instance)) .Handle(new WritableProbe(), next, default); await join.Should().ThrowAsync().WithMessage("*mixed-mode*"); handlerInvoked.Should().BeFalse(); @@ -205,9 +214,12 @@ public async Task Cancelled_handler_rolls_back_and_pooled_next_scope_is_writable { var unit = scope.ServiceProvider.GetRequiredService(); var capture = new AuditStateCapture(); + var producer = new ProducerExecution(unit); var behavior = new TransactionBehavior>( - unit, Context, Store(scope.ServiceProvider, capture), capture, - NullLogger>>.Instance); + new TransactionExecutionCoordinator( + unit, Context, Store(scope.ServiceProvider, capture), capture, producer, + new DomainEventCollector(producer, new Mediator(scope.ServiceProvider)), + NullLogger.Instance)); var attempt = async () => await behavior.Handle(new PublicProbe(), () => { unit.Mode.Should().Be(TransactionMode.ReadOnly); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenantCreatedProducerTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenantCreatedProducerTests.cs new file mode 100644 index 00000000..a61c1075 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantCreatedProducerTests.cs @@ -0,0 +1,146 @@ +using System.Diagnostics; +using System.Text.Json; +using FluentAssertions; +using LearnStack.Application.Messaging; +using LearnStack.Infrastructure.Messaging; +using LearnStack.Modules.Tenancy.Application.Contracts.IntegrationEvents; +using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; +using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Messaging; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Tenancy; +using LearnStack.Tools.Seeder; +using MediatR; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +/// Completed provisioning produces one pending fact and two MUST rows, on both real roots. +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +[Collection(SharedSchema.Name)] +public sealed class TenantCreatedProducerTests(SchemaFixture schema) +{ + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public async Task Provisioning_business_outbox_and_both_MUST_rows_share_one_commit(bool api, bool refuseAudit) + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + if (refuseAudit) + { + await using var owner = await PostgresFixture.OpenAsync(database.MigrationConnectionString); + await using var revoke = new NpgsqlCommand("REVOKE INSERT ON audit_log FROM learnstack_app", (NpgsqlConnection)owner); + await revoke.ExecuteNonQueryAsync(); + } + + await using var source = NpgsqlDataSource.Create(database.AppConnectionString); + await using var seed = SeedComposition.Build(source, null, NullLoggerFactory.Instance); + await using var host = new WebApplicationFactory().WithWebHostBuilder(builder => + { + builder.UseSetting("ConnectionStrings:Default", database.AppConnectionString); + builder.ConfigureServices(services => services.AddSingleton( + new StaticTenantContextAccessor(null))); + }); + await using var scope = (api ? host.Services : seed).CreateAsyncScope(); + var services = scope.ServiceProvider; + var tenant = TenantId.From(Guid.CreateVersion7()); + var organization = OrganizationId.From(Guid.CreateVersion7()); + var command = new ProvisionTenantCommand(tenant, "producer-probe", "Producer probe", organization, "hq", "HQ"); + services.GetRequiredService().Should().BeOfType(); + services.GetRequiredService().IsResolved.Should().BeFalse(); + + var send = () => services.GetRequiredService().Send(command); + if (refuseAudit) + { + (await send.Should().ThrowAsync()).Which.Error.Code.Should().Be("audit_unavailable"); + } + else + { + var result = await send(); + result.IsSuccess.Should().BeTrue(); + result.Value!.DefaultOrganizationId.Should().Be(organization); + } + + services.GetRequiredService().Transaction.Should().BeNull(); + services.GetRequiredService().HasInvocation.Should().BeFalse(); + services.GetRequiredService().Intents.Should().BeEmpty(); + var tracked = services.GetRequiredService().ChangeTracker.Entries() + .Select(entry => entry.Entity).OfType().ToList(); + tracked.Should().NotBeEmpty("the real writer tracked the tenant and organization"); + tracked.Should().OnlyContain(entity => entity.DomainEvents.Count == 0, + "owner cleanup clears saved facts after either terminal"); + + await using var connection = (NpgsqlConnection)await PostgresFixture.OpenAsync(database.AppConnectionString); + await using var transaction = await connection.BeginTransactionAsync(); + await SchemaQueries.SetTenantAsync(connection, transaction, tenant.Value); + (await ScalarAsync(connection, transaction, "SELECT current_user")).Should().Be("learnstack_app"); + (await ScalarAsync(connection, transaction, + "SELECT (rolbypassrls OR rolsuper)::text FROM pg_roles WHERE rolname = current_user")).Should().Be("false"); + var expected = refuseAudit ? 0L : 1L; + (await ScalarAsync(connection, transaction, "SELECT count(*) FROM tenants WHERE default_organization_id IS NOT NULL")) + .Should().Be(expected); + (await ScalarAsync(connection, transaction, "SELECT count(*) FROM organizations")).Should().Be(expected); + (await ScalarAsync(connection, transaction, "SELECT count(*) FROM outbox_messages")).Should().Be(expected); + + if (refuseAudit) return; + + await using var read = new NpgsqlCommand( + """ + SELECT id, tenant_id, organization_id, correlation_id, causation_id, actor_user_id, + type, topic, partition_key, payload::text, metadata IS NULL, + processed_at, attempts, last_error, occurred_at + FROM outbox_messages + """, connection, transaction); + await using (var row = await read.ExecuteReaderAsync()) + { + (await row.ReadAsync()).Should().BeTrue(); + var eventId = row.GetGuid(0); + var factId = row.GetGuid(4); + eventId.Should().NotBeEmpty().And.NotBe(factId, "translation mints an integration identity separate from causation"); + factId.Should().NotBeEmpty(); + row.GetGuid(1).Should().Be(tenant.Value); + row.IsDBNull(2).Should().BeTrue("provisioning announces no organization"); + ActivityContext.TryParse(row.GetString(3), null, out _).Should().BeTrue(); + row.IsDBNull(5).Should().BeTrue("unresolved provisioning invents no causal human"); + row.GetString(6).Should().Be(typeof(TenantCreatedIntegrationEventV1).AssemblyQualifiedName); + row.GetString(7).Should().Be("learnstack.tenancy.tenant"); + row.GetString(8).Should().Be(tenant.Value.ToString("D")); + using var payload = JsonDocument.Parse(row.GetString(9)); + payload.RootElement.EnumerateObject().Select(property => property.Name) + .Should().BeEquivalentTo(["EventId", "TenantId", "OccurredAt", "Topic", "PartitionKey"]); + var decoded = JsonSerializer.Deserialize( + row.GetString(9), IntegrationEventBase.PayloadJsonOptions)!; + decoded.EventId.Should().Be(eventId); + decoded.TenantId.Should().Be(tenant.Value); + decoded.OccurredAt.Should().BeCloseTo(row.GetFieldValue(14), TimeSpan.FromMicroseconds(1), + "PostgreSQL timestamps have microsecond precision"); + row.GetBoolean(10).Should().BeTrue(); + row.IsDBNull(11).Should().BeTrue(); + row.GetInt32(12).Should().Be(0); + row.IsDBNull(13).Should().BeTrue(); + (await row.ReadAsync()).Should().BeFalse("one completed provisioning yields one pending integration event"); + } + + (await ScalarAsync(connection, transaction, + "SELECT count(*) FROM audit_log WHERE operation_class = 'Must' AND outcome = 'success'")) + .Should().Be(2L); + (await ScalarAsync(connection, transaction, + "SELECT count(DISTINCT operation) FROM audit_log WHERE operation IN ('tenancy.tenant.create', 'tenancy.organization.create')")) + .Should().Be(2L); + } + + private static async Task ScalarAsync(NpgsqlConnection connection, NpgsqlTransaction transaction, string sql) + { + await using var command = new NpgsqlCommand(sql, connection, transaction); + return await command.ExecuteScalarAsync(); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenantProvisioningTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenantProvisioningTests.cs index 67555a1c..47ad6bab 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/TenantProvisioningTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantProvisioningTests.cs @@ -2,8 +2,11 @@ using LearnStack.Api.Composition; using LearnStack.Api.Common; using LearnStack.Application.Pipeline; +using LearnStack.Application.Persistence; +using LearnStack.Application.Messaging; using LearnStack.Infrastructure.Audit; using LearnStack.Infrastructure.Persistence; +using LearnStack.Infrastructure.Messaging; using LearnStack.Modules.Tenancy.Application.Abstractions; using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; using LearnStack.Modules.Tenancy.Domain; @@ -11,12 +14,14 @@ using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Errors; using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Messaging; using LearnStack.SharedKernel.Results; using LearnStack.SharedKernel.Tenancy; using LearnStack.SharedKernel.Time; using MediatR; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; using Npgsql; @@ -300,11 +305,12 @@ public async Task A_request_that_does_not_provision_still_fails_closed_when_unre var unitOfWork = scope.ServiceProvider.GetRequiredService(); var behavior = new TransactionBehavior>( - unitOfWork, - UnresolvedTenantContext.Instance, - new NoOpAuditStore(), - new AuditStateCapture(), - NullLogger>>.Instance); + new TransactionExecutionCoordinator( + unitOfWork, UnresolvedTenantContext.Instance, + new NoOpAuditStore(), new AuditStateCapture(), + scope.ServiceProvider.GetRequiredService(), + scope.ServiceProvider.GetRequiredService(), + NullLogger.Instance)); var write = () => behavior.Handle( new Probe(), @@ -506,13 +512,14 @@ private static async Task> ProvisionAsync( await using var scope = provider.CreateAsyncScope(); var services = scope.ServiceProvider; + var unit = services.GetRequiredService(); var behavior = new TransactionBehavior>( - services.GetRequiredService(), - context ?? UnresolvedTenantContext.Instance, - new NoOpAuditStore(), - new AuditStateCapture(), - NullLogger>> - .Instance); + new TransactionExecutionCoordinator( + unit, context ?? UnresolvedTenantContext.Instance, + new NoOpAuditStore(), new AuditStateCapture(), + services.GetRequiredService(), + services.GetRequiredService(), + NullLogger.Instance)); return await behavior.Handle( command, @@ -541,10 +548,15 @@ private ServiceProvider BuildProvider(NpgsqlDataSource? dataSource = null) sp.GetRequiredService().Current ?? UnresolvedTenantContext.Instance); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddModuleDbContext(); services.AddScoped(); services.AddScoped(); services.AddSingleton(Clock); + services.AddSingleton(); services.AddMediatR(configuration => configuration.RegisterServicesFromAssembly( typeof(ITenantWriteStore).Assembly)); @@ -591,6 +603,7 @@ private async Task CleanUpAsync(ProvisionTenantCommand command) foreach (var statement in new[] { + "DELETE FROM outbox_messages WHERE tenant_id = @tenant", "UPDATE tenants SET default_organization_id = NULL WHERE id = @tenant", "DELETE FROM organizations WHERE tenant_id = @tenant", "DELETE FROM tenants WHERE id = @tenant", diff --git a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs index c91a0df0..2046d5d5 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs @@ -1,6 +1,8 @@ using System.Data.Common; using FluentAssertions; using LearnStack.Api.Composition; +using LearnStack.Application.Messaging; +using LearnStack.Application.Persistence; using LearnStack.Application.Pipeline; using LearnStack.Infrastructure.Audit; using LearnStack.Infrastructure.Persistence; @@ -69,6 +71,339 @@ await unitOfWork.SetTenantContextAsync( await unitOfWork.RollbackAsync(); } + [Fact] + public async Task The_announcement_snapshot_records_the_scope_sent_to_Postgres() + { + var context = new MutableAnnouncementContext( + TenantId.From(SchemaFixture.TenantA), OrganizationId.From(SchemaFixture.OrgA1)); + using var logs = LoggerFactory.Create(logging => logging.SetMinimumLevel(LogLevel.Trace) + .AddProvider(new StatementObserverLogger("SELECT set_config('app.tenant_id'", () => + { + context.TenantId = TenantId.From(SchemaFixture.TenantB); + context.OrganizationId = OrganizationId.From(SchemaFixture.OrgB1); + }))); + await using var dataSource = new NpgsqlDataSourceBuilder(_schema.Postgres.AppConnectionString) + .UseLoggerFactory(logs).Build(); + await using var provider = BuildProvider(dataSource); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + await using var owner = await unit.BeginTransactionAsync(); + unit.GetTenantContextAnnouncement(unit.Transaction).Should().BeNull(); + + await unit.SetTenantContextAsync(context); + var snapshot = unit.GetTenantContextAnnouncement(unit.Transaction); + snapshot.Should().NotBeNull(); + snapshot!.Generation.Should().BePositive(); + snapshot.TenantId.Value.ToString().Should().Be( + await ReadAsync(unit, "SELECT current_setting('app.tenant_id', true)")); + snapshot.OrganizationId!.Value.Value.ToString().Should().Be( + await ReadAsync(unit, "SELECT current_setting('app.organization_id', true)")); + (await ReadAsync(unit, "SELECT current_user")).Should().Be("learnstack_app"); + (await ReadAsync(unit, "SELECT (rolbypassrls OR rolsuper)::text FROM pg_roles WHERE rolname = current_user")) + .Should().Be("false"); + + context.TenantId.Should().Be(TenantId.From(SchemaFixture.TenantB), + "the source changes at the driver command, after SQL parameters were captured"); + context.OrganizationId.Should().Be(OrganizationId.From(SchemaFixture.OrgB1)); + unit.GetTenantContextAnnouncement(unit.Transaction).Should().BeSameAs(snapshot); + snapshot.TenantId.Should().Be(TenantId.From(SchemaFixture.TenantA)); + snapshot.OrganizationId.Should().Be(OrganizationId.From(SchemaFixture.OrgA1)); + scope.ServiceProvider.GetRequiredService().Current.Should().BeNull( + "announcing SQL authority does not write the ambient tenant context"); + await owner.FailAsync(); + } + + [Theory] + [InlineData("absent")] + [InlineData("zero")] + [InlineData("uninitialized")] + public async Task The_announcement_snapshot_uses_the_normalized_organization(string organizationKind) + { + await using var provider = BuildProvider(); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + OrganizationId? organization = organizationKind switch + { + "zero" => OrganizationId.From(Guid.Empty), + "uninitialized" => UnassignedId(), + _ => null, + }; + await using var owner = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(new MutableAnnouncementContext( + TenantId.From(SchemaFixture.TenantA), organization)); + + var snapshot = unit.GetTenantContextAnnouncement(unit.Transaction); + snapshot.Should().NotBeNull(); + snapshot!.TenantId.Should().Be(TenantId.From(SchemaFixture.TenantA)); + snapshot.OrganizationId.Should().BeNull(); + (await ReadAsync(unit, "SELECT current_setting('app.organization_id', true)")).Should().BeEmpty(); + await owner.FailAsync(); + } + + [Theory] + [InlineData("unresolved")] + [InlineData("zero")] + [InlineData("uninitialized")] + [InlineData("sentinel")] + public async Task An_announcement_without_a_usable_tenant_supplies_no_producer_snapshot(string tenantKind) + { + await using var provider = BuildProvider(); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + ITenantContext context = tenantKind switch + { + "zero" => Resolved(Guid.Empty, SchemaFixture.OrgA1), + "uninitialized" => new UninitializedIdContext(), + "sentinel" => Resolved(TenantId.PlatformSentinel.Value, SchemaFixture.OrgA1), + _ => UnresolvedTenantContext.Instance, + }; + await using var owner = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(context); + + unit.IsTenantContextIssuedOn(unit.Transaction).Should().BeTrue( + "the fail-closed announcement succeeded, even though it authorizes no producer tenant"); + unit.GetTenantContextAnnouncement(unit.Transaction).Should().BeNull(); + (await ReadAsync(unit, "SELECT current_setting('app.tenant_id', true)")).Should().BeEmpty(); + await owner.FailAsync(); + } + + [Fact] + public async Task The_provisioning_snapshot_uses_the_new_tenant_without_resolving_the_context() + { + await using var provider = BuildProvider(); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + var tenant = TenantId.From(Guid.CreateVersion7()); + await using var owner = await unit.BeginTransactionAsync(); + await unit.SetProvisioningTenantContextAsync(tenant); + + var snapshot = unit.GetTenantContextAnnouncement(unit.Transaction); + snapshot.Should().NotBeNull(); + snapshot!.TenantId.Should().Be(tenant); + snapshot.OrganizationId.Should().BeNull(); + (await ReadAsync(unit, "SELECT current_setting('app.tenant_id', true)")).Should().Be(tenant.Value.ToString()); + (await ReadAsync(unit, "SELECT current_setting('app.organization_id', true)")).Should().BeEmpty(); + scope.ServiceProvider.GetRequiredService().IsResolved.Should().BeFalse(); + scope.ServiceProvider.GetRequiredService().Current.Should().BeNull(); + await owner.FailAsync(); + } + + [Fact] + public async Task Only_the_announced_transaction_can_read_the_owners_snapshot() + { + await using var provider = BuildProvider(); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + unit.GetTenantContextAnnouncement(null).Should().BeNull(); + await using var owner = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(Resolved(SchemaFixture.TenantA, SchemaFixture.OrgA1)); + var snapshot = unit.GetTenantContextAnnouncement(unit.Transaction); + snapshot.Should().NotBeNull(); + unit.GetTenantContextAnnouncement(null).Should().BeNull(); + + await using var elsewhere = await PostgresFixture.OpenAsync(_schema.Postgres.AppConnectionString); + await using var foreign = await elsewhere.BeginTransactionAsync(); + unit.GetTenantContextAnnouncement(foreign).Should().BeNull(); + + await using (var joiner = await unit.BeginTransactionAsync()) + { + joiner.IsOwner.Should().BeFalse(); + await unit.SetTenantContextAsync(Resolved(SchemaFixture.TenantB, SchemaFixture.OrgB1)); + unit.GetTenantContextAnnouncement(unit.Transaction).Should().BeSameAs(snapshot); + (await ReadAsync(unit, "SELECT current_setting('app.tenant_id', true)")) + .Should().Be(SchemaFixture.TenantA.ToString()); + var provision = async () => await unit.SetProvisioningTenantContextAsync( + TenantId.From(SchemaFixture.TenantB)); + await provision.Should().ThrowAsync().WithMessage("*joining frame*"); + unit.GetTenantContextAnnouncement(unit.Transaction).Should().BeSameAs(snapshot); + await joiner.CompleteAsync(); + } + unit.GetTenantContextAnnouncement(unit.Transaction).Should().BeSameAs(snapshot, + "resolving a joiner does not clear the owner's announcement"); + var reannounce = async () => await unit.SetProvisioningTenantContextAsync( + TenantId.From(SchemaFixture.TenantB)); + await reannounce.Should().ThrowAsync().WithMessage("*already been announced*"); + unit.GetTenantContextAnnouncement(unit.Transaction).Should().BeSameAs(snapshot); + await owner.FailAsync(); + } + + [Theory] + [InlineData("commit")] + [InlineData("rollback")] + [InlineData("dispose")] + [InlineData("cancelled-commit")] + public async Task A_terminal_boundary_discards_the_announcement_snapshot(string ending) + { + await using var provider = BuildProvider(); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + await using var owner = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(Resolved(SchemaFixture.TenantA, SchemaFixture.OrgA1)); + var transaction = unit.Transaction; + unit.GetTenantContextAnnouncement(transaction).Should().NotBeNull(); + + switch (ending) + { + case "commit": + await owner.CompleteAsync(); + break; + case "rollback": + await owner.FailAsync(); + break; + case "dispose": + await unit.DisposeAsync(); + break; + default: + { + using var cancellation = new CancellationTokenSource(); + await cancellation.CancelAsync(); + var complete = async () => await owner.CompleteAsync(cancellation.Token); + await complete.Should().ThrowAsync(); + break; + } + } + + unit.Transaction.Should().BeNull(); + unit.IsTenantContextIssuedOn(transaction).Should().BeFalse(); + unit.GetTenantContextAnnouncement(transaction).Should().BeNull(); + unit.GetTenantContextAnnouncement(null).Should().BeNull(); + } + + [Fact] + public async Task Reusing_a_transaction_object_requires_a_new_announcement_and_generation() + { + var builder = new NpgsqlDataSourceBuilder(_schema.Postgres.AppConnectionString); + builder.ConnectionStringBuilder.MaxPoolSize = 1; + builder.ConnectionStringBuilder.NoResetOnClose = true; + await using var dataSource = builder.Build(); + await using var provider = BuildProvider(dataSource); + await using var scope = provider.CreateAsyncScope(); + var unit = scope.ServiceProvider.GetRequiredService(); + await using var stale = await unit.BeginTransactionAsync(); + await unit.SetTenantContextAsync(Resolved(SchemaFixture.TenantA, SchemaFixture.OrgA1)); + var transaction = unit.Transaction; + var first = unit.GetTenantContextAnnouncement(transaction)!; + first.Should().NotBeNull(); + await unit.CommitAsync(); + unit.GetTenantContextAnnouncement(transaction).Should().BeNull(); + + await using var current = await unit.BeginTransactionAsync(); + unit.Transaction.Should().BeSameAs(transaction, + "the pinned Npgsql driver's reuse is the positive control for this stale-object proof"); + unit.IsTenantContextIssuedOn(unit.Transaction).Should().BeFalse(); + unit.GetTenantContextAnnouncement(transaction).Should().BeNull( + "object identity cannot retain an earlier physical transaction's authority"); + (await ReadAsync(unit, "SELECT current_setting('app.tenant_id', true)")).Should().BeEmpty(); + await unit.SetTenantContextAsync(Resolved(SchemaFixture.TenantB, SchemaFixture.OrgB1)); + var second = unit.GetTenantContextAnnouncement(unit.Transaction)!; + second.Generation.Should().BeGreaterThan(first.Generation); + second.TenantId.Should().Be(TenantId.From(SchemaFixture.TenantB)); + second.OrganizationId.Should().Be(OrganizationId.From(SchemaFixture.OrgB1)); + first.TenantId.Should().Be(TenantId.From(SchemaFixture.TenantA)); + await stale.CompleteAsync(); + unit.GetTenantContextAnnouncement(unit.Transaction).Should().BeSameAs(second, + "a stale UoW frame cannot clear or resolve the next generation"); + await current.CompleteAsync(); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task A_failed_announcement_never_retains_producer_authority(bool provisioning, bool cancel) + { + using var cancellation = new CancellationTokenSource(); + IUnitOfWork? unit = null; + var armed = false; + var observed = false; + var injected = new InvalidOperationException("injected announcement failure"); + using var logs = LoggerFactory.Create(logging => logging.SetMinimumLevel(LogLevel.Trace) + .AddProvider(new StatementObserverLogger("SELECT set_config('app.tenant_id'", () => + { + if (!armed) return; + observed = true; + unit!.GetTenantContextAnnouncement(unit.Transaction).Should().BeNull( + "authority is cleared before the announcement round trip begins"); + unit.IsTenantContextIssuedOn(unit.Transaction).Should().BeFalse(); + if (cancel) + { + cancellation.Cancel(); + cancellation.Token.ThrowIfCancellationRequested(); + } + throw injected; + }))); + await using var dataSource = new NpgsqlDataSourceBuilder(_schema.Postgres.AppConnectionString) + .UseLoggerFactory(logs).Build(); + await using var provider = BuildProvider(dataSource); + await using var scope = provider.CreateAsyncScope(); + unit = scope.ServiceProvider.GetRequiredService(); + await using var owner = await unit.BeginTransactionAsync(); + if (!provisioning) + { + await unit.SetTenantContextAsync(Resolved(SchemaFixture.TenantA, SchemaFixture.OrgA1)); + unit.GetTenantContextAnnouncement(unit.Transaction).Should().NotBeNull( + "the ordinary setter must discard a genuinely earlier successful announcement"); + } + armed = true; + var announce = async () => + { + if (provisioning) + await unit.SetProvisioningTenantContextAsync(TenantId.From(SchemaFixture.TenantB), cancellation.Token); + else + await unit.SetTenantContextAsync(Resolved(SchemaFixture.TenantB, SchemaFixture.OrgB1), cancellation.Token); + }; + if (cancel) await announce.Should().ThrowAsync(); + else (await announce.Should().ThrowAsync()).Which.Should().BeSameAs(injected); + observed.Should().BeTrue("the failure must reach the actual announcement command"); + unit.GetTenantContextAnnouncement(unit.Transaction).Should().BeNull(); + unit.IsTenantContextIssuedOn(unit.Transaction).Should().BeFalse(); + await owner.FailAsync(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Failed_transaction_setup_cannot_reuse_a_committed_announcement(bool cancel) + { + using var cancellation = new CancellationTokenSource(); + IUnitOfWork? unit = null; + var observed = false; + var injected = new InvalidOperationException("injected transaction setup failure"); + using var logs = LoggerFactory.Create(logging => logging.SetMinimumLevel(LogLevel.Trace) + .AddProvider(new StatementObserverLogger("SET TRANSACTION READ ONLY", () => + { + observed = true; + unit!.HasActiveTransaction.Should().BeTrue(); + unit.GetTenantContextAnnouncement(unit.Transaction).Should().BeNull(); + unit.IsTenantContextIssuedOn(unit.Transaction).Should().BeFalse(); + if (cancel) + { + cancellation.Cancel(); + cancellation.Token.ThrowIfCancellationRequested(); + } + throw injected; + }))); + await using var dataSource = new NpgsqlDataSourceBuilder(_schema.Postgres.AppConnectionString) + .UseLoggerFactory(logs).Build(); + await using var provider = BuildProvider(dataSource); + await using var scope = provider.CreateAsyncScope(); + unit = scope.ServiceProvider.GetRequiredService(); + await using (var first = await unit.BeginTransactionAsync()) + { + await unit.SetTenantContextAsync(Resolved(SchemaFixture.TenantA, SchemaFixture.OrgA1)); + unit.GetTenantContextAnnouncement(unit.Transaction).Should().NotBeNull(); + await first.CompleteAsync(); + } + var begin = async () => await unit.BeginTransactionAsync(TransactionMode.ReadOnly, cancellation.Token); + if (cancel) await begin.Should().ThrowAsync(); + else (await begin.Should().ThrowAsync()).Which.Should().BeSameAs(injected); + observed.Should().BeTrue(); + unit.Transaction.Should().BeNull(); + unit.GetTenantContextAnnouncement(null).Should().BeNull(); + unit.IsRollbackOnly.Should().BeTrue(); + } + [Fact] public async Task The_ordinary_setter_refuses_the_platform_sentinel() { @@ -579,18 +914,17 @@ public async Task An_absorbed_inner_failure_still_commits_the_outer_work() // owner/joiner distinction is about which frame may mark it, and two // buffers would let the joiner mark its own and prove nothing. var capture = new AuditStateCapture(); - var outer = new TransactionBehavior>( + var producer = new ProducerExecution(unitOfWork); + var coordinator = new TransactionExecutionCoordinator( unitOfWork, Resolved(SchemaFixture.TenantA, SchemaFixture.OrgA1), new NoOpAuditStore(), capture, - NullLogger>>.Instance); - var inner = new TransactionBehavior>( - unitOfWork, - Resolved(SchemaFixture.TenantA, SchemaFixture.OrgA1), - new NoOpAuditStore(), - capture, - NullLogger>>.Instance); + producer, + new DomainEventCollector(producer, new MediatR.Mediator(scope.ServiceProvider)), + NullLogger.Instance); + var outer = new TransactionBehavior>(coordinator); + var inner = new TransactionBehavior>(coordinator); var result = await outer.Handle( new Probe(), @@ -1057,6 +1391,8 @@ public async Task The_session_variables_do_not_survive_the_transaction_that_set_ builder.ConnectionStringBuilder.MaxPoolSize = 1; builder.ConnectionStringBuilder.NoResetOnClose = true; await using var dataSource = builder.Build(); + string firstBackend; + TenantContextAnnouncement firstAnnouncement; await using (var provider = BuildProvider(dataSource)) { @@ -1071,21 +1407,35 @@ await unitOfWork.SetTenantContextAsync( // against a setter that never ran. (await ReadAsync(unitOfWork, "SELECT current_setting('app.tenant_id', true)")) .Should().Be(SchemaFixture.TenantA.ToString()); + firstBackend = await ReadAsync(unitOfWork, "SELECT pg_backend_pid()::text"); + var transaction = unitOfWork.Transaction; + firstAnnouncement = unitOfWork.GetTenantContextAnnouncement(transaction)!; + firstAnnouncement.Should().NotBeNull(); await unitOfWork.CommitAsync(); + unitOfWork.GetTenantContextAnnouncement(transaction).Should().BeNull(); } - await using var connection = await dataSource.OpenConnectionAsync(); + await using var nextProvider = BuildProvider(dataSource); + await using var nextScope = nextProvider.CreateAsyncScope(); + var next = nextScope.ServiceProvider.GetRequiredService(); + await using var owner = await next.BeginTransactionAsync(); + (await ReadAsync(next, "SELECT pg_backend_pid()::text")).Should().Be(firstBackend, + "the proof must borrow the same physical connection without a pool reset"); + next.IsTenantContextIssuedOn(next.Transaction).Should().BeFalse(); + next.GetTenantContextAnnouncement(next.Transaction).Should().BeNull(); foreach (var variable in new[] { "app.tenant_id", "app.organization_id" }) { - await using var read = new NpgsqlCommand( - $"SELECT NULLIF(current_setting('{variable}', true), '')", connection); - - (await read.ExecuteScalarAsync() is null or DBNull).Should().BeTrue( + (await ReadAsync(next, $"SELECT NULLIF(current_setting('{variable}', true), '')")).Should().BeEmpty( $"{variable} was transaction-local, and the next borrower of this " + "connection is another tenant's request"); } + + await next.SetTenantContextAsync(Resolved(SchemaFixture.TenantB, SchemaFixture.OrgB1)); + next.GetTenantContextAnnouncement(next.Transaction)!.TenantId.Should().Be(TenantId.From(SchemaFixture.TenantB)); + firstAnnouncement.TenantId.Should().Be(TenantId.From(SchemaFixture.TenantA)); + await owner.FailAsync(); } [Fact] @@ -1195,6 +1545,38 @@ private sealed class ScopedAccessor : ITenantContextAccessor public ITenantContext? Current { get; set; } } + private sealed class MutableAnnouncementContext(TenantId tenantId, OrganizationId? organizationId) : ITenantContext + { + public bool IsResolved => true; + public TenantId TenantId { get; set; } = tenantId; + public OrganizationId? OrganizationId { get; set; } = organizationId; + public UserId? UserId => null; + public string? CorrelationId => null; + public string? ModuleName => "announcement-probe"; + } + + private static T UnassignedId() + { + var slots = new T[1]; + return slots[0]; + } + + private sealed class StatementObserverLogger(string statement, Action onStatement) : ILoggerProvider, ILogger + { + public ILogger CreateLogger(string categoryName) => this; + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Dispose() { } + public void Log(LogLevel logLevel, EventId eventId, TState state, + Exception? exception, Func formatter) + { + var message = formatter(state, exception); + if (message.StartsWith("Executing command: ", StringComparison.Ordinal) + && message.Contains(statement, StringComparison.Ordinal)) + onStatement(); + } + } + /// Collects what logged. private sealed class CapturingLogger : ILogger { diff --git a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/AuditLogBehaviorTests.cs b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/AuditLogBehaviorTests.cs index e77f87b7..fb80a49b 100644 --- a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/AuditLogBehaviorTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/AuditLogBehaviorTests.cs @@ -1,4 +1,5 @@ using FluentAssertions; +using LearnStack.Application.Audit; using LearnStack.Application.Pipeline; using LearnStack.Infrastructure.Audit; using LearnStack.SharedKernel.Audit; @@ -6,6 +7,7 @@ using LearnStack.SharedKernel.Localization; using LearnStack.SharedKernel.Results; using MediatR; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -100,6 +102,231 @@ await inner.Handle( capture.Intents.Should().BeEmpty("and the silent owner cleared on its way out"); } + [Fact] + public async Task Independent_DI_scopes_reconcile_independently_on_the_same_async_flow() + { + var services = new ServiceCollection(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(provider => Coordinator( + new FakeCatalog(AuditPipelineHarness.Entry(entityType: typeof(Subject))), + provider.GetRequiredService(), + provider.GetRequiredService())); + + using var provider = services.BuildServiceProvider(); + using var outerScope = provider.CreateScope(); + var outerCapture = outerScope.ServiceProvider.GetRequiredService(); + var outerStore = outerScope.ServiceProvider.GetRequiredService(); + var outer = outerScope.ServiceProvider.GetRequiredService(); + + await outer.ExecuteAsync( + typeof(DummyCommand), null, + async () => + { + outerCapture.Add(SubjectInsert("outer-1", "outer")); + + using var innerScope = provider.CreateScope(); + var innerCapture = innerScope.ServiceProvider.GetRequiredService(); + var innerStore = innerScope.ServiceProvider.GetRequiredService(); + var inner = innerScope.ServiceProvider.GetRequiredService(); + innerCapture.Should().NotBeSameAs(outerCapture); + + await inner.ExecuteAsync( + typeof(DummyCommand), null, + () => + { + innerCapture.Add(SubjectInsert("inner-1", "inner")); + innerCapture.MarkRolledBack(); + return Task.FromResult(Result.Ok("inner")); + }, + default); + + innerStore.Standalone.Should().ContainSingle().Which.EntityId.Should().Be("inner-1"); + innerCapture.HasOpenFrame.Should().BeFalse(); + innerCapture.Intents.Should().BeEmpty(); + outerCapture.HasOpenFrame.Should().BeTrue(); + outerCapture.Intents.Should().ContainSingle(); + outerCapture.Changes.Should().ContainSingle().Which.EntityId.Should().Be("outer-1"); + outerStore.Standalone.Should().BeEmpty("the outer scope still owns its open execution"); + + outerCapture.MarkCommitted(); + return Result.Ok("outer"); + }, + default); + + outerCapture.HasOpenFrame.Should().BeFalse(); + outerCapture.Intents.Should().BeEmpty(); + outerStore.Standalone.Should().BeEmpty("the outer MUST row is already durable"); + } + + [Fact] + public async Task An_unregistered_direct_handler_is_refused_before_construction() + { + var capture = new AuditStateCapture(); + var store = new RecordingAuditStore(); + var coordinator = Coordinator(new AuditCatalog([new DirectCatalogSource()]), store, capture); + + var result = await coordinator.ExecuteAsync( + typeof(UnregisteredDirectHandler), null, + () => Task.FromResult(Result.Ok(new UnregisteredDirectHandler())), + default); + + result.IsFailure.Should().BeTrue(); + result.Error!.Code.Should().Be("audit_unclassified_operation"); + capture.HasOpenFrame.Should().BeFalse(); + capture.Intents.Should().BeEmpty(); + store.Standalone.Should().BeEmpty(); + } + + [Fact] + public async Task A_direct_handler_is_classified_and_has_an_audit_frame_before_construction() + { + var capture = new AuditStateCapture(); + var store = new RecordingAuditStore(); + var coordinator = Coordinator(new AuditCatalog([new DirectCatalogSource()]), store, capture); + var constructed = false; + + var result = await coordinator.ExecuteAsync( + typeof(DirectAuditedHandler), null, + () => + { + _ = new DirectAuditedHandler(() => + { + constructed = true; + capture.HasOpenFrame.Should().BeTrue(); + capture.Intents.Should().ContainSingle().Which.Operation.Should().Be("tenancy.tenant.create"); + capture.Add(SubjectInsert("direct-1", "direct")); + }); + + capture.MarkRolledBack(); + return Task.FromResult(Result.Ok("direct")); + }, + default); + + constructed.Should().BeTrue(); + result.IsSuccess.Should().BeTrue(); + var row = store.Standalone.Should().ContainSingle().Subject; + row.EntityId.Should().Be("direct-1"); + row.AfterState.Should().Contain("direct"); + row.Outcome.Should().Be(AuditOutcome.Failed, "the direct owner reported that its transaction rolled back"); + capture.HasOpenFrame.Should().BeFalse(); + capture.Intents.Should().BeEmpty(); + } + + [Fact] + public async Task A_direct_owner_registered_Off_reconciles_its_nested_request_without_a_duplicate_row() + { + var capture = new AuditStateCapture(); + var store = new RecordingAuditStore(); + var coordinator = Coordinator(new AuditCatalog([new DirectCatalogSource()]), store, capture); + var nested = new AuditLogBehavior>(coordinator); + + await coordinator.ExecuteAsync( + typeof(DirectOrchestrator), null, + async () => + { + capture.HasOpenFrame.Should().BeTrue("registered Off still owns an audit frame"); + capture.Intents.Should().BeEmpty(); + + await nested.Handle( + new DummyCommand(), + () => + { + capture.Add(SubjectInsert("nested-1", "nested")); + return Task.FromResult(Result.Ok("nested")); + }, + default); + + capture.Intents.Should().ContainSingle().Which.Operation.Should().Be("tenancy.organization.create"); + store.Standalone.Should().BeEmpty("the nested request cannot reconcile before the direct owner resolves"); + capture.MarkRolledBack(); + return Result.Ok("direct owner"); + }, + default); + + var row = store.Standalone.Should().ContainSingle().Subject; + row.Operation.Should().Be("tenancy.organization.create"); + row.EntityId.Should().Be("nested-1"); + capture.HasOpenFrame.Should().BeFalse(); + capture.Intents.Should().BeEmpty(); + } + + [Theory] + [InlineData("success")] + [InlineData("refusal")] + [InlineData("exception")] + [InlineData("cancellation")] + public async Task The_same_coordinator_and_capture_start_fresh_after_every_exit(string ending) + { + var capture = new AuditStateCapture(); + var store = new RecordingAuditStore(); + var coordinator = Coordinator( + new FakeCatalog(AuditPipelineHarness.Entry(entityType: typeof(Subject))), store, capture); + var token = new CancellationToken(canceled: ending == "cancellation"); + + var first = async () => await coordinator.ExecuteAsync( + typeof(DummyCommand), null, + () => + { + capture.Add(SubjectInsert("first-1", "first")); + if (ending == "success") + { + capture.MarkCommitted(); + } + else + { + capture.MarkRolledBack(); + } + + return ending switch + { + "refusal" => Task.FromResult(Result.Fail(new Error( + new LocalizedMessage(LocalizedMessage.RequiredPrefix + "forbidden")))), + "exception" => throw new InvalidOperationException("first execution failed"), + "cancellation" => Task.FromCanceled>(token), + _ => Task.FromResult(Result.Ok("first")), + }; + }, + token); + + if (ending == "exception") + { + await first.Should().ThrowAsync(); + } + else if (ending == "cancellation") + { + await first.Should().ThrowAsync(); + } + else + { + var result = await first(); + result.IsFailure.Should().Be(ending == "refusal"); + } + + capture.HasOpenFrame.Should().BeFalse(); + capture.Intents.Should().BeEmpty(); + capture.Changes.Should().BeEmpty(); + capture.State.Should().Be(AuditIntentState.None); + var priorRows = store.Standalone.Count; + + await coordinator.ExecuteAsync( + typeof(DummyCommand), null, + () => + { + capture.HasOpenFrame.Should().BeTrue(); + capture.Intents.Should().ContainSingle(); + capture.Changes.Should().BeEmpty("the previous execution's snapshots cannot replay"); + capture.Add(SubjectInsert("second-1", "second")); + capture.MarkRolledBack(); + return Task.FromResult(Result.Ok("second")); + }, + default); + + store.Standalone.Skip(priorRows).Should().ContainSingle().Which.EntityId.Should().Be("second-1"); + capture.HasOpenFrame.Should().BeFalse(); + capture.Intents.Should().BeEmpty(); + } + [Fact] public async Task One_request_declares_one_intent_per_audited_operation() { @@ -176,7 +403,12 @@ public async Task A_committed_MUST_row_is_not_written_a_second_time() await behavior.Handle( new DummyCommand(), - () => { capture.MarkCommitted(); return Task.FromResult(Result.Ok("ok")); }, + () => + { + capture.Intents.Should().ContainSingle("a declared intent must exist before testing reconciliation"); + capture.MarkCommitted(); + return Task.FromResult(Result.Ok("ok")); + }, default); store.Standalone.Should().BeEmpty(); @@ -576,7 +808,7 @@ public async Task An_unresolved_request_that_is_not_provisioning_writes_no_row() var store = new RecordingAuditStore(); var handlerRan = false; - var behavior = new AuditLogBehavior>( + var behavior = new AuditLogBehavior>(new AuditExecutionCoordinator( new FakeCatalog(AuditPipelineHarness.Entry()), new FakeClassifier(), capture, @@ -584,7 +816,7 @@ public async Task An_unresolved_request_that_is_not_provisioning_writes_no_row() new HarnessTenantContext(resolved: false), new HarnessClock(DateTimeOffset.UnixEpoch), new HarnessGuidFactory(), - NullLogger>>.Instance); + NullLogger.Instance)); var result = await behavior.Handle( new DummyCommand(), @@ -843,7 +1075,7 @@ public async Task A_store_that_fails_before_writing_keeps_the_refusal_and_still_ logger.Levels.Should().NotContain(LogLevel.Critical); logger.Levels.Should().ContainSingle(level => level == LogLevel.Warning); - await AssertTheNextRequestIsOutermostAsync(store); + await AssertTheNextRequestIsOutermostAsync(store, capture); } [Fact] @@ -879,7 +1111,7 @@ public async Task A_composer_refusal_keeps_the_refusal_and_still_clears() // A programmer error nothing else reports, so this is its alert. logger.Levels.Should().ContainSingle(level => level == LogLevel.Critical); - await AssertTheNextRequestIsOutermostAsync(store); + await AssertTheNextRequestIsOutermostAsync(store, capture); } [Fact] @@ -913,20 +1145,21 @@ await behavior.Handle( } /// - /// A fresh request on the same flow is the outermost frame again — which it is not when - /// a previous request left AuditFrame open. + /// A fresh request using the same capture is the outermost frame again — which it is + /// not when a previous request left its capture frame open. /// - private static async Task AssertTheNextRequestIsOutermostAsync(RecordingAuditStore store) + private static async Task AssertTheNextRequestIsOutermostAsync( + RecordingAuditStore store, AuditStateCapture capture) { - var next = new AuditStateCapture(); - var behavior = Behavior(new FakeCatalog(AuditPipelineHarness.Entry()), store, next); + capture.HasOpenFrame.Should().BeFalse(); + var behavior = Behavior(new FakeCatalog(AuditPipelineHarness.Entry()), store, capture); await behavior.Handle( new DummyCommand(), - () => { next.MarkRolledBack(); return Task.FromResult(Result.Ok("ok")); }, + () => { capture.MarkRolledBack(); return Task.FromResult(Result.Ok("ok")); }, default); - next.Intents.Should().BeEmpty( + capture.Intents.Should().BeEmpty( "only an OUTERMOST frame clears, so a frame left open by the previous request " + "would leave this one's intents behind"); } @@ -939,6 +1172,31 @@ private static CapturedEntityChange SubjectInsert(string id, string slug) => /// A stand-in for the aggregate an intent names. private sealed class Subject; + private sealed class DirectOrchestrator; + + private sealed class DirectAuditedHandler + { + public DirectAuditedHandler(Action onConstruction) => onConstruction(); + } + + private sealed class UnregisteredDirectHandler + { + public UnregisteredDirectHandler() => + throw new InvalidOperationException("An unclassified handler must never be constructed."); + } + + private sealed class DirectCatalogSource : IAuditCatalogSource + { + public string ModuleName => "tenancy"; + + public void Describe(IAuditCatalogBuilder builder) + { + builder.Off(); + builder.MustAudit("tenancy.tenant.create", OperationType.Create, typeof(Subject)); + builder.MustAudit("tenancy.organization.create", OperationType.Create, typeof(Subject)); + } + } + /// Classifies the first entry at its declared tier and is cancelled on the second. private sealed class CancelledOnSecondClassifier : IAuditConfigService { @@ -956,7 +1214,15 @@ private static AuditLogBehavior> Behavior( RecordingAuditStore store, AuditStateCapture? capture = null, IAuditConfigService? classifier = null, - ILogger>>? logger = null) => + ILogger? logger = null) => + new(Coordinator(catalog, store, capture, classifier, logger)); + + private static AuditExecutionCoordinator Coordinator( + IAuditCatalog catalog, + RecordingAuditStore store, + AuditStateCapture? capture = null, + IAuditConfigService? classifier = null, + ILogger? logger = null) => new( catalog, classifier ?? new FakeClassifier(), @@ -965,10 +1231,10 @@ private static AuditLogBehavior> Behavior( new HarnessTenantContext(), new HarnessClock(DateTimeOffset.UnixEpoch), new HarnessGuidFactory(), - logger ?? NullLogger>>.Instance); + logger ?? NullLogger.Instance); /// Every log line the behavior writes, by level. - private sealed class CapturingLogger : ILogger>> + private sealed class CapturingLogger : ILogger { public List Levels { get; } = []; diff --git a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs index 743498d7..08e1ea23 100644 --- a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs @@ -1,6 +1,8 @@ using System.Data.Common; using FluentAssertions; using LearnStack.Application.Pipeline; +using LearnStack.Application.Persistence; +using LearnStack.Application.Messaging; using LearnStack.Infrastructure.Audit; using LearnStack.SharedKernel.Audit; using LearnStack.SharedKernel.Localization; @@ -10,6 +12,7 @@ using LearnStack.SharedKernel.Tenancy; using MediatR; using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; using Xunit; namespace LearnStack.Tests.Unit.Application.Pipeline; @@ -40,13 +43,66 @@ public sealed record DummyCommand : IRequest>; [PublicSurface] public sealed record PublicQuery : IRequest>; + [Fact] + public async Task Step_seven_refuses_dispatch_without_an_enrolled_invocation() + { + var producer = new ProducerExecution(new RecordingUnitOfWork()); + var guard = new OutboxFlushBehavior>(producer); + var reached = false; + var run = () => guard.Handle(new DummyCommand(), () => + { + reached = true; + return Task.FromResult(Result.Ok("ok")); + }, default); + + await run.Should().ThrowAsync() + .WithMessage("*invocation*transaction*"); + reached.Should().BeFalse(); + } + + [Theory] + [InlineData(TransactionMode.ReadWrite)] + [InlineData(TransactionMode.ReadOnly)] + public async Task Step_seven_preserves_event_free_enrollment_and_refuses_ended_authority(TransactionMode mode) + { + var unit = new RecordingUnitOfWork(); + var producer = new ProducerExecution(unit); + var coordinator = new TransactionExecutionCoordinator(unit, UnresolvedTenantContext.Instance, + new RecordingAuditStore(), new AuditStateCapture(), producer, + new DomainEventCollector(producer, Substitute.For()), + NullLogger.Instance); + var guard = new OutboxFlushBehavior>(producer); + var reached = false; + + (await coordinator.ExecuteAsync(null, mode, "guard-control", () => guard.Handle(new DummyCommand(), () => + { + reached = true; + return Task.FromResult(Result.Ok("ok")); + }, default))).IsSuccess.Should().BeTrue(); + reached.Should().BeTrue("an event-free unresolved announcement is valid"); + + var run = () => coordinator.ExecuteAsync(null, mode, "guard-ended", () => guard.Handle(new DummyCommand(), async () => + { + await unit.CommitAsync(); + return Result.Ok("ended"); + }, default)); + await run.Should().ThrowAsync() + .WithMessage("*invocation*transaction*"); + unit.IsRollbackOnly.Should().BeTrue(); + producer.HasInvocation.Should().BeFalse(); + } + [Fact] public async Task Public_surface_selects_read_only_before_announcement_and_dispatch() { var unitOfWork = new RecordingUnitOfWork(); + var producer = new ProducerExecution(unitOfWork); var behavior = new TransactionBehavior>( - unitOfWork, UnresolvedTenantContext.Instance, new RecordingAuditStore(), - new AuditStateCapture(), NullLogger>>.Instance); + new TransactionExecutionCoordinator( + unitOfWork, UnresolvedTenantContext.Instance, new RecordingAuditStore(), + new AuditStateCapture(), producer, + new DomainEventCollector(producer, Substitute.For()), + NullLogger.Instance)); var result = await behavior.Handle(new PublicQuery(), () => { @@ -317,6 +373,29 @@ public async Task A_faulted_commit_leaves_the_audit_state_indeterminate() capture.State.Should().Be(AuditIntentState.Indeterminate); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task A_pre_commit_frame_failure_rolls_back_without_replacing_the_original(bool rollbackFails) + { + var original = new InvalidOperationException("Frame completion refused before COMMIT"); + var capture = new AuditStateCapture(); + var unit = new RecordingUnitOfWork + { + CompletionFailure = original, + RollbackFailure = rollbackFails ? new DbTestException("rollback transport failed") : null, + }; + var run = () => Build(unit, capture: capture).Handle( + new DummyCommand(), () => Next(unit, Result.Ok("ok")), default); + + (await run.Should().ThrowAsync()).Which.Should().BeSameAs(original); + unit.Calls.Should().Equal("begin", "set-tenant", "handler", "mark-rollback-only", "rollback"); + unit.IsRollbackOnly.Should().BeTrue(); + unit.HasActiveTransaction.Should().BeFalse(); + unit.Committed.Should().BeFalse(); + capture.State.Should().Be(AuditIntentState.RolledBack); + } + [Fact] public async Task A_joiner_frame_writes_no_MUST_rows_and_claims_no_commit() { @@ -329,21 +408,26 @@ public async Task A_joiner_frame_writes_no_MUST_rows_and_claims_no_commit() var store = new RecordingAuditStore(); var unitOfWork = new RecordingUnitOfWork(); - // The outer frame, opened by whatever dispatched this request. The behaviour's own - // BeginTransactionAsync then returns frame 2 — a joiner. - await using var outer = await unitOfWork.BeginTransactionAsync(); - var response = await Build(unitOfWork, store: store, capture: capture).Handle( - new DummyCommand(), () => Next(unitOfWork, Result.Ok("ok")), default); + new DummyCommand(), async () => + { + var joined = await Build(unitOfWork, store: store, capture: capture).Handle( + new DummyCommand(), () => Next(unitOfWork, Result.Ok("ok")), default); - response.IsSuccess.Should().BeTrue(); + joined.IsSuccess.Should().BeTrue(); + store.PendingWrites.Should().Be(0, + "a joiner commits nothing, so it has nothing to write MUST rows onto"); + capture.State.Should().NotBe(AuditIntentState.Committed, + "only the owning frame may claim durability"); + unitOfWork.Committed.Should().BeFalse(); - store.PendingWrites.Should().Be(0, - "a joiner commits nothing, so it has nothing to write MUST rows onto"); - capture.State.Should().NotBe(AuditIntentState.Committed, - "only the owning frame may claim durability"); + return joined; + }, default); - await outer.CompleteAsync(); + response.IsSuccess.Should().BeTrue(); + store.PendingWrites.Should().Be(1, "the physical owner must write exactly once after the joiner returns"); + capture.State.Should().Be(AuditIntentState.Committed); + unitOfWork.Committed.Should().BeTrue(); } [Fact] @@ -437,13 +521,18 @@ await Build(unitOfWork, store).Handle( private static TransactionBehavior> Build( IUnitOfWork unitOfWork, RecordingAuditStore? store = null, - AuditStateCapture? capture = null) => - new( + AuditStateCapture? capture = null) + { + var producer = new ProducerExecution(unitOfWork); + return new(new TransactionExecutionCoordinator( unitOfWork, UnresolvedTenantContext.Instance, store ?? new RecordingAuditStore(), capture ?? new AuditStateCapture(), - NullLogger>>.Instance); + producer, + new DomainEventCollector(producer, Substitute.For()), + NullLogger.Instance)); + } private static Task> Next(RecordingUnitOfWork unitOfWork, Result result) { @@ -471,6 +560,9 @@ private sealed class RecordingUnitOfWork : IUnitOfWork /// Thrown by the outermost commit, to model a faulted COMMIT. public Exception? CommitFailure { get; init; } + /// Thrown while the frame is still live, before attempting COMMIT. + public Exception? CompletionFailure { get; init; } + /// /// Thrown by the rollback, to model the cleanup path failing on a /// connection the original exception already broke. @@ -484,6 +576,8 @@ private sealed class RecordingUnitOfWork : IUnitOfWork public DbTransaction? Transaction => null; + public TenantContextAnnouncement? GetTenantContextAnnouncement(DbTransaction? transaction) => null; + // This double records the context it was handed rather than announcing anything, // and its Transaction is always null — so the honest answer tracks whether the // announcement was made, which is what the behaviour under test drives. @@ -585,7 +679,9 @@ private sealed class Frame(RecordingUnitOfWork unitOfWork, int depth) : IUnitOfW public bool IsOwner => depth == 1; public Task CompleteAsync(CancellationToken cancellationToken = default) => - unitOfWork.CommitAsync(cancellationToken); + unitOfWork.CompletionFailure is { } failure + ? Task.FromException(failure) + : unitOfWork.CommitAsync(cancellationToken); public Task FailAsync(CancellationToken cancellationToken = default) => unitOfWork.RollbackAsync(cancellationToken); diff --git a/backend/tests/LearnStack.Tests.Unit/Modules/Customization/CustomizationCommandTests.cs b/backend/tests/LearnStack.Tests.Unit/Modules/Customization/CustomizationCommandTests.cs index fc85d18c..ef992dad 100644 --- a/backend/tests/LearnStack.Tests.Unit/Modules/Customization/CustomizationCommandTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Modules/Customization/CustomizationCommandTests.cs @@ -1555,6 +1555,9 @@ public Task SetTenantContextAsync( public bool IsTenantContextIssuedOn(System.Data.Common.DbTransaction? transaction) => throw new NotSupportedException(); + public TenantContextAnnouncement? GetTenantContextAnnouncement(System.Data.Common.DbTransaction? transaction) => + throw new NotSupportedException(); + public Task CommitAsync(CancellationToken cancellationToken = default) => throw new NotSupportedException(); diff --git a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/ProvisionTenantCommandTests.cs b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/ProvisionTenantCommandTests.cs index 83ee457e..4d4ce93e 100644 --- a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/ProvisionTenantCommandTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/ProvisionTenantCommandTests.cs @@ -4,6 +4,7 @@ using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; using LearnStack.Modules.Tenancy.Application.Tenant; using LearnStack.Modules.Tenancy.Domain; +using LearnStack.Modules.Tenancy.Domain.Events; using LearnStack.SharedKernel.Domain; using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Persistence; @@ -86,6 +87,22 @@ public async Task The_tenant_carries_its_default_organization_by_the_time_it_is_ .DefaultOrganizationId.Should().Be(Organization); } + [Fact] + public async Task Only_the_final_provisioning_write_carries_the_creation_fact() + { + var (sender, _) = Build(out var stores); + + (await sender.Send(Command())).IsSuccess.Should().BeTrue(); + + stores.TenantEventCounts.Should().Equal([0, 1], + "the initial insert does not claim that the organization/back-reference exists"); + var fact = stores.Updated.Should().ContainSingle().Subject.DomainEvents + .Should().ContainSingle().Subject.Should().BeOfType().Subject; + fact.TenantId.Should().Be(Tenant); + fact.EventId.Should().NotBeEmpty(); + fact.OccurredAt.Should().Be(Clock.UtcNow); + } + [Fact] public async Task The_organization_is_created_inside_the_tenant_being_provisioned() { @@ -406,6 +423,7 @@ private static ServiceProvider BuildProvider(out RecordingStores stores) services.AddSingleton(recording); services.AddSingleton(recording); services.AddSingleton(Clock); + services.AddSingleton(); return services.BuildServiceProvider(); } @@ -431,6 +449,8 @@ private sealed class RecordingStores : ITenantWriteStore, IOrganizationWriteStor public List Updated { get; } = []; + public List TenantEventCounts { get; } = []; + public Task FindAsync(TenantId id, CancellationToken cancellationToken = default) => throw new InvalidOperationException("Provisioning does not read a tenant."); @@ -438,6 +458,7 @@ Task IAggregateWriteStore.AddAsync( TenantAggregate aggregate, CancellationToken cancellationToken) { _writes.Add("tenant:add"); + TenantEventCounts.Add(aggregate.DomainEvents.Count); AddedTenants.Add(aggregate); return Task.CompletedTask; } @@ -446,6 +467,7 @@ Task IAggregateWriteStore.UpdateAsync( TenantAggregate aggregate, CancellationToken cancellationToken) { _writes.Add("tenant:update"); + TenantEventCounts.Add(aggregate.DomainEvents.Count); Updated.Add(aggregate); return Task.CompletedTask; } diff --git a/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenantCreatedEventTests.cs b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenantCreatedEventTests.cs new file mode 100644 index 00000000..adf841f2 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenantCreatedEventTests.cs @@ -0,0 +1,67 @@ +using FluentAssertions; +using LearnStack.Modules.Tenancy.Domain.Events; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Time; +using Xunit; +using TenantAggregate = LearnStack.Modules.Tenancy.Domain.Tenant; + +namespace LearnStack.Tests.Unit.Modules.Tenancy; + +public sealed class TenantCreatedEventTests +{ + private static readonly TenantId Tenant = TenantId.From(Guid.Parse("0199a000-0000-7000-8000-000000000001")); + private static readonly OrganizationId Organization = OrganizationId.From(Guid.Parse("0199a000-0000-7000-8000-000000000002")); + private static readonly Guid FactId = Guid.Parse("0199a000-0000-7000-8000-000000000003"); + private static readonly FixedClock Clock = new(new DateTimeOffset(2026, 10, 12, 8, 0, 0, TimeSpan.Zero)); + + [Fact] + public void Creation_is_a_fact_only_after_the_first_default_organization_assignment() + { + var tenant = Create(); + tenant.DomainEvents.Should().BeEmpty(); + tenant.DefaultOrganizationId.Should().BeNull(); + + tenant.AssignDefaultOrganization(Organization, Clock, new FixedGuidFactory(FactId), UserId.SystemActor); + + tenant.DefaultOrganizationId.Should().Be(Organization); + tenant.Version.Should().Be(1); + var fact = tenant.DomainEvents.Should().ContainSingle().Subject + .Should().BeOfType().Subject; + fact.EventId.Should().Be(FactId); + fact.OccurredAt.Should().Be(Clock.UtcNow); + fact.TenantId.Should().Be(Tenant); + + // An exhausted factory proves reassignment does not mint another creation id. + var replacement = OrganizationId.From(Guid.Parse("0199a000-0000-7000-8000-000000000004")); + tenant.AssignDefaultOrganization(replacement, Clock, new FixedGuidFactory(), UserId.SystemActor); + tenant.DefaultOrganizationId.Should().Be(replacement); + tenant.Version.Should().Be(2); + tenant.DomainEvents.Should().ContainSingle().Which.Should().BeSameAs(fact); + } + + [Theory] + [InlineData("organization")] + [InlineData("actor")] + [InlineData("clock")] + [InlineData("guids")] + [InlineData("exhausted")] + public void Refused_assignments_raise_no_fact_and_leave_the_tenant_unchanged(string refusal) + { + var tenant = Create(); + var assign = () => tenant.AssignDefaultOrganization( + refusal == "organization" ? OrganizationId.From(Guid.Empty) : Organization, + refusal == "clock" ? null! : Clock, + refusal == "guids" ? null! : new FixedGuidFactory(refusal == "exhausted" ? [] : [FactId]), + refusal == "actor" ? UserId.From(Guid.Empty) : UserId.SystemActor); + + if (refusal == "exhausted") assign.Should().Throw(); + else assign.Should().Throw(); + tenant.DomainEvents.Should().BeEmpty(); + tenant.DefaultOrganizationId.Should().BeNull(); + tenant.Version.Should().Be(0); + tenant.UpdatedAt.Should().BeNull(); + tenant.UpdatedBy.Should().BeNull(); + } + + private static TenantAggregate Create() => TenantAggregate.Create(Tenant, "event-probe", "Event probe", Clock, UserId.SystemActor); +} diff --git a/backend/tests/LearnStack.Tests.Unit/SharedKernel/Messaging/OutboxContractTests.cs b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Messaging/OutboxContractTests.cs new file mode 100644 index 00000000..50dda9f9 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/SharedKernel/Messaging/OutboxContractTests.cs @@ -0,0 +1,365 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using FluentAssertions; +using LearnStack.Application.Messaging; +using LearnStack.Infrastructure.Messaging; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Messaging; +using LearnStack.SharedKernel.Persistence; +using NSubstitute; +using Xunit; + +namespace LearnStack.Tests.Unit.SharedKernel.Messaging; + +public sealed class OutboxContractTests +{ + private static readonly Guid FactId = Guid.Parse("01988331-0000-7000-8000-000000000001"); + private static readonly Guid Tenant = Guid.Parse("01988331-0000-7000-8000-000000000002"); + private static readonly Guid Aggregate = Guid.Parse("01988331-aaaa-7000-8000-000000000003"); + + [Fact] + public void The_port_has_no_metadata_or_routing_override_arguments() + { + var enqueue = typeof(IOutbox).GetMethod(nameof(IOutbox.EnqueueAsync))!; + + enqueue.ReturnType.Should().Be(); + enqueue.GetParameters().Select(parameter => parameter.ParameterType).Should() + .Equal(typeof(IIntegrationEvent), typeof(CancellationToken)); + enqueue.GetParameters()[1].HasDefaultValue.Should().BeTrue(); + OutboxPayloadLimits.MaxBytes.Should().Be(262_144); + } + + [Fact] + public void Guid_and_approved_nullable_identifiers_keep_the_canonical_runtime_payload() + { + var fact = New(); + + var serialized = OutboxEventContract.Serialize(fact); + + serialized.Payload.Should().Be(fact.ToPayloadJson()); + serialized.TypeName.Should().Be(typeof(IdentifierEvent).AssemblyQualifiedName); + serialized.EventId.Should().Be(FactId); + serialized.TenantId.Should().Be(Tenant); + serialized.PartitionKey.Should().Be(Aggregate.ToString("D")); + using var payload = JsonDocument.Parse(serialized.Payload); + payload.RootElement.GetProperty(nameof(fact.RelatedTenant)).GetGuid().Should().Be(Tenant); + payload.RootElement.GetProperty(nameof(fact.RelatedOrganization)).GetGuid().Should().Be(Aggregate); + payload.RootElement.GetProperty(nameof(fact.RelatedUser)).GetGuid().Should().Be(UserId.SystemActor.Value); + payload.RootElement.GetProperty(nameof(fact.OptionalId)).ValueKind.Should().Be(JsonValueKind.Null); + } + + [Fact] + public void An_approved_strongly_typed_partition_source_is_canonicalized_as_a_guid() + { + var serialized = OutboxEventContract.Serialize(New()); + + serialized.PartitionKey.Should().Be(Aggregate.ToString("D")); + } + + [Fact] + public void Tenant_partitioning_is_accepted_only_when_deliberately_declared() + { + OutboxEventContract.Serialize(New()).PartitionKey.Should().Be(Tenant.ToString("D")); + } + + [Theory] + [InlineData(typeof(MissingPartitionEvent))] + [InlineData(typeof(ImplicitTenantPartitionEvent))] + [InlineData(typeof(MislabelledTenantPartitionEvent))] + [InlineData(typeof(UnknownSourceEvent))] + [InlineData(typeof(EventIdentityPartitionEvent))] + [InlineData(typeof(NullablePartitionEvent))] + [InlineData(typeof(EmptyPartitionEvent))] + [InlineData(typeof(WrongPartitionEvent))] + [InlineData(typeof(UppercasePartitionEvent))] + public void Missing_or_inconsistent_partition_contracts_are_refused(Type type) + { + var serialize = () => OutboxEventContract.Serialize(New(type)); + + serialize.Should().Throw(); + } + + [Theory] + [InlineData(typeof(TextPayloadEvent))] + [InlineData(typeof(DtoPayloadEvent))] + [InlineData(typeof(CollectionPayloadEvent))] + [InlineData(typeof(BinaryPayloadEvent))] + [InlineData(typeof(RawJsonPayloadEvent))] + [InlineData(typeof(UnapprovedIdEvent))] + [InlineData(typeof(ExtraFieldEvent))] + [InlineData(typeof(HiddenRoutingEvent))] + [InlineData(typeof(RemappedInterfaceEvent))] + [InlineData(typeof(IgnoredTextEvent))] + [InlineData(typeof(RenamedIdentifierEvent))] + [InlineData(typeof(ConverterPayloadEvent))] + [InlineData(typeof(SerializationCallbackEvent))] + public void Non_identifier_payloads_and_serializer_escape_hatches_are_refused(Type type) + { + EscapeConverter.Writes = 0; + SerializationCallbackEvent.Calls = 0; + var serialize = () => OutboxEventContract.Serialize(New(type)); + + serialize.Should().Throw(); + EscapeConverter.Writes.Should().Be(0, "the contract is refused before a custom converter runs"); + SerializationCallbackEvent.Calls.Should().Be(0, "callbacks cannot mutate the canonical payload"); + } + + [Fact] + public void A_computed_identifier_cannot_serialize_a_different_partition_source() + { + UnstableIdentifierEvent.Reads = 0; + var serialize = () => OutboxEventContract.Serialize(New()); + + serialize.Should().Throw().WithMessage("*same identity and routing*"); + } + + [Theory] + [InlineData("learnstack.education.course", true)] + [InlineData("learnstack.hub.custom-domain.activated", true)] + [InlineData("learnstack.a.b", true)] + [InlineData("learnstack.education.course.activated", false)] + [InlineData("Learnstack.education.course", false)] + [InlineData("learnstack.-education.course", false)] + [InlineData("learnstack.education-.course", false)] + [InlineData("learnstack.1education.course", false)] + [InlineData("learnstack.education.course\n", false)] + public void The_runtime_topic_guard_accepts_only_the_existing_topic_convention(string topic, bool accepted) + { + TopicEvent.DeclaredTopic = topic; + var serialize = () => OutboxEventContract.Serialize(New()); + + if (accepted) serialize.Should().NotThrow(); + else serialize.Should().Throw(); + } + + [Theory] + [InlineData("a", 1)] + [InlineData("é", 2)] + [InlineData("😀", 4)] + public void The_inclusive_payload_budget_counts_utf8_bytes(string text, int bytes) + { + var boundary = string.Concat(Enumerable.Repeat(text, OutboxPayloadLimits.MaxBytes / bytes)); + Encoding.UTF8.GetByteCount(boundary).Should().Be(OutboxPayloadLimits.MaxBytes); + var accepted = () => OutboxEventContract.EnsurePayloadFits(boundary); + var refused = () => OutboxEventContract.EnsurePayloadFits(boundary + "a"); + + accepted.Should().NotThrow(); + refused.Should().Throw().WithMessage("*262144 bytes*"); + } + + [Fact] + public async Task An_unenrolled_outbox_refuses_before_reading_connection_or_event_getters() + { + var unit = Substitute.For(); + var outbox = new PostgresOutbox(unit, new ProducerExecution(unit)); + UnstableIdentifierEvent.Reads = 0; + var enqueue = () => outbox.EnqueueAsync(New()); + + await enqueue.Should().ThrowAsync(); + UnstableIdentifierEvent.Reads.Should().Be(0); + _ = unit.DidNotReceive().Connection; + } + + [Fact] + public async Task Cancellation_refuses_before_acquiring_connection() + { + var unit = Substitute.For(); + var outbox = new PostgresOutbox(unit, new ProducerExecution(unit)); + using var cancellation = new CancellationTokenSource(); + await cancellation.CancelAsync(); + var enqueue = () => outbox.EnqueueAsync(New(), cancellation.Token); + + await enqueue.Should().ThrowAsync(); + _ = unit.DidNotReceive().Connection; + } + + private static T New() where T : IntegrationEventBase => (T)New(typeof(T)); + + private static IntegrationEventBase New(Type type) + { + // The same initialization for every planted shape, including types that hide + // members. Set the real base properties so a refusal cannot pass on empty IDs. + var fact = (IntegrationEventBase)Activator.CreateInstance(type, nonPublic: true)!; + typeof(IntegrationEventBase).GetProperty(nameof(fact.EventId))!.SetValue(fact, FactId); + typeof(IntegrationEventBase).GetProperty(nameof(fact.TenantId))!.SetValue(fact, Tenant); + typeof(IntegrationEventBase).GetProperty(nameof(fact.OccurredAt))!.SetValue(fact, DateTimeOffset.UnixEpoch); + return fact; + } + + private abstract record AggregateEvent : IntegrationEventBase + { + public Guid AggregateId { get; init; } = Aggregate; + public override string Topic => "learnstack.education.course"; + public override string PartitionKey => AggregateId.ToString("D"); + } + + [IntegrationEventPartition(nameof(AggregateId))] + private sealed record IdentifierEvent : AggregateEvent + { + public Guid? OptionalId { get; init; } + public TenantId RelatedTenant { get; init; } = LearnStack.SharedKernel.Identifiers.TenantId.From(Tenant); + public OrganizationId? RelatedOrganization { get; init; } = OrganizationId.From(Aggregate); + public UserId RelatedUser { get; init; } = UserId.SystemActor; + } + + [IntegrationEventPartition(nameof(SubjectId))] + private sealed record TypedPartitionEvent : AggregateEvent + { + public OrganizationId SubjectId { get; init; } = OrganizationId.From(Aggregate); + public override string PartitionKey => SubjectId.Value.ToString("D"); + } + + [IntegrationEventPartition(nameof(TenantId), tenantWide: true)] + private sealed record TenantPartitionEvent : AggregateEvent + { + public override string PartitionKey => TenantId.ToString("D"); + } + + private sealed record MissingPartitionEvent : AggregateEvent; + + [IntegrationEventPartition(nameof(TenantId))] + private sealed record ImplicitTenantPartitionEvent : AggregateEvent; + + [IntegrationEventPartition(nameof(AggregateId), tenantWide: true)] + private sealed record MislabelledTenantPartitionEvent : AggregateEvent; + + [IntegrationEventPartition("MissingId")] + private sealed record UnknownSourceEvent : AggregateEvent; + + [IntegrationEventPartition(nameof(EventId))] + private sealed record EventIdentityPartitionEvent : AggregateEvent; + + [IntegrationEventPartition(nameof(SubjectId))] + private sealed record NullablePartitionEvent : AggregateEvent + { + public Guid? SubjectId { get; init; } = Aggregate; + } + + [IntegrationEventPartition(nameof(EmptyId))] + private sealed record EmptyPartitionEvent : AggregateEvent + { + public Guid EmptyId { get; init; } + public override string PartitionKey => EmptyId.ToString("D"); + } + + [IntegrationEventPartition(nameof(AggregateId))] + private sealed record WrongPartitionEvent : AggregateEvent + { + public override string PartitionKey => TenantId.ToString("D"); + } + + [IntegrationEventPartition(nameof(AggregateId))] + private sealed record UppercasePartitionEvent : AggregateEvent + { + public override string PartitionKey => AggregateId.ToString("D").ToUpperInvariant(); + } + + [IntegrationEventPartition(nameof(AggregateId))] + private sealed record TextPayloadEvent : AggregateEvent + { + public string Name { get; init; } = "Private name"; + } + + [IntegrationEventPartition(nameof(AggregateId))] + private sealed record DtoPayloadEvent : AggregateEvent + { + public object Details { get; init; } = new { Name = "Private name" }; + } + + [IntegrationEventPartition(nameof(AggregateId))] + private sealed record CollectionPayloadEvent : AggregateEvent + { + public Guid[] Ids { get; init; } = [Aggregate]; + } + + [IntegrationEventPartition(nameof(AggregateId))] + private sealed record BinaryPayloadEvent : AggregateEvent + { + public byte[] Bytes { get; init; } = [1]; + } + + [IntegrationEventPartition(nameof(AggregateId))] + private sealed record RawJsonPayloadEvent : AggregateEvent + { + public JsonElement Data { get; init; } + } + + private readonly record struct UnapprovedId(Guid Value) : IStronglyTypedId + { + public bool IsInitialized() => true; + } + + [IntegrationEventPartition(nameof(AggregateId))] + private sealed record UnapprovedIdEvent : AggregateEvent + { + public UnapprovedId SubjectId { get; init; } = new(Aggregate); + } + + [IntegrationEventPartition(nameof(AggregateId))] + private sealed record ExtraFieldEvent : AggregateEvent + { + public Guid ExtraId = Aggregate; + } + + [IntegrationEventPartition(nameof(AggregateId))] + private sealed record HiddenRoutingEvent : AggregateEvent + { + public new Guid Topic { get; init; } = Aggregate; + } + + [IntegrationEventPartition(nameof(AggregateId))] + private sealed record RemappedInterfaceEvent : AggregateEvent, IIntegrationEvent + { + Guid IIntegrationEvent.TenantId => Aggregate; + } + + [IntegrationEventPartition(nameof(AggregateId))] + private sealed record IgnoredTextEvent : AggregateEvent + { + [JsonIgnore] public string Name { get; init; } = "Private name"; + } + + [IntegrationEventPartition(nameof(AggregateId))] + private sealed record RenamedIdentifierEvent : AggregateEvent + { + [JsonPropertyName("OtherId")] public Guid SubjectId { get; init; } = Aggregate; + } + + [IntegrationEventPartition(nameof(AggregateId))] + [JsonConverter(typeof(EscapeConverter))] + private sealed record ConverterPayloadEvent : AggregateEvent; + + private sealed class EscapeConverter : JsonConverter + { + public static int Writes { get; set; } + public override ConverterPayloadEvent? Read(ref Utf8JsonReader reader, Type typeToConvert, + JsonSerializerOptions options) => throw new NotSupportedException(); + public override void Write(Utf8JsonWriter writer, ConverterPayloadEvent value, JsonSerializerOptions options) + { + Writes++; + writer.WriteStringValue("Private name"); + } + } + + [IntegrationEventPartition(nameof(AggregateId))] + private sealed record SerializationCallbackEvent : AggregateEvent, IJsonOnSerializing + { + public static int Calls { get; set; } + public void OnSerializing() => Calls++; + } + + [IntegrationEventPartition(nameof(SubjectId))] + private sealed record UnstableIdentifierEvent : AggregateEvent + { + public static int Reads { get; set; } + public Guid SubjectId => ++Reads == 1 ? AggregateId : TenantId; + } + + [IntegrationEventPartition(nameof(AggregateId))] + private sealed record TopicEvent : AggregateEvent + { + public static string DeclaredTopic { get; set; } = "learnstack.education.course"; + public override string Topic => DeclaredTopic; + } +} diff --git a/docs/architecture/09-tenant-isolation.md b/docs/architecture/09-tenant-isolation.md index 6250ae47..fe05bbde 100644 --- a/docs/architecture/09-tenant-isolation.md +++ b/docs/architecture/09-tenant-isolation.md @@ -96,7 +96,8 @@ In text, for a reader whose renderer does not draw it: accessor's only member ([ADR-0036 Amendment 2](../decisions/0036-tenant-resolution-trusted-inputs.md)) — and the request continues. -5. TransactionBehavior selects ambient mode. Default writers announce tenant first; +5. `TransactionBehavior` selects ambient mode and delegates to + `TransactionExecutionCoordinator`. Default writers announce tenant first; physical public reads complete only the approved READ ONLY setup first, then announce tenant before data under [Security's rule](../standards/11-security.md#the-rule). 6. EF Core applies the global query filter, so the SQL carries the tenant and @@ -149,6 +150,20 @@ widens **reads** across organizations within the caller's tenant; it never widen writes, and it never crosses a tenant boundary. The default scope (`null` or `'organization'`) restricts reads to the caller's organization plus tenant-wide rows. +P02b-1 Step 1 centralizes this existing general announcement path in +[`TransactionExecutionCoordinator`](../../backend/src/LearnStack.Application/Persistence/TransactionExecutionCoordinator.cs). +It adds no logical setter or ambient context writer. The read-only +`GetTenantContextAnnouncement(DbTransaction?)` snapshot records successful +normalized tenant/organization SQL and its physical transaction generation. +Producer frames compare live generation/tenant rather than accepting caller +snapshots or reading an unresolved provisioning accessor. Joins reuse the same +announcement; the snapshot cannot retarget the transaction. The closed writer +set, host factory ceiling and canonical RLS placement remain unchanged under +[ADR-0057](../decisions/0057-transactional-event-production-and-completion.md). +P02b-1 Steps 2–3 deliver immediate outbox enqueue, successful-save collection +and owner-only completion. Dispatch and consumer delivery remain P02b-2/P02b-3 +work. + ## Default org semantics Per ADR-0017, every tenant has at least one **default organization** auto-created at diff --git a/docs/architecture/13-identity-and-auth.md b/docs/architecture/13-identity-and-auth.md index 71284c26..ef21c529 100644 --- a/docs/architecture/13-identity-and-auth.md +++ b/docs/architecture/13-identity-and-auth.md @@ -4,6 +4,14 @@ LearnStack delegates user authentication to a self-hosted identity provider rath This document covers what lives where: in Keycloak vs in LearnStack, how multi-tenant identity works, how tokens flow, and how SSO/OIDC integrations land later. +**Implementation boundary — 2026-10-12.** The flows and application identity +model below are target architecture. The dev realms exist, but API JWT validation, +tenant claim mappers, a BFF session and mirrored users/memberships are not shipped. +Current public sites use host-based anonymous admission without forwarding cookies +or credentials. [Phase 02b's entry verification](../roadmap/phase-02b-events-auth.md#p02b-0-entry-verification-2026-10-12) +records the baseline; G10–G13/G20 settle token shape, resolution, session custody +and lifetimes before their first consumers. Phase 03 owns the identity domain. + ## Why Not Build It Ourselves Email + password + refresh tokens + invitation flow + password reset + email verification + audit + future SSO + future MFA is a large, security-sensitive surface. Each of these pieces is non-trivial in isolation and easy to get subtly wrong. Off-the-shelf identity systems have been hardened against the entire OWASP authentication catalogue; replicating that hardening costs months of engineering and ongoing vigilance. diff --git a/docs/architecture/15-event-and-outbox.md b/docs/architecture/15-event-and-outbox.md index a27d5c72..075a0630 100644 --- a/docs/architecture/15-event-and-outbox.md +++ b/docs/architecture/15-event-and-outbox.md @@ -26,17 +26,30 @@ not land together. |---|---|---| | `outbox_messages` table, its schema, and its LearnStack ownership | [Phase 02a Packet 6](../roadmap/phase-02a-kernel-tenancy.md) | Ships even though nothing dispatches from it yet — the schema and its ownership are a one-way door | | `IEventBus` port + `InProcessEventBus` | [Phase 02a Packet 5](../roadmap/phase-02a-kernel-tenancy.md) | The only registered implementation | -| `OutboxProcessor`, `IInboxGuard`, the claim protocol, the first real integration event | [Phase 02b](../roadmap/phase-02b-events-auth.md) | The durable dispatcher | -| Domain events collected and dispatched in-process, inside the ambient transaction | [Phase 02b](../roadmap/phase-02b-events-auth.md) | `Entity` has carried the raise-and-clear list since Phase 02a Packet 2; nothing collects it yet | +| `IOutbox` and `TenantCreatedIntegrationEventV1` | [P02b-1](../roadmap/phase-02b-events-auth.md#p02b-1-decision-package-2026-10-12) | Implemented immediate transactional production; no delivery yet | +| `OutboxProcessor`, `IInboxGuard` and the claim protocol | [P02b-2/P02b-3](../roadmap/phase-02b-events-auth.md) | Pending durable dispatch and consumer transactions | +| Domain events collected and dispatched in-process, inside the ambient transaction | [P02b-1](../roadmap/phase-02b-events-auth.md#p02b-1-step-3--domain-completion-and-provisioning-publisher-2026-10-12) | Successful-save collection and owner-only completion implemented | | `DaprEventBus` → Dapr pub/sub → Kafka | [Phase 11](../roadmap/phase-11-production-hardening.md) | Demand-gated; trigger: a second process needs to consume an integration event, or event volume / replay / cross-process ordering is required ([ADR-0035](../decisions/0035-demand-gated-infrastructure.md)) | -**`InProcessEventBus` is a first-class transport, not a stub.** It uses the same -`IIntegrationEventHandler` interface, the same `IInboxGuard` deduplication, and the -same tenant-context restoration as the durable path. A development transport that skips -those is a development transport that never exercises the isolation code — and it forces -every consumer to carry two implementations, one of which is never tested against the -other. Everything in this document about consumer obligations applies identically to both -transports; the only difference is what carries the bytes between publish and handle. +**`InProcessEventBus` is a first-class transport.** It already supplies the handler +interface, scoped ambient tenant restoration and per-key serialisation. It does +not yet open a database transaction, announce RLS context, deduplicate deliveries +or persist retries. `IInboxGuard` is a documented seam, not a declared type. +[Phase 02b's entry verification](../roadmap/phase-02b-events-auth.md#p02b-0-entry-verification-2026-10-12) +records that baseline. The producer/consumer diagrams and examples below describe +the durable target: P02b-1 supplies enqueue, P02b-2 dispatch and P02b-3 consumer +transactions, inbox and recovery. G1 settles the inbox's ownership and caller; +this entry audit does not replace the Accepted per-module contract. + +The [P02b-1 decision package](../roadmap/phase-02b-events-auth.md#p02b-1-decision-package-2026-10-12) +and [Accepted ADR-0057](../decisions/0057-transactional-event-production-and-completion.md) +authorize immediate enqueue, owner-only completion and produced-work refusal +protection. P02b-1 Steps 1–3 implement shared execution, immediate enqueue, +successful-save collection and the completed-provisioning publisher. Step 4 +implements production-census guards and catalogue enforcement; its +[delivery record](../roadmap/phase-02b-events-auth.md#p02b-1-step-4--producer-enforcement-and-closeout-2026-10-12) +owns final verification and review status. Dispatcher/inbox +choices remain P02b-2/P02b-3. [ADR-0038](../decisions/0038-cross-cutting-port-and-event-contracts.md) stands as the decision that Dapr is the @@ -105,12 +118,13 @@ third document; the last time this table's policy was duplicated, the copy carri superseded two-permissive-policy shape that [ADR-0003 Amendment 3](../decisions/0003-tenant-isolation-defense-in-depth.md) corrects. -Three columns exist for reasons this document owns rather than the schema, and the two -guarantees below (ordering, single-claimant dispatch) cannot be honoured without them: +Three columns exist for reasons this document owns rather than the schema. They +support partition routing and the pending P02b-2 dispatch/lease decision; the +partition column alone establishes no durable publish-order guarantee: | Column | Why it exists | Lands with | |---|---|---| -| `partition_key text NOT NULL` | The ordering guarantee below is expressed entirely through this value. **The event declares it** — `PartitionKey` is abstract on `IntegrationEventBase`, so no event inherits a default — and `IOutbox.EnqueueAsync` copies it onto the row. Nothing resolves or re-derives it. | The table itself, in [Phase 02a Packet 6](../roadmap/phase-02a-kernel-tenancy.md) — a producer-side column is cheaper to ship with the table than to backfill | +| `partition_key text NOT NULL` | The event's declared routing key; durable publish order remains P02b-2 G3. **The event declares it** — `PartitionKey` is abstract on `IntegrationEventBase`, so no event inherits a default — and `IOutbox.EnqueueAsync` copies it onto the row. Nothing resolves or re-derives it. | The table itself, in [Phase 02a Packet 6](../roadmap/phase-02a-kernel-tenancy.md) — a producer-side column is cheaper to ship with the table than to backfill | | `locked_by text NULL` + `locked_until timestamptz NULL` | The dispatch **lease**. A processor stamps them to claim a row, and the claim survives the claiming transaction's commit. See the claim protocol below. | The dispatcher, in [Phase 02b](../roadmap/phase-02b-events-auth.md) | | `available_after timestamptz NOT NULL` | Retry backoff. A failed dispatch pushes this forward instead of blocking the batch. | Already in the canonical DDL | @@ -182,7 +196,8 @@ processed by N consumers independently). ## Producer pattern -In a module's command handler: +**Immediate enqueue implemented by P02b-1 Step 2 — ADR-0057.** In an enrolled +producer invocation on the announced, writable ambient transaction: ```csharp public async Task> Handle(CreateEnrollmentCommand cmd, CancellationToken ct) @@ -190,7 +205,7 @@ public async Task> Handle(CreateEnrollmentCommand cmd, Can var enrollment = Enrollment.Create(cmd.LearnerId, cmd.CourseId, ...); _dbContext.Enrollments.Add(enrollment); - await _outbox.EnqueueAsync(new EnrollmentCreatedIntegrationEvent + await _outbox.EnqueueAsync(new EnrollmentCreatedIntegrationEventV1 { EventId = _guidFactory.NewUuidV7(), // IGuidFactory, not Guid.NewGuid TenantId = _tenantContext.TenantId.Value, // Guid on the wire; ITenantContext, @@ -201,17 +216,84 @@ public async Task> Handle(CreateEnrollmentCommand cmd, Can CourseId = cmd.CourseId, }, ct); - await _dbContext.SaveChangesAsync(ct); // aggregate + outbox row, atomic + await _dbContext.SaveChangesAsync(ct); // flush only; owner commits state + row + audit return Result.Ok(MapToDto(enrollment), LocalizedMessage.Of("lockey_enrollment_created")); } ``` -`IOutbox.EnqueueAsync` writes to the same `DbContext` (no separate transaction); commit -is atomic with the aggregate write. It copies the event's `PartitionKey` onto the row — -here `EnrollmentId`, the aggregate this event is about — along with the correlation id -and organization from the ambient `ITenantContext`, which is the last point at which -they are available. See [Ordering](#ordering). +`IOutbox.EnqueueAsync` inserts immediately through `IUnitOfWork.Connection` and +`Transaction`; it opens no connection, commits nothing and never calls `IEventBus`. +Its enrolled producer frame requires a successful live transaction announcement, +a matching generation/tenant, and writable non-poisoned state. Provisioning reads +`GetTenantContextAnnouncement` even while ambient `ITenantContext` is unresolved; +there is no new context writer. `id` is exactly producer-supplied `EventId`, and a +duplicate is an error. `type` keeps the assembly-qualified concrete name. + +The concrete event declares `[IntegrationEventPartition(nameof(AggregateId))]` +or explicitly `[IntegrationEventPartition(nameof(TenantId), tenantWide: true)]`. +The named identifier must equal its canonical lowercase `D` GUID partition key; +enqueue persists that key. The +envelope preserves the transaction's announced organization, including for a +non-marked event; marked events require one. Correlation, causation and causal +actor come from the trusted execution boundary. That boundary guarantees a W3C +Activity without a telemetry listener, including the Seeder. Domain translation +mints a new integration EventId with the domain EventId as causation. Future +delivery-originated production uses the trusted incoming identity. + +Additional payload members are identifiers only, optionally nullable; text, +collections, DTOs, binary/raw JSON and serializer overrides are excluded. The +complete concrete-runtime JSON must fit the inclusive 262,144-byte UTF-8 +`OutboxPayloadLimits.MaxBytes` application bound before INSERT. The existing +frozen serializer is reused. `metadata` is SQL NULL, with no arbitrary metadata +argument. This adds no DDL CHECK or promise about stored `jsonb` byte length. +Identifiers minimize copied data without making them anonymous. See ADR-0057 +for the exact snapshot/admission lifecycle and [Ordering](#ordering) for its +separately owned delivery question. + +### Domain collection and owner completion + +**Implemented by P02b-1 Step 3 — ADR-0057.** The shared SaveChanges +interceptor stages source events and promotes only after +successful persistence, preserving source/frame attribution and generation in +both API and Seeder compositions. Only changed sources belonging to the saving +context qualify; zero-row, failed and canceled saves promote nothing. Retaining +a source for cleanup does not let its unsaved facts ride another source's save. +Repeated saves, detach and delete cannot duplicate or lose successful events. +Only the transaction owner drains the FIFO queue, at one site, before invariant +checks and pending MUST audit/commit. A transaction holds at most 256 domain +events; this total is not recursive cascade support. + +Domain reactions only translate facts into outbox rows. They do not write roots, +use write stores, send nested requests, raise more events, publish notifications +or call providers. Unexpected faults throw and roll back; expected decisions +belong in commands or later integration consumers. This preserves returned +versions and the one-root boundary. + +Successful INSERT or event promotion leaves a sticky receipt in the invocation +frame and every ancestor. A `Result.Fail` from any such frame poisons the shared +unit before resolving its UoW frame, even when absorbed; drain cannot erase the +receipt. Event-free refusals retain ADR-0040's semantics. Joiners neither drain +nor clear owner state; every terminal path discards collector/source state. +Step 7's `OutboxFlushBehavior` is an enrollment/invariant guard. + +The reusable audit coordinator gives a controlled non-MediatR owner the existing +classification, MUST-write, boundary-reporting and outer reconciliation duties. +P02b-3 owns production delivery/inbox wiring after G1; the P02b-1 fixture proves +that entry without claiming delivered subscribers. + +### First useful flow + +Completed provisioning produces `TenantCreatedIntegrationEventV1` on +`learnstack.tenancy.tenant`, partitioned by TenantId, with no extra payload and +null organization. P02b-1 persists pending rows after tenant/default organization/ +back-reference completion. P02b-3, after G1, implements independent Customization +subscriptions for untouched `card@1` and `plain@1`; each owns one root, generation, +inbox outcome and MUST audit. Authored/lifecycle/deletion decisions survive. +P02b-3 owns semantic key/version Seeder verification and fresh seed/delivery +convergence. Typed settings are uncached, definitions probe fresh generations, +and source audit already commits; neither invalidation nor an Audit mirror is +this flow's effect. **The payload is written by `event.ToPayloadJson()`, never by `JsonSerializer.Serialize(@event)`.** `EnqueueAsync` takes `IIntegrationEvent`, so the @@ -230,9 +312,9 @@ public sealed class QueueWelcomeNotificationOnEnrollmentCreated( IInboxGuard inboxGuard, NotificationsDbContext db, ILogger logger) - : IIntegrationEventHandler + : IIntegrationEventHandler { - public async Task HandleAsync(EnrollmentCreatedIntegrationEvent @event, CancellationToken ct) + public async Task HandleAsync(EnrollmentCreatedIntegrationEventV1 @event, CancellationToken ct) { if (await inboxGuard.IsAlreadyProcessedAsync(@event.EventId, ct)) { @@ -502,9 +584,9 @@ public sealed class DaprEventBus(DaprClient daprClient) : IEventBus } ``` -`InProcessEventBus` honours the same key by serialising dispatch per partition key — -concurrent across keys, sequential within one — so a consumer cannot observe an ordering -in development that the durable transport would not also produce. +`InProcessEventBus` serializes invocation per partition key within its process: +concurrent across keys, sequential within one. This does not establish durable +publish order across commits and retries; P02b-2 G3 owns that decision. Topic naming convention: `learnstack.{module}.{aggregate}`. Examples: @@ -520,15 +602,16 @@ Topic naming convention: `learnstack.{module}.{aggregate}`. Examples: ## `InProcessEventBus` Until the Dapr adapter's trigger fires, `InProcessEventBus` is the only registered -`IEventBus`. It is a **transport**, not a shortcut, and it carries the same four -obligations as the durable path: +`IEventBus`. The table below states its target obligations alongside the durable +path. Handler scopes and accessor restoration ship today; database announcement +and inbox deduplication remain P02b-3 work, under G1/G6. | Obligation | Why it cannot be skipped in development | |---|---| | Same handler interface — `IIntegrationEventHandler`, never a bare `INotificationHandler` | Two interfaces means two implementations per consumer, and the one that runs in CI is not the one that runs in production | | Same `IInboxGuard` deduplication | Deduplication bugs are the most common integration-event defect; a transport that never delivers a duplicate never surfaces them | | Same tenant-context restoration from `@event.TenantId` before the handler runs | A dev path that skips it is a dev path where Row Level Security and the query filters are never exercised on the consumer side | -| Same per-partition-key ordering | Ordering assumptions that hold only in process are discovered in production | +| Process-local per-key invocation serialization; durable ordering remains P02b-2 G3 | A serialized development invocation does not prove publish order across concurrent commits and retries | ```csharp public sealed partial class InProcessEventBus( @@ -593,7 +676,7 @@ public sealed partial class InProcessEventBus( { tenantAccessor.Current = previous; } - } // the handler calls IInboxGuard itself + } // Inbox ownership and its call site remain Phase 02b G1. } ``` @@ -618,9 +701,9 @@ handler writes to the outbox. What the in-process transport genuinely does **not** provide, and what therefore constitutes the trigger for the Dapr adapter: delivery to a second process, broker-side replay, and durability of the in-flight message if the process dies between publish and -handle. The outbox row covers the third — an unhandled event has not been marked -processed and is redelivered — which is precisely why the outbox is LearnStack-owned and -ships before any transport. +handle. Once P02b-1–3 deliver durable enqueue and recovery, the outbox row covers +the third: an unhandled event has not been marked processed and is redelivered. +The existing table alone does not provide that recovery. The composition root selects the implementation by `DeploymentMode`; modules never see the choice. @@ -656,9 +739,10 @@ the choice. ## Ordering -Ordering is **not** guaranteed across topics, and never has been. Within one topic, -ordering is guaranteed **per partition key** — and a partition key that nobody sets is a -guarantee nobody has. +P02b-1 accepts partition-source derivation under ADR-0057. It accepts no durable +publish-order guarantee. G3's ordering half remains P02b-2; the inherited per-key +transport target must be reconciled there against concurrent commits and retries. +`InProcessEventBus` serializes invocation per key within its process. The rule, therefore, is that **every outbox row carries a non-null `partition_key`**: @@ -667,7 +751,7 @@ a list of mechanisms: | Event shape | Partition key | |---|---| -| Event about one aggregate instance (the normal case) | The aggregate id — `EnrollmentCreatedIntegrationEvent` keys on `EnrollmentId` | +| Event about one aggregate instance (the normal case) | The aggregate id — `EnrollmentCreatedIntegrationEventV1` keys on `EnrollmentId` | | Event about the tenant as a whole (`TenantSuspended`, `EntitlementUpdated`) | `TenantId`, deliberately — it serialises that tenant's whole stream onto one partition | The member is **abstract rather than defaulted**, and that is the decision: a default @@ -680,12 +764,13 @@ and builds the envelope from it. `IntegrationEventEnvelope.PartitionKey` is alongside the event as a separate parameter, which meant two sources with nothing reconciling them: measured, the transport read the parameter and never the event, and every test published an event whose declared key disagreed with the one passed, green. -Ordering is guaranteed per partition key, so a key that can differ from itself is a -guarantee that cannot be stated. +A partition key that differs from itself cannot identify a consistent routing +domain; deriving it does not itself establish durable publish order. Consequences worth stating plainly: -- Two events about the **same** aggregate arrive in publish order at every consumer. +- Same-aggregate events share a declared key. Durable order across commits/retries + remains G3's P02b-2 decision. - Two events about **different** aggregates may arrive in any relative order, even inside one topic. A consumer that needs a cross-aggregate order needs a different design — usually a read model, not an ordering assumption. @@ -693,9 +778,9 @@ Consequences worth stating plainly: one partition. That is a deliberate throughput cost, paid only where the ordering is genuinely tenant-wide. - The column is `NOT NULL`, so the database asserts it. The catalogued - `Outbox_Row_Carries_Correlation_Context` integration test extends its assertion to - `partition_key` when the column lands, so a new enqueue path that forgets it fails in - CI rather than in a consumer. + `Outbox_Row_Carries_Correlation_Context` integration proof checks actual + persisted `partition_key` values in P02b-1, so a new enqueue path that forgets + the key fails in CI before a consumer receives it. ## Dead-letter: two sides, two failure domains diff --git a/docs/architecture/31-audit-subsystem.md b/docs/architecture/31-audit-subsystem.md index af05bcf7..8250c25a 100644 --- a/docs/architecture/31-audit-subsystem.md +++ b/docs/architecture/31-audit-subsystem.md @@ -3,6 +3,7 @@ **Derives from:** [ADR-0033](../decisions/0033-audit-durability-model.md) (supersedes [ADR-0016](../decisions/0016-audit-log-subsystem.md)), [ADR-0044 (The Audit Write Path)](../decisions/0044-audit-write-path.md), +[ADR-0057 (Transactional Event Production and Completion)](../decisions/0057-transactional-event-production-and-completion.md), [ADR-0017 (Tenant + Organization)](../decisions/0017-tenant-organization-hierarchy.md), [18-audit-coverage.md](../standards/18-audit-coverage.md). @@ -66,10 +67,11 @@ in-transaction `INSERT` at step 6 becomes durable only when `COMMIT` returns. Be two, a constraint violation, a lost connection, or — far more commonly — a handler that calls `SaveChanges` and then returns `Result.Fail(...)` takes the audit row away with the business row. A per-request "consumed" flag cannot observe that: the flag lives in a DI -scope and a database rollback does not touch it. `TransactionBehavior` therefore reports -the commit boundary explicitly — `Committed`, `RolledBack` or `Indeterminate` — and -`AuditLogBehavior`, on the **owning** unit-of-work frame only, re-writes every pending -intent standalone for anything that is not `Committed`. +scope and a database rollback does not touch it. `TransactionExecutionCoordinator` +therefore reports the commit boundary explicitly — `Committed`, `RolledBack` or +`Indeterminate` — on the owning UoW frame. `AuditExecutionCoordinator` reconciles +at the outermost audit frame, writing non-durable MUST intents standalone. The +two ownership questions are distinct. Redaction, projection and external fan-out happen after the commit, reading the committed row; none of them updates it. ADR-0016's "audit never blocks business logic" is preserved @@ -79,18 +81,18 @@ for that second stage and withdrawn for the first. ```mermaid flowchart TB - Cmd["Command / Query / Action"] --> Behavior["AuditLogBehavior (step 3)
DECIDE"] + Cmd["Command / Query / Action"] --> Behavior["AuditLogBehavior (step 3)
AuditExecutionCoordinator: DECIDE"] Behavior --> Config["IAuditConfigService.ClassifyAsync
in-process catalogue + cached audit_config
(no request-path DB read)"] Config -->|"not in the catalogue"| Closed["REJECT
audit_unclassified_operation"] Config --> Intent["MUST-class: park ONE INTENT PER audited
(resource, operation) in IAuditStateCapture
(no DbContext)"] - Intent --> Tx["TransactionBehavior (step 6)
BEGIN; SET LOCAL app.tenant_id"] + Intent --> Tx["TransactionBehavior (step 6)
TransactionExecutionCoordinator: BEGIN; announce"] Tx --> Handler["Handler + OutboxFlush
DbContext.SaveChangesAsync (1..n)"] Handler --> Capture["AuditChangeTrackerInterceptor
snapshots every Added/Modified/Deleted entry
into IAuditStateCapture (writes nothing)"] Capture --> Write["WRITE — IAuditStore.WritePendingAsync
owning frame only; every pending intent
on the ambient transaction"] Write --> Commit[("COMMIT — business rows + MUST audit row,
atomically, with app.tenant_id set")] Commit -->|"CommitAsync returned"| Ok["state := Committed"] Commit -->|"rolled back / commit faulted"| NotOk["state := RolledBack | Indeterminate"] - Ok --> Recon["RECONCILE — AuditLogBehavior, on the way out"] + Ok --> Recon["RECONCILE — outer AuditExecutionCoordinator"] NotOk --> Recon Recon -->|"state = Committed"| Done["nothing to do — the row is durable"] Recon -->|"anything else, MUST"| Standalone["IAuditStore.WriteStandaloneAsync
own transaction, real outcome"] @@ -100,16 +102,14 @@ flowchart TB Read as text — **decide → write → reconcile**. At step 3 the behavior classifies the request from in-process state and, for MUST, parks one intent per audited `(resource, operation)` in the scoped `IAuditStateCapture`; it opens no transaction and -touches no `DbContext`. The handler runs inside the transaction `TransactionBehavior` -opened, which issued `SET LOCAL app.tenant_id` as its first statement; the interceptor -snapshots each flush's ChangeTracker into the same buffer and writes nothing. Immediately -before `COMMIT`, and only on the frame it owns, `TransactionBehavior` calls -`IAuditStore.WritePendingAsync`, which composes every pending intent and inserts them on -that transaction — so they commit with the business write or not at all, and Row Level -Security accepts them. `TransactionBehavior` then records the commit boundary. On the way -out, the owning behavior reconciles: `Committed` means there is nothing to do, and -anything else means each row is re-written standalone with the real outcome. SHOULD/MAY -rows and all fan-out are written on that same outbound pass, best-effort. +touches no `DbContext`. Step 6 delegates to `TransactionExecutionCoordinator`, +which opens the ambient transaction and announces before handler dispatch under +[Security's sequencing rule](../standards/11-security.md#the-rule). The interceptor +captures each flush and writes nothing. On its owning UoW frame, the coordinator +calls `IAuditStore.WritePendingAsync` before commit and reports the actual boundary. +The outermost `AuditExecutionCoordinator` then reconciles non-durable MUST rows +standalone with the real outcome; SHOULD/MAY use the best-effort outbound path. +P02b-1 Step 3 delivers ADR-0057's owner-only domain drain before MUST audit/commit. Five components, separated concerns: @@ -125,15 +125,13 @@ Five components, separated concerns: 2. **`IAuditStateCapture`** — the scoped (per-request) audit state: the entity snapshots, the ordered list of pending MUST-class intents, and the ambient transaction's lifecycle state. -3. **`TransactionBehavior`** — owns the commit boundary on the frame it owns, and - therefore owns both the durable audit write (immediately before `COMMIT`) and the - `Committed` / `RolledBack` / `Indeterminate` signal the reconcile step reads. -4. **`AuditLogBehavior`** — keeps its shipped position and its - shipped exception responsibility: catch handler exceptions, record the outcome, - rethrow via `ExceptionDispatchInfo`. It decides on the way in and, on the **outermost** - frame, reconciles on the way out; it no longer writes the MUST-class rows itself except - in the standalone case. The reconcile runs in a `finally`, so a cancelled request — the - commonest way a MUST-class row is left unwritten — still reaches it. +3. **`TransactionExecutionCoordinator`** — owns announcement, producer enrollment, + completion and durable audit write/state reporting on its owning UoW frame. + `TransactionBehavior` is the step-6 request-mode adapter to this shared entry. +4. **`AuditExecutionCoordinator`** — classifies, attributes intents and outcomes, + catches/rethrows unexpected failures and reconciles in `finally` at the + outermost capture frame. `AuditLogBehavior` remains its step-3 request adapter. + The same entry serves direct owners; production delivery remains P02b-3. 5. **`PlatformAdminScope`** — the one writer outside the pipeline. `EnterAsync` records `platform.admin_scope.enter` through `IAuditStore.WritePlatformScopeAsync`, on the scope's own platform-role connection and transaction, **before** the operation runs @@ -243,7 +241,8 @@ public sealed class AuditChangeTrackerInterceptor : ISaveChangesInterceptor The interceptor's job is **capture only**. It returns the unmodified `InterceptionResult`, adds nothing to the context, and issues no SQL. It runs once per flush, and a MUST-class command may flush more than once inside one transaction — which -is precisely why the audit rows are not built here. `TransactionBehavior` composes them +is precisely why audit rows are not built here. `TransactionExecutionCoordinator` +composes them once, after the last flush and before `COMMIT`, so the snapshots are complete regardless of how many times the handler saved. @@ -365,9 +364,10 @@ public interface IAuditStateCapture // The commit boundary is one fact about one transaction, so one State serves however // many intents the scope holds. AuditIntentState State { get; } + bool HasOpenFrame { get; } // scoped audit ownership, not UoW ownership - // FRAMES. Every AuditLogBehavior invocation that lets its request through, outermost or - // nested, opens one before it classifies and closes it on every way out (§ 5). An + // FRAMES. The common coordinator opens one per classified invocation, request + // or direct handler, and closes it on every way out (§ 5). An // intent belongs to the frame that declared it and a capture to the frame innermost // when its flush ran, so a row is composed from its OWN request's writes; closing a // frame records what that request returned on the intents it declared (ADR-0044 @@ -376,13 +376,13 @@ public interface IAuditStateCapture void OpenFrame(); void CloseFrame(AuditIntentResult result); - void DeclareIntent(AuditIntent intent); // AuditLogBehavior, step 3; appends + void DeclareIntent(AuditIntent intent); // AuditExecutionCoordinator; appends void MarkWrittenInTransaction(); // IAuditStore.WritePendingAsync - void MarkCommitted(); // TransactionBehavior, OWNING FRAME ONLY - void MarkRolledBack(); // TransactionBehavior, OWNING FRAME ONLY - void MarkIndeterminate(Exception cause);// TransactionBehavior, OWNING FRAME ONLY + void MarkCommitted(); // transaction coordinator, OWNER ONLY + void MarkRolledBack(); // transaction coordinator, OWNER ONLY + void MarkIndeterminate(Exception cause);// transaction coordinator, OWNER ONLY - void Clear(); // the outermost behavior's finally, once + void Clear(); // outermost audit coordinator, once } // The unit of declaration: minted at step 3, drained at step 6, and re-written standalone @@ -478,12 +478,14 @@ back, which is the project cycle for `AuditEntryId`, resolved the same way. The Audit module's `AuditEntry` consumes them (§ 7); it does not declare them. -**Only the owning unit-of-work frame touches the lifecycle.** `IUnitOfWorkScope.IsOwner` -is the gate. The owner calls `IAuditStore.WritePendingAsync` and drains **every** intent +**Only the owning unit-of-work frame writes and reports transaction durability.** +`IUnitOfWorkScope.IsOwner` is that gate. The owner calls +`IAuditStore.WritePendingAsync` and drains **every** intent in the scope, not only the ones its own frame declared, and it is the only caller of `MarkCommitted` / `MarkRolledBack` / `MarkIndeterminate`. A joiner — the nested dispatch [ADR-0040 § Nesting](../decisions/0040-ambient-unit-of-work.md) sanctions — writes -nothing, signals nothing, does not reconcile, and does not call `Clear()`. A joiner that +nothing and signals nothing. Only the outermost audit frame reconciles and calls +`Clear()`; `HasOpenFrame` on its scoped capture identifies that owner. A joiner that signalled `Committed` would claim durability for a row nothing has committed, and if the outer transaction then rolled back the MUST row would be gone; a joiner that cleared would erase the outer request's intents and every snapshot before the owner committed. @@ -506,6 +508,7 @@ public sealed class AuditStateCapture : IAuditStateCapture, IAuditSubject public IReadOnlyList Changes => _changes; public IReadOnlyList Intents => _intents; public AuditIntentState State { get; private set; } = AuditIntentState.None; + public bool HasOpenFrame => _openFrames.Count != 0; private int CurrentFrame => _openFrames.Count == 0 ? RequestFrame : _openFrames[^1]; @@ -572,8 +575,8 @@ public sealed class AuditStateCapture : IAuditStateCapture, IAuditSubject } ``` -Registered as **scoped** in DI (per-request lifetime). Cleared once per request, by the -outermost behavior, to prevent cross-request bleed +Registered as **scoped** in DI. Cleared once per execution by the outermost audit +coordinator, to prevent state bleed ([`AuditStateCapture_ClearedPerRequest`](../standards/21-architecture-tests-catalogue.md) enforces this). Because the lifetime is the DI scope and not the database transaction, a rollback leaves every field of this object intact — which is exactly why `State` must be @@ -581,308 +584,62 @@ set by the component that owns the commit, and never inferred. ## 5. The MediatR behavior -```csharp -namespace LearnStack.Application.Pipeline; +P02b-1 Step 1 extracts the common audit lifecycle into +[`AuditExecutionCoordinator`](../../backend/src/LearnStack.Application/Audit/AuditExecutionCoordinator.cs). +The request adapter retains step 3 and delegates its operation type and optional +provisioning contract: +```csharp public sealed class AuditLogBehavior( - IAuditCatalog catalog, - IAuditConfigService classifier, - IAuditStateCapture capture, - IAuditStore store, - // The CONTEXT, not the accessor, and no IUnitOfWork. The context is registered scoped - // and resolved from the accessor at the composition root, so this behaviour takes what - // it reads; and the outermost-frame question is AuditFrame's, because at step 3 no - // transaction is open for a unit of work to answer it. - ITenantContext tenantContext, - IClock clock, - IGuidFactory guidFactory, - ILogger> logger) + AuditExecutionCoordinator coordinator) : IPipelineBehavior where TRequest : notnull where TResponse : IResultBase { - public async Task Handle(TRequest request, + public Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken ct) { - // DECIDE. The catalogue is keyed by REQUEST TYPE and built in code, from each - // module's IAuditCatalogSource.Describe(IAuditCatalogBuilder) discovered from DI. - // There is NO RequestKind.Other and no command/query discriminator — this codebase - // has none. Every IRequest> reaching step 3 must be classified, `Off` - // included; an unregistered one is a deployment defect, test-only request types - // included, and they register through the same builder in their fixture. - // TryGet, not a count: `Off` and `Unclassified` are different answers and a - // zero-length descriptor list cannot tell them apart. A registered-and-silent - // request — the eight test-only types among them — returns true with - // WritesNoRow; an unregistered one returns false and is refused. ArgumentNullException.ThrowIfNull(next); - - if (!catalog.TryGet(typeof(TRequest), out var registration)) - { - LogUnclassified(logger, typeof(TRequest).FullName ?? typeof(TRequest).Name, null); - return Result.FailFor(AuditErrors.UnclassifiedOperation); - } - - // Only the OUTERMOST AuditLogBehavior reconciles, signals or clears. A joiner — a - // nested dispatch inside a transaction another request opened — declares into the - // same buffer and leaves the draining to the owner. IUnitOfWorkScope.IsOwner is - // the gate at step 6; here, at step 3, no frame is open yet, so the question is - // whether an outer AuditLogBehavior is already on the stack. AuditFrame is a - // non-generic static holding one AsyncLocal — non-generic deliberately, - // because a static on the generic behaviour is one field PER (TRequest, TResponse) - // pair and a nested dispatch of a DIFFERENT request type would read its own, - // always false, and clear the outer request's buffer. - // Enter() RETURNS whether this frame is the outermost; it is not a disposable - // scope. The matching AuditFrame.Exit() is in the finally, beside the reconcile. - var outermost = AuditFrame.Enter(); - - // A FRAME for this invocation, outermost or nested, and for EVERY registered request - // — a WritesNoRow one included, because its handler can still flush or send a request - // that declares: each intent belongs to the request that declared it, and each capture - // to the request whose handler flushed it (ADR-0044 Amendment 6 § 3). Opened before - // anything that can throw or be cancelled, and closed in the finally below, so no exit - // leaves a frame open or skips the reconcile. - capture.OpenFrame(); - - // Recorded on the way through and acted on once, in the finally. A refusal returns, - // an exception throws, and a cancelled COMMIT does neither in a way this behaviour's - // catch can see — so the exits are reconciled in one place from two facts: what - // THIS request returned, recorded on its own intents, and the STATE the unit of work - // recorded, which is a fact about the database rather than the control flow. - // Thrown until the handler returns: none of the exceptional exits is a success. - var result = AuditIntentResult.Thrown; - - try - { - // DECLARE, inside the try. ClassifyAsync can throw or be cancelled — a cache miss - // opens its own transaction — and a declaration above this block would leave by a - // door the finally does not guard: no CloseFrame, no reconcile, no Clear. - if (!registration.WritesNoRow) - { - // WHICH TENANT the rows carry, decided ONCE and here, because step 3 is the - // only place all four of § 7's cases are decidable and is where the intent - // that carries them is minted (ADR-0044 Amendment 3 § 2). The store composes - // from the intent and never resolves a tenant itself. Reading TenantId on an - // unresolved context THROWS, so the gate is IsResolved and never a null check. - (TenantId Tenant, OrganizationId? Organization)? owner = null; - - if (tenantContext.IsResolved) - owner = (tenantContext.TenantId, tenantContext.OrganizationId); - else if (request is IProvisionsTenant provisioning) - // The organization stays null, and that is binding rather than stylistic: - // SetProvisioningTenantContextAsync announces app.organization_id as the - // EMPTY STRING, so a non-null organization on the - // tenancy.organization.create row would fail audit_log's org-scoped WITH - // CHECK on the very transaction the row has to ride. - owner = (provisioning.ProvisioningTenantId, null); - - // The third case — TenantId.PlatformSentinel — is off this path by - // construction: EnterPlatformAdminScope is not a MediatR request and writes - // its own row (§ 7). - // - // § 7's FOURTH case: an unresolved context that is not provisioning has no - // tenant whose admin could read the row, and ADR-0036 forbids inventing one. - // The question is per REQUEST, not per intent, and it is settled before any - // intent exists — which is why AuditIntent's TenantId is non-nullable, and why - // ClassifyAsync is never asked for the overrides of a tenant there is none of. - if (owner is not null) - { - foreach (var descriptor in registration.Entries) // 1..n per request - { - // ClassifyAsync reads the in-process catalogue plus the tenant's - // cached audit_config overrides. It issues NO query on the request - // path, and that is a correctness requirement, not an optimisation: - // at step 3 no transaction is open, app.tenant_id is unset, and - // audit_config carries ENABLE + FORCE row level security (§ 7). A - // read here would return ZERO ROWS SILENTLY — indistinguishable from - // "this tenant has no overrides" — so no catch could ever fire. On a - // cache miss the loader opens its OWN short transaction and sets - // app.tenant_id itself. The TENANT is a parameter, not something the - // classifier re-reads: the overrides are per tenant. - var classification = await classifier.ClassifyAsync( - owner.Value.Tenant, descriptor, ct); - if (classification == AuditClassification.Off) continue; - - // EVERY surviving tier is declared, SHOULD and MAY included — one - // intent per (resource, operation), so ProvisionTenantCommand declares - // two. There is no second list: the reconcile below branches on the - // intent's own class, and a parallel collection for the non-MUST tiers - // is a second place for the tenant, the id and the snapshot filter to - // be decided differently. - // - // The id is minted app-side here so the in-transaction row and any - // standalone replacement carry the same identity (ADR-0023 - // Amendment 9). Nothing is written yet; no DbContext is touched. - capture.DeclareIntent(new AuditIntent( - Id: AuditEntryId.From(guidFactory.NewUuidV7()), - TenantId: owner.Value.Tenant, - OrganizationId: owner.Value.Organization, - // Read HERE, beside the tenant, so both writers carry them: the - // principal the request carried, or null — never a substituted - // SystemActor (ADR-0044 Amendment 6 § 2). - ActorUserId: tenantContext.UserId, - CorrelationId: tenantContext.CorrelationId, - ModuleName: descriptor.ModuleName, - Operation: descriptor.Operation, // {module}.{resource}.{verb} - OperationType: descriptor.OperationType, - // The DECLARED tier, always. An override narrows to Off or leaves - // the tier alone (ADR-0033 Amendment 4 § 1), so a non-Off - // classification IS the declared one and the intent cannot - // disagree with the catalogue. - OperationClass: descriptor.OperationClass, - EntityType: descriptor.EntityType, // fills entity_type / entity_id - DeclaredAt: clock.UtcNow)); - } - } - } - - var response = await next(); - - result = response.IsFailure - ? AuditIntentResult.Refused(OutcomeOf(response.Error), response.Error.Message.Key) - : AuditIntentResult.Succeeded; - - return response; - } - catch (Exception failure) when (failure is not OperationCanceledException) - { - // Rethrown through ExceptionDispatchInfo, so handlers and the L1 boundary see the - // original stack (ADR-0032 § Sub-decision 2). A cancellation is not caught at all: - // it leaves with its type intact, and still reaches the reconcile, because the - // reconcile is in the finally. A client that hangs up mid-request is the commonest - // way a MUST-class row is left unwritten, so a reconcile the cancellation skipped - // would miss its own main case. - ExceptionDispatchInfo.Capture(failure).Throw(); - throw; // unreachable; the line above rethrows - } - finally - { - // EVERY frame closes its own, joiner or owner. An outcome kept in a local here, as - // it once was, reached the write only for the outermost request — and the owner - // wrote an inner refusal its handler had absorbed as `success`. - capture.CloseFrame(result); - - if (outermost) // a joiner writes, signals and clears nothing - { - try - { - // RECONCILE. TransactionBehavior already wrote every pending MUST-class - // row on the ambient transaction and already reported the commit boundary. - // The only question left is whether that transaction COMMITTED — - // "written" is not "committed", and a per-request flag cannot observe a - // rollback. - // - // ONE loop over the intents, because the class is a property of the - // intent. A MUST-class row on a Committed unit is already durable and is - // skipped; everything else is re-written, and the intent's class picks the - // posture. - foreach (var intent in capture.Intents) - { - if (intent.OperationClass == OperationClass.Must - && capture.State == AuditIntentState.Committed) - continue; - - try - { - // The SAME composer TransactionBehavior's in-transaction write - // uses — AuditDraftComposer, in SharedKernel because its two - // callers sit either side of a project boundary. A private one per - // writer is how the two diverged: only the happy-path row carried a - // snapshot, so every denied, failed and indeterminate row said - // nothing about what was attempted. It takes the intent's own - // request's captures and result and decides the outcome by one - // rule; the caller passes nothing else it could forget. Composed - // INSIDE the try, per intent: a composer refusal must not cost the - // next intent its attempt, nor replace the caller's outcome. - // - // A FRESH clock reading, not intent.DeclaredAt: the in-transaction - // row that may already exist under this id carries that, and the - // composite primary key (id, timestamp) is what makes the in-doubt - // pair legal rather than a 23505. - var draft = AuditDraftComposer.Reconciled( - intent, capture.ChangesOf(intent), capture.State, clock.UtcNow); - - // CancellationToken.None, and it is the whole point: the paths this - // reconcile exists for are the ones where the request's own token - // is ALREADY cancelled, and handing it to the write abandons the row - // at OpenConnectionAsync before a statement is issued. Both - // branches, not just the MUST one — best effort is not no effort, - // and a SHOULD row dropped because the caller hung up is not the - // DATABASE failure the module's matrix accepts. - if (intent.OperationClass == OperationClass.Must) - await store.WriteStandaloneAsync(draft, CancellationToken.None); - else - await store.WriteBestEffortAsync(draft, CancellationToken.None); - } - catch (Exception lost) - { - // EVERY exception, not only AuditWriteFailedException: a store that - // fails before issuing a statement — a data source that cannot be - // built — and a composer refusal both escaped the narrower catch - // this had, from a finally, and replaced a 403 with a 500. - // - // Critical, and swallowed here. ADR-0033 Amendment 1: the 503 - // replaces the response only when the operation would otherwise - // have SUCCEEDED. A row recording an operation that is ALREADY - // being refused — a `denied` outcome, a rejected tenant assertion — - // keeps its own 403 / 404. Downgrading a refusal to a 503 tells the - // caller more, not less, and on a path an anonymous client can - // drive it turns audit-store pressure into an availability signal - // that client controls. - // - // The store has already logged Critical, counted - // learnstack_audit_standalone_write_failures_total and taken the - // `audit` health check unhealthy (ADR-0033 Amendment 3). Past a - // configured unhealthy window a Phase 11 deployment stops serving - // rather than serving unaudited. - LogReconcileRowLost(logger, intent.Operation, lost); - } - } - } - finally - { - // Unconditional: a reconcile that threw used to skip both lines, and the - // flow then believed a frame was still open — the NEXT request in it was - // never outermost, never reconciled and never cleared. - AuditFrame.Exit(); - capture.Clear(); // once, and only on the outermost behaviour - } - } - } + return coordinator.ExecuteAsync( + typeof(TRequest), request as IProvisionsTenant, () => next(), ct); } - - // WHAT THIS LISTING ELIDES, so a reader does not mistake it for the file. The shipped - // behaviour splits this method into Handle / DeclareAsync / ReconcileAsync; the two - // loops are inlined here because the ORDER is the thing worth seeing and a three-method - // trace hides it. The order itself is the file's: the unclassified refusal first, then - // AuditFrame.Enter and OpenFrame before anything that can throw or be cancelled, the - // declaration and the handler inside the one try, and CloseFrame and the reconcile in - // its finally — so every exit, a cancelled classification and a WritesNoRow request - // included, closes its frame, and the outermost one reconciles and clears. - // - // OutcomeOf(Error) is the one private helper this listing keeps: `forbidden`, - // `resource_scope_violation` and `feature_disabled` — the three codes HttpStatusMap - // answers 403 to — are Denied, and every other refusal is Failed. What a ROW says is - // AuditDraftComposer's, by one rule per writer (ADR-0044 Amendment 6 § 3): the - // in-transaction row takes the intent's result, and none means the owner's own request, - // which succeeded; a reconciled row takes a refusal its request returned, then - // `indeterminate` for a COMMIT in doubt, then `success` only for a request that - // succeeded in a transaction that committed, and `failed` otherwise. Everything else - // comes from the INTENT — tenant, organization, actor, correlation, module, operation, - // type, class and entity type — never re-read from the accessor, which throws on the - // provisioning case and knows nothing of the sentinel one; the snapshots come from - // capture.ChangesOf(intent), the captures of the intent's own request, filtered to its - // declared aggregate and resolved to its subject instance. A SHOULD/MAY intent is - // declared at step 3 exactly as a MUST one is; what differs is only which store method - // drains it. } ``` +The coordinator refuses an unregistered operation before invoking deferred work; +registered Off is a different answer. It opens a frame before declaration or any +cancelable work, records that invocation's returned result on its own intents, +and closes the frame in `finally`. The outermost frame alone reconciles and +clears, including on classification cancellation, refused work or a faulted +commit. `IAuditStateCapture.HasOpenFrame` is the ownership authority, read before +opening the frame. It belongs to the scoped capture; a separate DI scope on the +same async flow has its own ownership. There is no static AsyncLocal audit flag. + +Outcome rules remain unchanged: the three 403 codes are Denied, other returned +refusals are Failed, and an exception remains Thrown. `AuditDraftComposer` +combines each intent's result with the transaction's reported state. It takes +returned refusal first, then Indeterminate for commit in doubt, then Success only +for successful work on a committed transaction, and Failed otherwise. Metadata +comes from the intent and snapshots from `capture.ChangesOf(intent)`; neither +writer re-resolves an ambient tenant during reconciliation. MUST, SHOULD and MAY +all declare intents; their store paths differ. + +A direct owner enters the same coordinator with its declared handler type before +constructing that handler. Its deferred work enters +[`TransactionExecutionCoordinator`](../../backend/src/LearnStack.Application/Persistence/TransactionExecutionCoordinator.cs), +then constructs and invokes the handler after announcement. Direct one-root work +declares its own intents; nested commands retain their own, without an extra +business row. Step 1 supplies the common entry and producer ancestry groundwork; +Steps 2–3 deliver immediate outbox enqueue, successful-save collection/promotion +and owner-only domain drain. Dispatch remains P02b-2; production consumer +delivery/inbox wiring remains P02b-3 after G1. + ### The two catalogue faces, and the three columns nothing fills yet **A module writes a source; the behavior reads a catalogue.** A module owns an `IAuditCatalogSource` and describes its request types into an `IAuditCatalogBuilder` ([ADR-0044 § 6](../decisions/0044-audit-write-path.md)); the composition root merges every -source **once at startup** into the `IAuditCatalog` this behavior injects, so +source **once at startup** into the `IAuditCatalog` the coordinator injects, so `TryGet(typeof(TRequest), out …)` is a dictionary lookup returning an `AuditRegistration` — a `WritesNoRow` flag and its `AuditCatalogEntry` entries, `(ModuleName, Operation, OperationType, OperationClass, EntityType)` — with no merge, no @@ -899,12 +656,14 @@ value types: `LearnStack.Application` references only `LearnStack.SharedKernel`, `LearnStack.Domain` and `LearnStack.Application.Contracts`, and a module's `Application` project references only SharedKernel plus its own Domain and Contracts, so SharedKernel is the one assembly the behavior and every module source share. Four of the five ship with -the ports; `IAuditConfigService` lands with `AuditLogBehavior`, which is its only caller. +the ports; `IAuditConfigService` shipped with `AuditLogBehavior`, and the extracted +`AuditExecutionCoordinator` now owns that call. **Three of `audit_log`'s columns have no source in Packet 9, and the document says so rather than implying one.** `actor_user_id` and `correlation_id` come from -`ITenantContext` — `UserId` and `CorrelationId`, on the accessor the behavior already -injects — read once at step 3 and carried on the intent, so the in-transaction write and the +`ITenantContext` — `UserId` and `CorrelationId`, injected into the common +coordinator — read before deferred work and carried on the intent, so the +in-transaction write and the reconcile cannot disagree about them; the in-transaction write once had no way to reach them and wrote both columns `NULL` on every successful row ([ADR-0044 Amendment 6 § 2](../decisions/0044-audit-write-path.md)). The actor is the @@ -924,7 +683,7 @@ way [ADR-0036 Amendment 2](../decisions/0036-tenant-resolution-trusted-inputs.md the ambient tenant context's four, and for the same reason: an ambient value anyone may write is an ambient value nobody can reason about. -Key invariants enforced by this behavior: +Key invariants enforced by the shared lifecycle: - **One intent per audited `(resource, operation)`, not one per request.** `ProvisionTenantCommand` writes `Tenant` and `Organization` on the one transaction @@ -932,8 +691,9 @@ Key invariants enforced by this behavior: sanctions, and [the Tenancy matrix](../modules/tenancy/audit.md) classifies both MUST. Under the singular reading the `Organization | create` row it requires would never be written and the matrix would be wrong the day it was implemented. -- **Only the owning frame acts.** A joiner declares and returns: it writes nothing, - signals nothing, and never calls `Clear()` (§ 4). +- **Ownership gates distinct duties.** A UoW joiner writes and signals nothing. + A nested audit frame does not reconcile or clear; the outermost audit + coordinator owns those duties (§ 4). - **Only `Committed` counts.** The reconcile step branches on `IAuditStateCapture.State`, never on a "consumed" flag. **Any** state other than `Committed` produces standalone rows — `Pending` included, which is the state a @@ -965,7 +725,7 @@ Key invariants enforced by this behavior: - **An in-transaction MUST-class audit failure fails the operation, as an exception.** `IAuditStore.WritePendingAsync` throws `AuditWriteFailedException : InfrastructureException` carrying the `audit_unavailable` - `Error`; `TransactionBehavior` rolls back and rethrows, and + `Error`; `TransactionExecutionCoordinator` rolls back and rethrows, and `HttpStatusMap.For(Exception)`'s existing `LearnStackException known => For(known.Error)` branch answers **503** with a matching body. This behavior's shipped catch-and-rethrow-via-`ExceptionDispatchInfo` contract is untouched. @@ -1028,120 +788,69 @@ Request → TenantContext (assert tenant_id resolved) → Authorization (resource-scoped checks beyond endpoint-level [Authorize]) → Transaction (begin transaction; SET LOCAL app.tenant_id; commit/rollback) - → OutboxFlush (flush outbox writes to DbContext before commit) + → OutboxFlush (producer enrollment/invariant guard; P02b-1 Step 2) → Handler (business logic) ``` **The order did not change, and does not need to.** `AuditLogBehavior` still sits outside `TransactionBehavior`, which is exactly why its *own* write cannot be the durable one. The -durable MUST-class row is written by `TransactionBehavior` — the behavior that owns the -commit boundary — from the intent the outer behavior declared. The decision travels inward +durable MUST-class row is written by its `TransactionExecutionCoordinator` on the +owning frame, from intents declared by `AuditExecutionCoordinator`. The decision +travels inward through the pipeline; the commit outcome travels back out. +The current request adapter selects the existing public-read mode and delegates +entry/completion to the common coordinator: + ```csharp -// LearnStack.Application.Pipeline.TransactionBehavior — step 6. The commit boundary owns -// the durable audit write AND the durability signal, because they are the same fact. -public async Task Handle(TRequest request, - RequestHandlerDelegate next, CancellationToken ct) +public sealed class TransactionBehavior( + TransactionExecutionCoordinator coordinator) + : IPipelineBehavior + where TRequest : notnull + where TResponse : IResultBase { - // No gate. Everything that reaches step 6 needs a transaction, because the - // requests that must not open one have already short-circuited: validation - // failure at step 1, an unresolved tenant at step 4 (tenant_mismatch), and - // authorization denial at step 5. Reads included — which is why a granted - // MUST-class read-sensitive query rides the in-transaction path (§ 1). - await using var scope = await unitOfWork.BeginTransactionAsync(ct); + private static readonly TransactionMode Mode = typeof(TRequest) + .IsDefined(typeof(PublicSurfaceAttribute), inherit: false) + ? TransactionMode.ReadOnly + : TransactionMode.ReadWrite; - var committing = false; - - try - { - // First statement inside the transaction, per ADR-0003 Amendment 3 — and inside - // the try, so an announcement that fails rolls back like everything after it. The - // provisioning arm is not optional: the tenant does not exist yet, so the context - // cannot resolve it, and the transaction announces the id the command carries — - // which is also the tenant its MUST-class rows carry (§ 7). - if (!tenantContext.IsResolved && request is IProvisionsTenant provisioning) - await unitOfWork.SetProvisioningTenantContextAsync(provisioning.ProvisioningTenantId, ct); - else - await unitOfWork.SetTenantContextAsync(tenantContext, ct); - - var response = await next(); - - if (response.IsFailure) - { - await scope.FailAsync(CancellationToken.None); - if (scope.IsOwner) capture.MarkRolledBack(); - return response; // audited standalone, outcome = failed - } - - // WRITE — OWNER ONLY, and it drains EVERY intent in the scope, not only the ones - // this frame declared. A joiner writes nothing and signals nothing: CompleteAsync - // is a documented no-op on it, so a joiner reporting Committed would claim - // durability for a row nothing has committed. No-op when no MUST-class intent is - // pending. Throws AuditWriteFailedException on failure, which reaches the catch - // below and rolls the business write back — fail closed. - if (scope.IsOwner) - await auditStore.WritePendingAsync(unitOfWork, ct); - - committing = true; - - try - { - await scope.CompleteAsync(ct); - } - catch (Exception ex) - { - // REFUSED is not FAULTED, and only the unit knows which. CompleteAsync throws - // the same way for both — but a unit an inner frame marked rollback-only is - // rolled back FOR REAL before it throws, so nothing committed and the outcome - // is certain. Labelling that Indeterminate would put a permanent row on an - // append-only table saying the COMMIT may have landed. - // - // A cancellation is this case, not a separate one (ADR-0033 Amendment 4 § 2). - if (scope.IsOwner) - { - if (unitOfWork.IsRollbackOnly) capture.MarkRolledBack(); - else capture.MarkIndeterminate(ex); - } - - throw; - } - - if (scope.IsOwner) - capture.MarkCommitted(); // the ONLY place durability is claimed - - return response; - } - catch when (!committing) + public Task Handle(TRequest request, + RequestHandlerDelegate next, CancellationToken ct) { - unitOfWork.MarkRollbackOnly(); - - // A rollback that fails is logged and never replaces the exception that caused it. - try { await scope.FailAsync(CancellationToken.None); } - catch (Exception rollbackFailure) { LogRollbackFailure(logger, typeof(TRequest).Name, rollbackFailure); } - - // Never over an Indeterminate the commit arm already recorded. - if (scope.IsOwner && capture.State != AuditIntentState.Indeterminate) - capture.MarkRolledBack(); - - // Rethrown, never converted to a Result. audit_unavailable travels as - // AuditWriteFailedException : InfrastructureException carrying the Error, and - // HttpStatusMap.For(Exception)'s existing `LearnStackException known => - // For(known.Error)` branch is what makes it a 503 with a matching body. - throw; + ArgumentNullException.ThrowIfNull(next); + return coordinator.ExecuteAsync( + request as IProvisionsTenant, Mode, typeof(TRequest).Name, + () => next(), ct); } } ``` -`IUnitOfWork` is the seam that lets this generic behavior open and commit a transaction -without naming any module's `DbContext`, and through which `IAuditStore` reaches the -ambient connection. It **shipped** in -[Phase 02a Packet 6](../roadmap/phase-02a-kernel-tenancy.md) step 6, together with the -`TransactionBehavior` body; the `auditStore` and `capture` lines shipped with -`IAuditStore` in Packet 9. The block above follows the shipped body statement for -statement — the provisioning arm and the `try` boundary included — and elides only the -argument guard and the log delegate's definition; the comments are this document's, and the -file is the authority. +[`TransactionExecutionCoordinator`](../../backend/src/LearnStack.Application/Persistence/TransactionExecutionCoordinator.cs) +uses the existing `IUnitOfWorkScope` handle, announces admitted tenant/organization +before dispatch and enrolls +[`ProducerExecution`](../../backend/src/LearnStack.Application/Messaging/ProducerExecution.cs). +The invocation stays open through completion. Successful production records sticky +receipts in its invocation and ancestors; a returned refusal with produced work +marks rollback-only before frame resolution. Ordinary event-free absorbed +refusals retain ADR-0040's meaning. Producer frames remain distinct from audit +ownership and cannot make a raw transaction into an enrolled producer owner. + +Only the transaction owner writes pending MUST intents and reports durability. +It refuses rollback-only or read-only-with-produced-work before composition; +Commit success reports Committed, an already poisoned refusal reports RolledBack, +and a genuinely uncertain commit fault reports Indeterminate. Rollback cleanup +uses `CancellationToken.None` and cannot replace the original failure. The shared +execution boundary creates a W3C Activity even without a telemetry listener and +restores ancestry/Activity on disposal. P02b-1 Steps 2–3 deliver immediate outbox +enqueue, successful-save collection/promotion and owner-only domain drain before +MUST audit/commit. Dispatch and consumer delivery/inbox remain P02b-2/P02b-3. + +`IUnitOfWork` remains the seam through which every module context and `IAuditStore` +reach the one ambient connection/transaction. Packet 6 shipped that seam and the +original transaction behavior; Packet 9 added the durable audit write and state +reporting. Step 1 moves those responsibilities into the common coordinator without +changing their classification or durability contracts. The linked source files +own implementation details; this document keeps the adapter and ownership shape. The one structural change Packet 9 made was the `try`/`catch` around the commit call. Packet 6's body had a single catch filtered `when (!committing)` precisely so it did @@ -1151,15 +860,16 @@ success and failure paths; the in-doubt outcome needed a catch of its own, and o existed it had to tell a REFUSED commit from a FAULTED one, because `CompleteAsync` throws the same way for both. -The block above is written on the shipped frame handling, which +The common coordinator retains the shipped frame handling, which [ADR-0040 Amendment 2](../decisions/0040-ambient-unit-of-work.md) settled after an earlier -draft of this section. The behavior resolves its frame through the `IUnitOfWorkScope` +draft of this section. It resolves its frame through the `IUnitOfWorkScope` handle (`CompleteAsync` / `FailAsync`) rather than through the frame-blind `unitOfWork.CommitAsync` / `RollbackAsync`, because a nested frame nobody resolved -otherwise turns the outer commit into a silent no-op. It marks the unit rollback-only on -the exception path only: an inner `Result.Fail` an outer handler absorbs is not a failure -of the unit, per ADR-0040 § Nesting. And the `committing` flag is what keeps a faulted -`COMMIT` from being followed by a rollback attempt. +otherwise turns the outer commit into a silent no-op. ADR-0057 adds rollback-only +for a returned refusal whose invocation or descendants +produced work; ordinary event-free absorbed refusals keep ADR-0040 § Nesting +semantics. The `committing` flag keeps a faulted `COMMIT` from being followed by +a rollback attempt. That same handle carries `IsOwner`, which is why the audit gating above is a property of the seam ADR-0040 already ships rather than a second mechanism invented for audit. @@ -1461,7 +1171,7 @@ its standalone replacement cannot disagree: | Request shape | `tenant_id` on the row | `organization_id` on the row | |---|---|---| | Resolved context | `ITenantContext.TenantId` | `ITenantContext.OrganizationId` — `NULL` when the request targets a tenant-wide resource, which is what makes the row's scope follow the resource's | -| `IProvisionsTenant` under an unresolved context | `IProvisionsTenant.ProvisioningTenantId` — the value `TransactionBehavior` announced | `NULL`, and not by choice: `SetProvisioningTenantContextAsync` announces `app.organization_id` as the empty string, so any other value fails the org-scoped `WITH CHECK` | +| `IProvisionsTenant` under an unresolved context | `IProvisionsTenant.ProvisioningTenantId` — the value the common transaction coordinator announced | `NULL`, and not by choice: `SetProvisioningTenantContextAsync` announces `app.organization_id` as the empty string, so any other value fails the org-scoped `WITH CHECK` | | No transaction and no resolvable tenant (`EnterPlatformAdminScope`) | `TenantId.PlatformSentinel` | `NULL` — the sentinel has no organizations | | Unresolved context, not provisioning | no row — there is no tenant whose admin could read it | — | @@ -2072,6 +1782,7 @@ for MUST-class would lock in exactly the defect ADR-0033 removes. | Phase | Deliverable | |-------|-------------| | [02a Packet 9](../roadmap/phase-02a-kernel-tenancy.md) | `AuditChangeTrackerInterceptor` with the broad capture predicate, the `[PiiSensitive]` redaction gate and the size cap; `IAuditStateCapture` + impl, holding an **ordered list** of intents; `AuditLogBehavior` lit up per ADR-0033 as amended; `IAuditStore` + `PostgresAuditStore` with **four** write methods, the fourth serving `EnterPlatformAdminScope`; the classification catalogue as a **pair** — the module-owned `IAuditCatalogSource` / `IAuditCatalogBuilder` and the `IAuditCatalog` the composition root merges them into once at startup — with the six value types beside the five ports in `LearnStack.SharedKernel.Audit`; `TenantId.PlatformSentinel` and the **four** guards that enforce its invariant — `SetProvisioningTenantContextAsync`, `TenantOwnership.EnsureRealTenant`, `EventTenantContext.FromEnvelope` and, deliberately redundant at the one site every announcement passes, `SetTenantContextAsync`; `AuditEntry` aggregate and `AuditConfig` with the fail-closed MUST floor; `audit_log` as a **single plain table** with the composite primary key, org-scoped, in a fourth migration chain, with its append-only grants and its two triggers; and the named `audit` health check §§ 1, 5 and 7 read, registered through `AddHealthChecks()` at the `LearnStack.Api` composition root. | +| P02b-1 Steps 1–3 | Common audit/transaction coordinators, capture-owned audit frames, producer ancestry/receipts and no-listener trace groundwork; Steps 2–3 deliver immediate outbox enqueue, successful-save collection/promotion and owner-only domain drain. Dispatch remains P02b-2; consumer delivery/inbox remains P02b-3. | | [03](../roadmap/phase-03-identity-admin.md) | Admin API endpoints over the audit stream; `UserGdprDeletedIntegrationEventHandler` + per-module `IUserReferenceLocator`. | | [06](../roadmap/phase-06-renderer-admin-studio.md) | Admin Studio audit UI: timeline view, filters, diff viewer, CSV / JSON export. | | [09](../roadmap/phase-09-billing-integrations-analytics.md) | Hub-side `hub_audit_log` + cross-stream correlation query. | diff --git a/docs/decisions/0032-exception-handling-logging-and-observability.md b/docs/decisions/0032-exception-handling-logging-and-observability.md index 0cb3a6e8..0f82b48d 100644 --- a/docs/decisions/0032-exception-handling-logging-and-observability.md +++ b/docs/decisions/0032-exception-handling-logging-and-observability.md @@ -824,3 +824,21 @@ glossary's `ITenantContextAccessor` entry. Not [Security Standards § Tenant Context](../standards/11-security.md): that section declares itself the authority for session-variable **placement** only, and a container lifetime is not a `SET LOCAL` concern. + +### Amendment 4 — Bounded producer supersession by ADR-0057 (2026-10-12) + +**Status: Accepted.** [ADR-0057](0057-transactional-event-production-and-completion.md) +supersedes only these clauses: + +- **Sub-decision 2, step 7:** its `OutboxFlushBehavior` flush/publication completion + role is replaced by immediate ambient enqueue and transaction-owner completion. + Step 7 retains its pipeline position as the producer enrollment/invariant guard; + it neither buffers messages nor dispatches domain events. +- **Sub-decision 12 and the Phase 02b deliverables table in Implementation Notes:** + both separate `event_id` column requirements are replaced by + `outbox_messages.id = EventId`. There is no second surrogate/event-ID column. + All other envelope columns and full W3C traceparent propagation survive. + +These were historical decisions, not false-at-entry statements. ADR-0057 supplies +this changed authority; this disclosure is not an ADR-0041 correction. All original +body text, acceptance metadata, rationale and earlier amendments remain intact. diff --git a/docs/decisions/0033-audit-durability-model.md b/docs/decisions/0033-audit-durability-model.md index 30c5fdd3..cca9c53e 100644 --- a/docs/decisions/0033-audit-durability-model.md +++ b/docs/decisions/0033-audit-durability-model.md @@ -740,6 +740,28 @@ Amendment 2 § 7 and Amendment 5 § 1, by the errata beside them; refusal writes no row; the `add-audit-coverage` skill; and `IAuditStore`'s own account of its standalone callers. No other Accepted ADR's body changes. +## Amendment 8 — Common audit ownership outside MediatR (2026-10-12) + +**Status: Accepted.** [ADR-0057](0057-transactional-event-production-and-completion.md#audit-ownership-outside-mediatr) +clarifies how a non-MediatR transaction owner performs the existing audit duties. +The reusable execution coordinator opens the outer audit frame before handler +construction, classifies the handler type and refuses an unregistered mutating +handler. A direct one-root handler declares its own intents; nested commands +retain theirs without a duplicate business row. + +The transaction owner drains domain events before composing and writing every +pending MUST row, then commits and reports `Committed`, `RolledBack` or +`Indeterminate` accurately. Only the outermost audit frame reconciles and clears +capture. Producer invocation receipts remain distinct from audit ownership; +joiners do not claim durability or clear the owner's capture. + +Classification, the MUST floor, standalone refusal posture, plural resource +intents and the single business-transaction guarantee are unchanged. No module +becomes an audit writer and no asynchronous audit mirror substitutes for source +audit. P02b-1 proves a controlled non-MediatR owner; production delivery/inbox +atomicity remains P02b-3 after G1. This is a dated clarification, not an ADR-0041 +correction. All earlier body text and acceptance metadata remain intact. + ## References - [ADR-0016 Audit Log Subsystem](0016-audit-log-subsystem.md) (superseded by this ADR) diff --git a/docs/decisions/0038-cross-cutting-port-and-event-contracts.md b/docs/decisions/0038-cross-cutting-port-and-event-contracts.md index 298f8886..856a8537 100644 --- a/docs/decisions/0038-cross-cutting-port-and-event-contracts.md +++ b/docs/decisions/0038-cross-cutting-port-and-event-contracts.md @@ -212,6 +212,21 @@ The rest of this ADR is unaffected: the envelope contract, the consumer identity (`UserId.SystemActor` as the effective principal, `CausalActorUserId` as the human), and the cache-key rules all stand. +### Amendment 2 — Additional producer payload policy (2026-10-12) + +**Status: Accepted.** [ADR-0057](0057-transactional-event-production-and-completion.md#transaction-metadata-and-payload-boundary) +decides new producer obligations: identifier-only additional payload members, +concrete-runtime-type serialization through the existing frozen +`IntegrationEventBase.PayloadJsonOptions`, an inclusive 262,144-byte canonical +UTF-8 application admission bound, and SQL NULL in `outbox_messages.metadata`. +`IOutbox` accepts no arbitrary JSON metadata. The cap adds no DDL CHECK and does +not equate serialized bytes with PostgreSQL's stored `jsonb` representation. + +This is an additive policy cross-reference, not a factual correction or a change +to this ADR's original envelope, transport, serializer or effective/causal actor +contracts. Identifiers minimize copied data; they are not a claim of anonymity. +G18's failure descriptors and erasure inventory remain P02b-2 and P02b-8. + ## References - [ADR-0006 — Events and Outbox](0006-events-and-outbox.md) diff --git a/docs/decisions/0040-ambient-unit-of-work.md b/docs/decisions/0040-ambient-unit-of-work.md index 6d29ebf9..fca7a1f7 100644 --- a/docs/decisions/0040-ambient-unit-of-work.md +++ b/docs/decisions/0040-ambient-unit-of-work.md @@ -669,6 +669,8 @@ architecture, module specs and catalogue. - [Permission Standards § Resource-scope checks](../standards/19-permissions.md) - [Security Standards § Tenant Context](../standards/11-security.md) +## Amendments (continued) + ### Amendment 9 — read-only frame delivery (2026-10-04) This delivery note changes no decision. Amendment 8 records the acceptance-time @@ -680,3 +682,43 @@ records foundation delivery; the [Step 1 delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-4-step-1-authority-and-read-only-foundation) owns real app-role EF/SQL write refusal, writable controls and lifecycle evidence. The original Amendment 8 status remains historical. + +### Amendment 10 — Bounded produced-work refusal supersession (2026-10-12) + +**Status: Accepted.** [ADR-0057](0057-transactional-event-production-and-completion.md) +supersedes § Nesting and Amendment 2's absorbable `Result.Fail` rule **only for +successfully produced work**. A producer frame returning `Result.Fail` after its +own or a descendant's successful outbox INSERT or successful-save event promotion +marks the shared unit rollback-only before resolving its UoW frame. Absorption by +an ancestor cannot commit those facts; drain/cleanup cannot erase the sticky receipt. +Joined frames still create no savepoint. + +Ordinary event-free refusals, uncollected raises, failed saves and unsuccessful +enqueue attempts do not themselves activate this additional rule. Existing +exception and unknown-outcome failure rules survive. ADR-0057 supplies the changed +decision; this is a bounded supersession disclosure, not an ADR-0041 correction. +The original body, acceptance metadata and earlier amendments remain intact. + +### Amendment 11 — Named transaction announcement snapshot reader (2026-10-12) + +**Status: Accepted.** [ADR-0057](0057-transactional-event-production-and-completion.md#transaction-metadata-and-payload-boundary) +adds this read member to `IUnitOfWork` in `SharedKernel.Persistence`: + +```csharp +TenantContextAnnouncement? GetTenantContextAnnouncement(DbTransaction? transaction); +``` + +`TenantContextAnnouncement` is an immutable snapshot with `long Generation`, +`TenantId TenantId` and `OrganizationId? OrganizationId`. Generation increases for +each new physical transaction. A snapshot exists only for the current non-null +transaction after all announcement SQL succeeds and +`IsTenantContextIssuedOn(transaction)` is true. It records normalized values sent +by either existing sanctioned announcement method; unresolved/no-tenant, foreign, +failed/canceled setup and terminal states return null. Clear it at transaction +begin, before an announcement attempt, and on commit, rollback or disposal. +Joiners reuse it and cannot retarget it. Producer frames check the live snapshot's +generation as well as tenant, including reused transaction objects. + +This separately enumerated additive read seam follows Amendment 5's precedent. +It adds no setter, ambient context writer, ownership change or savepoint. The +bounded nested-failure supersession is Amendment 10, not this seam addition. diff --git a/docs/decisions/0044-audit-write-path.md b/docs/decisions/0044-audit-write-path.md index 72eab8d8..05596eef 100644 --- a/docs/decisions/0044-audit-write-path.md +++ b/docs/decisions/0044-audit-write-path.md @@ -1083,6 +1083,28 @@ it rather than counting them again. Packet 9 scope; and `AuditChangeTrackerInterceptor`'s own remarks. No other Accepted ADR's body changes. +## Amendment 8 — Common audit ownership outside MediatR (2026-10-12) + +**Status: Accepted.** [ADR-0057](0057-transactional-event-production-and-completion.md#audit-ownership-outside-mediatr) +clarifies how a non-MediatR transaction owner performs the existing audit duties. +The reusable execution coordinator opens the outer audit frame before handler +construction, classifies the handler type and refuses an unregistered mutating +handler. A direct one-root handler declares its own intents; nested commands +retain theirs without a duplicate business row. + +The transaction owner drains domain events before composing and writing every +pending MUST row, then commits and reports `Committed`, `RolledBack` or +`Indeterminate` accurately. Only the outermost audit frame reconciles and clears +capture. Producer invocation receipts remain distinct from audit ownership; +joiners do not claim durability or clear the owner's capture. + +Classification, the MUST floor, standalone refusal posture, plural resource +intents and the single business-transaction guarantee are unchanged. No module +becomes an audit writer and no asynchronous audit mirror substitutes for source +audit. P02b-1 proves a controlled non-MediatR owner; production delivery/inbox +atomicity remains P02b-3 after G1. This is a dated clarification, not an ADR-0041 +correction. All earlier body text and acceptance metadata remain intact. + ## References - [ADR-0033 Audit Durability Model](0033-audit-durability-model.md) — the durability diff --git a/docs/decisions/0057-transactional-event-production-and-completion.md b/docs/decisions/0057-transactional-event-production-and-completion.md new file mode 100644 index 00000000..4baae490 --- /dev/null +++ b/docs/decisions/0057-transactional-event-production-and-completion.md @@ -0,0 +1,435 @@ +# ADR-0057: Transactional Event Production and Completion + +> **Erratum — 2026-10-12.** The locator `Architecture Tests` below names no +> ADR-0032 section. The second separate `event_id` requirement is in the +> Phase 02b deliverables table in Implementation Notes, as present at this +> ADR's acceptance (`b157650`). The bounded supersession and Decision are +> unchanged. Current authority: [ADR-0032 Amendment 4](0032-exception-handling-logging-and-observability.md#amendment-4--bounded-producer-supersession-by-adr-0057-2026-10-12). +> Recorded in [Amendment 2](#amendment-2--correct-adr-0032-clause-locators-2026-10-12). + +## Status + +**Accepted — 2026-10-12.** The maintainer approves the bounded supersessions +below and the +[P02b-1 decision package](../roadmap/phase-02b-events-auth.md#p02b-1-decision-package-2026-10-12). + +**Bounded supersession:** ADR-0032 Sub-decision 2's step-7 +completion role and Sub-decision 12 / Architecture Tests' separate `event_id` +column; ADR-0040 § Nesting and Amendment 2's absorbable-`Result.Fail` rule only +for successfully produced work. This new ADR supplies the changed authority. +Those records remain Accepted outside these clauses; this is not an ADR-0041 +factual correction. + +**Date:** 2026-10-12 +**Deciders:** @cemil + +## Decision Drivers + +- Commit an integration event with its source state and MUST audit under + [ADR-0006](0006-events-and-outbox.md) and + [ADR-0033](0033-audit-durability-model.md), including provisioning through the + Seeder's unresolved ambient context under + [ADR-0042](0042-tenant-provisioning-cross-aggregate-transaction.md). +- Give requests and future direct deliveries one completion protocol: + [ADR-0040](0040-ambient-unit-of-work.md#consumers-do-not-have-a-request-and-still-need-all-of-this) + requires a transaction owner outside MediatR; step 7 cannot flush later work. +- Prevent a refused nested producer from leaving a publishable fact when its + outer caller absorbs the refusal. Joined frames are not savepoints under + [ADR-0040 § Nesting](0040-ambient-unit-of-work.md#nesting). +- Establish a useful production subject using the declared tenant defaults; + [P02b-0's audit](../roadmap/phase-02b-events-auth.md#p02b-0-entry-verification-2026-10-12) + finds no useful invalidation effect for already fresh or uncached readers. + +## Considered Options + +1. **Immediate transactional enqueue and owner-side completion** (chosen). + One commit boundary; all sanctioned producer paths share it. +2. **Buffer until MediatR step 7** (rejected). Domain reactions and direct + delivery handlers can run after that slot or without it. +3. **Dispatch domain events during each SaveChanges** (rejected). A later save + or command refusal can invalidate an intermediate operation; joiners must + not dispatch or claim commit ownership. +4. **Allow domain reactions to mutate aggregates** (rejected). Post-handler + mutation can invalidate the returned version and evade per-handler root + counting. Such work belongs to an integration-event consumer. +5. **Retain explicit-only poisoning for all nested Result failures** (rejected + for produced work). It relies on every caller remembering a consistency + obligation that the shared producer boundary can enforce. + +## Decision + +LearnStack uses immediate transactional enqueue and owner-side completion under +the following contracts. They authorize P02b-1 producer implementation; dispatcher +and consumer delivery remain owned by P02b-2 and P02b-3. + +### Immediate enqueue and stored identity + +`IOutbox.EnqueueAsync` validates and inserts immediately through the active +`IUnitOfWork.Connection` and `Transaction`. It opens no connection, commits +nothing and never calls `IEventBus`. It requires an enrolled producer frame, +a writable, non-poisoned transaction and its successful tenant announcement. +Raw transactions without that frame cannot enqueue. + +- `outbox_messages.id` is exactly `EventId`, supplied by the producer. There + is no additional surrogate or `event_id` column. Retry and replay preserve + that identity; duplicate enqueue is an error, not an ignored conflict. +- `type` retains the assembly-qualified concrete event name. P02b-2 owns the + bounded compatibility resolver and unknown/retired/unsubscribed outcomes. +- Payload serialization uses the concrete runtime type and the existing frozen + `IntegrationEventBase.PayloadJsonOptions`. The + `OutboxPayloadLimits.MaxBytes = 256 * 1024` caps the complete serialized UTF-8 + payload at 262,144 bytes, inclusive; refusal happens before INSERT. This ADR + selects a conservative application-layer bound, not a measured throughput + limit. It matches the numerical customization-row ceiling but does not reuse + `JsonValue.MaxRowBytes`, whose contract belongs to customization. Existing + outbox DDL has no payload-size CHECK; this decision adds no migration or claim + that raw SQL is size-constrained. The stored `jsonb` representation need not + have the same byte length as the serialized input. +- Each concrete event declares its partition source: its aggregate identifier + or an explicitly tenant-wide `TenantId`. The key is that identifier's + canonical GUID representation. Structural controls check the declaration + and derivation with planted violations; behavioral controls compare actual + values. This decides no publish-order guarantee; G3's ordering half is P02b-2. + +### Transaction metadata and payload boundary + +`IUnitOfWork` gains one read-only member in `SharedKernel.Persistence`: + +```csharp +TenantContextAnnouncement? GetTenantContextAnnouncement(DbTransaction? transaction); +``` + +`TenantContextAnnouncement` is an immutable snapshot with `long Generation`, +`TenantId TenantId` and `OrganizationId? OrganizationId`. The generation belongs +to the physical transaction, increases for each new transaction on that unit, +and is not a tenant-context setter. The getter returns a snapshot only after +all announcement SQL succeeds, for the unit's current non-null transaction, +with `IsTenantContextIssuedOn(transaction)` true. It returns null for an absent +or foreign transaction, an unresolved announcement without a usable tenant, +and after setup failure, commit, rollback or disposal. Joiners reuse the same +snapshot and cannot retarget it. Both existing sanctioned announcement methods +capture the normalized values actually sent to PostgreSQL, not a later ambient +context read. Clear the snapshot when a new physical transaction begins and +before a new announcement attempt; a failed/canceled attempt cannot retain +earlier authority. No new session-variable setter is added. + +Producer frames capture that generation. Enqueue reads the live snapshot from +its own unit and compares both generation and event tenant, refusing stale +frames even if Npgsql reuses a transaction object, empty/sentinel tenants and +mismatches. It accepts no caller-supplied snapshot as authority. Provisioning +uses its already-sanctioned announcement without populating +`ITenantContextAccessor`; ADR-0036's context-writer set is unchanged. + +The envelope keeps the announced organization. Correlation, causation and the +optional causal actor come from the trusted execution boundary, not SQL session +variables or arbitrary producer-supplied metadata. An organization-scoped event +requires that organization; a non-marked event does not silently erase an +announced organization. G1's eventual delivery scope decision remains +P02b-2/P02b-3. Effective asynchronous execution uses `UserId.SystemActor`, +without impersonating the causal human. + +The producer execution boundary guarantees a W3C Activity even when telemetry +has no listener, as in the Seeder. Enqueue stores its full traceparent. Domain +translation allocates a new integration `EventId` and uses the originating domain +`EventId` as causation; future delivery-originated production uses the trusted +incoming event identity. No caller-supplied header establishes causation. + +The optional `outbox_messages.metadata` JSONB column is not a producer extension +point: `IOutbox` exposes no arbitrary metadata argument and writes SQL NULL. +This does not suppress the named envelope columns above. Existing stored rows +are not rewritten. Any future use of JSON metadata needs its own accepted +contract for allowed fields, minimization and size; it cannot bypass the payload +policy through a second JSON object. + +Beyond the existing event identity, tenant, timestamp and computed routing +members, payload fields are identifiers only: GUIDs or approved strongly-typed +identifier representations, optionally nullable. Arbitrary text, names, email, +DTOs, collections, binary data, raw JSON and custom serializer overrides are +excluded. A module consumer resolves needed state through owner contracts. +Identifiers can still reference people; this is data minimization, not a claim +that payloads contain no personal data. G18's error descriptors and durable-store +erasure inventory remain P02b-2 and P02b-8 respectively. + +### Collection, completion and nested refusals + +A shared SaveChanges interceptor stages domain events and promotes them only +after successful persistence. A failed/canceled save promotes nothing. Both +API and Seeder compositions install it. The collector preserves source/frame +attribution and the transaction generation; detach/delete and repeated saves +must neither lose nor duplicate an event. + +Only the transaction owner completes this sequence: + +1. Refuse a failed, read-only-with-produced-work or rollback-only unit. +2. Drain successfully collected domain events sequentially, at one dispatch + site, before audit composition. At most 256 domain events belong to one + transaction; exceeding the bound fails the whole unit. +3. Check producer invariants, then write pending MUST audit and commit. +4. Report committed, rolled-back or indeterminate audit durability accurately, + reconcile at the outer audit frame, and clear execution state exactly once. + +Domain-event handlers may translate facts into outbox messages only. They may +not mutate roots, use write stores, send nested requests, raise further domain +events, publish notifications themselves or invoke external providers. Unexpected +enqueue/contract faults throw and roll back; expected business decisions stay +in commands or later integration consumers. The 256 bound is a total count, +not recursive cascade support. + +Each producer invocation has a frame with a sticky produced-work receipt. A +successful outbox INSERT or promotion after a successful save records that +receipt in the frame **and all its ancestors**. A `Result.Fail` from any such +frame marks the shared transaction rollback-only **before** its UoW frame is +resolved, even if an ancestor later absorbs the result. Queue drain or cleanup +cannot erase the receipt. Joining never creates a savepoint. + +Reads, ordinary event-free refusals, uncollected raises, unsuccessful enqueue +attempts and failed saves do not themselves trigger this additional rule. +Exceptions and unknown database outcomes retain their existing failure posture. +This is a producer consistency guarantee; event-free nested mutations retain +ADR-0040's existing absorbable-failure semantics. + +Success, refusal, rollback, cancellation, fault and disposal discard collector +state and pending source lists, preventing replay in another transaction using +the same DI scope. Joiners neither drain nor clear the owner's whole queue. +`OutboxFlushBehavior` keeps step 7's position as an enrollment/invariant guard; +it is not a buffered flush or the domain-event dispatch site. + +### Audit ownership outside MediatR + +A reusable execution coordinator exposes the audit-frame lifecycle currently +owned by `AuditLogBehavior`; requests preserve their existing classification and +outcome rules. A future delivery owner opens it before handler construction, +classifies the handler type, and refuses an unregistered mutating handler. +Direct one-root writes declare that handler's intents; nested commands retain +their own intents without an extra duplicate business row. + +The transaction owner drains events, writes MUST rows, and reports the commit +boundary. Only the outermost audit frame reconciles and clears the capture. +Producer invocation frames remain distinct from audit ownership: each nested +request needs a receipt, while only the owner reconciles. P02b-1 proves the +non-MediatR owner through a controlled fixture; production transport wiring and +inbox atomicity belong to P02b-3. There is no second audit writer in a module. + +### First useful production flow + +Tenancy publishes `TenantCreatedIntegrationEventV1` on +`learnstack.tenancy.tenant` when `ProvisionTenantCommand` completes the tenant, +default organization and back-reference. This is completed provisioning, not +the first intermediate tenant INSERT. Its partition source is `TenantId`, its +payload needs no extra fields, and its envelope organization is null. +The `IntegrationEventV` suffix follows the existing +[event-authoring convention](../../.claude/skills/add-integration-event/SKILL.md); +unversioned illustrative names in older documents do not introduce a second +convention for new event types. + +P02b-3 implements two independent Customization subscriptions: one initializes +`card@1`, the other `plain@1`, the defaults already declared by +`BuiltInCustomizations`. Each business effect owns one root and its generation +change; its inbox outcome and MUST audit must share the delivery transaction. +One may finish before the other; retry/recovery resolves an incomplete pair. +This fixes the two business effects and independent subscriptions, not G1's +inbox table, key, claim owner or organization-delivery policy. P02b-2 must accept +G1, including distinct subscription deduplication identities so one consumer +cannot suppress its sibling, and P02b-3 must reconcile its delivery/audit +contracts before these consumers are implemented. An incompatible G1 choice +reopens this flow's contract before consumer code. ADR-0042's provisioning +exception is unchanged; no cross-module write enters the producer transaction. + +Initialization is create-only for an untouched built-in concept. Existing +Draft, Active, Deprecated, successor or deliberately deleted definitions are +preserved, never overwritten, republished or resurrected. Customization owns +new UUIDv7 IDs and semantic key/version identity; Tenancy carries neither schema +nor Customization IDs. + +P02b-3 also owns the Seeder handoff: discover/verify those built-ins through +module contracts rather than requiring demo-fixed IDs, preserve existing IDs, +retain exact declared IDs for tenant-authored curriculum, and prove concurrent +fresh seeding/delivery convergence. Until then P02b-1 keeps today's seed writes +and emits pending outbox rows; it claims no automatic initialization delivery. +Settings/cache invalidation and an asynchronous Audit mirror are not the sample: +typed settings are uncached, definition readers probe fresh generations, and +source MUST audit already commits with the producer. + +## Context + +[P02b-0](../roadmap/phase-02b-events-auth.md#p02b-0-entry-verification-2026-10-12) +re-verified an existing outbox table and transport seam, but no enqueue port, +collector, domain publisher or useful production consumer. ADR-0032's step-7 +target predates an implemented producer. It cannot own completion for direct +handlers or events dispatched after a command returns. +[The existing provisioning seam](../../backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs) +announces a tenant only on the transaction; the Seeder has no guaranteed Activity. +Reading ambient HTTP context alone would reject its event. The new read seam +makes that announcement observable without another authority writer. + +The immediate-row choice avoids a second durable buffer. Reusing EventId avoids +two identities and a migration without losing a replay key. Restricting domain +reactions protects returned versions and the one-root write boundary. Automatic +poisoning is deliberately limited to produced work; a general redesign of +nested command failures is outside this decision. + +The 256 KiB payload ceiling is an explicit producer budget selected here. The +existing customization-row cap supplies a comparable conservative size, not a +shared schema rule. Identifier-only payloads should remain far smaller; evidence +that a useful event cannot fit requires reconsidering its contract before +raising the budget or copying state into it. + +### What would reopen this decision + +The sample changes default-installation orchestration, not the customization +model. If defaults cease to be a product requirement, G19 needs a replacement +useful subscriber before P02b-3 closes; a test-only or no-op consumer is not one. +An incompatible G1 delivery choice likewise reopens the consumer contract. +If the shared lifecycle cannot prove successful-save attribution, ancestor +receipts or terminal cleanup, producer implementation must revisit the design +rather than weaken the rollback guarantee. A future need for domain reactions +to write roots requires a new decision about versions and aggregate boundaries. + +### Explicitly deferred ownership + +P02b-2 owns leases, ordering, type-resolution failures and G1's inbox decision; +P02b-3 owns its concrete consumer/audit implementation and the Seeder handoff. +P02b-8 owns the durable-store erasure inventory. This record adds no broker, +worker, job runtime, cache invalidation or new Hub crossing. + +## Consequences + +- Source state, integration fact and MUST audit share one commit boundary. +- Refused producer work cannot escape through an absorbing outer handler. +- Domain reactions have a deliberately narrow capability set; substantive + reactions run asynchronously and must tolerate retries and partial readiness. +- Producer memory and payload size are bounded. Exceeding a bound fails the + operation rather than silently dropping a fact. +- P02b-3 gains real default initialization and a substantive Seeder handoff; + P02b-1 does not claim consumer or dispatcher completeness. + +## Implementation Notes + +This acceptance precedes runtime code. The catalogue registers the canonical +proofs below as Registered, not Implemented; the phase package records the +P02b-1 gate vehicles and their split ownership. The instruments are distinct: + +> **Erratum — 2026-10-12.** The table's `Architecture Tests table` locator is +> false: ADR-0032's second separate `event_id` requirement is in the Phase 02b +> deliverables table in Implementation Notes, verified at acceptance commit +> `b157650`. [ADR-0032 Amendment 4](0032-exception-handling-logging-and-observability.md#amendment-4--bounded-producer-supersession-by-adr-0057-2026-10-12) +> names that same location. The bounded supersession and Decision are unchanged. +> Recorded in Amendment 2. + +| Affected contract | Acceptance instrument | Boundary | +|---|---|---| +| ADR-0032 Sub-decision 2, step 7 | Bounded supersession by this new ADR | Owner-side completion replaces the step-7 flush/publication role; the pipeline position survives as an invariant guard | +| ADR-0032 Sub-decision 12 and Architecture Tests table, `event_id` | Bounded supersession by this new ADR | `id = EventId` replaces the separate column requirement in both locations; it is not asserted to have been false at acceptance | +| ADR-0040 § Nesting and Amendment 2 | Bounded supersession by this new ADR | Absorbed failure with frame/descendant produced work poisons the unit; ordinary event-free refusals retain their prior meaning | +| ADR-0040's enumerated seam | Additive, dated seam amendment citing this ADR | Name `GetTenantContextAnnouncement` and its immutable result, lifecycle and generation checks, as Amendment 5 named its boolean reader; no setter or ownership change | +| ADR-0038 producer payload/metadata policy | Additional policy decided by this ADR, with a dated cross-reference | Identifier-only payloads, application byte cap and NULL JSON metadata are new obligations; the envelope, frozen serializer and transport contracts survive | +| ADR-0044 / ADR-0033 common audit entry | Dated clarifying amendments citing this ADR | Non-MediatR owners perform existing classification, MUST-write and outer reconciliation duties; no durability/classification change | + +Append dated supersession disclosures in ADR-0032 and ADR-0040 linking the exact +replaced clauses to this ADR, following the bounded-supersession precedent in +[ADR-0054](0054-bounded-public-renderer-admission.md). The disclosures provide +navigation; they do not supply the changed decision. Keep all original bodies, +acceptance metadata and earlier amendments intact. ADR-0040's seam addition is +recorded separately from its nested-failure supersession. None of these changes +uses ADR-0041's false-at-entry erratum or in-place correction instruments. + +Update architecture, Backend/Database/Observability/Audit/Infrastructure +standards, the phase's domain-event target sketch, glossary, +Tenancy/Customization/Audit specs and relevant workflow skills with those +approved contracts. Align the event skill's name input with its existing +`EnrollmentCreatedIntegrationEventV1` example (aggregate then past-tense fact), +as well as its producer payload guidance. Do not add lease/inbox tables, +dispatcher policies, job runtime, auth, Hub endpoints or provider adapters in +P02b-1. +P02b-3 also updates `BuiltInCustomizations.cs`'s creator/Seeder/Hub remarks with +the accepted subscription owner and seed handoff when that delivery exists; +P02b-1 must not describe those consumers as already implemented. + +## Architecture Tests + +These are **Registered obligations**, not implemented or passing tests. The +[catalogue](../standards/21-architecture-tests-catalogue.md#events-jobs-and-correlation) +owns their names, status, kind and enforcing tests. Existing entries retain event +naming/serialization, partition derivation and row correlation; the five additions +cover obligations those entries do not already cover: + +- `Outbox_Enqueue_Uses_The_Announced_Transaction` — behavioral: resolved and + unresolved provisioning, mismatch/read-only/stale frame refusals, same role, + connection and transaction, no-listener trace context and causal metadata; + complete canonical UTF-8 payload at/over the inclusive byte bound, multibyte + accounting and SQL NULL JSON metadata. Snapshot proofs cover failed setup, + foreign/reused transaction objects, joins and every terminal boundary. +- `Producer_Failures_Poison_Produced_Work` — behavioral: direct and descendant + receipts survive drain; absorbed refusal cannot commit; event-free control + still commits; failed saves do not promote and stale source lists do not replay. +- `Domain_Event_Completion_Is_Owner_Only` — behavioral: FIFO, repeated saves, + rollback/cancellation, count bound, response version stability and MUST audit + failure all preserve atomicity; controlled direct owner and nested joiners. +- `Domain_Event_Handlers_Are_Outbox_Only` — structural: complete module census, + helper/interface traversal, planted forbidden write/send/publish/provider + paths and clean translation control. +- `Integration_Event_Payloads_Contain_Only_Identifiers` — structural plus + behavioral: production subjects, disallowed members/serializer overrides + and runtime-type JSON round-trip. Extend + `Integration_Event_Declares_PartitionKey` for the declared source/derivation + and `Outbox_Enqueue_Uses_The_Announced_Transaction` for rollback/atomicity. + +## Amendments + +### Amendment 1 — Producer implementation and enforcement (2026-10-12) + +P02b-1 implements this decision's producer scope. Steps 1–3 deliver the +transaction announcement snapshot, shared producer/audit lifecycle, immediate +outbox INSERT, successful-save collection, owner-only completion and the +completed-provisioning publisher in both API and Seeder compositions. Step 4 +adds complete production censuses with dirty/clean controls and promotes eight +producer rules in the +[catalogue](../standards/21-architecture-tests-catalogue.md#events-jobs-and-correlation). +The acceptance-time Registered statements above remain historical commitments; +the catalogue now records their implementing tests. + +The [Step 4 delivery record](../roadmap/phase-02b-events-auth.md#p02b-1-step-4--producer-enforcement-and-closeout-2026-10-12) +owns final validation, independent reviews and packet status. This delivery note +changes no decision or original acceptance metadata. Dispatcher/lease work +remains P02b-2; production inbox/subscriptions and the Seeder identity handoff +remain P02b-3 after G1. Pending rows are not a claim of delivered consumption. + +### Amendment 2 — Correct ADR-0032 clause locators (2026-10-12) + +The bounded-supersession summary and Implementation Notes table named an +`Architecture Tests` section/table in ADR-0032. That section does not exist. +`git show b157650:docs/decisions/0032-exception-handling-logging-and-observability.md` +shows the second separate `event_id` requirement under **Implementation Notes → +Phase 02b deliverables that flow from this ADR**, alongside Sub-decision 12's +requirement. The locator was false when this ADR was accepted at `b157650`; +ADR-0032's own Amendment 4 already identifies the correct table. + +Under [ADR-0041](0041-correcting-false-statements-in-accepted-adrs.md), inline +errata precede both affected spans; the first sits before the protected Status +section so that the entire original section, Date and Deciders remain intact. +The accepted body wording is retained. The mutable carriers corrected are +[Phase 02b](../roadmap/phase-02b-events-auth.md#the-decision-register)'s G5 +register row and +[accepted G5 answer](../roadmap/phase-02b-events-auth.md#p02b-1-decision-package-2026-10-12), +plus this amendment's entry in the [ADR index](README.md). +These are all carriers changed for this locator correction. The Decision and +the exact bounded supersession are unchanged: the two historical separate-column +requirements yield to `outbox_messages.id = EventId`; all other clauses survive. + +## References + +- [ADR-0006: Events and Outbox](0006-events-and-outbox.md) +- [ADR-0010: Cross-Module Communication](0010-cross-module-communication.md) +- [ADR-0023: Strongly-Typed IDs](0023-strongly-typed-id-source-generator.md) +- [ADR-0032: Exception Handling, Logging and Observability](0032-exception-handling-logging-and-observability.md) +- [ADR-0033: Audit Durability](0033-audit-durability-model.md) +- [ADR-0036: Trusted Tenant Resolution](0036-tenant-resolution-trusted-inputs.md) +- [ADR-0038: Cross-Cutting Ports and Events](0038-cross-cutting-port-and-event-contracts.md) +- [ADR-0040: Ambient Unit of Work](0040-ambient-unit-of-work.md) +- [ADR-0041: Correcting Accepted ADRs](0041-correcting-false-statements-in-accepted-adrs.md) +- [ADR-0042: Bounded Tenant Provisioning](0042-tenant-provisioning-cross-aggregate-transaction.md) +- [ADR-0043: Customization Validation and Generations](0043-customization-payload-validation.md) +- [ADR-0044: Audit Write Path](0044-audit-write-path.md) +- [Events and Outbox](../architecture/15-event-and-outbox.md) +- [Documentation Standards: Correcting and Amending ADRs](../standards/13-documentation.md#correcting-and-amending-adrs) +- [Phase 02b](../roadmap/phase-02b-events-auth.md) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index edc108fa..69211ef6 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -18,6 +18,11 @@ Status is preserved under [Documentation Standards § Correcting and Amending ADRs](../standards/13-documentation.md#correcting-and-amending-adrs); an amendment is not a lifecycle status change. +[ADR-0057](0057-transactional-event-production-and-completion.md) makes bounded +supersessions of named ADR-0032 and ADR-0040 clauses. Their original Accepted +records remain intact; the linked disclosures below identify the replaced +clauses and preserve the unaffected authority. + ## Active ADRs | # | Title | Topic | @@ -51,19 +56,19 @@ an amendment is not a lifecycle status change. | 0029 | [Object Storage — SeaweedFS](0029-object-storage-seaweedfs.md) | Self-hosted SeaweedFS behind the existing `IStorageProvider` S3 contract; partially supersedes ADR-0002's MinIO row | | 0030 | [Redis-compatible Store — Valkey](0030-redis-compatible-store-valkey.md) | Valkey (Linux Foundation, BSD-3-Clause) for the cache + Dapr state-store backend; RESP-protocol drop-in; partially supersedes ADR-0002's Redis row | | 0031 | [PostgreSQL — Start on 18.x](0031-postgresql-major-version.md) | Pin primary RDBMS major version to PostgreSQL 18; native `uuidv7()` + async I/O + longest LTS runway; partially supersedes ADR-0002's PostgreSQL row | -| 0032 | [Exception Handling, Logging, and Observability](0032-exception-handling-logging-and-observability.md) | `IExceptionHandler` + 8-step MediatR pipeline + `Result.Fail`-only validation + `DomainException`-is-bug discipline + `IProviderResilience` (Polly v8) + Sentry vs OTel error capture boundary + Serilog primary + `TenantContextSpanProcessor` + `IErrorTrackingProvider` deployment-mode branching | -| 0033 | [Audit Durability Model](0033-audit-durability-model.md) | **Supersedes ADR-0016.** MUST-class audit written as durable intent inside the business transaction (fails closed); SHOULD/MAY-class stays best-effort; corrected partitioned `audit_log` primary key; `AuditConfig` cannot remove baseline MUST coverage. **Amendment 1 (2026-08-18):** a standalone MUST write failure changes the response only when the operation would otherwise have succeeded. **Amendment 2 (2026-09-07)** meets the code that shipped after it: intents are **plural**, one per audited resource, because one command already writes two; only the **owning** unit-of-work frame writes them and reports the commit boundary, so a nested `Send` cannot claim durability nothing committed; `outcome` closes at four values and the writer supplies `timestamp`, which is what keeps the deliberate duplicate-id pair legal under `(id, timestamp)`; both standalone writers announce **two** session variables, measured — an org-scoped `denied` row is refused without the second; a fourth write method serves `EnterPlatformAdminScope`; and a read-sensitive query **does** reach step 6, since Packet 6's `TransactionBehavior` has no request-kind gate. Decided by [ADR-0044](0044-audit-write-path.md). **Amendment 3 (2026-09-08)** gives the audit health check an owner and demand-gates the deployment-level stop-serving backstop to Phase 11 with a written trigger: stopping a process is the one operational act the next request cannot undo, and Phase 02a has neither a readiness surface nor an operator to answer the page it would raise. **Amendment 4 (2026-09-08):** a tenant override narrows and never elevates, a cancelled `COMMIT` is `Indeterminate`, and the override loader runs on a cache miss. **Amendment 5 (2026-09-11):** `EnterPlatformAdminScope` never reaches `WriteStandaloneAsync`, and a nested request's refusal outlives the commit it joined. **Amendment 6 (2026-09-11):** the audit health check answers only whether a MUST-class row can be written; a tenant-override read failure is its `Error` line alone. **Amendment 7 (2026-09-11):** a request refused at step 1 writes no audit row — validation runs before the audit step classifies anything — so the standalone path is reached by a short-circuit at step 4 or 5, the assertion recorder and the reconcile | +| 0032 | [Exception Handling, Logging, and Observability](0032-exception-handling-logging-and-observability.md) | `IExceptionHandler` + 8-step MediatR pipeline + `Result.Fail`-only validation + `DomainException`-is-bug discipline + `IProviderResilience` (Polly v8) + Sentry vs OTel error capture boundary + Serilog primary + `TenantContextSpanProcessor` + `IErrorTrackingProvider` deployment-mode branching. [Amendment 4 (2026-10-12)](0032-exception-handling-logging-and-observability.md#amendment-4--bounded-producer-supersession-by-adr-0057-2026-10-12) discloses ADR-0057's bounded supersession of step-7 completion and the two separate `event_id` requirements; other clauses remain Accepted. | +| 0033 | [Audit Durability Model](0033-audit-durability-model.md) | **Supersedes ADR-0016.** MUST-class audit written as durable intent inside the business transaction (fails closed); SHOULD/MAY-class stays best-effort; corrected partitioned `audit_log` primary key; `AuditConfig` cannot remove baseline MUST coverage. **Amendment 1 (2026-08-18):** a standalone MUST write failure changes the response only when the operation would otherwise have succeeded. **Amendment 2 (2026-09-07)** meets the code that shipped after it: intents are **plural**, one per audited resource, because one command already writes two; only the **owning** unit-of-work frame writes them and reports the commit boundary, so a nested `Send` cannot claim durability nothing committed; `outcome` closes at four values and the writer supplies `timestamp`, which is what keeps the deliberate duplicate-id pair legal under `(id, timestamp)`; both standalone writers announce **two** session variables, measured — an org-scoped `denied` row is refused without the second; a fourth write method serves `EnterPlatformAdminScope`; and a read-sensitive query **does** reach step 6, since Packet 6's `TransactionBehavior` has no request-kind gate. Decided by [ADR-0044](0044-audit-write-path.md). **Amendment 3 (2026-09-08)** gives the audit health check an owner and demand-gates the deployment-level stop-serving backstop to Phase 11 with a written trigger: stopping a process is the one operational act the next request cannot undo, and Phase 02a has neither a readiness surface nor an operator to answer the page it would raise. **Amendment 4 (2026-09-08):** a tenant override narrows and never elevates, a cancelled `COMMIT` is `Indeterminate`, and the override loader runs on a cache miss. **Amendment 5 (2026-09-11):** `EnterPlatformAdminScope` never reaches `WriteStandaloneAsync`, and a nested request's refusal outlives the commit it joined. **Amendment 6 (2026-09-11):** the audit health check answers only whether a MUST-class row can be written; a tenant-override read failure is its `Error` line alone. **Amendment 7 (2026-09-11):** a request refused at step 1 writes no audit row — validation runs before the audit step classifies anything — so the standalone path is reached by a short-circuit at step 4 or 5, the assertion recorder and the reconcile. [Amendment 8 (2026-10-12)](0033-audit-durability-model.md#amendment-8--common-audit-ownership-outside-mediatr-2026-10-12) clarifies common audit ownership outside MediatR under ADR-0057; durability and classification contracts survive. | | 0034 | [Hub Contract Surface Invariant](0034-hub-contract-surface-invariant.md) | Replaces "closed at four endpoints" with two enforceable invariants (Hub stores no tenant content; every crossing goes through a named adapter); enumerates the real endpoint set; TLS key material leaves the entitlement payload; host resolution never calls the Hub | | 0035 | [Demand-Gated Infrastructure](0035-demand-gated-infrastructure.md) | The one-way-door test; ports + default implementations ship now, vendor adapters ship on a named trigger in a named phase; uncontracted deployment modes may not decide technical choices | | 0036 | [Trusted Inputs for Tenant and Organization Resolution](0036-tenant-resolution-trusted-inputs.md) | Resolution by **agreement, not priority** — every authoritative signal present is resolved independently and the request proceeds only on their intersection; no request header names a tenant or an organization, one header names a **host** over an authenticated hop and LearnStack still resolves it itself; `TenantContextOrigin` caps a host-only context to the `[PublicSurface]` read set; the platform-admin override leaves the resolution model | | 0037 | [What an Idempotency Key Identifies, Owns, and Replays](0037-idempotency-key-contract.md) | A client-chosen key is a **nonce inside a tenant's key space**, not an identity: `(tenant, key)` addresses the record and a fingerprint over organization, principal, method, path, query and body decides whether replaying it answers the question asked; a fencing token owns the claim; capacity is **admission, not eviction**, so nothing unexpired is ever displaced; the guarantee is at-most-once while a claim is live and at-least-once across process death | -| 0038 | [Cross-Cutting Port and Event Contracts](0038-cross-cutting-port-and-event-contracts.md) | **Supersedes ADR-0014.** Retains Dapr behind demand gates; fixes the event envelope, handler isolation, trace/audit scope and cache contracts | +| 0038 | [Cross-Cutting Port and Event Contracts](0038-cross-cutting-port-and-event-contracts.md) | **Supersedes ADR-0014.** Retains Dapr behind demand gates; fixes the event envelope, handler isolation, trace/audit scope and cache contracts. [Amendment 2 (2026-10-12)](0038-cross-cutting-port-and-event-contracts.md#amendment-2--additional-producer-payload-policy-2026-10-12) links ADR-0057's additional producer payload policy; original envelope, serializer and transport contracts survive. | | 0039 | [The Optimistic Concurrency Token](0039-optimistic-concurrency-token.md) | An explicit `row_version bigint` (CLR `long`), project-wide, incremented inside `AuditableEntity` by the single primitive `MarkUpdated` and `SoftDelete` both route through, and mapped with `HasDefaultValue(0L).IsConcurrencyToken().ValueGeneratedNever()` — exactly those three calls (Amendment 2): `ValueGeneratedOnAddOrUpdate()` / `IsRowVersion()` make EF omit the column from the `UPDATE` entirely (Amendment 1, measured), and `HasDefaultValue` alone leaves `ValueGenerated` at `OnAdd`, which the registered rule rejects; `xmin` is rejected because the token is client-visible through ETag / `If-Match` and a dump-restore or logical-replication cutover changes it — measured, unlike the widely-cited `VACUUM FREEZE` objection, which does not | -| 0040 | [The Ambient Unit of Work](0040-ambient-unit-of-work.md) | One `DbConnection` per scope, owned by `IUnitOfWork`; every module `DbContext`, `IAuditStore` and `IOutbox` enlists on it, because `SET LOCAL` is connection-local and a context on its own connection reads **zero rows** under the corrected RLS policy. Spans one aggregate's write plus audit plus outbox plus any reads — cross-aggregate writes stay forbidden, with the one enumerated exception [ADR-0042](0042-tenant-provisioning-cross-aggregate-transaction.md) carves for tenant provisioning (Amendment 4, 2026-08-30). Defines nesting, disposal, and the event-consumer entry point that never reaches MediatR. The closed set of `app.tenant_id` setters is seven by Amendment 3 (2026-08-30) and eight by **Amendment 7 (2026-09-11)**, which adds the tenant-flag projection loader `IFeatureFlags` fills on a cache miss | +| 0040 | [The Ambient Unit of Work](0040-ambient-unit-of-work.md) | One `DbConnection` per scope, owned by `IUnitOfWork`; every module `DbContext`, `IAuditStore` and `IOutbox` enlists on it, because `SET LOCAL` is connection-local and a context on its own connection reads **zero rows** under the corrected RLS policy. Spans one aggregate's write plus audit plus outbox plus any reads — cross-aggregate writes stay forbidden, with the one enumerated exception [ADR-0042](0042-tenant-provisioning-cross-aggregate-transaction.md) carves for tenant provisioning (Amendment 4, 2026-08-30). Defines nesting, disposal, and the event-consumer entry point that never reaches MediatR. The closed set of `app.tenant_id` setters is seven by Amendment 3 (2026-08-30) and eight by **Amendment 7 (2026-09-11)**, which adds the tenant-flag projection loader `IFeatureFlags` fills on a cache miss. [Amendment 10 (2026-10-12)](0040-ambient-unit-of-work.md#amendment-10--bounded-produced-work-refusal-supersession-2026-10-12) discloses ADR-0057's bounded produced-work refusal supersession; [Amendment 11 (2026-10-12)](0040-ambient-unit-of-work.md#amendment-11--named-transaction-announcement-snapshot-reader-2026-10-12) separately adds the named read-only announcement snapshot seam. | | 0041 | [Correcting False Statements in Accepted ADRs](0041-correcting-false-statements-in-accepted-adrs.md) | Inline erratum beside the false text is the default; in-place replacement only where the text is a **canonical artifact for reuse** — a template others are told to copy, a DDL or command meant to be applied — never merely because something could be copied, and never because a non-ADR carrier holds the same token. Bounded to statements false **when they entered the record**, judged per statement: original body at the acceptance commit, amendment text at that amendment's date. A statement that has since gone stale is history, and gets an amendment or a superseding ADR. Every changed Accepted ADR carries its own dated Amendment | | 0042 | [Tenant Provisioning as a Bounded Cross-Aggregate Transaction](0042-tenant-provisioning-cross-aggregate-transaction.md) | A standing, **enumerated** exception to § Aggregate Ownership: tenant provisioning writes `Tenant` and its default `Organization` in one transaction, because `tenants.default_organization_id` carries an invariant an integration event cannot deliver — the substitute moves the second write to a later transaction and the window between them is exactly what the invariant forbids. Bounded by enumeration rather than by principle: two roots, one operation, a literal allow-list of one in the architecture test. Covers no child entity, no projection, and no cross-**module** write, which stays forbidden with no exception | | 0043 | [What LearnStack Does With a Tenant-Authored Payload](0043-customization-payload-validation.md) | JSON Schema draft 2020-12, evaluated by `JsonSchema.Net` **pinned at 8.0.5** (the last MIT-expression release) behind an `IJsonSchemaValidator` port. A tenant document passes **four ordered gates** — JSON, the LearnStack profile, the 2020-12 meta-schema, then the build — because the library's own defaults keep none of the rules the corpus states: its default dialect is not 2020-12, a tenant's `$schema` line overrides the pinned one, a tenant's `$id` becomes process-global state (first-writer-wins, a cross-tenant denial of service), and `true` / `false` / `{}` are all valid schemas that switch validation off. `pattern` is refused — the built `Regex` carries an infinite `MatchTimeout` and `maxLength` does not short-circuit it (measured, 30.2 s on one property). **No compiled-validator cache**: compiling is measured *cheaper* than evaluating (0.5× at the 100-property ceiling), so § 8.2's cache is deleted rather than re-keyed. Rule bodies are opaque `text` with a `dialect` discriminator naming the language of their `condition` expressions. Amendments 1–2 (2026-09-04/05) refuse **every** `$ref` cycle and bound the reference graph; Amendment 3 (2026-09-06) seals the port's exception surface and makes `ValidateInstance` enforce § 8.4's entry cap; **Amendment 4 (2026-09-07) refuses what the `jsonb` column refuses** — `U+0000`, an unpaired surrogate, a number outside `numeric`; **Amendment 5 (2026-10-02) adds ADR-0051’s optional ordered text-card profile** | -| 0044 | [The Audit Write Path](0044-audit-write-path.md) | **Amendments 1–5 (2026-09-08), 6–7 (2026-09-11).** The eleven questions ADR-0033 left open, decided before the first audit file: the reserved **non-nil** platform sentinel tenant id (the nil uuid is refused by three shipped guards that read all-zero as *no tenant*), and a `tenants` CHECK that keeps it unprovisionable; the row's `tenant_id` is the tenant the **transaction announced**, so a provisioning command audits under the tenant it creates and `make seed` still commits; **N intents per request**, one per audited resource, because two shipped commands already write two — and only the **owning** unit-of-work frame writes them or reports the commit boundary, which is what stops a nested `Send` from claiming durability nothing committed; `outcome` closes at four values (`indeterminate` included) and the writer always supplies `timestamp`, so ADR-0033's deliberate duplicate-id pair is legal under `(id, timestamp)` rather than a `23505`; the catalogue is **code** keyed by request type and the matrix is prose, with a test asserting they agree, and every request must be classified — there is no `RequestKind.Other`; the interceptor captures **every** tracked entity minus a named exclusion list, because the `AuditableEntity<>` predicate was blind to `platform_host_to_tenant`; `[PiiSensitive]` and an elision record that preserves the column's JSON type; `audit_log` is org-scoped, carries **no** FK to `tenants`, and both standalone writers announce **two** GUCs — measured: an org-scoped `denied` row is refused without the second. Amendment 1 makes `audit_config` tenant-**wide**; Amendment 2 corrects the redaction sentinel to the shipped constant and the reason `FORCE` needs a trigger (the policy binds the owner by tenant, not by immutability — measured); Amendment 3 gives the matrix↔catalogue join two directions with two domains, because 23 of 30 shipped matrix slugs have no request type and the rule as written was red on arrival, and moves the audit value types into `SharedKernel.Audit` to break the same project cycle `AuditEntryId` had; Amendment 4 makes `[PiiSensitive]` property-granular, `jsonb` included, ships `audit_config` unwritten and binds the matrix rule to modules with code; Amendment 5 guards the sentinel where it is announced and fills `entity_type` / `entity_id` from the declared aggregate; Amendment 6 — from an external review that reproduced four defects against real PostgreSQL — has a handler designate the subject when it writes two instances of its aggregate, carries the actor and correlation id on the intent, keeps each intent's own request's result, captures a contained entity with its aggregate, and instance-qualifies `changes` pointers; Amendment 7 corrects § 7's count of plain entities — seven today, five of them MUST — and names the Tenancy matrix alone as the one that singles out the host mapping | +| 0044 | [The Audit Write Path](0044-audit-write-path.md) | **Amendments 1–5 (2026-09-08), 6–7 (2026-09-11).** The eleven questions ADR-0033 left open, decided before the first audit file: the reserved **non-nil** platform sentinel tenant id (the nil uuid is refused by three shipped guards that read all-zero as *no tenant*), and a `tenants` CHECK that keeps it unprovisionable; the row's `tenant_id` is the tenant the **transaction announced**, so a provisioning command audits under the tenant it creates and `make seed` still commits; **N intents per request**, one per audited resource, because two shipped commands already write two — and only the **owning** unit-of-work frame writes them or reports the commit boundary, which is what stops a nested `Send` from claiming durability nothing committed; `outcome` closes at four values (`indeterminate` included) and the writer always supplies `timestamp`, so ADR-0033's deliberate duplicate-id pair is legal under `(id, timestamp)` rather than a `23505`; the catalogue is **code** keyed by request type and the matrix is prose, with a test asserting they agree, and every request must be classified — there is no `RequestKind.Other`; the interceptor captures **every** tracked entity minus a named exclusion list, because the `AuditableEntity<>` predicate was blind to `platform_host_to_tenant`; `[PiiSensitive]` and an elision record that preserves the column's JSON type; `audit_log` is org-scoped, carries **no** FK to `tenants`, and both standalone writers announce **two** GUCs — measured: an org-scoped `denied` row is refused without the second. Amendment 1 makes `audit_config` tenant-**wide**; Amendment 2 corrects the redaction sentinel to the shipped constant and the reason `FORCE` needs a trigger (the policy binds the owner by tenant, not by immutability — measured); Amendment 3 gives the matrix↔catalogue join two directions with two domains, because 23 of 30 shipped matrix slugs have no request type and the rule as written was red on arrival, and moves the audit value types into `SharedKernel.Audit` to break the same project cycle `AuditEntryId` had; Amendment 4 makes `[PiiSensitive]` property-granular, `jsonb` included, ships `audit_config` unwritten and binds the matrix rule to modules with code; Amendment 5 guards the sentinel where it is announced and fills `entity_type` / `entity_id` from the declared aggregate; Amendment 6 — from an external review that reproduced four defects against real PostgreSQL — has a handler designate the subject when it writes two instances of its aggregate, carries the actor and correlation id on the intent, keeps each intent's own request's result, captures a contained entity with its aggregate, and instance-qualifies `changes` pointers; Amendment 7 corrects § 7's count of plain entities — seven today, five of them MUST — and names the Tenancy matrix alone as the one that singles out the host mapping. [Amendment 8 (2026-10-12)](0044-audit-write-path.md#amendment-8--common-audit-ownership-outside-mediatr-2026-10-12) clarifies common audit ownership outside MediatR under ADR-0057 without adding an audit writer. | | 0045 | [The Entitlement and Feature-Flag Socket](0045-entitlement-and-feature-flag-socket.md) | **Amendment 1 (2026-09-08)** reads it against the Hub's merged code: the limit vocabulary is the Hub's, `expires_at`/`valid_until` are nullable, the generation guard admits the equal case, and `platform_killswitches` ships **unwritten** because `DenyAllPlatformAdminGate` makes a writer unreachable until Phase 03. **Amendment 2 (2026-09-11):** a registry's membership is the vocabulary the contract names — fourteen Hub features, nine `limits.*`, two tenant flags, three killswitches — and enforcement, not membership, waits for a consumer. **Amendment 3 (2026-09-11):** a key's fail-open/fail-closed class decides only when no projection exists; one past its grace window is read-only, as ADR-0021 decides. **Amendment 4 (2026-09-11):** of the projection's names, `PlanCode` differs from the wire (`tier`) and `ExpiresAt` from the column (`valid_until`) — § 1 had called both wire differences. Declares the port twenty documents name and none define. `IEntitlementProvider.GetAsync(TenantId)` + `RefreshAsync(projection)`, the projection carrying every field `entitlement-v1.schema.json` requires — **`compliance` and `generation` included**, both already `NOT NULL` columns — and the refresh **generation-guarded inside the write statement**, so a stale push cannot resurrect a revoked plan. `IFeatureFlags` is the only module-facing read and **composes over the provider** rather than querying `platform_entitlement_cache`, which is the only reading under which the Phase 02a criterion — swapping the provider changes the answer — can be true. Limits: **`-1` unlimited, `0` denied**, `long` not `long?`; the inverse reading would have made the degraded read-only mode grant unlimited. `NullEntitlementProvider` is registered in **every** deployment mode, per ADR-0035's default-implementation gate rather than ADR-0020's Development-only switch. Killswitches leave `tenant_feature_flags` for a platform-scoped `platform_killswitches`: a foreign key to `tenants` is a constraint no role or `BYPASSRLS` moves, so the sentinel could never have satisfied it. Push-primary, **not** push-only — ADR-0034's `license/verify` fallback stands; the no-Hub absolute belongs to host resolution | | 0050 | [Publication and Course Content Access](0050-publication-and-course-content-access.md) | Accepted 2026-10-02: publication/access separation, restricted backfill and anonymous denial; supersedes ADR-0048; P02d-2 policy, migration, writers and seed delivered ([Amendment 1](0050-publication-and-course-content-access.md#amendment-1--p02d-2-implementation-delivery-2026-10-02)); P02d-4 public-read enforcement delivered, grants Phase 07 | | 0051 | [Ordered Text Card Presentation](0051-ordered-text-card-presentation.md) | Accepted 2026-10-02: optional root `x-fields`, localized ordered text cards; compatible extension linked by ADR-0043 Amendment 5; P02d-2 profile parsing, resolution and seed verification delivered ([Amendment 1](0051-ordered-text-card-presentation.md#amendment-1--p02d-2-implementation-delivery-2026-10-02)); P02d-4 public projection delivered; P02d-6 completes ordered plain-text rendering, automated product proofs and manual accessibility smoke | @@ -71,7 +76,8 @@ an amendment is not a lifecycle status change. | 0053 | [Trusted Public Server Rendering](0053-trusted-public-server-rendering.md) | Accepted 2026-10-08: P02d-5 delivers native ingress, configured transport, dynamic entry and source/runtime proofs. [ADR-0054](0054-bounded-public-renderer-admission.md) replaces bounded accounting/native/query contracts. P02d-5 is complete and merged through PR #26 on 2026-10-09; the [merge closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-5-merge-and-closeout-2026-10-09) owns final verification ([Amendment 8](0053-trusted-public-server-rendering.md#amendment-8--merge-closeout-2026-10-09)); P6/P7 remain separate | | 0054 | [Bounded Public Renderer Admission](0054-bounded-public-renderer-admission.md) | Accepted 2026-10-09: coordinated anonymous accounting, native GET/HEAD/production-upgrade admission and equivalent redirect-query encoding are delivered. All five remediation steps are complete and merged through PR #26; the [merge closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-5-merge-and-closeout-2026-10-09) owns final verification ([Amendment 6](0054-bounded-public-renderer-admission.md#amendment-6--merge-closeout-2026-10-09)) | | 0055 | [Handle Public Renderer Bootstrap Failures in Middleware](0055-public-renderer-bootstrap-failures.md) | Accepted 2026-10-10: one live middleware bootstrap and an immutable native same-request snapshot; exact bootstrap 404/429/503, bounded renderer Retry-After and two/one/three-call accounting implemented. Production replacement proofs pass; all three steps completed both review rounds and focused fix verification. [Step 3](../roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout) owns delivery evidence, completed reviews and verified fixes; five new catalogue rules are Implemented | -| 0056 | [Public Demo Browser and Lighthouse Enforcement](0056-public-demo-browser-and-lighthouse-enforcement.md) | Accepted 2026-10-11: pinned Lighthouse/Playwright Chromium, seed-derived nine-page browser proof, 27 serial audits, actual-page admission, normalized gzip/CLS/accessibility hard checks and advisory timings delivered; four proof rules Implemented, real PR CI/artifacts pass and seventh required context activated ([Amendment 1](0056-public-demo-browser-and-lighthouse-enforcement.md#amendment-1--p02d-7-enforcement-delivery-2026-10-11)); PR #28 review/merge pending | +| 0056 | [Public Demo Browser and Lighthouse Enforcement](0056-public-demo-browser-and-lighthouse-enforcement.md) | Accepted 2026-10-11: pinned Lighthouse/Playwright Chromium, seed-derived nine-page browser proof, 27 serial audits, actual-page admission, normalized gzip/CLS/accessibility hard checks and advisory timings delivered; four proof rules Implemented, real PR CI/artifacts pass and seventh required context activated ([Amendment 1](0056-public-demo-browser-and-lighthouse-enforcement.md#amendment-1--p02d-7-enforcement-delivery-2026-10-11)); complete and merged through PR #28 on 2026-10-11 ([merge closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-7-merge-and-closeout-2026-10-11)) | +| 0057 | [Transactional Event Production and Completion](0057-transactional-event-production-and-completion.md) | Accepted 2026-10-12: immediate ambient enqueue, owner-only domain completion, sticky produced-work refusal protection, identifier-only bounded payloads and completed-provisioning publisher; eight producer rules Implemented; Steps 1–3 implement shared execution, immediate enqueue, successful-save collection and the publisher; Step 4 adds production censuses and catalogue enforcement. Amendment 1 links delivery verification and review status. P02b-3 owns default-initialization subscriptions and Seeder handoff. [Amendment 2 (2026-10-12)](0057-transactional-event-production-and-completion.md#amendment-2--correct-adr-0032-clause-locators-2026-10-12) corrects the ADR-0032 clause locators through inline errata; the Decision and bounded supersession are unchanged. | ## Proposed ADRs @@ -140,8 +146,8 @@ table records the phase commitment so reviewers can flag late drafts. |---|---|---|---| | 0025 | Scoring + completion DSL sandbox engine (CEL vs restricted Lua vs custom; sandbox boundary; allowed function set) | **Phase 05** — `TenantCompletionRule` runtime evaluator lights up here; Phase 08a's assessment scoring depends on it | [ADR-0018](0018-tenant-driven-customization-model.md), [phase-05-education-learning-content.md](../roadmap/phase-05-education-learning-content.md), [phase-08a-assessment-notifications.md](../roadmap/phase-08a-assessment-notifications.md) | | 0026 | Release-tag scheme (`vYYYY.MM.DD.` vs SemVer; SaaS continuous-deploy reconciliation; Self-Hosted release cadence) | **Phase 11** — production hardening checklist owns this | [14-git-workflow.md § Tagging and Releases](../standards/14-git-workflow.md) | -| 0046 | Event delivery, the inbox, and the subscriber failure contract (inbox table identity and deduplication key; the dead-letter store; who claims a delivery; per-subscription attempts, terminal state and replay as one state machine) | **Phase 02b** — the consumer packet cannot start without it, and two committed designs currently contradict each other | [phase-02b-events-auth.md](../roadmap/phase-02b-events-auth.md), [15-event-and-outbox.md](../architecture/15-event-and-outbox.md), [ADR-0006](0006-events-and-outbox.md) | -| 0047 | Background job runtime (storage under the closed four-role model; the single tenant-context writer; the job frame's transaction owner; the enqueue-site rule; the retry and dead-letter contract; the ADR-0035 classification; the queue grammar; package pins and licence verdict) | **Phase 02b** — Hangfire is attributed to ADR-0002 and ADR-0006, neither of which mentions it, and no compliant first boot exists today | [phase-02b-events-auth.md](../roadmap/phase-02b-events-auth.md), [20-infrastructure-stack.md § Background Jobs and Hangfire](../standards/20-infrastructure-stack.md), [09-tenant-isolation.md](../architecture/09-tenant-isolation.md) | +| 0046 | Event delivery, the inbox, and the subscriber failure contract (inbox table identity and deduplication key; the dead-letter store; who claims a delivery; per-subscription attempts, terminal state and replay as one state machine) | **P02b-2 decision pass** — before dispatcher code; P02b-3 consumes the same state machine for its inbox and subscriber implementation | [phase-02b-events-auth.md](../roadmap/phase-02b-events-auth.md), [15-event-and-outbox.md](../architecture/15-event-and-outbox.md), [ADR-0006](0006-events-and-outbox.md) | +| 0047 | Background job runtime (storage under the closed four-role model; the single tenant-context writer; the job frame's transaction owner; the enqueue-site rule; the retry and dead-letter contract; the ADR-0035 classification; the queue grammar; package pins and licence verdict) | **P02b-4 decision pass**, after P02b-3 — before job-runtime code; no compliant first boot exists today | [phase-02b-events-auth.md](../roadmap/phase-02b-events-auth.md), [20-infrastructure-stack.md § Background Jobs and Hangfire](../standards/20-infrastructure-stack.md), [09-tenant-isolation.md](../architecture/09-tenant-isolation.md) | **Reservation rule:** the numbers above are *reserved but not yet drafted*. When a draft lands, take its reserved number; do not let another ADR claim it. If the diff --git a/docs/glossary.md b/docs/glossary.md index becf8322..6b350580 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -114,6 +114,10 @@ This glossary defines LearnStack-specific terms. When a term is ambiguous across | **Domain Event** | An event raised inside one module to express that a meaningful change happened in its aggregate. Stays inside the module. | | **Integration Event** | An event published outward via the outbox so that other modules or external consumers can react. | | **Outbox** | The transactional buffer that turns domain changes into integration events without dual-write inconsistency. | +| **Producer frame** | Per-invocation bookkeeping implemented by scoped `ProducerExecution` in P02b-1 Steps 1–3, including nested ancestry, sticky successful INSERT/save-promotion receipts and no-listener W3C Activity. Saved facts retain the saving invocation's span and causal actor through owner-only translation. Receipts are distinct from transaction and audit ownership. See [ADR-0057](decisions/0057-transactional-event-production-and-completion.md). | +| **Audit frame** | The boundary grouping classified operation intents and captured changes. Nested operations contribute intents; only the outermost frame reconciles and clears them. P02b-1 Step 1 supplies `AuditExecutionCoordinator` for request and direct entry; the scoped capture's `HasOpenFrame` identifies outermost reconciliation, independently of transaction/producer ownership. `AuditLogBehavior` is the step-3 adapter. Production delivery remains P02b-3 under [ADR-0057](decisions/0057-transactional-event-production-and-completion.md). | +| **Transaction announcement snapshot** | Read-only `TenantContextAnnouncement` returned by `IUnitOfWork.GetTenantContextAnnouncement(DbTransaction?)`: a record of the tenant/organization context successfully announced on one physical transaction generation. It supplies producer checks without granting a new tenant-context writer or setter. P02b-1 Steps 1–3 implement the reader/generation lifecycle, outbox admission and save-origin checks under [ADR-0057](decisions/0057-transactional-event-production-and-completion.md). | +| **Domain-event collector** | Scoped `DomainEventCollector`, implemented in P02b-1 Step 3. A SaveChanges interceptor stages only changed sources of the saving context and promotes them after nonzero successful persistence. A FIFO queue retains those facts and their producer origins until owner-only completion; a separate retained-source set clears even uncollected/detached facts at owner termination. Retention is never save eligibility. See [ADR-0057](decisions/0057-transactional-event-production-and-completion.md). | | **Learning Event** | An analytics event describing learner behavior (lesson viewed, assessment completed). | | **Commerce Event** | An analytics event describing the commerce funnel. | | **Classroom Event** | An analytics event derived from `LiveSessionEvent` streams. | @@ -244,11 +248,11 @@ These entries distinguish P02d-2 contracts from P02d-3 internal read contracts: | **Audit Operation Key** | The catalogue key `(module, operation)`, where `operation` is the dotted slug `{module}.{resource}.{verb}` — `tenancy.tenant.create`, `customization.content_type.publish`. It borrows the **shape** of a *Permission Key* and shares its first two segments with the permission gating the same resource, but **not** its closed action set: an audit verb records what happened and comes from the module's own coverage matrix (`create`, `publish`, `revise`, `rename`, `soft_delete`, …), where a permission action bounds what a principal may do. There is no `RequestKind.Other` — every `IRequest>` reaching pipeline step 3 must be classified, an unregistered one is rejected with `audit_unclassified_operation` (500), and test-only request types register through the same builder in their fixture. Per [ADR-0044 § 6](decisions/0044-audit-write-path.md). | | **Audit Coverage Matrix** | The per-module table in `docs/modules//audit.md` mapping resources × operations to MUST / SHOULD / MAY / – classifications, with an `Operation` column carrying the dotted slug. Required for every module spec. It is the human-readable artifact; the **audit catalogue** — declared in code through `IAuditCatalogSource.Describe(IAuditCatalogBuilder)`, discovered from DI and keyed by request type — is the executable one. Neither is parsed from the other: `Every_TenantOwned_Command_HasAuditCoverage` (catalogue → matrix) and `Every_Matrix_Row_Whose_Command_Exists_Is_Registered` (matrix → catalogue) assert they agree, in **two directions with two domains**. *Catalogue → matrix is total* — every entry a module's source registers has a row in that module's matrix carrying the same slug, no exemption. *Matrix → catalogue binds only to slugs whose request type exists* — a row classified ahead of the command that will raise it carries **`(planned)`** in the `Operation` cell and is outside that direction, because classifying before the command ships is what the standard asks for. The marker is a claim the rule re-checks, not an escape: a `(planned)` row whose command **has** shipped fails. Operations that are not MediatR requests at all carry **`(off-path)`**, are outside the join in both directions, and register by slug rather than by type; the closed list of them is enumerated once, in the amendment that decides it. Per [ADR-0044 § 6 and Amendment 3](decisions/0044-audit-write-path.md). | | **AuditConfig** | The per-tenant override of the catalogue's coverage, held as a cached projection whose loader runs on a cache miss on its own short transaction — once per tenant and generation, never once per request. It **narrows and never elevates**: `is_enabled = false` silences a SHOULD or a MAY, `true` is the baseline, and neither touches a MUST. The classifier applies the override and then re-applies the catalogue's MUST floor. Elevation is refused rather than deferred: a lever that moved an operation onto the durable path would let a tenant admin provoke a `503` on operations the platform classified MAY ([ADR-0033 Amendment 4](decisions/0033-audit-durability-model.md)). A failure to **read** it does **not** reject the operation: classification falls back to the in-process catalogue, which carries the same MUST floor, so nothing proceeds unaudited and one cache outage does not deny every request platform-wide. The failure logs at `Error`; the audit health check does not move, because it answers only whether a MUST-class row can be written. Per [ADR-0033 and its Amendment 6](decisions/0033-audit-durability-model.md). | -| **Audit Capture Pipeline** | `AuditChangeTrackerInterceptor` (EF) → `IAuditStateCapture` (before/after/`changes` JSON) → `AuditLogBehavior` (MediatR) → `IAuditStore`. Modules never write `audit_log` directly. The interceptor captures **every** `ChangeTracker` entry in state `Added`, `Modified` or `Deleted`, minus a named exclusion list — `OutboxMessage`, `IdempotencyKey`, `AuditEntry`, `AuditConfig`. The earlier `AuditableEntity<>`-descendants predicate is withdrawn: it was blind to every plain class the modules ship, `PlatformHostMapping` and the other MUST-classified ones among them — [Audit Coverage § Required Behaviours](standards/18-audit-coverage.md#required-behaviours) lists them with their classes. It sees only what the `ChangeTracker` holds, so EF Core's set-based writes (`ExecuteUpdate`, `ExecuteDelete`, `ExecuteSql*`) would escape it and backend source uses none. It **captures only** — no row construction, no SQL — and reaches every module `DbContext` through `AddModuleDbContext`, which resolves `IEnumerable` from the provider, because DI registration alone does not attach it. An entity that is not an aggregate root and is reached through its root's navigation — a taxonomy's bands — is captured **inside its root's** diff rather than on its own, and in its root's snapshot only when the root was created in the same request and every member it was seen with is still tracked, the one case its membership is known to be complete; a filtered `Include` marks a partial collection loaded, so a loaded one is left out as unknown. `changes` serialises as a JSON **array** of `{ path, before, after }` with an instance-qualified pointer — `/{EntityType}/{EntityId}` and then an RFC 6901 pointer into that instance's snapshot — never ADR-0016's polymorphic object-or-array shape. Per [ADR-0044 § 7 and Amendment 6 §§ 4–5](decisions/0044-audit-write-path.md). | +| **Audit Capture Pipeline** | `AuditChangeTrackerInterceptor` (EF) → `IAuditStateCapture` (before/after/`changes` JSON) → `AuditExecutionCoordinator` (MediatR adapter or direct owner) → `IAuditStore`. Modules never write `audit_log` directly. The interceptor captures **every** `ChangeTracker` entry in state `Added`, `Modified` or `Deleted`, minus a named exclusion list — `OutboxMessage`, `IdempotencyKey`, `AuditEntry`, `AuditConfig`. The earlier `AuditableEntity<>`-descendants predicate is withdrawn: it was blind to every plain class the modules ship, `PlatformHostMapping` and the other MUST-classified ones among them — [Audit Coverage § Required Behaviours](standards/18-audit-coverage.md#required-behaviours) lists them with their classes. It sees only what the `ChangeTracker` holds, so EF Core's set-based writes (`ExecuteUpdate`, `ExecuteDelete`, `ExecuteSql*`) would escape it and backend source uses none. It **captures only** — no row construction, no SQL — and reaches every module `DbContext` through `AddModuleDbContext`, which resolves `IEnumerable` from the provider, because DI registration alone does not attach it. An entity that is not an aggregate root and is reached through its root's navigation — a taxonomy's bands — is captured **inside its root's** diff rather than on its own, and in its root's snapshot only when the root was created in the same request and every member it was seen with is still tracked, the one case its membership is known to be complete; a filtered `Include` marks a partial collection loaded, so a loaded one is left out as unknown. `changes` serialises as a JSON **array** of `{ path, before, after }` with an instance-qualified pointer — `/{EntityType}/{EntityId}` and then an RFC 6901 pointer into that instance's snapshot — never ADR-0016's polymorphic object-or-array shape. Per [ADR-0044 § 7 and Amendment 6 §§ 4–5](decisions/0044-audit-write-path.md). | | **Elision Record** | What replaces a snapshot value too large to store: `{"_elided": true, "bytes": , "sha256": ""}` — never an empty object and never a silent truncation. Each of `before_state`, `after_state` and `changes` is capped at `JsonValue.MaxRowBytes` (256 KiB). The elision **preserves the column's JSON type**: `changes` is an array on both sides of the cap, so an elided `changes` is `[{"_elided": …}]`, while `before_state` and `after_state` take the object form — a reader must not have to branch on shape to know what it is reading. `audit_blob_id` is struck from the corpus: it exists in no DDL and names no blob store. Per [ADR-0044 § 8](decisions/0044-audit-write-path.md). | -| **Audit Intent** | One parked declaration that an audited `(resource, operation)` happened, minted with its `AuditEntryId` by `AuditLogBehavior` at pipeline step 3 and held in `IAuditStateCapture` as an **ordered list** — one intent per audited `(resource, operation)`, **not** one per request. `ProvisionTenantCommand` declares two, `tenancy.tenant.create` and `tenancy.organization.create`, which is why "exactly one intent per request" is retired. Only the **owning** unit-of-work frame (`IUnitOfWorkScope.IsOwner`) flushes the intents, signals `Committed` / `RolledBack` / `Indeterminate`, and calls `Clear()`, and it drains every intent in the scope rather than only its own; a joiner writes nothing, signals nothing and reconciles nothing — one that cleared would erase the outer request's intents before the owner committed. It also carries the `TenantId` and optional `OrganizationId` its row will be written under, resolved by `AuditLogBehavior` at step 3 — the only place all four of ADR-0044 § 2's cases are decidable, and the reason `PostgresAuditStore` composes a row from the intent rather than resolving a tenant itself. It carries the actor and the correlation id too, read at the same moment, so both writers write them. Every `AuditLogBehavior` invocation opens an audit **frame**: an intent belongs to the frame that declared it, a captured change to the frame whose handler flushed it, and closing a frame records on its intents what that request returned — so a nested request refused under an outer one that commits keeps its refusal. Per [ADR-0044 §§ 2, 3, 4, Amendment 3 and Amendment 6 §§ 2–3](decisions/0044-audit-write-path.md) and [ADR-0033 Amendment 2](decisions/0033-audit-durability-model.md). | +| **Audit Intent** | One parked declaration that an audited `(resource, operation)` happened, minted with its `AuditEntryId` by `AuditExecutionCoordinator` (through step 3 for requests) and held in `IAuditStateCapture` as an **ordered list** — one intent per audited `(resource, operation)`, **not** one per request. `ProvisionTenantCommand` declares two, `tenancy.tenant.create` and `tenancy.organization.create`, which is why "exactly one intent per request" is retired. Only the **owning** unit-of-work frame (`IUnitOfWorkScope.IsOwner`) flushes the intents, signals `Committed` / `RolledBack` / `Indeterminate`, and it drains every intent in the scope rather than only its own; a joiner writes nothing and signals nothing. Only the outermost audit frame reconciles and calls `Clear()`; a nested frame that cleared would erase the outer execution's intents before commit. It also carries the `TenantId` and optional `OrganizationId` its row will be written under, resolved by `AuditExecutionCoordinator` before deferred work — through step 3 for requests, where ADR-0044 § 2's four cases are decidable, and the reason `PostgresAuditStore` composes a row from the intent rather than resolving a tenant itself. It carries the actor and the correlation id too, read at the same moment, so both writers write them. Every classified `AuditExecutionCoordinator` invocation opens an audit **frame**: an intent belongs to the frame that declared it, a captured change to the frame whose handler flushed it, and closing a frame records on its intents what that request returned — so a nested request refused under an outer one that commits keeps its refusal. Per [ADR-0044 §§ 2, 3, 4, Amendment 3 and Amendment 6 §§ 2–3](decisions/0044-audit-write-path.md) and [ADR-0033 Amendment 2](decisions/0033-audit-durability-model.md). | | **Audit Subject** | The one aggregate instance an audit row is about: its `entity_id`, `before_state` and `after_state`. The composer finds it among the captures of the intent's declared type; when a request writes **two** instances of that type — a publication retires the incumbent revision and activates the successor — the handler names it through `IAuditSubject.Designate(aggregate)`, and every other instance stays in `changes` under its own instance-qualified pointer. Undesignated, two instances are refused rather than guessed between. A designation binds only the current request's intents of that type. Per [ADR-0044 Amendment 6 § 1](decisions/0044-audit-write-path.md). | -| **Durable Audit Intent** | The MUST-class audit row, written by `IAuditStore.WritePendingAsync` on the **same transaction as the business write** — `AuditLogBehavior` classifies and parks each intent at pipeline step 3, and `TransactionBehavior` writes them all, on the owning frame only, immediately before `COMMIT` — so each commits with that write or not at all, and so each executes while `app.tenant_id` is set and the Row Level Security `WITH CHECK` accepts it. "The same `SaveChanges`" was the earlier formulation and ADR-0033 withdraws it: the guarantee is the transaction, which needs no cross-`DbContext` machinery. If it cannot be written, the business operation **fails closed**. Enrichment, redaction, projection, and external fan-out happen after the commit, reading the durable row, and are best-effort. SHOULD/MAY-class audit is **not** a durable intent: it is written outside the transaction, and its accepted loss is written down rather than assumed. Per [ADR-0033](decisions/0033-audit-durability-model.md), which supersedes ADR-0016. | +| **Durable Audit Intent** | The MUST-class audit row, written by `IAuditStore.WritePendingAsync` on the **same transaction as the business write** — `AuditExecutionCoordinator` classifies and parks each intent (through step 3 for requests), and `TransactionExecutionCoordinator` writes them all, on the owning frame only, immediately before `COMMIT` — so each commits with that write or not at all, and so each executes while `app.tenant_id` is set and the Row Level Security `WITH CHECK` accepts it. "The same `SaveChanges`" was the earlier formulation and ADR-0033 withdraws it: the guarantee is the transaction, which needs no cross-`DbContext` machinery. If it cannot be written, the business operation **fails closed**. Enrichment, redaction, projection, and external fan-out happen after the commit, reading the durable row, and are best-effort. SHOULD/MAY-class audit is **not** a durable intent: it is written outside the transaction, and its accepted loss is written down rather than assumed. Per [ADR-0033](decisions/0033-audit-durability-model.md), which supersedes ADR-0016. | | **MUST-class audit** | The coverage tier whose row is part of the operation's contract, and whose two shapes fail differently. **In-transaction** — the durable intent above: a row that cannot be written leaves zero business rows and answers `503 audit_unavailable`. **Standalone** — written by `IAuditStore.WriteStandaloneAsync` on its own transaction, which announces **both** `app.tenant_id` and `app.organization_id` from the draft, since an org-scoped row written with the organization GUC unset fails `WITH CHECK`: its failure changes the response **only when the operation would otherwise have succeeded**. A standalone row recording an operation that is already being refused — a `denied` authorisation outcome, a rejected tenant assertion — keeps its own 403 / 404; the failure logs at `Critical`, increments the standalone-write-failure counter and marks the audit health check unhealthy. Per [ADR-0033 Amendments 1 and 2](decisions/0033-audit-durability-model.md) and [ADR-0044 §§ 9, 11](decisions/0044-audit-write-path.md). | | **Retention Class** | The retention floor for a category of audit entries (7y for security-event / platform-admin / financial; 2y for others). Enforced by a **daily** Hangfire purge job from [Phase 11](roadmap/phase-11-production-hardening.md). | @@ -299,7 +303,7 @@ These entries distinguish P02d-2 contracts from P02d-3 internal read contracts: | **`IClock` / `IRandom` / `IGuidFactory`** | The three deterministic-test abstractions in `LearnStack.SharedKernel`. Production code never reads `DateTime.UtcNow`, instantiates `System.Random`, or calls `Guid.NewGuid()` directly — those calls go through the abstractions so tests pin the values via `FixedClock` / `FixedRandom` / `FixedGuidFactory`. Per Standards 02 § Time. | | **`UserId`** | The cross-cutting strongly-typed actor identifier in `LearnStack.SharedKernel.Identifiers` (Vogen `[ValueObject]`). Audit columns on `AuditableEntity` reference users by `UserId` so the "no raw `Guid` on the public surface" rule (Standards 02) holds even though the Identity module lands in Phase 02b. Identity consumes the same type when it ships. | | **`Entity` / `AuditableEntity`** | The two aggregate bases in `LearnStack.SharedKernel.Domain`. `Entity` is the append-only / audit-row base — identity, in-process domain events, identity-based equality with **uninitialized-id + cross-runtime-type guards** so `HashSet`-backed collection navigations, `Distinct()` and `Contains` behave correctly before ids are minted. EF Core's change tracker is not among the reasons — it keys on the primary-key value and tracks by reference, never calling these members. `AuditableEntity` is the mutable base — adds `CreatedAt/By`, `UpdatedAt/By`, `DeletedAt/By`, `Version`, and the `IsDeleted` projection by implementing `ISoftDelete` + `IOptimisticConcurrency`. Three ordering guards, all throwing `InvalidOperationException`: `MarkCreated` refuses a second call, `SoftDelete` refuses a second delete (both because audit-trail integrity rules out silent overwrites), and `MarkUpdated` / `SoftDelete` refuse to run before `MarkCreated` — measured, without that last one an update left `CreatedAt` at the `0001-01-01` sentinel and a later `MarkCreated` produced a row whose `updated_at` preceded its `created_at`. `SoftDelete` also stamps `UpdatedAt` with the same instant so a job scanning `UpdatedAt` sees the delete; that is not a monotonicity promise, which nothing enforces — the caller's `IClock` is what supplies forward time. `AuditEntry` (audit subsystem) inherits `Entity` — never `AuditableEntity` — by architecture-test rule. | -| **`IDomainEvent`** | The marker interface (`: MediatR.INotification`) every in-process domain event implements. Raised from aggregate methods; collected by the unit of work and dispatched in-process by MediatR inside the ambient transaction — the collection and dispatch land in [Phase 02b](roadmap/phase-02b-events-auth.md). The abstract `DomainEvent` base declares `EventId` and `OccurredAt` as `required init` so events are always stamped through `IGuidFactory` / `IClock` at the call site. Distinct from integration events, which cross module boundaries through the outbox + `IEventBus` (`InProcessEventBus` today; Dapr pub/sub after its trigger) per [ADR-0038](decisions/0038-cross-cutting-port-and-event-contracts.md). | +| **`IDomainEvent`** | The marker interface (`: MediatR.INotification`) every in-process domain event implements. Raised from aggregate methods; P02b-1 Step 3 collects successfully saved facts and dispatches them sequentially through MediatR during owner completion, before MUST audit and commit. The abstract `DomainEvent` base declares `EventId` and `OccurredAt` as `required init` so events are always stamped through `IGuidFactory` / `IClock` at the call site. Distinct from integration events, which cross module boundaries through the outbox + `IEventBus` (`InProcessEventBus` today; Dapr pub/sub after its trigger) per [ADR-0038](decisions/0038-cross-cutting-port-and-event-contracts.md). | | **`CursorPagination` / `Page` / `PageInfo`** | The cursor-first pagination triple in `LearnStack.SharedKernel.Pagination` matching Standards 04 § Pagination. `CursorPagination(Cursor, Limit)` is the request (default `Limit = 20`, max 100; ctor throws on `Limit <= 0` — kernel-level guard); `Page(Items, PageInfo)` is the response; `PageInfo(NextCursor, PreviousCursor, HasNext, HasPrevious)` carries the opaque cursors the client never parses. | | **`LearnStackVogenDefaults.IdMask`** | The canonical `Conversions` mask every Vogen-emitted ID and value object opts into: `EfCoreValueConverter \| SystemTextJson \| TypeConverter`. Per [ADR-0023](decisions/0023-strongly-typed-id-source-generator.md) every aggregate-root ID writes `[ValueObject(LearnStackVogenDefaults.IdMask)]`. | | **Pipeline Behavior** | A MediatR pipeline behavior — one of the eight canonical steps wrapping every command / query: `Validation → Logging → AuditLog → TenantContext → Authorization → Transaction → OutboxFlush → Handler`. The order is binding per [ADR-0032 § Sub-decision 2](decisions/0032-exception-handling-logging-and-observability.md); the architecture test `MediatR_Pipeline_Order_Matches_Canonical_Sequence` enforces it. | @@ -320,9 +324,11 @@ These entries distinguish P02d-2 contracts from P02d-3 internal read contracts: | **Demand-Gated Building Block** | An infrastructure choice that ships as a **port plus a working default implementation** now, and as a **vendor adapter later**, in a named phase, when a written trigger fires. It qualifies only when all four exist: the port, the default implementation, the owning phase, and the trigger condition. A block missing any of the four is not demand-gated — it is simply missing. Distinguished from a one-way door by the test below. The gated set and each trigger are tabulated in [ADR-0035](decisions/0035-demand-gated-infrastructure.md), which also names its own two exceptions — `audit_log` partitioning, which is schema-internal and so has no port, and LiveKit, which has no default because its absence is a missing product feature rather than a missing implementation. | | **One-Way Door** | A decision that gets more expensive with every week of delay, tested by the question: *if I add this six months from now, will I have to touch code that is already written?* **Yes → one-way door; ship it now** (tenant and organization isolation, the `outbox_messages` table and its ownership, strongly-typed identifiers, the localization schema — each touches every query, migration, or job payload ever written). **No → additive; ship the port now and the adapter on demand.** The test is mechanical, not a matter of taste: tenant isolation's cost grows with the codebase, a Dapr adapter's cost does not. A corollary rule: **a deployment mode or customer segment without a signed contract cannot be the deciding factor in a technical choice** — it may break a tie between otherwise-equal options, nothing more. Per [ADR-0035](decisions/0035-demand-gated-infrastructure.md) and [Engineering Principles](standards/00-principles.md). | | **Dapr Building Blocks** | The three Dapr abstractions LearnStack uses **when it uses Dapr**: pub/sub (Kafka), state (Valkey), secrets (Vault) per [ADR-0038](decisions/0038-cross-cutting-port-and-event-contracts.md). Service invocation, workflow, bindings, and actors are out of scope. Demand-gated to [Phase 11](roadmap/phase-11-production-hardening.md); ADR-0038 decides *what*, [ADR-0035](decisions/0035-demand-gated-infrastructure.md) decides *when*. | -| **`IEventBus`** | Interface for publishing integration events, taking an `IntegrationEventEnvelope`. `InProcessEventBus` is the only registered implementation until the Dapr adapter's trigger fires — and it is a **first-class transport, not a stub**: same `IIntegrationEventHandler`, same `IInboxGuard`, same tenant-context restoration, same per-partition-key ordering as the durable path. The `OutboxProcessor` is the only sanctioned caller. | +| **`IEventBus`** | Interface for publishing integration events, taking an `IntegrationEventEnvelope`. `InProcessEventBus` is the only registered implementation until the Dapr adapter's trigger fires. It supplies `IIntegrationEventHandler`, scoped subscriptions, ambient tenant-context restoration and process-local per-key invocation serialization. Database announcement and `IInboxGuard` deduplication remain P02b-3 targets; durable publish ordering remains P02b-2 G3. The `OutboxProcessor` is the only sanctioned production caller. | +| **`IOutbox`** | Immediate integration-event INSERT through the enrolled, announced, writable ambient transaction. `PostgresOutbox` is registered in API and Seeder by P02b-1 Step 2; it opens no connection, commits nothing and calls no transport. Identity is `EventId`; duplicates fail. Concrete payloads use the frozen serializer, identifiers only, and the inclusive 262,144-byte UTF-8 `OutboxPayloadLimits.MaxBytes`. `metadata` is SQL NULL. See [ADR-0057](decisions/0057-transactional-event-production-and-completion.md). | +| **`IntegrationEventPartitionAttribute`** | The concrete event's explicit partition-source declaration: a non-nullable aggregate identifier, or `TenantId` with `tenantWide: true`. Runtime admission compares the actual canonical lowercase `D` GUID value. Inherited declarations and implicit tenant partitioning are refused; durable publish ordering stays with P02b-2 G3. | | **`IntegrationEventEnvelope`** | One integration event plus the dispatch metadata the outbox row carries and the event does not: W3C `CorrelationId`, optional `OrganizationId`, `CausationId`, and causal `ActorUserId`. Its `Topic` and `PartitionKey` are the event's own — both are properties of the event *type* rather than of one delivery, so neither can hold a second answer ([ADR-0038](decisions/0038-cross-cutting-port-and-event-contracts.md)). An event implementing `IOrganizationScopedIntegrationEvent` requires a non-empty organization in its envelope. Metadata describes the *delivery*; the event describes the *fact*. | -| **`IPartitionSerializer`** | Runs work sequentially within one partition key and concurrently across different ones — the in-process stand-in for what a broker gives you by assigning a partition to one consumer. It exists so the development transport carries the same ordering guarantee as the durable path rather than a weaker one. Queuing work for the key you are already inside is refused rather than deadlocked: the caller that does it is publishing from inside a handler, which [Standards 20](standards/20-infrastructure-stack.md) forbids. | +| **`IPartitionSerializer`** | Runs work sequentially within one partition key and concurrently across different ones in the current process. This invocation serialization establishes no durable publish-order guarantee; P02b-2 G3 owns that decision. Queuing work for the key you are already inside is refused rather than deadlocked: the caller that does it is publishing from inside a handler, which [Standards 20](standards/20-infrastructure-stack.md) forbids. | | **`EventTenantContext`** | The `ITenantContext` a consumer runs under, rebuilt from the envelope by the transport before handler discovery or resolution. A consumer executes outside the request that produced the fact, so there is no ambient context to inherit. The effective principal is `UserId.SystemActor`; an envelope human remains available separately as `CausalActorUserId`. Restoring the tenant and optional organization is what makes query filters and RLS policies evaluate against the right scope. | | **`TenantId`** | The strongly-typed tenant identifier, in `LearnStack.SharedKernel.Identifiers` per [ADR-0023 Amendment 2](decisions/0023-strongly-typed-id-source-generator.md)'s cross-cutting placement rule — it rides on `ITenantContext`, on every `[TenantOwned]` entity, in cache keys, job payloads and event envelopes, so a Tenancy-owned type would make each of those a reference to that module. It has **no `New()`**: a tenant id is assigned by the registry that owns the `Tenant` aggregate, because a handler minting its own could not satisfy the self-keyed policy's `WITH CHECK` ([Database Standards § Table classes](standards/05-database.md)). | | **`TenantId.PlatformSentinel`** | The reserved platform sentinel tenant id `00000000-0000-7000-8000-000000000002` — a UUIDv7-shaped constant on the precedent `UserId.SystemActor` sets, and **not** the nil UUID, which three shipped mechanisms read as *the absence of a tenant*. `tenants` carries a `CHECK` refusing it, so no tenant is ever provisioned under it and its rows are invisible to every tenant policy. Exactly one class of row carries it: a platform-scope operation with no resolvable tenant, written standalone — entry into `EnterPlatformAdminScope(reason)` and the operations inside it, of which `tenancy.killswitch.toggle` is the first — a row the Tenancy matrix carries as `(planned)`, because Packet 9 ships the killswitch table with no writer and [Phase 03](roadmap/phase-03-identity-admin.md) owns the toggle command. Never announced by `SetTenantContextAsync`, never written by a tenant request path, and never carrying a rejected tenant assertion — and that invariant has an **enforcer** rather than a hope: `SetProvisioningTenantContextAsync`, the one announcement path that takes a caller-supplied id, refuses the sentinel exactly as it already refuses `Guid.Empty`, and `Tenant.Create` refuses it in the factory. The `CHECK` is the **backstop, not the control** — a constraint on `tenants` cannot stop a GUC from being announced. Per [ADR-0044 § 1 and Amendment 3](decisions/0044-audit-write-path.md). | diff --git a/docs/modules/audit/README.md b/docs/modules/audit/README.md index c6462f2f..b0faf188 100644 --- a/docs/modules/audit/README.md +++ b/docs/modules/audit/README.md @@ -339,6 +339,13 @@ surface reads `audit_log` from the operator plane, and one that did would be a n — decided by ADR in both repositories, like every other. This module **consumes** none either. It has no inbox and no projection. +Accepted [ADR-0057](../../decisions/0057-transactional-event-production-and-completion.md) +uses completed provisioning to initialize Customization defaults; Audit is not +its mirror consumer. Existing source MUST rows remain on the source transaction. +The reusable execution coordinator supplies non-MediatR classification/entry, +owner write/boundary reporting and outermost reconciliation under ADR-0044/0033. +P02b-1 proves a controlled direct owner; P02b-3 owns production delivery and its +per-effect audit after G1. No new audit writer or durability model is introduced. ## Permission matrix diff --git a/docs/modules/customization/README.md b/docs/modules/customization/README.md index e2a3940c..5abefb79 100644 --- a/docs/modules/customization/README.md +++ b/docs/modules/customization/README.md @@ -179,14 +179,27 @@ as `business_rule_violation` / `lockey_customization_key_already_live`. Default retains ordinary revision succession. The same-row race remains typed concurrency; an Active winner from another revision is never retired or adopted by seed. -### Primary integration-event flow: none +### Primary integration-event flow: provisioning defaults -There is no integration-event diagram because there is no integration event — -see [§ Integration-event catalogue](#integration-event-catalogue) for why, and -for the condition under which one becomes owed. -[Documentation Standards § Per-Module Specifications](../../standards/13-documentation.md) -asks for a diagram of the primary integration-event flow; this records its -absence rather than substituting an unrelated diagram for it. +**Accepted flow, runtime subscriptions pending P02b-3 after G1.** +[ADR-0057](../../decisions/0057-transactional-event-production-and-completion.md#first-useful-production-flow) +authorizes independent `card@1` and `plain@1` initialization on completed tenant +provisioning: + +```mermaid +flowchart LR + T[Completed provisioning] --> O[Transactional outbox] + O --> D[P02b-2 dispatcher] + D --> C[P02b-3 card@1 subscription] + D --> P[P02b-3 plain@1 subscription] + C --> CT[One root + generation + inbox + MUST audit] + P --> PT[One root + generation + inbox + MUST audit] +``` + +Text fallback: Tenancy persists the completed-provisioning fact; after G1, +independent subscriptions each create one untouched built-in concept on their +own delivery transaction. One may finish before its sibling; retry completes +an incomplete pair without overwriting tenant decisions. ### Primary read flow: resolving a tenant's shapes @@ -279,10 +292,27 @@ keeps the library's types inside it. ## Integration-event catalogue -**None yet.** Nothing outside this module reacts to a customization change -through events. P02d-2 Step 1 introduces contextual exact reads for write validation; -The public renderer in [Phase 02d](../../roadmap/phase-02d-walking-skeleton.md) -will read through the generation-keyed cache rather than by subscription — a cache +**No publisher or subscriber implemented yet.** Accepted ADR-0057 fixes the +P02b-3 subscriptions below; G1 must settle distinct deduplication identities and +delivery scope first. Each effect owns one root, generation change, inbox outcome +and MUST audit. Customization mints UUIDv7 IDs and resolves semantic key/version +identity; Tenancy carries neither schemas nor Customization IDs. + +| Consumed topic | Event | Independent effect | Owner | +|---|---|---|---| +| `learnstack.tenancy.tenant` | `TenantCreatedIntegrationEventV1` | Create untouched `card@1` | P02b-3 after G1 | +| `learnstack.tenancy.tenant` | `TenantCreatedIntegrationEventV1` | Create untouched `plain@1` | P02b-3 after G1 | + +Initialization preserves Draft, Active, Deprecated, successor and deliberately +deleted definitions; it never overwrites, republishes or resurrects them. P02b-3 +switches Seeder built-in verification to owner-resolved key/version, preserves +warm IDs and exact tenant-authored curriculum IDs, and proves concurrent fresh +seed/delivery convergence and sibling recovery. It updates the implementation's +creator/Seeder/Hub installation remarks when delivery exists. P02b-1 keeps +current seed writes and pending outbox rows. + +No other module reacts to a customization change through events today. Public +readers use the delivered generation-keyed cache with fresh generation probes; a cache key that changes is a cheaper invalidation than an event every pod has to receive. An event becomes owed when a second module needs to *act* on a change rather than merely notice it; [Phase 04](../../roadmap/phase-04-cms-media-pages.md) diff --git a/docs/modules/tenancy/README.md b/docs/modules/tenancy/README.md index c4e3eee8..813ac087 100644 --- a/docs/modules/tenancy/README.md +++ b/docs/modules/tenancy/README.md @@ -398,7 +398,7 @@ somebody has to remember to write. Nothing verifies that the requested id is unused, and the guarantee does not need it to be: the only statement the announcement authorises is an `INSERT` the primary key rejects when the tenant already exists. Anything later added inside that transaction — the MUST-class -audit write, the outbox flush — inherits that assumption and must not rely on +audit write, the immediate outbox INSERT — inherits that assumption and must not rely on the announced tenant being new. The `default_organization_id` update is separate because the composite foreign @@ -484,18 +484,37 @@ never a cross-module join ## Integration-event catalogue -**Tenancy publishes none yet.** The events below are declared by the phases that -build their producers; listing them here with their owning phase is the -alternative to discovering the topic name twice. +**Completed-provisioning publisher implemented by P02b-1 Step 3.** Accepted +[ADR-0057](../../decisions/0057-transactional-event-production-and-completion.md#first-useful-production-flow) +governs the publisher. The initial default-organization assignment raises +`TenantCreatedDomainEvent`; only its successful final save qualifies it for +owner-only translation. `TenantCreatedDomainEventHandler` emits +`TenantCreatedIntegrationEventV1` only after tenant, default organization and +back-reference completion; partition source is `TenantId`, payload has no extra +fields and envelope organization is null. Enqueue reads the transaction's +announcement while provisioning ambient context remains unresolved. State, event +and the existing two MUST audit rows share one transaction. P02b-1 persists +pending rows; P02b-2/P02b-3 own dispatch/delivery. + +The subscriptions below are accepted flow contracts, not delivered handlers. +G1 must settle distinct subscription deduplication/delivery scope first. | Topic | Payload | Publisher | Consumers | Phase | |---|---|---|---|---| -| `learnstack.tenancy.tenant` | tenant created / status changed | Tenancy | Identity, Audit | 02b | -| `learnstack.tenancy.organization` | organization created / archived | Tenancy | Identity, Education | 02b | -| `learnstack.tenancy.settings` | settings changed — the eager cache invalidation | Tenancy | every settings reader | 02b | +| `learnstack.tenancy.tenant` | `TenantCreatedIntegrationEventV1`, completed provisioning | Tenancy | Independent Customization `card@1` and `plain@1` initializers | P02b-1 publisher; P02b-3 subscribers after G1 | | `learnstack.hub.entitlement` | entitlement projection refreshed | Hub adapter | entitlement cache readers | 02c | | `learnstack.hub.custom-domain.activated` / `.deactivated` | host mapping changed | Hub adapter | `CachedHostToTenantResolver` | 02c | +Initialization is create-only for untouched built-in concepts; Draft, Active, +Deprecated, successor and deliberately deleted definitions are preserved. Each +P02b-3 effect owns one Customization root, generation change, inbox outcome and +MUST audit on its delivery transaction. Customization owns UUIDv7 IDs and semantic +key/version identity. P02b-3 moves Seeder verification away from fixed built-in +IDs, preserves warm IDs and exact authored curriculum IDs, and proves fresh +seed/delivery convergence. P02b-1 keeps existing seed writes. Typed settings are +uncached; no settings-invalidation event or Audit-mirror consumer is promised. +Future tenant-status/organization publishers need their owning feature decision. + Topic names follow `learnstack.{module}.{aggregate}`; `Integration_Event_TopicNames_FollowConvention` is the authority for the pattern and this table does not restate it. @@ -518,7 +537,7 @@ In [audit.md](audit.md), the file | Host → tenant resolution (cache miss) | **< 15 ms** p95 | One indexed single-row read in its own short transaction | | Entitlement projection read (L1 hit) | **< 1 ms** | Read on every feature check | | Tenant provisioning (3 statements) | **< 100 ms** p95 | Interactive but rare | -| Settings read for a request | **< 5 ms** p95 target, not measured | Accepted P02d-2/3: no settings cache; P02d-3 measures the ambient indexed read/merge. Phase 02b's event does not exist yet | +| Settings read for a request | **< 5 ms** p95 target, not measured | Accepted P02d-2/3: no settings cache; P02d-3 measures the ambient indexed read/merge. No invalidation consumer/cache is promised | The two resolution numbers are the load-bearing ones: they sit in front of every request and are the only Tenancy work an anonymous visitor pays for. diff --git a/docs/modules/tenancy/audit.md b/docs/modules/tenancy/audit.md index 09d68e1c..ad78a2d7 100644 --- a/docs/modules/tenancy/audit.md +++ b/docs/modules/tenancy/audit.md @@ -3,6 +3,13 @@ Per [Audit Coverage](../../standards/18-audit-coverage.md), which names this file. Part of the [module spec](README.md). +**P02b-1 Step 3 producer implemented — 2026-10-12.** Completed provisioning +adds one pending outbox row before the existing two MUST rows and commit. Its +domain translator adds no independent intent or audit operation and writes no +aggregate. A failed MUST write rolls back tenant, organization and outbox together. +The [integration-event catalogue](README.md#integration-event-catalogue) owns the flow; +production dispatch and subscribers remain P02b-2/P02b-3. + **P02d-4 Step 2 implemented — 2026-10-03.** Both review rounds and focused fix verification pass. Site bootstrap is a marked GET/HEAD request, audit Off and without a permission key or grant. It diff --git a/docs/modules/tenancy/permissions.md b/docs/modules/tenancy/permissions.md index 9ce41d74..1649298f 100644 --- a/docs/modules/tenancy/permissions.md +++ b/docs/modules/tenancy/permissions.md @@ -3,6 +3,12 @@ Per [Permission Standards](../../standards/19-permissions.md), which names this file. Part of the [module spec](README.md). +**P02b-1 Step 3 producer implemented — 2026-10-12.** The completed-provisioning +domain translator runs only during owner completion, adds no endpoint or +permission, and inserts a pending outbox row under the provisioning announcement. +Existing command reachability is unchanged. The +[integration-event catalogue](README.md#integration-event-catalogue) owns the flow. + **P02d-4 Step 2 implemented — 2026-10-03.** Both review rounds and focused fix verification pass. Site bootstrap is a marked GET/HEAD request, audit Off and without a permission key or grant. It diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 1a1c4937..92a520e1 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -45,7 +45,7 @@ not deferred to the showcase phase. - [Phase 01: Repository, Tooling, and Local Infrastructure](phase-01-repository-tooling.md) — **complete** - [Phase 02a: Platform Kernel, Multi-Tenancy, Organization, and Foundation Sockets](phase-02a-kernel-tenancy.md) — **complete** (packets 0–3, 3b and 4–10 shipped) - [Phase 02d: Two-Tenant Walking Skeleton](phase-02d-walking-skeleton.md) — - **implementation and exit verification complete; PR #28 review/merge pending**. + **complete and merged — 2026-10-11**. P02d-1 and P02d-2 complete and merged; the [P02d-2 closeout](phase-02d-walking-skeleton.md#p02d-2-merge-and-closeout-2026-10-02) records final verification. P02d-3 read internals are implemented; the @@ -89,9 +89,18 @@ not deferred to the showcase phase. proof pass real CI; the seventh required context is active. The [Step 4 record](phase-02d-walking-skeleton.md#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11) owns CI enforcement, reviews and the positive phase-exit verdict. - [PR #28](https://github.com/HodeTech/LearnStack/pull/28) awaits maintainer review - and merge. P02b-0 re-verification and decision pass are next afterward. + [PR #28](https://github.com/HodeTech/LearnStack/pull/28) is merged; its + [merge closeout](phase-02d-walking-skeleton.md#p02d-7-merge-and-closeout-2026-10-11) + owns final verification and the completed phase exit. - [Phase 02b: Events, Background Jobs, Identity, and Session](phase-02b-events-auth.md) + — **P02b-0 and P02b-1 complete; P02b-1 PR review/merge pending**. Its + [entry audit](phase-02b-events-auth.md#p02b-0-entry-verification-2026-10-12) + verifies the inherited baseline; Accepted ADR-0057 and the + [producer package](phase-02b-events-auth.md#p02b-1-decision-package-2026-10-12) + authorize the implemented lifecycle, enqueue and provisioning publisher. The + [P02b-1 closeout](phase-02b-events-auth.md#p02b-1-packet-closeout-2026-10-12) + records full backend verification and two completed review rounds per step. + Next is P02b-2's decision pass; dispatcher/consumer work remains P02b-2/P02b-3. - [Phase 03: Identity Domain, Authorization, and Admin Foundation](phase-03-identity-admin.md) - [Phase 04: Headless CMS, Page Builder, and Media Library](phase-04-cms-media-pages.md) - [Phase 05: Education Catalog and Learning Content](phase-05-education-learning-content.md) diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 0c2e64c9..f56c648c 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -1,5 +1,27 @@ # Phase 02b: Events, Background Jobs, Identity, and Session +**P02b-0 complete — 2026-10-12.** Phase 02d is complete and merged through PR #28; +its [merge closeout](phase-02d-walking-skeleton.md#p02d-7-merge-and-closeout-2026-10-11) +records the verified exit. The +[entry verification](#p02b-0-entry-verification-2026-10-12) reconciles the code, +corpus and Hub premises. P02b-1's +[decision package](#p02b-1-decision-package-2026-10-12) and ADR-0057 are +Accepted — 2026-10-12. G2, G3 derivation, G5 stored identities, G6 producer/common +ownership, G18 payload and G19 flow are Accepted; remaining gate parts stay open. +The four producer implementation steps are authorized. Step 1 lifecycle work is +implemented; both independent review rounds passed. The +[Step 1 record](#p02b-1-step-1--shared-execution-lifecycle-2026-10-12) owns +verification. Step 2 implements immediate enqueue; its +[record](#p02b-1-step-2--immediate-outbox-production-2026-10-12) owns verification +and both completed review rounds. Step 3 implements collection and the publisher; +its [record](#p02b-1-step-3--domain-completion-and-provisioning-publisher-2026-10-12) +records passing verification and both completed review rounds. Step 4 adds +production-census guards and catalogue enforcement; its +[record](#p02b-1-step-4--producer-enforcement-and-closeout-2026-10-12) records full +backend verification and both completed review rounds. **P02b-1 is complete; +PR review and merge remain pending.** The +[packet closeout](#p02b-1-packet-closeout-2026-10-12) owns the next P02b-2 handoff. + **Phase 02d handoff — 2026-10-11.** P02b-0 re-verifies the delivered `/api/v1` public contracts, native HTTPS on retained `*.learnstack.local` seed hosts, manual hosts/trust setup and the shared private hop-secret source. ADR-0053/0054 @@ -73,10 +95,12 @@ keeps the phase from re-deciding settled questions or repairing repaired defects [ADR-0044](../decisions/0044-audit-write-path.md). This phase adds the entry points it does not yet cover. -What is genuinely absent: `IOutbox`, `IInboxGuard`, `inbox_messages`, the lease columns, -`OutboxProcessor`, any `BackgroundService` or `IHostedService`, Hangfire and its package -pins, the realm's protocol mappers, and a body for `OutboxFlushBehavior` — still -`return next()` behind a Phase 02b `TODO`. Packet 5 shipped the `IInboxGuard` **seam**, +At the [P02b-0 entry audit](#p02b-0-entry-verification-2026-10-12), `IOutbox` +and step 7's implementation were absent. P02b-1 Step 2 now supplies immediate +enqueue and the enrollment/invariant guard. Still absent are `IInboxGuard`, +`inbox_messages`, the lease columns, `OutboxProcessor`, any `BackgroundService` +or `IHostedService`, Hangfire and its package pins, and the realm's protocol +mappers. Packet 5 shipped the `IInboxGuard` **seam**, which is the name in three comments — two XML doc comments in `SharedKernel.Messaging` and the composition root's transport `TODO` — and in no declaration; the guard, its table and its semantics are this phase's. @@ -169,12 +193,14 @@ Each item below is named somewhere as adjacent to this phase's work. Each has an this phase, on the trigger that a second contributor gains write access. The trigger has not fired; `P02b-8` picks them up if it does, and otherwise they travel to the phase that inherits them. -- **Browser end-to-end tests.** +- **The full authenticated browser end-to-end suite.** [Testing Standards § End-to-End Tests](../standards/06-testing.md#end-to-end-tests) - assigns Playwright over a running stack to - [Phase 06](phase-06-renderer-admin-studio.md). This phase proves its browser-facing - claims with route-level tests in the `frontend` job and container-backed token tests - in `backend-integration`, and says so rather than writing "tested end to end". + assigns the full Playwright Test/axe suite to + [Phase 06](phase-06-renderer-admin-studio.md). P02d-7 already delivers a narrow + real-stack public browser/Lighthouse proof, which remains enforced. This phase + adds route-level session tests, container-backed token tests and the live cookie + isolation checks its completion criteria name; those do not constitute a full + browser-driven OIDC journey. - **Production realm provisioning.** Every realm change this phase makes is a requirement on the production identity definition [Phase 11](phase-11-production-hardening.md) owns; the changes here land in the dev @@ -255,51 +281,858 @@ whose code it shapes, together with the gates coupled to it; where its parts sha different packets, the **Blocks** cell names the part each packet waits on. Every premise a row cites is re-verified at that pass rather than trusted. -| # | Question | Leaning | Vehicle | Blocks | -|---|---|---|---|---| -| G1 | What is the subscriber's fate when its handler exhausts retries, and what is the inbox table — one shared or per module, keyed `event_id` or `(tenant_id, consumer, event_id)`, claimed by the handler or by the transport? Three arms travel with it: the class of consumer whose effect cannot share the inbox transaction (an external call, the platform-scope erasure handler the Audit spec already carries) and what "effectively once" means for it; whether an event without the organization marker is delivered tenant-wide regardless of the producing request's organization; and whether the inbox entity joins [ADR-0044 § 7](../decisions/0044-audit-write-path.md)'s named audit-capture exclusion list once a delivery declares intents | One shared tenant-owned `inbox_messages`, transport-claimed; the terminal state in its own store so "the inbox row is not marked processed" stays true | **ADR-0046** + a dated **ADR-0006 Amendment** (or an explicit supersession note in ADR-0046 naming Amendment 1's consumer-side paragraph, which fixes the per-module inbox) + a dated **ADR-0010 Amendment**, because its § Idempotency makes two things mandatory that the shared-table leaning changes — the inbox is "the module's **own** inbox table", and the guard is called by the consumer — + the `Integration_Event_Handlers_Use_InboxGuard` assertion re-written in the same record to name whoever ends up owning the call, since a transport-claimed delivery fails a structural rule that looks for the call inside the handler + Database Standards § Inbox + an ADR-0041 erratum beside ADR-0023's minting list | P02b-2 (the record and its amendments: the dispatcher's retry state and a failed delivery's next attempt are one state machine), P02b-3 (Database Standards § Inbox and the ADR-0023 erratum) | -| G2 | Does `IOutbox.EnqueueAsync` write the row immediately on the ambient transaction, or buffer for `OutboxFlushBehavior` at step 7 — and what is the flush point for the two paths that have no step 7? | Immediate write, with the behavior asserting no buffered message survives the handler | Phase-doc statement + [Events and Outbox § Producer pattern](../architecture/15-event-and-outbox.md#producer-pattern); a dated ADR-0032 Amendment if step 7 stops being a flush point | P02b-1 | -| G3 | What replaces the unconditional same-key publish-order promise — a scoped bound, or strict per-key head-of-line blocking — and is the key's derivation checkable structurally, given that [§ Ordering](../architecture/15-event-and-outbox.md#ordering) admits an aggregate id or a deliberately declared tenant-wide key and nothing else? | Scope it: no column in `outbox_messages` carries an unconditional per-key guarantee, so the honest bound is one processor, no failure, enqueue order | Scoping is a phase-doc plus [§ Ordering](../architecture/15-event-and-outbox.md#ordering) edit; blocking is a dated ADR-0006 Amendment plus an index | P02b-1 (whether the key's derivation is checked structurally: the first event declares a key), P02b-2 (the ordering bound) | -| G4 | What counts as an attempt, what is `MaxAttempts` bound to, and what marker keeps a terminal row terminal as the clock advances? | Six attempts (one immediate plus the five documented delays) and an explicit terminal column, because a pushed-out `available_after` stops being terminal and never leaves the pending gauge | Dated ADR-0006 Amendment + the Database Standards § Outbox DDL, index and grant edits riding it | P02b-2 | -| G5 | Does the stored event identity stay the assembly-qualified type name with declared compatibility mappings, and what happens to an unresolvable type, a retired type and an event with no subscriber? And what is the **row's** identity — a surrogate `id` plus an `event_id` column, or `EventId` as the key — given that two documents list an `event_id` the canonical DDL does not have? | Keep the type identity, add a bounded resolver reading through `PayloadJsonOptions` and declared mappings; dead-letter a retired type at once rather than spending its attempt budget; reconcile the row's column list in one direction | Phase-doc + Database Standards § Outbox if kept; a dated ADR-0006 Amendment if the type identity is replaced, and a dated ADR-0032 Amendment if the `event_id` column is withdrawn from its § Sub-decision 12 list | P02b-1 (the stored type identity and the row's identity, both written at enqueue), P02b-2 (unresolvable, retired and unsubscribed types) | -| G6 | When the unit-of-work owner is not an `AuditLogBehavior`, who performs the four owner-side audit acts and enters the outermost audit frame — and does a state-mutating delivery declare its own intents, keyed by handler type? | The transport opens the outermost frame beside its transaction; a delivery declares its own intents and an unregistered handler is refused as an unregistered request is | One dated diff: an **ADR-0044** amendment, carried into **ADR-0033** and **ADR-0040** | P02b-1, P02b-3 | -| G7 | For a dead-letter transition: what class, what `OperationType`, what slug, on which transaction, in what order against the state change, with what failure posture — and may a second non-MediatR caller reach `IAuditStore.WriteStandaloneAsync`? | class MUST, `OperationType` `security-event`, the slug open under the § The join grammar `{module}.{resource}.{verb}`; the row written before the transition, so a failed write leaves the transition unmade and the next poll retries | Dated ADR-0033 Amendment + `DeclareOffPath` registrations + the Baseline row in [Audit Coverage Standards](../standards/18-audit-coverage.md#baseline-coverage-learnstack-core-modules). If the chosen ordering persists the terminal transition and its MUST row on **separate** transactions, that changes what ADR-0033's Decision guarantees rather than clarifying it, and the vehicle is a superseding record, not an amendment | P02b-2 (the producer dead-letter row its criteria assert), P02b-3 | -| G8 | Does the dispatcher's `BYPASSRLS` use carry a per-invocation `security-event` row, or is it exempt — and on what written terms? | Exempt, on three terms: the bypass is grant-bounded and code-path-confined, dispatch attempts are logged rather than audited, and the dead-letter transitions are the audited events | Dated ADR-0006 or ADR-0003 Amendment; an architecture document cannot grant it | P02b-2 | -| G9 | Which record decides the background-job runtime — its storage under the closed four-role model, the single tenant-context writer, the job frame's transaction owner, the enqueue-site rule *and who may enqueue whose job type*, the retry contract (whose class, slug and posture adopt **G7**'s), the tenantless platform-job class a purge needs, the ADR-0035 classification, the queue grammar, and the package pins with their licence verdict? | One record answering all of it; a job reaches the database only through `ISender`, which leaves ADR-0040's closed setter set at eight | **ADR-0047** + a dated ADR-0003 Amendment for the storage + a dated **ADR-0036 Amendment** (or an erratum) if the job-path writer is not the `JobActivator` its § Rules names in the closed four-caller set, then Standards 05 and Standards 20 | P02b-4 | -| G10 | Does the active tenant travel as a scalar `tenant_id` claim, a `memberships` array, or both — and what value does `UserId` hold? | The scalar claim, whose 02b source is an admin-set user attribute; a second UUID-valued `user_id` attribute for the actor, with `sub` staying Keycloak's subject | Phase-doc: [ADR-0036 § What is out of scope](../decisions/0036-tenant-resolution-trusted-inputs.md#what-is-out-of-scope-and-what-is-not) hands the shape to this phase by name. Reconcile `architecture/13`'s array sketch in the same change | P02b-5 | -| G11 | Which [ADR-0036 matrix](../decisions/0036-tenant-resolution-trusted-inputs.md#the-reconciliation-matrix) rows resolve in this phase, given that row 10 consults a reader that denies everyone — and what row covers a signature-valid token carrying no `tenant_id` claim on a tenant host? | Either mint `organization_id` scoped to the host's organization, which makes row 10 need no membership read, or declare the organization host's authenticated 404 and test it as the expected outcome | A dated **ADR-0036** amendment plus errata beside § Staging across packets and the 2026-09-02 erratum, both of which assign row 10 to this phase without noting that its `M covers (T, O)` term is denied until Phase 03 — which groups it with rows 7 and 14 rather than with 6 and 9 | P02b-5 | -| G12 | Which Keycloak client does the BFF exchange the code with, where does sign-in happen across two tenant hosts, and how do the client scope, the mappers and the seed users reach a Keycloak database that has already consumed the import? Native HTTPS on retained `*.learnstack.local` hosts, with manual hosts/trust setup, is delivered by Phase 02d and re-verified by P02b-0 | A confidential BFF client with one redirect URI per seed host, retaining native HTTPS and `Secure` cookies; and an idempotent reconciler inside `make seed` — a mapper added only to the import reaches no existing workstation | Phase-doc + the realm JSON + `scripts/seed.sh` + `infra/keycloak/README.md`; a new ADR only if the reconciler becomes ADR-0004's `IIdentityProvider` | P02b-0 re-verifies [Phase 02d G32](phase-02d-walking-skeleton.md#the-decision-register), ADR-0053/0054 and ADR-0055; P02b-5 (which client the BFF redeems the code with: the API pins `azp` against it), P02b-6 | -| G13 | What is the BFF session — its custody of the refresh token, its store, its idle and absolute lifetimes, its refresh serialisation under concurrent requests, its terminal `invalid_grant` behaviour, its CSRF control and its logout — and which document owns the access-token lifetime? | The `HttpOnly` cookie Security Standards already describes, encrypted by the session adapter, read only by server code, with single-flight refresh and one authority for the TTL | Phase-doc + one reconciled [Security Standards § Authentication](../standards/11-security.md#authentication); a **new ADR** only for a server-side session store | P02b-6 (which document owns the access-token lifetime: the realm export and its reconciler write it), P02b-7 | -| G14 | What bounds the request types reachable over HTTP while `AuthorizationBehavior` is `return next()`, and what is the shape of the token-keyed rate-limit stage behind a shared BFF connection? | An enumerated read-only routable set held by a catalogue-registered rule, and a limiter stage after `UseAuthentication` keyed on the validated subject. The early anonymous stage already runs before authentication: P02d-5 delivers 60/min per canonical direct or authenticated-hop visitor IP plus 600/min peer acquisitions/allocations under ADR-0053 and ADR-0054. Exhausted known visitors refuse before peer debit; unknown visitor allocation remains peer-gated. These remain IP/peer quotas, not token quotas; a request they reject never reaches the token stage. Re-verify the [P5 remediation decision](phase-02d-walking-skeleton.md#p02d-5-external-review-remediation-2026-10-09) before choosing authenticated accounting, preserving pre-classification protection against unknown-host abuse | Phase-doc + catalogue registration; a dated ADR-0032 Amendment for a deny-by-default step instead | P02b-5 (the token-keyed stage and the early stage's treatment of validated callers); G34's anonymous-hop policy and ADR-0054's bounded accounting replacement are Accepted and delivered in P02d-5 | -| G15 | What initiates a replay in this phase? | A dispatcher-level operation on the dispatcher's own connection, with the operator surface deferred | Phase-doc; the URL space is its own decision record, per ADR-0036 | — (answered in § The consumer side; recorded so the question travels with the deferral) | -| G16 | What is `LS0002`'s rule name, which project trees does it run over, what does it inspect, and what escalates it to Error? | The name and scope recorded the way Amendment 1 recorded `LS0001`, with the scope reaching the assemblies that actually handle tokens | A dated **ADR-0032** amendment + a catalogue entry + an `AnalyzerReleases` row | P02b-8 | -| G17 | What are the label sets for the event and job metrics, is the tenant axis permitted, and is it spelled `tenant` or `tenant_id`? | One note in the document that owns metric names, no raw tenant label on a per-event series, and an oldest-eligible-row age metric separate from the pending count | [Observability Standards § Required Metrics](../standards/10-observability.md#required-metrics) edit | P02b-2 (the first series ships there, and a label added afterwards touches every emitter already written) | -| G18 | May an integration-event payload or a job argument carry personal data, what may `last_error` contain, and do the four new durable stores enter the erasure scope? | Identifiers only, a sanitized and length-bounded failure descriptor, and the stores named in the erasure inventory | A dated **ADR-0038** amendment for the payload rule; [Data Protection § Right to Erasure](../architecture/23-data-protection.md#right-to-erasure-right-to-be-forgotten) and Phase 11 for the rest | P02b-1 (the payload rule: the first payload ships there), P02b-2 (the `last_error` rule and its DDL bound), P02b-8 (the erasure inventory) | -| G19 | Where do the sample flow's publisher and consumer live — a fixture pair in the integration assembly, or a real event a shipped module consumes? | A real event between two shipped domain modules, because a test-assembly-only consumer leaves every module-scoped sweep with zero subjects | Phase-doc + the Tenancy module spec, which books three `learnstack.tenancy.*` rows to this phase and names Audit as a consumer of one, and the Audit module spec, which says it consumes nothing and has no inbox — a disagreement whichever pair G19 picks has to resolve. If the pick is a provisioning event, the flow needs its own proof: provisioning leaves `ITenantContext` unresolved and announces its tenant on the transaction instead, so an enqueue check that reads the ambient context would refuse the seed's own first event | P02b-1 | -| G20 | What does an anonymous request to a **routed non-public** request type on a live tenant host answer — and does the catalogued `Backend_RequiresJwt_OnAllAuthenticatedRoutes` narrow to a surface outside tenant resolution? A second half rides with it: the shipped assertion middleware says that from this phase the refusal code differs by caller, `tenant_mismatch` for an authenticated one and `not_found` for an anonymous one, written by the middleware itself | The matrix wins on a tenant host — [row 2](../decisions/0036-tenant-resolution-trusted-inputs.md#the-reconciliation-matrix) answers 404, byte-identical to an unknown host's, and the 401 rule narrows to the surface where a 401 discloses nothing | The dated **ADR-0036** amendment G11 needs + a catalogue edit to that rule's Asserts line | P02b-5 | +| # | Question | Leaning | Vehicle | Blocks | Status | +|---|---|---|---|---|---| +| G1 | What is the subscriber's fate when its handler exhausts retries, and what is the inbox table — one shared or per module, keyed `event_id` or `(tenant_id, consumer, event_id)`, claimed by the handler or by the transport? Three arms travel with it: the class of consumer whose effect cannot share the inbox transaction (an external call, the platform-scope erasure handler the Audit spec already carries) and what "effectively once" means for it; whether an event without the organization marker is delivered tenant-wide regardless of the producing request's organization; and whether the inbox entity joins [ADR-0044 § 7](../decisions/0044-audit-write-path.md)'s named audit-capture exclusion list once a delivery declares intents | One shared tenant-owned `inbox_messages`, transport-claimed; the terminal state in its own store so "the inbox row is not marked processed" stays true | **ADR-0046** + a dated **ADR-0006 Amendment** (or an explicit supersession note in ADR-0046 naming Amendment 1's consumer-side paragraph, which fixes the per-module inbox) + a dated **ADR-0010 Amendment**, because its § Idempotency makes two things mandatory that the shared-table leaning changes — the inbox is "the module's **own** inbox table", and the guard is called by the consumer — + the `Integration_Event_Handlers_Use_InboxGuard` assertion re-written in the same record to name whoever ends up owning the call, since a transport-claimed delivery fails a structural rule that looks for the call inside the handler + Database Standards § Inbox + an ADR-0041 erratum beside ADR-0023's minting list | P02b-2 (the record and its amendments: the dispatcher's retry state and a failed delivery's next attempt are one state machine), P02b-3 (Database Standards § Inbox and the ADR-0023 erratum) | Open | +| G2 | Does `IOutbox.EnqueueAsync` write the row immediately on the ambient transaction, or buffer for `OutboxFlushBehavior` at step 7 — and what is the flush point for the two paths that have no step 7? | Immediate write, with the behavior asserting no buffered message survives the handler | [Accepted ADR-0057](../decisions/0057-transactional-event-production-and-completion.md) bounded supersession of ADR-0032 Sub-decision 2's step-7 completion role, with a dated supersession disclosure in ADR-0032 at acceptance; phase-doc + [Events and Outbox § Producer pattern](../architecture/15-event-and-outbox.md#producer-pattern) | P02b-1 | Accepted — immediate enqueue/owner completion | +| G3 | What replaces the unconditional same-key publish-order promise — a scoped bound, or strict per-key head-of-line blocking — and is the key's derivation checkable structurally, given that [§ Ordering](../architecture/15-event-and-outbox.md#ordering) admits an aggregate id or a deliberately declared tenant-wide key and nothing else? | Scope it: no column in `outbox_messages` carries an unconditional per-key guarantee, so the honest bound is one processor, no failure, enqueue order | Derivation: [Accepted ADR-0057](../decisions/0057-transactional-event-production-and-completion.md). Ordering: scoping is a phase-doc plus [§ Ordering](../architecture/15-event-and-outbox.md#ordering) edit; blocking is a dated ADR-0006 Amendment plus an index | P02b-1 (whether the key's derivation is checked structurally: the first event declares a key), P02b-2 (the ordering bound) | Derivation Accepted; ordering open (P02b-2) | +| G4 | What counts as an attempt, what is `MaxAttempts` bound to, and what marker keeps a terminal row terminal as the clock advances? | Six attempts (one immediate plus the five documented delays) and an explicit terminal column, because a pushed-out `available_after` stops being terminal and never leaves the pending gauge | Dated ADR-0006 Amendment + the Database Standards § Outbox DDL, index and grant edits riding it | P02b-2 | Open | +| G5 | Does the stored event identity stay the assembly-qualified type name with declared compatibility mappings, and what happens to an unresolvable type, a retired type and an event with no subscriber? And what is the **row's** identity — a surrogate `id` plus an `event_id` column, or `EventId` as the key — given that two documents list an `event_id` the canonical DDL does not have? | Keep the type identity, add a bounded resolver reading through `PayloadJsonOptions` and declared mappings; dead-letter a retired type at once rather than spending its attempt budget; reconcile the row's column list in one direction | Stored identities: [Accepted ADR-0057](../decisions/0057-transactional-event-production-and-completion.md) bounded supersession of ADR-0032 Sub-decision 12 and the Phase 02b deliverables table in Implementation Notes, with a dated supersession disclosure at acceptance; Database Standards alignment. Resolver outcomes remain a P02b-2 decision; replacing the type identity requires its own ADR-0006 vehicle | P02b-1 (the stored type identity and the row's identity, both written at enqueue), P02b-2 (unresolvable, retired and unsubscribed types) | Stored identities Accepted; resolution outcomes open (P02b-2) | +| G6 | When the unit-of-work owner is not an `AuditLogBehavior`, who performs the four owner-side audit acts and enters the outermost audit frame — and does a state-mutating delivery declare its own intents, keyed by handler type? | The transport opens the outermost frame beside its transaction; a delivery declares its own intents and an unregistered handler is refused as an unregistered request is | [Accepted ADR-0057](../decisions/0057-transactional-event-production-and-completion.md): bounded ADR-0040 nested-failure supersession; separately enumerated additive UoW read-seam amendment; clarifying ADR-0044/0033 common-owner amendments. G1-dependent production delivery/inbox implementation remains P02b-3 | P02b-1, P02b-3 | Producer/common owner Accepted; production delivery/inbox open (P02b-3 after G1) | +| G7 | For a dead-letter transition: what class, what `OperationType`, what slug, on which transaction, in what order against the state change, with what failure posture — and may a second non-MediatR caller reach `IAuditStore.WriteStandaloneAsync`? | class MUST, `OperationType` `security-event`, the slug open under the § The join grammar `{module}.{resource}.{verb}`; the row written before the transition, so a failed write leaves the transition unmade and the next poll retries | Dated ADR-0033 Amendment + `DeclareOffPath` registrations + the Baseline row in [Audit Coverage Standards](../standards/18-audit-coverage.md#baseline-coverage-learnstack-core-modules). If the chosen ordering persists the terminal transition and its MUST row on **separate** transactions, that changes what ADR-0033's Decision guarantees rather than clarifying it, and the vehicle is a superseding record, not an amendment | P02b-2 (the producer dead-letter row its criteria assert), P02b-3 | Open | +| G8 | Does the dispatcher's `BYPASSRLS` use carry a per-invocation `security-event` row, or is it exempt — and on what written terms? | Exempt, on three terms: the bypass is grant-bounded and code-path-confined, dispatch attempts are logged rather than audited, and the dead-letter transitions are the audited events | Dated ADR-0006 or ADR-0003 Amendment; an architecture document cannot grant it | P02b-2 | Open | +| G9 | Which record decides the background-job runtime — its storage under the closed four-role model, the single tenant-context writer, the job frame's transaction owner, the enqueue-site rule *and who may enqueue whose job type*, the retry contract (whose class, slug and posture adopt **G7**'s), the tenantless platform-job class a purge needs, the ADR-0035 classification, the queue grammar, and the package pins with their licence verdict? | One record answering all of it; a job reaches the database only through `ISender`, which leaves ADR-0040's closed setter set at eight | **ADR-0047** + a dated ADR-0003 Amendment for the storage + a dated **ADR-0036 Amendment** (or an erratum) if the job-path writer is not the `JobActivator` its § Rules names in the closed four-caller set, then Standards 05 and Standards 20 | P02b-4 | Open | +| G10 | Does the active tenant travel as a scalar `tenant_id` claim, a `memberships` array, or both — and what value does `UserId` hold? | The scalar claim, whose 02b source is an admin-set user attribute; a second UUID-valued `user_id` attribute for the actor, with `sub` staying Keycloak's subject | Phase-doc: [ADR-0036 § What is out of scope](../decisions/0036-tenant-resolution-trusted-inputs.md#what-is-out-of-scope-and-what-is-not) hands the shape to this phase by name. Reconcile `architecture/13`'s array sketch in the same change | P02b-5 | Open | +| G11 | Which [ADR-0036 matrix](../decisions/0036-tenant-resolution-trusted-inputs.md#the-reconciliation-matrix) rows resolve in this phase, given that row 10 consults a reader that denies everyone — and what row covers a signature-valid token carrying no `tenant_id` claim on a tenant host? | Either mint `organization_id` scoped to the host's organization, which makes row 10 need no membership read, or declare the organization host's authenticated 404 and test it as the expected outcome | A dated **ADR-0036** amendment plus errata beside § Staging across packets and the 2026-09-02 erratum, both of which assign row 10 to this phase without noting that its `M covers (T, O)` term is denied until Phase 03 — which groups it with rows 7 and 14 rather than with 6 and 9 | P02b-5 | Open | +| G12 | Which Keycloak client does the BFF exchange the code with, where does sign-in happen across two tenant hosts, and how do the client scope, the mappers and the seed users reach a Keycloak database that has already consumed the import? Native HTTPS on retained `*.learnstack.local` hosts, with manual hosts/trust setup, is delivered by Phase 02d and re-verified by P02b-0 | A confidential BFF client with one redirect URI per seed host, retaining native HTTPS and `Secure` cookies; and an idempotent reconciler inside `make seed` — a mapper added only to the import reaches no existing workstation | Phase-doc + the realm JSON + `scripts/seed.sh` + `infra/keycloak/README.md`; a new ADR only if the reconciler becomes ADR-0004's `IIdentityProvider` | P02b-0 re-verifies [Phase 02d G32](phase-02d-walking-skeleton.md#the-decision-register), ADR-0053/0054 and ADR-0055; P02b-5 (which client the BFF redeems the code with: the API pins `azp` against it), P02b-6 | Open | +| G13 | What is the BFF session — its custody of the refresh token, its store, its idle and absolute lifetimes, its refresh serialisation under concurrent requests, its terminal `invalid_grant` behaviour, its CSRF control and its logout — and which document owns the access-token lifetime? | The `HttpOnly` cookie Security Standards already describes, encrypted by the session adapter, read only by server code, with single-flight refresh and one authority for the TTL | Phase-doc + one reconciled [Security Standards § Authentication](../standards/11-security.md#authentication); a **new ADR** only for a server-side session store | P02b-6 (which document owns the access-token lifetime: the realm export and its reconciler write it), P02b-7 | Open | +| G14 | What bounds the request types reachable over HTTP while `AuthorizationBehavior` is `return next()`, and what is the shape of the token-keyed rate-limit stage behind a shared BFF connection? | An enumerated read-only routable set held by a catalogue-registered rule, and a limiter stage after `UseAuthentication` keyed on the validated subject. The early anonymous stage already runs before authentication: P02d-5 delivers 60/min per canonical direct or authenticated-hop visitor IP plus 600/min peer acquisitions/allocations under ADR-0053 and ADR-0054. Exhausted known visitors refuse before peer debit; unknown visitor allocation remains peer-gated. These remain IP/peer quotas, not token quotas; a request they reject never reaches the token stage. Re-verify the [P5 remediation decision](phase-02d-walking-skeleton.md#p02d-5-external-review-remediation-2026-10-09) before choosing authenticated accounting, preserving pre-classification protection against unknown-host abuse | Phase-doc + catalogue registration; a dated ADR-0032 Amendment for a deny-by-default step instead | P02b-5 (the token-keyed stage and the early stage's treatment of validated callers); G34's anonymous-hop policy and ADR-0054's bounded accounting replacement are Accepted and delivered in P02d-5 | Open | +| G15 | What initiates a replay in this phase? | A dispatcher-level operation on the dispatcher's own connection, with the operator surface deferred | Phase-doc; the URL space is its own decision record, per ADR-0036 | — (answered in § The consumer side; recorded so the question travels with the deferral) | Answered in consumer scope; implementation pending | +| G16 | What is `LS0002`'s rule name, which project trees does it run over, what does it inspect, and what escalates it to Error? | The name and scope recorded the way Amendment 1 recorded `LS0001`, with the scope reaching the assemblies that actually handle tokens | A dated **ADR-0032** amendment + a catalogue entry + an `AnalyzerReleases` row | P02b-8 | Open | +| G17 | What are the label sets for the event and job metrics, is the tenant axis permitted, and is it spelled `tenant` or `tenant_id`? | One note in the document that owns metric names, no raw tenant label on a per-event series, and an oldest-eligible-row age metric separate from the pending count | [Observability Standards § Required Metrics](../standards/10-observability.md#required-metrics) edit | P02b-2 (the first series ships there, and a label added afterwards touches every emitter already written) | Open | +| G18 | May an integration-event payload or a job argument carry personal data, what may `last_error` contain, and do the four new durable stores enter the erasure scope? | Identifiers only, a sanitized and length-bounded failure descriptor, and the stores named in the erasure inventory | [Accepted ADR-0057](../decisions/0057-transactional-event-production-and-completion.md) decides the new producer payload/NULL JSON metadata policy and application byte cap; a dated ADR-0038 cross-reference at acceptance. [Data Protection § Right to Erasure](../architecture/23-data-protection.md#right-to-erasure-right-to-be-forgotten), P02b-8 and Phase 11 retain erasure ownership | P02b-1 (the payload rule: the first payload ships there), P02b-2 (the `last_error` rule and its DDL bound), P02b-8 (the erasure inventory) | Payload Accepted; failure descriptors and erasure open (P02b-2/P02b-8) | +| G19 | Where do the sample flow's publisher and consumer live — a fixture pair in the integration assembly, or a real event a shipped module consumes? | A real event between two shipped domain modules, because a test-assembly-only consumer leaves every module-scoped sweep with zero subjects | [Accepted ADR-0057](../decisions/0057-transactional-event-production-and-completion.md) and the P02b-1 package; reconcile Tenancy/Customization/Audit specs at acceptance. G1 must settle distinct subscription deduplication and delivery scope before P02b-3 implements the two effects and Seeder handoff. The provisioning publisher must prove enqueue from its transaction announcement while ambient context remains unresolved | P02b-1 | Flow Accepted; subscriptions/Seeder handoff pending P02b-3 after G1 | +| G20 | What does an anonymous request to a **routed non-public** request type on a live tenant host answer — and does the catalogued `Backend_RequiresJwt_OnAllAuthenticatedRoutes` narrow to a surface outside tenant resolution? A second half rides with it: the shipped assertion middleware says that from this phase the refusal code differs by caller, `tenant_mismatch` for an authenticated one and `not_found` for an anonymous one, written by the middleware itself | The matrix wins on a tenant host — [row 2](../decisions/0036-tenant-resolution-trusted-inputs.md#the-reconciliation-matrix) answers 404, byte-identical to an unknown host's, and the 401 rule narrows to the surface where a 401 discloses nothing | The dated **ADR-0036** amendment G11 needs + a catalogue edit to that rule's Asserts line | P02b-5 | Open | + +### P02b-0 entry verification (2026-10-12) + +**Complete.** This packet verifies entry premises and corrects current-state +claims. It writes no runtime code, drafts no ADR, accepts no later packet's gate +and introduces no glossary definition ahead of its owning decision pass. + +#### Baseline and inherited enforcement + +The audit used LearnStack `development` at +`3e86041dc32009ae6120c8cbb1533fd5d1325465`, tree +`7aac9df1ed19f0826b042fbd1a303dc92c9ab11a`. That commit reconciles the merged +P02d-7 source with PR #28's squash on `main`; the +[P7 merge closeout](phase-02d-walking-skeleton.md#p02d-7-merge-and-closeout-2026-10-11) +owns the merge/tree comparison and the completed phase exit. + +The post-merge [CI run 38129121225](https://github.com/HodeTech/LearnStack/actions/runs/38129121225) +completed successfully on `main` at +`d37f46ccf0ebbf909f02c81a59d9019af3e9a1ca`. A fresh branch-protection API read +on 2026-10-12 confirmed strict up-to-date protection and all seven required +contexts: `backend (build + unit + arch + contract)`, `frontend (typecheck + lint ++ build + test)`, `secret scan (leakwatch)`, `meta (compose + commit hygiene + +link audit)`, `backend integration (Testcontainers)`, `openapi diff`, and +`public demo + lighthouse`. This receipt does not claim a new local full-stack +run or change those settings. + +The Hub checkout was inspected read-only at +`6adf93638e76114eda9c18a5095d0fe3c0fd43b1`; fetched `origin/main` was +`310f718e5f2bad0dfb51e2c307c1ec361dab5952`. The checkout is an ancestor of that +main and their trees are identical: +`c5c380cfee42c6a32923d26d604ada7da2d5dc45`. Its roadmap still freezes P02c-2 +onward pending the documented billing/plan-gate demand and ADR-0034 invariants. +Its P02c-3 waits on LearnStack's durable event path, not the BFF session. +This audit neither opens that track nor changes its contract surface. + +The retained public baseline is native HTTPS, signed ingress, one live +middleware bootstrap and request-local immutable admission. Anonymous public +reads use the host boundary, physical read-only transactions, query filters and +RLS. They do not forward cookies or bearer credentials. The early limiter still +coordinates 60/min canonical visitors and 600/min physical-peer acquisitions/ +allocations; known visitor refusals do not debit that peer budget. P02b-5 must +decide authenticated accounting without weakening this pre-classification bound. + +#### Gate-premise audit + +All twenty register rows were checked against their cited records and the +current implementation. **Verified** below means the premise is confirmed; +it does not mean the proposed answer is Accepted. G15 remains the register's +already-answered replay boundary. Every other row retains its decision vehicle +and first dependent packet above. + +| Gate | Verified current premise and disposition | First decision consumer | +|---|---|---| +| G1 | No `IInboxGuard` declaration, inbox table or durable subscription failure state exists. ADR-0006/0010 still require a per-module, consumer-called inbox. Shared transport ownership remains a proposal requiring the registered record/amendments. | P02b-2 record; P02b-3 DDL | +| G2 | `OutboxFlushBehavior` only calls `next()`; no `IOutbox` exists. Immediate enqueue versus a buffer/flush policy remains undecided, including paths outside MediatR. | P02b-1 | +| G3 | The envelope requires a partition key and the in-process serializer orders invocation per key. The table has no durable sequence or retry ordering guarantee; key derivation and the dispatcher bound remain separate decisions. | P02b-1 derivation; P02b-2 bound | +| G4 | The table has `attempts`, `available_after` and `processed_at`, but no explicit terminal marker or lease columns. Five published retry delays do not themselves settle attempt counting or terminal state. | P02b-2 | +| G5 | Canonical DDL has a surrogate `id` but no `event_id`; ADR-0032 lists both. No durable type resolver or enqueue implementation resolves that mismatch yet. | P02b-1 identities; P02b-2 resolution | +| G6 | The bus restores the accessor and creates handler scopes without opening a transaction or audit frame. Pipeline audit ownership cannot simply be assumed for delivery/domain-event owners. No aggregate currently raises a domain event. | P02b-1; P02b-3 | +| G7 | No producer/subscriber terminal writer exists. The MUST durability contract, off-path caller restriction and transaction atomicity still apply; a multi-transaction alternative needs the vehicle the register names. | P02b-2; P02b-3 | +| G8 | The outbox-admin role and its bounded grants exist; a running dispatcher does not. No Accepted dispatcher-specific audit exemption has been added by this audit. | P02b-2 | +| G9 | No job runtime, package pin, storage, activator or enqueue guard is shipped. The closed four-role and tenant-context-writer contracts remain constraints, not implicit permission for a new runtime. | P02b-4, after P02b-3 | +| G10 | The API has no JwtBearer wiring. Dev users carry slug-valued tenant attributes without explicit protocol mappers; the scalar/array and actor-UUID choices remain open. | P02b-5 | +| G11 | The resolver's claim insertion point is reserved; membership reads still deny everyone. Tenant-only authentication on an organization host and a claimless valid token need explicit outcomes before activation. | P02b-5 | +| G12 | Delivered HTTPS/host/hop/bootstrap behavior is retained. Dev redirects still target localhost HTTP, and `make seed` polls realm readiness without reconciling mappers or warm users. | P02b-5 client; P02b-6 realm | +| G13 | No Auth.js session/helper exists. Access-token lifetimes in the realm and target documents differ; storage, refresh, CSRF and logout remain decisions before their first writes. | P02b-6 TTL; P02b-7 session | +| G14 | `AuthorizationBehavior` remains pass-through. Current HTTP product operations are public reads; anonymous coordinated admission already runs before classification. A protected read census and validated-subject quota do not yet exist. | P02b-5 | +| G15 | Replay remains an internal dispatcher operation for this phase; the operator URL/permission belongs to Phase 03. No recovery endpoint is shipped or authorized here. | Answered; P02b-3 implements | +| G16 | Only `LS0001` is shipped. `LS0002` needs scope, token-source parity, severity/escalation and its release/catalogue entries; it is not a current build-breaking guard. | P02b-8 | +| G17 | Metric carriers disagree on tenant-label spelling and cardinality. Dispatcher heartbeat and oldest-eligible-row age have no emitter yet. Resolve the label set before the first series ships. | P02b-2 | +| G18 | No shipped integration payload/job argument commits this phase to PII. `last_error` is unbounded text today; the future outbox, inbox, subscriber-failure and job stores need the registered erasure inventory. | P02b-1 payload; P02b-2 failure; P02b-8 erasure | +| G19 | No production integration event is published/consumed. Tenancy books three future topics while Audit consumes none. Typed settings are uncached, so a settings-invalidation consumer needs a real effect rather than an artificial cache. Provisioning also has an unresolved ambient context despite its transaction announcement. | P02b-1 | +| G20 | Only public product reads are routed. Existing non-public 404/401 and authenticated assertion-code questions remain unresolved; authentication must not silently choose framework defaults over the matrix. | P02b-5 | + +Implementation evidence was read in the pipeline's `OutboxFlushBehavior`, +`TransactionBehavior` and `AuditLogBehavior`; the platform infrastructure +migration; `InProcessEventBus`, `IntegrationEventEnvelope` and +`EventTenantContext`; API startup, resolver and rate limiter; the Keycloak realm +exports and `scripts/seed.sh`; the uncached `TenantSettingsAccessor`; and the +web middleware, admission/resource loaders and configured caller. Existing +architecture and integration tests were inspected to distinguish accessor +restoration from database announcement and durable delivery. + +#### Reconciliation and next packet + +Current-state corrections in this packet cover the separate seeder executable, +the already-delivered public browser proof, absent API authentication, the +organization-claim choice still owned by G11, logical session-version wording, +future auth examples, absent delivery transactions/audit frames, planned LS0002 +severity and the existing required-check/zero-skip enforcement. Architecture 13 +now labels its identity/session diagrams as targets; Architecture 15 separates +the shipped transport from its future inbox/transaction obligations. Accepted +ADR bodies and historical Phase 02d delivery records remain unchanged. + +Other target sketches that depend on an open gate remain target sketches. +Their packet-owned reconciliation is explicit in the register and deliverables: +P02b-1 updates producer/domain-event/payload carriers and the sample module +specs; P02b-2/3 update delivery, inbox, roles/grants, failure and metric carriers; +P02b-4 owns job storage/runtime carriers; P02b-5–7 own authentication, realm, +session and auth-skill carriers; P02b-8 owns analyzer and erasure closeout. +The Hub's per-module inbox reference remains consistent with the current +Accepted baseline; a different G1 decision must reconcile the cross-repo +reference without thawing the Hub roadmap. + +**Next: P02b-1 decision pass.** Prepare G2, G3's derivation, G5's stored +identities, G6, G18's payload rule and G19 as one reviewable package, including +the domain-event mutation/dispatch boundary and every owning frame's audit +duties. Define the first real cross-module effect before choosing its event. +Any required ADR/amendment must be drafted and approved before implementation. +Only then register its tests, split the implementation into reviewable steps +and begin producer code. P02b-0 requires no new product or architecture choice +from the maintainer. + +#### Validation + +Local Release architecture tests: **203 passed, zero failed, zero skipped**. +ADR lifecycle/disclosure tests: **7 passed**. The changed-document sweep checked +590 relative links and 164 Markdown fragments, with no broken target. Added +prose satisfies the 88-column rule; `git diff --check` is clean. The residual +scan found only CLAUDE.md's unchanged prohibition on local analysis references, +not a new dependency on a private document. No local full integration, browser +or Lighthouse rerun was needed for this documentation-only packet; the exact +post-merge CI receipt above owns that inherited evidence. + + + +### P02b-1 decision package (2026-10-12) + +**Accepted — 2026-10-12, @cemil.** The maintainer approves the revised package +and [ADR-0057](../decisions/0057-transactional-event-production-and-completion.md). +This is the producer decision record. The +[Step 1 record](#p02b-1-step-1--shared-execution-lifecycle-2026-10-12) tracks +the first runtime delivery; acceptance alone closes no implementation step. The +[Step 4 record](#p02b-1-step-4--producer-enforcement-and-closeout-2026-10-12) owns +final validation, reviews and the packet closeout. +Only the gate parts listed below are Accepted. ADR-0057 supplies the authority +for the coupled producer choices. It explicitly +replaces ADR-0032's step-7 flush/separate event-ID column and ADR-0040's +absorbable-failure rule only for successfully produced work. Other Accepted +rules and historical delivery records stay intact. + +#### Document and code audit + +The decision pass re-read the inherited event, transaction and audit decisions +(ADR-0006/0010/0032/0033/0038/0040/0042/0044), the producer/consumer architecture, +Database/Observability/Audit/Infrastructure standards, the three module specs +and their matrices, the production pipeline and persistence seams, the existing +transport and both composition roots, and the actual provisioning/Seeder paths. +The [entry audit](#p02b-0-entry-verification-2026-10-12) remains the authority for +P02b-0 evidence; it is not rewritten as a P02b-1 delivery claim. + +Two independent read-only audits examined producer/audit boundaries +(GPT-6-astra, xhigh) and real subscriber usefulness/seed compatibility +(GPT-6.1-sol, xhigh). These pre-implementation code facts shape the decision: + +- The outbox DDL already exists; it has `id`, not a separate `event_id`. + `IOutbox` is absent and step 7 is a pass-through. A shared SaveChanges is + neither necessary nor sufficient; the one ambient transaction is the boundary. +- Provisioning deliberately leaves `ITenantContext` unresolved and announces + its new tenant on the transaction. The Seeder has neither a guaranteed + correlation value nor an Activity listener. An HTTP-only enqueue check fails + its first real producer. +- Joined UoW frames are not savepoints. Discarding an inner event queue cannot + undo its saved state or already inserted outbox row. +- Audit entry/reconciliation is currently specific to `AuditLogBehavior`. + A direct delivery needs common ownership rather than pretending a nested + request owns a transaction it only joined. +- Typed settings are uncached; definition reads probe fresh generations. Neither + needs an invented invalidation consumer. Audit already durably records source + changes, so an asynchronous mirror adds no honest useful effect. +- The existing generic defaults are real data with a declared product purpose. + Their automatic installation changes orchestration ownership; it is not a + currently implemented consumer. Current seed verification pins their IDs, + so P02b-3 needs an explicit handoff before enabling delivery races. + +#### Accepted gate answers and implementation boundary + +The ADR owns exact semantics and bounds; this table owns gate timing. Maintainer +approval accepts this package and ADR-0057 together. The decision-only acceptance +commit and Registered catalogue obligations precede runtime code. Acceptance is +not an implementation or passing-test claim. + +| Gate / part | Accepted answer | Acceptance carrier / remaining owner | +|---|---|---| +| G2 | Immediate ambient INSERT; step 7 checks enrollment, while the owner drains domain events before MUST audit/commit | ADR-0057 bounded supersession of ADR-0032 Sub-decision 2; dated supersession disclosure and architecture producer-pattern reconciliation | +| G3 derivation | Explicit aggregate-ID or declared tenant-wide source; canonical key and falsifiable structural/behavioral proof | ADR-0057 and existing `Integration_Event_Declares_PartitionKey`; publish ordering remains P02b-2 | +| G5 stored identities | Assembly-qualified type; `id = EventId`, no extra column; runtime-type JSON with existing frozen options | ADR-0057 bounded supersession of ADR-0032 Sub-decision 12 and the Phase 02b deliverables table in Implementation Notes; dated supersession disclosure and Database Standards alignment; resolver failure policies remain P02b-2 | +| G6 producer/common owner | One collector/drain and reusable audit entry; outbox-only domain reactions; absorbed fail-after-produced-work poisons the unit, including descendant receipts | ADR-0057 bounded ADR-0040 nested-failure supersession; separate additive UoW snapshot-reader amendment and ADR-0044/0033 clarifications; production delivery/inbox atomicity remains P02b-3 after G1 | +| G18 payload | Identifier-only additional fields, inclusive 256 KiB canonical UTF-8 application cap, SQL NULL JSON metadata, no copied personal data or serializer escape | ADR-0057 new producer policy with dated ADR-0038 cross-reference; no outbox-size DDL CHECK claimed; sanitized errors P02b-2, erasure inventory P02b-8 | +| G19 | Completed tenant provisioning produces `TenantCreatedIntegrationEventV1`; two Customization subscribers initialize `card@1` and `plain@1` without overwriting | This package and ADR-0057; module-spec reconciliation at acceptance, publisher P02b-1; G1 must settle independent subscription identities before P02b-3 subscriptions and Seeder handoff | + +The additional nested-refusal protection is deliberate, not a correction to an +old statement: ADR-0040's prior rule was valid when accepted. Its exception and +ordinary event-free refusal semantics survive. No independent scope may bypass +the producer/audit coordinator by opening a raw transaction and calling outbox. + +The acceptance carriers disclose ADR-0057's exact bounded supersessions and +separate additive/clarifying amendments. Current producer guidance in +architecture 15, standards 02/05/10/18/20, module specs and event/review skills +uses owner-only completion and outbox-only domain reactions. The five new +catalogue obligations are Registered, with Implemented counts unchanged. +Consumer target guidance remains under its existing decisions until P02b-2's +G1 pass. Typed settings are uncached and definition reads probe fresh +generations; there is no useful settings-invalidation or Audit-mirror sample. +A later settings cache needs its own measured need and freshness decision. + +#### Four authorized implementation steps + +All work stays on `development`. Each step includes implementation tests and +an explicit commit, then **two independent review rounds with fresh agents**. +Each round covers security/isolation, correctness/lifecycle, architecture/audit, +test falsifiability and carrier accuracy. Use GPT-6-astra xhigh for transaction/ +audit risks and GPT-6.1-sol xhigh for independent structural/test/doc review; +adjust scope rather than weakening critical review. Verify every finding before +fixing and commit confirmed fixes; close both rounds before the next step. + +| Step | Deliverable | Required evidence before its review closeout | +|---|---|---| +| 1 — Shared execution lifecycle | Named `GetTenantContextAnnouncement` reader and immutable transaction-generation snapshot, producer invocation receipts, common audit entry/owner coordinator and guaranteed no-listener trace context; request pipeline retains existing outcomes | Owner/joiner and descendant receipts, event-free absorbed control, no context/setter expansion, same-scope cleanup, existing audit/public read-only regressions, controlled direct non-MediatR owner | +| 2 — Immediate outbox | `IOutbox`, ambient parameterized implementation, identity/metadata/payload admission and step-7 invariant guard; both compositions wired | Real `learnstack_app` INSERT/rollback and cross-tenant refusal, exact persisted envelope/runtime round-trip and SQL NULL JSON metadata, inclusive/multibyte byte-bound proofs, duplicate/oversized/malformed/stale/read-only refusal, no extra connection or publish, MUST-audit failure atomicity | +| 3 — Domain completion and real publisher | Successful-save collection, owner-only FIFO drain, outbox-only translation and completed-provisioning publisher used by API and Seeder | Multi-save/detach/delete, save failure/cancellation, absorbed direct/descendant producer refusal, bounded drain, no replay after rollback/scope reuse, exact one event per successful provision and zero on rollback; response version remains accurate | +| 4 — Enforcement and closeout | Production-subject architecture sweeps with planted controls, end-to-end producer regression, all affected docs/skills/catalogue states reconciled | Release/format and applicable complete backend suites with zero skips; exact test evidence, links/anchors and immutable ADR history; inspect required CI on the eventual PR without claiming unrun results | + +Use the implementation workflow's `add-integration-event`, +`add-mediatr-handler`, `add-integration-test` and `add-architecture-test` skills +for the relevant surfaces, plus `run-tests-locally`, `standards-check`, +`code-review` and `commit-and-pr`. No producer schema change is authorized; +unexpected schema needs reopen planning rather than borrowing P02b-2's lease work. + +No step is complete with review findings or required evidence pending. Partial +step commits are review checkpoints, not packet delivery claims. The final PR +remains for maintainer review; a local pass does not imply merge. + +#### Named handoff to P02b-3 + +P02b-1 is authorized to supply the production publisher and pending rows. +Steps 1–3 implement producer runtime; dispatcher claims and delivery belong to +P02b-2/P02b-3. P02b-3 must deliver both named defaults in +separate one-root subscriber transactions after G1 settles distinct subscription +identities and delivery scope, preserve any authored/lifecycle decision +(including deletion), and prove redelivery and sibling failure recovery. +It also switches built-in seed verification to owner-resolved key/version +identity while preserving warm IDs and exact authored curriculum IDs. Fresh +seed/dispatcher races, repeated-run neutrality, affected audit fixtures and the +seed inventory must be verified together before that packet closes. That packet +also updates `BuiltInCustomizations.cs`'s creator/Seeder/Hub installation remarks +to the delivered subscriber ownership; no consumer is claimed delivered now. + +G1/G3-ordering/G4/G5-resolution/G7/G8/G17 and G18's remaining parts remain open +for their named packets. This approval neither decides inbox ownership nor adds +jobs, authentication, Hub integration, Dapr or another worker process. + +#### Decision-draft review and validation + +Two fresh independent draft reviewers (GPT-6-astra xhigh for runtime/transaction +semantics; GPT-6.1-sol xhigh for corpus/gates/seed scope) approved the proposal +for maintainer review. Neither found a blocking issue. The corpus review's +carrier reminder was verified and incorporated above: Backend Standards 02 and +this phase's domain-event sketch must be reconciled on approval. These are +decision-draft reviews, not the two review rounds each implementation step owes. + +**External-review revision — 2026-10-12.** The maintainer's review is not an +approval. ADR-0057 now classifies each normative supersession, additive seam +amendment and clarification separately; ADR-0041 correction mechanisms do not +authorize the changed decisions. It names the UoW snapshot getter and its +generation/cleanup obligations, closes the JSON metadata channel with SQL NULL, +and identifies the UTF-8 cap as application admission rather than a DDL CHECK. +G1 must settle independent subscription identities before consumer code; +P02b-3 explicitly owns the `BuiltInCustomizations.cs` remarks handoff. The event +version suffix already follows the authoring skill, so no event rename is +needed; its inconsistent input-order example is a named acceptance carrier. +Source-linked drivers, reopening criteria, deferred ownership and the missing +ADR references are also explicit. All P02b-1 gates remain open pending approval. + +Two fresh independent revision reviewers (GPT-6-astra xhigh for governance and +the UoW seam; GPT-6.1-sol xhigh for payload, G1/G19 and carriers) approve the +revised draft for maintainer review. The governance review caught an incorrect +provisioning-amendment attribution; the reference now points to the existing +`IUnitOfWork` provisioning seam, and the fix was rechecked. Neither reviewer +reports a remaining actionable finding. These reviews do not accept the ADR or +count as implementation-step reviews. + +The seven ADR lifecycle-parser tests pass. A local file-and-heading check resolves +all relative links/anchors in the changed draft carriers; it is not a new CI +anchor gate. Accepted ADR files and the P02b-0 entry record remain unchanged. +No backend runtime suite was needed or claimed for this documentation-only +preparation. Maintainer approval remains pending. + +#### Maintainer acceptance — 2026-10-12 + +The maintainer explicitly approved the externally revised ADR-0057 and this +package after the draft reviews recorded above. The pending-approval statements +in that dated draft history describe the earlier review state. This acceptance +closes only the six gate parts in the accepted table. Five canonical proofs are +Registered; no runtime implementation, step review or runtime test is claimed +by this decision record. + +### P02b-1 Step 1 — Shared execution lifecycle (2026-10-12) + +**Step 1 complete; both independent review rounds passed.** Work is +on `development`, following the Accepted four-step package. This is not P02b-1 +completion. No immediate enqueue, successful-save event collector, publisher or +production delivery is claimed by this step. + +- `IUnitOfWork.GetTenantContextAnnouncement` returns an immutable normalized + tenant/organization snapshot with its physical transaction generation. The + PostgreSQL unit captures the values actually sent to SQL, clears authority + before a fresh attempt and on every terminal path, and refuses absent/foreign + transactions. Joins reuse the owner's announcement without retargeting it. +- Scoped `ProducerExecution` records sticky successful-production receipts on + an invocation and every ancestor, separately from pending event counts. The + common transaction coordinator poisons a refusing invocation before resolving + its frame when it carries such a receipt. Ordinary event-free absorbed refusals + retain their previous behavior. Production needs a live writable announcement + and an enrolled owner. A W3C Activity exists even without telemetry listeners. +- `AuditLogBehavior` and `TransactionBehavior` are thin adapters over common + coordinators registered by the pipeline helper in both API and Seeder. Audit + classification precedes deferred direct-handler construction. Capture-owned + audit frames determine outer reconciliation; the UoW owner writes MUST rows + and reports actual commit durability. An independent DI scope has independent + ownership even inside another audit execution's async flow. +- Architecture/audit/security guidance and the glossary describe the common + ownership seams. The closed context-writer set, eight logical announcement + paths, pipeline order and public read-only mode are unchanged. + +**Verification before the implementation commit.** A fresh `CI=true` Release +build succeeds with zero warnings/errors. The focused pipeline unit run passes +83 cases; the complete unit rerun passes 1,713. The focused integration run +passes 125, including all UnitOfWork, +ambient UoW, tenant-context guard, audit pipeline/workflow/capture, public +read-only, provisioning, direct-producer and cross-cutting HTTP cases selected +for this step. Both runs report zero failures and zero skips. The integration +proofs use the application role; privileged connections only prepare fixtures. +The new direct-producer class contributes 13 cases. Its receipts are deliberately +recorded after positively observed writes; automatic INSERT/save-promotion +receipt sites are Step 2/3 obligations, not evidence supplied here. + +The snapshot matrix adds 21 cases, including successful SQL values versus a +mutated source context, unresolved provisioning, join/foreign/stale transaction +checks, pooled same-session reuse, failed/cancelled announcement/setup and every +terminal path. The final complete architecture run passes 203 cases with zero +failures/skips. The local carrier check resolves 719 relative links and 153 +Markdown fragments with no broken target; added prose width and +`git diff --check` are clean. +Required CI and final packet counts are not claimed by this local checkpoint. + +#### Step 1 review round 1 + +Two fresh independent reviewers examined `b157650..cbab31a`: GPT-6-astra xhigh +for lifecycle/security/audit and GPT-6.1-sol xhigh for proof/architecture/carriers. +Two findings were confirmed: public `BeginInvocation(true)` could assert raw +transaction ownership, and the receipt test did not distinguish all ancestors +from only the immediate parent. An isolated assembly mutant implementing that +weaker propagation still passed all original 13 direct-producer cases. + +Enrollment is now internal to core Application; only the common coordinator +opens frames. Raw announced owners and orphan joiners have explicit refusal +controls. A depth-four invocation case exercises receipt propagation past an +intervening successful ancestor. The UoW XML comment names the common coordinator +and adapter correctly. The reviewers' stale-ancestry observation was reproduced: +an old owner could lend ownership to a later transaction's joiner. The new test +failed on that behavior; inheritance now requires the parent's live announcement +generation to match. The same test passes only after that change, with outer +audit cleanup included. The fresh Release build has zero warnings/errors; +the expanded focused integration run passes 129 cases with zero failures/skips, +including all 17 direct-producer cases. The same isolated weak-propagation mutant +now fails exactly the depth-two-descendant case (1 failed, 16 passed); restoring +the unmodified assembly passes all 17. The complete architecture rerun passes +203 cases with zero failures/skips. The second independent review round remains +pending at this checkpoint. + +#### Step 1 review round 2 and closeout + +Two new independent reviewers examined `b157650..ab2c37d`: GPT-6-astra xhigh +for lifecycle/security/audit and GPT-6.1-sol xhigh for proof/architecture/carriers. +Both approve Step 1 with no Blocker/Major findings. Their independent reruns +pass 59 audit/transaction unit cases, 73 producer/UoW integration cases and +25 persistence architecture cases, with zero failures/skips. These are selected +subsets of the runs above, not additional distinct test totals. + +The confirmed minor carrier findings are corrected in this closeout: the +catalogue names coordinator delegation and the outer audit coordinator's clear +ownership; the Security derivation header includes ADR-0057. Canonical rule names, +statuses and counts remain unchanged. The full producer contracts still await +Steps 2–4; this closeout does not claim automatic production receipts, domain +collection or completed P02b-1 delivery. + +### P02b-1 Step 2 — Immediate outbox production (2026-10-12) + +**Implemented; both independent review rounds complete.** +`IOutbox.EnqueueAsync` persists immediately through the admitted producer's +existing connection and transaction. API and Seeder register `PostgresOutbox`; +no transport, extra connection, commit or schema migration is added. + +- Admission checks the unit's identity, live announcement/generation, writable + non-poisoned frame and event tenant before serialization and again immediately + before INSERT. Explicit aggregate or tenant-wide partition declarations must + match the actual canonical GUID key. Organization comes from announced SQL + authority, while the invocation captures the causal actor and full W3C span. +- The row retains producer `EventId`, concrete assembly-qualified type and all + admitted identifier payload properties. Serializer escape shapes are refused; + the complete canonical UTF-8 payload has an inclusive 262,144-byte bound. + JSON metadata is SQL NULL. A duplicate INSERT is an error. +- Only a successful INSERT records a sticky receipt in the invocation and all + ancestors. Step 7 checks live enrollment before and after dispatch; it does + not flush a buffer or dispatch domain events. Event-free unresolved and + public read-only work retain their admission semantics. + +**Verification before the implementation commit.** The `CI=true` Release build +passes with zero warnings/errors. All 1,757 unit and 203 architecture cases pass +with zero failures/skips. The new real-PostgreSQL outbox class passes all 29 +cases, including immediate visibility, atomic commit/rollback with business and +MUST audit rows, provisioning, stale transaction-object reuse, distinct-unit +refusal and exact canonical UTF-8 boundary values. Initial failures exposed two +fixture assumptions (telemetry subscription and SystemActor identity); both +fixtures were corrected before the passing run. No runtime defect is hidden by +those corrections. Domain-save collection and its first publisher are Step 3; +no dispatch or consumer delivery is claimed here. +The separate existing-flow regression passes 166 integration cases, including +the convergent seeder, audit, provisioning, UoW and public read-only paths, with +zero failures/skips. The carrier audit resolves 1,158 relative links and 216 +Markdown fragments with no broken target; added prose width, formatting and +`git diff --check` are clean. Required CI is not claimed by this local checkpoint. + +#### Step 2 review round 1 + +Two fresh independent reviewers examined `7541b73..db882e0`: GPT-6-astra xhigh +for transaction/security/envelope correctness and GPT-6.1-sol xhigh for +proof/architecture/carrier integrity. Both approve with no Blocker/Major +findings. The confirmed minor finding was an undated inherited-absence paragraph +still describing `IOutbox` and step 7 as absent. It now distinguishes the P02b-0 +entry baseline from Step 2 delivery; the frozen entry audit is unchanged. + +The lifecycle reviewer independently passes 79 focused unit and 29 real outbox +integration cases, with zero failures/skips. These overlap the implementation +runs above. The corpus reviewer independently resolves 723 relative links and +165 fragments in changed carriers, with no unresolved target. The second fresh +review round remains pending at this checkpoint. + +#### Step 2 review round 2 and closeout + +Two new independent reviewers examined `7541b73..37d95bd`: GPT-6-astra xhigh +for runtime/security and GPT-6.1-sol xhigh for proof/architecture/carriers. +Both approve with no Blocker/Major findings. Each independently passes 61 +focused unit, 203 architecture and 29 outbox integration cases, with zero +failures/skips. These overlap earlier runs and are not additive suite totals. + +The confirmed minor test gap was transport silence: an unsubscribed event would +not expose an accidental publish. A real API-root test now resolves a counting +transport, positively observes the pending and committed row, and proves zero +publishes through enqueue and owner completion. Calling that same resolved +transport afterwards proves the counter works. The fresh Release build has zero +warnings/errors; all 30 outbox cases pass with zero failures/skips. +Step 4 still owns the transitive producer/helper fence against publication, +extra connections and commit ownership, alongside the complete production +censuses. Step 3's save collector, domain causation and publisher remain pending. + +### P02b-1 Step 3 — Domain completion and provisioning publisher (2026-10-12) + +**Implemented; both independent review rounds complete.** + +- A shared SaveChanges interceptor snapshots only changed sources of its saving + context, then promotes facts after nonzero successful persistence. Failed, + canceled, suppressed and zero-row saves promote nothing. Separate source + retention clears uncollected or detached facts; it cannot promote them on an + unrelated save. Repeat saves do not duplicate successfully promoted facts. +- The transaction owner alone drains the FIFO queue before producer invariants, + MUST audit and commit. Saved facts retain their saving invocation, generation, + W3C span and causal human through child disposal. Translation mints a new + integration identity and records the domain identity as causation. Successful + promotion marks every ancestor; a produced-work refusal poisons before its + frame resolves. Total production is bounded at 256 domain events. +- API and Seeder install the same collector/interceptor. Owner termination and + scope disposal clear source lists, staged/queued facts and promotion state; + later successful transactions in the same DI scope cannot replay facts. +- The tenant's initial default-organization assignment raises + `TenantCreatedDomainEvent`. The final successful provisioning save qualifies + it for outbox-only translation into `TenantCreatedIntegrationEventV1`. + One pending row commits with tenant, default organization/back-reference and + both existing MUST audit rows. No dispatch or default subscriber is delivered; + existing seed orchestration remains unchanged until P02b-3's named handoff. + +**Verification before the implementation commit.** The fresh `CI=true` Release +build passes with zero warnings/errors; all 1,764 unit and 203 architecture cases +pass with zero failures/skips. The final focused producer/provisioning integration +run passes 68 cases with zero failures/skips, including both real API/Seeder +roots, exact metadata and successful/failed MUST provisioning. A separate +existing-flow run passes 152 integration cases with zero failures/skips, +covering audit, UoW, public read-only and convergent seed behavior. The focused +post-format unit rerun passes 28 provisioning/domain cases; it overlaps the full +unit total above. + +The initial focused integration run exposed two fixture defects: EF refused a +duplicate alternate key before SQL, and two fault cases tried to reuse an +irreversibly poisoned UoW. The corrected cases reach the real database uniqueness +failure and verify poisoned-scope refusal; a separate successful-owner case +proves later-generation reuse without replay. The full focused rerun above is +green. These corrections change no runtime policy and conceal no failing case. +Step 4 still owns complete structural enforcement and catalogue promotion; +required CI and packet completion are not claimed at this checkpoint. + +#### Step 3 independent reviews + +Round 1 reviewed `937e36a..d6fbd85` with separate GPT-6-astra xhigh +runtime/transaction and GPT-6.1-sol xhigh proof/corpus reviewers. Both approved +without a verified actionable finding. The proof reviewer independently reran +28 domain/provisioning integration and 28 focused unit cases with zero +failures/skips. Round 2 used two new agents with the same separate lenses; +both approved, and its proof reviewer independently repeated those same +28 integration and 28 unit cases with zero failures/skips. These focused repeats +overlap the implementation evidence; they are not added to its totals. + +The reviews checked real SQL/save qualification, retained child metadata, +ancestor receipts, FIFO/count bounds, owner/audit atomicity, terminal cleanup, +response versions and both production registrations. No runtime fix was needed. +Complete production censuses and catalogue promotion remain Step 4 work. + +### P02b-1 Step 4 — Producer enforcement and closeout (2026-10-12) + +**Step 4 complete; both independent review rounds passed.** Work remains on +`development`. The packet closeout below records full local verification; +the PR's required checks own fresh CI evidence. PR approval and merge remain +pending. + +- `IntegrationEventContractTests` independently checks the complete production + project/module-layer census, base record/required members, declared partition + sources and identifier-only payloads. Stored-source IL and independently + varied tenant/event/aggregate identifiers exercise key derivation. Invalid + declarations, unrelated/constant keys, hidden members, serialization overrides + and disallowed payload shapes have dirty/clean controls; concrete runtime JSON + round-trips preserve approved identifiers. Unsupported computed partition + derivations fail loudly rather than silently leaving the proof's scope. +- `DomainEventHandlerTests` follows assembly-scoped helpers, inherited/generic + interfaces and generated async/lambda bodies across every production project. + Planted roots/helpers exercise every module layer. Root/write-store mutation, + nested requests, further facts/notifications, connection/UoW access, provider + ports and service location are rejected; clean outbox translation stays clean. + Static IL analysis does not claim arbitrary reflection/dynamic-code coverage; + new provider ports need explicit capability controls. +- `OutboxProducerConventionTests` complements real transaction proofs with + transitive no-new-connection, no-commit and no-publication controls. The + topic-convention census now requires production events and rejects trailing + newlines. Eight producer catalogue rules move to Implemented; delivery/inbox, + dispatcher, jobs and auth rules keep their named later packets. +- Current carriers and event/architecture/review skills distinguish delivered + production from remaining dispatcher/inbox work. ADR-0057's dated Amendment 1 + links delivery without rewriting its Accepted body or prior decision records. + +**Verification checkpoint.** The Release build has zero warnings/errors and the +complete solution's format verification passes. All 1,764 unit, 261 architecture, +four contract and 1,148 integration cases pass: 3,177 total, zero failures/skips. +TRX counters were inspected per assembly; the 59 focused producer-guard cases +overlap the architecture total. The first full integration run had five failures +in two older test setups that called persistence registration without the +application pipeline, leaving the new collector dependency absent. Both fixtures +now install the shared application registration; the complete integration rerun +above passes. No runtime policy was weakened. Review fixes below have their own +verification and supersede affected counts at this checkpoint. + + +#### Step 4 independent review round 1 + +Round 1 reviewed `3473843..b6e3932` with separate GPT-6-astra xhigh +runtime/security and GPT-6.1-sol xhigh proof/corpus reviewers. The runtime review +found one Major structural backstop gap: both guard walks expanded interface +implementations but missed overrides reached through abstract or non-sealed +base-class helpers. No production handler currently uses the escaping path. +Both walks now include derived implementations and have separate dirty/clean +abstract and virtual dispatch controls. Reverting the walk to interface-only +makes two domain-handler controls and one outbox control fail; restored source passes +all 63 focused producer-guard cases and all 265 architecture cases, zero +failures/skips. Release build has zero warnings/errors and changed C# formatting +passes. These four additional architecture cases bring the complete backend +verification to 3,181 cases; unchanged unit/contract/integration evidence is the +full run above, not an additional rerun claim. + +The proof reviewer independently passed 261 architecture and 41 focused unit +cases at the implementation checkpoint. Its one Minor carrier finding is fixed: +the P02b-2 error/lease migration must account for existing P02b-1 producer rows; +its decision pass still owns descriptor handling. Both findings were verified +against source. The fixes were committed in `98c22a8`; the runtime reviewer +independently reran its exact twelve dirty/clean probes and confirmed both +formerly escaping paths are caught while clean paths remain legal. + +#### Step 4 independent review round 2 + +Two fresh reviewers (GPT-6-astra xhigh for runtime/security; GPT-6.1-sol xhigh +for proof/corpus) reviewed `3473843..98c22a8` and the adjacent producer/audit +lifecycle. Both approve with no additional runtime or guard finding. The runtime +reviewer independently passed all 63 focused architecture cases; the proof +reviewer independently passed all 265 architecture and 59 focused messaging +unit cases, zero failures/skips. These cases overlap the complete run above. +The proof reviewer's only Minor confirms the final carrier sweep: Architecture +15's stale conditional partition-proof wording now describes the delivered +persisted-value proof. + +### P02b-1 packet closeout (2026-10-12) + +**Complete on `development`; PR review and merge remain pending.** All four +implementation steps completed their commits, both independent review rounds +and verified fixes. Accepted ADR-0057 governs the delivered scope: + +- Shared producer/audit execution and immutable successful transaction + announcements, preserving ordinary event-free refusals while poisoning + failures after direct or descendant successful production. +- Immediate parameterized outbox INSERT on the existing announced writable + transaction, exact envelope/routing/identifier payload admission and no new + connection, commit or transport publication. +- Successful-save domain collection, bounded owner-only FIFO completion before + MUST audit/commit and one completed-provisioning publisher in API and Seeder. +- Nonempty production censuses with independent dirty/clean and mutation + controls; eight producer catalogue rules are Implemented. Current architecture, + standards, module specs, glossary and skills reflect the delivered boundary. + +**Local verification:** Release build has zero warnings/errors; complete solution +format verification and changed-code formatting pass. Per-assembly TRX counters +record 1,764 unit, 265 architecture, four contract and 1,148 integration cases: +**3,181 passed, zero failed/skipped**. Integration includes actual PostgreSQL +`learnstack_app` isolation/atomicity and the existing seed/public-renderer +regressions. Unit/contract/integration ran at the Step 4 implementation +checkpoint; the subsequent proof-only class-dispatch fix added four architecture +cases and reran that complete suite. Focused repeats and deliberately failing +mutation experiments are not added to the passing total. The five corpus checks +and seven ADR lifecycle tests pass. A local sweep of 33 changed Markdown files +checks 2,900 relative links and 700 fragments with no broken target; this is +manual/local validation, not a new CI anchor gate. Added closeout prose satisfies +the 88-column rule and `git diff --check` passes. + +No frontend production source, OpenAPI contract or schema migration changes. The PR's +seven required checks provide fresh CI evidence, including frontend and the +public demo/Lighthouse lifecycle; local success alone does not claim CI approval +or merge. Existing Accepted ADR changes are insertion-only dated disclosures; +ADR-0057's original acceptance content and P02b-0's entry record are preserved. + +#### PR #29 CI verification + +The first full [PR run](https://github.com/HodeTech/LearnStack/actions/runs/38228839007) +at `1e799a8` passes six of seven required jobs, including the public demo and +Lighthouse lifecycle. Docker integration reports 967 passed and one failed: +the existing stock-browser admission proof exits during its `browser 429` +phase. All other integration cases pass. The same focused proof passes locally; +the original CI diagnostic does not identify the failing browser operation. + +Test-only browser diagnostics now expose closed phase names for startup, +hydration, navigation, wire assertions and cleanup. Raw exceptions, browser +output, URLs and private values remain excluded. A verified review follow-up +also preserves the first proof failure if shutdown fails, while always running +cleanup. Assertions, deadlines and production behavior are unchanged. + +At `11871a2`, the [diagnostic run](https://github.com/HodeTech/LearnStack/actions/runs/38231443960) +passes all 968 Docker integration cases. Its non-Docker artifacts record 1,764 +unit, 265 architecture, four contract and 180 integration cases: 3,181 backend +cases pass with zero failures/skips across both jobs. The original stock-browser +failure did not recur; its cause remains unestablished, not claimed fixed. + +Both diagnostics and first-failure preservation completed two fresh independent +review rounds. Focused browser checks pass 18 cases; final guarded frontend tests +pass 1,542 cases across two packages with zero skips/todos. Lint/typecheck and +the real admission integration proof pass locally. Verified CodeRabbit findings +also reconcile delivered producer work, pending inbox delivery and process-local +versus durable ordering in architecture/glossary/XML guidance. Five corpus +checks and the changed-document link/anchor sweep pass after those corrections. +The [PR's required checks](https://github.com/HodeTech/LearnStack/pull/29/checks) +own the final-head CI verdict; passing local or intermediate evidence does not +claim approval or merge. + +**Next: P02b-2 decision pass.** Reverify G1, G3 ordering, G4, G5 resolver policy, +G7/G8, G17 and G18 error descriptors before implementation. ADR-0046 remains +reserved and undrafted; any new decision package/ADR needs maintainer approval. +That packet owns lease/grant migration, existing-row handling, dispatcher +credential/boot guard, claim/publish/mark, retry/terminal policy and metrics/health. +P02b-3 subsequently owns inbox/subscriptions, both Customization defaults and +the Seeder identity handoff after G1. Pending outbox rows do not claim delivered +consumption; no producer work is silently transferred to those packets. + +#### PR #29 external-review proof follow-up (2026-10-12) + +The review of `d37f46c..cb2bb6d` found three Minor proof gaps and no +Blocker/Major runtime finding. Commit `d8fd919` strengthens the existing cases: + +- The shared execution test enqueues through the registered `IOutbox` at + descendant depths 0, 1 and 2. It observes both business and outbox rows inside + the transaction, then verifies both disappear after the absorbed refusal. +- The committed-MUST reconciliation test requires one declared intent inside + the handler before checking that reconciliation writes no duplicate. +- The nested transaction test observes zero joiner writes and one owner write + in the same execution, including the actual commit-state transition. + +Three temporary mutations independently remove the real outbox receipt, audit +declaration and owner audit write. Their respective three, one and one target +cases fail. Production files are restored byte-for-byte, then all 62 audit/ +transaction unit cases and 71 producer integration cases pass again. + +Release build has zero warnings/errors; changed C# formatting and +`git diff --check` pass. The complete 1,764 unit and 265 architecture cases and +the 71 focused PostgreSQL cases pass with zero failures/skips; focused repeats +overlap these counts. This follow-up changes no production code or case count. + +The public-receipt suggestion needs no visibility change here: `PostgresOutbox` +is a real cross-assembly caller; invocation receipts also serve existing +integration proofs. A temporary probe of compiled `BeginInvocation` confirms +that a hierarchical ambient Activity receives a valid W3C child and is restored +on disposal. The suggested non-W3C fallback failure does not reproduce. + +Two fresh independent review rounds cover `cb2bb6d..d8fd919` and this follow-up +record. Each pairs GPT-6-astra high runtime/fixture review with GPT-6.1-sol high +proof/corpus review; all four reviewers approve without verified findings. +Five corpus checks, seven ADR lifecycle tests and the changed-document +link/anchor sweep pass. The PR checks own fresh final-head CI evidence. + +#### PR #29 systematic-review remediation (2026-10-12) + +The review against `75fc79b` identifies a valid pre-COMMIT audit classification +fault. Commit `c7dae0c` distinguishes a still-live transaction from a physical +COMMIT whose acknowledgement failed. A live frame refusal now enters the guarded, +uncancelled rollback path; the original error survives cleanup failure. Actual +COMMIT faults retain the existing Indeterminate behavior. + +The PostgreSQL regression first fails against the old code: a leaked frame +produces a permanent `indeterminate` audit row despite never attempting COMMIT. +After the fix, owner and absorbed-joiner cases observe actual business/outbox +INSERTs, then prove their rollback, a single `failed` audit row, cleared authority +and refused same-scope reuse. Scope disposal already collapsed leaked frames in +the old code; the review's claim that they stayed live after unwinding is not +reproduced. Two unit cases also preserve the frame error when rollback succeeds +or throws. API and Seeder tests document and enforce producer enrollment before +event-bearing saves, with successful event-free raw-save controls. + +Other verified changes reconcile the current uncached-settings handoff without +promising a settings event, correct ADR-0032 clause locators through ADR-0041 +errata, refresh amendment/index/example links and name concrete proof methods. +The parallelism census includes shared Application, Infrastructure and the +separate audit infrastructure project, with planted controls and exact-path +exceptions for singleton cache and pre-admission host lookup flights. Test-only +browser diagnostics distinguish launch, DevTools readiness and private-carrier +failures; cleanup preserves the first +failure and exposes only closed phase tokens. + +The remaining runtime suggestions do not demonstrate a current failing path: +domain dispatch validates enrollment before owner access; outbox ancestry and +JSON shape are guarded before serialization checks; the Seeder test positively +observes promotion before its deliberate refusal; reflection and log-trigger +changes fail their existing controls loudly. Provider-port recognition remains +an explicitly enumerated limitation, not a future-port discovery claim. No +runtime visibility/marker, serializer, scheduling or ConfigureAwait refactor is +needed. The final announcement check remains defense in depth after drain. +The earlier temporary mutations are recorded manual experiments; their target +regression cases are permanent, without a new automated mutation-runner claim. + +Two fresh independent review rounds pair GPT-6-astra xhigh runtime review with +GPT-6.1-sol xhigh proof/corpus review. Round 1 checks `75fc79b..3f4c121` and +identifies one additional Minor in the outer browser proof: a caller-supplied +closed phase token can masquerade as a sanitized error. Commit `9b2fcf6` requires +the existing private brand at that boundary. Its paired actual-export cases +first fail for the forged token, then pass while retaining genuine branded +error identity. The first-round proof reviewer verifies the fix. Both fresh +Round 2 reviewers approve `75fc79b..9b2fcf6` without findings. + +Full local verification passes 1,766 unit, 266 architecture, four contract and +1,152 integration cases: 3,188 backend cases, zero failed/skipped. Focused +verification passes 75 producer integration and 64 audit/transaction unit cases, +overlapping those totals. Guarded frontend tests pass 1,550 cases across two +packages, including 26 browser diagnostics cases, with zero skips/todos. Release +build has zero warnings/errors; solution format, frontend lint/typecheck, seven +ADR lifecycle checks, the changed-document link/anchor sweep and +`git diff --check` pass. Original Accepted Status/Decision/prior amendments and +the frozen P02b-0 entry record remain byte-preserved. The +[PR checks](https://github.com/HodeTech/LearnStack/pull/29/checks) own the exact-head +CI verdict; local results do not establish that verdict or merge approval. +PR review and merge remain pending. + +#### PR #29 outbox INSERT and ownership follow-up (2026-10-12) + +All four follow-up comments reproduce against `b561c8f`. Commit `96e6f4d` +marks the existing unit rollback-only when the immediate outbox INSERT throws, +then rethrows the same exception. Only a successful INSERT records produced +work; validation refusals before execution retain their existing behavior. + +The existing duplicate-EventId regression now covers propagation and absorption +of a real PostgreSQL INSERT failure. Both cases first fail against the old code +at the immediate rollback-only assertion, before coordinator unwinding can +poison the unit. The fixed cases retain the successful parent's receipt, create +no failed-child receipt, prove original exception identity or absorbed-error +refusal, and verify rollback and shared-state cleanup. All 31 outbox cases pass. + +Architecture 31 records Steps 2–3's delivered enqueue, successful-save +collection/promotion and owner-only drain. Standards 02/18 distinguish UoW-owned +domain collector/source cleanup from reconciliation and audit capture cleanup +owned exclusively by the outermost `AuditExecutionCoordinator` frame. The +catalogue no longer claims inbox parity for `InProcessEventBus`. Dispatch and +consumer delivery/inbox remain P02b-2 and P02b-3; no ADR decision changes. + +Two fresh independent review rounds pair GPT-6-astra xhigh runtime review with +GPT-6.1-sol xhigh proof/corpus review. Round 1 approves `b561c8f..96e6f4d` +without findings. Round 2 covers the same code/document changes and this dated +closeout; both reviewers approve without findings. + +Full local verification passes 1,766 unit, 266 architecture, four contract and +1,153 integration cases: 3,189 backend cases, zero failed/skipped. Focused outbox +repeats overlap that total. Release build has zero warnings/errors; solution +format, seven ADR lifecycle checks, the changed-document link/anchor sweep and +`git diff --check` pass. No frontend source, schema, credential or accepted ADR +changes. The [PR checks](https://github.com/HodeTech/LearnStack/pull/29/checks) +own final-head CI evidence; PR review and merge remain pending. ### Durable outbox dispatch -The producer side is **planned, not shipped**: `IOutbox` does not exist and Packet 3's -`OutboxFlushBehavior` is a pass-through shell. Both land here, and so does the consumer -side. The table shipped in Packet 6. +P02b-1 Steps 2/3 implement `IOutbox`, replace Packet 3's step-7 shell with +an enrollment/invariant guard, and supply owner-side domain collection and the +first publisher. Dispatch and consumer delivery remain P02b-2/P02b-3. +The table shipped in Packet 6. A handler calls `IOutbox.EnqueueAsync` and the row commits on the same **transaction** as the aggregate change — the ambient one `IUnitOfWork` owns ([ADR-0040](../decisions/0040-ambient-unit-of-work.md)), not a shared `SaveChanges`, a -formulation [ADR-0033](../decisions/0033-audit-durability-model.md) withdrew. Whether -the row is written at the call or buffered for step 7 is **G2**, and the answer decides -whether an enqueue from a consumer, a domain-event handler or a nested command reaches -the table at all: two of the three paths never run `OutboxFlushBehavior`. +formulation [ADR-0033](../decisions/0033-audit-durability-model.md) withdrew. [ADR-0057](../decisions/0057-transactional-event-production-and-completion.md) +accepts immediate INSERT through that connection/transaction. Step 7 is an +enrollment/invariant guard; the transaction owner drains domain events before +MUST audit and commit. Raw transactions without an enrolled producer frame +cannot enqueue. Production delivery wiring remains P02b-3. #### Process topology `OutboxProcessor` is a `BackgroundService` — the shape [Events and Outbox § OutboxProcessor](../architecture/15-event-and-outbox.md#outboxprocessor-backgroundservice), ADR-0006 Amendment 1, ADR-0010 and the glossary all specify, with a 200 ms poll no job -schedule expresses. This phase hosts it in `LearnStack.Api`, the only host that exists: -the solution builds one runnable host, nothing in `backend/src` implements -`IHostedService` today, the corpus's gateway route table puts `/admin/hangfire*` on that +schedule expresses. This phase hosts it in `LearnStack.Api`, the only web host. +`LearnStack.Tools.Seeder` is a separate one-shot executable, not a worker host. +Nothing in `backend/src` implements `IHostedService` today. The corpus's gateway +route table puts `/admin/hangfire*` on that host ([API Gateway](../architecture/30-api-gateway.md), route id 3, upstream `learnstack-api`), and the production image is [Phase 11](phase-11-production-hardening.md)'s. A second worker host is not a @@ -360,47 +1193,29 @@ defined row order. The completion criterion follows the gate, and #### Domain events collected and dispatched -`Entity` has carried the raise-and-clear list since -[Phase 02a Packet 2](phase-02a-kernel-tenancy.md); no aggregate raises an event yet, and -nothing collects them. This phase adds the collector — an `ISaveChangesInterceptor` -registered beside `AuditChangeTrackerInterceptor`, which is what makes it reach the -seeder's composition as well as the API's — and one dispatch site inside the ambient -transaction, because [ADR-0010](../decisions/0010-cross-module-communication.md) puts a -domain event in the same transaction as the change that raised it. - -Three things the site has to satisfy, and **G6** is the vehicle for the first two: - -- **It runs before the MUST-class audit flush** that ADR-0033 puts immediately before - `COMMIT`, or a handler's write is captured into a buffer that holds no intent naming - its type — and the composer selects `changes` by the intent's entity type, so the - write reaches no row and nothing fails. -- **It runs where an enqueued outbox row can still be enrolled.** `OutboxFlushBehavior` - is step 7 and the owning frame's post-handler region runs after it returns, so a - handler told to add an outbox row there adds it to a buffer whose only flush has - already run. -- **Every frame that owns an ambient transaction owes the same step**, in the same - order. There is more than one today, and a job frame would be a third. - -What a handler may add to the transaction is an outbox row. Whether it may also write -state the raising aggregate owns is part of **G6**: dispatch runs after the handler -built its `Result`, so a post-response `Version++` commits a version the response never -reported. `Cross_Aggregate_Writes_Are_Confined_To_Tenant_Provisioning` does **not** -cover the shape — it counts aggregate roots per constructor, and two handlers holding -one port each are green — so the limit needs a rule of its own, registered with the -planted-offender companion -[the catalogue](../standards/21-architecture-tests-catalogue.md#how-to-add-an-entry) -requires of a rule that ships before its first subject. - -`IDomainEvent` is a MediatR `INotification`, so a handler's only failure channel is a -throw, which -[ADR-0032](../decisions/0032-exception-handling-logging-and-observability.md)'s -two-track model treats as an *unexpected* failure — a bug, a transient infrastructure -fault or a contract violation — never as an expected outcome a `Result` could carry. The -rule this phase states is therefore narrower than that model: an in-module domain-event -handler is infallible by construction, and a reaction that can legitimately fail is an -integration event. Events are cleared as they are collected; a handler may raise -another, drained inside the same transaction to a bounded depth; and a failed `Result` -or a rollback discards what was neither collected nor dispatched. +**Implemented by P02b-1 Step 3 — ADR-0057.** The shared SaveChanges +interceptor stages source events and promotes them only after successful +persistence, in both API and Seeder compositions. Failed/canceled saves promote +nothing. Collection retains source/frame attribution and transaction generation; +repeated saves, detach and delete cannot lose or duplicate a successful event. + +Only the transaction owner drains the collected FIFO queue, at one dispatch site, +before producer invariant checks, MUST audit composition/write and commit. At most +256 domain events belong to one transaction; exceeding this total fails the whole +unit. This is a total count, not recursive cascade support. + +Domain-event handlers translate facts into outbox messages only. They do not +mutate roots, use write stores, send nested requests, raise further events, +publish notifications or invoke external providers. Unexpected enqueue/contract +faults throw and roll back. Expected business decisions stay in commands or later +integration consumers, preserving the returned version and one-root boundary. + +Each producer invocation keeps a sticky receipt after a successful outbox INSERT +or successful-save promotion, also recorded in every ancestor. A `Result.Fail` +from any frame with that work poisons the shared unit before its UoW frame is +resolved, even when an ancestor absorbs the refusal. Ordinary event-free refusals +retain ADR-0040's semantics. Joiners neither dispatch nor clear the owner's queue; +every terminal path clears transaction-scoped source/collector state exactly once. #### The consumer side @@ -619,8 +1434,9 @@ or refresh-token rotation. Those live in Keycloak; the split is described in - **Seed users**, under **G12**: tenant users only. Operators are the `learnstack-hub` population and ADR-0004 Amendment 1 makes the separation a hard invariant — the `learnstack` realm declares no platform-admin role and should not gain one. The - organization dimension is not representable in a token this phase issues, so "across - both organizations" is not a promise this phase can keep. + current dev export has no organization claim. **G11** decides whether this phase + mints one or deliberately refuses the authenticated organization-host case; + "across both organizations" is not yet an accepted promise. - **Keycloak realm roles.** The realm ships three `tenant-*` roles with full scope on both clients, so they appear in every token, while roles live in LearnStack and not in Keycloak. Either they leave the realm or they are declared inert with a rule proving @@ -629,13 +1445,14 @@ or refresh-token rotation. Those live in Keycloak; the split is described in #### Correction: the realm emits no `tenant_id` claim at all -This is the one defect in this phase's original four that the corpus has not since -repaired, and it is the reason the cross-check cannot run today. +This is an unimplemented prerequisite of this phase's authenticated cross-check. `infra/keycloak/realms/learnstack.json` stores `tenant_id` as a **user attribute** and declares **zero protocol mappers**. Keycloak does not put user attributes into tokens -unless a mapper says so, so every issued JWT reaches the API with no `tenant_id` claim, -every request resolves under the host-only ceiling, and the first layer of tenant -defence is off with nothing reporting it. +unless a mapper says so. The API also has no JwtBearer registration or +`UseAuthentication` today: it does not validate those tokens or populate the +resolver's reserved claim signal. Current public requests use the delivered +host-only admission, query filters and Row Level Security. Authenticated behavior +must not go live until G10–G12 settle the token and resolution contract. Three changes, and the third is the one that makes the first two take effect: @@ -678,7 +1495,8 @@ settles it; what the phase ships either way: - **Lifetimes and refresh.** One authority for the access-token TTL — four documents currently give four numbers, and this phase links the authority rather than adding a fifth. Concurrent requests arriving with a near-expiry token produce exactly one token - request and one `Set-Cookie`; a terminal `invalid_grant` clears the session and the + request and one logical replacement session version, including any cookie chunks; + a terminal `invalid_grant` clears the session and the visitor gets the public page anonymously rather than an error. - **Cookie scoping and CSRF.** Host-only cookies with no `Domain`, so two tenant hosts do not share a session, plus an `Origin` check and a session-bound token on every @@ -703,19 +1521,26 @@ settles it; what the phase ships either way: login completes" cannot be demonstrated, and `Backend_RequiresJwt_OnAllAuthenticatedRoutes` sweeps an empty route set. - **Auth.js and `auth()` placed in `apps/web`**, the `(studio)` / `(portal)` redirect, - the server SDK's bearer attachment, and server-only environment keys. One skill - imports `auth` from a package that does not exist and a second calls `auth()` with no - import at all; both are corrected here. - -### Tenant settings eager invalidation - -This phase owns `learnstack.tenancy.settings`: Tenancy publishes supported settings -changes through the durable outbox, and settings readers consume eager invalidation, -as declared by the [Tenancy event catalogue](../modules/tenancy/README.md#integration-event-catalogue). -Its producer/payload, consumer scope and failure/replay behavior are settled in the -owning event packet's decision pass before implementation. P02d-3's -`ITenantSettingsAccessor` uses uncached ambient scoped reads; this handoff introduces -no settings cache, staleness window or organization branding override. + the server SDK's bearer attachment, and server-only environment keys. The + `add-frontend-route` and `add-feature-gated-ui` skills contain future auth + illustrations rather than shipped helpers; P02b-7 replaces them with the real + session API when it lands. + +### Useful provisioning flow + +[ADR-0057](../decisions/0057-transactional-event-production-and-completion.md#first-useful-production-flow) +accepts completed provisioning as the producer: `TenantCreatedIntegrationEventV1` +on `learnstack.tenancy.tenant`, partitioned by `TenantId`, with no extra payload +fields and null envelope organization. P02b-1 emits it only after tenant/default +organization/back-reference completion and persists pending rows. + +After G1, P02b-3 supplies two independent Customization subscriptions for `card@1` +and `plain@1`. Each create-only effect owns one root, generation update and MUST +audit on its inbox transaction, preserves authored/lifecycle/deletion decisions, +and resolves incomplete pairs through retry. P02b-3 owns semantic key/version +Seeder verification, preserved warm IDs and fresh seed/delivery convergence. +Typed settings stay uncached; definition generations are freshly probed. This +phase promises no eager settings invalidation or asynchronous Audit mirror. ### Audit coverage wiring @@ -724,24 +1549,28 @@ The audit pipeline lit up in [ADR-0033](../decisions/0033-audit-durability-model.md). This phase adds the entry points it does not reach, and both of Packet 9's hand-offs land here. -- **The delivery path has no audit mechanism today**, and the gap is silent. +- **The delivery path has no transaction or audit frame today.** [ADR-0040](../decisions/0040-ambient-unit-of-work.md#consumers-do-not-have-a-request-and-still-need-all-of-this) - makes an event delivery the application's second entry point, and the transport runs - `Begin → SetTenantContext → handler → Commit` with no MediatR behavior in the path. - Only `AuditLogBehavior` declares intents, only `TransactionBehavior` flushes them, and + assigns `Begin → SetTenantContext → handler → Commit` to this phase; the current + transport restores only the ambient accessor and resolves a fresh handler scope. + Adding that transaction alone would leave an audit gap: + only `AuditLogBehavior` declares intents, only `TransactionBehavior` flushes them, and the catalogue is keyed by request type — so a consumer that writes its own aggregate through its `DbContext`, which is the shape the corpus teaches as canonical, has its - changes captured into a buffer that holds no intent naming its type. **No row is - written and nothing fails.** That is the one window - [ADR-0033](../decisions/0033-audit-durability-model.md) says must not exist. **G6** - decides it, and the case that proves it is a MUST-classified direct-write consumer, + changes would be captured into a buffer that holds no intent naming its type. + **No row would be written and nothing would fail.** That is the window + [ADR-0033](../decisions/0033-audit-durability-model.md) says must not exist. + **G6's common-owner contract is Accepted under ADR-0057**; P02b-3 implements + it in production delivery after G1. Its proof is a MUST-classified direct-write + consumer, not only a consumer that sends a command. - **The audit frame's owner on a non-pipeline entry point.** What is *already* decided is not reopened here: [ADR-0033 Amendment 2 § 2](../decisions/0033-audit-durability-model.md) and [ADR-0044 § 4](../decisions/0044-audit-write-path.md) both say only the owning frame writes, signals the boundary, reconciles and clears, and a joiner does none of it. - What is open is narrower and is **G6**: the shipped `AuditLogBehavior` takes its owner + ADR-0057 accepts common entry coordination for **G6**. The remaining P02b-3 + production wiring must repair this baseline: `AuditLogBehavior` takes its owner flag from an audit-frame counter rather than from unit-of-work ownership, and [Audit Subsystem](../architecture/31-audit-subsystem.md) records that split as deliberate for the HTTP path — at step 3 no transaction is open to answer the @@ -750,13 +1579,15 @@ points it does not reach, and both of Packet 9's hand-offs land here. the coincidence: the inner command becomes a unit-of-work joiner while still being the outermost audit frame, so it reconciles before the delivery commits, writes its MUST row standalone as `failed` for a change that then commits, and clears the buffer the - owner would have flushed. Either resolution edits an Accepted record, so the amendment - is Accepted before the transport opens a transaction. + owner would have flushed. ADR-0044/0033 now clarify the common owner; P02b-1 + proves it through a + controlled direct fixture before production transport wiring in P02b-3. - **The discriminating case.** The pair this phase used to name — a consumer's MUST-class command recording `success` on the delivery's transaction, and a rolled-back delivery recording it standalone as `failed` — passes under **both** - candidate designs. The cases that tell them apart are an inner refusal the consumer - absorbs while the delivery commits, a faulted `COMMIT` producing exactly one + ownership designs. ADR-0057's cases distinguish an ordinary event-free refusal + absorbed by a committing delivery from a produced-work refusal that poisons it, + a faulted `COMMIT` producing exactly one `indeterminate` row, and `Clear()` running exactly once per delivery. - **The dead-letter transitions**, under **G7**. "Through `IAuditStore`" names neither a method nor a permitted caller, and no single role can both make the terminal outbox @@ -782,7 +1613,8 @@ points it does not reach, and both of Packet 9's hand-offs land here. columns for both, and neither reaches a consumer's audit row or a follow-up outbox row. ADR-0038 promises the causal actor "is retained separately as causal audit metadata" and nothing retains it. Which metadata keys a composed row may carry is part - of **G6**. + of the Accepted **G6** producer/common-owner contract; production delivery + supplies the trusted incoming event in P02b-3. - **Classification for the operations this phase introduces** — the sample flow's command, the first authenticated read, login, logout and refresh — registered in their owning module's catalogue source and matrix before their handlers ship, because an @@ -805,7 +1637,8 @@ and error-tracker tags. It does not cover a value already interpolated into an e Those sinks, not a Problem Details body, are the threat this phase's diagnostic answers — the response factory builds no free text from an exception message. -`LS0002` is a build-breaking diagnostic, and a roadmap document is not its registry. +`LS0002` is planned, not shipped; its initial Warning severity does not itself +make a build fail. A roadmap document is not its diagnostic registry. **G16** records it the way ADR-0032 Amendment 1 recorded `LS0001`: its `LearnStackException-` rule name, the project trees it runs over, what it inspects, its `AnalyzerReleases` row, its catalogue entry, and the condition that @@ -827,8 +1660,9 @@ outside a `throw` staying clean, no `AD0001` crash — the failure mode ADR-0032 The same change bounds what the durable sinks may hold, under **G18**: a sanitized descriptor — the exception type, the error code and the attempt's `traceparent` — with a -length bound in the canonical DDL, applied in the same migration as the lease columns -while nothing writes the table yet. +length bound in the canonical DDL. P02b-2 owns that migration beside the lease +columns and must account for existing P02b-1 producer rows; its decision pass +settles descriptor handling before applying the bound. ### Architecture tests @@ -843,25 +1677,25 @@ omitted. What this phase owes the catalogue beyond implementing those rows: -- **New rows**, each registered in the decision pass of the first packet whose code - satisfies it: the same-transaction outbox write, the two domain-event rules and a - payload-content rule (**G18**) in `P02b-1`; the ADR-0036 middleware-ordering rule, the +- **Producer rows delivered by P02b-1:** the announced-transaction write, + produced-work refusal, owner-only completion, outbox-only domain handlers and + identifier-only payloads. Later decision passes register and implement the + ADR-0036 middleware-ordering rule, the routable-set bound (**G14**) and the Keycloak-role-claim rule — with the realm-role question it answers — in `P02b-5`, where the API first reads a claim; and `LS0002` under its rule name (**G16**) in `P02b-8`. - **Widened assertions** where a catalogued rule is satisfied by the defect it exists to - catch. `Outbox_Row_Carries_Correlation_Context` asserts only non-nullness of two - columns and extends to `partition_key` and to value correctness. + catch. P02b-1 extends `Outbox_Row_Carries_Correlation_Context` to exact stored + identity, tenant/organization, full traceparent and partition values, including + unresolved provisioning. Its partition declaration rule combines independent + stored-source IL checks, varied identifier probes and real persisted keys; + an uninitialized instance is not a value proof. Publish ordering stays P02b-2. `Outbox_Claim_IsHeld_Until_Dispatch_Completes` says "exactly once" unconditionally, which contradicts the at-least-once contract; it narrows to live-lease contention and gains a redelivery companion for crash and expiry. `Hangfire_Job_Payloads_Include_TenantId` gains the organization, actor and traceparent fields. `OutboxProcessor_NeverBlocks_OnSingleMessageFailure` gains the hanging-handler - case. `Integration_Event_Declares_PartitionKey` is typed as reflection over module - assemblies and cannot observe a runtime key — an uninitialised instance reads a zeroed - Guid as non-blank — while the compiler and the envelope's own guard already hold both - halves; it becomes a behavioural assertion over persisted rows, or is re-typed and - retired, in `P02b-1`'s decision pass. Two rules the catalogue stamps **02a** also + case. Two rules the catalogue stamps **02a** also widen here, so the Phase-field sweep above does not reach them: the transport arm of `Tenant_Context_Guard_Fires_Only_On_An_Unmarked_Transaction`, whose second arm has no subject until the transport announces a tenant, and @@ -889,16 +1723,14 @@ What this phase owes the catalogue beyond implementing those rows: Two notes on what "green in CI" can mean. Several of this phase's rows are behavioural rather than structural, and CI runs those in a separate job under a Docker trait filter, -so "the architecture suite is green" does not reach them. That job was **not** a -required check when this was written — CONTRIBUTING names adding it to branch protection -as the remaining edit — and every behavioural criterion below sits in it. -[Phase 02d](phase-02d-walking-skeleton.md) makes that edit no later than its first -Education migration, because -[Git Workflow Standards § Checks](../standards/14-git-workflow.md#checks) already makes -tenant-isolation tests a merge condition. `P02b-8` re-verifies that the check is still -required under the name the job reports, and records the date. And the executed-test -guard counts executed tests, so a per-test skip leaves its count non-zero — the -criterion says *not skipped*, per assembly. +so "the architecture suite is green" does not reach them. `backend integration +(Testcontainers)` is already a required check; the +[P02b-0 receipt](#p02b-0-entry-verification-2026-10-12) verifies all seven current +required contexts with strict up-to-date protection. `P02b-8` re-verifies those +live settings under the exact reported names. `scripts/assert-tests-ran.py` +already rejects a missing assembly, zero executed tests and any unexecuted test +in the required assemblies; test failures additionally fail the `dotnet test` +command. The phase retains zero skips, per assembly. ## Deliverables @@ -918,10 +1750,11 @@ criterion says *not skipped*, per assembly. DB-side minting list owes. This phase states the **contract** — how long a `(event, consumer)` pair stays replayable — and [Phase 11](phase-11-production-hardening.md) picks the purge interval that honours it. -- `IOutbox` and the `OutboxFlushBehavior` body, with the producer write model G2 - settles. -- The `learnstack.tenancy.settings` producer and eager-invalidation consumers, - with tenant/organization scope preserved and delivery/replay behavior tested. +- `IOutbox` and the `OutboxFlushBehavior` invariant guard, delivered by P02b-1 + under Accepted ADR-0057; immediate INSERT uses the announced transaction. +- `TenantCreatedIntegrationEventV1` from completed provisioning; after G1, two + independent Customization subscriptions install untouched `card@1`/`plain@1` + defaults with one-root effects and the P02b-3 Seeder identity handoff. - A durable `OutboxProcessor` implementing the specified lease: a fenced claim, a per-delivery deadline, bounded retry, a terminal producer state, and the migration that adds `locked_by` / `locked_until` **and** extends `learnstack_outbox_admin`'s @@ -943,8 +1776,8 @@ criterion says *not skipped*, per assembly. where G19 settles, between modules that hold domain code, so the module-scoped sweeps have a subject. - Domain-event collection on the shipped seam and one dispatch site inside the owning - frame's transaction, ordered before the MUST-class audit flush and before whatever - enrols outbox rows. + frame's transaction, ordered before producer invariant checks and MUST audit. + Domain reactions translate to immediate outbox INSERTs only. - **The delivery path's audit frame** in the shape **G6** settles — the component that performs the four owner-side acts on a transport-opened transaction, and the same owner duties performed in the same order by every frame that owns an ambient @@ -980,7 +1813,7 @@ criterion says *not skipped*, per assembly. - OIDC PKCE login through the BFF on the visitor's own tenant host, a cookie session with host-only scoping and single-flight refresh, an `Origin` plus session-bound-token CSRF control, RP-initiated logout, and Auth.js wired in `apps/web` with the two - skills' broken `auth` references corrected. + skills' illustrative `auth` references replaced with the shipped helper. - The first authenticated `/api/v1/*` read and the first guarded page, both in the OpenAPI document and the regenerated SDK. - A Keycloak test fixture over the committed realm JSON and an in-process JWT issuer for @@ -1036,7 +1869,9 @@ shape depends on an open gate say so, and are written when that gate is Accepted - An aggregate change and its outbox row commit together and roll back together, asserted as `learnstack_app`. An enqueue whose event `TenantId` differs from the - ambient context is refused. + successfully announced transaction tenant is refused, including unresolved + ambient provisioning. Stale-generation, read-only and unenrolled producers + fail before INSERT. - An event enqueued from an HTTP request **and** one enqueued from inside an event delivery both commit rows whose `correlation_id` rehydrates to the enqueuing activity's trace, whose `tenant_id` and `organization_id` equal the announced context, @@ -1124,17 +1959,18 @@ shape depends on an open gate say so, and are written when that gate is Accepted one standalone `failed` row. - A state-mutating integration-event handler the catalogue does not classify is refused, proved by a planted unregistered handler rather than by the absence of one. -- An inner refusal a consumer absorbs keeps its own outcome on its own intent while the - delivery commits; a faulted delivery `COMMIT` produces exactly one `indeterminate` - row; and the capture is cleared exactly once per delivery. -- A domain event raised by an aggregate method reaches its in-module handler inside the - same transaction; the handler's write appears in the request's MUST-class row's - `changes`, proving dispatch ran before the flush; and an outbox row that handler - enqueues is dispatched — asserted on the dispatched message, not on the enqueue call. -- A domain-event handler that throws, and a drain that exceeds its bounded depth, each - leave the raising aggregate and every outbox row uncommitted. The success path alone - cannot show this, and the rule that such a handler is infallible by construction is - what makes the throw a rollback rather than a swallowed failure. +- An ordinary event-free inner refusal can be absorbed while the delivery commits; + a refusal after own/descendant produced work poisons the shared transaction. + A faulted delivery `COMMIT` produces exactly one `indeterminate` row, and the + outermost audit frame clears capture exactly once per delivery. +- A successfully saved domain event reaches its in-module translation handler on + owner completion before MUST audit. Its exact outbox row commits with source + state/audit; P02b-2/P02b-3 subsequently prove actual dispatch and consumption. + Repeated saves, detach/delete and failed/canceled saves prove attribution. +- A translation fault or a 257th domain event leaves source state, audit and every + outbox row uncommitted. Planted handlers using writes, nested sends, notification + publication, raises or providers fail the complete module/helper/interface sweep. + Rollback, refusal, cancellation, fault and same-scope reuse cannot replay sources. - A handler that ignores its cancellation token does not stall the rest: the per-delivery deadline elapses, other keys keep dispatching, and the abandoned delivery commits nothing afterwards. @@ -1203,9 +2039,10 @@ shape depends on an open gate say so, and are written when that gate is Accepted cases as `learnstack_app`: an identifier from another tenant or an invisible sibling organization reveals no resource, and each route has a visible-resource positive control. Host/JWT cross-check cases remain independently asserted. -- A supported settings change enqueues `learnstack.tenancy.settings` with the - correct scope; its consumers invalidate only that scope, and replay follows the - inbox contract without repeating the effective side effect. +- Completed provisioning yields one pending `TenantCreatedIntegrationEventV1` + and zero on rollback. P02b-3 proves independent create-only `card@1`/`plain@1` + delivery, sibling recovery, preserved authored/deleted definitions, semantic + built-in identity verification and concurrent fresh seed/delivery convergence. - Two principals behind one BFF do not share an authenticated quota: through the **whole** middleware chain, from one peer address, one exhausts that budget and the other's next request is not 429. G14 settles the delivered pre-auth visitor/ diff --git a/docs/roadmap/phase-02d-exit-evidence.md b/docs/roadmap/phase-02d-exit-evidence.md index 54a9c1c3..6553ec2f 100644 --- a/docs/roadmap/phase-02d-exit-evidence.md +++ b/docs/roadmap/phase-02d-exit-evidence.md @@ -4,8 +4,9 @@ independent review rounds. The real PR check receipts, verified artifacts, seventh-check activation and positive exit verdict are recorded in the [CI and exit closeout](phase-02d-walking-skeleton.md#p02d-7-ci-and-phase-exit-closeout-2026-10-11). -This document is an evidence index, not a new architecture decision or a merge -approval. PR #28 awaits maintainer review and merge. +This document is an evidence index, not a new architecture decision. PR #28 is +merged; the [merge closeout](phase-02d-walking-skeleton.md#p02d-7-merge-and-closeout-2026-10-11) +owns final verification and the completed phase exit. The rows enumerate all 36 [deliverables](phase-02d-walking-skeleton.md#deliverables), 79 [completion criteria](phase-02d-walking-skeleton.md#completion-criteria), @@ -20,7 +21,7 @@ phase document owns the complete contract and every subordinate condition. |---|---| | B | Real PR `backend` passes: Release build/format, 1,705 unit, 203 architecture, four contract and 180 Docker-free integration; zero failures/skips and execution guards. | | I | Real PR `backend integration (Testcontainers)` passes all 872 cases, including the three locale/branding/combined-scope cases and physical app-role/RLS/read-only production proofs; zero failures/skips. | -| F | Real PR guarded `frontend` passes 1,444 web + 60 SDK cases, zero failures/skips/todos, plus lint, typecheck, builds and controls. | +| F | Final PR guarded `frontend` passes 1,478 web + 60 SDK cases, zero failures/skips/todos, plus lint, typecheck, builds and controls. | | O | Real PR `openapi diff` and generated SDK drift pass with retained planted incompatibility controls. | | J | Real PR `public demo + lighthouse` passes with verified nine-page/27-audit artifacts and confirmed cleanup; authorized seventh-context activation and complete protection read-back pass. | | M | Named source/document inspection and explicit future-owner reconciliation in Step 4; both fresh review rounds and verified fixes complete. | @@ -31,13 +32,12 @@ Named backend classes live under `backend/tests/`; frontend tests live under `frontend/apps/web/src/` or `frontend/scripts/`. Partial-class filenames are identified separately where needed. P1–P6 refer to the merged packet records in [Phase 02d](phase-02d-walking-skeleton.md); Step 1–3 refer to P7's records. -[Run 38108622191](https://github.com/HodeTech/LearnStack/actions/runs/38108622191) -supplies B/I/F/O/J for reviewed implementation source `d47ef17`, tested merge -`ba44563`; the [exact-head verification](phase-02d-walking-skeleton.md#reviewed-implementation-head-verification-2026-10-11) -records full identities and artifact validation. The earlier run owns -seventh-check activation history. The -[PR rollup](https://github.com/HodeTech/LearnStack/pull/28/checks) must also pass -on subsequent heads before merge; historic counts do not replace it. +[Run 38127402847](https://github.com/HodeTech/LearnStack/actions/runs/38127402847) +supplies final B/I/F/O/J for source `8d9d219`, tested merge `2d8ff6b`; the +[merge closeout](phase-02d-walking-skeleton.md#p02d-7-merge-and-closeout-2026-10-11) +records full identities and verified artifacts. Earlier runs retain their dated +counts and seventh-check activation history. The squash merge has the same tree +as the final PR source; its post-merge run is identified separately. ## Local and manual evidence boundaries @@ -207,12 +207,16 @@ These are explicit phase boundaries, not unfinished P7 implementations. The named owners' documents carry the handoff; no future decision gate is accepted by listing its phase here. +**Reconciled — 2026-10-12.** X04 follows the Accepted P02b-1 production +contract and current Tenancy catalogue. P7's dated handoff remains preserved +with its [reconciliation](phase-02d-walking-skeleton.md#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11). + | ID | Exclusion locator | Named owner | Evidence and boundary | |---|---|---|---| | X01 | Authentication, sessions, login | [Phase 02b](phase-02b-events-auth.md) | Phase02b P02b-5/6/7, Identity/session scope and criteria. | | X02 | Token-keyed authenticated and write rate-limit budgets, and what the pre-authentication limiter does once a caller is validated | [Phase 02b](phase-02b-events-auth.md) (G14) | Phase02b G14/P02b-5 and whole-chain two-principal quota criterion. | | X03 | Keycloak realm reconciliation (a seed step that writes to Keycloak) | [Phase 02b](phase-02b-events-auth.md) (G12) | Phase02b G12/P02b-6 and warm-realm repeat-seed criteria. | -| X04 | The `learnstack.tenancy.settings` eager invalidation event | [Phase 02b](phase-02b-events-auth.md), per [the Tenancy spec](../modules/tenancy/README.md) | Phase 02b Event Bus scope, deliverable and completion criterion explicitly own learnstack.tenancy.settings; the Tenancy catalogue names the same owner. | +| X04 | Future settings caching and any required invalidation | A future cached consumer's scope/key/freshness decision; [Phase 11](phase-11-production-hardening.md#demand-gated-building-blocks) owns triggered distributed adapters | The [Tenancy catalogue](../modules/tenancy/README.md#integration-event-catalogue) declares no settings-invalidation event. Typed settings are uncached; Phase 11's adapter triggers do not promise a settings event. | | X05 | Isolation tests through authenticated or id-addressed routes | [Phase 02b](phase-02b-events-auth.md) | Phase 02b completion criteria explicitly include authenticated and id-addressed tenant/organization isolation with app-role denial and successful controls. | | X06 | Identity domain, roles, permissions, invitations; permission keys for public reads; the rule that every endpoint carries `[Authorize]` or `[AllowAnonymous]` | [Phase 03](phase-03-identity-admin.md) | Phase03 Identity/authorization/invitations scope and deliverables. | | X07 | A tenant-scope read across all organizations on a tenant host (the `app.scope` carrier) | [Phase 03](phase-03-identity-admin.md) | Phase03 Authorization explicitly owns app.scope and tenant-wide cross-organization carrier. | @@ -261,14 +265,14 @@ by listing its phase here. | E06 | G20 Implemented and green required | B/F nonempty census and dirty/clean controls, I schema-replacement companion, Step2 record. | | E07 | Accessibility and renderer PR record | D31/C66 PR27 link and original manual attribution plus F/I/J automated controls; no inferred new manual pass. | | E08 | Zero 02d Registered/Awaiting catalogue rows and standard/index alignment | Four P7 promotions with actual behavioral owners and hard CI, computed counts and Standard_Status_Headers_Match_The_Index exact-head receipt. | -| E09 | Delivery record names moved P02b-0 premises | /api/v1 contract, *.learnstack.local native HTTPS/manual trust, visitor+peer quotas, seed default-org/tenant scopes, no-cache settings/eager event owner, single bootstrap; do not Accept auth/BFF future gates. | +| E09 | Delivery record names moved P02b-0 premises | /api/v1 contract, *.learnstack.local native HTTPS/manual trust, visitor+peer quotas, seed default-org/tenant scopes, uncached settings with the dated future-invalidation reconciliation, single bootstrap; do not Accept auth/BFF future gates. | The [Step 4 preservation and P02b-0 handoff](phase-02d-walking-skeleton.md#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11) names the retained Education data, live API/host/trust/limiter/bootstrap premises -and future settings-event ownership. Phase 05 migrates the existing records; it -does not treat reseeding as migration. Course Marketplace ADR-0049 and Phase 09a -remain Proposed and are outside this exit. +and the reconciled uncached-settings boundary. Phase 05 migrates the existing +records; it does not treat reseeding as migration. Course Marketplace ADR-0049 +and Phase 09a remain Proposed and are outside this exit. -All E01–E09 obligations pass against the receipts and named evidence above, as -recorded in the positive CI/exit closeout. Maintainer PR review and merge are -separate actions; subsequent PR heads retain the seven required checks. +All E01–E09 obligations pass against the receipts and named evidence above. +The maintainer merged PR #28 on 2026-10-11; Phase 02d is closed. P02b-0 +re-verification is next, with no future-phase gate accepted by this closeout. diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 76887cdd..66993062 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -1,9 +1,10 @@ # Phase 02d: Two-Tenant Walking Skeleton -> **Status (2026-10-11).** Phase 02d implementation and exit verification are -> **complete**; [PR #28](https://github.com/HodeTech/LearnStack/pull/28) awaits -> maintainer review and merge. The [CI and exit closeout](#p02d-7-ci-and-phase-exit-closeout-2026-10-11) -> records the evidence and live protection read-back. The kickoff, `P02d-0`, ships this +> **Status (2026-10-11).** Phase 02d is **complete and merged** through +> [PR #28](https://github.com/HodeTech/LearnStack/pull/28). The +> [merge closeout](#p02d-7-merge-and-closeout-2026-10-11) records final verification +> and the completed phase exit. P02b-0 phase-entry re-verification is next. +> The kickoff, `P02d-0`, ships this > plan — the inherited baseline, the packet table, the decision register, criteria that > name their evidence, and the corrections to the documents that contradicted the phase > — and no code. Every later packet opens with its decision pass and updates its own @@ -18,7 +19,7 @@ > | P02d-4 | Public read API and contract checks | ✅ complete and merged — 2026-10-08; [merge closeout](#p02d-4-merge-and-closeout-2026-10-08) | > | P02d-5 | Server-rendering path | ✅ complete and merged — 2026-10-09; [merge closeout](#p02d-5-merge-and-closeout-2026-10-09) | > | P02d-6 | Public renderer | ✅ complete and merged — 2026-10-10; [merge closeout](#p02d-6-merge-and-closeout-2026-10-10) | -> | P02d-7 | Demo, full-stack CI and exit | ✅ implementation and exit verification complete — 2026-10-11; all four steps completed both independent review rounds; [CI and exit closeout](#p02d-7-ci-and-phase-exit-closeout-2026-10-11); PR #28 review/merge pending | +> | P02d-7 | Demo, full-stack CI and exit | ✅ complete and merged — 2026-10-11; all four steps completed both independent review rounds; [merge closeout](#p02d-7-merge-and-closeout-2026-10-11) | **Acceptance update — 2026-10-02.** P02d-1 remains merged. The maintainer accepted ADR-0050/0051 and the [P02d-2 package](#p02d-2-decision-package-2026-10-02), including @@ -285,7 +286,7 @@ Named so that no reader has to guess, and so no later phase can assume it was do | Authentication, sessions, login | [Phase 02b](phase-02b-events-auth.md) | | Token-keyed authenticated and write rate-limit budgets, and what the pre-authentication limiter does once a caller is validated | [Phase 02b](phase-02b-events-auth.md) (G14) | | Keycloak realm reconciliation (a seed step that writes to Keycloak) | [Phase 02b](phase-02b-events-auth.md) (G12) | -| The `learnstack.tenancy.settings` eager invalidation event | [Phase 02b](phase-02b-events-auth.md), per [the Tenancy spec](../modules/tenancy/README.md) | +| Future settings caching and any required invalidation | A future cached consumer's scope/key/freshness decision; [Phase 11](phase-11-production-hardening.md#demand-gated-building-blocks) owns triggered distributed adapters. The [Tenancy catalogue](../modules/tenancy/README.md#integration-event-catalogue) declares no settings-invalidation event | | Isolation tests through authenticated or id-addressed routes | [Phase 02b](phase-02b-events-auth.md) | | Identity domain, roles, permissions, invitations; permission keys for public reads; the rule that every endpoint carries `[Authorize]` or `[AllowAnonymous]` | [Phase 03](phase-03-identity-admin.md) | | A tenant-scope read across all organizations on a tenant host (the `app.scope` carrier) | [Phase 03](phase-03-identity-admin.md) | @@ -439,7 +440,7 @@ premise a row cites is re-verified at that pass rather than trusted. | G20 | What mechanically backs "no production code branches on which tenant it serves"? The shipped domain-term scan strips literals and exempts seed data. (a) The mechanism and its literal source; (b) its subjects, matching and the platform built-ins; (c) its exemptions, including development hosts in frontend or infrastructure configuration; (d) whether a ban on production references to `LearnStack.Tools.Seeder` and a behavioural same-code, different-data test accompany it | A Standards 21 sibling row scanning production backend and `frontend/` sources, comments stripped, for exact identity literals read from `SeedData` (slugs, ids, hosts, display names, customization keys), built-ins excluded, with planted offenders; plus the behavioural test. The exemption policy is the owner's judgement | Detail: a Standards 21 row Registered in the first pass that uses it and Implemented before exit; a phase-doc statement in § Genericity proof. No ADR | P02d-2 (a: every seed literal lives where the source reads it), P02d-5 (c: the first host outside `SeedData`), P02d-6 (b: frontend subjects), P02d-7 (Implemented and required) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): (a) SeedData literal source and Registered guard. [Accepted — 2026-10-08](#p02d-5-accepted-answers): (c) exemptions; [Accepted — 2026-10-09](#p02d-6-accepted-answers): (b) frontend subjects; implementation and behavioral proof remain P7; [Accepted — 2026-10-11](#p02d-7-decision-package-2026-10-10): matching, canonical built-ins, Seeder-reference fence and behavioral ownership; Step 2 implements the required fences and schema-replacement companion | | G21 | Does the anonymous public path set any cookie — the [Frontend Architecture Standards § Tenant Resolution](../standards/07-frontend-architecture.md#tenant-resolution) flowchart sets them — and may a public page load any cross-origin subresource, such as the CDN-hosted logo and font assets Frontend Architecture describes? | No cookies, since the locale is already in the path and a locale-less request redirects ([Localization Standards § URL Strategy](../standards/08-localization.md#url-strategy)); same-origin subresources only; both asserted by a check. Whether tenant branding may point visitors' browsers at third-party hosts is a data-protection choice for the owner | Detail: a phase-doc statement; the Standards 07 flowchart and Frontend Architecture § Theming reconciled in the deciding pass | P02d-2 (subresources, if G16 admits a URL-valued token), P02d-5 (cookies: the middleware replacement is the first code that could set one) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): subresources. Cookies remain open for P02d-5; [Accepted — 2026-10-08](#p02d-5-accepted-answers): cookies; prior subresource answer retained | | G22 | How does the customization definition projection load and stay correct? In the request's ambient transaction, or as a ninth out-of-band tenant-context setter (ADR-0040's set is closed at eight)? In what order are the generation and the rows read; what does an absent generation row mean; how is a cache filled inside a transaction that bumped and rolled back kept unreachable, when the bump is an upsert increment that can reissue a number; what does an absent definition set return; which families are registered, and how does the adapter's exact-tuple `cache.name` mapping match generation-embedded names; what do the TTLs bound; and is the contract batched so a public read issues a bounded number of statements? | Load in the ambient transaction; read the generation first, then the rows; fill only from non-bumping transactions; treat cache faults as misses; restate the module's cache-hit budget; a batched contract, with statement-count assertions cold and warm | Contract: the Customization spec § Primary read flow and a [Tenant Customization Model § 8.2](../architecture/32-tenant-customization-model.md#82-cache-strategy) statement on how a request learns the generation; a dated ADR-0040 amendment and a setters row only if the loader is out-of-band. Detail: the [Infrastructure Stack Standards](../standards/20-infrastructure-stack.md) cache table, the `cache.name` mapping, the Observability Standards metrics family list | P02d-3 | [Accepted — 2026-10-02](#p02d-3-decision-package-2026-10-02) | -| G23 | The typed settings accessor and its freshness. With no `learnstack.tenancy.settings` event until Phase 02b and the seed writing from its own process, what bounds staleness: a TTL with a stated bound, a writer-coupled Tenancy settings generation counter, or no settings cache here? What are the accessor's name and glossary headword; how is a cached read keyed so tenant-wide and organization rows never cross organizations — a settings read depends on `app.organization_id` today, and the policy's tenant-scope read gains a carrier in Phase 03; and does its loader run in the ambient transaction? | The reviews split on freshness — a TTL bound until 02b, a counter, or no cache. For keys: tenant-wide rows loaded with an explicit `organization_id IS NULL` predicate under `CacheKey.ForTenant`, each organization's overrides under `CacheKey.ForOrganization`, merged in memory; an ambient loader. The documented tenant-only key is rejected, because it would serve one organization's overrides to another | Detail: if settings are cached, the Infrastructure Stack Standards cheat-sheet rows and `cache.name` mapping; the Tenancy spec's event row and budget; a glossary headword. Contract only for a counter (the Tenancy spec, Database Standards § Table classes and § GRANT matrix) or an out-of-band loader (an ADR-0040 amendment) | P02d-2 (a counter is bumped inside the setting command's transaction), P02d-3 (name, keys, loader) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): no settings cache in P02d-2/3. [Accepted — 2026-10-02](#p02d-3-decision-package-2026-10-02): typed ambient accessor/scoped merge | +| G23 | The typed settings accessor and its freshness. With no declared settings-invalidation event and the seed writing from its own process, what bounds staleness: a TTL with a stated bound, a writer-coupled Tenancy settings generation counter, or no settings cache here? What are the accessor's name and glossary headword; how is a cached read keyed so tenant-wide and organization rows never cross organizations — a settings read depends on `app.organization_id` today, and the policy's tenant-scope read gains a carrier in Phase 03; and does its loader run in the ambient transaction? | The reviews split on freshness — a TTL bound, a counter, or no cache. For keys: tenant-wide rows loaded with an explicit `organization_id IS NULL` predicate under `CacheKey.ForTenant`, each organization's overrides under `CacheKey.ForOrganization`, merged in memory; an ambient loader. The documented tenant-only key is rejected, because it would serve one organization's overrides to another | Detail: if settings are cached, the Infrastructure Stack Standards cheat-sheet rows and `cache.name` mapping; the Tenancy spec's event catalogue and budget; a glossary headword. Contract only for a counter (the Tenancy spec, Database Standards § Table classes and § GRANT matrix) or an out-of-band loader (an ADR-0040 amendment) | P02d-2 (a counter is bumped inside the setting command's transaction), P02d-3 (name, keys, loader) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): no settings cache in P02d-2/3. [Accepted — 2026-10-02](#p02d-3-decision-package-2026-10-02): typed ambient accessor/scoped merge | | G24 | Display fallback. Which document owns the chain — [Localization § Fallback Rules](../architecture/12-localization.md#fallback-rules) or [Localization Standards § Locale Model](../standards/08-localization.md#locale-model), which state different chains, while the shipped `LocalizedText.Resolve` narrows one subtag at a time and ends at the first authored value? What is the terminal state of a nullable Pattern A field and of a Pattern B label? Does a response say which locale a fallback value resolved in, so the page can mark its language (WCAG 3.1.2)? | Localization architecture owns the chain and Localization Standards links it, both recording the shipped narrowing and the first-authored terminal for labels; a nullable Pattern A field renders absent; each fallback-capable field reports its resolved locale | Detail: Localization Standards § Locale Model linking its owner, reconciled with `LocalizedText` in the same diff; the Customization contract's signature; the response schema under G26. No ADR | P02d-3 (the first caller that passes a fallback chain), P02d-4 (response fields) | [Accepted — 2026-10-02](#p02d-3-decision-package-2026-10-02): internal fallback; [Accepted — 2026-10-03](#p02d-4-accepted-answers): public response locale fields; renderer language attributes remain P02d-6 | | G25 | Site data and the page set. How does the renderer get the per-host data none of the Education reads returns — enabled and default locales, branding tokens, taxonomy display values, content-type field lists: fields embedded in the course reads (which cannot supply a default locale before a locale is known), a separate `[PublicSurface]` read resolved from the effective host, or the edge host lookup [Frontend Architecture Standards § Tenant Resolution](../standards/07-frontend-architecture.md#tenant-resolution) and [Infrastructure Stack Standards § Host → Tenant Resolution](../standards/20-infrastructure-stack.md#host--tenant-resolution) prescribe today, which must then state the effective host over the hop? Does the frontend ever hold a tenant or organization id? And which `(public)` pages ship — catalog, course with ordered lesson links and lesson, or two pages with bounded lesson links in the catalog response? | One `[PublicSurface]` site-data read with no host parameter, returning a closed projection and no ids, and three pages, which gives the course-detail read a consumer; one review keeps two pages with an explicit catalog outline. The first two options change what two Active standards prescribe | Contract: a phase-doc statement in § Read API and § Public renderer; for the first two options, edits to the two standards named, with an ADR if the pass judges the change non-trivial (no ADR carries the edge-lookup rule). Detail: the API Standards § Public surface rows; the Frontend Architecture sketch, sequence diagram and cache rows; the Localization architecture's edge locale sentence; the glossary; Phase 06 § What Phase 02d already shipped; Phase 05's inherited row if the course-detail read changes | P02d-4 (the endpoint set and DTOs the OpenAPI baseline freezes; a two-page answer changes the catalog response) | [Accepted — 2026-10-03](#p02d-4-accepted-answers): bootstrap and page set; page implementation remains P02d-6 | | G26 | The v1 public read contract. The path shape beside Phase 05's authoring `/courses/{id}` — a shared slot, a distinct public prefix, or `/courses/by-slug/{slug}`; each response as an allow-list and what it never carries; the embedded lesson list's fields, order and bound, and whether an empty list is valid; per-locale alternates; how enums and envelopes stay additive; and which Problem Details responses each operation documents, given that no non-idempotent operation documents any today and a baseline of `200`s cannot see a status change | Fields limited to what the pages render; object envelopes, extensible enums, a deny-list contract test (`tenantId`, `organizationId`, `createdBy`, `updatedBy`, `deletedAt`, `rowVersion`, `slugKey`); the embedded list carries title, slug and order under a cap; `alternates` for enabled, translated locales; one shared transformer declaring each operation's statuses as `application/problem+json`. No review settled the path | Contract: a phase-doc statement recorded before the breaking-change check stores its baseline. Detail: the OpenAPI snapshot; [API Standards § URL Structure](../standards/04-api-design.md#url-structure) for a prefix class, § Pagination for an embedded list, § OpenAPI; the gateway's public-band row. [ADR-0024](../decisions/0024-api-versioning-policy.md) settles that later additions are non-breaking | P02d-1 (whether the slug grammar must refuse GUID shapes, with G9), P02d-4 (route templates, records, snapshot) | [Accepted — 2026-09-14](#p02d-1-accepted-answers): slug grammar; [Accepted — 2026-10-03](#p02d-4-accepted-answers): route and response contracts | @@ -6210,6 +6211,16 @@ ten lessons and 27 translations at their declared scopes, including draft and restricted controls. Carry those persisted fixtures through the migration; reseeding is not a migration strategy. +> **Reconciliation — 2026-10-12.** The settings-event ownership below records +> P7's expectation at closeout. Accepted +> [ADR-0057](../decisions/0057-transactional-event-production-and-completion.md#first-useful-production-flow) +> selects completed provisioning as the first useful event; the +> [Tenancy catalogue](../modules/tenancy/README.md#integration-event-catalogue) +> promises no settings-invalidation event. Settings remain uncached. Any future +> cached consumer needs its scope/key/freshness decision; Phase 11 owns the +> distributed adapters on their existing triggers, without a settings-event promise. +> The dated handoff text is retained. + **P02b-0 re-verification handoff.** Re-read the four `/api/v1/public` GET/HEAD pairs and contract; retained `*.learnstack.local` seed hosts over native HTTPS, manual local hosts/CA setup and private paired hop configuration; coordinated @@ -6506,6 +6517,74 @@ merge; the [PR check rollup](https://github.com/HodeTech/LearnStack/pull/28/chec owns that moving-head result. Next after maintainer review and merge is **P02b-0 re-verification and decision pass**, before Phase 02b implementation. +### P02d-7 merge and closeout (2026-10-11) + +The maintainer merged [PR #28](https://github.com/HodeTech/LearnStack/pull/28) at +2026-10-11 09:38:58 UTC. GitHub records final PR source +`8d9d2195cfadf1d83807613ea22d792b7ce329f4` and squash merge +`d37f46ccf0ebbf909f02c81a59d9019af3e9a1ca`, whose parent is the previous main +`378346126949c90bb286b5217c24513fc2044f06`. Source and squash merge have the +identical tree `7f83bee243fe17626ef10af051e54a9218fd7aaa`. Development incorporates +the main merge without switching branches or rewriting published history. + +All seven required checks pass in the +[final PR run](https://github.com/HodeTech/LearnStack/actions/runs/38127402847) +for that source. The tested synthetic merge is +`2d8ff6b23cd1b27691ea0038859944ca6c0ce395`, with the final source and previous +main as parents. Independently verified downloaded receipts confirm: + +- **2,964 backend passes**, zero failures/skips: 1,705 unit, 203 architecture, + four contract, 180 Docker-free integration and 872 Docker integration cases. +- **1,538 guarded frontend passes**: 1,478 web and 60 SDK, zero + failures/skips/todos; lint, typecheck, builds and SDK drift also pass. +- Exactly 27 Lighthouse JSON/HTML report pairs, nine screenshots and two + sanitized receipts. Report digests, source/checkout/build/invocation identities + and the nine-URL/three-run census match. Normalized initial JS gzip is + 106,358–106,552 bytes, CLS 0 and automated accessibility 1 throughout. + Per-URL timing medians remain advisory. +- Nine linked navigations, 24 planted visibility refusals, both TLS refusals and + the actual audit-timeout control pass. All 134 API observations are 200, + with zero 429, malformed or pending records. Setup/proof/cleanup take + 170,462 / 618,055 / 10,695 ms; all owned-cleanup flags pass. +- A fresh protection read-back retains `strict: true` and all seven GitHub + Actions contexts, including `public demo + lighthouse`. This closeout changes + no protection setting. + +The [post-merge main run](https://github.com/HodeTech/LearnStack/actions/runs/38129121225) +tests the squash commit separately; its result is not inferred from source CI. +At this documentation check, five jobs pass; Docker integration and the +demo/Lighthouse job are still running. + +**P02d-7 and Phase 02d are closed.** All seven implementation packets are merged; +all four P7 steps and verified fixes retain their recorded independent reviews. +The [individual exit evidence](phase-02d-exit-evidence.md) reconciles all +36 deliverables, 79 completion criteria, 39 exclusions and nine exit obligations. +Earlier dated delivery/review records, Accepted ADRs and their acceptance-time +or pre-merge wording remain historical and unchanged. No new human/VoiceOver, +full WCAG, zoom, production-p95 or deployment-readiness claim is added. + +**Next: P02b-0 — phase-entry re-verification.** The +[Phase 02b packet table](phase-02b-events-auth.md#packets-and-decision-gates) +requires re-reading code, corpus and Hub premises against HEAD, including the +[P7 handoff](#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11). +P02b-0 has not started; each later packet closes its own gates in its decision +pass. No Phase 02b implementation or auth/BFF decision is accepted here. + +Phase 05 retains versioned-curriculum migration and preservation of existing +Education/seed records; Phase 06 retains full renderer/Studio and Playwright +Test/axe; Phase 07 retains enrollment, learner portal, progress and access grants. +Phase 11 retains production hardening, telemetry and demand-gated adapters. +Course Marketplace ADR-0049 and Phase 09a remain Proposed; commerce, commissions, +payouts and cross-installation federation are not delivered. + +**Documentation-only closeout validation.** All 203 local Release architecture +cases and seven ADR lifecycle/workflow controls pass, with zero failures/skips. +The seven changed Markdown files resolve 1,088 relative links and 478 fragments +with zero errors; added prose wrapping and whitespace checks pass. Prior dated +phase delivery records and all Accepted ADR files are byte-preserved. Full runtime +suites are not rerun locally for this documentation-only change; the final PR +receipts above supply their evidence. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved @@ -7201,7 +7280,7 @@ catalogued legs are not implemented (**G28**), and the tenant-branching check is - The typed tenant and organization settings accessor over `tenant_settings`, internal to the backend, with its interface name and glossary headword, key composition, loader and staleness bound, if any (**G23**), recorded in the cheat sheet when G23 caches - settings, and in the Tenancy spec's event row and budget. + settings, and in the Tenancy spec's current event catalogue and budget. **Public API and contract checks** @@ -7294,8 +7373,9 @@ catalogued legs are not implemented (**G28**), and the tenant-branching check is - The delivery record at exit names the Education model shipped, the preservation obligations G2 recorded, the seed fixtures Phase 05's migration must carry, and the premises this phase moved that `P02b-0` re-verifies: the first `/api/v1` endpoints, - G32's hosts, G34's limiter answer, G14's seed context and the - `learnstack.tenancy.settings` event G23 leaves to Phase 02b. + G32's hosts, G34's limiter answer, G14's seed context and G23's uncached + settings boundary. The dated P7 handoff retains its original event expectation + with the 2026-10-12 reconciliation beside it; no current settings event is declared. ## Completion Criteria diff --git a/docs/standards/02-backend-coding.md b/docs/standards/02-backend-coding.md index 42ecdd11..e6701326 100644 --- a/docs/standards/02-backend-coding.md +++ b/docs/standards/02-backend-coding.md @@ -4,6 +4,8 @@ **Derives from:** [ADR 0002 — Initial Architecture](../decisions/0002-initial-architecture.md), [ADR 0006 — Events and Outbox](../decisions/0006-events-and-outbox.md), [ADR 0023 — Strongly-Typed ID Source Generator](../decisions/0023-strongly-typed-id-source-generator.md), [ADR 0031 — PostgreSQL Major Version](../decisions/0031-postgresql-major-version.md), [ADR 0036 — Trusted Inputs for Tenant and Organization Resolution](../decisions/0036-tenant-resolution-trusted-inputs.md). Public-read additions derive from [ADR-0052](../decisions/0052-anonymous-public-read-boundary.md). +Producer additions derive from +[ADR-0057](../decisions/0057-transactional-event-production-and-completion.md). C# / .NET conventions for LearnStack backend code. @@ -228,7 +230,21 @@ Rules: - Aggregates are the only entry points for state changes. - Aggregate methods enforce invariants; setters are private. -- Domain events raised from aggregate methods; collected by the unit of work and dispatched in-process inside the ambient transaction, before it commits ([ADR-0010](../decisions/0010-cross-module-communication.md) puts a domain event in the same transaction as the change that raised it). The collection and dispatch land in [Phase 02b](../roadmap/phase-02b-events-auth.md); until then an aggregate can raise and nothing hears it. +- Domain events are raised by aggregate methods and promoted by the shared + SaveChanges interceptor only after successful persistence. Under Accepted + ADR-0057, the transaction owner drains them sequentially before producer + invariants, MUST audit and commit, with at most 256 per transaction. API and + Seeder share the collection/completion runtime delivered by P02b-1 Step 3. +- Domain-event handlers translate facts to outbox rows only: no root/write-store + mutation, nested requests, further raises, notification publishing or providers. + Unexpected translation faults roll back; expected decisions belong in commands + or integration consumers. The total-count bound permits no recursive cascade. +- Successful enqueue or successful-save promotion records sticky produced work in + its invocation and every ancestor. `Result.Fail` from such a frame poisons the + shared unit before its UoW frame resolves, including absorbed nested refusals. + Event-free refusals keep ADR-0040's meaning; joiners are not savepoints. +- Completion/refusal/rollback/cancellation/fault/disposal clear collected sources + and generation state exactly once. A joiner cannot drain or clear owner state. - Avoid anemic models (data + getters/setters with logic outside). - Avoid primitive obsession; use value objects. - **Entity equality is identity equality, and it is defined once.** `Entity` @@ -306,13 +322,13 @@ decides what happens *inside* the steps ADR-0033 named; the order is untouched: ([ADR-0044 § 3](../decisions/0044-audit-write-path.md); `ProvisionTenantCommand` declares two), and parking them touches no `DbContext`. - On the way out the **owning** unit-of-work frame reconciles, gated on - `IUnitOfWorkScope.IsOwner`: every intent whose state is anything other than + On the way out only the outermost `AuditExecutionCoordinator` frame reconciles: + every intent whose state is anything other than `Committed` — never written, rolled back, or a commit whose outcome is unknown — is written standalone with the real outcome, in its own short transaction. "Written" is not "committed", and a per-request flag cannot - observe a rollback. A joiner frame reconciles nothing, signals nothing, and - does not call `Clear()`; the outermost behavior clears the capture in its + observe a rollback. Nested audit frames reconcile nothing and never call + `IAuditStateCapture.Clear()`; the outermost audit coordinator clears capture in its `finally` ([ADR-0044 § 4](../decisions/0044-audit-write-path.md)). **Fail-closed is narrower than it reads.** An **in-transaction** MUST-class @@ -376,7 +392,8 @@ decides what happens *inside* the steps ADR-0033 named; the order is untouched: **owning** frame only (`IUnitOfWorkScope.IsOwner`) — a joiner's `CompleteAsync` is a no-op, so a joiner that reported the boundary would claim durability for a row nothing has committed. First, immediately before - `COMMIT` it calls `IAuditStore.WritePendingAsync`, which inserts **every** + `COMMIT`, after owner-only domain drain and producer invariant checks under + ADR-0057, it calls `IAuditStore.WritePendingAsync`, which inserts **every** pending intent in the scope as a complete MUST-class audit row on this transaction — a no-op when none is pending, and a rollback plus `audit_unavailable` when it fails, raised as @@ -388,13 +405,12 @@ decides what happens *inside* the steps ADR-0033 named; the order is untouched: after a rollback, `Indeterminate` when `CommitAsync` faults and the server-side result is genuinely unknown. That signal is the only thing step 3's reconcile pass trusts. -7. **`OutboxFlushBehavior`** — Per - [15-event-and-outbox.md](../architecture/15-event-and-outbox.md), enrols - `IOutbox` messages in the current transaction; the dispatcher ships them - through the registered `IEventBus` after commit. The registered - implementation is `InProcessEventBus` until the Dapr adapter's trigger - fires ([ADR-0035](../decisions/0035-demand-gated-infrastructure.md)); - handler code is identical either way, which is the point of the port. +7. **`OutboxFlushBehavior`** — Retains step 7 as the producer enrollment and + invariant guard under Accepted ADR-0057. `IOutbox.EnqueueAsync` inserts + immediately through the announced ambient connection/transaction; step 7 + neither buffers nor flushes messages nor dispatches domain events. The owner + completes those events before MUST audit/commit. P02b-1 implements this + contract; P02b-2 owns later dispatch through `IEventBus`. 8. **Handler** — domain logic; returns `Result`. **No** `throw new DomainException` for expected business-rule violations — use `Result.Fail(business_rule_violation, ...)`. The diff --git a/docs/standards/05-database.md b/docs/standards/05-database.md index a8be60ef..736a8b03 100644 --- a/docs/standards/05-database.md +++ b/docs/standards/05-database.md @@ -26,6 +26,8 @@ write path, not on the `tenants` CHECK**), ships unwritten**). Public-read additions derive from [ADR-0052](../decisions/0052-anonymous-public-read-boundary.md). +Producer additions derive from +[ADR-0057](../decisions/0057-transactional-event-production-and-completion.md). PostgreSQL schema, EF Core, and migration conventions. @@ -1380,7 +1382,7 @@ Public read models consumed by other modules: ```sql CREATE TABLE outbox_messages ( - id uuid PRIMARY KEY DEFAULT uuidv7(), + id uuid PRIMARY KEY DEFAULT uuidv7(), -- producer supplies EventId occurred_at timestamptz NOT NULL DEFAULT now(), tenant_id uuid NOT NULL, organization_id uuid NULL, -- null = tenant-wide event; see note below @@ -1389,9 +1391,9 @@ CREATE TABLE outbox_messages ( actor_user_id uuid NULL, type text NOT NULL, -- assembly-qualified event type name topic text NOT NULL, -- "learnstack.{module}.{aggregate}" - partition_key text NOT NULL, -- ordering domain: aggregate id, else tenant_id + partition_key text NOT NULL, -- routing source: aggregate id or explicitly tenant-wide tenant_id payload jsonb NOT NULL, - metadata jsonb NULL, + metadata jsonb NULL, -- producer writes SQL NULL (ADR-0057) processed_at timestamptz NULL, attempts int NOT NULL DEFAULT 0, last_error text NULL, @@ -1438,6 +1440,23 @@ GRANT UPDATE (processed_at, attempts, last_error, available_after) -- See ADR-0006 Amendment 1 and 15-event-and-outbox.md. ``` +[ADR-0057](../decisions/0057-transactional-event-production-and-completion.md) +accepts `id = EventId`, supplied by the producer; the existing DDL default is +not the producer identity source. There is no separate `event_id` column. +Duplicate enqueue fails. `type` retains the assembly-qualified concrete name. +`IOutbox` inserts immediately on the enrolled, announced, writable ambient +`IUnitOfWork` connection/transaction as `learnstack_app`; it opens/commits +nothing and calls no transport. The immutable announcement generation and tenant +must match the producer frame, including unresolved provisioning. + +The producer writes concrete-runtime JSON with frozen `PayloadJsonOptions`. +Additional payload fields are identifiers only; the complete canonical UTF-8 +payload is capped at 262,144 bytes inclusive before INSERT. `metadata` is SQL +NULL with no caller extension channel. This is application admission, not a new +DDL CHECK or a claim about stored `jsonb` byte length. P02b-1 implements this +producer boundary; type-resolution outcomes, leases and inbox stay P02b-2/P02b-3 +work. + `organization_id` is `uuid NULL` and mirrors the organization context of the transaction that enqueued the row — [ADR-0032 § Sub-decision 12](../decisions/0032-exception-handling-logging-and-observability.md) diff --git a/docs/standards/10-observability.md b/docs/standards/10-observability.md index c78737b0..c1883972 100644 --- a/docs/standards/10-observability.md +++ b/docs/standards/10-observability.md @@ -2,6 +2,8 @@ **Status:** Active **Derives from:** [ADR 0002 — Initial Architecture](../decisions/0002-initial-architecture.md), [ADR 0006 — Events and Outbox](../decisions/0006-events-and-outbox.md) (outbox + tenant-context propagation across async boundaries), [ADR 0032 — Exception Handling, Logging, and Observability Architecture](../decisions/0032-exception-handling-logging-and-observability.md) (Sentry / OTel boundary, Serilog wiring, span attribute propagation, deployment-mode branching). +Producer additions derive from +[ADR-0057](../decisions/0057-transactional-event-production-and-completion.md). Three signals — logs, traces, metrics — bound by a single correlation id. Everything we ship is observable from day one. @@ -63,7 +65,7 @@ These propagate into: - Background jobs (Hangfire activator + filter) — payload carries `tenant_id` + `correlation_id`; activator restores `ITenantContext` before invocation - Outbox dispatcher — row schema carries `tenant_id`, `organization_id?`, - `correlation_id`, `event_id`, `occurred_at`, `type`; consumer handler + `correlation_id`, `id` (the event's `EventId`), `occurred_at`, `type`; the consumer restores `ITenantContext` from the envelope and starts an `Activity` with `traceparent` set to the row's `correlation_id` - Integration event handlers @@ -75,6 +77,18 @@ These propagate into: /api/v1/usage/report`) inject the current `traceparent`. Per [ADR-0032 § Sub-decision 11](../decisions/0032-exception-handling-logging-and-observability.md). +Accepted [ADR-0057](../decisions/0057-transactional-event-production-and-completion.md) +requires the producer execution boundary to supply a W3C Activity even without a +telemetry listener, including the Seeder. Enqueue persists the full traceparent; +transaction announcement supplies tenant/organization while trusted execution +supplies correlation, causation and optional causal actor. Domain translation +allocates a new integration EventId and retains the domain EventId as causation. +Future delivery-originated production uses the trusted incoming event identity, +with effective `UserId.SystemActor` and causal human retained separately. No +caller-supplied header establishes causation; JSON `metadata` stays SQL NULL. +P02b-1 implements this producer metadata boundary in both API and Seeder. +Production delivery context restoration remains P02b-3 work. + The propagation primitive is **W3C `traceparent`** end to end. Every cross-boundary write (outbox row, Hangfire payload, Hub envelope) carries it; the receiving side resumes the trace by setting `Activity.ParentId = diff --git a/docs/standards/11-security.md b/docs/standards/11-security.md index 09975612..70e76745 100644 --- a/docs/standards/11-security.md +++ b/docs/standards/11-security.md @@ -25,6 +25,9 @@ ingress/caller and anonymous visitor budgets derive from [ADR-0053](../decisions/0053-trusted-public-server-rendering.md); coordinated accounting, native admission and redirect/query rules derive from [ADR-0054](../decisions/0054-bounded-public-renderer-admission.md). +Producer announcement and common execution ownership derive from +[ADR-0057](../decisions/0057-transactional-event-production-and-completion.md). + Security is layered. No single control is sufficient. The standards here apply to every PR. ## Threat Model Summary @@ -259,7 +262,8 @@ from; this section remains the authority for session-variable **placement** only ### The rule `app.tenant_id` and `app.organization_id` are announced inside the ambient -transaction by TransactionBehavior at step 6, from the context admitted at step 4. +transaction by the step-6 `TransactionBehavior` adapter through +`TransactionExecutionCoordinator`, from the context admitted at step 4. Default ReadWrite transactions announce them as their first SQL statement. For a physical PublicSurface ReadOnly owner only, @@ -312,9 +316,20 @@ runtime — the hatch term reads an unset variable and is never true, so reads s the caller's organization plus the tenant-wide rows — which is the correct default. The placement rule above is unchanged and governs `app.scope` the moment a carrier exists. +P02b-1 Step 1 adds a read-only announcement snapshot through +`IUnitOfWork.GetTenantContextAnnouncement(DbTransaction?)`, under +[ADR-0057](../decisions/0057-transactional-event-production-and-completion.md). +Its generation/normalized values describe successful SQL on the current physical +transaction. It is cleared before attempts and terminal transitions. Foreign +transactions and failed/no-tenant announcements supply no usable snapshot; +unresolved ambient provisioning still uses its sanctioned announcement. Frames +compare the live snapshot with their enrolled generation. This adds no setter, +context writer, factory privilege or tenant retargeting authority. + ### The out-of-band setters -`TransactionBehavior` is the general case, not the only one. Eight setters exist in +`TransactionBehavior` delegates the general announcement path to +`TransactionExecutionCoordinator`. Eight logical setter paths exist in total and the set is closed ([ADR-0040 § Who sets `app.tenant_id`, completely](../decisions/0040-ambient-unit-of-work.md), with its Amendments 3 and 7, is the authority; it is reproduced here because this section @@ -324,7 +339,7 @@ yet: | Setter | Transaction | Why it is not `TransactionBehavior` | |---|---|---| -| `TransactionBehavior` | ambient | — the general case | +| `TransactionBehavior` → `TransactionExecutionCoordinator` | ambient | — the existing general announcement path; common execution adds no authority | | The integration-event transport, per delivery | ambient — it opens it | There is no MediatR request: `InProcessEventBus` invokes the handler directly, so no behavior runs. It opens the ambient transaction itself, from the delivery's `EventTenantContext` | | `IOrganizationScopeValidator` | its own short read-only one | The organization assertion is validated in the request edge, before the pipeline reaches step 6 ([ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md)) | | `IIdempotencyStore` (durable) | its own short one | A claim is taken **before** the pipeline reaches step 6 ([ADR-0037](../decisions/0037-idempotency-key-contract.md)) | @@ -375,8 +390,9 @@ runs on it. eight — both narrower than an earlier wording here, and both for reasons the shipped mechanism makes plain. Matching `[TenantOwned]` table names would put a parser between every query and the database to decide something the transaction already answers. -And of the eight setters only `TransactionBehavior`, through -`IUnitOfWork.SetTenantContextAsync`, marks anything today: two do not exist in code yet — +And of the eight paths, only `TransactionBehavior` through +`TransactionExecutionCoordinator` and the sanctioned `IUnitOfWork` announcement +methods marks the ambient transaction today: two do not exist in code yet — the durable idempotency store, and the integration-event transport, which matters most, because it is the other setter that *opens* the ambient transaction and must therefore announce it when Phase 02b lands it — and the five that do, `OrganizationScopeValidator`, @@ -669,7 +685,8 @@ Security-relevant durability rules: - **MUST-class audit fails closed.** Its rows — one per audited `(resource, operation)`, not one per request — are written on the same **transaction** as the business write: `AuditLogBehavior` classifies and parks the - intents, and `TransactionBehavior` calls `IAuditStore.WritePendingAsync` on the + intents through `AuditExecutionCoordinator`, and + `TransactionExecutionCoordinator` calls `IAuditStore.WritePendingAsync` on the **owning** unit-of-work frame, immediately before `COMMIT`, draining every intent in the scope ([ADR-0044 §§ 3–4](../decisions/0044-audit-write-path.md)). So a privileged operation cannot commit unaudited. It also means the insert runs while diff --git a/docs/standards/18-audit-coverage.md b/docs/standards/18-audit-coverage.md index 20c22923..21d5cfe1 100644 --- a/docs/standards/18-audit-coverage.md +++ b/docs/standards/18-audit-coverage.md @@ -8,6 +8,8 @@ subsystem's context), [ADR-0017 Tenant + Organization Hierarchy](../decisions/0017-tenant-organization-hierarchy.md), [11-security.md](11-security.md) § Audit Log, [01-architecture-standards.md](01-architecture-standards.md). +Producer additions derive from +[ADR-0057](../decisions/0057-transactional-event-production-and-completion.md). This standard defines **which operations must be audited**, **what an audit entry contains**, **how long entries are retained**, and how each module signs up for its @@ -35,8 +37,9 @@ document is the rule that prevents the audit story from drifting. The matrix is reviewed when the module is built and again when it is extended. - Coverage is configured in the **catalog** (`AuditConfig` per-tenant override of the module/operation MUST/SHOULD/MAY mapping) and **enforced by the MediatR - `AuditLogBehavior`**. Modules never call `IAuditStore` directly — the pipeline does - it for them based on the catalog. + `AuditLogBehavior`** for requests. Accepted ADR-0057 gives non-MediatR owners + the same classification/entry lifecycle through the reusable coordinator. + Modules never call `IAuditStore` directly. ## Operation Types @@ -366,15 +369,24 @@ Rules: classifies and declares **one MUST-class intent per audited `(resource, operation)`** in `IAuditStateCapture`'s ordered list — `ProvisionTenantCommand` declares two; `AuditChangeTrackerInterceptor` snapshots each `SaveChanges` into the same buffer and - writes nothing; `TransactionBehavior` (step 6) calls `IAuditStore.WritePendingAsync` + writes nothing; the transaction owner drains successful domain events and checks + producer invariants before calling `IAuditStore.WritePendingAsync` immediately before `COMMIT`, inserting every pending intent as a complete row on the ambient transaction, and then records whether the commit succeeded; `AuditLogBehavior` reconciles on the way out, re-writing standalone whichever rows the transaction did not - carry. Only the **owning** unit-of-work frame (`IUnitOfWorkScope.IsOwner`) writes, - reports the commit boundary and clears the buffer; a joiner does none of the three, + carry. Only the **owning** unit-of-work frame (`IUnitOfWorkScope.IsOwner`) writes + pending MUST rows, reports the commit boundary and cleans up domain-event + collector/source state; a joiner does none of the three, because a joiner that reported `Committed` would claim durability for a row nothing has - committed and a joiner that cleared would erase the outer request's intents. Modules see - none of this plumbing. + committed and a joiner that cleared domain events would erase the owner's work. + Only the outermost `AuditExecutionCoordinator` frame reconciles standalone rows + and calls `IAuditStateCapture.Clear()`. Accepted + ADR-0057 separates audit ownership from per-invocation producer receipts. + A controlled direct owner proves common entry in P02b-1; production delivery + opens it before handler construction in P02b-3 after G1, refuses an + unregistered mutating handler and declares direct one-root intents. Nested + commands keep their own intents without a duplicate business row. Modules see + none of this plumbing; the MUST floor and standalone refusal posture survive. - The interceptor captures **every** `ChangeTracker` entry in state `Added`, `Modified` or `Deleted`, minus a named exclusion list — `OutboxMessage` and `IdempotencyKey` (machinery) and `AuditEntry` / `AuditConfig` (the audit tables themselves). The @@ -566,8 +578,8 @@ observes neither a rollback nor a `42501`. One further catalogue rule holds a claim made above and belongs to neither list: [`AuditStateCapture_ClearedPerRequest`](21-architecture-tests-catalogue.md#auditstatecapture_clearedperrequest) -is behavioural — `Clear()` is called exactly once, by the outermost `AuditLogBehavior`, -and never by a joiner. +is behavioural — `Clear()` is called exactly once, by the outermost +`AuditExecutionCoordinator`, and never by a nested audit frame. These drive the runtime path as `learnstack_app`, except where the privilege model is itself the subject: `AuditLog_Update_Is_Column_Restricted` measures three layers that bind diff --git a/docs/standards/20-infrastructure-stack.md b/docs/standards/20-infrastructure-stack.md index 8c988757..25c76716 100644 --- a/docs/standards/20-infrastructure-stack.md +++ b/docs/standards/20-infrastructure-stack.md @@ -15,6 +15,8 @@ [ADR-0045 The Entitlement and Feature-Flag Socket](../decisions/0045-entitlement-and-feature-flag-socket.md). Public-read additions derive from [ADR-0052](../decisions/0052-anonymous-public-read-boundary.md). +Producer additions derive from +[ADR-0057](../decisions/0057-transactional-event-production-and-completion.md). Three audit decisions are referenced here but **not owned** here, and the split matters because they are easy to conflate: @@ -245,7 +247,7 @@ different decisions: | `{tenant_id}:hub:entitlement` (plan projection) | 60 s | 15 min (upper bound; Hub-push refresh resets it) | `learnstack.hub.entitlement` | | `{tenant_id}:tenancy:feature-flags` | 60 s | 15 min | none yet — see the note below | | `{tenant_id}:identity:permissions:{session_id}` | 60 s | session-scoped (no L2) | `learnstack.identity.role` / `.membership` events | -| `{tenant_id}:tenancy:settings` (reserved; unused in P02d-2/3) | No settings cache | No settings cache | Phase 02b owns the declared settings event; it is not implemented | +| `{tenant_id}:tenancy:settings` (reserved; unused in P02d-2/3) | No settings cache | No settings cache | No settings-invalidation event is declared; a future cached consumer needs a scope/key/freshness decision | | `{tenant_id}:audit:config` (per-tenant audit overrides) | 5 min | 1 h | none yet — see the note below | | `{tenant_id}:customization:content-types:v{generation}` | 60 s | 15 min on Phase 11's trigger | Fresh durable generation, no event | | `{tenant_id}:customization:taxonomies:v{generation}` | 60 s | 15 min on Phase 11's trigger | Fresh durable generation, no event | @@ -281,9 +283,14 @@ control. selects no settings cache. The adapter's existing `tenancy:settings` metric spelling is reserved, not an active reader or a TTL promise. P02d-3 owns the typed ambient accessor and explicit tenant/organization scope; it adds no settings generation -counter or out-of-band loader. Phase 02b owns the declared -`learnstack.tenancy.settings` event. Adding a cached settings consumer requires a -new scope/key/freshness decision before that consumer ships. +counter or out-of-band loader. The +[Tenancy catalogue](../modules/tenancy/README.md#integration-event-catalogue) +declares no settings-invalidation event under +[ADR-0057](../decisions/0057-transactional-event-production-and-completion.md#first-useful-production-flow). +Adding a cached settings consumer requires a new scope/key/freshness decision +before that consumer ships. Phase 11 owns the distributed adapters on +[their existing triggers](../roadmap/phase-11-production-hardening.md#demand-gated-building-blocks); +those triggers do not promise a settings event. **`{tenant_id}:audit:config` has no eager invalidation, and that is a stated gap rather than an omission.** The projection is read by `IAuditConfigService` on the classification @@ -404,9 +411,25 @@ Rules: ## Outbox and Inbox -- Every integration event is written to `outbox_messages` **in the same `DbContext` - transaction** as the aggregate that produced it. `IOutbox.EnqueueAsync` enrolls in - the ambient `DbContext`; do **not** open a new transaction. +- Accepted ADR-0057 requires immediate `IOutbox.EnqueueAsync` INSERT through + the enrolled, announced, writable `IUnitOfWork.Connection`/`Transaction`. + It opens/commits nothing and never calls `IEventBus`. State, outbox and MUST + audit share that transaction, regardless of module `SaveChanges` calls. +- Producer frames compare live announcement generation and tenant, including + unresolved provisioning. `id = EventId`; duplicate enqueue fails. Type identity + stays assembly-qualified; runtime JSON uses frozen `PayloadJsonOptions`. + Additional fields are identifiers only, complete canonical UTF-8 payloads fit + the inclusive 262,144-byte cap, and JSON `metadata` is SQL NULL. This adds no + DDL CHECK. P02b-1 Steps 2/3 implement immediate enqueue, successful-save + collection and the completed-provisioning publisher. Step 4 implements the + production censuses and catalogue enforcement; the + [delivery record](../roadmap/phase-02b-events-auth.md#p02b-1-step-4--producer-enforcement-and-closeout-2026-10-12) + owns final verification and review status. +- Only the owner drains successful-save domain events before invariants/MUST + audit/commit. Domain handlers only translate to outbox; a total 256-event bound + permits no recursive mutation. Produced-work receipts propagate to ancestors; + a returning `Result.Fail` poisons the shared unit before frame resolution. + Joiners neither drain nor clear the owner; terminal paths discard state. - The shared outbox table is RLS-protected; `OutboxProcessor` connects with the `learnstack_outbox_admin` role that bypasses RLS. - `OutboxProcessor` uses `FOR UPDATE SKIP LOCKED` to allow horizontal scaling. Each diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 2c03a25b..a0dcfb7f 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -10,7 +10,9 @@ [ADR-0055 Public Renderer Bootstrap Failures](../decisions/0055-public-renderer-bootstrap-failures.md) (replacement admission proofs implemented; both review rounds complete), [ADR-0056 Public Demo Browser and Lighthouse Enforcement](../decisions/0056-public-demo-browser-and-lighthouse-enforcement.md) -(accepted 2026-10-11; browser/audit replacement proofs implemented). The +(accepted 2026-10-11; browser/audit replacement proofs implemented), +[ADR-0057 Transactional Event Production and Completion](../decisions/0057-transactional-event-production-and-completion.md) +(producer lifecycle, payload and production-census proofs implemented). The catalogue grows as subsequent ADRs and phases land their tests; per-test ownership stays with the originating ADR / standard. @@ -103,11 +105,11 @@ not implemented is the failure mode this column exists to prevent. ### Implemented today -**159 test methods run in +**173 test methods run in [`backend/tests/LearnStack.Tests.Architecture`](../../backend/tests/LearnStack.Tests.Architecture),** shipped by [Phase 01](../roadmap/phase-01-repository-tooling.md), [Phase 02a Packets 2–3](../roadmap/phase-02a-kernel-tenancy.md), Packet 4, Packet 6, Packet 7, -Packet 8, Packet 9, Packet 10, P02d-1, P02d-2, P02d-3 and P02d-4 Steps 1–3. +Packet 8, Packet 9, Packet 10, P02d-1, P02d-2, P02d-3, P02d-4 Steps 1–3 and P02b-1. Methods are not rows: a `[Theory]` is one row and many cases, and most rows pair a rule with the companion assertion that stops it passing vacuously. @@ -134,8 +136,8 @@ two fifths of its subject is the defect this section is about. It also refuses a test class that exists nowhere, because otherwise a renamed or deleted file drops its entries out of the subject instead of failing. -**175 rules in this catalogue are Implemented, and 108 of them are in that assembly.** -The other 67 are no less binding, and most could not live there. The table says where +**183 rules in this catalogue are Implemented, and 113 of them are in that assembly.** +The other 70 are no less binding, and most could not live there. The table says where and why, and deliberately carries no per-row count: those are the numbers nothing recomputes, and the first version of this table claimed "three rules" for a suite that holds ten. @@ -1693,8 +1695,9 @@ catalogue as the carrier of their status — so all three are Packet 10's. #### `TransactionBehavior_Does_Not_Reference_A_Module_Assembly` -- **Asserts:** `TransactionBehavior`'s constructor names `IUnitOfWork` and no - `DbContext`, and `LearnStack.Application` references no module assembly — checked +- **Asserts:** `TransactionBehavior` delegates to `TransactionExecutionCoordinator`, + whose constructor names `IUnitOfWork` and no `DbContext`. + `LearnStack.Application` references no module assembly — checked against the **project file** as well as the emitted assembly-reference table, because the compiler elides a reference whose types the IL never touches, so a dangling `` would leave a reflection-only check green. The @@ -1742,12 +1745,19 @@ catalogue as the carrier of their status — so all three are Packet 10's. #### `Modules_Do_Not_Parallelize_Over_The_Ambient_Connection` -- **Asserts:** no module code fans out — no `Task.WhenAll`, `WhenAny` or `WhenEach`, with or +- **Asserts:** no module or shared Application/Infrastructure execution code fans + out — no `Task.WhenAll`, `WhenAny` or `WhenEach`, with or without an explicit type argument; no `Parallel.For`, `ForAsync`, `ForEach`, `ForEachAsync` or `Invoke`; no PLINQ `AsParallel()`; and no task started and stored instead of awaited, which - is `Task.WhenAll` written out longhand — outside an exact-path list, empty today, whose entries - each say why their concurrent work touches no connection. One connection means one command at a - time; a handler that fans out corrupts the protocol. Stricter than ADR-0040's sentence, + is `Task.WhenAll` written out longhand — outside an exact-path list whose entries + each say why their concurrent work touches no ambient connection. The entries are + `LearnStack.Infrastructure/Caching/InMemoryCacheService.cs`: singleton cache flights + race their own deadline, without a scoped unit or module context; and + `LearnStack.Infrastructure/MultiTenancy/CachedHostToTenantResolver.cs`: singleton + host lookup flights use their own read-only connection before tenant admission. + The census includes the separate `LearnStack.Infrastructure.Audit` project. + One connection means one command at a time; a handler that fans out corrupts the + protocol. Stricter than ADR-0040's sentence, which bans two `DbContext`-bound operations: which operations are bound to the ambient connection cannot be read from source, and the honest rule is the one that can. - **Source:** ADR-0040 § Nesting. @@ -1756,6 +1766,10 @@ catalogue as the carrier of their status — so all three are Packet 10's. for module code to scan: three modules have shipped handlers since Packets 7 and 8, and the rule asserts it read them. Its companion, `The_Parallelism_Scan_Can_Actually_Fail`, feeds it each fan-out shape — a `using static` import included — and two that are not. + P02b-1 extends the census to shared execution state; + `The_parallelism_census_reaches_shared_code_and_bounds_its_singleton_exceptions` + plants violations in every covered root and proves both exemptions are + path-specific. - **Phase:** 02a (Packet 6 registers it; Packet 10 implements it). ### Tenancy and isolation @@ -2531,8 +2545,8 @@ which decides identity, multiplicity, capture and classification; - **Asserts:** after a request completes, success or failure, the scoped `IAuditStateCapture` holds no changes and no intent, and its state reads `None` for the - next request. `Clear()` is called exactly once, by the **outermost** `AuditLogBehavior`, - in its `finally`. + next request. `Clear()` is called exactly once, by the **outermost** + `AuditExecutionCoordinator`, in its `finally`; `AuditLogBehavior` delegates to it. - **A joiner never clears.** Under [ADR-0040](../decisions/0040-ambient-unit-of-work.md) a nested dispatch reaches a joiner frame, and a joiner that cleared would erase the outer request's intents and every snapshot **before** the owner committed — the same @@ -3112,13 +3126,17 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). #### `Outbox_Row_Carries_Correlation_Context` - **Asserts:** every persisted `outbox_messages` row has non-null - `tenant_id` and `correlation_id` columns. Integration test that writes - through `IOutbox.EnqueueAsync` and inspects the row. -- **Source:** ADR-0032 § Sub-decision 12; + `tenant_id`, full W3C `correlation_id` and `partition_key` columns. Integration + proof writes through `IOutbox.EnqueueAsync` and compares persisted `id` to + `EventId`, announced tenant/organization and the event's declared key, including + unresolved provisioning. A null/wrong-column control must fail. +- **Source:** ADR-0032 § Sub-decision 12, as bounded by + [ADR-0057](../decisions/0057-transactional-event-production-and-completion.md); [ADR-0006](../decisions/0006-events-and-outbox.md) Amendment 1. - **Type:** integration test (Testcontainers). **Kind:** behavioural. -- **Status:** **Registered.** -- **Phase:** 02b. +- **Status:** **Implemented** (`OutboxProducerTests`, `TenantCreatedProducerTests`); + actual stored values and unresolved provisioning run as `learnstack_app`. +- **Phase:** 02b, P02b-1. #### `Hangfire_Job_Payloads_Include_TenantId` @@ -3137,10 +3155,11 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). business code runs. Verifies the envelope-to-context restoration. - **Source:** ADR-0032 § Sub-decision 12; Phase 02b deliverable. - **Type:** integration test (Testcontainers). Runs against - `InProcessEventBus` — which is a first-class transport with the same handler - interface, inbox guard and context restoration as the durable path + `InProcessEventBus` — which invokes the shared handler interface and restores + tenant context ([ADR-0035](../decisions/0035-demand-gated-infrastructure.md)), so the assertion does - not wait on the Dapr adapter. **Kind:** behavioural. + not wait on the Dapr adapter. Consumer delivery/inbox deduplication remains + P02b-3 work. **Kind:** behavioural. - **Status:** **Registered.** - **Phase:** 02b. @@ -3164,23 +3183,116 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). - **Source:** [15-event-and-outbox.md § Architecture tests](../architecture/15-event-and-outbox.md); [ADR-0006](../decisions/0006-events-and-outbox.md). - **Type:** xUnit + reflection over module assemblies. **Kind:** structural. -- **Status:** **Registered.** +- **Status:** **Implemented** (`IntegrationEventContractTests`); all production projects + and module layers, required base members and dirty/clean inheritance controls. - **Phase:** 02b. #### `Integration_Event_Declares_PartitionKey` -- **Asserts:** every `IIntegrationEvent` resolves a non-null partition key. `PartitionKey` - is abstract on `IntegrationEventBase`, so the compiler already refuses an event that - omits it; the residual assertion is that the value is non-null and non-blank at - runtime. `IntegrationEventEnvelope` reads it off the event — it is deliberately **not** - threaded through `IEventBus` as a second parameter, which is the source of drift - [ADR-0038](../decisions/0038-cross-cutting-port-and-event-contracts.md) removes. `InProcessEventBus` - serialises dispatch per key: concurrent across keys, sequential within one. -- **Source:** [Phase 02b](../roadmap/phase-02b-events-auth.md); - [15-event-and-outbox.md](../architecture/15-event-and-outbox.md). -- **Type:** xUnit + reflection over module assemblies. **Kind:** structural. -- **Status:** **Registered.** -- **Phase:** 02b. +- **Asserts:** every concrete integration event declares its partition source: + the aggregate identifier, or explicit tenant-wide `TenantId`. The key is that + identifier's canonical GUID representation and is non-null/non-blank. A complete + production-module census checks declaration/derivation, with planted constant, + unrelated-ID and missing-declaration offenders plus clean controls. Behavioral + cases compare actual source/key values. The envelope reads the event's key; + it cannot hold a second answer. This establishes derivation, not durable order; + G3's publish-order half remains P02b-2. +- **Source:** [ADR-0057](../decisions/0057-transactional-event-production-and-completion.md); + [15-event-and-outbox.md](../architecture/15-event-and-outbox.md#ordering). +- **Type:** xUnit + reflection/IL/source scan and value probes. + **Kind:** structural + behavioural. +- **Status:** **Implemented** (`IntegrationEventContractTests`, `OutboxProducerTests`); + stored-source IL grammar, independently varied identifiers, dirty/clean controls and + persisted keys. Unsupported derivations fail loudly. +- **Phase:** 02b, P02b-1 (derivation); P02b-2 (ordering). + +#### `Outbox_Enqueue_Uses_The_Announced_Transaction` + +- **Asserts:** enqueue uses the enrolled owner's live announced connection and + transaction as `learnstack_app`, for resolved and unresolved provisioning. + Mismatched/empty/sentinel tenants, read-only units and stale generations refuse; + no extra connection, commit or publish occurs. Snapshot cases cover failed or + canceled setup, foreign/reused transaction objects, joins and every terminal + boundary. No-listener trace context, exact causal/envelope metadata and runtime + JSON survive persistence. Complete canonical UTF-8 payloads at the inclusive + 262,144-byte bound succeed; oversized/multibyte cases refuse before INSERT. + JSON metadata is SQL NULL. Duplicate INSERT fails; rollback and MUST-audit + failure leave source/outbox uncommitted. +- **Source:** [ADR-0057](../decisions/0057-transactional-event-production-and-completion.md). +- **Type:** integration test (Testcontainers), with focused lifecycle unit cases. + **Kind:** behavioural. +- **Status:** **Implemented** (`OutboxProducerConventionTests`, `OutboxProducerTests`, + `UnitOfWorkTests`, `ProducerExecutionTests`, `OutboxContractTests`); transitive + no-connection/commit/publication controls complement real transaction, byte-bound and + metadata cases. +- **Phase:** 02b, P02b-1. + +#### `Producer_Failures_Poison_Produced_Work` + +- **Asserts:** successful outbox INSERT or successful-save event promotion leaves + sticky receipts in its invocation and every ancestor. Direct and descendant + receipts survive drain; absorbed `Result.Fail` cannot commit and poisons before + UoW-frame resolution. An ordinary event-free absorbed refusal still commits. + Failed/canceled saves do not promote; uncollected raises and unsuccessful + enqueue attempts do not themselves count. Terminal/same-scope reuse cases prove + source lists never replay into another transaction. +- **Source:** [ADR-0057](../decisions/0057-transactional-event-production-and-completion.md). +- **Type:** integration and focused lifecycle unit tests. **Kind:** behavioural. +- **Status:** **Implemented** (`ProducerExecutionTests`, + `OutboxProducerTests.Actual_INSERT_receipts_prevent_an_absorbed_direct_or_descendant_refusal_from_committing`, + `DomainEventProductionTests`); actual INSERT/save receipts, every ancestor, event-free + controls and terminal reuse. +- **Phase:** 02b, P02b-1. + +#### `Domain_Event_Completion_Is_Owner_Only` + +- **Asserts:** a single owner dispatches successful-save events FIFO before + invariants/MUST audit/commit; nested joiners neither dispatch nor clear owner + state. Repeated saves, detach/delete, rollback/cancellation, save failures and + terminal cleanup preserve attribution without loss/duplication. At most 256 + events belong to the transaction; a 257th rolls back all source/outbox work. + Returned versions remain accurate; MUST-audit failure preserves atomicity. + A controlled non-MediatR owner opens the common audit frame, classifies direct + work and reconciles once, retaining nested command intents without duplication. +- **Source:** [ADR-0057](../decisions/0057-transactional-event-production-and-completion.md). +- **Type:** integration and lifecycle unit tests. **Kind:** behavioural. +- **Status:** **Implemented** (`DomainEventProductionTests.The_owner_drains_FIFO_across_sources_saves_delete_and_detach_without_duplicates`, + `TenantCreatedProducerTests`, `ProducerExecutionTests`); real save qualification, + FIFO/bounds, owner/common-audit atomicity and cleanup. +- **Phase:** 02b, P02b-1; production delivery remains P02b-3 after G1. + +#### `Domain_Event_Handlers_Are_Outbox_Only` + +- **Asserts:** the complete production-module domain-handler census translates + facts to outbox only. Traverse helpers, interface targets and derived class + implementations; root mutation, + write stores, nested requests, further raises, notification publication and + external providers are forbidden. Every forbidden path has a planted offender + and a clean translation control, so empty subjects or shallow traversal fail. +- **Source:** [ADR-0057](../decisions/0057-transactional-event-production-and-completion.md). +- **Type:** xUnit + reflection/IL/source scan. **Kind:** structural. +- **Status:** **Implemented** (`DomainEventHandlerTests`); assembly-scoped transitive + helper/interface/derived-class traversal, all production layers and dirty/clean + capability controls. + Static analysis does not claim to resolve arbitrary reflection or dynamically loaded + code. +- **Phase:** 02b, P02b-1. + +#### `Integration_Event_Payloads_Contain_Only_Identifiers` + +- **Asserts:** beyond inherited identity/tenant/time and computed routing members, + production payload fields are GUIDs or approved strongly-typed identifiers, + optionally nullable. Reject text, collections, DTOs, binary/raw JSON and custom + serializer overrides with planted controls. A non-empty production census and + concrete-runtime JSON round-trip prove fields survive the frozen serializer. + Existing partition and announced-transaction rules own derivation, byte/metadata + admission and rollback/atomicity; this rule does not duplicate those names. +- **Source:** [ADR-0057](../decisions/0057-transactional-event-production-and-completion.md). +- **Type:** xUnit + reflection/IL/source scan and serialization probes. + **Kind:** structural + behavioural. +- **Status:** **Implemented** (`IntegrationEventContractTests`); independent reflection + checks, forbidden serialization/member controls and concrete runtime JSON probes. +- **Phase:** 02b, P02b-1. #### `OutboxProcessor_NeverBlocks_OnSingleMessageFailure` @@ -3259,9 +3371,10 @@ registered, which is the shape of gap this catalogue exists to close. It is ther - **Source:** [20-infrastructure-stack.md § `IEventBus`](20-infrastructure-stack.md); [ADR-0006](../decisions/0006-events-and-outbox.md). - **Type:** xUnit + reflection over module assemblies. **Kind:** structural. -- **Status:** **Implemented** (`CrossCuttingFoundationTests`). No module declares an - event yet, so the module sweep is vacuous today; the convention checker is pointed at - deliberate offenders first, so the rule can be shown to fire. +- **Status:** **Implemented** (`CrossCuttingFoundationTests`). The production + census is non-empty and includes Tenancy's completed-provisioning event. + Deliberate invalid topics, including trailing newlines, and clean controls + exercise the same convention checker. - **Phase:** 02a (Packet 5) — lands with `InProcessEventBus`, the first transport. Writing it required a contract change. The rule reads the event **declarations**, and diff --git a/frontend/apps/web/scripts/admission-browser.d.mts b/frontend/apps/web/scripts/admission-browser.d.mts index 3ad70eb2..287bb555 100644 --- a/frontend/apps/web/scripts/admission-browser.d.mts +++ b/frontend/apps/web/scripts/admission-browser.d.mts @@ -24,3 +24,28 @@ export function verifyBrowserFallback(options: { readonly secrets: readonly string[]; }): Promise>; import type { createFixtureOwner, createPrivateScanner } from './fixture-support.mjs'; +export type BrowserPhase = + | 'browser-launch' + | 'browser-devtools' + | 'browser-discovery' + | 'browser-protocol' + | 'browser-navigation' + | 'browser-hydration' + | 'browser-arm' + | 'browser-click' + | 'browser-fallback' + | 'browser-wire' + | 'browser-completion' + | 'browser-private-carrier' + | 'browser-shutdown'; +export function browserFailureDetail(error: unknown): string; +export function atBrowserPhase(phase: BrowserPhase, operation: () => T | Promise): Promise; +export function waitForBrowserDevTools(options: { + readonly assertActive: () => void; + readonly isLive: () => boolean; + readonly readPort: () => number | null; +}): Promise; +export function withBrowserCleanup( + operation: () => T | Promise, + cleanup: () => void | Promise, +): Promise; diff --git a/frontend/apps/web/scripts/admission-browser.mjs b/frontend/apps/web/scripts/admission-browser.mjs index ccf79e46..33ef54c2 100644 --- a/frontend/apps/web/scripts/admission-browser.mjs +++ b/frontend/apps/web/scripts/admission-browser.mjs @@ -9,6 +9,62 @@ import { join } from 'node:path'; import { createPrivateScanner, fixtureEnvironment } from './fixture-support.mjs'; import { stopTestChild } from './stop-test-child.mjs'; +const browserPhases = new Set([ + 'browser-launch', + 'browser-devtools', + 'browser-discovery', + 'browser-protocol', + 'browser-navigation', + 'browser-hydration', + 'browser-arm', + 'browser-click', + 'browser-fallback', + 'browser-wire', + 'browser-completion', + 'browser-private-carrier', + 'browser-shutdown', +]); +const sanitizedFailures = new WeakSet(); + +/** Only closed phase names may cross the private fixture's diagnostic boundary. */ +export function browserFailureDetail(error) { + return browserPhases.has(error?.admissionStage) ? ' ' + error.admissionStage : ''; +} + +/** Never propagate raw browser errors, URLs, output or private fixture values. */ +export async function atBrowserPhase(phase, operation) { + assert.ok(browserPhases.has(phase), 'Browser diagnostic phase is registered'); + try { + return await operation(); + } catch (error) { + if (sanitizedFailures.has(error)) throw error; + const failure = new Error('Public admission browser proof failed'); + failure.admissionStage = phase; + sanitizedFailures.add(failure); + throw failure; + } +} + +/** Cleanup always runs; its failure cannot replace an earlier proof failure. */ +export async function withBrowserCleanup(operation, cleanup) { + let failed = false; + let failure; + let result; + try { + result = await operation(); + } catch (error) { + failed = true; + failure = error; + } + try { + await atBrowserPhase('browser-shutdown', cleanup); + } catch (error) { + if (!failed) throw error; + } + if (failed) throw failure; + return result; +} + /** A failed Flight must precede a fresh successful document for the same target. */ export function assertFlightFallback(events, origin, target, status) { const matching = events.filter((event) => { @@ -53,20 +109,48 @@ async function until(predicate, message, timeout = 20_000) { throw new Error(message); } +/** Keep browser death distinct from a live browser's debugging readiness deadline. */ +export function waitForBrowserDevTools({ assertActive, isLive, readPort }) { + return atBrowserPhase('browser-devtools', () => + until(async () => { + assertActive(); + await atBrowserPhase('browser-launch', () => { + assert.equal(isLive(), true, 'The owned browser remains live'); + }); + return readPort(); + }, 'Owned browser debugging deadline'), + ); +} + /** Isolated profile and certificate-specific trust; never touches a user's browser. */ -export async function verifyBrowserFallback({ - owner, - app, - root, - certificate, - origin, - initialPath, - target, - status, - beforeNavigation, - afterNavigation, - secrets, -}) { +export async function verifyBrowserFallback(options) { + const phase = { value: 'browser-launch' }; + try { + return await runBrowserFallback(options, phase); + } catch (error) { + if (sanitizedFailures.has(error)) throw error; + return atBrowserPhase(phase.value, () => { + throw error; + }); + } +} + +async function runBrowserFallback( + { + owner, + app, + root, + certificate, + origin, + initialPath, + target, + status, + beforeNavigation, + afterNavigation, + secrets, + }, + phase, +) { const originUrl = new URL(origin); assert.equal(originUrl.protocol, 'https:'); assert.equal(originUrl.origin, origin); @@ -114,17 +198,17 @@ export async function verifyBrowserFallback({ failed = true; }); const portFile = join(profile, 'DevToolsActivePort'); - const port = await until(() => { - owner.assertActive(); - assert.equal( - failed || child.exitCode !== null || child.signalCode !== null, - false, - 'The owned browser remains live', - ); - if (!existsSync(portFile)) return null; - const value = Number(readFileSync(portFile, 'utf8').split('\n')[0]); - return Number.isInteger(value) && value > 0 && value < 65536 ? value : null; - }, 'Owned browser debugging deadline'); + phase.value = 'browser-devtools'; + const port = await waitForBrowserDevTools({ + assertActive: () => owner.assertActive(), + isLive: () => !failed && child.exitCode === null && child.signalCode === null, + readPort: () => { + if (!existsSync(portFile)) return null; + const value = Number(readFileSync(portFile, 'utf8').split('\n')[0]); + return Number.isInteger(value) && value > 0 && value < 65536 ? value : null; + }, + }); + phase.value = 'browser-discovery'; // eslint-disable-next-line no-restricted-globals -- Owned local browser CDP discovery, never a LearnStack API request. const response = await fetch('http://127.0.0.1:' + port + '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/json/list', { signal: AbortSignal.timeout(5000), @@ -200,57 +284,79 @@ export async function verifyBrowserFallback({ return false; } }; - try { - await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error('Browser protocol startup deadline')), 5000); - socket.once('open', () => { - clearTimeout(timer); - resolve(); - }); - socket.once('error', () => { - clearTimeout(timer); - reject(new Error('Browser protocol startup')); + return withBrowserCleanup( + async () => { + phase.value = 'browser-protocol'; + await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error('Browser protocol startup deadline')), + 5000, + ); + socket.once('open', () => { + clearTimeout(timer); + resolve(); + }); + socket.once('error', () => { + clearTimeout(timer); + reject(new Error('Browser protocol startup')); + }); }); - }); - await command('Network.enable'); - await command('Page.enable'); - await command('Runtime.enable'); - await command('Page.navigate', { url: origin + initialPath }); - await until( - () => evaluateBoolean('Boolean(document.querySelector(\'[data-admission-ready="true"]\'))'), - 'Navigation probe hydration deadline', - ); - await beforeNavigation(); - events.length = 0; - requests.clear(); - await command('Runtime.evaluate', { - expression: "document.querySelector('[data-admission-navigation]').click()", - }); - await until(async () => { - const complete = await evaluateBoolean( - 'location.pathname === ' + - JSON.stringify(target) + - ' && document.readyState === "complete" && Boolean(document.querySelector("h1"))', + await command('Network.enable'); + await command('Page.enable'); + await command('Runtime.enable'); + phase.value = 'browser-navigation'; + await command('Page.navigate', { url: origin + initialPath }); + phase.value = 'browser-hydration'; + await until( + () => evaluateBoolean('Boolean(document.querySelector(\'[data-admission-ready="true"]\'))'), + 'Navigation probe hydration deadline', ); - return ( - complete && - events.some( - (event) => - event.type === 'Document' && - new URL(event.url).pathname === target && - event.status === 200, - ) - ); - }, 'Stock Next document fallback deadline'); - assertFlightFallback(events, origin, target, status); - await afterNavigation(); - assert.equal(scanner.leaked, false, 'Browser diagnostics contain no private carrier'); - return scanner; - } finally { - rejectPending(); - socket.terminate(); - await stopTestChild(child); - scanner.finish(); - assert.equal(scanner.leaked, false, 'Browser shutdown diagnostics contain no private carrier'); - } + phase.value = 'browser-arm'; + await beforeNavigation(); + events.length = 0; + requests.clear(); + phase.value = 'browser-click'; + await command('Runtime.evaluate', { + expression: "document.querySelector('[data-admission-navigation]').click()", + }); + phase.value = 'browser-fallback'; + await until(async () => { + const complete = await evaluateBoolean( + 'location.pathname === ' + + JSON.stringify(target) + + ' && document.readyState === "complete" && Boolean(document.querySelector("h1"))', + ); + return ( + complete && + events.some( + (event) => + event.type === 'Document' && + new URL(event.url).pathname === target && + event.status === 200, + ) + ); + }, 'Stock Next document fallback deadline'); + phase.value = 'browser-wire'; + assertFlightFallback(events, origin, target, status); + phase.value = 'browser-completion'; + await afterNavigation(); + await atBrowserPhase('browser-private-carrier', () => { + assert.equal(scanner.leaked, false, 'Browser diagnostics contain no private carrier'); + }); + return scanner; + }, + async () => { + rejectPending(); + socket.terminate(); + await stopTestChild(child); + scanner.finish(); + await atBrowserPhase('browser-private-carrier', () => { + assert.equal( + scanner.leaked, + false, + 'Browser shutdown diagnostics contain no private carrier', + ); + }); + }, + ); } diff --git a/frontend/apps/web/scripts/verify-public-admission.mjs b/frontend/apps/web/scripts/verify-public-admission.mjs index 9fba75c6..ff2ada53 100644 --- a/frontend/apps/web/scripts/verify-public-admission.mjs +++ b/frontend/apps/web/scripts/verify-public-admission.mjs @@ -19,7 +19,7 @@ import { fileURLToPath } from 'node:url'; import { JSDOM } from 'jsdom'; -import { verifyBrowserFallback } from './admission-browser.mjs'; +import { browserFailureDetail, verifyBrowserFallback } from './admission-browser.mjs'; import { watchDevelopmentSource } from './admission-hmr-watch.mjs'; import { verifyHmrAdmission } from './admission-hmr.mjs'; import { @@ -845,7 +845,9 @@ try { 'hmr-refresh-release', 'hmr-after', ]); - const detail = hmrStages.has(error?.admissionStage) ? ' ' + error.admissionStage : ''; + const detail = hmrStages.has(error?.admissionStage) + ? ' ' + error.admissionStage + : browserFailureDetail(error); process.stderr.write('Public admission fixture failed during ' + stage + detail + '.\n'); process.exitCode = 1; } finally { diff --git a/frontend/apps/web/src/test/admission-browser.test.ts b/frontend/apps/web/src/test/admission-browser.test.ts index aaf1a49a..f7934145 100644 --- a/frontend/apps/web/src/test/admission-browser.test.ts +++ b/frontend/apps/web/src/test/admission-browser.test.ts @@ -1,7 +1,15 @@ // @vitest-environment node -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; -import { assertFlightFallback } from '../../scripts/admission-browser.mjs'; +import { + assertFlightFallback, + atBrowserPhase, + browserFailureDetail, + verifyBrowserFallback, + waitForBrowserDevTools, + withBrowserCleanup, +} from '../../scripts/admission-browser.mjs'; +import { createFixtureOwner } from '../../scripts/fixture-support.mjs'; const origin = 'https://fixture.localhost:3000'; const target = '/en/courses/fixture-course'; @@ -13,7 +21,181 @@ const flight = { }; const document = { url: origin + target, type: 'Document', rsc: false, status: 200 }; +afterEach(() => vi.useRealTimers()); + describe('stock browser Flight fallback observation', () => { + it('exposes only a closed phase when a private browser operation fails', async () => { + const privateValue = 'private URL, envelope and browser output'; + const failure = await atBrowserPhase('browser-hydration', () => { + throw new Error(privateValue); + }).catch((error: Error) => error); + expect(browserFailureDetail(failure)).toBe(' browser-hydration'); + expect(String(failure)).not.toContain(privateValue); + expect(failure).not.toHaveProperty('cause'); + expect(browserFailureDetail({ admissionStage: privateValue })).toBe(''); + expect(browserFailureDetail(new Error(privateValue))).toBe(''); + expect(browserFailureDetail(null)).toBe(''); + }); + + it('preserves successful browser operations and rejects unregistered phases', async () => { + await expect(atBrowserPhase('browser-wire', () => 42)).resolves.toBe(42); + // @ts-expect-error Exercise the runtime guard for an untyped caller. + await expect(atBrowserPhase('unregistered', () => 42)).rejects.toThrow( + 'Browser diagnostic phase is registered', + ); + }); + + it('reports browser death before reading a debugging port as a launch failure', async () => { + const readPort = vi.fn(() => 12345); + const failure = await waitForBrowserDevTools({ + assertActive: () => undefined, + isLive: () => false, + readPort, + }).catch((error: Error) => error); + expect(browserFailureDetail(failure)).toBe(' browser-launch'); + expect(readPort).not.toHaveBeenCalled(); + }); + + it('reports a live browser without a debugging port at the unchanged readiness deadline', async () => { + vi.useFakeTimers(); + const waiting = waitForBrowserDevTools({ + assertActive: () => undefined, + isLive: () => true, + readPort: () => null, + }).catch((error: Error) => error); + await vi.advanceTimersByTimeAsync(20_000); + expect(browserFailureDetail(await waiting)).toBe(' browser-devtools'); + }); + + it('accepts the debugging port of a live owned browser', async () => { + const assertActive = vi.fn(); + await expect( + waitForBrowserDevTools({ assertActive, isLive: () => true, readPort: () => 12345 }), + ).resolves.toBe(12345); + expect(assertActive).toHaveBeenCalledOnce(); + }); + + it('preserves a sanitized inner phase without trusting a raw error with the same token', async () => { + const failure = await atBrowserPhase('browser-private-carrier', () => { + throw new Error('Private browser output'); + }).catch((error: Error) => error); + await expect( + atBrowserPhase('browser-shutdown', () => { + throw failure; + }), + ).rejects.toBe(failure); + const forged = Object.assign(new Error('Private browser output'), { + admissionStage: 'browser-private-carrier', + }); + const sanitized = await atBrowserPhase('browser-shutdown', () => { + throw forged; + }).catch((error: Error) => error); + expect(browserFailureDetail(sanitized)).toBe(' browser-shutdown'); + expect(String(sanitized)).not.toContain('Private browser output'); + }); + + it('keeps a private-carrier failure distinct when cleanup is the sole failure', async () => { + const failure = await withBrowserCleanup( + () => 42, + () => + atBrowserPhase('browser-private-carrier', () => { + throw new Error('Private browser shutdown output'); + }), + ).catch((error: Error) => error); + expect(browserFailureDetail(failure)).toBe(' browser-private-carrier'); + expect(String(failure)).not.toContain('Private browser shutdown output'); + expect(failure).not.toHaveProperty('cause'); + }); + + it.each([false, true])( + 'trusts only branded failures at the outer boundary (branded=%s)', + async (branded) => { + const raw = Object.assign(new Error('Private browser setup value'), { + admissionStage: 'browser-wire', + }); + const inner = branded + ? await atBrowserPhase('browser-wire', () => { + throw raw; + }).catch((error: Error) => error) + : raw; + const owner = createFixtureOwner(); + try { + const result = await verifyBrowserFallback({ + owner, + app: '', + root: '', + certificate: '', + get origin(): string { + throw inner; + }, + initialPath: '', + target, + status: 503, + beforeNavigation: async () => undefined, + afterNavigation: async () => undefined, + secrets: [], + }).catch((error: Error) => error); + expect(browserFailureDetail(result)).toBe(branded ? ' browser-wire' : ' browser-launch'); + expect(String(result)).not.toContain('Private browser setup value'); + expect(result).not.toHaveProperty('cause'); + if (branded) expect(result).toBe(inner); + else expect(result).not.toBe(inner); + } finally { + await owner.dispose(); + } + }, + ); + + it('keeps the first proof failure when cleanup also detects a private carrier', async () => { + const first = await atBrowserPhase('browser-wire', () => { + throw new Error('Private wire failure'); + }).catch((error: Error) => error); + await expect( + withBrowserCleanup( + () => { + throw first; + }, + () => + atBrowserPhase('browser-private-carrier', () => { + throw new Error('Private browser shutdown output'); + }), + ), + ).rejects.toBe(first); + }); + + it('runs cleanup and preserves the first proof failure if both operations fail', async () => { + const first = new Error('Private primary failure'); + let cleaned = false; + const result = withBrowserCleanup( + () => { + throw first; + }, + () => { + cleaned = true; + throw new Error('Private cleanup failure'); + }, + ); + await expect(result).rejects.toBe(first); + expect(cleaned).toBe(true); + }); + + it('reports only the shutdown phase when cleanup is the sole failure', async () => { + const failure = await withBrowserCleanup( + () => 42, + () => { + throw new Error('Private cleanup failure'); + }, + ).catch((error: Error) => error); + expect(browserFailureDetail(failure)).toBe(' browser-shutdown'); + expect(String(failure)).not.toContain('Private cleanup failure'); + await expect( + withBrowserCleanup( + () => 42, + () => undefined, + ), + ).resolves.toBe(42); + }); + it.each([429, 503])('accepts one refused Flight then recovered document: %s', (status) => { expect(() => assertFlightFallback(