|
1 | 1 | # Phase 02a: Platform Kernel, Multi-Tenancy, Organization, and Foundation Sockets |
2 | 2 |
|
| 3 | +> **Status (2026-05-20).** Phase 02a in progress. Packet 0 (kickoff) ships the |
| 4 | +> breakdown plan; subsequent packets ship the foundation incrementally. Each |
| 5 | +> packet is independently reviewable in its own commit, matching the |
| 6 | +> [Phase 01 cadence](phase-01-repository-tooling.md). |
| 7 | +> |
| 8 | +> The packet order is dependency-driven: a later packet may consume any earlier |
| 9 | +> packet's deliverables, but never the reverse. Packets land sequentially on |
| 10 | +> `main` via their own pull request. |
| 11 | +> |
| 12 | +> **Packet 0 — Kickoff ✅ (this commit)** |
| 13 | +> Phase 02a packet breakdown captured in this Status block. Glossary |
| 14 | +> entries for "Phase", "Packet", and "Kickoff Packet" added under a new |
| 15 | +> *Roadmap & Delivery* group so the terms are defined in exactly one |
| 16 | +> place. No code, no ADR state changes — Packet 0 is a planning slice |
| 17 | +> that unblocks the rest of the phase by fixing the order. |
| 18 | +> |
| 19 | +> **Packet 1 — Foundation decisions ⏳** |
| 20 | +> Move the three Phase-02a-blocking drafts from |
| 21 | +> [decisions/README.md § Open ADR Drafts](../decisions/README.md) to |
| 22 | +> **Accepted**: ADR-0023 (strongly-typed ID source generator — |
| 23 | +> Vogen / StronglyTypedId / custom), ADR-0024 (API versioning policy — |
| 24 | +> `/v1/` URL convention, deprecation cadence, sunset headers), ADR-0028 |
| 25 | +> (`audit_log` monthly partition management — Hangfire job vs `pg_partman`). |
| 26 | +> Decision-only; no code. Unblocks Packets 2+. |
| 27 | +> |
| 28 | +> **Packet 2 — Shared Kernel core ⏳** |
| 29 | +> `IClock`, `IRandom`, `IGuidFactory` (deterministic-test abstractions per |
| 30 | +> Standards 02 § Time), `LocalizedMessage` (carrying the `lockey_` prefix |
| 31 | +> invariant for `Result.Fail` payloads), `Entity<TId>` (append-only / audit |
| 32 | +> aggregate base), `AuditableEntity<T>` (mutable aggregate base with |
| 33 | +> `CreatedAt` / `CreatedBy` / `UpdatedAt` / `UpdatedBy` / `DeletedAt` / |
| 34 | +> `DeletedBy` / `Version`), soft-delete + optimistic concurrency primitives, |
| 35 | +> domain-event model (in-process MediatR `INotification`), pagination model |
| 36 | +> (cursor-first), strongly-typed ID emitter wired per ADR-0023. Unit tests |
| 37 | +> for every primitive. |
| 38 | +> |
| 39 | +> **Packet 3 — Cross-cutting foundation ⏳** |
| 40 | +> Wires the [ADR-0032](../decisions/0032-exception-handling-logging-and-observability.md) |
| 41 | +> surface end to end via the |
| 42 | +> [wire-cross-cutting-foundation](../../.claude/skills/wire-cross-cutting-foundation/SKILL.md) |
| 43 | +> skill: L1 `LearnStackExceptionHandler : IExceptionHandler`, 8-step MediatR |
| 44 | +> pipeline shells (Validation / Logging / AuditLog / TenantContext / |
| 45 | +> Authorization / Transaction / OutboxFlush / Handler — behaviors whose |
| 46 | +> dependencies are not yet present are scaffolded as no-op shells that later |
| 47 | +> packets light up), `Result<T>.ToActionResult()` extension, `DomainException` |
| 48 | +> Roslyn analyzer (warning class in Phase 02a, error class after Phase 03 exit), |
| 49 | +> `IProviderResilience<TPort>` decorator (Polly v8 ResiliencePipeline — retry + |
| 50 | +> circuit breaker + timeout + bulkhead — config shape |
| 51 | +> `appsettings.Resilience:<port-name>:`), Serilog primary logger + |
| 52 | +> `WriteTo.OpenTelemetry(...)` sink, OpenTelemetry SDK with |
| 53 | +> `AspNetCore` + `HttpClient` + `EntityFrameworkCore` instrumentation + |
| 54 | +> `TenantContextSpanProcessor` + OTLP exporter, `IErrorTrackingProvider` |
| 55 | +> socket with `NoOpErrorTracker` / `SentryErrorTracker` / `LocalFileErrorTracker` |
| 56 | +> + composition-root branching by `DeploymentMode`. Mediator pipeline order |
| 57 | +> backed by the `MediatR_Pipeline_Order_Matches_Canonical_Sequence` |
| 58 | +> architecture test. |
| 59 | +> |
| 60 | +> **Packet 4 — API conventions ⏳** |
| 61 | +> REST + URL versioning (`/v1/...` per ADR-0024), Problem Details (RFC 7807) |
| 62 | +> shape on every error, cursor pagination, idempotency keys for write |
| 63 | +> endpoints with external side effects, ETag concurrency, correlation IDs |
| 64 | +> in headers and logs, OpenAPI generation from code + SDK generation |
| 65 | +> scaffolding, tenant + organization header binding (`X-Tenant-Id`, |
| 66 | +> `X-Organization-Id`). |
| 67 | +> |
| 68 | +> **Packet 5 — Dapr building blocks + APISIX config ⏳** |
| 69 | +> `IEventBus` / `ICacheService` / `ISecretProvider` interfaces in |
| 70 | +> `LearnStack.SharedKernel` + `InProcessEventBus` / `InMemoryCacheService` / |
| 71 | +> `EnvironmentSecretProvider` defaults + `DaprEventBus` / `DaprCacheService` / |
| 72 | +> `DaprSecretProvider` adapters in `LearnStack.Infrastructure`. Composition-root |
| 73 | +> branching by `DeploymentMode` (Development / SaaS / Dedicated / |
| 74 | +> SelfHostedOnline / SelfHostedAirGapped). APISIX `config.yaml` finalised |
| 75 | +> with the documented plugin chain |
| 76 | +> (`cors` → `jwt-auth` → `limit-req` → `proxy-rewrite` → `prometheus`) and |
| 77 | +> the `/api/internal/*` SSL-object + `ip-restriction` stub reserved for the |
| 78 | +> Phase 02c Hub egress. `Dapr_PubSub_TopicNames_FollowConvention` |
| 79 | +> architecture test goes green. |
| 80 | +> |
| 81 | +> **Packet 6 — Tenancy schema foundations ⏳** |
| 82 | +> Migrations + EF configurations for `tenants`, `organizations` (per |
| 83 | +> [ADR-0017](../decisions/0017-tenant-organization-hierarchy.md)), |
| 84 | +> `tenant_domains`, `tenant_locales` (per |
| 85 | +> [ADR-0008](../decisions/0008-localization-schema.md)), `tenant_settings` |
| 86 | +> (with nullable `organization_id` for org-scoped settings), |
| 87 | +> `tenant_feature_flags` (tenant-flag-level only — plan-level features |
| 88 | +> arrive via the entitlement projection), `platform_entitlement_cache`, |
| 89 | +> `platform_host_to_tenant`. Default-organization seeding at tenant |
| 90 | +> creation. No business logic yet; later packets light up CRUD. |
| 91 | +> |
| 92 | +> **Packet 7 — Tenant + Organization resolution + isolation |
| 93 | +> (defense-in-depth) ⏳** |
| 94 | +> `IHostToTenantResolver` (Postgres-backed default reading |
| 95 | +> `platform_host_to_tenant`), `TenantResolverMiddleware`, request-scoped |
| 96 | +> `ITenantContext` (`TenantId`, `OrganizationId?`, `UserId?`), singleton |
| 97 | +> `ITenantContextAccessor` (`AsyncLocal<ITenantContext?>`-backed) populated |
| 98 | +> at scope start by `TenantResolverMiddleware` (HTTP), |
| 99 | +> `HubCorrelationMiddleware` (`/api/internal/*` — stub for 02c), Hangfire |
| 100 | +> `JobActivator` (background jobs), and the outbox / inbox handler scope |
| 101 | +> (integration events). `[TenantOwned]` and `[OrganizationScoped]` marker |
| 102 | +> attributes. EF global query filters on every entity implementing |
| 103 | +> `ITenantOwned` / `IOrganizationScoped`. PostgreSQL RLS policies on every |
| 104 | +> tenant-owned table, with `app.tenant_id` and `app.organization_id` |
| 105 | +> session variables set per connection lease via a |
| 106 | +> `DbConnectionInterceptor` (transaction-local `set_config(..., true)`). |
| 107 | +> Explicit, scoped, audited `EnterPlatformAdminScope(reason)` for the |
| 108 | +> narrow cross-tenant access path. Cross-tenant + cross-org isolation |
| 109 | +> integration tests for at least two seed tenants × two organizations each |
| 110 | +> (`Tenant_A_cannot_read_Tenant_B_data`, |
| 111 | +> `Org_X_cannot_read_Org_Y_within_TenantA`, |
| 112 | +> `Unsetting_tenant_context_returns_zero_rows_through_RLS`). Picks up the |
| 113 | +> application-level seed drop-in deferred from |
| 114 | +> [Phase 01 Packet 8](phase-01-repository-tooling.md) — two demo tenants |
| 115 | +> + platform admin user, wired through the new Tenancy module |
| 116 | +> `DbContext` instead of the placeholder `scripts/seed.sh`. |
| 117 | +> |
| 118 | +> **Packet 8 — Tenant Customization foundation ⏳** |
| 119 | +> `LearnStack.Modules.Customization` with `TenantContentType`, |
| 120 | +> `TenantPageBlock`, `TenantLessonItemType`, `TenantLevelTaxonomy`, |
| 121 | +> `TenantScoringRule`, `TenantCompletionRule`, `TenantCustomFieldDef`, |
| 122 | +> `TenantTemplateLibrary` aggregates and their schema tables (per |
| 123 | +> [ADR-0018](../decisions/0018-tenant-driven-customization-model.md)). |
| 124 | +> Runtime read paths — JSON Schema validators and sandboxed DSL stub — |
| 125 | +> ship now; the Admin Studio editors land in Phase 06. A small built-in |
| 126 | +> seed (a `default-card` page-block composite, a stock `Plain` level |
| 127 | +> taxonomy) lets early phases exercise the customization runtime without |
| 128 | +> a real tenant data set. The full DSL engine is gated on ADR-0025 |
| 129 | +> (Phase 05); Packet 8 ships only the stub. |
| 130 | +> |
| 131 | +> **Packet 9 — Audit infrastructure + Entitlement socket ⏳** |
| 132 | +> `LearnStack.Infrastructure.Audit` with `AuditChangeTrackerInterceptor` |
| 133 | +> (EF SaveChanges interceptor), `IAuditStateCapture` (before / after / |
| 134 | +> changes JSON capture), `AuditLogBehavior` (MediatR behavior — already |
| 135 | +> scaffolded as a shell in Packet 3; now lights up). |
| 136 | +> `LearnStack.Modules.Audit` with `AuditEntry` aggregate (inheriting |
| 137 | +> `Entity<TId>`, **not** `AuditableEntity<T>` — guarded by the |
| 138 | +> `AuditEntry_Inherits_Entity_Not_AuditableEntity` architecture test) and |
| 139 | +> `AuditConfig`. `audit_log` table partitioned by month from Day 1 per |
| 140 | +> ADR-0028's chosen implementation; retention job (Hangfire) ships now |
| 141 | +> with the policy from |
| 142 | +> [18-audit-coverage.md](../standards/18-audit-coverage.md). MUST-class |
| 143 | +> coverage is enabled for every command and security event the seed |
| 144 | +> modules declare; modules added in later phases extend the catalog, not |
| 145 | +> the infrastructure. `IEntitlementProvider` socket declared with |
| 146 | +> `NullEntitlementProvider` as the default (returns "all features |
| 147 | +> enabled, no limits"); Hub-backed and signed-license-key implementations |
| 148 | +> land in Phase 02c. |
| 149 | +> |
| 150 | +> **Packet 10 — Architecture tests catalogue green + phase exit ⏳** |
| 151 | +> Every Phase 02a architecture test in |
| 152 | +> [21-architecture-tests-catalogue.md](../standards/21-architecture-tests-catalogue.md) |
| 153 | +> goes green in CI. The catalogue is the canonical name registry; the |
| 154 | +> identifiers below either already exist there (the ADR-0032 batch) or |
| 155 | +> land in their owning packet and are registered then. Tests grouped by |
| 156 | +> introducing packet: |
| 157 | +> |
| 158 | +> - From the cross-cutting ADR-0032 batch already in the catalogue |
| 159 | +> (introduced by Packet 3): |
| 160 | +> - `IExceptionHandler_Registered_AtStartup` |
| 161 | +> - `MediatR_Pipeline_Order_Matches_Canonical_Sequence` |
| 162 | +> - `ValidationBehavior_DoesNotThrow_ValidationException` |
| 163 | +> - `Domain_Methods_Do_Not_Throw_For_Expected_Cases` (Roslyn analyzer |
| 164 | +> report) |
| 165 | +> - `Adapters_Wrap_Provider_Exceptions` |
| 166 | +> - `Modules_Do_Not_Reference_Sentry_SDK_Directly` |
| 167 | +> - `Logging_Goes_Through_Microsoft_Extensions_Logging` |
| 168 | +> - `OTel_Pipeline_Includes_TenantContextSpanProcessor` |
| 169 | +> - `TenantContextSpanProcessor_DoesNotThrow_When_Context_Missing` |
| 170 | +> - From the module-dependency arm (introduced by Packet 2 / closed by |
| 171 | +> this packet): |
| 172 | +> - The Application + Infrastructure full matrix extending the |
| 173 | +> existing Phase-01 Packet 1 `ModuleDependencyTests` TODO at |
| 174 | +> [backend/tests/LearnStack.Tests.Architecture/ModuleDependencyTests.cs:17-21](../../backend/tests/LearnStack.Tests.Architecture/ModuleDependencyTests.cs#L17-L21) |
| 175 | +> - `LearnStack_Modules_DoNotReference_Hub` |
| 176 | +> - `Modules_Do_Not_Inject_Valkey_Directly` |
| 177 | +> - `Modules_Do_Not_Read_Entitlement_Cache_Directly` |
| 178 | +> - `Modules_Do_Not_Write_AuditLog_Directly` |
| 179 | +> - `Modules_Do_Not_Reference_DeploymentMode` |
| 180 | +> - `Core_Modules_HaveNo_DomainSpecific_Names` |
| 181 | +> - `No_Source_Folder_Named_Verticals` (already green from Phase 01) |
| 182 | +> - From the tenancy + isolation arm (introduced by Packet 7 — canonical |
| 183 | +> identifiers land in the catalogue when the tests do): |
| 184 | +> - `Every_OrgScoped_Entity_HasOrgIdAndFilter` |
| 185 | +> - An `Every_TenantOwned_Entity_HasFilterAndRlsPolicy`-shaped pair |
| 186 | +> for the tenant dimension (final name TBD in Packet 7's catalogue |
| 187 | +> entry) |
| 188 | +> - "No `IgnoreQueryFilters()` outside the platform-admin scope" rule |
| 189 | +> (final identifier TBD in Packet 7's catalogue entry) |
| 190 | +> - From the audit arm (introduced by Packet 9): |
| 191 | +> - `AuditEntry_Inherits_Entity_Not_AuditableEntity` |
| 192 | +> - `Every_TenantOwned_Command_HasAuditCoverage` |
| 193 | +> - "Audit-coverage matrix file exists per module" rule (final |
| 194 | +> identifier TBD in Packet 9's catalogue entry) |
| 195 | +> - From the Dapr arm (introduced by Packet 5): |
| 196 | +> - `Dapr_PubSub_TopicNames_FollowConvention` |
| 197 | +> |
| 198 | +> The `if: false` CI placeholder for integration tests |
| 199 | +> ([phase-01-repository-tooling.md § Packet 8](phase-01-repository-tooling.md)) |
| 200 | +> is removed once the first integration test from Packet 7 is green. |
| 201 | +> Closes the architecture-test arm of the |
| 202 | +> [Phase Exit Decision](#phase-exit-decision) checklist; the remaining |
| 203 | +> exit gates (tenant + organization resolution, isolation tests, audit |
| 204 | +> pipeline, customization runtime read paths, API conventions, the three |
| 205 | +> blocking ADRs) close as their owning packets ship. |
| 206 | +
|
3 | 207 | ## Goal |
4 | 208 |
|
5 | 209 | Build the runtime foundation everything else stands on: shared kernel conventions, |
@@ -189,7 +393,7 @@ Per [ADR-0018](../decisions/0018-tenant-driven-customization-model.md): |
189 | 393 | - `LearnStack.Modules.Customization` ships with `TenantContentType`, |
190 | 394 | `TenantPageBlock`, `TenantLessonItemType`, `TenantLevelTaxonomy`, |
191 | 395 | `TenantScoringRule`, `TenantCompletionRule`, `TenantCustomFieldDef`, |
192 | | - `TenantTemplateLibrary` aggregates and their schemas tables. |
| 396 | + `TenantTemplateLibrary` aggregates and their schema tables. |
193 | 397 | - The runtime read paths (JSON Schema validators, sandboxed DSL stub) ship now; the |
194 | 398 | Admin Studio editors land in Phase 06. |
195 | 399 | - A small built-in seed (a `default-card` page-block composite, a stock `Plain` level |
|
0 commit comments