From f8574377a56edd27d2eb5a9b53d92e2afd2c55b5 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sat, 10 Oct 2026 20:23:07 +0300 Subject: [PATCH 01/92] docs(roadmap): close merged P02d-6 and record next packet Record PR #27's merged head and independently verified merge-run evidence so current status surfaces agree with the delivered public renderer. Keep dated delivery history intact and name P02d-7's remaining decisions, demo, Lighthouse and phase-exit obligations without starting that packet. --- CLAUDE.md | 12 +-- README.md | 8 +- docs/roadmap/README.md | 8 +- docs/roadmap/phase-02d-walking-skeleton.md | 76 ++++++++++++++++++- .../roadmap/phase-06-renderer-admin-studio.md | 5 +- docs/standards/README.md | 7 +- 6 files changed, 102 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a0632c14..8890612d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,18 +134,20 @@ with actual-config planted controls. Its owns verification and review status. Step 4 adds concurrent host/locale, theme, freshness and content-fault product proofs; its [delivery record](docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout) -records both completed review rounds. **P02d-6 implementation is complete — -2026-10-10, unmerged**: the +records both completed review rounds. **P02d-6 is complete and merged** through +[PR #27](https://github.com/HodeTech/LearnStack/pull/27) on 2026-10-10; its +[merge closeout](docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-merge-and-closeout-2026-10-10) +records final verification. The [packet closeout](docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-packet-closeout-2026-10-10) records passing manual browser checks and the maintainer's VoiceOver smoke. -Maintainer PR review and merge remain pending. ADR-0055 remediation delivers +ADR-0055 remediation delivers single-bootstrap admission and passing production replacement proofs. All three steps completed both independent review rounds; the [Step 3 record](docs/roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout) owns completed Step 3 reviews, verified fixes and delivery evidence. The five new catalogue rules are Implemented. -P02d-7 owns the browser demo and Lighthouse; Phase 06 retains -the full renderer/Studio expansion. +P02d-7's demo, Lighthouse and phase-exit decision pass is next and has not started. +Phase 02d remains in progress; Phase 06 retains the full renderer/Studio expansion. **Phase 01** shipped the .NET 10 solution scaffold under `backend/` (core + 7 modules × 4 projects + 4 test projects including the diff --git a/README.md b/README.md index ffe643e6..289c7a1d 100644 --- a/README.md +++ b/README.md @@ -101,8 +101,12 @@ The original P02d-6 implementation is complete. ADR-0055 remediation delivers single-bootstrap admission and passing production replacement proofs; all three steps completed both independent review rounds, and the [Step 3 record](docs/roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout) -owns completed Step 3 reviews, verified fixes and delivery evidence. Maintainer -PR review and merge remain pending. The browser demo remains P02d-7. The +owns completed Step 3 reviews, verified fixes and delivery evidence. **P02d-6 is +complete and merged** through [PR #27](https://github.com/HodeTech/LearnStack/pull/27) +on 2026-10-10; its +[merge closeout](docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-merge-and-closeout-2026-10-10) +records final verification. P02d-7's demo, Lighthouse and phase-exit decision pass +is next and has not started. 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 diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index e9e837d8..c92eff02 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -77,8 +77,12 @@ not deferred to the showcase phase. single-bootstrap admission and passing production replacement proofs. All three steps completed both independent review rounds; the [Step 3 record](phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout) - owns completed Step 3 reviews, verified fixes and delivery evidence. Maintainer - PR review and merge remain pending. P7 owns the browser/demo. + owns completed Step 3 reviews, verified fixes and delivery evidence. P02d-6 is + complete and merged through [PR #27](https://github.com/HodeTech/LearnStack/pull/27) + on 2026-10-10; its + [merge closeout](phase-02d-walking-skeleton.md#p02d-6-merge-and-closeout-2026-10-10) + records final verification. P02d-7's demo, Lighthouse and phase-exit decision + pass is next and has not started; Phase 02d remains in progress. - [Phase 02b: Events, Background Jobs, Identity, and Session](phase-02b-events-auth.md) - [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) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index e7073dd5..4a06ce72 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -14,7 +14,7 @@ > | P02d-3 | Read internals | ✅ complete and merged — 2026-10-03; [merge closeout](#p02d-3-merge-and-closeout-2026-10-03) | > | 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 | ✅ implementation complete — 2026-10-10; all four steps and both review rounds per step complete; scoped browser/VoiceOver smoke passed; [packet closeout](#p02d-6-packet-closeout-2026-10-10); maintainer PR review/merge pending | +> | 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 | not started | **Acceptance update — 2026-10-02.** P02d-1 remains merged. The maintainer accepted @@ -126,6 +126,13 @@ manual browser observations and the maintainer's passing VoiceOver smoke. P02d-6 awaits maintainer PR review and merge. P02d-7's demo, browser/Lighthouse harness and phase-exit decision pass are next; they have not started. +**P02d-6 merge complete — 2026-10-10.** The preceding notes record pre-merge +milestones. P02d-6 is now closed through +[PR #27](https://github.com/HodeTech/LearnStack/pull/27); its +[merge closeout](#p02d-6-merge-and-closeout-2026-10-10) records the final head, +verification and merge. Phase 02d remains in progress. P02d-7's demo, Lighthouse +and phase-exit decision pass is next; implementation has not started. + ## Goal Put a working education site in a browser — twice, on two hosts, for two tenants in @@ -5553,6 +5560,73 @@ transformation syntax, one shared-constant listener, adjacent source anchors, registration order and cleanup. Both rounds are read-only and start no runtime services. The PR description owns final exact-head CI after this record commit. +### P02d-6 merge and closeout (2026-10-10) + +The maintainer merged [PR #27](https://github.com/HodeTech/LearnStack/pull/27) at +2026-10-10 17:07:50 UTC. GitHub records final PR head +`46ed971bf976514fb451a162cc9450d147ed0441` and merge commit +`378346126949c90bb286b5217c24513fc2044f06`. The merge's second parent is that +head, and both commits have the identical tree. Development is fast-forwarded to +the verified merge without changing branches or rewriting history. The final +head includes the testing-standard correction marking both ADR-0055 Step 3 +review rounds complete. + +- All six required checks pass on the + [final PR head](https://github.com/HodeTech/LearnStack/actions/runs/38070332107) + and the [merge commit](https://github.com/HodeTech/LearnStack/actions/runs/38070479860). + Independently downloaded merge-run TRX confirms **2,946 passed**, zero failures + or skips: 1,705 unit, 198 architecture, four contract, 172 Docker-free + integration and 867 Docker integration cases. +- Merge-run frontend logs confirm **1,402 passed** (1,342 web, 60 SDK), successful + typecheck, lint, production build and SDK drift verification. Native production + ingress verification and all **19** setup/cleanup and negative controls pass. + Release builds report zero warnings and errors. These are merge-run receipts; + the documentation closeout does not rerun the full runtime suites. +- Read-only verification confirms `strict: true` and the same six required Actions + contexts, including OpenAPI diff and Docker integration. No protection setting + changes. Lighthouse remains disabled with activation owned by P02d-7. + +**P02d-6 is closed.** Localized catalog, course/outline and ordered plain-text +lesson pages, safe four-color themes, exact-locale metadata, scoped pagination +and controlled public states are delivered. ADR-0055 adds middleware-owned single +bootstrap admission, immutable request-local reuse and production lifecycle/ +isolation proofs. All implementation steps and focused fixes retain their +recorded independent review rounds. The +[packet closeout](#p02d-6-packet-closeout-2026-10-10) owns scoped keyboard, focus, +reflow and contrast observations and the maintainer's VoiceOver smoke; this +merge adds no new manual observation or full WCAG-conformance claim. + +**Next: P02d-7 — Demo, full-stack CI and exit.** Its decision pass and +implementation have not started. Resolve the remaining G20/G33/G38/G44/G45 parts +against the delivered renderer before harness implementation: + +- Define `make demo` and stop/re-run ownership on a clean checkout, environment + projection, readiness, non-destructive seeding and the printed host/locale URLs + (G45). Local use and CI reuse the selected entrypoint. +- Select Lighthouse's tools, both-host/locale URL set, assertions, budgets, + aggregation and artifacts; activate its currently disabled job and reconcile + standards/required-check carriers with the chosen enforcement (G44). +- Complete the shared full-stack/browser demo and trusted-hop CI evidence + (G33/G38), including anonymous catalog-to-lesson flows with Keycloak stopped. +- Implement and prove G20's remaining production/seed and frontend genericity + fences in required checks, preserving tenant-data-driven differences. +- Reconcile every exit criterion and register row with its evidence and record + the Education/seed preservation obligations and premises `P02b-0` re-verifies. + [Phase 02b](phase-02b-events-auth.md) follows only after the Phase 02d exit + decision, not this packet merge. + +Phase 06 retains full Studio/portal, authored composition and Playwright/axe; +Phase 11 retains broader production hardening and telemetry. Course Marketplace +commerce is not delivered by this closeout. Earlier dated delivery records, +including their pre-merge pending statements, remain historical and unchanged. + +**Documentation-only closeout validation.** All **198** local Release architecture +cases and seven ADR lifecycle/workflow controls pass, with zero failures/skips. +The six changed Markdown files resolve **873** relative links and **371** fragments +with zero errors in the local audit. Added prose wrapping and diff checks pass. +Earlier dated phase records are byte-preserved; no Accepted ADR or production +code changes. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/docs/roadmap/phase-06-renderer-admin-studio.md b/docs/roadmap/phase-06-renderer-admin-studio.md index 94f0f8c0..2ac7c9d1 100644 --- a/docs/roadmap/phase-06-renderer-admin-studio.md +++ b/docs/roadmap/phase-06-renderer-admin-studio.md @@ -22,8 +22,9 @@ and a non-developer tenant admin can maintain it. ### What Phase 02d supplies -Rows identify implemented P02d-6 scope; its PR review and merge remain pending. Planned -P02d-7 work is not current behavior. +Rows identify P02d-6 scope merged through PR #27; its +[merge closeout](phase-02d-walking-skeleton.md#p02d-6-merge-and-closeout-2026-10-10) +records final verification. Planned P02d-7 work is not current behavior. | Phase 02d foundation | Phase 06 adds | |---|---| diff --git a/docs/standards/README.md b/docs/standards/README.md index 0d6dbf7e..21a9ae12 100644 --- a/docs/standards/README.md +++ b/docs/standards/README.md @@ -68,7 +68,7 @@ bookkeeping pass. ### Honest status today The table below describes the implementation as of 2026-10-10: -[Phase 02a](../roadmap/phase-02a-kernel-tenancy.md) and P02d-1–5 are complete and +[Phase 02a](../roadmap/phase-02a-kernel-tenancy.md) and P02d-1–6 are complete and merged. P02d-5's ingress, configured server caller, dynamic entry and frontend guards are delivered through PR #26; the [merge closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-5-merge-and-closeout-2026-10-09) @@ -87,7 +87,10 @@ and passing production replacement proofs. All three steps completed both independent review rounds. The [Step 3 record](../roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout) owns completed Step 3 reviews, verified fixes and delivery evidence; its five rules -are Implemented. Acceptance alone promotes no standard or test status. +are Implemented. P6's +[merge closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-6-merge-and-closeout-2026-10-10) +records PR #27's merge and final verification; P7 has not started. Acceptance +alone promotes no standard or test status. **The documents and this table say the same thing, and a test holds them to it.** [Phase 02a Packet 10](../roadmap/phase-02a-kernel-tenancy.md) reconciled the twenty-two From 3160714ead228c288da627760e22922cc5514ec9 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Sun, 11 Oct 2026 02:46:30 +0300 Subject: [PATCH 02/92] docs(decisions): accept P02d-7 demo and audit contracts Record the approved lifecycle, genericity scope and serial browser/audit measurement boundaries before implementation. Register the four proof obligations without claiming passing runtime evidence or activating CI. ADR: 0056 --- CLAUDE.md | 6 +- README.md | 6 +- ...demo-browser-and-lighthouse-enforcement.md | 318 ++++++++++++++++++ docs/decisions/README.md | 1 + docs/roadmap/README.md | 6 +- docs/roadmap/phase-02d-walking-skeleton.md | 231 ++++++++++++- docs/standards/15-performance.md | 11 + .../21-architecture-tests-catalogue.md | 73 +++- frontend/apps/web/README.md | 5 +- 9 files changed, 640 insertions(+), 17 deletions(-) create mode 100644 docs/decisions/0056-public-demo-browser-and-lighthouse-enforcement.md diff --git a/CLAUDE.md b/CLAUDE.md index 8890612d..17c55185 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,7 +146,11 @@ steps completed both independent review rounds; the [Step 3 record](docs/roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout) owns completed Step 3 reviews, verified fixes and delivery evidence. The five new catalogue rules are Implemented. -P02d-7's demo, Lighthouse and phase-exit decision pass is next and has not started. +P02d-7's demo, Lighthouse and phase-exit +[decision package](docs/roadmap/phase-02d-walking-skeleton.md#p02d-7-decision-package-2026-10-10) +was approved on 2026-10-11, including bounded seventh-check activation after real +success. ADR-0056 was accepted on the same date. Implementation follows the +approved four-step plan; no P7 enforcement delivery is claimed yet. Phase 02d remains in progress; Phase 06 retains the full renderer/Studio expansion. **Phase 01** shipped the .NET 10 solution scaffold under `backend/` diff --git a/README.md b/README.md index 289c7a1d..1848598b 100644 --- a/README.md +++ b/README.md @@ -105,8 +105,10 @@ owns completed Step 3 reviews, verified fixes and delivery evidence. **P02d-6 is complete and merged** through [PR #27](https://github.com/HodeTech/LearnStack/pull/27) on 2026-10-10; its [merge closeout](docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-merge-and-closeout-2026-10-10) -records final verification. P02d-7's demo, Lighthouse and phase-exit decision pass -is next and has not started. The +records final verification. P02d-7's demo, Lighthouse and phase-exit +[decision package](docs/roadmap/phase-02d-walking-skeleton.md#p02d-7-decision-package-2026-10-10) +and ADR-0056 were approved on 2026-10-11; implementation follows the four-step +plan. 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 diff --git a/docs/decisions/0056-public-demo-browser-and-lighthouse-enforcement.md b/docs/decisions/0056-public-demo-browser-and-lighthouse-enforcement.md new file mode 100644 index 00000000..231d01e8 --- /dev/null +++ b/docs/decisions/0056-public-demo-browser-and-lighthouse-enforcement.md @@ -0,0 +1,318 @@ +# ADR-0056: Public Demo Browser and Lighthouse Enforcement + +## Status + +Accepted + +No implementation, passing audit or gate acceptance is claimed. + +**Date:** 2026-10-11 +**Deciders:** @cemil + +## Decision Drivers + +- P02d-7 must demonstrate two tenant sites from one production build and database. +- G44 already assigns Lighthouse activation to P7; its measurements are undecided. +- A fast error page must never satisfy a performance or accessibility check. +- The delivered visitor and peer limiters remain active during the entire proof. +- Reproducible gzip, browser and lab measurements need explicit semantics. +- The evidence must preserve the existing ingress, request-local and TLS boundaries. + +## Considered Options + +1. **Direct Lighthouse with managed Chromium** (chosen). + Pin Lighthouse and the Playwright library/browser; validate the actual audited + page, complete report inventory and explicitly defined budgets. +2. **LHCI with the runner's stock Chrome** (rejected). LHCI supplies useful + assertions but still needs page-identity and gzip checks; stock Chrome drifts. + LHCI 0.15.1 pins Lighthouse 12.6.1, rather than this proposal's 13.5.0. +3. **Human walkthrough or static HTML audit only** (rejected). Neither exercises + the delivered navigation in a browser or closes G44's required full-stack job. + This rejects human evidence as the sole gate; G33's human walkthrough remains. +4. **Make all existing timing targets hard immediately** (rejected). The landing + page and lesson player are absent, and a shared CI lab is not a production + p95 or field-INP baseline. + +## Decision + +LearnStack gates the delivered public catalog, course and lesson pages with a +seed-derived browser walkthrough and pinned Lighthouse audits over the shared +demo lifecycle. Every audit proves the identity of its actual product document; +normalized initial JavaScript gzip size, CLS and automated accessibility are hard +checks, while per-URL median timing measurements are advisory. A required CI job +runs these checks without changing the production anonymous admission budgets. + +## Context + +The maintainer accepted this enforcement decision on 2026-10-11. It +implements P02d-7's remaining G33/G38/G44 evidence; the +[phase package](../roadmap/phase-02d-walking-skeleton.md#p02d-7-decision-package-2026-10-10) +owns G20, G45, implementation steps and phase exit. The maintainer approved that +package's lifecycle and bounded seventh-check activation on 2026-10-11; this +revised ADR is accepted on the same date. G45 names the approved `make demo` +lifecycle and G33 names the approved CI topology in the register. The decision +commit records both dependencies before implementation; ADR acceptance does not +replace the lifecycle decision. It adds no production +API, new request authority, shared representation cache or deployment mode. + +[ADR-0053](0053-trusted-public-server-rendering.md), +[ADR-0054](0054-bounded-public-renderer-admission.md) and +[ADR-0055](0055-public-renderer-bootstrap-failures.md) remain unchanged. Native +HTTPS ingress is mandatory; ordinary product documents cost two API calls. +The 60-call/minute visitor and 600-call/minute peer policies remain registered. + +### Toolchain and page matrix + +Use exact development-tool pins: `lighthouse@13.5.0` and +`playwright@1.64.0` as a library, without a new Playwright Test runner or a +separate axe suite. Install Playwright's managed full Chromium: revision 1248, +version 156.0.8078.4, with the new headless mode. The repository's Node 22.23.1 +satisfies Lighthouse's Node >=22.19 requirement. Record resolved versions and the +OS/runner image; refuse a missing or mismatched browser instead of skipping. +Both tools are Apache-2.0 licensed. Pin them as development tools in +`frontend/package.json` and `frontend/pnpm-lock.yaml`, outside application runtime +dependencies. Record transitive license/security review with implementation. + +Derive the URL/identity/expected-content manifest from `SeedData`; do not maintain +another list of seed slugs, titles, colors or field labels. + +| Lane | Locale | Pages | +|---|---|---| +| English tenant-wide host | en | Catalog, public course, first public lesson | +| Yoga default-organization host | tr-TR | Catalog, public course, first public lesson | +| Yoga default-organization host | en | The same three resource kinds in English | + +The exact seeded organization-scoped Yoga course supplies both Yoga lanes. This +is nine URLs and three cold audits per URL: exactly 27 JSON/HTML report pairs. +The browser opens each catalog, then follows rendered course and lesson links. +It checks host/locale, expected headings, ordered authored fields, distinct field +sets, computed theme colors, and exclusion of foreign/draft/restricted content. +It uses the same running API, web build and database across all lanes. +Keycloak is started for the existing seed gate, then stopped in the CI-owned +project before these fresh anonymous navigations. + +These flows supplement the existing HTML/Flight and schema-replacement proofs. +[Phase 06](../roadmap/phase-06-renderer-admin-studio.md) still owns the broader +Playwright/axe golden-flow suite. Existing P6 manual/VoiceOver evidence keeps its +recorded scope and date; this ADR establishes neither a new manual pass nor full +WCAG conformance. + +### Actual audited document and report admission + +Before scoring, each run requires the expected URL and a successful live product +preflight. The audit additionally checks its own final URL, main-document status, +tenant/locale/content markers and original document/DOM. A healthy preflight +alone is insufficient. Inspect the audited target or its gathered artifacts +without a second post-audit navigation. A bounded custom gatherer is permitted. + +Reject controlled error/noindex/fallback documents, wrong-tenant content, missing +expected fields, unexpected redirects and any API 429, including a content +refusal rendered as HTTP 200. Observe API responses as well as browser responses; +a genuine product DOM cannot turn a hidden API failure into a clean receipt. + +Each report must belong to this invocation's commit, build, URL, run index, +configuration and browser. Missing, duplicate or stale reports, runtime errors, +audit errors, missing categories and null/nonfinite budgeted measurements fail +independently of the hard/advisory split. Both tenants and every locale/run must +pass; results are never averaged across URLs. + +### Lab profile and budgets + +Run only `performance` and `accessibility` from the pinned default configuration, +explicitly skipping `bf-cache`, whose gatherer navigates away and back. Preserve +the complete pinned accessibility audit/weight set and the four budgeted numeric +audits: `cumulative-layout-shift`, `largest-contentful-paint`, +`server-response-time` and `total-blocking-time`. The selected performance +category also gathers and scores FCP and Speed Index in Lighthouse's report; +neither has a P7 threshold, and the performance category score is not asserted. +Assert the resolved audit/artifact census and refuse missing or changed scoring +entries. Do not select `seo`, `best-practices` or `agentic-browsing`; no active +secondary-navigation probes run. Product-identity/gzip gatherers observe the +initial navigation and introduce no fetch or navigation of their own. + +Use Lighthouse 13.5's mobile simulated slow-4G profile, recorded explicitly: +412 × 823 CSS pixels, DPR 1.75, 150 ms RTT, 1638.4 Kbps throughput and 4× CPU +slowdown. Record resolved settings in every run manifest. This is not the +fast-4G landing-page profile in Performance Standards. + +| Measurement | Threshold | Verdict | +|---|---|---| +| `initial-js-gzip-bytes`, defined below | Strictly < 200000 bytes | Hard, every run | +| `cumulative-layout-shift.numericValue` | Strictly < 0.05 | Hard, every run | +| `categories.accessibility.score` | Exactly 1 | Hard, every run | +| `largest-contentful-paint.numericValue` | Median of three < 1500 ms | Advisory, per URL | +| `server-response-time.numericValue` | Median of three < 300 ms | Advisory, per URL | +| `total-blocking-time.numericValue` | Median of three < 200 ms | Advisory, per URL | + +The advisory thresholds are P7 lab baselines across the nine delivered pages. +Main-document server response is not full-navigation TTFB; TBT is not INP. +No landing-page, lesson-player, production p95 or load-test result follows. +The 250 KB forbidden line does not replace the 200 KB route budget. +Accessibility score 1 covers Lighthouse's automated scored checks, not every +accessibility criterion. + +### Normalized initial JavaScript gzip size + +`initial-js-gzip-bytes` adopts an explicit, potentially stricter interpretation +of the existing public-route gzip budget in +[Performance Standards](../standards/15-performance.md#bundle-size): + +- Use a cold navigation with no interactions or speculative prefetch. The + collection window is that audit's navigation through collection completion. +- Inventory every actual external Script response in that window, same-origin + and cross-origin, including async/lazy scripts loaded by initial scripts + without interaction. Count each delivered response once; gzip its complete + decoded UTF-8 source with Node `gzipSync` at level 9 and sum the byte lengths. +- Add the gzip length of original-document inline executable script bodies, + concatenated in document order with one newline between bodies. Include Next's + inline Flight push scripts; exclude inert JSON/data blocks and external-script + elements already counted above. Scripts first requested after collection + completion or through later user interaction are outside this measurement. +- Record source URL, digest, decoded byte length and gzip length, plus inline + count/digest/lengths. Reconcile the network inventory with collected sources. +- Missing, undecodable or truncated bodies, inventory disagreement and an + unexpectedly empty script census fail; unavailable sources never count as zero. + +This is a deterministic normalized payload measurement using decimal KB. It is +neither actual wire bytes nor Next's build-time First Load JS. Lighthouse's +Script `transferSize` may use another content encoding; it cannot substitute for +this budget. Evaluation-generated code is not a delivered source response. +The collection window and inline/async coverage can reject a route that passed +a narrower build-time or entry-chunk-only reading. This tightening is intentional; +the enforcing Standards 15 update links this exact scope and its exclusions. + +### Pacing, runtime and artifacts + +Keep one running API throughout the proof. Use exactly four sequential batches: +one batch containing all three browser click paths, followed by three audit +batches, one per host/locale lane, each containing nine audits. Use one worker: +preflight/audit pairs execute serially, with at most one active browser target +and no overlapping audits. Record the runner's CPU count and OS/image; do not +parallelize Chromium to meet a deadline on a shared runner. +Leave at least 61 seconds after a batch completes before starting the next. +With one navigating preflight per audit, each audit batch expects 36 API calls; +the click paths expect 18. Count readiness and other documents separately. + +Cap each batch at 48 observed API calls and refuse any 429 or unexplained extra +traffic. Do not spoof visitors, raise quotas, reset the API/limiter, retry 429s or +silently audit a refusal page. Prove the counting observer has a positive control. +Startup checks also consume quota: finish them before the first 61-second quiet +interval. Real counts, not the arithmetic alone, own the passing evidence. + +The proof has one 12-minute wall-clock deadline after setup/build, starting +before the first quiet interval and ending after report validation. All four +61-second quiet intervals consume it: 244 seconds, leaving at most 476 seconds +for browser paths, all preflights, 27 audits and validation. Audit/preflight +pairs must therefore average less than 17.6 seconds even before deducting the +browser paths and validation; no measured feasibility is claimed yet. + +Navigation and audit timeouts are upper bounds, not reserved time allocations: +each navigation gets at most 15 seconds and each audit at most 60 seconds, +both shortened to the remaining proof deadline. Exhaustion aborts the active +operation, refuses unstarted work and fails the run; partial evidence cannot +pass. In particular, 27 audits taking 60 seconds each cannot pass this deadline. +Owned cleanup has a separate maximum of 30 seconds. The 20-minute CI job ceiling +includes setup/build, proof, cleanup and artifact handling; record each duration +and reserve cleanup/artifact time within that ceiling. + +The first full serial run is a feasibility gate before required-check activation. +If the complete run cannot meet these limits, record a failed result and obtain +a revised decision for changed time ceilings or execution topology. Do not +silently run parallel audits, reduce the 27-run census or weaken checks to fit. + +Keep reports and sanitized receipts in GitHub workflow artifacts only: +all 27 JSON/HTML pairs, expected/run census, metrics verdicts, version/config/ +commit manifest, browser screenshots, API status/count totals and cleanup result. +Upload bounded failure evidence on failure. Never archive private environments, +raw process logs, browser profiles, certificates or keys, and never upload to a +public Lighthouse service. + +### Trust and required-check activation + +Use the shared lifecycle's ownership and verified TLS contract. Human local use +retains explicit manual hosts/CA/leaf setup. CI uses disposable certificates, +certificate-specific browser SPKI trust, resolver mappings and independent Node +CA/hostname verification with wrong-key/untrusted-certificate controls. +Global certificate bypass or system hosts/trust modifications are forbidden. + +The job is named `public demo + lighthouse`, runs on the normal PR/push triggers +without the deferred condition, and fails if any proof or cleanup is incomplete. +After its first real successful PR run, add that exact GitHub Actions context as +the seventh required check. Read back protection and preserve all six existing +contexts, strict freshness and every unrelated setting. Acceptance authorizes +only this bounded activation after the successful run; it does not claim activation. + +## Consequences + +### Positive + +- Browser navigation, product identity and budgets become repeatable merge gates. +- Error pages, missing metrics and empty/stale evidence cannot produce green CI. +- Seed-derived expectations preserve the same-code/different-data claim. + +### Negative + +- Managed Chromium and Lighthouse increase dependency and CI setup costs. +- Real-window pacing adds deliberate waiting; browser/lab maintenance remains work. +- The normalized gzip collector and audit-page admission need their own controls. + +### Neutral + +- Performance becomes Active only for implemented hard checks, in the enforcing PR. +- Broader performance baselines and telemetry remain Phase 11. +- Local demo lifecycle details remain G45 phase decisions, rather than a new runtime. + +## Implementation Notes + +Acceptance precedes runtime/dependency/CI changes. Reconcile Testing 06's current +“Phase 02d … gates on a human opening them, not on a Playwright run” statement +when the narrow P7 browser job ships; preserve Phase 06's broader suite ownership. +Make the public-route size unit explicit in Standards 15 as decimal KB +(`< 200000` bytes), alongside its normalized-gzip window, same-/cross-origin +scope, exclusions and potentially stricter interpretation. + +Register the canonical rules below in Standards 21 on acceptance as +**Kind: behavioural**, owned by the planned +Node entrypoint `frontend/apps/web/scripts/verify-public-demo.mjs` and the +`public demo + lighthouse` job; these are not C# architecture-test methods. +Mark them Implemented only with named, executed enforcing tests. The phase +package owns four implementation steps and two fresh independent review rounds +per step. Update Standards 06/15/16, the +standards index, CI, CONTRIBUTING and test/setup skills alongside enforcement. +Acceptance registers proof obligations, not passing tests or delivered enforcement. + +## Architecture Tests + +- `Public_Demo_Uses_Seeded_Production_Pages`: real link navigation and page/role/ + theme/locale observations, with wrong-page and error-page controls. +- `Public_Lighthouse_Reports_Enforce_Budgets`: exact census and hard/advisory + validation, with missing/stale/error/budget offenders and clean reports. +- `Public_Initial_JavaScript_Gzip_Budget_Is_Measured`: real source inventory and + byte accounting, with omitted/empty/malformed sources and over-budget controls. +- `Public_Demo_Preserves_Admission_And_Cleanup`: real quotas, Keycloak-stopped + reads, TLS refusal and owned lifecycle, with observer and cleanup controls. + +These are registered behavioral obligations for that Node/CI proof owner, not +existing scripts or C# test methods. + +## References + +- [P02d-7 package](../roadmap/phase-02d-walking-skeleton.md#p02d-7-decision-package-2026-10-10). +- [Performance Standards](../standards/15-performance.md). +- [Testing Standards](../standards/06-testing.md#end-to-end-tests). +- [Accessibility Standards](../standards/16-accessibility.md#tooling). +- [Lighthouse 13.5.0 package and Node requirement](https://github.com/GoogleChrome/lighthouse/blob/v13.5.0/package.json). +- [Lighthouse programmatic use](https://github.com/GoogleChrome/lighthouse/blob/v13.5.0/docs/readme.md#using-programmatically). +- [Lighthouse mobile profile selection](https://github.com/GoogleChrome/lighthouse/blob/v13.5.0/core/config/constants.js). +- [Pinned slow-4G upstream constants](https://github.com/ChromeDevTools/devtools-frontend/blob/v1.0.1684555/front_end/models/trace/lantern/simulation/Constants.ts#L12-L18), matching Lighthouse's trace-engine 0.0.65 dependency. +- [Lighthouse default audit set](https://github.com/GoogleChrome/lighthouse/blob/v13.5.0/core/config/default-config.js). +- [Lighthouse audit/artifact filtering](https://github.com/GoogleChrome/lighthouse/blob/v13.5.0/core/config/filters.js). +- [Lighthouse Script source collection](https://github.com/GoogleChrome/lighthouse/blob/v13.5.0/core/gather/gatherers/scripts.js). +- [Lighthouse resource-summary transfer semantics](https://github.com/GoogleChrome/lighthouse/blob/v13.5.0/core/audits/resource-summary.js). +- [Lighthouse TBT](https://developer.chrome.com/docs/lighthouse/performance/lighthouse-total-blocking-time). +- [Lighthouse accessibility scoring](https://developer.chrome.com/docs/lighthouse/accessibility/scoring). +- [Playwright library](https://playwright.dev/docs/library). +- [Playwright 1.64.0 package/license](https://github.com/microsoft/playwright/blob/v1.64.0/packages/playwright/package.json). +- [Playwright 1.64.0 browser manifest](https://github.com/microsoft/playwright/blob/v1.64.0/packages/playwright-core/browsers.json). +- [Playwright browser installation](https://playwright.dev/docs/browsers). +- [LHCI 0.15.1 dependency](https://github.com/GoogleChrome/lighthouse-ci/blob/v0.15.1/packages/cli/package.json). diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 6083b93f..769cfc3a 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -71,6 +71,7 @@ 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, advisory timings and bounded seventh-check activation; proof rules Registered, implementation pending | ## Proposed ADRs diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index c92eff02..c67a4b89 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -81,8 +81,10 @@ not deferred to the showcase phase. complete and merged through [PR #27](https://github.com/HodeTech/LearnStack/pull/27) on 2026-10-10; its [merge closeout](phase-02d-walking-skeleton.md#p02d-6-merge-and-closeout-2026-10-10) - records final verification. P02d-7's demo, Lighthouse and phase-exit decision - pass is next and has not started; Phase 02d remains in progress. + records final verification. P02d-7's demo, Lighthouse and phase-exit + [decision package](phase-02d-walking-skeleton.md#p02d-7-decision-package-2026-10-10) + and ADR-0056 were approved on 2026-10-11; implementation follows the four-step + plan. Phase 02d remains in progress. - [Phase 02b: Events, Background Jobs, Identity, and Session](phase-02b-events-auth.md) - [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) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 4a06ce72..f40d3719 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -15,7 +15,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 | not started | +> | P02d-7 | Demo, full-stack CI and exit | decision pass Accepted — 2026-10-11; [decision package](#p02d-7-decision-package-2026-10-10); implementation starts with Step 1 | **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 @@ -433,7 +433,7 @@ premise a row cites is re-verified at that pass rather than trusted. | G17 | Does `TenantSetting.Value` carry `[PiiSensitive]`? [Phase 03](phase-03-identity-admin.md) sequences the decision before the first command writing `tenant_settings`, and this phase ships that command | Not marked, provided `tenancy.setting.write` admits only G16's closed key set, so the answer cannot stretch to keys a tenant invents; modelling a sensitive part as its own property stays open to Phase 03 | Contract: a dated phase-doc statement, reflected in `TenantSetting.cs`, the Tenancy spec and `audit.md`. Whole-value redaction of `jsonb` is settled by [ADR-0044](../decisions/0044-audit-write-path.md) Amendment 4 § 1 | P02d-2 (the first MUST-class settings audit row is written by the seed, and rows cannot be redacted retroactively); closes with G16 (a) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): generic whole-value PII redaction before the writer | | G18 | How is a tenant content type presented? `json_schema` is `jsonb`, which keeps no key order, and the schema profile collects only `x-renderer`, `x-taxonomy` and `x-language`. How are field order, a label per enabled locale and a composite's field roles carried; which registered composite draws a lesson for each seeded type; which primitives does this phase implement, and does `markdown` render; how do types with no primitive row (`integer`, `number`, `boolean`, enums) map; may a rendered type declare a field outside the subset; and is a presentation entry naming a missing property refused at save? | A LearnStack extension — `x-order` and `x-label`, or one ordered `x-fields` list — carrying Pattern B labels, resolved at write like `x-taxonomy`; one composite already in both registries; the reviews split on the subset — `text`, `list` and `link`, with `markdown` without raw HTML, or a placeholder until Phase 05's sanitiser; the seed uses only the subset | Contract: a dated ADR-0043 amendment for a keyword or a save-time refusal; a dated ADR-0018 amendment for a presentation column; a phase-doc statement for `title` plus `required`, which cannot carry two locales. Detail: [Tenant Customization Model § 2](../architecture/32-tenant-customization-model.md) and § 8.1, the Customization spec, the profile's extension and reference-graph skip lists, `composites.ts` | P02d-2 (the seed publishes both content types as `schema_version` 1 with their renderer keys and field kinds; a later answer needs successor revisions) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): ADR-0051 profile and first-render subset. Component placement/fallback remains G41 | | G19 | URL and markup policy for tenant-authored values on an anonymous page: which schemes (`https` only, or `http` too), credentials and `target`, which media origins, whether the rule is enforced on write — in the Education command, or as a validation gate Phase 04's entries share — whether the public API filters too, and whether URLs inside markdown fall under it. The write-time check constrains structure, not schemes: `format: uri` admits `javascript:` and `data:` | The reviews split on `http`; all refuse `javascript:`, dangerous `data:` and credentials; checked on write by a LearnStack rule and again on render; no third-party media in the seed | Detail: one home for the scheme list — [Security Standards § XSS & Output Encoding](../standards/11-security.md#xss--output-encoding) or [Frontend Architecture Standards § Security](../standards/07-frontend-architecture.md#security), not both; the Education spec's write rules; Tenant Customization Model § 8.1 if checked on write. Contract: a dated ADR-0043 amendment if it becomes a shared validation gate | P02d-2 (the lesson command's validation and the seed values; the render-time check reuses the answer) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): no active sink in the seeded text profile; future URL/markup contracts precede Phase 04/05 sinks | -| 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 | +| 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; enforcement remains pending | | 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 | @@ -446,19 +446,19 @@ premise a row cites is re-verified at that pass rather than trusted. | G30 | The locale error matrix and transport. On each read, what answers an empty, repeated, malformed, over-length, non-canonical, not-enabled or enabled-but-untranslated locale? Does a not-enabled locale answer the Active `unsupported_locale` `400` or the not-found body? Is `X-Locale`, which [Frontend Architecture Standards § Locale Resolution](../standards/07-frontend-architecture.md#locale-resolution) still names as the API carrier, withdrawn, so that locale reaches the API only as the query parameter? | Missing or malformed → `400` `validation_failed` naming `locale`; untranslated → an empty catalog page; the query parameter only. The reviews split on not-enabled — not-found, amending the Error Handling row, or the Active `400`; a uniform answer after the enabled check hides pre-launch rows under either | Detail: a phase-doc statement; the [Error Handling Standards](../standards/09-error-handling.md) table only if not-found; Standards 07 § Locale Resolution; the Frontend Architecture SDK sketch; the API Standards § Pagination example gains `locale` | P02d-4 (validators, OpenAPI parameters and responses), P02d-5 (the server SDK's header set) | [Accepted — 2026-10-03](#p02d-4-accepted-answers): query/error matrix; trusted server header transport remains P02d-5; [Accepted — 2026-10-08](#p02d-5-accepted-answers): header transport; prior API query-locale answer retained | | G31 | Contract checks and the SDK surface. The committed OpenAPI snapshot's path, and how the contract suite proves it equals the served document; how the base copy is read; how the first run behaves; which `oasdiff` version and fail level, and which ADR-0024 rows that level detects (a tightened validator or a changed status may be invisible to any diff); what is uploaded on failure. The drift gate's source — the committed snapshot through `LEARNSTACK_OPENAPI`, or a running API — and its job. Whether an activated deferred job keeps its `if: vars.ENABLE_*` condition, given that GitHub treats a skipped required job as passing, or loses it as the integration job's did. And what the SDK surface becomes when regeneration makes `paths` non-empty: a hand-written transport over `paths` or a typed client library, the fate of `createClientSdk`, and whether the package root keeps re-exporting the server entry | A committed snapshot the contract suite asserts equal, diffed against the base ref's copy with `oasdiff` pinned; drift generated from the snapshot in the required `frontend` job; the condition removed on activation; a thin hand-written transport, and no root re-export of the server entry | Detail: [Testing Standards § API Contract Tests](../standards/06-testing.md#api-contract-tests), the API Standards § OpenAPI links, `ci.yml`, [CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md#branch-protection-settings-on-main) (its activation procedure follows the answer), [Frontend Architecture Standards § SDK](../standards/07-frontend-architecture.md#sdk); a package pin with its licence verdict if a client library is chosen | P02d-4 (the first operation, its snapshot and assertion, the drift gate, and the regenerated types the factories must compile against) | [Accepted — 2026-10-03](#p02d-4-accepted-answers): snapshot, SDK, diff/drift and approved required-check activation | | G32 | **The development-transport part of [Phase 02b](phase-02b-events-auth.md#the-decision-register)'s G12.** Which development hostnames and transport serve the two seed tenants — keep `*.learnstack.local`, with a hosts-file step and local TLS with a trust step, or move the seed hosts under `*.localhost`? What does a reviewer do between a clean checkout and both sites, and which carriers move, including host rows already on warm databases, where the host is the primary key and the seeder removes no mapping? | `*.localhost` over HTTP, provided the pass verifies in each browser the team uses and in CI's Chrome that both hosts resolve with no hosts entry and that a `Secure` cookie set on them is stored and returned; otherwise `*.learnstack.local` with local TLS and a named trust step. Tenants are never told apart by port: the effective host strips it | Detail: a dated phase-doc statement in [§ Host-based tenant resolution, end to end](#host-based-tenant-resolution-end-to-end), with `SeedData`, `scripts/seed.sh`, the README Quickstart, the `seed-tenant` and `local-dev-setup` skills and `apps/web`'s dev script and Next configuration in the same packet; Infrastructure Standards only if a TLS proxy publishes a port. No ADR | P02d-5 (the development transport, and the hop configuration a TLS proxy would change), or the first earlier packet that writes a seed-host literal outside `SeedData` | [Accepted — 2026-10-08](#p02d-5-accepted-answers): local hosts and TLS | -| G33 | The server-rendering topology and its evidence. Where do Next.js and the API run relative to each other — the workstation loopback, containers, gated APISIX; which networks are trusted; how does one hop secret reach both processes; what is the server-only API origin — and the same for the CI job that renders the pages? What evidence discharges ADR-0036's "Phase 02d's browser test" and its matrix rows "a direct socket bypassing the hop" and "the Phase 02d anonymous two-host browser render", given that Testing Standards gate this phase on a human? And ADR-0036 § Consequences says the root refuses to start outside Development when the secret list is empty or short, while the shipped rule, in every mode, refuses a half-configured hop, a network entry that is not CIDR and a blank secret or one under 32 characters (characters, not bytes), and admits both lists empty: is that recorded or restored, and is non-development hop configuration this phase's or Phase 11's? | Both processes on the loopback, networks `127.0.0.1/32` and `::1/128`, one generated secret from a single source and never under `NEXT_PUBLIC_`, with networks and secrets arriving together (committing networks without secrets breaks every Development-environment fixture); the API called directly; a per-run secret in CI. Evidence: request-level hop tests as `learnstack_app` — no `X-Tenant-Id`, a non-hop peer, a wrong secret, a repeated header — beside the human walkthrough, with one review adding an automated browser smoke. Record the shipped startup rule; non-development hop configuration is Phase 11's | Contract: one dated [ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md) amendment if the evidence departs from the ADR's words, recording the startup rule, and carrying G34 if G34 changes the key or budget. Detail: `.env.example`, `apps/web/.env.local.example`, `appsettings.Development.json` or the demo recipe; a Standards 21 row for the hop runtime test; a Testing Standards § End-to-End Tests sentence only if ownership moves; a Phase 11 scope row | P02d-5 (the hop configuration and the fixture shape — in-process test hosts have no socket peer), P02d-7 (the CI part) | [Accepted — 2026-10-08](#p02d-5-accepted-answers): local topology, shared source and ingress | +| G33 | The server-rendering topology and its evidence. Where do Next.js and the API run relative to each other — the workstation loopback, containers, gated APISIX; which networks are trusted; how does one hop secret reach both processes; what is the server-only API origin — and the same for the CI job that renders the pages? What evidence discharges ADR-0036's "Phase 02d's browser test" and its matrix rows "a direct socket bypassing the hop" and "the Phase 02d anonymous two-host browser render", given that Testing Standards gate this phase on a human? And ADR-0036 § Consequences says the root refuses to start outside Development when the secret list is empty or short, while the shipped rule, in every mode, refuses a half-configured hop, a network entry that is not CIDR and a blank secret or one under 32 characters (characters, not bytes), and admits both lists empty: is that recorded or restored, and is non-development hop configuration this phase's or Phase 11's? | Both processes on the loopback, networks `127.0.0.1/32` and `::1/128`, one generated secret from a single source and never under `NEXT_PUBLIC_`, with networks and secrets arriving together (committing networks without secrets breaks every Development-environment fixture); the API called directly; a per-run secret in CI. Evidence: request-level hop tests as `learnstack_app` — no `X-Tenant-Id`, a non-hop peer, a wrong secret, a repeated header — beside the human walkthrough, with one review adding an automated browser smoke. Record the shipped startup rule; non-development hop configuration is Phase 11's | Contract: one dated [ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md) amendment if the evidence departs from the ADR's words, recording the startup rule, and carrying G34 if G34 changes the key or budget. Detail: `.env.example`, `apps/web/.env.local.example`, `appsettings.Development.json` or the demo recipe; a Standards 21 row for the hop runtime test; a Testing Standards § End-to-End Tests sentence only if ownership moves; a Phase 11 scope row | P02d-5 (the hop configuration and the fixture shape — in-process test hosts have no socket peer), P02d-7 (the CI part) | [Accepted — 2026-10-08](#p02d-5-accepted-answers): local topology, shared source and ingress; [Accepted — 2026-10-11](#p02d-7-decision-package-2026-10-10): shared CI lifecycle/topology; ADR-0056 measurement Accepted on the same date; implementation pending | | G34 | How does the pre-classification anonymous limiter treat a request arriving over the authenticated trusted hop? Every server-rendered call reaches the API from the renderer's peer, so every visitor of both tenants shares one partition, and one client sending random `Host` values through the renderer can starve both sites. The partition key, the budget and how the renderer derives any visitor identity it states — while unknown-host floods stay bounded before database work, and a direct peer is still limited per peer | The reviews differ: a visitor address stated over the hop, in a dedicated single-valued header or through `X-Forwarded-For` with the peer captured first, as ADR-0036 anticipates; a separate hop budget with limiting in the renderer; or an explicitly sized shared quota. A per-host ceiling as the only backstop multiplies under a random-`Host` flood | Contract: a dated ADR-0036 amendment if the hop changes the key or the budget, stating the new input's trust rule and whether it may be logged or audited; otherwise a phase-doc statement. Detail: API Standards § Request and Response Limits, [Security Standards § Rate Limiting](../standards/11-security.md#rate-limiting), the catalogue's per-peer rule. `P02b-0` re-verifies Phase 02b's G14 against the answer | P02d-5 | [Accepted — 2026-10-08](#p02d-5-accepted-answers): authenticated visitor and peer budgets | | G35 | The server SDK transport. Its options — visitor host, locale, an optional assertion, never a tenant id as selector; where the host comes from; how the API origin is configured; the server-only guard, timeouts and cancellation; and whether it forwards W3C `traceparent`. With it: which parts of [Observability Standards § Frontend Observability](../standards/10-observability.md#frontend-observability) — Next.js error capture, web vitals — ship here, and which phase owns the rest, since no phase names them | Host and locale plus an optional assertion; hop headers and origin from server configuration, never from request input; a `server-only` guard; Problem Details mapped to the `AppError` union [Error Handling Standards](../standards/09-error-handling.md) already fixes; `traceparent` forwarded; Next.js error capture and web vitals assigned to Phase 11, whose § Observability lists error tracking | Detail: Frontend Architecture Standards § SDK and the Frontend Architecture SDK sketch; a pin and licence verdict if a guard package is added; an exclusion row naming frontend observability's owner, with an Observability Standards sentence | P02d-5 (the transport's headers and configuration read); the ownership half closes by exit, because a deferral names its phase | [Accepted — 2026-10-08](#p02d-5-accepted-answers): configured caller, deadlines and observability owners | | G36 | The edge middleware and entry behaviour. Does the middleware resolve anything (with G25)? What does it carry inward, and under which header name — the Frontend Architecture sketch reuses `x-learnstack-host`, the hop header's own name? Which inbound internal headers are removed or overwritten, including `x-organization-id` when resolution has none — a deny-strip or an allowlist rebuild? What does the matcher exclude? What do `/` and a locale-less path answer, with which status, target and default-locale source; what do a disabled, malformed or non-canonical locale segment (G6 c), a platform host and an unknown host answer; does this run in middleware or the route tree, and may an i18n library own the middleware? Does this phase build the locale-less redirect [Localization Standards § URL Strategy](../standards/08-localization.md#url-strategy) requires, or keep the standards index's i18n-runtime carve-out while Phase 06 claims redirect handling? | Normalise the host; strip every client-supplied `x-tenant-id`, `x-organization-id`, `x-locale` and `x-learnstack-*`; drop the scaffold's `503` guard and TODOs; `/` redirects to the tenant's default locale; a disabled locale is a `404` before any content call; a platform or unknown host gets a `404` with no platform text; the redirect built minimally here, with Phase 06's rows reworded to "deepens" | Detail: a phase-doc statement; the Standards 07 § Tenant Resolution flowchart and the Frontend Architecture middleware sketch; Phase 06's rows in the same diff; if less is built, a dated narrowing of the standards index row for Localization Standards naming the owning phase | P02d-5 (the middleware replacement rewrites the scaffold's locale fallback, so every placement answer changes it first) | [Accepted — 2026-10-08](#p02d-5-accepted-answers): membership-first entry and redirects | | G37 | How do tenant-varying `(public)` routes render, and which Next.js caches may hold tenant data — the full-route cache, the fetch data cache, `unstable_cache`, `generateStaticParams` — so one host's page is never served on the other? A public URL carries no tenant, and both tenants send the same request line over the hop, so a path-keyed cache leaks. What freshness does a page have after a customization or content write? | All three reviews: dynamic rendering with uncached SDK fetches — no `revalidate`, no `generateStaticParams`, no `unstable_cache` — relying on the API's generation-keyed cache; no ISR here | Detail: [Frontend Architecture Standards § Public Site Renderer](../standards/07-frontend-architecture.md#public-site-renderer), which prescribes `revalidate` today, and [Frontend Architecture § Rendering Strategies](../architecture/14-frontend-architecture.md#rendering-strategies), rewritten with the `add-frontend-route` skill; [Performance Standards § Caching](../standards/15-performance.md#caching) if the answer caches; an ADR if the pass judges the Standards 07 change non-trivial | P02d-5 (one mechanism: a host-bearing rewrite target is middleware code and fetch cache options are transport code, both written before the renderer) | [Accepted — 2026-10-08](#p02d-5-accepted-answers): dynamic no-store renderer policy | -| G38 | The frontend test set. (a) Which predicates of the middleware and the server SDK does Vitest cover, and in which packages — `pnpm -r test` runs only packages with a test script, which `packages/sdk` lacks? (b) How are async Server Component pages and field components covered below the browser, given vendor guidance that Vitest does not render async Server Components? (c) What automated evidence, if any, re-proves the page-level two-host claim after exit — an HTTP smoke against `next start`, one narrow Playwright smoke pulled forward from Phase 06, or a dated manual record? (d) By what mechanism does the `frontend` job refuse skipped and todo cases, which it does not today, since `No_Architecture_Test_Is_Skippable`'s runner leg reads only the backend `.trx` files? | (a) host normalization and header stripping, locale parsing and entry answers, hop headers with no tenant selector, `not_found` mapping — each with an inversion companion; (b) async pages covered through their synchronous children; (c) the reviews split three ways; (d) a reporter-output check or a lint ban on disabled tests, failing also on a package with tests and no script, proven with a planted skip | Detail: a phase-doc statement; the test files; the Standards 21 entry `No_Architecture_Test_Is_Skippable` extended rather than a second name; `ci.yml`. Contract for (c) only if a browser smoke moves: a Testing Standards § End-to-End Tests edit citing this row, with its carriers | P02d-5 (a; d at the latest), P02d-6 (b, c), P02d-7 (c's job) | [Accepted — 2026-10-08](#p02d-5-accepted-answers): (a,d) tests and non-skippability; [Accepted — 2026-10-09](#p02d-6-accepted-answers): (b,c) synchronous tests and required production HTML/RSC product proof; P7 owns the browser/demo job | +| G38 | The frontend test set. (a) Which predicates of the middleware and the server SDK does Vitest cover, and in which packages — `pnpm -r test` runs only packages with a test script, which `packages/sdk` lacks? (b) How are async Server Component pages and field components covered below the browser, given vendor guidance that Vitest does not render async Server Components? (c) What automated evidence, if any, re-proves the page-level two-host claim after exit — an HTTP smoke against `next start`, one narrow Playwright smoke pulled forward from Phase 06, or a dated manual record? (d) By what mechanism does the `frontend` job refuse skipped and todo cases, which it does not today, since `No_Architecture_Test_Is_Skippable`'s runner leg reads only the backend `.trx` files? | (a) host normalization and header stripping, locale parsing and entry answers, hop headers with no tenant selector, `not_found` mapping — each with an inversion companion; (b) async pages covered through their synchronous children; (c) the reviews split three ways; (d) a reporter-output check or a lint ban on disabled tests, failing also on a package with tests and no script, proven with a planted skip | Detail: a phase-doc statement; the test files; the Standards 21 entry `No_Architecture_Test_Is_Skippable` extended rather than a second name; `ci.yml`. Contract for (c) only if a browser smoke moves: a Testing Standards § End-to-End Tests edit citing this row, with its carriers | P02d-5 (a; d at the latest), P02d-6 (b, c), P02d-7 (c's job) | [Accepted — 2026-10-08](#p02d-5-accepted-answers): (a,d) tests and non-skippability; [Accepted — 2026-10-09](#p02d-6-accepted-answers): (b,c) synchronous tests and required production HTML/RSC product proof; P7 owns the browser/demo job; [Accepted — 2026-10-11](#p02d-7-decision-package-2026-10-10): narrow browser-job scope; ADR-0056 measurement Accepted on the same date; implementation pending | | G39 | Is [ADR-0027](../decisions/0027-frontend-i18n.md), the frontend i18n library, Accepted in this phase rather than Phase 04, or do this phase's pages meet a library-neutral message contract under the standards index's carve-out? Where does the one UI string catalogue live — the carriers name three paths? | The reviews differ: Accept at first use (`next-intl` composed with the tenant middleware, with its pin and licence verdict), or move only the minimum slice — catalogue loading, lookup, layout locale, `lang`. A third option, no platform-authored text, is hard for a skip link or a not-found page | Contract: ADR-0027 Accepted, with the decisions index row, Phase 04's ADR-0027 lines and the standards index row in the same diff — which, under [the decisions index SLA](../decisions/README.md#open-adr-drafts), makes it an exit blocker here — or a narrowed carve-out plus a phase-doc statement. Detail: [Localization Standards § Strings in Code](../standards/08-localization.md#strings-in-code), [Localization § UI String Catalogue](../architecture/12-localization.md#ui-string-catalogue), the `add-i18n-key` and `add-frontend-route` skills | P02d-6 (the first platform string, message loading and catalogue files) | [Accepted — 2026-10-09](#p02d-6-accepted-answers): ADR-0027, exact pin and one checked UI catalogue home | | G40 | Route files, page states and site chrome. What happens to `(public)/page.tsx`'s platform placeholder, and to `/studio` and `/portal` on tenant hosts; is the `courses` segment fixed, and which phase owns localized section names? Does this phase ship `(public)` loading, error and not-found files — [Frontend Architecture Standards § Routing](../standards/07-frontend-architecture.md#routing) requires `loading.tsx` and `error.tsx` per route group, and a not-found page is this phase's own choice; how do SDK `not_found`, a cursor `validation_failed`, unavailable and `429` map to page states; what do an empty catalog and a course with no eligible lesson show; does the catalog render a next-page link? Does a minimal chrome ship? Are `hreflang`, canonical and `og:locale`, which [Localization Standards § SEO](../standards/08-localization.md#seo) requires on translated public pages, built here or carved out? | The placeholder leaves the public tree; a fixed `courses` segment with the section-name owner named; minimal state files in tenant tokens; `not_found` → an HTTP `404` page; unavailable or `429` → the route group's error page without disclosure; an explicit empty state; a next-page link over enough seeded courses; in-page links rather than chrome, keeping Phase 06's navigation rows true | Detail: a phase-doc statement; Phase 06's § What Phase 02d already shipped rows reworded; a dated carve-out in the standards index row for Frontend Architecture Standards or Localization Standards, naming the owning phase, for whatever this phase builds less of | P02d-6 | [Accepted — 2026-10-09](#p02d-6-accepted-answers): product routes, approved status/URL chain, page states and metadata | | G41 | How does the lesson page draw what the subset does not implement — an out-of-subset `x-renderer`, a missing optional field, an array of objects, a stored value whose type differs from its declaration, an `x-taxonomy` value and its missing band? And where do composite and primitive components live — Frontend Architecture Standards names `packages/blocks`, Frontend Architecture names `components/blocks/`, and the registry sits in `apps/web/src/lib/customization/`? | A safe placeholder, never an exception and never raw JSON; a missing optional field renders nothing; `integer`, `boolean` and enums as text; an `x-taxonomy` value as the band's display name in the requested locale. No review addresses the component home | Detail: Tenant Customization Model § 2 (the implemented subset, not a second list) and § 8.1; Frontend Architecture Standards § Public Site Renderer and the Frontend Architecture tree. A new primitive would be an ADR-0018 release, which this row must not assume, since Phase 04 owns the field-type set | P02d-6 | [Accepted — 2026-10-09](#p02d-6-accepted-answers): ordered plain-string components and bounded fallbacks | | G42 | How do validated branding tokens reach the server-rendered HTML — a `style` attribute on the root element, which needs `unsafe-inline` or `unsafe-hashes`; a nonce-compatible `` — never reaches a rendered document, and a refused write commits neither … | TenancyWriterTests branding refusal/atomicity, BrandingThemeTests, product theme defenses. | B, I, F | +| C28 | A branding key written as an organization-scoped `tenant_settings` row behaves as **G16** (e) records, checked request-level through a host mapped to the organization: refused … | PublicLocaleEvolutionHttpTests.Organization_branding_writer_is_refused_with_denied_audit_and_mapped_host_keeps_tenant_palette; local focused case passed; one expected Denied audit, no successful setting mutation; final I pending. | I, M | +| C29 | If **G19** includes a write-time rule, a lesson write holding a disallowed URL is refused with details naming the JSON pointer, and nothing is … | G19 Accepted text profile introduces no active URL sink or scheme write-time rule. | N | +| C30 | Whatever **G18** refuses at save — which may include a presentation entry naming a property absent from `properties`, a malformed label map or a … | TextCardPresentationTests, customization command/schema validation tests. | B | +| C31 | A course write naming a taxonomy revision or band key the tenant does not declare, including one only the other tenant declares, returns `validation_failed` … | EducationWriterTests and EducationWriterValidationTests taxonomy/band binding validation. | B, I | +| C32 | Under **G15**'s answer, `SeedRunner` either no longer announces its own tenant context or appears in ADR-0040's setter table, and a planted caller fails any … | SeederConventionTests and contextual ownership verification/caller fence with planted offenders. | B | +| C33 | With the API running, publishing a successor content type or taxonomy for one tenant through its command changes what the next read returns for … | CustomizationProjectionTests/cache suites and production presentation successor/rebinding fixture. | B, I | +| C34 | A second warm read for the same tenant and generation issues no definition-set query; a publish that rolls back after its generation bump leaves … | CustomizationProjectionTests (cache partial), composition/publication concurrency tests. | B, I | +| C35 | None of the public reads, and no definition or settings read, invokes `IJsonSchemaValidator`. | CustomizationProjectionTests and architecture read-path guards. | B, I | +| C36 | If **G23** caches settings, then after that cache is warmed in the yoga studio's default organization, a read in the sibling organization's context and … | G23 selects no cache; TenantSettingsAccessorTests/TenantSettingsTests prove ambient scoped reads/current values. | B, I, N | +| C37 | Every cache family this phase adds appears in the cheat sheet, the Observability Standards family list and the `cache.name` mapping, and none of its … | InMemoryCacheServiceTests and three named cache-family carrier lists. | B, M | +| C38 | For a request in an enabled `tr-TR`, a label authored only under `tr` resolves as the chain **G24** records, and a value missing in … | PublicEducationDisplayTests and locale-resolution/projection tests; P3/P4 fallback records. | B, I | +| C39 | The out-of-band setter guard stays green: either the setter table is unchanged, or the ADR-0040 amendment and Security Standards row land with the loader. | Out-of-band setter guards and P2/P3 approved setter decisions. | B, M | +| C40 | In one test, a course slug that exists only in tenant B returns `200` with that course on tenant B's host and `404` on … | PublicEducationHttpTests and TenantIsolationHttpTests paired positive/negative host cases. | I, M | +| C41 | In the same test, a course returns `200` at its slug in a locale it is translated into and `404` for that slug under … | PublicEducationHttpTests.Course_and_lesson_use_exact_translations_and_real_alternate_url_pairs and Missing_exact_translation_never_uses_default_content_or_advertises_disabled_alternates. | I | +| C42 | On the organization host, a sibling organization's course is absent from the catalog and its slug returns `404`, while a tenant-wide course and its … | PublicEducationHttpTests.Organization_host_never_serves_a_sibling_organization and isolation suite raw satellite cases. | I | +| C43 | A draft course is absent from the catalog, and its detail slug and every lesson requested under it answer per **G29**; a lesson of … | PublicEducationHttpTests.Course_draft_branch_foreign_and_unknown_are_uniformly_hidden and Lesson_masks_draft_parent_draft_restricted_scope_wrong_parent_and_foreign_rows. | I | +| C44 | Each of the reads without `locale` returns `400` `validation_failed` naming `locale`, never a `500` or a defaulted locale; sending a different `X-Locale` or `Accept-Language` … | PublicEducationHttpTests query/detail/unsupported-locale cases including Well_formed_unsupported_locale_is_400_and_headers_do_not_replace_explicit_locale. | I | +| C45 | A cursor value the catalog list cannot read — malformed or truncated — returns `400` `validation_failed` naming `cursor`, never `500`. Walking `nextCursor` with a … | PublicEducationHttpTests query-shape/cursor/seek cases, PublicReadProtocolTests and OpenAPI exact parameter controls. | B, I, O | +| C46 | A course's embedded lesson list returns the same order on every read, including for any sort values **G2**'s invariant permits to tie. | PublicEducationStoreTests and PublicEducationHttpTests catalog/outline seek-order cases. | I | +| C47 | A lesson whose bound `(key, schema_version)` revision cannot be resolved (a fixture removes it) answers as **G12** records — never a `500`, and never … | PublicEducationHttpTests/PublicEducationDisplayTests and product presentation fault mode. | B, I | +| C48 | A stored band that the resolved taxonomy revision does not declare yields the state **G5** records, never a `500` and never another tenant's band … | PublicEducationDisplayTests, PublicEducationHttpTests and product band-fault mode. | B, I | +| C49 | On each seed host, each shipped anonymous read returns `200` under a host-only context, and the same request on an unknown host returns the … | PublicSiteHttpTests/PublicEducationHttpTests; PublicSurfaceTests/PublicReadTransactionTests runtime refusal/positive control and structural dispatch offenders. | B, I, M | +| C50 | Accepted **G28** records `Off`; serving anonymous reads on both hosts adds no `audit_log` rows. | PublicEducationHttpTests.Catalog_is_exact_locale_host_scoped_and_only_marketing_with_no_normal_audit and PublicSiteHttpTests. | I | +| C51 | Every anonymous response — a catalog `200`, a detail `404`, a bad-cursor `400` and an unmapped-host `404` — carries the directive **G27** decides, and … | Public HTTP no-store/validator cases and PublicServerRenderingTests A/B/A product fixture. | I | +| C52 | The committed OpenAPI document lists exactly the public operations the register decides, each with `locale` required on the Education reads, and a planted extra … | OpenApiContractTests and PublicEducationHttpTests exact public operation/DTO/status/bounded-list/alternate cases. | B, I, O | +| C53 | P02d-4 proves SDK wrapper coverage of all four logical GET contracts and HTTP coverage of all eight GET/HEAD operations, with a planted missing-operation companion. … | P4 SDK missing-operation/wrapper tests and eight GET/HEAD HTTP coverage; P5 configured/P6 every logical-page consumers. | B, I, F | +| C54 | The contract suite fails when the served `/openapi/v1.json` differs from the committed snapshot, shown with a companion; the breaking-change job fails on a planted … | OpenApiContractTests served snapshot/deny-list controls; P4 pinned oasdiff policy/offender suite; SDK drift/typecheck controls. | B, F, O | +| C55 | Each activated check has no `(deferred …)` suffix in `ci.yml`, appears in CONTRIBUTING's required list and in the live required contexts, and its skip … | P1-P6 dated merge/check/protection receipts exist. | O, J | +| C56 | `Handlers_Return_Result` stays green with Education's handlers counted, and an Education handler changed to return a raw DTO fails the build (`backend`). | Handlers_Return_Result with nonempty Education handlers and raw-DTO companion. | B | +| C57 | On both seed hosts the catalog renders through the Next.js server while no server-to-API call carries `X-Tenant-Id`, each host showing only its own tenant's … | PublicServerRenderingTests/hop HTTP tests and P6 manual walkthrough; Step3 browser feasibility. | I, J, H | +| C58 | Neither the built client assets nor rendered HTML contain the hop secret value, and no `NEXT_PUBLIC_` variable carries it. Only `frontend/apps/web/src/server/configured-public-client.ts` sets hop headers … | public-boundaries.test.ts/configured-public-client.test.ts and real production client-import/build probes. | I, F | +| C59 | Client-supplied `x-tenant-id`, `x-organization-id`, `x-locale`, `x-learnstack-host` and `x-learnstack-hop-secret` never reach an SDK call's outbound headers unchanged; under the production build and mandatory native launcher, public … | public-entry/admission/runtime tests and PublicAdmissionRenderingTests. | I, F, M | +| C60 | With private hop configuration kept outside version control, ordinary no-hop API startup works under committed Development settings with no `.env` present, and every Development-environment … | TrustedHopConfigurationTests and ordinary Development fixture baseline. | B, I, M | +| C61 | A non-hop peer is still limited per socket peer, getting `429` with `Retry-After` over budget, and rotating `X-Forwarded-For` or any header **G34** introduces buys … | RateLimitingHttpTests, anonymous request identity/admission/lifecycle and unknown-host cache tests. | B, I, J | +| C62 | If **G35** places it here, a server-to-API call carries a `traceparent` whose trace id matches the API's Problem Details `traceId`. | PublicServerRenderingTests transport mode and configured-public-client.test.ts W3C propagation/refusal controls. | I, F | +| C63 | On a production build, one `(public)` path requested on host A, then B, then A returns each tenant's own markers every time; the build … | PublicServerRenderingTests.Production_rendering_is_host_isolated_fresh_and_private_through_the_real_public_API and frontend forbidden-cache offenders. | I, F | +| C64 | Each rendered public page's `` equals the locale in its path, on both locales of the bilingual tenant, and `dir` follows that locale. | P6 lang/dir product/route assertions; Step3 actual product-document checks. | I, F, J | +| C65 | Each tenant-host page has one `
`, no skipped heading level, a skip link and a descriptive `` rather than a fixed platform string, and … | P6 public-ui/route/metadata/lint-rule tests and product assertions. | I, F, H | +| C66 | Catalog to lesson works keyboard-only on both hosts with focus always visible, each page reflows at 320 CSS px, and the text and UI-component … | P6 manual record on 180dde0: Brave154.1.96.61/macOS27.0.1, keyboard English/Turkish flow, visible focus, 320CSSpx desktop reflow, calculated palettes; maintainer VoiceOver confirms both flows. PR27 description preserves attribution and links original manual evidence. | H | +| C67 | On the two-locale tenant, every visible platform-authored string resolves through the message layer **G39** records, in both locales; Localization Standards § Strings in Code, … | ADR-0027 i18n catalogue/ICU controls and WEB messages. | F, M | +| C68 | Tenant B's hidden course URL on host A returns a local HTTP `307` to the fixed `/{locale}/status/not-found` URL, followed by visible localized HTTP `404` … | public-route/resource/admission tests; PublicServerRenderingTests/PublicAdmissionRenderingTests fixed redirect/status and fault modes. | I, F | +| C69 | A page whose course or lesson carries a band the resolved revision does not declare renders the state **G5** records, without throwing and without … | P6 UI and product band-fault tests. | I, F | +| C70 | Field values containing `<script>`, an `<img onerror>` fragment and a `javascript:` URL render inert, and a `data:` or `http:` URL is rendered only if … | P6 inert plain-text/fallback renderer tests, shared ordered-field predicate and product presentation fault modes. | I, F | +| C71 | A branding token value containing `;`, `}`, `url(` or `</style>` never reaches rendered HTML, and the theme is emitted only as `--ls-*` custom properties … | Theme and public-boundaries tests; real production fixture and Step3 network/product checks. | I, F, J | +| C72 | A non-branding setting present for a tenant (the fixture's `tz` row) appears in no `[PublicSurface]` response body and in no rendered HTML, on either … | PublicSiteHttpTests/PublicEducationHttpTests and product rendering privacy checks. | I | +| C73 | A change that adds `it.skip`, `describe.skip` or `it.todo` to any Vitest file under `frontend/` fails the required `frontend` check, and each predicate in **G38**'s … | Guarded frontend runner planted controls; P5/P6 product predicates; Step3 browser hard admission. | I, F, J, M | +| C74 | No production code branches on which tenant it serves. The mechanism is **G20**'s: registered in the catalogue, Implemented and green in a required check … | Step2 DomainGenericityTests.DemoIdentities.cs, demo-genericity.test.ts, Seeder-reference fence and production presentation successor companion. | B, I, F | +| C75 | No Education type, member, column, key or DTO name carries a forbidden domain term, and Education references Customization only through its `Application.Contracts` assembly (`backend`). | DomainGenericityTests and module-dependency guards. | B | +| C76 | If **G44** activates it in this phase, the Lighthouse job runs real steps under a name without "(deferred to Phase 02d)", audits the URL … | Step3 complete local nineURL x threeRun feasibility, report/gzip/admission/offender controls and zero429 positive observer. | J | +| C77 | Every register row is closed in the form **G1** chose; every Deliverable is observed by at least one criterion; no catalogue row whose Phase … | Four P7 behavioral entries are Implemented with Node owners, planted controls and the always-running public-demo job; CorpusConsistencyTests enforces status/count alignment. | B, I, F, J, M | +| C78 | Every row in § Explicitly not in this phase names a phase whose own document carries that capability in its scope, its deliverables or … | Owner audit X01-X39 below; current output/seed-host carriers partly stale. | M | +| C79 | The documentation Deliverables are in the tree at exit: the Education spec's `README.md` and `permissions.md` beside `audit.md`, with its state diagram and the eligibility … | Existing Education/Customization/Testing/schema/skill files and Phase05 preservation baseline. | M | + +C07 is conditional: G32 retains `*.learnstack.local`, so no seed-host migration +is required. C29 is conditional: P6 introduces no authored URL/media sink; +Phase 04/05/06 retain the named future contracts. C36 is conditional: G23 selects +uncached settings, so no settings-cache eviction rule is claimed. Other named +rows retain their full canonical conditions even when a locator is abbreviated. + +The two new `PublicLocaleEvolutionHttpTests` cases passed locally with zero skips. +They use the normal API-root pipeline and actual mapped-host HTTP, under +`learnstack_app`, physical READ ONLY and non-BYPASSRLS. French-only new roots are +authored while Draft; published seeded roots are not edited to create the proof. +All five migration histories and complete pre-existing EN/TR DTOs remain equal. +The organization writer refusal commits exactly its expected Denied audit; +public reads remain audit Off. Final receipt I must include both cases. + +## Exclusions and future owners + +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. + +| 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. | +| 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. | +| X08 | CMS editing, page builder, media library and tenant media origins | [Phase 04](phase-04-cms-media-pages.md) | Phase 04 Media security owns the tenant media-origin allow-policy, active URL sinks and corresponding delivery/validation criteria. | +| X09 | Per-locale publish readiness workflow, `tenant_route_slugs` registry | [Phase 04](phase-04-cms-media-pages.md) | Phase04 Page Model/Localization and tenant_route_slugs deliverable. | +| X10 | Studio authoring of translated field values (titles, bodies, slugs) and the view of untranslated gaps — where it lives is an open question | [Phase 04](phase-04-cms-media-pages.md#admin-studio-cms-screens); its screen row goes in [Phase 06 § Admin Studio — screen ownership](phase-06-renderer-admin-studio.md#admin-studio--screen-ownership) | Phase04 Admin Studio CMS Screens explicitly registers question of translated-field/gap UI placement and points to Phase06 screen ownership table. | +| X11 | `Accept-Language` negotiation for API-returned messages; the per-tenant locale fallback chain; the `/{locale}/{slug}` page routing shape | [Phase 04](phase-04-cms-media-pages.md) | Phase04 Localization scope explicitly lists per-tenant fallback, /{locale}/{slug}, publish readiness and Accept-Language API-message negotiation. | +| X12 | Authoring and translating a content type's field order and labels; the closed set of built-in primitive field types (number, boolean, date/time, select) | [Phase 04](phase-04-cms-media-pages.md) | Phase 04 Content Type System explicitly owns field order and localized label authoring; built-in field types remain a platform release boundary. | +| X13 | The schema-version migration path for stored instances; deleting a content-type revision after a zero-instance count | [Phase 04](phase-04-cms-media-pages.md) | Phase04 exact revision deletion requires zero-instance count; lazy/bulk schema migration explicit. | +| X14 | Customization deprecate and revise commands, and per-band taxonomy editing | [Phase 04](phase-04-cms-media-pages.md) and [Phase 05](phase-05-education-learning-content.md), consolidated in [Phase 06](phase-06-renderer-admin-studio.md) | Phase 04 owns deprecate/revise commands; Phase 05 owns per-band taxonomy editing, with Phase 06 consolidating Studio screens. | +| X15 | Tenant-authored page composition (`TenantPageBlock` rows and their cache family) | [Phase 04](phase-04-cms-media-pages.md); the block resolver is [Phase 06](phase-06-renderer-admin-studio.md)'s | Phase04 TenantPageBlock aggregate/migration/editor/renderer deliverable; Phase06 complete block resolver. | +| X16 | Deciding whether a customization change owes an integration event | [Phase 04](phase-04-cms-media-pages.md), per [the Customization spec § Integration-event catalogue](../modules/customization/README.md#integration-event-catalogue) | Phase04 Content Type System explicitly owns whether customization change owes integration event. | +| X17 | The `Redirect` model the CMS auto-creates when a published slug changes | [Phase 04](phase-04-cms-media-pages.md) | Phase04 Page Model/Localization Redirect auto-creation and shared namespace. | +| X18 | Search — the `ITenantSearch` port and its PostgreSQL default | [Phase 04](phase-04-cms-media-pages.md) | Phase04 Search port/PostgreSQL default section. | +| X19 | Search — the Meilisearch adapter behind that port | [Phase 09](phase-09-billing-integrations-analytics.md) | Phase09 Search/Meilisearch adapter deliverables and trigger. | +| X20 | Course versioning, programs, lesson items, completion rules; richer publish-readiness validation and versioned publication; catalog visibility separate from publication | [Phase 05](phase-05-education-learning-content.md) | Phase05 Catalog/Course versioning/lesson items/completion/runtime scope. | +| X21 | The migration that brings this phase's courses and lessons under course versions and modules | [Phase 05](phase-05-education-learning-content.md) | Phase05 What Phase02d supplies explicitly preserves existing rows/IDs/URLs/order/scope/translated bodies/exact pins/access policy in migration; re-seeding forbidden. | +| X22 | The authenticated course and lesson authoring surface, with the commands that edit a course or lesson or reorder lessons after the seed | [Phase 05](phase-05-education-learning-content.md) | Phase 05 owns authenticated post-seed edit/reorder/unpublish/delete lifecycle and its decisions; no such writer is claimed by P7. | +| X23 | The `Level` aggregate and the catalog API's levels resource; the `TenantLevelTaxonomy` editor | [Phase 05](phase-05-education-learning-content.md) | Phase05 Catalog Level/resource inheritance and Studio TenantLevelTaxonomy editor explicit. | +| X24 | The `TenantLessonItemType` read, the batched reference walk and the measured customization cost model; the `embed-html` sanitisation contract | [Phase 05](phase-05-education-learning-content.md) | Phase05 inherited TenantLessonItemType read, batched reference/cost model and embed-html contract. | +| X25 | Admin Studio, navigation menus, SEO metadata beyond what G40 settles for translated public pages, full block registry | [Phase 06](phase-06-renderer-admin-studio.md) | Phase06 renderer/Studio/block registry/navigation/full SEO scope. | +| X26 | The full `UnknownVersionBlock` / `UnknownBlock` placeholders and per-block error boundary; tenant-authored error pages; redirect handling | [Phase 06](phase-06-renderer-admin-studio.md) | Phase06 renderer explicit UnknownVersionBlock/UnknownBlock/per-block boundary/tenant404/redirects. | +| X27 | Branding configuration surface — tenant-admin editor, theme preview, logo, header and footer settings; the per-organization branding override (`OrganizationBranding`) and the token merge | [Phase 06](phase-06-renderer-admin-studio.md) | Phase06 Tenant branding/own Studio work explicit logo/header/footer/preview/OrganizationBranding merge. | +| X28 | Preview of draft courses and lessons through the production renderer | [Phase 06](phase-06-renderer-admin-studio.md) | Phase06 own Studio work explicitly preview published/draft through production renderer. | +| X29 | Browser end-to-end flows (Playwright) and axe checks through Playwright | [Phase 06](phase-06-renderer-admin-studio.md), per [Testing Standards § End-to-End Tests](../standards/06-testing.md#end-to-end-tests); any narrow smoke this phase adopts is G38 | Phase 06 owns full Playwright Test/axe flows; the narrow P7 Playwright-library browser and Lighthouse proof is explicitly separated. | +| X30 | Enrollment, learner portal, progress tracking; Course Access — whether content published anonymously here stays on the public path once enrollment exists | [Phase 07](phase-07-enrollment-learner-portal.md) | Phase07 enrollment/portal/progress/CourseAccess and public-path-vs-enrollment boundary explicit. | +| X31 | Live classroom | [Phase 08c](phase-08c-classroom.md) | Phase08c Live classroom/ILiveClassProvider/LiveKit deliverables. | +| X32 | Billing | [Phase 09](phase-09-billing-integrations-analytics.md) | Phase09 billing scope/deliverables. | +| X33 | Hub, entitlement gating | [Phase 02c](phase-02c-hub-foundation.md) | Phase02c Hub-backed entitlement/provider/enforcement scope. | +| X34 | APISIX as the edge gateway for a non-development deployment; gateway or CDN caching; edge rate limiting | [Phase 11](phase-11-production-hardening.md#security), on [ADR-0035](../decisions/0035-demand-gated-infrastructure.md)'s trigger | Phase 11 explicitly owns gateway/CDN response caching, authority keys, invalidation, private-content exclusion and its demand trigger. | +| X35 | Plan-differentiated API rate limits (`LimitKeys.ApiRatePerMinute`) | [Phase 11](phase-11-production-hardening.md#security) for the key's gate; [Phase 02c](phase-02c-hub-foundation.md) for its enforcement path | Phase11 Security explicitly owns LimitKeys.ApiRatePerMinute gate and links Phase02c generic enforcement path. | +| X36 | The L2 cache tier behind `ICacheService` (default `InMemoryCacheService`) | [Phase 11](phase-11-production-hardening.md), on [ADR-0035](../decisions/0035-demand-gated-infrastructure.md)'s trigger | Phase11 explicit Valkey-backed ICacheService/L1-L2 trigger. | +| X37 | Content-Security-Policy and the other secure headers | [Phase 11 § Security](phase-11-production-hardening.md#security), per the [standards index](../standards/README.md#honest-status-today) row for Security Standards | Phase11 Security explicit secure headers/CSP. | +| X38 | `GET /readyz`; the production Dockerfile and image pipeline; the performance baseline and load tests against Performance Standards | [Phase 11](phase-11-production-hardening.md) | Phase11 readiness/deployment images/performance/load-test scope/deliverables. | +| X39 | Custom-domain TLS automation | [Phase 11](phase-11-production-hardening.md), on [ADR-0035](../decisions/0035-demand-gated-infrastructure.md)'s trigger | Phase11 explicit custom-domain TLS ACME/DNS/renewal trigger. | + +## Phase-exit reconciliation + +| ID | Obligation | Required evidence | +|---|---|---| +| E01 | Every Completion Criterion holds with named/manual evidence | All C01-C79 rows, the two new combined HTTP/pipeline cases; actual B/I/F/O/J/H receipts linked at exact final head where applicable. | +| E02 | Every register row closed through G1 vehicle and carriers, including G32 development transport | Dated closure/link census; Accepted ADRs retained, implementation receipts distinct from decisions; Phase02b native HTTPS carriers reconciled. | +| E03 | Phase02a and Education schema/request isolation green as app role, zero skips, required | Exact final backend/backend-integration TRX/rollup, role/isolation evidence and skip guard. | +| E04 | OpenAPI/SDK/Lighthouse required and shown able to fail; live list/date; no deferred job names | O/J offender controls, successful final real PR rollup, dated seven-context strict protection read-back, all other settings preserved. | +| E05 | Clean make demo prints both working addresses; sites render with Keycloak stopped | J clean lifecycle/readiness/Keycloak status/browser/artifact/cleanup receipt; H human context retained. | +| 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. | + +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. + +A positive phase-exit verdict requires the final receipts and both Step 4 review +rounds above. Maintainer PR review and merge are separate actions. diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 8413caa9..043ca445 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -15,7 +15,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 | decision pass Accepted — 2026-10-11; [decision package](#p02d-7-decision-package-2026-10-10); Steps 1–3 complete; [Step 3 validation](#p02d-7-step-3--browser-and-audit-proof-2026-10-11); Step 4 pending | +> | P02d-7 | Demo, full-stack CI and exit | decision pass Accepted — 2026-10-11; [decision package](#p02d-7-decision-package-2026-10-10); Steps 1–3 complete; [Step 4](#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11) in progress | **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 @@ -433,7 +433,7 @@ premise a row cites is re-verified at that pass rather than trusted. | G17 | Does `TenantSetting.Value` carry `[PiiSensitive]`? [Phase 03](phase-03-identity-admin.md) sequences the decision before the first command writing `tenant_settings`, and this phase ships that command | Not marked, provided `tenancy.setting.write` admits only G16's closed key set, so the answer cannot stretch to keys a tenant invents; modelling a sensitive part as its own property stays open to Phase 03 | Contract: a dated phase-doc statement, reflected in `TenantSetting.cs`, the Tenancy spec and `audit.md`. Whole-value redaction of `jsonb` is settled by [ADR-0044](../decisions/0044-audit-write-path.md) Amendment 4 § 1 | P02d-2 (the first MUST-class settings audit row is written by the seed, and rows cannot be redacted retroactively); closes with G16 (a) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): generic whole-value PII redaction before the writer | | G18 | How is a tenant content type presented? `json_schema` is `jsonb`, which keeps no key order, and the schema profile collects only `x-renderer`, `x-taxonomy` and `x-language`. How are field order, a label per enabled locale and a composite's field roles carried; which registered composite draws a lesson for each seeded type; which primitives does this phase implement, and does `markdown` render; how do types with no primitive row (`integer`, `number`, `boolean`, enums) map; may a rendered type declare a field outside the subset; and is a presentation entry naming a missing property refused at save? | A LearnStack extension — `x-order` and `x-label`, or one ordered `x-fields` list — carrying Pattern B labels, resolved at write like `x-taxonomy`; one composite already in both registries; the reviews split on the subset — `text`, `list` and `link`, with `markdown` without raw HTML, or a placeholder until Phase 05's sanitiser; the seed uses only the subset | Contract: a dated ADR-0043 amendment for a keyword or a save-time refusal; a dated ADR-0018 amendment for a presentation column; a phase-doc statement for `title` plus `required`, which cannot carry two locales. Detail: [Tenant Customization Model § 2](../architecture/32-tenant-customization-model.md) and § 8.1, the Customization spec, the profile's extension and reference-graph skip lists, `composites.ts` | P02d-2 (the seed publishes both content types as `schema_version` 1 with their renderer keys and field kinds; a later answer needs successor revisions) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): ADR-0051 profile and first-render subset. Component placement/fallback remains G41 | | G19 | URL and markup policy for tenant-authored values on an anonymous page: which schemes (`https` only, or `http` too), credentials and `target`, which media origins, whether the rule is enforced on write — in the Education command, or as a validation gate Phase 04's entries share — whether the public API filters too, and whether URLs inside markdown fall under it. The write-time check constrains structure, not schemes: `format: uri` admits `javascript:` and `data:` | The reviews split on `http`; all refuse `javascript:`, dangerous `data:` and credentials; checked on write by a LearnStack rule and again on render; no third-party media in the seed | Detail: one home for the scheme list — [Security Standards § XSS & Output Encoding](../standards/11-security.md#xss--output-encoding) or [Frontend Architecture Standards § Security](../standards/07-frontend-architecture.md#security), not both; the Education spec's write rules; Tenant Customization Model § 8.1 if checked on write. Contract: a dated ADR-0043 amendment if it becomes a shared validation gate | P02d-2 (the lesson command's validation and the seed values; the render-time check reuses the answer) | [Accepted — 2026-10-02](#p02d-2-accepted-answers): no active sink in the seeded text profile; future URL/markup contracts precede Phase 04/05 sinks | -| 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; enforcement remains pending | +| 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 | @@ -446,19 +446,19 @@ premise a row cites is re-verified at that pass rather than trusted. | G30 | The locale error matrix and transport. On each read, what answers an empty, repeated, malformed, over-length, non-canonical, not-enabled or enabled-but-untranslated locale? Does a not-enabled locale answer the Active `unsupported_locale` `400` or the not-found body? Is `X-Locale`, which [Frontend Architecture Standards § Locale Resolution](../standards/07-frontend-architecture.md#locale-resolution) still names as the API carrier, withdrawn, so that locale reaches the API only as the query parameter? | Missing or malformed → `400` `validation_failed` naming `locale`; untranslated → an empty catalog page; the query parameter only. The reviews split on not-enabled — not-found, amending the Error Handling row, or the Active `400`; a uniform answer after the enabled check hides pre-launch rows under either | Detail: a phase-doc statement; the [Error Handling Standards](../standards/09-error-handling.md) table only if not-found; Standards 07 § Locale Resolution; the Frontend Architecture SDK sketch; the API Standards § Pagination example gains `locale` | P02d-4 (validators, OpenAPI parameters and responses), P02d-5 (the server SDK's header set) | [Accepted — 2026-10-03](#p02d-4-accepted-answers): query/error matrix; trusted server header transport remains P02d-5; [Accepted — 2026-10-08](#p02d-5-accepted-answers): header transport; prior API query-locale answer retained | | G31 | Contract checks and the SDK surface. The committed OpenAPI snapshot's path, and how the contract suite proves it equals the served document; how the base copy is read; how the first run behaves; which `oasdiff` version and fail level, and which ADR-0024 rows that level detects (a tightened validator or a changed status may be invisible to any diff); what is uploaded on failure. The drift gate's source — the committed snapshot through `LEARNSTACK_OPENAPI`, or a running API — and its job. Whether an activated deferred job keeps its `if: vars.ENABLE_*` condition, given that GitHub treats a skipped required job as passing, or loses it as the integration job's did. And what the SDK surface becomes when regeneration makes `paths` non-empty: a hand-written transport over `paths` or a typed client library, the fate of `createClientSdk`, and whether the package root keeps re-exporting the server entry | A committed snapshot the contract suite asserts equal, diffed against the base ref's copy with `oasdiff` pinned; drift generated from the snapshot in the required `frontend` job; the condition removed on activation; a thin hand-written transport, and no root re-export of the server entry | Detail: [Testing Standards § API Contract Tests](../standards/06-testing.md#api-contract-tests), the API Standards § OpenAPI links, `ci.yml`, [CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md#branch-protection-settings-on-main) (its activation procedure follows the answer), [Frontend Architecture Standards § SDK](../standards/07-frontend-architecture.md#sdk); a package pin with its licence verdict if a client library is chosen | P02d-4 (the first operation, its snapshot and assertion, the drift gate, and the regenerated types the factories must compile against) | [Accepted — 2026-10-03](#p02d-4-accepted-answers): snapshot, SDK, diff/drift and approved required-check activation | | G32 | **The development-transport part of [Phase 02b](phase-02b-events-auth.md#the-decision-register)'s G12.** Which development hostnames and transport serve the two seed tenants — keep `*.learnstack.local`, with a hosts-file step and local TLS with a trust step, or move the seed hosts under `*.localhost`? What does a reviewer do between a clean checkout and both sites, and which carriers move, including host rows already on warm databases, where the host is the primary key and the seeder removes no mapping? | `*.localhost` over HTTP, provided the pass verifies in each browser the team uses and in CI's Chrome that both hosts resolve with no hosts entry and that a `Secure` cookie set on them is stored and returned; otherwise `*.learnstack.local` with local TLS and a named trust step. Tenants are never told apart by port: the effective host strips it | Detail: a dated phase-doc statement in [§ Host-based tenant resolution, end to end](#host-based-tenant-resolution-end-to-end), with `SeedData`, `scripts/seed.sh`, the README Quickstart, the `seed-tenant` and `local-dev-setup` skills and `apps/web`'s dev script and Next configuration in the same packet; Infrastructure Standards only if a TLS proxy publishes a port. No ADR | P02d-5 (the development transport, and the hop configuration a TLS proxy would change), or the first earlier packet that writes a seed-host literal outside `SeedData` | [Accepted — 2026-10-08](#p02d-5-accepted-answers): local hosts and TLS | -| G33 | The server-rendering topology and its evidence. Where do Next.js and the API run relative to each other — the workstation loopback, containers, gated APISIX; which networks are trusted; how does one hop secret reach both processes; what is the server-only API origin — and the same for the CI job that renders the pages? What evidence discharges ADR-0036's "Phase 02d's browser test" and its matrix rows "a direct socket bypassing the hop" and "the Phase 02d anonymous two-host browser render", given that Testing Standards gate this phase on a human? And ADR-0036 § Consequences says the root refuses to start outside Development when the secret list is empty or short, while the shipped rule, in every mode, refuses a half-configured hop, a network entry that is not CIDR and a blank secret or one under 32 characters (characters, not bytes), and admits both lists empty: is that recorded or restored, and is non-development hop configuration this phase's or Phase 11's? | Both processes on the loopback, networks `127.0.0.1/32` and `::1/128`, one generated secret from a single source and never under `NEXT_PUBLIC_`, with networks and secrets arriving together (committing networks without secrets breaks every Development-environment fixture); the API called directly; a per-run secret in CI. Evidence: request-level hop tests as `learnstack_app` — no `X-Tenant-Id`, a non-hop peer, a wrong secret, a repeated header — beside the human walkthrough, with one review adding an automated browser smoke. Record the shipped startup rule; non-development hop configuration is Phase 11's | Contract: one dated [ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md) amendment if the evidence departs from the ADR's words, recording the startup rule, and carrying G34 if G34 changes the key or budget. Detail: `.env.example`, `apps/web/.env.local.example`, `appsettings.Development.json` or the demo recipe; a Standards 21 row for the hop runtime test; a Testing Standards § End-to-End Tests sentence only if ownership moves; a Phase 11 scope row | P02d-5 (the hop configuration and the fixture shape — in-process test hosts have no socket peer), P02d-7 (the CI part) | [Accepted — 2026-10-08](#p02d-5-accepted-answers): local topology, shared source and ingress; [Accepted — 2026-10-11](#p02d-7-decision-package-2026-10-10): shared CI lifecycle/topology; ADR-0056 measurement Accepted on the same date; implementation pending | +| G33 | The server-rendering topology and its evidence. Where do Next.js and the API run relative to each other — the workstation loopback, containers, gated APISIX; which networks are trusted; how does one hop secret reach both processes; what is the server-only API origin — and the same for the CI job that renders the pages? What evidence discharges ADR-0036's "Phase 02d's browser test" and its matrix rows "a direct socket bypassing the hop" and "the Phase 02d anonymous two-host browser render", given that Testing Standards gate this phase on a human? And ADR-0036 § Consequences says the root refuses to start outside Development when the secret list is empty or short, while the shipped rule, in every mode, refuses a half-configured hop, a network entry that is not CIDR and a blank secret or one under 32 characters (characters, not bytes), and admits both lists empty: is that recorded or restored, and is non-development hop configuration this phase's or Phase 11's? | Both processes on the loopback, networks `127.0.0.1/32` and `::1/128`, one generated secret from a single source and never under `NEXT_PUBLIC_`, with networks and secrets arriving together (committing networks without secrets breaks every Development-environment fixture); the API called directly; a per-run secret in CI. Evidence: request-level hop tests as `learnstack_app` — no `X-Tenant-Id`, a non-hop peer, a wrong secret, a repeated header — beside the human walkthrough, with one review adding an automated browser smoke. Record the shipped startup rule; non-development hop configuration is Phase 11's | Contract: one dated [ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md) amendment if the evidence departs from the ADR's words, recording the startup rule, and carrying G34 if G34 changes the key or budget. Detail: `.env.example`, `apps/web/.env.local.example`, `appsettings.Development.json` or the demo recipe; a Standards 21 row for the hop runtime test; a Testing Standards § End-to-End Tests sentence only if ownership moves; a Phase 11 scope row | P02d-5 (the hop configuration and the fixture shape — in-process test hosts have no socket peer), P02d-7 (the CI part) | [Accepted — 2026-10-08](#p02d-5-accepted-answers): local topology, shared source and ingress; [Accepted — 2026-10-11](#p02d-7-decision-package-2026-10-10): shared CI lifecycle/topology; ADR-0056 measurement Accepted on the same date; Step 3 local proof passes; Step 4 owns real CI receipts | | G34 | How does the pre-classification anonymous limiter treat a request arriving over the authenticated trusted hop? Every server-rendered call reaches the API from the renderer's peer, so every visitor of both tenants shares one partition, and one client sending random `Host` values through the renderer can starve both sites. The partition key, the budget and how the renderer derives any visitor identity it states — while unknown-host floods stay bounded before database work, and a direct peer is still limited per peer | The reviews differ: a visitor address stated over the hop, in a dedicated single-valued header or through `X-Forwarded-For` with the peer captured first, as ADR-0036 anticipates; a separate hop budget with limiting in the renderer; or an explicitly sized shared quota. A per-host ceiling as the only backstop multiplies under a random-`Host` flood | Contract: a dated ADR-0036 amendment if the hop changes the key or the budget, stating the new input's trust rule and whether it may be logged or audited; otherwise a phase-doc statement. Detail: API Standards § Request and Response Limits, [Security Standards § Rate Limiting](../standards/11-security.md#rate-limiting), the catalogue's per-peer rule. `P02b-0` re-verifies Phase 02b's G14 against the answer | P02d-5 | [Accepted — 2026-10-08](#p02d-5-accepted-answers): authenticated visitor and peer budgets | | G35 | The server SDK transport. Its options — visitor host, locale, an optional assertion, never a tenant id as selector; where the host comes from; how the API origin is configured; the server-only guard, timeouts and cancellation; and whether it forwards W3C `traceparent`. With it: which parts of [Observability Standards § Frontend Observability](../standards/10-observability.md#frontend-observability) — Next.js error capture, web vitals — ship here, and which phase owns the rest, since no phase names them | Host and locale plus an optional assertion; hop headers and origin from server configuration, never from request input; a `server-only` guard; Problem Details mapped to the `AppError` union [Error Handling Standards](../standards/09-error-handling.md) already fixes; `traceparent` forwarded; Next.js error capture and web vitals assigned to Phase 11, whose § Observability lists error tracking | Detail: Frontend Architecture Standards § SDK and the Frontend Architecture SDK sketch; a pin and licence verdict if a guard package is added; an exclusion row naming frontend observability's owner, with an Observability Standards sentence | P02d-5 (the transport's headers and configuration read); the ownership half closes by exit, because a deferral names its phase | [Accepted — 2026-10-08](#p02d-5-accepted-answers): configured caller, deadlines and observability owners | | G36 | The edge middleware and entry behaviour. Does the middleware resolve anything (with G25)? What does it carry inward, and under which header name — the Frontend Architecture sketch reuses `x-learnstack-host`, the hop header's own name? Which inbound internal headers are removed or overwritten, including `x-organization-id` when resolution has none — a deny-strip or an allowlist rebuild? What does the matcher exclude? What do `/` and a locale-less path answer, with which status, target and default-locale source; what do a disabled, malformed or non-canonical locale segment (G6 c), a platform host and an unknown host answer; does this run in middleware or the route tree, and may an i18n library own the middleware? Does this phase build the locale-less redirect [Localization Standards § URL Strategy](../standards/08-localization.md#url-strategy) requires, or keep the standards index's i18n-runtime carve-out while Phase 06 claims redirect handling? | Normalise the host; strip every client-supplied `x-tenant-id`, `x-organization-id`, `x-locale` and `x-learnstack-*`; drop the scaffold's `503` guard and TODOs; `/` redirects to the tenant's default locale; a disabled locale is a `404` before any content call; a platform or unknown host gets a `404` with no platform text; the redirect built minimally here, with Phase 06's rows reworded to "deepens" | Detail: a phase-doc statement; the Standards 07 § Tenant Resolution flowchart and the Frontend Architecture middleware sketch; Phase 06's rows in the same diff; if less is built, a dated narrowing of the standards index row for Localization Standards naming the owning phase | P02d-5 (the middleware replacement rewrites the scaffold's locale fallback, so every placement answer changes it first) | [Accepted — 2026-10-08](#p02d-5-accepted-answers): membership-first entry and redirects | | G37 | How do tenant-varying `(public)` routes render, and which Next.js caches may hold tenant data — the full-route cache, the fetch data cache, `unstable_cache`, `generateStaticParams` — so one host's page is never served on the other? A public URL carries no tenant, and both tenants send the same request line over the hop, so a path-keyed cache leaks. What freshness does a page have after a customization or content write? | All three reviews: dynamic rendering with uncached SDK fetches — no `revalidate`, no `generateStaticParams`, no `unstable_cache` — relying on the API's generation-keyed cache; no ISR here | Detail: [Frontend Architecture Standards § Public Site Renderer](../standards/07-frontend-architecture.md#public-site-renderer), which prescribes `revalidate` today, and [Frontend Architecture § Rendering Strategies](../architecture/14-frontend-architecture.md#rendering-strategies), rewritten with the `add-frontend-route` skill; [Performance Standards § Caching](../standards/15-performance.md#caching) if the answer caches; an ADR if the pass judges the Standards 07 change non-trivial | P02d-5 (one mechanism: a host-bearing rewrite target is middleware code and fetch cache options are transport code, both written before the renderer) | [Accepted — 2026-10-08](#p02d-5-accepted-answers): dynamic no-store renderer policy | -| G38 | The frontend test set. (a) Which predicates of the middleware and the server SDK does Vitest cover, and in which packages — `pnpm -r test` runs only packages with a test script, which `packages/sdk` lacks? (b) How are async Server Component pages and field components covered below the browser, given vendor guidance that Vitest does not render async Server Components? (c) What automated evidence, if any, re-proves the page-level two-host claim after exit — an HTTP smoke against `next start`, one narrow Playwright smoke pulled forward from Phase 06, or a dated manual record? (d) By what mechanism does the `frontend` job refuse skipped and todo cases, which it does not today, since `No_Architecture_Test_Is_Skippable`'s runner leg reads only the backend `.trx` files? | (a) host normalization and header stripping, locale parsing and entry answers, hop headers with no tenant selector, `not_found` mapping — each with an inversion companion; (b) async pages covered through their synchronous children; (c) the reviews split three ways; (d) a reporter-output check or a lint ban on disabled tests, failing also on a package with tests and no script, proven with a planted skip | Detail: a phase-doc statement; the test files; the Standards 21 entry `No_Architecture_Test_Is_Skippable` extended rather than a second name; `ci.yml`. Contract for (c) only if a browser smoke moves: a Testing Standards § End-to-End Tests edit citing this row, with its carriers | P02d-5 (a; d at the latest), P02d-6 (b, c), P02d-7 (c's job) | [Accepted — 2026-10-08](#p02d-5-accepted-answers): (a,d) tests and non-skippability; [Accepted — 2026-10-09](#p02d-6-accepted-answers): (b,c) synchronous tests and required production HTML/RSC product proof; P7 owns the browser/demo job; [Accepted — 2026-10-11](#p02d-7-decision-package-2026-10-10): narrow browser-job scope; ADR-0056 measurement Accepted on the same date; implementation pending | +| G38 | The frontend test set. (a) Which predicates of the middleware and the server SDK does Vitest cover, and in which packages — `pnpm -r test` runs only packages with a test script, which `packages/sdk` lacks? (b) How are async Server Component pages and field components covered below the browser, given vendor guidance that Vitest does not render async Server Components? (c) What automated evidence, if any, re-proves the page-level two-host claim after exit — an HTTP smoke against `next start`, one narrow Playwright smoke pulled forward from Phase 06, or a dated manual record? (d) By what mechanism does the `frontend` job refuse skipped and todo cases, which it does not today, since `No_Architecture_Test_Is_Skippable`'s runner leg reads only the backend `.trx` files? | (a) host normalization and header stripping, locale parsing and entry answers, hop headers with no tenant selector, `not_found` mapping — each with an inversion companion; (b) async pages covered through their synchronous children; (c) the reviews split three ways; (d) a reporter-output check or a lint ban on disabled tests, failing also on a package with tests and no script, proven with a planted skip | Detail: a phase-doc statement; the test files; the Standards 21 entry `No_Architecture_Test_Is_Skippable` extended rather than a second name; `ci.yml`. Contract for (c) only if a browser smoke moves: a Testing Standards § End-to-End Tests edit citing this row, with its carriers | P02d-5 (a; d at the latest), P02d-6 (b, c), P02d-7 (c's job) | [Accepted — 2026-10-08](#p02d-5-accepted-answers): (a,d) tests and non-skippability; [Accepted — 2026-10-09](#p02d-6-accepted-answers): (b,c) synchronous tests and required production HTML/RSC product proof; P7 owns the browser/demo job; [Accepted — 2026-10-11](#p02d-7-decision-package-2026-10-10): narrow browser-job scope; ADR-0056 measurement Accepted on the same date; browser proof delivered in Step 3; CI receipts owned by Step 4 | | G39 | Is [ADR-0027](../decisions/0027-frontend-i18n.md), the frontend i18n library, Accepted in this phase rather than Phase 04, or do this phase's pages meet a library-neutral message contract under the standards index's carve-out? Where does the one UI string catalogue live — the carriers name three paths? | The reviews differ: Accept at first use (`next-intl` composed with the tenant middleware, with its pin and licence verdict), or move only the minimum slice — catalogue loading, lookup, layout locale, `lang`. A third option, no platform-authored text, is hard for a skip link or a not-found page | Contract: ADR-0027 Accepted, with the decisions index row, Phase 04's ADR-0027 lines and the standards index row in the same diff — which, under [the decisions index SLA](../decisions/README.md#open-adr-drafts), makes it an exit blocker here — or a narrowed carve-out plus a phase-doc statement. Detail: [Localization Standards § Strings in Code](../standards/08-localization.md#strings-in-code), [Localization § UI String Catalogue](../architecture/12-localization.md#ui-string-catalogue), the `add-i18n-key` and `add-frontend-route` skills | P02d-6 (the first platform string, message loading and catalogue files) | [Accepted — 2026-10-09](#p02d-6-accepted-answers): ADR-0027, exact pin and one checked UI catalogue home | | G40 | Route files, page states and site chrome. What happens to `(public)/page.tsx`'s platform placeholder, and to `/studio` and `/portal` on tenant hosts; is the `courses` segment fixed, and which phase owns localized section names? Does this phase ship `(public)` loading, error and not-found files — [Frontend Architecture Standards § Routing](../standards/07-frontend-architecture.md#routing) requires `loading.tsx` and `error.tsx` per route group, and a not-found page is this phase's own choice; how do SDK `not_found`, a cursor `validation_failed`, unavailable and `429` map to page states; what do an empty catalog and a course with no eligible lesson show; does the catalog render a next-page link? Does a minimal chrome ship? Are `hreflang`, canonical and `og:locale`, which [Localization Standards § SEO](../standards/08-localization.md#seo) requires on translated public pages, built here or carved out? | The placeholder leaves the public tree; a fixed `courses` segment with the section-name owner named; minimal state files in tenant tokens; `not_found` → an HTTP `404` page; unavailable or `429` → the route group's error page without disclosure; an explicit empty state; a next-page link over enough seeded courses; in-page links rather than chrome, keeping Phase 06's navigation rows true | Detail: a phase-doc statement; Phase 06's § What Phase 02d already shipped rows reworded; a dated carve-out in the standards index row for Frontend Architecture Standards or Localization Standards, naming the owning phase, for whatever this phase builds less of | P02d-6 | [Accepted — 2026-10-09](#p02d-6-accepted-answers): product routes, approved status/URL chain, page states and metadata | | G41 | How does the lesson page draw what the subset does not implement — an out-of-subset `x-renderer`, a missing optional field, an array of objects, a stored value whose type differs from its declaration, an `x-taxonomy` value and its missing band? And where do composite and primitive components live — Frontend Architecture Standards names `packages/blocks`, Frontend Architecture names `components/blocks/`, and the registry sits in `apps/web/src/lib/customization/`? | A safe placeholder, never an exception and never raw JSON; a missing optional field renders nothing; `integer`, `boolean` and enums as text; an `x-taxonomy` value as the band's display name in the requested locale. No review addresses the component home | Detail: Tenant Customization Model § 2 (the implemented subset, not a second list) and § 8.1; Frontend Architecture Standards § Public Site Renderer and the Frontend Architecture tree. A new primitive would be an ADR-0018 release, which this row must not assume, since Phase 04 owns the field-type set | P02d-6 | [Accepted — 2026-10-09](#p02d-6-accepted-answers): ordered plain-string components and bounded fallbacks | | G42 | How do validated branding tokens reach the server-rendered HTML — a `style` attribute on the root element, which needs `unsafe-inline` or `unsafe-hashes`; a nonce-compatible `<style>` element built from validated values; or a per-tenant stylesheet route — without constraining [Security Standards § HTTP Headers](../standards/11-security.md#http-headers)' nonce-based target before Phase 11 documents it per surface? | A `<style>` element built only from registry-validated values, emitting only the `--ls-*` vocabulary, never a `style` attribute; a nonce would force dynamic rendering (G37) | Detail: Frontend Architecture Standards § Tenant Branding for the mechanism; Frontend Architecture § Theming | P02d-6 (the layout's token injection; closes with G37's answer and G16's grammar) | [Accepted — 2026-10-09](#p02d-6-accepted-answers): atomic four-token style-element injection | | G43 | Which accessibility checks on this phase's pages fail a build — route tests asserting `lang`, one `<main>`, the heading outline, a skip link and a descriptive title; `jsx-a11y` at error severity, where most of the shipped config's rules warn; or jsdom axe, which Testing Standards puts through Playwright in Phase 06? Is catalog → lesson a critical flow that needs [Accessibility Standards § Testing](../standards/16-accessibility.md#testing)' screen-reader smoke test? | Route tests in the `frontend` job for the checkable semantics; keyboard, focus and 320 CSS px reflow in the manual record; failing `jsx-a11y` with a planted companion. No review addresses the screen-reader question | Detail: a phase-doc statement; [Accessibility Standards § Tooling](../standards/16-accessibility.md#tooling) if lint severity or component axe becomes a rule; the lint configuration. Whether failing lint enforces the standard couples with G44; its index row changes only in the enforcing pull request | P02d-6 (page components and their tests) | [Accepted — 2026-10-09](#p02d-6-accepted-answers): failing lint/semantics and mandatory manual assistive-technology proof | -| G44 | The Lighthouse job. Does it activate in this phase, and on what full-stack harness — a migrated stack, a seed written as `learnstack_app`, the API as a process over the hop with a per-run secret, `next start` on a production build, both seed hosts reachable from CI's browser, a readiness and tenant-marker check before the audit — or does activation move to the phase that brings a browser harness? What does it assert: the URL set and which [Performance Standards](../standards/15-performance.md) row each page answers to (its LCP row names a landing page this phase does not ship); which rows, under which throttling preset; the 200 KB budget or the 250 KB forbidden line; hard or warning, run count and aggregation; categories, including the accessibility audit [Accessibility Standards § Tooling](../standards/16-accessibility.md#tooling) requires; the runtime ceiling, tool install and report destination; and what the standards index rows for Performance and Accessibility say afterwards? | Activate here, reusing the `make demo` entrypoint; catalog, course and lesson on both hosts plus the bilingual tenant's second locale; hard assertions on deterministic audits (script transfer size, CLS, the accessibility category or contrast over both palettes) and median-of-N timings as warnings, with TBT rather than a lab INP; tooling from the lockfile; reports to workflow artifacts only; Accessibility promoted if asserted hard, Performance kept Adopted as a pre-baseline check unless a hard budget makes it Active for what ships. The budget authority is settled: [Frontend Architecture Standards § Performance](../standards/07-frontend-architecture.md#performance) names Performance Standards | Contract: a phase-doc statement plus a committed Lighthouse configuration. Detail: `ci.yml`, CONTRIBUTING's activation edits, the `run-tests-locally` skill; Performance Standards only if a number or lab profile changes; the standards index rows and status headers in the enforcing pull request. Moving activation edits `ci.yml`, CONTRIBUTING and the skills, and leaves Phase 01's and 02a's records as written | P02d-5 (whether it activates, as an input to G32: the hosts must work in CI's browser), P02d-7 (the harness and assertions) | [Accepted — 2026-10-08](#p02d-5-accepted-answers): whether: activate in P7; harness details stay P7; [Approved — 2026-10-11](#p02d-7-approval-and-exit-boundary): bounded seventh-check activation after real success; [Accepted — 2026-10-11](#p02d-7-accepted-answers): ADR-0056 measurements and serial feasibility gate; enforcement pending | -| G45 | What does `make demo` start and guarantee on a clean checkout? The process model — a foreground supervisor over detached compose, or detached processes with a stop target; the web app's mode; how environment reaches host processes, given `dotnet run` reads no `.env` and nothing creates `apps/web/.env.local`; readiness waits before Phase 11's `/readyz`; stale-`.env` detection; re-run and stop behaviour; whether its seed step keeps `make seed`'s wait on every default-profile service and both Keycloak realms; and what it prints, including the bilingual tenant's second-locale URLs | The reviews differ on attached versus detached processes; both reject a destructive reset, wait on both processes, keep re-runs idempotent and have CI reuse the entrypoint | Detail: the `Makefile` targets with help lines and a dated phase-doc statement; README § Quickstart, `scripts/seed.sh`'s closing output and the `local-dev-setup` and `seed-tenant` skills in the same packet; Infrastructure Standards § Healthchecks if the seed's gate narrows. No ADR | P02d-7 (with G44, whose job invokes the same entrypoint; if G44's harness moves earlier, this row moves with it) | [Accepted — 2026-10-11](#p02d-7-demo-lifecycle): shared foreground lifecycle; implementation pending | +| G44 | The Lighthouse job. Does it activate in this phase, and on what full-stack harness — a migrated stack, a seed written as `learnstack_app`, the API as a process over the hop with a per-run secret, `next start` on a production build, both seed hosts reachable from CI's browser, a readiness and tenant-marker check before the audit — or does activation move to the phase that brings a browser harness? What does it assert: the URL set and which [Performance Standards](../standards/15-performance.md) row each page answers to (its LCP row names a landing page this phase does not ship); which rows, under which throttling preset; the 200 KB budget or the 250 KB forbidden line; hard or warning, run count and aggregation; categories, including the accessibility audit [Accessibility Standards § Tooling](../standards/16-accessibility.md#tooling) requires; the runtime ceiling, tool install and report destination; and what the standards index rows for Performance and Accessibility say afterwards? | Activate here, reusing the `make demo` entrypoint; catalog, course and lesson on both hosts plus the bilingual tenant's second locale; hard assertions on deterministic audits (script transfer size, CLS, the accessibility category or contrast over both palettes) and median-of-N timings as warnings, with TBT rather than a lab INP; tooling from the lockfile; reports to workflow artifacts only; Accessibility promoted if asserted hard, Performance kept Adopted as a pre-baseline check unless a hard budget makes it Active for what ships. The budget authority is settled: [Frontend Architecture Standards § Performance](../standards/07-frontend-architecture.md#performance) names Performance Standards | Contract: a phase-doc statement plus a committed Lighthouse configuration. Detail: `ci.yml`, CONTRIBUTING's activation edits, the `run-tests-locally` skill; Performance Standards only if a number or lab profile changes; the standards index rows and status headers in the enforcing pull request. Moving activation edits `ci.yml`, CONTRIBUTING and the skills, and leaves Phase 01's and 02a's records as written | P02d-5 (whether it activates, as an input to G32: the hosts must work in CI's browser), P02d-7 (the harness and assertions) | [Accepted — 2026-10-08](#p02d-5-accepted-answers): whether: activate in P7; harness details stay P7; [Approved — 2026-10-11](#p02d-7-approval-and-exit-boundary): bounded seventh-check activation after real success; [Accepted — 2026-10-11](#p02d-7-accepted-answers): ADR-0056 measurements and serial feasibility gate; local enforcement passes; live CI/protection receipts owned by Step 4 | +| G45 | What does `make demo` start and guarantee on a clean checkout? The process model — a foreground supervisor over detached compose, or detached processes with a stop target; the web app's mode; how environment reaches host processes, given `dotnet run` reads no `.env` and nothing creates `apps/web/.env.local`; readiness waits before Phase 11's `/readyz`; stale-`.env` detection; re-run and stop behaviour; whether its seed step keeps `make seed`'s wait on every default-profile service and both Keycloak realms; and what it prints, including the bilingual tenant's second-locale URLs | The reviews differ on attached versus detached processes; both reject a destructive reset, wait on both processes, keep re-runs idempotent and have CI reuse the entrypoint | Detail: the `Makefile` targets with help lines and a dated phase-doc statement; README § Quickstart, `scripts/seed.sh`'s closing output and the `local-dev-setup` and `seed-tenant` skills in the same packet; Infrastructure Standards § Healthchecks if the seed's gate narrows. No ADR | P02d-7 (with G44, whose job invokes the same entrypoint; if G44's harness moves earlier, this row moves with it) | [Accepted — 2026-10-11](#p02d-7-demo-lifecycle): shared foreground lifecycle; implemented with both review rounds complete in Step 1 | Gates that define one mechanism are answered against each other. Where their parts shape different packets' code, the mechanism is split as @@ -6023,7 +6023,7 @@ protection receipts; historical delivery records retain their original limits. ### P02d-7 Step 3 — browser and audit proof (2026-10-11) -**Implementation in progress; both independent review rounds remain pending.** +**Complete — both independent review rounds and verified fixes pass.** The shared CI lifecycle invokes `verify-public-demo.mjs` over the same seeded production API/native renderer. `DemoManifest` also derives hidden lesson titles and field values, excluding values shared with eligible public content. The @@ -6170,6 +6170,58 @@ The subsequent shared-bound change has focused verification above and must also run in final CI. Both independent review rounds and fresh fix verification are complete. Step 4 owns CI enforcement and phase exit; neither is claimed here. +### P02d-7 Step 4 — CI enforcement and phase exit (2026-10-11) + +**Implementation in progress; both independent review rounds pending.** +The always-running `public demo + lighthouse` job replaces the disabled +placeholder. It installs the exact locked browser tools and managed Chromium, +then invokes `make demo DEMO_MODE=ci` over the shared seven-service lifecycle. +The 20-minute job includes setup, the 12-minute serial proof, separate bounded +cleanup and artifact handling. An unconditional bounded workflow receipt records +failure even when setup prevents a product receipt. Uploads allow only public +JSON/HTML reports, screenshots and sanitized workflow/proof receipts. + +The [individual exit evidence matrix](phase-02d-exit-evidence.md) maps all +36 Deliverables, 79 Completion Criteria, 39 exclusions and nine exit obligations. +It preserves prior human/PR evidence and names outstanding final CI receipts; +implementation alone does not constitute phase exit. + +**Criterion reconciliation.** Published roots cannot receive new translations +under ADR-0050. The third-locale proof therefore creates and publishes new +French-only roots through supported commands, preserving the existing EN/TR +public responses and all five migration histories. Organization branding writers +remain unrouted. The branding criterion now separates API-root command refusal +from mapped-host reads and distinguishes a successful business audit from the +required standalone denied-attempt audit. This corrects the mutable criterion's +no-audit overclaim; ADR-0033/0044 and production auditing are unchanged. + +**Education preservation handoff.** Phase 05 inherits separate Course/Lesson +roots and their contained Pattern A translations. Its migration preserves stable +IDs and published localized URLs, `(sort,id)` lesson order, tenant/organization +scope, translated bodies, exact content-type/taxonomy revision pins and course +access policy. `SeedData.All` supplies both tenant curricula: eight courses, +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. + +**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 +60/min visitor and 600/min physical-peer accounting; tenant-wide English versus +organization-scoped Yoga seed context; one live bootstrap with two calls per +normal product document and three for a followed missing-content chain; and the +uncached settings reader plus `learnstack.tenancy.settings` eager-invalidation +event owned by Phase 02b. These delivered premises do not Accept future +session/BFF/authenticated-rate-limit decisions. + +**Validation boundary.** Both third-locale/branding combined cases pass locally +with zero skips. Release build has zero warnings/errors; 1,705 unit, 203 architecture, +four contract and 180 Docker-free integration cases pass, as do all 1,500 guarded +frontend cases. Typecheck, lint, whitespace and 2,042 relative links (666 fragments) +pass. The complete Docker suite is running. Two fresh review rounds, a real +successful PR job and the authorized seventh-context activation/read-back remain. +No PR, live check activation or phase exit is claimed yet. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved @@ -7101,11 +7153,13 @@ otherwise string containing `</style>` — never reaches a rendered document, and a refused write commits neither a `tenant_settings` row nor an audit row. A key outside the contract, and a failing contrast pair, behave as G16 records — never a silent save. -- A branding key written as an organization-scoped `tenant_settings` row behaves as - **G16** (e) records, checked request-level through a host mapped to the organization: - refused at write, committing neither a `tenant_settings` row nor an audit row; stored - while the page served on that host is unchanged; or, if G16 (e) applies it, shown on - that host's page and on no other host's. +- Under **G16** (e), the unrouted branding command dispatched through the API + composition root in organization scope refuses the write. It changes no + `tenant_settings` row and commits no successful setting-write audit; the denied + attempt remains auditable under ADR-0033/0044. A real GET on the mapped + organization host returns the same tenant palette before and after refusal. + This combines the application-writer boundary with the shipped HTTP reader; + it does not introduce an HTTP authoring endpoint. - If **G19** includes a write-time rule, a lesson write holding a disallowed URL is refused with details naming the JSON pointer, and nothing is written. - Whatever **G18** refuses at save — which may include a presentation entry naming a @@ -7437,8 +7491,8 @@ otherwise handler's response type and neither has a handler. The mechanical control is accepted in **G28**: mandatory marked dispatch and structural guards complement the physical read-only barrier. P02d-4 Step 1 delivers the physical read-only barrier and host admission. - Endpoint dispatch/dependency guards remain with Step 2; the barrier covers only - enlisted transactions. + Steps 2–4 deliver endpoint dispatch/dependency and eligibility proofs; + the physical barrier covers only enlisted transactions. - **A missing marker looks like a resolver bug.** A host-only request to a type without `[PublicSurface]` answers the unknown-host `404` by design, so a public read shipped without its marker reads as a resolution or Row Level Security defect. The tempting diff --git a/docs/roadmap/phase-04-cms-media-pages.md b/docs/roadmap/phase-04-cms-media-pages.md index ce8e8184..e83c46fb 100644 --- a/docs/roadmap/phase-04-cms-media-pages.md +++ b/docs/roadmap/phase-04-cms-media-pages.md @@ -8,7 +8,9 @@ catalog pages, campaign pages, and tenant-defined page blocks. [Phase 02d](phase-02d-walking-skeleton.md) delivers the anonymous read API and trusted server transport through P02d-5. Catalog, course-detail/outline and lesson product -pages are delivered by P02d-6; the two-host browser/demo proof remains P02d-7. +pages are delivered by P02d-6; P02d-7 implements the two-host demo and narrow +browser/Lighthouse proof. Its [delivery record](phase-02d-walking-skeleton.md) +owns current CI enforcement and phase-exit evidence. Its bounded renderer uses fixed section names and ADR-0051's ordered plain-text profile. This phase adds an authored, versioned, localized content system that a tenant admin drives from Admin Studio, including the richer primitive field set. @@ -87,6 +89,15 @@ Also in scope: - `TenantContentType` JSON Schema editor in Admin Studio; the schema is validated as a schema on save, not merely stored. +- Authored field ordering and per-locale field-label editing under + [ADR-0051](../decisions/0051-ordered-text-card-presentation.md), preserving the + ordered `x-fields` contract and localized visible labels when extending the + plain-text profile to richer primitives. +- Customization deprecate and revise commands with their Studio lifecycle controls; + per-band taxonomy editing is delivered with [Phase 05](phase-05-education-learning-content.md#admin-studio-education-screens) + and consolidated by [Phase 06](phase-06-renderer-admin-studio.md#admin-studio--screen-ownership). + Existing exact revision pins remain readable; the revision workflow decision + below precedes any new mutation contract. - Built-in primitive field types composed by the JSON Schema: text, rich text, number, boolean, date/time, media reference, entry reference, select / multi-select, JSON / object. The set is closed and changes only with a LearnStack release. @@ -283,6 +294,10 @@ Storage and asset management: emit no protected bearer URL; issuance and retrieval require effective access. It does not claim an authenticated evaluator exists before Phase 07. The first active URL/markup sink also needs the shared safety contract named in ADR-0051. + This phase owns the tenant media-origin allow-policy: approved origin provenance, + permitted schemes/hosts and enforcement at authored URL validation and the first + active media sink. Its decision pass settles the policy before introducing that + sink; Phase 02d's inert plain-text fields authorize no media URL or markup. **This phase owns the media processing pipeline.** No phase currently does: [Media Pipeline](../architecture/16-media-pipeline.md) describes `IVideoTranscoder`, @@ -399,7 +414,10 @@ describes. a `LimitKey`. - `ITenantSearch` / `IPlatformSearch` with a PostgreSQL full-text implementation over content entries and pages, tenant-filtered inside the port. -- Admin Studio CMS screens per the list above. +- Admin Studio CMS screens per the list above, including field-order/localized-label + authoring and customization deprecate/revise controls. +- The tenant media-origin allow-policy and its authoring/runtime enforcement at + active media sinks. - Public read APIs for the renderer, versioned per [ADR-0024](../decisions/0024-api-versioning-policy.md). - CMS/Studio UI messages extend the ADR-0027 foundation owned by P02d-6, with @@ -445,6 +463,10 @@ describes. page block; a video asset produces an HLS master playlist, a poster and a thumbnail strip through the ffmpeg worker. - An SVG containing a `<script>` element is stored sanitised and served without it. +- Disallowed tenant media origins are refused at authoring and cannot load through + an active sink; allowed origins render under the same tenant's policy. +- Studio preserves authored field order and displays translated labels in each + supported locale; deprecating/revising a definition preserves existing exact pins. - Draft and published versions are separated, published pages never show draft content, and the preview flow works for admin users. - The same Studio editor and rendering pipeline serve both diff --git a/docs/roadmap/phase-05-education-learning-content.md b/docs/roadmap/phase-05-education-learning-content.md index 34c23230..f4c99d1b 100644 --- a/docs/roadmap/phase-05-education-learning-content.md +++ b/docs/roadmap/phase-05-education-learning-content.md @@ -58,21 +58,22 @@ content-access policy through version/module migration, decides its versioned ow and any policy-edit/reparent/preview behavior before those writers, and never infers public access from a new version or listing. This note claims no shipped column. -**Current delivery — 2026-10-09.** P02d-2 delivered the policy column, restricted +**Current delivery — 2026-10-11.** P02d-2 delivered the policy column, restricted backfill and writers; P02d-4 delivered eligible public reads. Preserve those contracts through this phase's version/module migration. P02d-6 delivers the -bounded renderer and product/manual proofs; P02d-7 still owns the browser/demo -harness. The [packet closeout](phase-02d-walking-skeleton.md#p02d-6-packet-closeout-2026-10-10) -records current implementation evidence. +bounded renderer and product/manual proofs; P02d-7 implements the demo and narrow +browser/Lighthouse proof. The [Phase 02d delivery record](phase-02d-walking-skeleton.md) +owns current implementation, CI enforcement and phase-exit evidence. Phase 05 does not re-create these. Phase 02d's [delivery status](phase-02d-walking-skeleton.md#delivery-record-p02d-1) distinguishes accepted design from shipped implementation. This phase's decision pass designs the move from independent lessons referencing courses to course versions and modules, and from translated inline bodies to lesson items. It must preserve course and -lesson ids, published localized URLs, lesson order, organization scope, translated -bodies and exact content-type and taxonomy revision bindings. The migration and its -tests must carry existing rows forward; re-seeding is not a migration strategy. +lesson ids, published localized URLs, lesson order, tenant/organization scope, +translated bodies, exact content-type and taxonomy revision bindings and effective +content-access policy. Migration tests must carry existing rows forward; +re-seeding is not a migration strategy. Each change follows [Database Standards § Migrations](../standards/05-database.md#migrations). | Phase 02d baseline | Phase 05 adds | @@ -134,12 +135,13 @@ scoring and does not re-decide it. for the yoga studio, tracks for a coding bootcamp). A taxonomy key and an item key are lowercase under [ADR-0018's key rule](../decisions/0018-tenant-driven-customization-model.md#2026-09-04--customization-keys-and-item-keys-are-lowercase), - and a band's name is its `display_name`. Which key the yoga taxonomy uses is G14 in - [Phase 02d's decision register](phase-02d-walking-skeleton.md#the-decision-register), - and the decision pass that closes it writes the key here. The taxonomy is data, not - code. The `Level` projection must preserve the exact revision and band referenced - by an existing course; a new Active taxonomy cannot silently relabel or remove that - stored binding. This phase's decision pass defines how the revision-aware projection + and a band's name is its `display_name`. The shipped Yoga seed uses + `yoga-difficulty` revision 1, with `foundation`, `developing` and `advanced` bands; + [SeedData](../../backend/src/LearnStack.Tools.Seeder/SeedData.cs) owns that + inventory. The taxonomy is tenant data. The `Level` projection preserves the exact + revision and band referenced by an existing course; a new Active taxonomy cannot + silently relabel or remove that stored binding. This phase's decision pass defines + how the revision-aware projection composes with the target `Level` identity. - Tag. - Instructor profile reference, with tenant-defined custom fields via @@ -163,6 +165,18 @@ scoring and does not re-decide it. Required capabilities: draft version, published version, version clone, change summary, publish validation. +### Post-seed authoring and lifecycle + +This phase owns the authenticated Course/Lesson authoring API and Studio surface, +including commands to edit content after seeding, reorder lessons, unpublish and +delete. Its decision pass settles allowed states, versioned owners, concurrency, +access-policy edits, reparenting and published-URL behavior before those writers. +The current ADR-0050 baseline permits draft-only insertions and independent +`draft → published` transitions; it supplies no published edit, reorder, unpublish +or delete operation. New lifecycle behavior must preserve the migration obligations +[above](#what-phase-02d-already-shipped), rather than treating a seed rerun as an edit +or migration. This handoff records future ownership without selecting that contract. + ### Learning structure - Module. @@ -367,8 +381,10 @@ cross-phase screen ownership table lives in **one** place — - Lesson editor. - Lesson item editor (schema-driven for both built-in and tenant-defined item types). - `TenantLessonItemType` editor (schema + player key). -- `TenantLevelTaxonomy` editor (items + metadata; the tenant admin declares CEFR / - difficulty / track / kyu-dan). +- `TenantLevelTaxonomy` editor with per-band editing of keys, ordering, localized + labels and metadata; the tenant admin declares CEFR / difficulty / track / + kyu-dan. It consumes [Phase 04](phase-04-cms-media-pages.md)'s customization + deprecate/revise lifecycle and preserves existing exact revision/band pins. - `TenantCompletionRule` editor — sandboxed DSL editor with live validation against the ADR-0025 engine. - Course publish flow. @@ -387,6 +403,9 @@ cross-phase screen ownership table lives in **one** place — - Education catalog API — programs, courses, versions, categories, levels, tags, SEO. - Versioned course structure with draft / publish / clone. - Lesson content management with lesson items across both registry tiers. +- Authenticated post-seed Course/Lesson edit, reorder, unpublish and delete commands + and Studio controls under the lifecycle contract settled in this phase. +- Per-band taxonomy editing over the versioned customization lifecycle. - Public catalog rendering data, extending the read endpoints [Phase 02d](phase-02d-walking-skeleton.md) shipped. - The customization runtime cost model implemented and **measured**: validation on the @@ -407,6 +426,11 @@ cross-phase screen ownership table lives in **one** place — complete, no scoring band is assigned, and the failure is audited. - An admin creates a course, adds modules and lessons, edits it as a draft, and publishes it; the published course detail is readable by the public site. +- Post-seed edit/reorder/unpublish/delete cases prove the allowed state transitions, + concurrency and refusal behavior chosen by the lifecycle decision; seed reruns + cannot substitute for those commands. +- Migration tests preserve the existing seed rows' stable IDs, localized URLs, + scopes, lesson order, translated bodies, exact definition pins and access policy. - `CourseVersion` behaviour is covered by integration tests, including that an existing enrollment reference stays pinned to its version when a new version publishes. - Both seed tenants from [Phase 02a Packet 7](phase-02a-kernel-tenancy.md) — the English diff --git a/docs/roadmap/phase-06-renderer-admin-studio.md b/docs/roadmap/phase-06-renderer-admin-studio.md index 2ac7c9d1..8def8216 100644 --- a/docs/roadmap/phase-06-renderer-admin-studio.md +++ b/docs/roadmap/phase-06-renderer-admin-studio.md @@ -9,8 +9,8 @@ learner-facing and instructor-facing screen after this phase is built into. [Phase 02d](phase-02d-walking-skeleton.md) delivers the public read API and trusted server transport through P02d-5. P02d-6 owns catalog, course-detail/outline and lesson pages, now implemented with product proofs and passing manual accessibility -smoke. P02d-7 owns the unbuilt browser/demo harness. This phase deepens -that bounded path with authored navigation, +smoke. P02d-7 implements the demo, narrow Playwright library browser proof and +Lighthouse audits. This phase deepens that bounded path with authored navigation, tenant error pages, complete block composition and editing surfaces. After this phase, LearnStack publishes a simple but real education website for a tenant, @@ -24,7 +24,10 @@ and a non-developer tenant admin can maintain it. Rows identify P02d-6 scope merged through PR #27; its [merge closeout](phase-02d-walking-skeleton.md#p02d-6-merge-and-closeout-2026-10-10) -records final verification. Planned P02d-7 work is not current behavior. +records final verification. P02d-7's +[delivery record](phase-02d-walking-skeleton.md#p02d-7-step-3--browser-and-audit-proof-2026-10-11) +records the narrow browser/audit proof; the canonical Phase 02d closeout records +its CI enforcement and phase-exit state. | Phase 02d foundation | Phase 06 adds | |---|---| @@ -32,7 +35,7 @@ records final verification. Planned P02d-7 work is not current behavior. | Anonymous `(public)` catalog/course Server Component pages delivered P02d-6 Step 2; lesson page delivered Step 3 in [Phase 02d § Public renderer](phase-02d-walking-skeleton.md#public-renderer) | Navigation, SEO metadata beyond G40, tenant-authored error pages, redirect handling, full page composition | | ADR-0051 ordered plain-text lesson presentation, API projection delivered P02d-4 and bounded renderer delivered P02d-6 Step 3 | The complete two-tier block registry with safe-render placeholders | | Four-color public theme projection delivered P02d-4; safe document injection delivered P02d-6 Step 1 | The branding configuration surface, additional tokens and organization override | -| The `apps/web` Vitest suite [Phase 02d](phase-02d-walking-skeleton.md) extends | The browser-level end-to-end suite | +| The `apps/web` Vitest suite and P02d-7's pinned Playwright library/Lighthouse proof over nine seed-derived product URLs | The full Playwright Test project and direct axe suite across public, Studio and portal flows | **P02d-6 inheritance — Accepted 2026-10-09; Steps 1–4 implemented.** [ADR-0027](../decisions/0027-frontend-i18n.md) selects exact `next-intl` 4.14.9, @@ -46,9 +49,12 @@ uses plain same-host anchors, opaque signed-target pagination, safe four-color injection and local 307→404 navigation to `/{locale}/status/not-found`. The URL changes; known content-call failures are translated HTTP 200 noindex states. Tenant-authored errors, menus, media, expanded renderer composition and -organization overrides remain here. P02d-7 owns the initial browser/demo and -Lighthouse activation; this phase owns the full Playwright/axe suite and Phase 11 -owns web-vitals telemetry. Acceptance does not claim these consumers are shipped. +organization overrides remain here. P02d-7's Playwright 1.64.0 library proof and +Lighthouse 13.5.0 audits exercise the seeded production public pages; Lighthouse's +transitive axe audit is part of that bounded proof. This phase owns the full +`@playwright/test` project and direct axe suite, including authenticated Studio and +portal flows. [Phase 11](phase-11-production-hardening.md#observability) owns +web-vitals telemetry. These future consumers are not Phase 02d delivery. ### Public site renderer @@ -234,8 +240,9 @@ live class. **Phase 06 owns it.** ### End-to-end test stack — a carried dependency -P02d-7 owns the initial browser/demo harness; Phase 06 expands it into the full -browser/axe suite and depends on the ephemeral end-to-end stack working. +P02d-7 delivers a narrow Playwright library browser and Lighthouse harness through +the shared demo lifecycle. Phase 06 adds the full Playwright Test/axe suite and +retains the ephemeral end-to-end stack dependency. `infra/compose/e2e.yml` overlays the development stack with `volumes: !reset []`, which discards more than the named volumes: it also discards the **PostgreSQL init script** and the **SeaweedFS S3 identity file**. @@ -262,7 +269,8 @@ browser suite against a mis-provisioned stack is worse evidence than no suite. information-architecture pass. - The `(portal)` route-group shell and the instructor portal's first screens. - Shared UI package and the typed SDK over the full API surface. -- A browser-level end-to-end suite running against the fixed ephemeral stack. +- The full Playwright Test/axe end-to-end suite running against the fixed ephemeral + stack, extending the narrow P02d-7 public browser/audit proof. ## Completion Criteria diff --git a/docs/roadmap/phase-11-production-hardening.md b/docs/roadmap/phase-11-production-hardening.md index 1085bad5..96a290d7 100644 --- a/docs/roadmap/phase-11-production-hardening.md +++ b/docs/roadmap/phase-11-production-hardening.md @@ -401,7 +401,11 @@ bounds request *cost* once a request is inside. Neither substitutes for the othe - Database indexes. - Query review. -- Cache strategy. +- Cache strategy, including gateway/CDN response caching for public API responses + and rendered pages. This phase settles host/tenant/organization/locale and + eligibility partitioning, cache keys, validators and invalidation before enabling + it. The Phase 02d API and renderer retain `no-store`; the generation-keyed + definition cache does not authorize response or page caching. - CDN strategy for media and public assets. - Pagination enforcement. - Search indexing optimization. @@ -532,6 +536,8 @@ same `ILiveClassProvider`. - Backup and restore process with a completed drill. - Performance baseline against the budgets in [15-performance.md](../standards/15-performance.md). +- Gateway/CDN response-cache policy and isolation/freshness proofs before activation, + preserving the existing uncached public contract until that decision ships. - Live classroom operations checklist. - Release checklist and operational runbooks under `docs/runbooks/`. - ADR-0026 Accepted, and diff --git a/docs/standards/06-testing.md b/docs/standards/06-testing.md index f747a94f..66b4734f 100644 --- a/docs/standards/06-testing.md +++ b/docs/standards/06-testing.md @@ -84,10 +84,12 @@ belongs to is decided by what it needs, not by what it is about: a Testcontainers PostgreSQL. **Packet 7** introduces the population, because it is the only shape in which the real middleware chain, the real EF query filters and the real RLS policies run against one another in one request, connected as - `learnstack_app`. It drives no production endpoint — Packet 7 ships none — but a - test-only controller the fixture registers, which is what `IdempotencyFixture` - already does for `/api/v1/sideeffectprobe`. These live under `Database/` with the - rest of the Docker-bound suite and carry the same trait. + `learnstack_app`. Packet 7 originally drives test-only controllers because it + ships no production endpoint. P02d-4 adds actual public Education GET/HEAD cases + in `PublicEducationHttpTests`, including mapped-host scope and uniform hidden + resources. Test-only probes remain separate from the production endpoint set. + These live under `Database/` with the rest of the Docker-bound suite and carry + the same trait. **Where a Docker-bound test lives, and what it takes, is what routes it.** `Every_Database_Test_Carries_The_Docker_Trait` scans the whole of @@ -140,8 +142,10 @@ P02d-7 Step 3 implements the narrow seeded catalog → course → lesson browser proof over the running production API/native renderer. Its [delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-7-step-3--browser-and-audit-proof-2026-10-11) records the passing local nine-page walkthrough and 27 Lighthouse audits; -independent reviews and required CI activation remain pending. The proof uses -Playwright's library and managed Chromium, not a separate Playwright Test project. +both independent Step 3 reviews are complete. The packet's Step 4 record owns +real CI receipts and required-check activation; local proof alone does not establish +activation. The proof uses Playwright's library and managed Chromium, without a +separate Playwright Test project. It supplements G33's human walkthrough and preserves P6's recorded manual smoke; it does not replace either with automated accessibility scores. @@ -152,6 +156,31 @@ separate from browser evidence. P02d-7's proof adds actual linked navigation, computed themes, ordered fields, TLS refusal controls and bounded audits over unchanged seeded product routes. +Run the shared proof from a fresh checkout without root `.env`, web `.env.local` +or `artifacts/public-demo`, with Docker and the documented toolchain available: + +```bash +(cd frontend && pnpm install --frozen-lockfile) +(cd frontend && pnpm exec playwright install --with-deps chromium) +make demo DEMO_MODE=ci +``` + +The lifecycle provisions a unique tmpfs-backed Compose project, generates private +disposable TLS/configuration, seeds through normal commands and stops its Keycloak +before browser navigation. Fixed application/Compose ports must be free. Review +the sanitized `artifacts/public-demo/receipt.json`, all 27 JSON/HTML report pairs +and browser screenshots; proof and owned cleanup must both pass. Existing artifacts +must be moved aside before a repeat run. Private environments, certificates, keys, +browser profiles and raw process logs are excluded from artifacts. Human foreground +`make demo` retains manual hosts/CA/leaf setup and persistent local data. The +[local test workflow](../../.claude/skills/run-tests-locally/SKILL.md#public-demo-managed-browser-and-lighthouse) +owns the complete setup and artifact instructions. + +The frontend workspace pins Playwright and Lighthouse as development tools. +Lighthouse's transitive development `axe-core@4.14.0` does not supply a standalone +application axe dependency or the full Phase 06 axe suite. There is no +`pnpm test:e2e`, `pnpm test:a11y` or `@playwright/test` project. + ### Public renderer fixture ownership P02d-5 remediation Step 3 implements test-owned process/tree cleanup, positive @@ -234,29 +263,33 @@ offenders. Canonical names/statuses live in the not mark them Implemented. Manual keyboard, focus, 320 CSS px reflow/zoom, long strings, contrast and real screen-reader smoke evidence must name commit, environment, both hosts/locales and assistive technology. If pending, P6 completion -is not claimed. Full Playwright/axe remains Phase 06; demo/browser/Lighthouse remains -P02d-7. G38(a)'s transport proofs and G38(d)'s guarded runner are already delivered. +is not claimed. Full Playwright Test/axe remains Phase 06; the narrow seeded +demo/browser/Lighthouse proof is delivered by P02d-7. G38(a)'s transport proofs and +G38(d)'s guarded runner are already delivered. ## Tenant Isolation Tests **Mandatory** for every PR touching tenant-owned data: -```csharp -public sealed class CourseTenantIsolationTests : IntegrationTestBase -{ - [Fact] - public async Task User_in_tenant_A_cannot_see_courses_in_tenant_B() - { - var (tenantA, tenantB) = await SeedTwoTenantsAsync(); - var courseInB = await CreateCourseAsync(tenantB); - - var response = await Client.As(tenantA.AdminUser) - .GetAsync($"/api/v1/courses/{courseInB.Id}"); - - response.StatusCode.Should().Be(HttpStatusCode.NotFound); - } -} -``` +Use the shipped examples: + +- [PublicEducationHttpTests](../../backend/tests/LearnStack.Tests.Integration/Database/PublicEducationHttpTests.cs) + resolves actual mapped hosts and asserts exact-locale public catalog/detail + behavior, foreign/draft/restricted hiding and sibling-organization refusal. + `Course_draft_branch_foreign_and_unknown_are_uniformly_hidden` and + `Organization_host_never_serves_a_sibling_organization` exercise the production + public GET/HEAD contract. +- [EducationIsolationTests](../../backend/tests/LearnStack.Tests.Integration/Database/EducationIsolationTests.cs) + independently tests raw app-role RLS and EF-filter behavior on all four Education + tables, including unresolved scope, insert scope, reporting reads versus writes, + immutable organizations and parent constraints. + +Fixtures apply every chain through +[MigrationChains](../../backend/tests/LearnStack.Tests.Integration/Database/MigrationChains.cs), +which owns current chain/history discovery. The current schema has five chains, +21 data tables and five history tables; tests must not copy a fixed partial chain +list. Follow [add-integration-test](../../.claude/skills/add-integration-test/SKILL.md) +for fixture selection and transaction-scoped context. Rules: - Every module owning tenant-scoped data has at least one such test. @@ -333,8 +366,12 @@ Real LiveKit is **not** required in CI; a fake provider satisfies the contract. `backend` job. 4. The Docker-bound integration tests — the `backend-integration` job. 5. Frontend unit and component tests — the `frontend` job. -6. E2E tests — from [Phase 06](../roadmap/phase-06-renderer-admin-studio.md), per - § End-to-End Tests; allowed to retry on infra flake, with logged justification. +6. Narrow seeded browser/Lighthouse proof — the `public demo + lighthouse` job + invokes the shared `make demo DEMO_MODE=ci` lifecycle, per § End-to-End Tests. + It retains real admission budgets and never retries a 429 or audits a refusal + page. The packet's Step 4 record owns the real PR receipt and seventh required + check activation. Phase 06 owns the broader Playwright Test/axe golden flows; + any infrastructure-flake retry there requires logged justification. 7. Coverage report — none yet, per § Coverage Targets. A failure in any step that runs fails the pull request. Which checks block a merge is a diff --git a/docs/standards/15-performance.md b/docs/standards/15-performance.md index ba134417..ec90ffa9 100644 --- a/docs/standards/15-performance.md +++ b/docs/standards/15-performance.md @@ -1,6 +1,6 @@ # 15 — Performance Standards -**Status:** Adopted +**Status:** Active **Derives from:** [ADR 0002 — Initial Architecture](../decisions/0002-initial-architecture.md) (initial budgets in [04-technical-architecture.md § Performance Budgets](../architecture/04-technical-architecture.md)), [ADR 0005 — Live Classroom Media Stack](../decisions/0005-live-classroom-media-stack.md) (classroom join + bandwidth budgets). Public-read additions derive from [ADR-0052](../decisions/0052-anonymous-public-read-boundary.md); P02d-5's bounded @@ -17,9 +17,13 @@ cold navigation through collection completion, including initial async/lazy loads and original-document inline executable bodies. It excludes inert data, evaluation-generated code and scripts requested after that window or by later interaction. This can be stricter than entry-chunk/build-time accounting; it is -not merely a unit clarification. P7 Step 3's local browser/audit feasibility passes; -independent reviews and CI activation remain pending. This standard stays Adopted -until Step 4 lands required enforcement. +not merely a unit clarification. P7 delivers the always-running +`public demo + lighthouse` job and its fail-closed browser/report/source controls. +The hard gzip/CLS/automated-accessibility checks make this standard Active for +that delivered scope. Timing medians remain advisory; production baselines, +field telemetry and load tests remain Phase 11. The +[Step 4 record](../roadmap/phase-02d-walking-skeleton.md#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11) +owns real CI receipts and bounded required-check activation. ## Initial Budgets diff --git a/docs/standards/16-accessibility.md b/docs/standards/16-accessibility.md index f60ebfa0..38b2e0e6 100644 --- a/docs/standards/16-accessibility.md +++ b/docs/standards/16-accessibility.md @@ -98,8 +98,11 @@ LearnStack is an education platform; learners with disabilities are a first-clas - `eslint-plugin-jsx-a11y` in the frontend lint config. - Phase 06 integrates `axe-core` with Playwright for public-renderer and portal critical flows. -- P02d-7 owns Lighthouse activation and the accessibility assertions for public - routes; the CI placeholder remains disabled. +- P02d-7 delivers the narrow managed-browser walkthrough and hard Lighthouse + automated accessibility score for nine seeded pages under + [ADR-0056](../decisions/0056-public-demo-browser-and-lighthouse-enforcement.md). + The [Step 4 record](../roadmap/phase-02d-walking-skeleton.md#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11) + owns CI and required-check receipts; this supplements the scoped P6 human record. - Manual keyboard walkthroughs for new screens in the PR review. **P02d-6 G43 enforcement — Step 3, 2026-10-10.** All 31 active recommended diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index ac3d9e57..65906fdf 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -10,7 +10,7 @@ [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 rules Registered). The +(accepted 2026-10-11; browser/audit replacement proofs implemented). The catalogue grows as subsequent ADRs and phases land their tests; per-test ownership stays with the originating ADR / standard. @@ -134,8 +134,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. -**171 rules in this catalogue are Implemented, and 108 of them are in that assembly.** -The other 63 are no less binding, and most could not live there. The table says where +**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 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. @@ -656,7 +656,8 @@ otherwise). [P7 package](../roadmap/phase-02d-walking-skeleton.md#p02d-7-genericity-enforcement), approved on 2026-10-11; Step 2 implements the exact-literal/reference fences. ADR-0056's separate - browser/Lighthouse measurement decision is Accepted; its rules remain Registered. + browser/Lighthouse measurement decision and its implemented controls complement + this source fence; neither proves all dynamically assembled tenant comparisons. - **Type:** Roslyn C#/embedded-JSON scan, TypeScript AST/JSON scan, compiled and restore-resolved project references, with planted offenders and clean controls. **Kind:** structural. @@ -4489,12 +4490,15 @@ test-only instrumentation stays in the disposable copied app. ## P02d-7 public demo and Lighthouse controls -These behavioral rules remain Registered under Accepted ADR-0056 while P7 Step 3 -independent reviews and Step 4 CI enforcement remain pending. The first complete -local browser/audit feasibility run passes. The Node proof -entrypoint is `frontend/apps/web/scripts/verify-public-demo.mjs`, invoked through -the shared demo lifecycle. Step 4 adds the `public demo + lighthouse` CI job. -Registration claims neither a passing complete audit nor delivered CI enforcement. +These behavioral rules are Implemented under Accepted ADR-0056 by +`frontend/apps/web/scripts/verify-public-demo.mjs`, its contract/gatherer/worker +and the always-running `public demo + lighthouse` job through `make demo`. +`src/test/public-demo.test.ts` and `src/test/demo-lifecycle.test.ts` execute +planted failing and clean controls in the required frontend job. Actual browser +mutants, TLS refusals and a real Lighthouse timeout also run in the full proof. +The [Step 4 record](../roadmap/phase-02d-walking-skeleton.md#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11) +owns exact CI and protection receipts. These are Node behavioral rules, not +C# architecture-test methods or a full WCAG/production-performance claim. ### `Public_Demo_Uses_Seeded_Production_Pages` @@ -4505,8 +4509,8 @@ Registration claims neither a passing complete audit nor delivered CI enforcemen - **Scope:** nine delivered pages; full Playwright/axe remains Phase 06. - **Type:** Node browser proof with wrong-page/error-page controls. **Kind:** behavioural. -- **Status:** **Registered** — planned Node proof entrypoint and CI job above; - implemented only after named enforcing controls and the actual proof pass. +- **Status:** **Implemented** — `verify-public-demo.mjs` browser mode and + `public-demo.test.ts` actual-page controls, executed by the jobs above. - **Phase:** 02d (P02d-7 Step 3). ### `Public_Lighthouse_Reports_Enforce_Budgets` @@ -4518,7 +4522,7 @@ Registration claims neither a passing complete audit nor delivered CI enforcemen - **Scope:** pinned Lighthouse/Chromium; every seed lane/URL/run. - **Type:** Node report validation and real audits, with stale/missing/error and budget-offender controls. **Kind:** behavioural. -- **Status:** **Registered** — planned Node proof entrypoint and CI job above. +- **Status:** **Implemented** — named Node proof and planted controls above. - **Phase:** 02d (P02d-7 Step 3). ### `Public_Initial_JavaScript_Gzip_Budget_Is_Measured` @@ -4529,7 +4533,7 @@ Registration claims neither a passing complete audit nor delivered CI enforcemen - **Scope:** the ADR's collection window, origin coverage and exclusions. - **Type:** Node source accounting, with omitted/empty/malformed/over-budget and clean-source controls. **Kind:** behavioural. -- **Status:** **Registered** — planned Node proof entrypoint and CI job above. +- **Status:** **Implemented** — named Node proof and planted controls above. - **Phase:** 02d (P02d-7 Step 3). ### `Public_Demo_Preserves_Admission_And_Cleanup` @@ -4540,7 +4544,7 @@ Registration claims neither a passing complete audit nor delivered CI enforcemen - **Scope:** the shared demo lifecycle and full-stack CI job. - **Type:** Node lifecycle/proof controls and real production navigation. **Kind:** behavioural. -- **Status:** **Registered** — planned Node proof entrypoint and CI job above. +- **Status:** **Implemented** — named Node proof and planted controls above. - **Phase:** 02d (P02d-7 Steps 1, 3 and 4). ## References diff --git a/docs/standards/README.md b/docs/standards/README.md index 21a9ae12..72fe952c 100644 --- a/docs/standards/README.md +++ b/docs/standards/README.md @@ -67,7 +67,7 @@ bookkeeping pass. ### Honest status today -The table below describes the implementation as of 2026-10-10: +The table below describes the implementation as of 2026-10-11: [Phase 02a](../roadmap/phase-02a-kernel-tenancy.md) and P02d-1–6 are complete and merged. P02d-5's ingress, configured server caller, dynamic entry and frontend guards are delivered through PR #26; the @@ -89,8 +89,10 @@ independent review rounds. The owns completed Step 3 reviews, verified fixes and delivery evidence; its five rules are Implemented. P6's [merge closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-6-merge-and-closeout-2026-10-10) -records PR #27's merge and final verification; P7 has not started. Acceptance -alone promotes no standard or test status. +records PR #27's merge and final verification. P7 delivers the shared demo, +G20 fences and seeded browser/Lighthouse enforcement. Its +[Step 4 record](../roadmap/phase-02d-walking-skeleton.md#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11) +owns review, real CI and protection receipts. Acceptance alone promotes no status. **The documents and this table say the same thing, and a test holds them to it.** [Phase 02a Packet 10](../roadmap/phase-02a-kernel-tenancy.md) reconciled the twenty-two @@ -116,8 +118,8 @@ included. | 12 | [Infrastructure](12-infrastructure.md) | **Active** | Compose stack, `Makefile`, CI workflow, pre-commit hooks and secret scanning all live since Phase 01. | | 13 | [Documentation](13-documentation.md) | **Active** | Governs this corpus; the CI link audit walks changed Markdown. | | 14 | [Git Workflow](14-git-workflow.md) | **Active** | Conventional Commits, hooks and required checks are live. Two branch-protection settings — `Require approvals` and `Do not allow bypassing` — are **deferred by maintainer decision (2026-08-10)** while the repository has one active contributor; the trigger and what activating them involves are recorded in [CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md). P02d-1 repaired and re-read the live required-check list on 2026-09-14: all five active jobs, including the current meta name and Docker integration, are required with GitHub Actions attribution and strict up-to-date checking. P02d-4 activates the sixth `openapi diff` context after a successful real job and verifies every other setting unchanged. [CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md#branch-protection-settings-on-main) owns the current list; the [P02d-4 delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-4-step-4-contract-sdk-and-ci) records activation verification. | -| 15 | [Performance](15-performance.md) | **Adopted** | No budget is measured and no load test exists. Enforcement lands in [Phase 11](../roadmap/phase-11-production-hardening.md). Accepted P02d-5 G44 assigns Lighthouse activation to P02d-7 after P6 pages; P7 selects the harness and assertions. The job remains disabled. [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register) owns the decision; any status promotion lands with its enforcement. | -| 16 | [Accessibility](16-accessibility.md) | **Active** | P6 delivers catalog/course/lesson and controlled-state semantics, all 31 active recommended jsx-a11y rules at error severity with per-rule actual-config dirty/clean controls, and real product HTML/Flight proofs. The [packet closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-6-packet-closeout-2026-10-10) records keyboard/focus/reflow/contrast observations and the maintainer's passing VoiceOver smoke. This is enforcement for delivered public-page scope, not a full WCAG audit. Full Playwright/axe remains [Phase 06](../roadmap/phase-06-renderer-admin-studio.md); Accepted G44 assigns Lighthouse to P02d-7 after P6. | +| 15 | [Performance](15-performance.md) | **Active** | P02d-7 delivers the always-running `public demo + lighthouse` job under ADR-0056: nine seeded pages, 27 audits, actual-page/source/census controls and hard normalized gzip/CLS/automated-accessibility checks. Timing medians are advisory. The [Step 4 record](../roadmap/phase-02d-walking-skeleton.md#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11) owns real CI and required-check receipts. Production p95/field telemetry/load tests remain [Phase 11](../roadmap/phase-11-production-hardening.md). | +| 16 | [Accessibility](16-accessibility.md) | **Active** | P6 delivers catalog/course/lesson and controlled-state semantics, all 31 active recommended jsx-a11y rules at error severity with per-rule actual-config dirty/clean controls, and real product HTML/Flight proofs. The [packet closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-6-packet-closeout-2026-10-10) records keyboard/focus/reflow/contrast observations and the maintainer's passing VoiceOver smoke. This is enforcement for delivered public-page scope, not a full WCAG audit. P02d-7 adds hard Lighthouse automated accessibility and a narrow library browser walkthrough; full Playwright Test/axe remains [Phase 06](../roadmap/phase-06-renderer-admin-studio.md). | | 17 | [Code Review](17-code-review.md) | **Active** | Applied to every pull request merged so far; the zero-tolerance blocker list is in live use. | | 18 | [Audit Coverage](18-audit-coverage.md) | **Active** | Packet 9 lit the write path under [ADR-0033](../decisions/0033-audit-durability-model.md) and [ADR-0044](../decisions/0044-audit-write-path.md): classification at step 3, MUST rows on the business transaction before `COMMIT`, `audit_log` / `audit_config` with their append-only layers, and the catalogue ↔ matrix join enforced in both directions and per request type by `AuditCoverageTests`. Retention and partitioning are [Phase 11](../roadmap/phase-11-production-hardening.md)'s. | | 19 | [Permissions](19-permissions.md) | **Adopted** | No permission key, policy or role exists. Lands in [Phase 03](../roadmap/phase-03-identity-admin.md). | @@ -134,12 +136,13 @@ Step 3 adds Education eligibility, scoped seek pages and bounded content/label projections with HTTP/database proof. Step 4 adds served/snapshot equality, recursive DTO guards, generated SDK/drift and breaking-policy CI controls. -Twenty `Active`, two `Adopted`. Packet 10 moved five: the frontend rules, the one-app -rule, the locale invariants, the out-of-band setters and the port bans are all mechanical -now, and a standard whose rules a test enforces is `Active` by this document's own -definition. P02d-6 promotes Accessibility with blocking lint/semantic controls and -recorded manual evidence. Performance and Permissions remain Adopted: no budget is -enforced and no permission key exists. Each names the phase that will promote it. +Twenty-one `Active`, one `Adopted`. Packet 10 moved five: the frontend rules, the one- +app rule, the locale invariants, the out-of-band setters and the port bans are all +mechanical now, and a standard whose rules a test enforces is `Active` by this +document's own definition. P02d-6 promotes Accessibility with blocking lint/semantic +controls and recorded manual evidence. P02d-7 promotes Performance with the hard checks +above; this does not make advisory or future production measurements enforced. +Permissions remains Adopted until Phase 03 ships permission keys and enforcement. ## Relationship to ADRs diff --git a/frontend/README.md b/frontend/README.md index c6045710..bb9263ae 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -27,7 +27,7 @@ There is **no `extensions/` folder** — ADR-0018 model is data, not code. ## Common Commands ```bash -pnpm install # install workspace deps +pnpm install --frozen-lockfile # install workspace deps pnpm --filter @learnstack/web dev # native HTTPS ingress; prepare TLS first pnpm lint # eslint across the workspace pnpm typecheck # tsc --noEmit across the workspace @@ -40,5 +40,32 @@ remain explicit developer steps. Start `make public-api` in another terminal. The native ingress serves the configured tenant hosts over HTTPS on port 3000. Direct stock `next dev/start` is unsupported. P6 implements localized public catalog/course/lesson pages; its [closeout](../docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-step-4-product-proof-and-accessibility-closeout) -records completed reviews and passing manual accessibility. P7 owns the browser/demo -harness; P6 awaits maintainer PR review and merge. +records completed reviews and passing manual accessibility. P6 is complete and +merged through PR #27 on 2026-10-10. P7 delivers the shared demo and narrow +Playwright library/Lighthouse proof; Phase 06 owns the broader Playwright Test/axe +suite. Automated scores retain P6's separately attributed manual evidence. + +## Seeded browser and Lighthouse proof + +From a fresh checkout without root `.env`, web `.env.local` or +`artifacts/public-demo`, run: + +```bash +# from frontend/, after the locked dependency install above +pnpm exec playwright install --with-deps chromium +cd .. +make demo DEMO_MODE=ci +``` + +The shared lifecycle builds the production API/native renderer, seeds an isolated +tmpfs-backed Compose project, stops its Keycloak and runs nine linked product +pages plus 27 cold audits. Docker, .NET, Node, pnpm, OpenSSL and make are required; +fixed application/Compose ports must be free. It uses disposable private TLS and +certificate-specific browser trust without system hosts/trust changes. Review +`artifacts/public-demo/receipt.json`, all 27 JSON/HTML report pairs and browser +screenshots; proof and owned cleanup must both pass. Follow +[Running tests locally](../.claude/skills/run-tests-locally/SKILL.md#public-demo-managed-browser-and-lighthouse) +for the complete workflow. The packet's Step 4 record and +[branch protection](../.github/CONTRIBUTING.md#branch-protection-settings-on-main) +own real CI receipts and required-check activation; local success alone does not +establish activation. Human foreground `make demo` retains the manual HTTPS setup. diff --git a/frontend/apps/web/README.md b/frontend/apps/web/README.md index db22d27e..1df84221 100644 --- a/frontend/apps/web/README.md +++ b/frontend/apps/web/README.md @@ -31,8 +31,12 @@ records completed verification, reviews and scoped browser/VoiceOver observation P6 is complete and merged through PR #27 on 2026-10-10; its [merge closeout](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-merge-and-closeout-2026-10-10) owns final verification. P7's shared demo/browser package was approved on -2026-10-11, together with ADR-0056. Step 1 implements the foreground lifecycle; -the browser/audit proof remains Step 3. +2026-10-11, together with ADR-0056. The shared local/CI lifecycle and narrow +Playwright library proof are delivered: nine seeded pages and 27 Lighthouse audits. +The [Step 3 record](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-7-step-3--browser-and-audit-proof-2026-10-11) +owns local execution and reviews; the packet's Step 4 record and CONTRIBUTING own +real CI receipts and required-check activation. Phase 06 retains the broader +Playwright Test/axe suite. There is **no `extensions/` folder for vertical-provided components** — per [ADR-0018](../../../docs/decisions/0018-tenant-driven-customization-model.md), @@ -62,3 +66,22 @@ IPv4 loopback port 3000 and serves configured hosts over HTTPS. `/api/healthz` i the exact web health path. The paired API origin comes from private local configuration, never from the visitor's URL. Direct stock `next dev/start` does not establish trusted provenance and is unsupported. + +For the isolated browser/Lighthouse proof, use a fresh checkout without root +`.env`, web `.env.local` or `artifacts/public-demo`: + +```bash +(cd frontend && pnpm install --frozen-lockfile) +(cd frontend && pnpm exec playwright install --with-deps chromium) +make demo DEMO_MODE=ci +``` + +Run these commands from the repository root with Docker and the documented +toolchain available and fixed application/Compose ports free. CI mode generates +private disposable TLS/configuration, uses certificate-specific browser trust and +stops its Keycloak before the product proof. Review the sanitized +`artifacts/public-demo/receipt.json`, 27 JSON/HTML report pairs and browser +screenshots; proof and owned cleanup must both pass. See +[Running tests locally](../../../.claude/skills/run-tests-locally/SKILL.md#public-demo-managed-browser-and-lighthouse) +for artifact freshness and cleanup rules. Local success alone does not establish +required-check activation or a new manual accessibility pass. diff --git a/infra/compose/README.md b/infra/compose/README.md index 09be2650..aa4b18dd 100644 --- a/infra/compose/README.md +++ b/infra/compose/README.md @@ -181,8 +181,28 @@ COMPOSE_PROFILES=gated docker compose --env-file .env -f infra/compose/dev.yml - ``` P02d-7's isolated demo lifecycle uses this overlay with a unique project and -private configuration. Its browser/Lighthouse driver remains Step 3 work. -The full Playwright/axe suite remains Phase 06. Explicit Compose selection ignores +private configuration. The delivered Playwright library walkthrough covers nine +seeded product pages, followed by 27 Lighthouse audits over the same production +API/build/database with its Keycloak stopped. From a fresh checkout without root +`.env`, web `.env.local` or `artifacts/public-demo`: + +```bash +(cd frontend && pnpm install --frozen-lockfile) +(cd frontend && pnpm exec playwright install --with-deps chromium) +make demo DEMO_MODE=ci +``` + +Fixed ports must be free. The lifecycle owns disposable TLS/configuration, +certificate-specific browser trust and cleanup, without modifying system hosts or +trust. Review the sanitized `artifacts/public-demo/receipt.json`, all 27 JSON/HTML +report pairs and browser screenshots; proof and cleanup must both pass. See +[Running tests locally](../../.claude/skills/run-tests-locally/SKILL.md#public-demo-managed-browser-and-lighthouse) +for the complete workflow and +[branch protection](../../.github/CONTRIBUTING.md#branch-protection-settings-on-main) +for actual required-check activation. Local success alone does not establish it. +Human `make demo` retains manual hosts/CA/leaf setup and persistent local data. + +The full Playwright Test/axe suite remains Phase 06. Explicit Compose selection ignores ambient `COMPOSE_*` overrides; use an explicit `--profile gated` for that opt-in. Host ports remain fixed, so a foreign stack/listener is refused rather than stopped. From 100b02217d0babdc0bc1989c93d235a94eff6171 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sun, 11 Oct 2026 05:46:12 +0300 Subject: [PATCH 17/92] fix(ci): complete P7 exit proofs and reserve owned cleanup Pair host, locale and organization HTTP outcomes with raw translation reads on each request connection. Reserve cleanup inside the CI budget and prepare sandbox-enabled managed Chromium on hosted Linux only. Reconcile mutable exit and review guidance with delivered contracts. ADR: 0036, 0040, 0052, 0056 Module: Education, Tenancy --- .claude/skills/run-tests-locally/SKILL.md | 16 + .claude/skills/standards-check/SKILL.md | 18 +- .github/workflows/ci.yml | 37 ++- .../PublicLocaleEvolutionHttpTests.cs | 275 +++++++++++++++++- docs/roadmap/phase-02b-events-auth.md | 15 +- docs/roadmap/phase-02d-exit-evidence.md | 14 +- docs/roadmap/phase-02d-walking-skeleton.md | 102 +++++-- docs/standards/17-code-review.md | 22 +- .../apps/web/src/test/public-demo-ci.test.ts | 91 ++++++ scripts/ci-public-chromium.mjs | 237 +++++++++++++++ scripts/ci-public-chromium.test.mjs | 246 ++++++++++++++++ 11 files changed, 1003 insertions(+), 70 deletions(-) create mode 100644 frontend/apps/web/src/test/public-demo-ci.test.ts create mode 100644 scripts/ci-public-chromium.mjs create mode 100644 scripts/ci-public-chromium.test.mjs diff --git a/.claude/skills/run-tests-locally/SKILL.md b/.claude/skills/run-tests-locally/SKILL.md index 3e32bda4..8ea2ee4e 100644 --- a/.claude/skills/run-tests-locally/SKILL.md +++ b/.claude/skills/run-tests-locally/SKILL.md @@ -219,6 +219,22 @@ It runs nine linked product pages and 27 cold Lighthouse audits over one unchang API/build/database, with real admission pacing and certificate-specific trust. Missing browser installation fails the proof rather than skipping it. +On GitHub-hosted Linux runners, run `node scripts/ci-public-chromium.mjs prepare` +after installation and before the lifecycle, then run +`node scripts/ci-public-chromium.mjs cleanup` in an always-running cleanup step. +The helper discovers Playwright's exact executable and performs a sandbox-enabled +20-second launch smoke with a disposable browser profile. If Ubuntu's AppArmor +user-namespace restriction is enabled, it loads one invocation-owned profile +allowing `userns` only for that exact executable; the source and ownership marker +stay under `RUNNER_TEMP`, and cleanup unloads only that profile. It preserves the +kernel restriction and foreign policies/files. This follows +[Chromium's AppArmor guidance](https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md) +with a literal attachment path. The helper refuses local/self-hosted execution; +it does not change workstation policy. Run its isolated controls with +`node --test scripts/ci-public-chromium.test.mjs`; these use fake commands and +an executable fixture without mutating host AppArmor. A passing Linux job remains +required evidence of the actual runner prerequisite and complete demo/audit flow. + Review `artifacts/public-demo/receipt.json`, the 27 JSON/HTML report pairs and browser screenshots. Proof and owned cleanup must both pass. Archive only this sanitized artifact directory; private configuration, certificates, keys, profiles and raw diff --git a/.claude/skills/standards-check/SKILL.md b/.claude/skills/standards-check/SKILL.md index ef3e44b3..347b60f6 100644 --- a/.claude/skills/standards-check/SKILL.md +++ b/.claude/skills/standards-check/SKILL.md @@ -315,18 +315,20 @@ domain the diff doesn't touch. - [ ] Public-route budgets in [15-performance.md § Initial Budgets](../../../docs/standards/15-performance.md#initial-budgets) and [§ Bundle Size](../../../docs/standards/15-performance.md#bundle-size) respected. - **Not mechanically checkable yet** — `ci.yml`'s `lighthouse-budget` job is a - placeholder. Accepted P02d-5 G44 assigns activation to P02d-7/G44/G45 after - P6 pages. Judge by reading until that harness ships; do not report an unrun - audit as a measured pass or failure. + P02d-7's `make demo DEMO_MODE=ci` proof enforces the ADR-0056 normalized + gzip/CLS/automated-accessibility checks on nine seeded pages; timing medians + remain advisory. Follow + [run-tests-locally](../run-tests-locally/SKILL.md#public-demo-managed-browser-and-lighthouse) + for prerequisites and inspect actual receipts rather than claiming an unrun pass. - [ ] Backend latency budget per module respected. #### `16-accessibility.md` - [ ] WCAG 2.2 AA target. -- [ ] `axe-core` clean on changed components. **Not mechanically checkable yet** — - no `axe` dependency exists in `frontend/`; [Phase 06](../../../docs/roadmap/phase-06-renderer-admin-studio.md) - wires the automated checks, per - [Standards index row 16](../../../docs/standards/README.md). Judge by reading. +- [ ] Applicable blocking jsx-a11y rules, component semantics and P7's automated + Lighthouse accessibility checks pass. Lighthouse's transitive development axe + audit is bounded to its nine pages; the full Playwright Test/direct axe suite + remains [Phase 06](../../../docs/roadmap/phase-06-renderer-admin-studio.md). + Automated scores do not replace the recorded manual checks below. - [ ] Keyboard navigation + focus order reviewed. - [ ] Color contrast verified per brand token. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 739a4e9e..b1095b58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -380,28 +380,38 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: + # Start before checkout. Reserve four minutes for the shared 30-second + # teardown, owned sandbox-policy cleanup and bounded receipt/artifact steps. + - name: Reserve demo cleanup and artifact time + run: echo "PUBLIC_DEMO_DEADLINE=$(( $(date +%s) + 16 * 60 ))" >> "$GITHUB_ENV" + - name: Checkout + timeout-minutes: 2 uses: actions/checkout@v4 with: persist-credentials: false - name: Set up .NET SDK + timeout-minutes: 3 uses: actions/setup-dotnet@v4 with: global-json-file: backend/global.json - name: Set up Node.js + timeout-minutes: 2 uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} - name: Set up pnpm + timeout-minutes: 1 uses: pnpm/action-setup@v4 with: version: ${{ env.PNPM_VERSION }} run_install: false - name: Cache NuGet packages + timeout-minutes: 1 uses: actions/cache@v4 with: path: ~/.nuget/packages @@ -410,20 +420,42 @@ jobs: nuget-${{ runner.os }}- - name: Install pinned browser tools + timeout-minutes: 5 working-directory: frontend run: | pnpm install --frozen-lockfile pnpm exec playwright install --with-deps chromium + - name: Prepare sandbox-enabled managed Chromium + timeout-minutes: 1 + run: | + node --test scripts/ci-public-chromium.test.mjs + node scripts/ci-public-chromium.mjs prepare + # The same entrypoint owns restoration, seven-service readiness, migrations, # contextual seeding, native TLS, production builds and confirmed cleanup. - # Its 12-minute proof deadline is separate from setup and 30-second cleanup. + # Its internal 12-minute proof bound remains; this earlier absolute job + # deadline also bounds cumulative setup/build before owned teardown. - name: Prove the seeded production sites and budgets id: demo - run: make demo DEMO_MODE=ci + run: | + remaining=$(( PUBLIC_DEMO_DEADLINE - $(date +%s) )) + if [ "$remaining" -le 0 ]; then + echo 'Demo setup exhausted the reserved job budget.' >&2 + exit 1 + fi + # SIGINT enters the shared owner teardown; forced termination is a + # failed run, never passing or partial proof evidence. + timeout --signal=INT --kill-after=35s "${remaining}s" make demo DEMO_MODE=ci + + - name: Remove only the owned Chromium sandbox profile + if: always() + timeout-minutes: 1 + run: node scripts/ci-public-chromium.mjs cleanup - name: Record bounded workflow outcome if: always() + timeout-minutes: 1 env: DEMO_OUTCOME: ${{ steps.demo.outcome }} CHECKOUT_SHA: ${{ github.sha }} @@ -442,6 +474,7 @@ jobs: - name: Upload public reports and sanitized receipts only if: always() + timeout-minutes: 1 uses: actions/upload-artifact@v4 with: name: public-demo-lighthouse diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PublicLocaleEvolutionHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PublicLocaleEvolutionHttpTests.cs index 2f773815..4f85383d 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PublicLocaleEvolutionHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PublicLocaleEvolutionHttpTests.cs @@ -10,12 +10,16 @@ using LearnStack.Modules.Tenancy.Application.Contracts.Locales; using LearnStack.Modules.Tenancy.Application.Contracts.PublicReads; using LearnStack.Modules.Tenancy.Application.Contracts.Seeding; +using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; using LearnStack.Modules.Tenancy.Infrastructure.Persistence; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Persistence; using LearnStack.SharedKernel.Results; using LearnStack.SharedKernel.Tenancy; using LearnStack.Tools.Seeder; using MediatR; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; @@ -92,6 +96,10 @@ public async Task Third_locale_authored_through_api_commands_serves_exact_new_ur course.Status.Should().Be("Published"); course.ContentAccess.Should().Be("public"); + var englishContext = new SeedTenantContext(SeedData.English.TenantId, null); + var englishBefore = (await SendAsync(host, englishContext, new GetTenantSeedStateQuery())).State!; + await SendAsync(host, englishContext, new AddTenantLocaleCommand(englishBefore.Version, "fr", true, false, 1)); + var auditsAfterAuthoring = await CountsAsync(database.AppConnectionString, organizationContext); var catalog = await JsonAsync(client, Root + "?locale=fr"); var detail = await JsonAsync(client, $"{Root}/{FrenchCourseSlug}?locale=fr"); @@ -108,10 +116,14 @@ public async Task Third_locale_authored_through_api_commands_serves_exact_new_ur } foreach (var path in new[] { "/foundation", "/studyo-temelleri", "/foundation/lessons/tree-pose", "/studyo-temelleri/lessons/agac-durusu" }) await NotFoundAsync(client, Root + path + "?locale=fr"); - // English's enabled locale admits the request, then tenant isolation hides - // the new Yoga course and lesson independently of locale admission. - await NotFoundAsync(client, $"http://{SeedData.English.Host}{Root}/{FrenchCourseSlug}?locale=en"); - await NotFoundAsync(client, $"http://{SeedData.English.Host}{Root}/{FrenchCourseSlug}/lessons/{FrenchLessonSlug}?locale=en"); + // Both hosts now admit French, and these exact URLs succeeded on Yoga. + // The foreign refusal therefore discriminates tenant isolation from an + // unsupported locale or a translation missing on the owning tenant. + var englishFrench = await JsonAsync(client, $"http://{SeedData.English.Host}{Root}?locale=fr"); + englishFrench["locale"]!.GetValue<string>().Should().Be("fr"); + englishFrench["items"]!.AsArray().Should().BeEmpty(); + await NotFoundAsync(client, $"http://{SeedData.English.Host}{Root}/{FrenchCourseSlug}?locale=fr"); + await NotFoundAsync(client, $"http://{SeedData.English.Host}{Root}/{FrenchCourseSlug}/lessons/{FrenchLessonSlug}?locale=fr"); await NotFoundAsync(client, $"{Root}/foundation/lessons/{FrenchLessonSlug}?locale=fr"); var siteAfter = await JsonAsync(client, Site); @@ -182,6 +194,89 @@ public async Task Organization_branding_writer_is_refused_with_denied_audit_and_ observation.Reads.Should().BeGreaterThan(0); } + [Fact] + public async Task Public_host_locale_and_organization_contracts_hold_together_with_exact_request_connection_translation_scope() + { + await using var database = await SeededDatabaseAsync(); + var observation = new PublicReadObservation(); + var scopes = new TranslationScopeObservation(); + await using var host = ApiHost(database, observation, scopes); + using var client = Client(host, Yoga.Host); + var context = new SeedTenantContext(Yoga.TenantId, null); + const string tenantHost = "yoga-tenant-scope.example.test"; + var mapping = await SendAsync(host, context, new MapHostToTenantCommand(tenantHost, null, true, true)); + mapping.TenantId.Should().Be(Yoga.TenantId); + mapping.OrganizationId.Should().BeNull(); + var tenant = (await SendAsync(host, context, new GetTenantSeedStateQuery())).State!; + await SendAsync(host, context, new AddTenantLocaleCommand(tenant.Version, "fr", true, false, 2)); + var probe = await AuthorLocaleProbeAsync(host, context); + scopes.Expect(SeedData.English.Host, SeedData.English, null, null); + scopes.Expect(Yoga.Host, Yoga, Yoga.DefaultOrganization.OrganizationId, probe); + scopes.Expect(tenantHost, Yoga, null, probe); + var requests = 0; + + // The same supported route and locale positively resolve B's unique + // course, then conceal it on A's already live host. + var yogaOnly = Yoga.Curriculum!.Courses.Single(course => course.SlugKey == "shared-practice"); + var yogaOnlySlug = yogaOnly.Translations.Single(row => row.Locale == "en").Slug; + JsonNode.DeepEquals(await Read(Yoga.Host, $"/{yogaOnlySlug}?locale=en"), + ExpectedSeedCourse(Yoga, yogaOnly.Id, "en")).Should().BeTrue(); + await Hidden(SeedData.English.Host, $"/{yogaOnlySlug}?locale=en"); + + // Stable source identities select the full expected public payloads; + // neither accepted public DTO exposes its source ID. + var englishShared = SeedData.English.Curriculum!.Courses.Single(course => course.SlugKey == "foundations"); + var yogaShared = Yoga.Curriculum.Courses.Single(course => course.SlugKey == "studio-foundations"); + var englishDetail = await Read(SeedData.English.Host, "/foundation?locale=en"); + var yogaDetail = await Read(Yoga.Host, "/foundation?locale=en"); + JsonNode.DeepEquals(englishDetail, ExpectedSeedCourse(SeedData.English, englishShared.Id, "en")).Should().BeTrue(); + JsonNode.DeepEquals(yogaDetail, ExpectedSeedCourse(Yoga, yogaShared.Id, "en")).Should().BeTrue(); + JsonNode.DeepEquals(englishDetail, yogaDetail).Should().BeFalse(); + + var englishProbe = await Read(Yoga.Host, "/locale-probe?locale=en"); + englishProbe["course"]!["title"]!.GetValue<string>().Should().Be("Locale probe"); + Slugs(englishProbe["lessons"]!["items"]!).Should().Equal("english-lesson"); + var turkishProbe = await Read(Yoga.Host, "/yerel-prova?locale=tr-TR"); + Slugs(turkishProbe["lessons"]!["items"]!).Should().Equal("yalniz-turkce"); + var translatedLesson = await Read(Yoga.Host, "/locale-probe/lessons/english-lesson?locale=en"); + translatedLesson["lesson"]!["title"]!.GetValue<string>().Should().Be("English lesson"); + var otherLocaleLesson = await Read(Yoga.Host, "/yerel-prova/lessons/yalniz-turkce?locale=tr-TR"); + otherLocaleLesson["lesson"]!["title"]!.GetValue<string>().Should().Be("Yalnız Türkçe"); + await Hidden(Yoga.Host, "/locale-probe/lessons/yalniz-turkce?locale=en"); + await Hidden(Yoga.Host, "/locale-probe?locale=fr"); + var frenchCatalog = await Read(Yoga.Host, "?locale=fr"); + frenchCatalog["locale"]!.GetValue<string>().Should().Be("fr"); + Slugs(frenchCatalog["items"]!).Should().BeEmpty("French is enabled but none of these published courses has a French translation"); + + var organizationCatalog = await Read(Yoga.Host, "?locale=en"); + Slugs(organizationCatalog["items"]!).Should().Equal( + SeedCatalogSlugs(Yoga, Yoga.DefaultOrganization.OrganizationId, "en").Append("locale-probe")); + Slugs((await Read(Yoga.Host, "?locale=tr-TR"))["items"]!).Should().Contain("yerel-prova"); + var sibling = Yoga.Curriculum.Courses.Single(course => course.OrganizationId == Yoga.SecondOrganization.OrganizationId); + await Hidden(Yoga.Host, "/" + sibling.Translations.Single(row => row.Locale == "en").Slug + "?locale=en"); + var tenantCatalog = await Read(tenantHost, "?locale=en"); + Slugs(tenantCatalog["items"]!).Should().Equal(SeedCatalogSlugs(Yoga, null, "en").Append("locale-probe")); + await Hidden(tenantHost, "/foundation?locale=en"); + JsonNode.DeepEquals(await Read(tenantHost, $"/{yogaOnlySlug}?locale=en"), + ExpectedSeedCourse(Yoga, yogaOnly.Id, "en")).Should().BeTrue(); + + // Each successful/hidden HTTP request passes the raw satellite observer + // exactly once on its own physically read-only application transaction. + scopes.Reads.Should().Be(requests); + observation.Reads.Should().Be(requests); + + async Task<JsonNode> Read(string hostname, string path) + { + requests++; + return await JsonAsync(client, $"http://{hostname}{Root}{path}"); + } + async Task Hidden(string hostname, string path) + { + requests++; + await NotFoundAsync(client, $"http://{hostname}{Root}{path}"); + } + } + private async Task<DisposableSchemaDatabase> SeededDatabaseAsync() { var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); @@ -199,7 +294,8 @@ private async Task<DisposableSchemaDatabase> SeededDatabaseAsync() } } - private WebApplicationFactory<Program> ApiHost(DisposableSchemaDatabase database, PublicReadObservation observation) => + private WebApplicationFactory<Program> ApiHost(DisposableSchemaDatabase database, PublicReadObservation observation, + TranslationScopeObservation? scopes = null) => factory.WithWebHostBuilder(builder => builder .UseEnvironment(Environments.Development) .UseSetting("ConnectionStrings:Default", database.AppConnectionString) @@ -207,10 +303,16 @@ private WebApplicationFactory<Program> ApiHost(DisposableSchemaDatabase database .ConfigureTestServices(services => { services.AddSingleton(observation); + services.AddHttpContextAccessor(); services.AddScoped<PublicTenantConfigurationReader>(); // This existing observer checks the actual enlisted transaction's // READ ONLY, current_user, superuser and BYPASSRLS flags. - services.AddScoped<IPublicTenantConfigurationReader, ObservedPublicConfigurationReader>(); + services.AddScoped<ObservedPublicConfigurationReader>(); + services.AddScoped<IPublicTenantConfigurationReader>(provider => scopes is null + ? provider.GetRequiredService<ObservedPublicConfigurationReader>() + : new RequestTranslationScopeReader(provider.GetRequiredService<ObservedPublicConfigurationReader>(), + provider.GetRequiredService<IUnitOfWork>(), provider.GetRequiredService<ITenantContext>(), + provider.GetRequiredService<IHttpContextAccessor>(), scopes)); })); private static HttpClient Client(WebApplicationFactory<Program> host, string hostname) => @@ -231,6 +333,76 @@ private static async Task<T> SendAsync<T>(WebApplicationFactory<Program> host, I finally { accessor.Current = previous; } } + private static async Task<LocaleProbe> AuthorLocaleProbeAsync(WebApplicationFactory<Program> host, SeedTenantContext context) + { + var course = await SendAsync(host, context, new CreateCourseCommand(Guid.CreateVersion7(), "locale-probe", "public")); + course = await SendAsync(host, context, new AddCourseTranslationCommand(course.Id, course.Version, + "en", "Locale probe", null, "locale-probe")); + course = await SendAsync(host, context, new AddCourseTranslationCommand(course.Id, course.Version, + "tr-TR", "Yerel prova", null, "yerel-prova")); + var contentType = Yoga.Curriculum!.ContentType; + const string body = """{"pose":"Probe pose","instruction":"Hold steadily.","breathing":"Breathe slowly."}"""; + var turkish = await SendAsync(host, context, new CreateLessonCommand(Guid.CreateVersion7(), course.Id, 0, + contentType.Key, contentType.SchemaVersion)); + turkish = await SendAsync(host, context, new AddLessonTranslationCommand(turkish.Id, turkish.Version, + "tr-TR", "Yalnız Türkçe", "yalniz-turkce", body)); + await SendAsync(host, context, new PublishLessonCommand(turkish.Id, turkish.Version)); + var english = await SendAsync(host, context, new CreateLessonCommand(Guid.CreateVersion7(), course.Id, 1, + contentType.Key, contentType.SchemaVersion)); + english = await SendAsync(host, context, new AddLessonTranslationCommand(english.Id, english.Version, + "en", "English lesson", "english-lesson", body)); + await SendAsync(host, context, new PublishLessonCommand(english.Id, english.Version)); + await SendAsync(host, context, new PublishCourseCommand(course.Id, course.Version)); + return new LocaleProbe(context.TenantId, course.Id, turkish.Id, english.Id); + } + + private static JsonNode ExpectedSeedCourse(SeedTenant tenant, Guid sourceId, string locale) + { + var curriculum = tenant.Curriculum!; + var course = curriculum.Courses.Single(row => row.Id == sourceId); + var translation = course.Translations.Single(row => row.Locale == locale); + curriculum.Taxonomy.Key.Should().Be(course.LevelTaxonomyKey); + curriculum.Taxonomy.SchemaVersion.Should().Be(course.LevelTaxonomySchemaVersion); + var band = curriculum.Taxonomy.Bands.Single(row => row.Key == course.LevelBandKey); + var alternates = curriculum.Locales.Where(row => row.IsEnabled && row.Locale != locale) + .OrderBy(row => row.Sort).ThenBy(row => row.Locale, StringComparer.Ordinal) + .Select(row => course.Translations.SingleOrDefault(item => item.Locale == row.Locale)) + .Where(row => row is not null).Select(row => new { locale = row!.Locale, slug = row.Slug }).ToArray(); + object? outline = course.ContentAccess == "public" ? new + { + items = course.Lessons.Where(row => row.Status == "Published" && row.Translations.Any(item => item.Locale == locale)) + .OrderBy(row => row.Sort).ThenBy(row => row.Id).Select(row => new + { + slug = row.Translations.Single(item => item.Locale == locale).Slug, + title = row.Translations.Single(item => item.Locale == locale).Title, + sort = row.Sort, + }).ToArray(), + pageInfo = new { nextCursor = (string?)null, previousCursor = (string?)null, hasNext = false, hasPrevious = false }, + } : null; + return JsonSerializer.SerializeToNode(new + { + locale, + course = new + { + slug = translation.Slug, + title = translation.Title, + summary = translation.Summary, + contentAccess = course.ContentAccess, + level = new { state = "ready", label = new { value = band.DisplayName[locale], locale } }, + }, + alternates, + lessons = outline, + })!; + } + + private static string[] SeedCatalogSlugs(SeedTenant tenant, OrganizationId? organization, string locale) => + tenant.Curriculum!.Courses.Where(row => row.Status == "Published" + && (row.OrganizationId is null || row.OrganizationId == organization)) + .Select(row => row.Translations.SingleOrDefault(item => item.Locale == locale)) + .Where(row => row is not null).Select(row => row!.Slug).ToArray(); + + private static string[] Slugs(JsonNode items) => items.AsArray().Select(item => item!["slug"]!.GetValue<string>()).ToArray(); + private static void AssertFrenchResponses(JsonNode catalog, JsonNode course, JsonNode lesson) { var summary = JsonSerializer.SerializeToNode(new { slug = FrenchCourseSlug, title = FrenchCourseTitle, summary = FrenchSummary, contentAccess = "public", level = (object?)null }); @@ -266,7 +438,7 @@ private static async Task<JsonNode> JsonAsync(HttpClient client, string path) { using var response = await client.GetAsync(path); response.StatusCode.Should().Be(HttpStatusCode.OK, await response.Content.ReadAsStringAsync()); - response.Headers.CacheControl?.NoStore.Should().BeTrue(); + (response.Headers.CacheControl?.NoStore).Should().BeTrue(); return (await response.Content.ReadFromJsonAsync<JsonNode>())!; } @@ -274,7 +446,7 @@ private static async Task NotFoundAsync(HttpClient client, string path) { using var response = await client.GetAsync(path); response.StatusCode.Should().Be(HttpStatusCode.NotFound, await response.Content.ReadAsStringAsync()); - response.Headers.CacheControl?.NoStore.Should().BeTrue(); + (response.Headers.CacheControl?.NoStore).Should().BeTrue(); response.Content.Headers.ContentType!.MediaType.Should().Be("application/problem+json"); var problem = (await response.Content.ReadFromJsonAsync<JsonNode>())!; problem["code"]!.GetValue<string>().Should().Be("not_found"); @@ -342,4 +514,91 @@ SELECT count(*) FROM audit_log private sealed record HistoryRow(string MigrationId, string ProductVersion); private sealed record WriteCounts(long Settings, long Audits, long SuccessfulBrandingAudits); + private sealed record LocaleProbe(TenantId TenantId, Guid CourseId, Guid TurkishLessonId, Guid EnglishLessonId); + private sealed record TranslationRow(Guid TenantId, Guid? OrganizationId, Guid RootId, string Locale, string Slug); + private sealed record TranslationScope(TenantId TenantId, OrganizationId? OrganizationId, + TranslationRow[] Courses, TranslationRow[] Lessons); + + private sealed class TranslationScopeObservation + { + private readonly Dictionary<string, TranslationScope> _expected = new(StringComparer.Ordinal); + private int _reads; + public int Reads => Volatile.Read(ref _reads); + + public void Expect(string hostname, SeedTenant tenant, OrganizationId? organization, LocaleProbe? probe) + { + // Raw scope deliberately includes every seeded translation regardless + // of publication/access/locale; these are RLS assertions, not the + // narrower public-eligibility projection. + var courses = tenant.Curriculum!.Courses + .Where(row => row.OrganizationId is null || row.OrganizationId == organization).ToArray(); + var courseRows = courses.SelectMany(course => course.Translations.Select(row => + new TranslationRow(tenant.TenantId.Value, course.OrganizationId?.Value, course.Id, row.Locale, row.Slug))).ToList(); + var lessonRows = courses.SelectMany(course => course.Lessons.SelectMany(lesson => lesson.Translations.Select(row => + new TranslationRow(tenant.TenantId.Value, course.OrganizationId?.Value, lesson.Id, row.Locale, row.Slug)))).ToList(); + if (probe is not null) + { + probe.TenantId.Should().Be(tenant.TenantId); + courseRows.AddRange([ + new TranslationRow(probe.TenantId.Value, null, probe.CourseId, "en", "locale-probe"), + new TranslationRow(probe.TenantId.Value, null, probe.CourseId, "tr-TR", "yerel-prova"), + ]); + lessonRows.AddRange([ + new TranslationRow(probe.TenantId.Value, null, probe.TurkishLessonId, "tr-TR", "yalniz-turkce"), + new TranslationRow(probe.TenantId.Value, null, probe.EnglishLessonId, "en", "english-lesson"), + ]); + } + courseRows.Should().NotBeEmpty(); + lessonRows.Should().NotBeEmpty(); + _expected.Add(hostname, new TranslationScope(tenant.TenantId, organization, courseRows.ToArray(), lessonRows.ToArray())); + } + + public async Task AssertAsync(string hostname, ITenantContext context, IUnitOfWork unit, CancellationToken cancellationToken) + { + var expected = _expected[hostname]; + context.TenantId.Should().Be(expected.TenantId); + context.OrganizationId.Should().Be(expected.OrganizationId); + context.HostScope.Should().NotBeNull(); + context.HostScope!.TenantId.Should().Be(expected.TenantId); + context.HostScope.OrganizationId.Should().Be(expected.OrganizationId); + unit.Mode.Should().Be(TransactionMode.ReadOnly); + unit.HasActiveTransaction.Should().BeTrue(); + unit.IsTenantContextIssuedOn(unit.Transaction).Should().BeTrue(); + await SchemaQueries.AssertApplicationRoleAsync(unit.Connection); + // No WHERE clause, EF query filter, replacement connection, or scope + // announcement: only the request's already enlisted RLS frame filters + // these two complete satellite-table reads. + var courses = await ReadRowsAsync(unit, + "SELECT tenant_id, organization_id, course_id, locale, slug FROM course_translations", cancellationToken); + var lessons = await ReadRowsAsync(unit, + "SELECT tenant_id, organization_id, lesson_id, locale, slug FROM lesson_translations", cancellationToken); + courses.Should().NotBeEmpty(); + lessons.Should().NotBeEmpty(); + courses.Should().BeEquivalentTo(expected.Courses, "raw course translations must match the exact scope of host {0}", hostname); + lessons.Should().BeEquivalentTo(expected.Lessons, "raw lesson translations must match the exact scope of host {0}", hostname); + Interlocked.Increment(ref _reads); + } + + private static async Task<List<TranslationRow>> ReadRowsAsync(IUnitOfWork unit, string sql, CancellationToken cancellationToken) + { + await using var command = new NpgsqlCommand(sql, (NpgsqlConnection)unit.Connection, (NpgsqlTransaction?)unit.Transaction); + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + var rows = new List<TranslationRow>(); + while (await reader.ReadAsync(cancellationToken)) + rows.Add(new TranslationRow(reader.GetGuid(0), reader.IsDBNull(1) ? null : reader.GetGuid(1), + reader.GetGuid(2), reader.GetString(3), reader.GetString(4))); + return rows; + } + } + + private sealed class RequestTranslationScopeReader(ObservedPublicConfigurationReader reader, IUnitOfWork unit, + ITenantContext context, IHttpContextAccessor http, TranslationScopeObservation observation) : IPublicTenantConfigurationReader + { + public async Task<Result<PublicTenantConfiguration>> ReadAsync(CancellationToken cancellationToken) + { + var result = await reader.ReadAsync(cancellationToken); + await observation.AssertAsync(http.HttpContext!.Request.Host.Host, context, unit, cancellationToken); + return result; + } + } } diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index a2239036..0c2e64c9 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -1270,13 +1270,14 @@ shape depends on an open gate say so, and are written when that gate is Accepted amendment written after the code it governs is a record nobody can disagree with — the lesson Packet 9 recorded for its own readings. The mitigation is the decision pass each packet opens with: its decisions are its first commit, and its code follows them. -- **A gate is answered before the ground it stands on exists.** Several premises the - register cites are code [Phase 02d](phase-02d-walking-skeleton.md) has not yet - written: the first `/api/v1/*` endpoints, which **G14** and **G20** bound; the local - hosts **G12**'s transport serves; and the socket-peer limiter G14 describes, which - Phase 02d's server-rendered pages reach through one peer. Earlier packets move later - premises the same way: the dispatcher's keyed data source can change the connection - allow-list before **G9** decides the job storage's entry in it. The mitigation is +- **A gate relies on a premise that an earlier packet has moved.** + [Phase 02d](phase-02d-walking-skeleton.md) delivered public GET/HEAD endpoints, + retained native HTTPS tenant hosts and coordinated canonical visitor/physical-peer + admission. **G14**, **G20** and **G12** must use those delivered contracts, including + single-bootstrap rendering and exact peer accounting, rather than the former + socket-peer-only assumption. Earlier packets move later premises the same way: + the dispatcher's keyed data source can change the connection allow-list before + **G9** decides the job storage's entry in it. The mitigation is [Roadmap § Decision Timing](README.md#decision-timing): `P02b-0` re-verifies the register once Phase 02d exits, and each gate closes in the decision pass of the first packet it shapes. diff --git a/docs/roadmap/phase-02d-exit-evidence.md b/docs/roadmap/phase-02d-exit-evidence.md index ed796514..4d297c33 100644 --- a/docs/roadmap/phase-02d-exit-evidence.md +++ b/docs/roadmap/phase-02d-exit-evidence.md @@ -92,9 +92,9 @@ checks supplement that record; they do not manufacture new human evidence. | D30 | The required `frontend` job refusing a Vitest run that reports a skipped or todo case in every workspace package that carries tests, proven with … | Guarded workspace runner and planted skip/todo/empty/omitted-workspace controls; No_Architecture_Test_Is_Skippable. | B, F | | D31 | The accessibility review Accessibility Standards requires of every new screen, recorded in the renderer pull request's description: a manual keyboard walkthrough of each public … | PR27 description preserves attributed prior manual smoke and links Original manual evidence to P6 Step4; P6 manual/VoiceOver closeout. | H | | D32 | The tenant-branching check **G20** selects, Registered in the first pass that uses it and Implemented with its companion before exit. | Step2 DomainGenericityTests.DemoIdentities.cs, demo-genericity.test.ts, Production_Projects_Do_Not_Reference_The_Seeder, schema-replacement fixture. | B, I, F, M | -| D33 | `make demo` and its stop behaviour per **G45**, with README § Quickstart, the closing output of `scripts/seed.sh` and the `local-dev-setup` and `seed-tenant` skills updated … | Step1 demo-lifecycle controls/shared supervisor; README/seed/local-dev/seed skills partly updated. | J, M | +| D33 | `make demo` and its stop behaviour per **G45**, with README § Quickstart, the closing output of `scripts/seed.sh` and the `local-dev-setup` and `seed-tenant` skills updated … | Step1 demo-lifecycle controls/shared supervisor; README, seed output and local-dev/seed/test skills updated to the delivered shared lifecycle. | J, M | | D34 | The full-stack Lighthouse job and its committed configuration, if **G44** activates it here. | Step3 pinned browser/Lighthouse configuration, gatherer/report/gzip/admission controls and local 27-report feasibility. | J | -| D35 | The standards index rows this phase makes stale — the notes on Frontend Coding, Frontend Architecture and Accessibility Standards, the table count on Database … | Database counts updated previously; Accessibility already Active. Performance still Adopted and four P7 rules still Registered. Performance is Active for the implemented hard P7 checks; Permissions remains Adopted. | B, F, J, M | +| D35 | The standards index rows this phase makes stale — the notes on Frontend Coding, Frontend Architecture and Accessibility Standards, the table count on Database … | Database counts updated previously; Accessibility already Active. Performance is Active for implemented hard P7 checks; all four behavioral P7 rules are Implemented. Permissions remains Adopted. | B, F, J, M | | D36 | 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 … | Phase05 inherited baseline already preserves rows/IDs/URLs/order/scope/body/pins/policy. | M | ## Completion criteria @@ -128,7 +128,7 @@ checks supplement that record; they do not manufacture new human evidence. | C25 | A duplicate slug written through the command **G11** names returns `Result.Fail(business_rule_violation)` and does not throw. A slug outside the shape **G9** settles is refused … | EducationWriterTests, EducationWriterValidationTests, EducationConstraintTests. | B, I | | C26 | A publication-state transition **G3** forbids answers `Result.Fail(business_rule_violation)` from the command, does not throw, and writes nothing (`backend integration`, plus a command or aggregate unit … | EducationAggregateTests/EducationWriterTests forbidden lifecycle and no mutation. | B, I | | C27 | A branding value outside **G16**'s grammar — `red;}body{background:url(//x)}`, a string containing `</style>` — never reaches a rendered document, and a refused write commits neither … | TenancyWriterTests branding refusal/atomicity, BrandingThemeTests, product theme defenses. | B, I, F | -| C28 | A branding key written as an organization-scoped `tenant_settings` row behaves as **G16** (e) records, checked request-level through a host mapped to the organization: refused … | PublicLocaleEvolutionHttpTests.Organization_branding_writer_is_refused_with_denied_audit_and_mapped_host_keeps_tenant_palette; local focused case passed; one expected Denied audit, no successful setting mutation; final I pending. | I, M | +| C28 | An organization-scoped branding command is refused without setting mutation; its Denied audit is retained, and the live organization host keeps the tenant palette. | PublicLocaleEvolutionHttpTests.Organization_branding_writer_is_refused_with_denied_audit_and_mapped_host_keeps_tenant_palette; local focused case passed; one expected Denied audit, no successful setting mutation; final I pending. | I, M | | C29 | If **G19** includes a write-time rule, a lesson write holding a disallowed URL is refused with details naming the JSON pointer, and nothing is … | G19 Accepted text profile introduces no active URL sink or scheme write-time rule. | N | | C30 | Whatever **G18** refuses at save — which may include a presentation entry naming a property absent from `properties`, a malformed label map or a … | TextCardPresentationTests, customization command/schema validation tests. | B | | C31 | A course write naming a taxonomy revision or band key the tenant does not declare, including one only the other tenant declares, returns `validation_failed` … | EducationWriterTests and EducationWriterValidationTests taxonomy/band binding validation. | B, I | @@ -140,9 +140,9 @@ checks supplement that record; they do not manufacture new human evidence. | C37 | Every cache family this phase adds appears in the cheat sheet, the Observability Standards family list and the `cache.name` mapping, and none of its … | InMemoryCacheServiceTests and three named cache-family carrier lists. | B, M | | C38 | For a request in an enabled `tr-TR`, a label authored only under `tr` resolves as the chain **G24** records, and a value missing in … | PublicEducationDisplayTests and locale-resolution/projection tests; P3/P4 fallback records. | B, I | | C39 | The out-of-band setter guard stays green: either the setter table is unchanged, or the ADR-0040 amendment and Security Standards row land with the loader. | Out-of-band setter guards and P2/P3 approved setter decisions. | B, M | -| C40 | In one test, a course slug that exists only in tenant B returns `200` with that course on tenant B's host and `404` on … | PublicEducationHttpTests and TenantIsolationHttpTests paired positive/negative host cases. | I, M | -| C41 | In the same test, a course returns `200` at its slug in a locale it is translated into and `404` for that slug under … | PublicEducationHttpTests.Course_and_lesson_use_exact_translations_and_real_alternate_url_pairs and Missing_exact_translation_never_uses_default_content_or_advertises_disabled_alternates. | I | -| C42 | On the organization host, a sibling organization's course is absent from the catalog and its slug returns `404`, while a tenant-wide course and its … | PublicEducationHttpTests.Organization_host_never_serves_a_sibling_organization and isolation suite raw satellite cases. | I | +| C40 | In one test, a course slug that exists only in tenant B returns `200` with that course on tenant B's host and `404` on … | PublicLocaleEvolutionHttpTests.Public_host_locale_and_organization_contracts_hold_together_with_exact_request_connection_translation_scope pairs foreign-only/shared slugs with full seed-derived payloads. | I | +| C41 | In the same test, a course returns `200` at its slug in a locale it is translated into and `404` for that slug under … | The same PublicLocaleEvolutionHttpTests case pairs enabled/untranslated French with translated EN/TR course and lesson lists. | I | +| C42 | On the organization host, a sibling organization's course is absent from the catalog and its slug returns `404`, while a tenant-wide course and its … | The same PublicLocaleEvolutionHttpTests case proves three host scopes and exact raw satellite row sets on each of its 17 HTTP requests' own enlisted connection. | I | | C43 | A draft course is absent from the catalog, and its detail slug and every lesson requested under it answer per **G29**; a lesson of … | PublicEducationHttpTests.Course_draft_branch_foreign_and_unknown_are_uniformly_hidden and Lesson_masks_draft_parent_draft_restricted_scope_wrong_parent_and_foreign_rows. | I | | C44 | Each of the reads without `locale` returns `400` `validation_failed` naming `locale`, never a `500` or a defaulted locale; sending a different `X-Locale` or `Accept-Language` … | PublicEducationHttpTests query/detail/unsupported-locale cases including Well_formed_unsupported_locale_is_400_and_headers_do_not_replace_explicit_locale. | I | | C45 | A cursor value the catalog list cannot read — malformed or truncated — returns `400` `validation_failed` naming `cursor`, never `500`. Walking `nextCursor` with a … | PublicEducationHttpTests query-shape/cursor/seek cases, PublicReadProtocolTests and OpenAPI exact parameter controls. | B, I, O | @@ -178,7 +178,7 @@ checks supplement that record; they do not manufacture new human evidence. | C75 | No Education type, member, column, key or DTO name carries a forbidden domain term, and Education references Customization only through its `Application.Contracts` assembly (`backend`). | DomainGenericityTests and module-dependency guards. | B | | C76 | If **G44** activates it in this phase, the Lighthouse job runs real steps under a name without "(deferred to Phase 02d)", audits the URL … | Step3 complete local nineURL x threeRun feasibility, report/gzip/admission/offender controls and zero429 positive observer. | J | | C77 | Every register row is closed in the form **G1** chose; every Deliverable is observed by at least one criterion; no catalogue row whose Phase … | Four P7 behavioral entries are Implemented with Node owners, planted controls and the always-running public-demo job; CorpusConsistencyTests enforces status/count alignment. | B, I, F, J, M | -| C78 | Every row in § Explicitly not in this phase names a phase whose own document carries that capability in its scope, its deliverables or … | Owner audit X01-X39 below; current output/seed-host carriers partly stale. | M | +| C78 | Every row in § Explicitly not in this phase names a phase whose own document carries that capability in its scope, its deliverables or … | Owner audit X01–X39 below and synchronized current output/retained seed-host carriers. | M | | C79 | The documentation Deliverables are in the tree at exit: the Education spec's `README.md` and `permissions.md` beside `audit.md`, with its state diagram and the eligibility … | Existing Education/Customization/Testing/schema/skill files and Phase05 preservation baseline. | M | C07 is conditional: G32 retains `*.learnstack.local`, so no seed-host migration diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 043ca445..34d87b2e 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -6172,7 +6172,7 @@ complete. Step 4 owns CI enforcement and phase exit; neither is claimed here. ### P02d-7 Step 4 — CI enforcement and phase exit (2026-10-11) -**Implementation in progress; both independent review rounds pending.** +**Implementation delivered; round 1 fixes complete, round 2 pending.** The always-running `public demo + lighthouse` job replaces the disabled placeholder. It installs the exact locked browser tools and managed Chromium, then invokes `make demo DEMO_MODE=ci` over the shared seven-service lifecycle. @@ -6218,10 +6218,46 @@ session/BFF/authenticated-rate-limit decisions. with zero skips. Release build has zero warnings/errors; 1,705 unit, 203 architecture, four contract and 180 Docker-free integration cases pass, as do all 1,500 guarded frontend cases. Typecheck, lint, whitespace and 2,042 relative links (666 fragments) -pass. The complete Docker suite is running. Two fresh review rounds, a real +pass. All 871 Docker integration cases pass with zero skips; execution guards pass. +Two fresh review rounds, a real successful PR job and the authorized seventh-context activation/read-back remain. No PR, live check activation or phase exit is claimed yet. +#### Step 4 review round 1 (2026-10-11) + +Fresh GPT-6-astra (xhigh) and GPT-6.1-sol (xhigh) agents reviewed +`06d46ad..bf5bc9c` for runtime/CI ownership and corpus/exit evidence. Their +confirmed findings concern proof completeness and CI prerequisites, not a +production contract defect. C40–C42 now share one HTTP test: 17 requests pair +foreign-only/shared slugs, exact-locale omissions and organization/tenant host +controls with exact raw translation sets on each request's own enlisted +application connection. The third-locale case now enables French on the foreign +host and proves its positive catalog before checking the same French detail +URLs are hidden. Missing Cache-Control can no longer bypass its assertions. + +The CI job starts an absolute deadline before checkout, bounds setup steps and +interrupts the shared lifecycle by minute 16, reserving four minutes inside the +unchanged 20-minute ceiling for owned cleanup, policy cleanup and artifacts. +Four controls execute the actual workflow shell: first-step reservation, +remaining-budget arguments and two exhausted-budget refusals. An actual GNU +timeout → make → shared Node-owner experiment exits 124 only after child cleanup. +The internal 12-minute serial proof deadline remains unchanged. + +The GitHub-hosted Linux prerequisite loads only an invocation-owned AppArmor +userns profile for the exact managed Chromium when the kernel restriction needs +it, then performs a sandbox-enabled launch smoke. Fifteen isolated controls +exercise ownership, literal paths, failed load/removal and cleanup without +changing workstation policy. A real Linux job remains required; the initial +runner risk was source-supported inference, not a reproduced hosted failure. +The exit matrix, stale review guidance, Phase 02b risk premises and mutable +renderer/writer scope descriptions now match delivered contracts. + +All three focused locale/branding Docker cases pass with zero skips and the +execution guard. The guarded frontend rerun passes 1,504 cases (1,444 web + 60 +SDK), zero failures/skips/todos; typecheck, lint, scoped backend formatting and +whitespace pass. The initial full backend totals above precede the added combined +case. Final complete PR CI, round 2 and protection read-back remain required. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved @@ -6366,10 +6402,11 @@ scope, with explicit data-preservation obligations. The distinguish this shape from Phase 05's target hierarchy. - `Course` — tenant-owned, optionally organization-scoped, with independent - `draft` / `published` state under [ADR-0048](../decisions/0048-walking-skeleton-publication.md). + `draft` / `published` state and explicit content-access policy under + [ADR-0050](../decisions/0050-publication-and-course-content-access.md). Its title, summary and per-locale slug live in `course_translations`; its optional - level reference pins an exact taxonomy revision and band. G5's write validation and - unresolved-band presentation remain with their later packets. No versioning, + level reference pins an exact taxonomy revision and band. P02d-2/4 delivered + G5's write validation and bounded unresolved-band presentation. No versioning, programs or cohorts. - `Lesson` — ordered within a course; its title and per-locale slug likewise live in `lesson_translations`. Its body is drawn the way @@ -6550,11 +6587,10 @@ module's audit catalogue and registered in both composition roots. [Audit Coverage Standards](../standards/18-audit-coverage.md). The command set and re-run behaviour are **G11**; the Customization contract the lesson writer calls, and which revisions it may bind, are **G12**. -- **Tenancy commands** raise `tenancy.locale.write` (SHOULD) and `tenancy.setting.write` - (MUST), both `(planned)` today. The setting command lands only once **G17** is - answered, because no command writing `tenant_settings` precedes the `[PiiSensitive]` - decision ([Phase 03](phase-03-identity-admin.md)); its keys and value checks follow - **G16**, and it performs the contrast check +- **Tenancy commands**, delivered in P02d-2, raise `tenancy.locale.write` (SHOULD) + and `tenancy.setting.write` (MUST). Accepted **G17** supplies whole-value + `[PiiSensitive]` redaction before setting writes; keys and value checks follow + Accepted **G16**, including the contrast check [Accessibility Standards § Color and Contrast](../standards/16-accessibility.md#color-and-contrast) requires before a theme token is saved, with the outcome G16 records. - **The Education module spec**, `docs/modules/education/` (`README.md`, `audit.md`, @@ -6573,8 +6609,8 @@ module's audit catalogue and registered in both composition roots. announced context, never from a command field ([Security Standards § Forbidden](../standards/11-security.md#forbidden)), and a tenant-wide row can be updated only from a context that announces no organization. The - seeder announces its context today by calling `IUnitOfWork.SetTenantContextAsync` - itself, which ADR-0040's closed setter set does not list: **G15**. + seeder uses the accepted **G15** contextual composition and registered caller + fence; it does not directly call a tenant-context setter. Each tenant's **own** customization data — the table in § Genericity proof — is authored through the Customization module's commands, as Phase 02a's seed is, and presented as @@ -6618,8 +6654,8 @@ Obligations already imposed: P02d-3 delivered **G12**'s cache contract, the loader/correctness contract (**G22**) and internal display fallback (**G24**) under the -[accepted gate answers](#accepted-gate-answers). P02d-4 delivers public response -parts; page-state behavior remains P02d-6. +[accepted gate answers](#accepted-gate-answers). P02d-4 delivered public response +parts; P02d-6 delivered page-state behavior. **The typed settings accessor** over `tenant_settings`, which Phase 02a left to its first reader, is delivered in P02d-3. Under Row Level Security a `tenant_settings` @@ -6732,27 +6768,31 @@ The full path from [Phase 02a Packet 7](phase-02a-kernel-tenancy.md#delivery-record-packet-7), exercised for real — on the path a Server Component takes: -1. The browser's `Host` reaches Next.js. Both pages are Server Components, so the API - call is made by the Next.js server, and the `Host` the API sees is not the visitor's. -2. The server SDK states the visitor's host to the API over the trusted hop - ([ADR-0036 § Effective host and the trusted hop](../decisions/0036-tenant-resolution-trusted-inputs.md#effective-host-and-the-trusted-hop)), - whose § Consequences names `frontend/packages/sdk/src/server.ts` as the only frontend - place that sets the hop headers. +1. Native HTTPS ingress validates the browser's host, physical peer and request + target, strips authority carriers and signs the request-local provenance envelope. + Middleware verifies it before making the one live site bootstrap call. +2. The configured server caller in + `frontend/apps/web/src/server/configured-public-client.ts` states the verified + host/visitor to the API over the closed trusted hop under + [ADR-0053](../decisions/0053-trusted-public-server-rendering.md) and + [ADR-0054](../decisions/0054-bounded-public-renderer-admission.md). + The injected SDK remains transport-policy agnostic. 3. The API resolves that effective host through `platform_host_to_tenant` to a `(tenant_id, organization_id?)` pair, the singleton `ITenantContextAccessor` is written and the transient `ITenantContext` resolves from it on every access, the transaction sets `app.tenant_id` / `app.organization_id` with `SET LOCAL`, and Row Level Security filters every read. -4. Whatever the edge resolves is for rendering only, and any `X-Tenant-Id` is an - assertion +4. RSC consumes the verified immutable bootstrap snapshot for that request under + [ADR-0055](../decisions/0055-public-renderer-bootstrap-failures.md). Rendering + contributes no tenant authority; the API owns resolution and admission ([Frontend Architecture Standards § Tenant Resolution](../standards/07-frontend-architecture.md#tenant-resolution)). -5. In this phase's default topology the loopback bind, not the secret, is the boundary - (ADR-0036 § Consequences). +5. The private loopback hop and authenticated internal envelope preserve the + accepted local trust boundary; a stock Next process cannot mint admission. -None of that path is wired from the renderer's side yet — § What this phase inherits -lists the scaffolds this phase replaces. The topology and its evidence are **G33**, the -limiter in front of it **G34**, the SDK transport **G31** and **G35**, the middleware -and entry behaviour **G36**, and the rendering mode **G37**. +P02d-5/6 delivered this path and its real production HTML/RSC isolation proofs. +**G33**, **G34**, **G31**, **G35**, **G36** and **G37** are Accepted and implemented; +their packet records retain the decisions and test receipts. P02d-7 adds the +shared foreground lifecycle and real browser/audit enforcement. Two hosts are registered in local development, one per seed tenant: [Phase 02a Packet 7](phase-02a-kernel-tenancy.md#delivery-record-packet-7) wrote one @@ -7207,8 +7247,10 @@ otherwise - In one test, a course slug that exists only in tenant B returns `200` with that course on tenant B's host and `404` on tenant A's host, and a `(locale, slug)` held by one - course in each tenant returns each host's own course, compared by id. A status alone - proves nothing here: a route miss and an unknown host also answer `404` with the same + course in each tenant returns each host's own course, compared against its exact + seed-derived public payload. Stable source-side IDs select the expected course; + accepted public DTOs expose no ID. A status alone proves nothing here: a route miss + and an unknown host also answer `404` with the same `not_found` code, and the same route's `200` is what rules them out. - In the same test, a course returns `200` at its slug in a locale it is translated into and `404` for that slug under an enabled locale it has no translation for; a lesson diff --git a/docs/standards/17-code-review.md b/docs/standards/17-code-review.md index 9ff8a4f0..d2c1fd1c 100644 --- a/docs/standards/17-code-review.md +++ b/docs/standards/17-code-review.md @@ -2,6 +2,9 @@ **Status:** Active **Derives from:** [ADR 0003 — Tenant Isolation Defense in Depth](../decisions/0003-tenant-isolation-defense-in-depth.md), [ADR 0010 — Cross-Module Communication](../decisions/0010-cross-module-communication.md), [ADR 0041 — Correcting False Statements in Accepted ADRs](../decisions/0041-correcting-false-statements-in-accepted-adrs.md) (every zero-tolerance blocker maps back to one of these three). +Public accessibility enforcement additionally derives from +[ADR-0027](../decisions/0027-frontend-i18n.md) and +[ADR-0056](../decisions/0056-public-demo-browser-and-lighthouse-enforcement.md). How LearnStack reviews pull requests. The goal is faster, safer ship — not gatekeeping. @@ -65,16 +68,19 @@ Before requesting reviews, run the relevant checklist on your own diff: ([Security Standards § Multi-Tenant + Organization Isolation Review Checklist](11-security.md#multi-tenant--organization-isolation-review-checklist)). - [ ] New forms use React Hook Form + Zod and render errors at the field level. - [ ] New routes have `error.tsx` and `loading.tsx` where appropriate. -- [ ] Accessibility: every interactive element keyboard-reachable; labels associated; color contrast checked; axe-core tests pass where a suite runs them — none does yet (see the note below). +- [ ] Accessibility: keyboard reachability, labels and contrast reviewed; blocking + JSX accessibility checks and the public browser/Lighthouse proof pass where + applicable. Preserve the scoped manual evidence described below. - [ ] Bundle size delta acceptable on public routes (< 200 KB gzipped per route). -> **Open in Phase 02d.** No axe-core suite exists in `frontend/` today, and -> [Testing Standards § End-to-End Tests](06-testing.md#end-to-end-tests) assigns the -> Playwright axe run to [Phase 06](../roadmap/phase-06-renderer-admin-studio.md). Which -> accessibility checks fail a build on Phase 02d's public pages — route tests, -> `jsx-a11y` at error severity or component axe — is G43 in -> [Phase 02d's decision register](../roadmap/phase-02d-walking-skeleton.md#the-decision-register), -> and the pass that closes it edits the accessibility box with its answer. +> **Accepted G43 enforcement is delivered.** P02d-6 supplies blocking `jsx-a11y` +> rules with planted controls. P02d-7's shared browser/Lighthouse proof enforces +> automated accessibility score 100 through Lighthouse's transitive axe audits; +> it does not provide the full Playwright Test/axe suite owned by +> [Phase 06](../roadmap/phase-06-renderer-admin-studio.md). +> [Testing Standards § End-to-End Tests](06-testing.md#end-to-end-tests) separates +> these automated checks from the preserved keyboard, focus, reflow, calculated +> contrast and maintainer VoiceOver smoke evidence. Neither is a full WCAG audit. If a checkbox doesn't apply to your diff, omit it. If you cannot tick a checkbox, fix the gap before requesting review. diff --git a/frontend/apps/web/src/test/public-demo-ci.test.ts b/frontend/apps/web/src/test/public-demo-ci.test.ts new file mode 100644 index 00000000..0229ea1f --- /dev/null +++ b/frontend/apps/web/src/test/public-demo-ci.test.ts @@ -0,0 +1,91 @@ +// @vitest-environment node +import { spawnSync } from 'node:child_process'; +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, expect, it } from 'vitest'; + +const job = readFileSync( + new URL('../../../../../.github/workflows/ci.yml', import.meta.url), + 'utf8', +) + .split(' public-demo:\n')[1] + ?.split('\n meta:')[0]; +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'learnstack-ci-budget-')); + roots.push(root); + const environment = join(root, 'environment'); + const invocation = join(root, 'invocation'); + for (const [name, body] of [ + ['date', 'printf "1000\\n"'], + ['timeout', 'printf "%s\\n" "$@" > "$INVOCATION"'], + ] as const) { + writeFileSync(join(root, name), '#!/bin/sh\n' + body + '\n'); + chmodSync(join(root, name), 0o700); + } + const env = { + PATH: root, + NODE_ENV: 'test' as const, + GITHUB_ENV: environment, + INVOCATION: invocation, + }; + return { root, environment, invocation, env }; +} + +function proofScript() { + expect(job).toBeDefined(); + const script = job?.match(/ {8}id: demo\n {8}run: \|\n((?: {10}.*\n|\n)+)/)?.[1]; + expect(script).toBeDefined(); + if (!script) throw new Error('Missing real workflow proof command'); + return script; +} + +it('starts the absolute budget before checkout and reserves four minutes of the job ceiling', () => { + const files = fixture(); + const first = job?.match( + /steps:\n(?: {6}#.*\n)* {6}- name: Reserve demo cleanup and artifact time\n {8}run: (.+)\n/, + ); + expect(first).not.toBeNull(); + expect(job).toContain('timeout-minutes: 20'); + if (!first?.[1]) throw new Error('Missing first-step budget reservation'); + const result = spawnSync('/bin/bash', ['-eu', '-c', first[1]], { env: files.env }); + expect(result.status).toBe(0); + expect(readFileSync(files.environment, 'utf8')).toBe('PUBLIC_DEMO_DEADLINE=1960\n'); +}); + +it('bounds the real workflow command by the remaining budget and requests owned SIGINT cleanup', () => { + const files = fixture(); + const result = spawnSync('/bin/bash', ['-eu', '-c', proofScript()], { + env: { ...files.env, PUBLIC_DEMO_DEADLINE: '1123' }, + }); + expect(result.status).toBe(0); + expect(readFileSync(files.invocation, 'utf8').split('\n')).toEqual([ + '--signal=INT', + '--kill-after=35s', + '123s', + 'make', + 'demo', + 'DEMO_MODE=ci', + '', + ]); +}); + +it.each(['1000', '999'])( + 'refuses an exhausted absolute budget before starting owned resources: %s', + (deadline) => { + const files = fixture(); + const result = spawnSync('/bin/bash', ['-eu', '-c', proofScript()], { + env: { ...files.env, PUBLIC_DEMO_DEADLINE: deadline }, + encoding: 'utf8', + }); + expect(result.status).toBe(1); + expect(result.stderr).toContain('Demo setup exhausted'); + expect(() => readFileSync(files.invocation)).toThrow(); + }, +); diff --git a/scripts/ci-public-chromium.mjs b/scripts/ci-public-chromium.mjs new file mode 100644 index 00000000..c4a38d79 --- /dev/null +++ b/scripts/ci-public-chromium.mjs @@ -0,0 +1,237 @@ +import assert from 'node:assert/strict'; +import { randomBytes } from 'node:crypto'; +import { + accessSync, + constants, + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + renameSync, + rmdirSync, + rmSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { DemoProcesses, installDemoCancellation } from './demo-process.mjs'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const restrictionPath = '/proc/sys/kernel/apparmor_restrict_unprivileged_userns'; + +async function runCommand(command, args, options) { + const controller = new AbortController(); + const removeCancellation = installDemoCancellation(() => controller.abort()); + const processes = new DemoProcesses(controller.signal); + try { + return await processes.run(command, args, { + ...options, + capture: true, + observe: (chunk, stream) => { + if (stream === 'stderr') process.stderr.write(chunk); + }, + }); + } finally { + try { + await processes.dispose(); + } finally { + removeCancellation(); + } + } +} + +/** AppArmor attachment syntax is a pattern language: accept literal paths only. */ +export function chromiumProfile(name, executable) { + assert.match(name, /^learnstack-public-chromium-[a-f0-9]{32}$/); + assert.match(executable, /^\/[a-zA-Z0-9_./-]+\/chrome$/); + assert.equal(resolve(executable), executable); + // Chromium's documented userns exception, narrowed to this executable. + // https://chromium.googlesource.com/chromium/src/+/main/docs/security/apparmor-userns-restrictions.md + return `abi <abi/4.0>,\ninclude <tunables/global>\nprofile ${name} "${executable}" flags=(unconfined) {\n userns,\n}\n`; +} + +function statOrMissing(path) { + try { + return lstatSync(path); + } catch (error) { + if (error.code === 'ENOENT') return undefined; + throw error; + } +} + +/** Injections are for tests only; the CLI always uses fixed host paths/commands. */ +export function chromiumPrerequisite({ + env = process.env, + platform = process.platform, + run = runCommand, + restriction = restrictionPath, +} = {}) { + assert.equal(platform, 'linux', 'Chromium prerequisite requires Linux'); + assert.equal(env.GITHUB_ACTIONS, 'true', 'Chromium prerequisite requires GitHub Actions'); + assert.equal(env.RUNNER_ENVIRONMENT, 'github-hosted', 'Requires an ephemeral hosted runner'); + assert.match(env.GITHUB_RUN_ID ?? '', /^\d+$/); + assert.match(env.GITHUB_RUN_ATTEMPT ?? '', /^\d+$/); + assert.match(env.GITHUB_JOB ?? '', /^[a-zA-Z0-9_-]+$/); + assert.ok(env.RUNNER_TEMP && resolve(env.RUNNER_TEMP) === realpathSync(env.RUNNER_TEMP)); + const runner = `${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT}-${env.GITHUB_JOB}`; + const directory = join(env.RUNNER_TEMP, `learnstack-public-chromium-${runner}`); + const marker = join(directory, 'owner.json'); + const profile = join(directory, 'profile'); + const phase = join(directory, 'phase'); + const uid = process.getuid(); + const policy = (action) => + run('sudo', ['-n', 'apparmor_parser', '--skip-cache', action, profile], { + cwd: root, + timeout: 10000, + label: `Owned Chromium AppArmor ${action}`, + }); + const updatePhase = (value) => { + writeFileSync(join(directory, 'phase-next'), value, { + flag: 'wx', + mode: 0o600, + }); + renameSync(join(directory, 'phase-next'), phase); + }; + function ownedState() { + const dir = lstatSync(directory); + assert.ok(dir.isDirectory() && dir.uid === uid && (dir.mode & 0o777) === 0o700); + assert.deepEqual(readdirSync(directory).sort(), ['owner.json', 'phase', 'profile']); + for (const file of [marker, profile, phase]) { + const stat = lstatSync(file); + assert.ok(stat.isFile() && stat.uid === uid && stat.nlink === 1); + assert.equal(stat.mode & 0o777, 0o600); + } + const state = JSON.parse(readFileSync(marker, 'utf8')); + assert.deepEqual(Object.keys(state).sort(), [ + 'executable', + 'name', + 'required', + 'runner', + 'schema', + ]); + assert.equal(state.schema, 1); + assert.equal(state.runner, runner); + assert.equal(typeof state.required, 'boolean'); + assert.equal(readFileSync(profile, 'utf8'), chromiumProfile(state.name, state.executable)); + const status = readFileSync(phase, 'utf8'); + assert.ok(['pending', 'loaded', 'unloaded', 'unneeded'].includes(status)); + assert.ok(state.required ? status !== 'unneeded' : status === 'unneeded'); + return { state, status }; + } + async function cleanup() { + if (!statOrMissing(directory)) return; + const { status } = ownedState(); + // An interrupted load is not proof of ownership. Retain evidence and fail. + assert.notEqual(status, 'pending', 'Chromium AppArmor load was not confirmed'); + if (status === 'loaded') { + await policy('--remove'); + updatePhase('unloaded'); + } + ownedState(); + for (const file of [profile, phase, marker]) unlinkSync(file); + rmdirSync(directory); + } + async function prepare() { + assert.ok(!statOrMissing(directory), 'Chromium prerequisite marker must be fresh'); + const discovered = ( + await run( + 'pnpm', + [ + 'exec', + 'node', + '--input-type=module', + '-e', + "import { chromium } from 'playwright'; process.stdout.write(chromium.executablePath());", + ], + { + cwd: join(root, 'frontend'), + timeout: 10000, + label: 'Managed Chromium executable', + }, + ) + ).trim(); + const executable = realpathSync(discovered); + accessSync(executable, constants.X_OK); + assert.ok(lstatSync(executable).isFile(), 'Managed Chromium must be a regular executable'); + const name = `learnstack-public-chromium-${randomBytes(16).toString('hex')}`; + const source = chromiumProfile(name, executable); + let required = false; + try { + const setting = readFileSync(restriction, 'utf8').trim(); + assert.ok(['0', '1'].includes(setting), 'Unknown AppArmor userns restriction'); + required = setting === '1'; + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + mkdirSync(directory, { mode: 0o700 }); + writeFileSync(marker, JSON.stringify({ schema: 1, runner, name, executable, required }), { + flag: 'wx', + mode: 0o600, + }); + writeFileSync(profile, source, { flag: 'wx', mode: 0o600 }); + writeFileSync(phase, required ? 'pending' : 'unneeded', { + flag: 'wx', + mode: 0o600, + }); + try { + if (required) { + // --add never replaces another definition. If it fails or is interrupted, + // retain pending state: neither ownership nor absence has been proven. + await policy('--add'); + updatePhase('loaded'); + } + const smoke = join(directory, 'smoke'); + mkdirSync(smoke, { mode: 0o700 }); + try { + const document = await run( + executable, + [ + '--headless=new', + `--user-data-dir=${smoke}`, + '--no-first-run', + '--no-default-browser-check', + '--disable-background-networking', + '--disable-component-update', + '--disable-sync', + '--no-proxy-server', + '--dump-dom', + 'about:blank', + ], + { + cwd: root, + timeout: 20000, + label: 'Sandbox-enabled managed Chromium smoke', + }, + ); + assert.match(document, /<html[\s>]/i, 'Chromium smoke must render a document'); + } finally { + rmSync(smoke, { recursive: true }); + } + } catch (error) { + try { + await cleanup(); + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], 'Chromium prerequisite and cleanup failed'); + } + throw error; + } + return { executable, apparmor: required }; + } + return { prepare, cleanup }; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const [action, ...extra] = process.argv.slice(2); + assert.ok(['prepare', 'cleanup'].includes(action) && extra.length === 0); + const result = await chromiumPrerequisite()[action](); + console.log( + `Chromium prerequisite ${action} passed${result ? ` (AppArmor: ${result.apparmor})` : ''}`, + ); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/ci-public-chromium.test.mjs b/scripts/ci-public-chromium.test.mjs new file mode 100644 index 00000000..ad47e744 --- /dev/null +++ b/scripts/ci-public-chromium.test.mjs @@ -0,0 +1,246 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { promisify } from 'node:util'; +import { chromiumPrerequisite, chromiumProfile } from './ci-public-chromium.mjs'; + +const execute = promisify(execFile); + +function fixture(t, { setting = '1', loadFails = false, smokeFails = false } = {}) { + const temp = mkdtempSync(join(realpathSync(tmpdir()), 'ci-public-chromium-test-')); + t.after(() => rmSync(temp, { recursive: true, force: true })); + const executable = join(temp, 'managed/chrome'); + mkdirSync(join(temp, 'managed')); + writeFileSync(executable, '#!/bin/sh\nprintf "<html><head></head><body></body></html>\\n"\n', { + mode: 0o700, + }); + const restriction = join(temp, 'userns'); + if (setting !== null) writeFileSync(restriction, setting); + const env = { + GITHUB_ACTIONS: 'true', + RUNNER_ENVIRONMENT: 'github-hosted', + GITHUB_RUN_ID: '123', + GITHUB_RUN_ATTEMPT: '2', + GITHUB_JOB: 'public-demo', + RUNNER_TEMP: temp, + }; + const directory = join(temp, 'learnstack-public-chromium-123-2-public-demo'); + const calls = []; + const loaded = new Set(['foreign-profile']); + const control = { removeFails: false }; + async function run(command, args, options) { + calls.push({ command, args, options }); + if (command === 'pnpm') { + assert.deepEqual(args.slice(0, 4), ['exec', 'node', '--input-type=module', '-e']); + assert.match(args[4], /chromium\.executablePath\(\)/); + assert.ok(options.cwd.endsWith('/frontend')); + return executable; + } + if (command === 'sudo') { + assert.deepEqual(args.slice(0, 3), ['-n', 'apparmor_parser', '--skip-cache']); + const source = readFileSync(args[4], 'utf8'); + const name = source.match(/profile (\S+) /)[1]; + if (args[3] === '--add') { + // Model --add refusing a foreign definition with this same name. + if (loadFails) { + loaded.add(name); + throw new Error('Definition already exists'); + } + assert.ok(!loaded.has(name)); + loaded.add(name); + } else { + assert.equal(args[3], '--remove'); + if (control.removeFails) throw new Error('Removal failed'); + assert.ok(loaded.delete(name)); + } + return ''; + } + assert.equal(command, executable); + assert.equal(options.timeout, 20000); + assert.ok(args.includes('--headless=new') && args.includes('--dump-dom')); + assert.equal(args.at(-1), 'about:blank'); + assert.ok(!args.some((arg) => /no-sandbox|disable.*sandbox|ignore-certificate/.test(arg))); + assert.equal( + args.find((arg) => arg.startsWith('--user-data-dir=')), + `--user-data-dir=${directory}/smoke`, + ); + assert.ok(existsSync(join(directory, 'smoke'))); + if (smokeFails) throw new Error('Sandbox launch refused'); + return (await execute(command, args, { timeout: options.timeout })).stdout; + } + const prerequisite = chromiumPrerequisite({ + env, + platform: 'linux', + run, + restriction, + }); + return { + prerequisite, + directory, + executable, + env, + restriction, + calls, + loaded, + control, + temp, + }; +} + +test('AppArmor source attaches only a literal executable path', () => { + const name = `learnstack-public-chromium-${'a'.repeat(32)}`; + const profile = chromiumProfile( + name, + '/home/runner/.cache/ms-playwright/chromium-1248/chrome-linux64/chrome', + ); + assert.match(profile, /^abi <abi\/4\.0>,\ninclude <tunables\/global>\n/); + assert.match(profile, /flags=\(unconfined\) \{\n userns,\n\}\n$/); + for (const path of [ + '/tmp/**/chrome', + '/tmp/[ab]/chrome', + '/tmp/../bin/chrome', + '/tmp/"/chrome', + '/tmp/\n/chrome', + ]) + assert.throws(() => chromiumProfile(name, path)); +}); + +test('prepare loads one exact profile, renders sandbox-enabled smoke and cleans only its policy', async (t) => { + const f = fixture(t); + const foreign = join(f.temp, 'foreign-profile-file'); + writeFileSync(foreign, 'untouched'); + assert.deepEqual(await f.prerequisite.prepare(), { + executable: f.executable, + apparmor: true, + }); + assert.equal(f.loaded.size, 2); + const source = readFileSync(join(f.directory, 'profile'), 'utf8'); + assert.ok(source.includes(`"${f.executable}" flags=(unconfined)`)); + assert.ok(!existsSync(join(f.directory, 'smoke'))); + await f.prerequisite.cleanup(); + await f.prerequisite.cleanup(); + assert.deepEqual([...f.loaded], ['foreign-profile']); + assert.equal(readFileSync(foreign, 'utf8'), 'untouched'); + assert.ok(!existsSync(f.directory)); + assert.deepEqual( + f.calls.filter((call) => call.command === 'sudo').map((call) => call.args[3]), + ['--add', '--remove'], + ); +}); + +for (const setting of ['0', null]) { + test(`restriction ${setting ?? 'absent'} needs no policy but still requires browser smoke`, async (t) => { + const f = fixture(t, { setting }); + assert.equal((await f.prerequisite.prepare()).apparmor, false); + await f.prerequisite.cleanup(); + assert.ok(f.calls.some((call) => call.command === f.executable)); + assert.ok(!f.calls.some((call) => call.command === 'sudo')); + }); +} + +test('--add failure never removes the existing foreign definition', async (t) => { + const f = fixture(t, { loadFails: true }); + await assert.rejects(f.prerequisite.prepare(), (error) => { + assert.ok(error instanceof AggregateError); + assert.match(error.errors[0].message, /Definition already exists/); + assert.match(error.errors[1].message, /load was not confirmed/); + return true; + }); + assert.equal(f.loaded.size, 2); + assert.deepEqual( + f.calls.filter((call) => call.command === 'sudo').map((call) => call.args[3]), + ['--add'], + ); + assert.equal(readFileSync(join(f.directory, 'phase'), 'utf8'), 'pending'); + await assert.rejects(f.prerequisite.cleanup(), /load was not confirmed/); + assert.equal(f.loaded.size, 2); +}); + +test('smoke failure unloads its successfully created policy and removes its profile', async (t) => { + const f = fixture(t, { smokeFails: true }); + await assert.rejects(f.prerequisite.prepare(), /Sandbox launch refused/); + assert.deepEqual([...f.loaded], ['foreign-profile']); + assert.ok(!existsSync(f.directory)); +}); + +test('duplicate prepare refuses the existing marker without loading again', async (t) => { + const f = fixture(t); + await f.prerequisite.prepare(); + await assert.rejects(f.prerequisite.prepare(), /marker must be fresh/); + assert.equal(f.calls.filter((call) => call.command === 'sudo').length, 1); + await f.prerequisite.cleanup(); +}); + +for (const mutation of ['runner', 'profile', 'extra-file', 'symlink', 'pending']) { + test(`cleanup refuses ${mutation} ownership mismatch before any policy command`, async (t) => { + const f = fixture(t); + await f.prerequisite.prepare(); + const commands = f.calls.length; + if (mutation === 'runner') { + const marker = join(f.directory, 'owner.json'); + const state = JSON.parse(readFileSync(marker, 'utf8')); + state.runner = '456-1-other-job'; + writeFileSync(marker, JSON.stringify(state)); + } else if (mutation === 'profile') { + writeFileSync(join(f.directory, 'profile'), 'profile foreign-profile { }'); + } else if (mutation === 'extra-file') { + writeFileSync(join(f.directory, 'foreign'), 'untouched'); + } else if (mutation === 'symlink') { + const marker = join(f.directory, 'owner.json'); + const foreign = join(f.temp, 'foreign-marker'); + writeFileSync(foreign, readFileSync(marker)); + rmSync(marker); + symlinkSync(foreign, marker); + } else { + writeFileSync(join(f.directory, 'phase'), 'pending'); + } + await assert.rejects(f.prerequisite.cleanup()); + assert.equal(f.calls.length, commands); + assert.equal(f.loaded.size, 2); + assert.ok(existsSync(f.directory)); + }); +} + +test('failed unload retains owned marker and allows a successful cleanup retry', async (t) => { + const f = fixture(t); + await f.prerequisite.prepare(); + f.control.removeFails = true; + await assert.rejects(f.prerequisite.cleanup(), /Removal failed/); + assert.equal(readFileSync(join(f.directory, 'phase'), 'utf8'), 'loaded'); + f.control.removeFails = false; + await f.prerequisite.cleanup(); + assert.deepEqual([...f.loaded], ['foreign-profile']); + assert.ok(!existsSync(f.directory)); +}); + +test('unknown kernel setting and missing executable fail before creating owned state', async (t) => { + const f = fixture(t, { setting: 'unexpected' }); + await assert.rejects(f.prerequisite.prepare(), /Unknown AppArmor/); + assert.ok(!existsSync(f.directory)); + rmSync(f.executable); + await assert.rejects(f.prerequisite.prepare(), /ENOENT/); + assert.ok(!existsSync(f.directory)); + assert.ok(!f.calls.some((call) => call.command === 'sudo')); +}); + +test('host policy operations refuse a local or self-hosted environment', (t) => { + const f = fixture(t); + for (const change of [ + { GITHUB_ACTIONS: 'false' }, + { RUNNER_ENVIRONMENT: 'self-hosted' }, + { GITHUB_RUN_ID: '../other' }, + ]) + assert.throws(() => chromiumPrerequisite({ env: { ...f.env, ...change }, platform: 'linux' })); +}); From 8605fc94f7ba343d5cd7cc15d28001d99349735b Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sun, 11 Oct 2026 05:51:01 +0300 Subject: [PATCH 18/92] docs(roadmap): close both P7 phase-exit review rounds Record independent runtime and corpus approval, focused proof results and verified navigation fixes. Keep real Linux CI, full receipts and seventh-check activation explicitly pending before phase exit. ADR: 0056 --- .github/CONTRIBUTING.md | 2 +- docs/roadmap/phase-02d-exit-evidence.md | 6 ++-- docs/roadmap/phase-02d-walking-skeleton.md | 32 +++++++++++++++++-- .../21-architecture-tests-catalogue.md | 3 ++ 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 96200101..f4a1122e 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -78,7 +78,7 @@ Configure these in **GitHub → Settings → Branches → Branch protection rule public-key trust. Its Flight-to-document navigation and fresh HMR admission scope does not deliver P7's browser demo, full Playwright/axe or Lighthouse. The [Step 3 record](../docs/roadmap/phase-02d-walking-skeleton.md#adr-0055-step-3--production-admission-proof-and-closeout) - owns actual execution and remaining reviews. + owns completed implementation, reviews and execution evidence. - P02d-7 replaces the disabled Lighthouse placeholder with the always-running `public demo + lighthouse` job under ADR-0056. It installs the locked browser tools and Playwright-managed Chromium, then runs `make demo DEMO_MODE=ci` over diff --git a/docs/roadmap/phase-02d-exit-evidence.md b/docs/roadmap/phase-02d-exit-evidence.md index 4d297c33..890a151d 100644 --- a/docs/roadmap/phase-02d-exit-evidence.md +++ b/docs/roadmap/phase-02d-exit-evidence.md @@ -187,13 +187,13 @@ Phase 04/05/06 retain the named future contracts. C36 is conditional: G23 select uncached settings, so no settings-cache eviction rule is claimed. Other named rows retain their full canonical conditions even when a locator is abbreviated. -The two new `PublicLocaleEvolutionHttpTests` cases passed locally with zero skips. +The three `PublicLocaleEvolutionHttpTests` cases passed locally with zero skips. They use the normal API-root pipeline and actual mapped-host HTTP, under `learnstack_app`, physical READ ONLY and non-BYPASSRLS. French-only new roots are authored while Draft; published seeded roots are not edited to create the proof. All five migration histories and complete pre-existing EN/TR DTOs remain equal. The organization writer refusal commits exactly its expected Denied audit; -public reads remain audit Off. Final receipt I must include both cases. +public reads remain audit Off. Final receipt I must include all three cases. ## Exclusions and future owners @@ -247,7 +247,7 @@ by listing its phase here. | ID | Obligation | Required evidence | |---|---|---| -| E01 | Every Completion Criterion holds with named/manual evidence | All C01-C79 rows, the two new combined HTTP/pipeline cases; actual B/I/F/O/J/H receipts linked at exact final head where applicable. | +| E01 | Every Completion Criterion holds with named/manual evidence | All C01-C79 rows, the three locale/branding/combined-scope HTTP/pipeline cases; actual B/I/F/O/J/H receipts linked at exact final head where applicable. | | E02 | Every register row closed through G1 vehicle and carriers, including G32 development transport | Dated closure/link census; Accepted ADRs retained, implementation receipts distinct from decisions; Phase02b native HTTPS carriers reconciled. | | E03 | Phase02a and Education schema/request isolation green as app role, zero skips, required | Exact final backend/backend-integration TRX/rollup, role/isolation evidence and skip guard. | | E04 | OpenAPI/SDK/Lighthouse required and shown able to fail; live list/date; no deferred job names | O/J offender controls, successful final real PR rollup, dated seven-context strict protection read-back, all other settings preserved. | diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 34d87b2e..8397896c 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -6138,7 +6138,8 @@ verified proof gaps remain; the existing 50 proof controls passed independently. child with deliberately deferred stdout demonstrated a 429 arriving after a prematurely passing final census. Cleanup now joins `close` with a separate shared five-second stop/output-drain bound before observer finalization. The - regression holds and releases the real pipe, proves cleanup waits, then refuses the 429. + regression holds and releases the real pipe, proves cleanup waits, then refuses + the 429. - Clip/filter/mask and offscreen/overflow styles could hide laid-out markers. The bounded seeded-page predicate now refuses these ancestor styles and horizontally unreachable/negative-area markers. Each lesson navigation checks @@ -6172,7 +6173,7 @@ complete. Step 4 owns CI enforcement and phase exit; neither is claimed here. ### P02d-7 Step 4 — CI enforcement and phase exit (2026-10-11) -**Implementation delivered; round 1 fixes complete, round 2 pending.** +**Implementation and both independent review rounds complete; real CI pending.** The always-running `public demo + lighthouse` job replaces the disabled placeholder. It installs the exact locked browser tools and managed Chromium, then invokes `make demo DEMO_MODE=ci` over the shared seven-service lifecycle. @@ -6214,7 +6215,7 @@ uncached settings reader plus `learnstack.tenancy.settings` eager-invalidation event owned by Phase 02b. These delivered premises do not Accept future session/BFF/authenticated-rate-limit decisions. -**Validation boundary.** Both third-locale/branding combined cases pass locally +**Initial validation at `bf5bc9c`.** Both third-locale/branding cases pass locally with zero skips. Release build has zero warnings/errors; 1,705 unit, 203 architecture, four contract and 180 Docker-free integration cases pass, as do all 1,500 guarded frontend cases. Typecheck, lint, whitespace and 2,042 relative links (666 fragments) @@ -6258,6 +6259,31 @@ SDK), zero failures/skips/todos; typecheck, lint, scoped backend formatting and whitespace pass. The initial full backend totals above precede the added combined case. Final complete PR CI, round 2 and protection read-back remain required. +#### Step 4 review round 2 (2026-10-11) + +Fresh GPT-6-astra (xhigh) and GPT-6.1-sol (xhigh) agents reviewed +`06d46ad..100b022` across runtime/CI ownership, security, correctness, +maintainability, test quality and the complete changed corpus/exit matrix. +Both approve with no remaining Blocker or Major. The corpus reviewer confirmed +two Minor navigation/census remnants: the matrix still called the expanded +three-case class two cases, and CONTRIBUTING still described completed +ADR-0055 reviews as remaining. Both are corrected; the reviewer independently +checked the bounded follow-up. One overlong P7 prose line is wrapped without +changing its content, and catalogue navigation names the new deadline/sandbox +controls. Earlier P1–P6 delivery records remain unchanged. + +The runtime reviewer independently passes all 15 Chromium-prerequisite controls, +four actual-workflow deadline controls and all three focused Docker HTTP cases, +with zero skips, plus whitespace. The corpus reviewer verifies all 36/79/39/9 +inventory counts, complete named evidence, 175 Implemented rules, 21 Active/one +Adopted standards and future-owner scope. Root checks pass seven ADR lifecycle +and 14 OpenAPI-CI controls. The complete PR's 33 unique Markdown files resolve +2,188 relative links and 686 fragments with no errors before these closing +navigation additions. Fenced and inline examples are excluded from link counts. +Both review rounds and verified fixes are complete. Actual successful Linux PR +CI, complete artifact receipts and authorized protection activation/read-back +remain required before a positive phase-exit verdict. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 65906fdf..0fb033a4 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -4496,6 +4496,9 @@ and the always-running `public demo + lighthouse` job through `make demo`. `src/test/public-demo.test.ts` and `src/test/demo-lifecycle.test.ts` execute planted failing and clean controls in the required frontend job. Actual browser mutants, TLS refusals and a real Lighthouse timeout also run in the full proof. +`src/test/public-demo-ci.test.ts` executes the actual workflow deadline shell; +`scripts/ci-public-chromium.test.mjs` tests owned Linux sandbox preparation and +cleanup before the job runs the real managed-browser launch smoke. The [Step 4 record](../roadmap/phase-02d-walking-skeleton.md#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11) owns exact CI and protection receipts. These are Node behavioral rules, not C# architecture-test methods or a full WCAG/production-performance claim. From 36cd6e0fa96f696e666c3ea52dd37090142dc2e5 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sun, 11 Oct 2026 06:11:28 +0300 Subject: [PATCH 19/92] docs: record P02d-7 CI and phase-exit verification Tie completion to the passing real PR reports and bounded required-check activation so acceptance and local feasibility cannot stand in for exit evidence. Preserve earlier delivery history and maintainer merge review. ADR: 0056 --- .github/CONTRIBUTING.md | 16 +++- CLAUDE.md | 13 +++- README.md | 12 ++- ...demo-browser-and-lighthouse-enforcement.md | 23 ++++++ docs/decisions/README.md | 2 +- docs/roadmap/README.md | 12 ++- docs/roadmap/phase-02d-exit-evidence.md | 35 +++++---- docs/roadmap/phase-02d-walking-skeleton.md | 78 ++++++++++++++++++- docs/standards/README.md | 2 +- 9 files changed, 156 insertions(+), 37 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index f4a1122e..9fdb78f9 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -61,6 +61,14 @@ Configure these in **GitHub → Settings → Branches → Branch protection rule every other protection setting unchanged. The [packet delivery record](../docs/roadmap/phase-02d-walking-skeleton.md#p02d-4-step-4-contract-sdk-and-ci) records the successful job and activation proof. + - `public demo + lighthouse` — **required since P02d-7, 2026-10-11.** The + first complete [real PR run](https://github.com/HodeTech/LearnStack/actions/runs/38106505383) + passes before activation. Downloaded artifacts confirm all nine pages, + 27 audits and owned cleanup. Full API read-back confirms seven GitHub + Actions contexts, strict up-to-date checking, all six previous contexts + retained and every unrelated protection setting unchanged. The + [CI and exit closeout](../docs/roadmap/phase-02d-walking-skeleton.md#p02d-7-ci-and-phase-exit-closeout-2026-10-11) + records execution, artifact validation and activation proof. - P02d-5 keeps the existing check names. The frontend job uses the guarded workspace test runner and native production/cleanup controls. Backend integration also builds a disposable Next app against the real API/PostgreSQL; @@ -86,13 +94,13 @@ Configure these in **GitHub → Settings → Branches → Branch protection rule normalized gzip/CLS/automated-accessibility checks, real quota observation and confirmed cleanup are mandatory. Only public reports/screenshots and sanitized receipts are uploaded; raw logs, profiles and private TLS/env files - are excluded. The maintainer authorized adding it as the seventh required - context only after a complete successful real PR run. The + are excluded. The authorized seventh-context activation followed the complete + successful real PR run on 2026-10-11. The [Step 4 record](../docs/roadmap/phase-02d-walking-skeleton.md#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11) - owns the actual run and activation/read-back status. + owns the actual run and verified activation/read-back. GitHub matches required checks **by name**. The - [packet record](../docs/roadmap/phase-02d-walking-skeleton.md#p02d-4-step-4-contract-sdk-and-ci) + [current CI and exit record](../docs/roadmap/phase-02d-walking-skeleton.md#p02d-7-ci-and-phase-exit-closeout-2026-10-11) owns execution and dated API read-back; a workflow edit alone is not a protection-setting change. - **Require conversation resolution before merging**: on. diff --git a/CLAUDE.md b/CLAUDE.md index b6073e23..9dbc7f19 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +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) is in progress**: its kickoff +**[Phase 02d](docs/roadmap/phase-02d-walking-skeleton.md) implementation and exit +verification are complete; PR #28 review and merge remain pending**. 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 @@ -155,9 +156,13 @@ independent review rounds; the owns browser/audit implementation, passing complete local reruns and both completed Step 3 review rounds. 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, individual exit reconciliation and the outstanding real PR/ -protection receipts. Step 4 implementation and reviews are in progress. -Phase 02d remains in progress; Phase 06 retains the full renderer/Studio expansion. +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; +Phase 06 retains the full renderer/Studio expansion. **Phase 01** shipped the .NET 10 solution scaffold under `backend/` (core + 7 modules × 4 projects + 4 test projects including the diff --git a/README.md b/README.md index 218dfece..46f59592 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,8 @@ items, rules, custom fields and notification templates; their delivery is tracke ## Where it is today -**Phase 01 and Phase 02a are complete. Phase 02d is in progress.** +**Phase 01 and Phase 02a are complete. Phase 02d implementation and exit +verification are complete; PR #28 awaits maintainer review and merge.** [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, @@ -107,10 +108,13 @@ on 2026-10-10; its [merge closeout](docs/roadmap/phase-02d-walking-skeleton.md#p02d-6-merge-and-closeout-2026-10-10) records final verification. P02d-7's demo, Lighthouse and phase-exit [decision package](docs/roadmap/phase-02d-walking-skeleton.md#p02d-7-decision-package-2026-10-10) -and ADR-0056 were approved on 2026-10-11. Steps 1–3 deliver the shared demo, -genericity fences and seeded browser/Lighthouse proof; the +and ADR-0056 were approved on 2026-10-11. All four steps completed both independent +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 phase-exit status. The +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 [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 diff --git a/docs/decisions/0056-public-demo-browser-and-lighthouse-enforcement.md b/docs/decisions/0056-public-demo-browser-and-lighthouse-enforcement.md index 231d01e8..992154d6 100644 --- a/docs/decisions/0056-public-demo-browser-and-lighthouse-enforcement.md +++ b/docs/decisions/0056-public-demo-browser-and-lighthouse-enforcement.md @@ -2,6 +2,10 @@ ## Status +> **Delivery navigation — 2026-10-11:** [Amendment 1](#amendment-1--p02d-7-enforcement-delivery-2026-10-11) +> records implemented proofs, passing real PR CI and required-check activation. +> Pending statements below describe the acceptance baseline. + Accepted No implementation, passing audit or gate acceptance is claimed. @@ -295,6 +299,25 @@ Acceptance registers proof obligations, not passing tests or delivered enforceme These are registered behavioral obligations for that Node/CI proof owner, not existing scripts or C# test methods. +## Amendments + +### Amendment 1 — P02d-7 enforcement delivery (2026-10-11) + +The four-step implementation and both independent review rounds per step are +complete. All four behavioral rules above are Implemented with named executable +owners. The shared demo lifecycle supplies the actual linked nine-page browser +proof and 27 serial cold Lighthouse audits, bounded report/source admission, +hard gzip/CLS/automated-accessibility checks, real quota accounting and confirmed +owned cleanup. Per-URL timing medians remain advisory. + +The first real [PR run](https://github.com/HodeTech/LearnStack/actions/runs/38106505383) +passes all seven jobs. Its complete sanitized artifacts and the subsequent +authorized seventh-context activation/full protection read-back are verified in +the [CI and exit closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-7-ci-and-phase-exit-closeout-2026-10-11). +PR #28 remains subject to maintainer review and merge. This is delivery navigation; +the acceptance metadata, decision and feasibility obligations remain unchanged. +Existing P6 human/VoiceOver evidence and Phase 06/11 scope retain their limits. + ## References - [P02d-7 package](../roadmap/phase-02d-walking-skeleton.md#p02d-7-decision-package-2026-10-10). diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 769cfc3a..edc108fa 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -71,7 +71,7 @@ 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, advisory timings and bounded seventh-check activation; proof rules Registered, implementation 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)); PR #28 review/merge pending | ## Proposed ADRs diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 98466216..1a1c4937 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -45,7 +45,8 @@ 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) — - **in progress**; P02d-1 and P02d-2 complete and merged; the + **implementation and exit verification complete; PR #28 review/merge pending**. + 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 [decision package](phase-02d-walking-skeleton.md#p02d-3-decision-package-2026-10-02) @@ -83,10 +84,13 @@ not deferred to the showcase phase. [merge closeout](phase-02d-walking-skeleton.md#p02d-6-merge-and-closeout-2026-10-10) records final verification. P02d-7's demo, Lighthouse and phase-exit [decision package](phase-02d-walking-skeleton.md#p02d-7-decision-package-2026-10-10) - and ADR-0056 were approved on 2026-10-11. Steps 1–3 deliver the shared demo, - genericity fences and seeded browser/Lighthouse proof. The + and ADR-0056 were approved on 2026-10-11. All four steps completed both independent + 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](phase-02d-walking-skeleton.md#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11) - owns CI enforcement, reviews and phase-exit status. Phase 02d remains in progress. + 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. - [Phase 02b: Events, Background Jobs, Identity, and Session](phase-02b-events-auth.md) - [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) diff --git a/docs/roadmap/phase-02d-exit-evidence.md b/docs/roadmap/phase-02d-exit-evidence.md index 890a151d..f99a7e08 100644 --- a/docs/roadmap/phase-02d-exit-evidence.md +++ b/docs/roadmap/phase-02d-exit-evidence.md @@ -1,11 +1,11 @@ # Phase 02d — individual exit evidence -**Reconciliation in progress — 2026-10-11.** P7 Steps 1–3 completed both -independent review rounds. Step 4 implementation, its two review rounds, the -real PR check receipts and seventh-check activation are recorded in the -[Step 4 record](phase-02d-walking-skeleton.md#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11). +**Reconciliation complete — 2026-10-11.** All four P7 steps completed both +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. Its pending receipts are not passing results. +approval. PR #28 awaits maintainer review and merge. The rows enumerate all 36 [deliverables](phase-02d-walking-skeleton.md#deliverables), 79 [completion criteria](phase-02d-walking-skeleton.md#completion-criteria), @@ -18,12 +18,12 @@ phase document owns the complete contract and every subordinate condition. | Code | Required receipt and present boundary | |---|---| -| B | Final PR `backend` success: Release build/format, unit, architecture, contract and Docker-free integration; zero skipped tests and execution guards. Pending. | -| I | Final PR `backend integration (Testcontainers)` success, including physical app-role/RLS/read-only production proofs. Pending. | -| F | Final PR guarded `frontend` success: complete workspace reports, zero skipped/todo, lint, typecheck, builds and controls. Pending. | -| O | Final PR `openapi diff` and generated SDK drift success with retained planted incompatibility controls. Pending. | -| J | Final PR `public demo + lighthouse` success, sanitized nine-page/27-audit receipt and confirmed cleanup; then authorized seventh-context activation and complete protection read-back. Pending. | -| M | Named source/document inspection and explicit future-owner reconciliation in Step 4. Requires both fresh review rounds. | +| 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. | +| 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. | | H | Attributed carried-forward P6 manual evidence below; no new final-head human/WCAG claim. | | N | Conditional branch made inapplicable by its Accepted decision, as named below. | @@ -31,7 +31,11 @@ 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. -The final PR rollup and workflow artifacts, not a historic count, supply B/I/F/O/J. +[Run 38106505383](https://github.com/HodeTech/LearnStack/actions/runs/38106505383) +supplies B/I/F/O/J for source `8605fc9`, tested merge `15109c3`; the closeout +records full identities and artifact validation. The +[PR rollup](https://github.com/HodeTech/LearnStack/pull/28/checks) must also pass +on subsequent documentation heads before merge; historic counts do not replace it. ## Local and manual evidence boundaries @@ -193,7 +197,7 @@ They use the normal API-root pipeline and actual mapped-host HTTP, under authored while Draft; published seeded roots are not edited to create the proof. All five migration histories and complete pre-existing EN/TR DTOs remain equal. The organization writer refusal commits exactly its expected Denied audit; -public reads remain audit Off. Final receipt I must include all three cases. +public reads remain audit Off. Downloaded receipt I includes all three Passed cases. ## Exclusions and future owners @@ -263,5 +267,6 @@ 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. -A positive phase-exit verdict requires the final receipts and both Step 4 review -rounds above. Maintainer PR review and merge are separate actions. +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. diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 8397896c..009c573e 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -1,6 +1,9 @@ # Phase 02d: Two-Tenant Walking Skeleton -> **Status (2026-10-11).** Phase 02d **in progress**. The kickoff, `P02d-0`, ships this +> **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 > 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 @@ -15,7 +18,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 | decision pass Accepted — 2026-10-11; [decision package](#p02d-7-decision-package-2026-10-10); Steps 1–3 complete; [Step 4](#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11) in progress | +> | 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 | **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 @@ -6173,7 +6176,9 @@ complete. Step 4 owns CI enforcement and phase exit; neither is claimed here. ### P02d-7 Step 4 — CI enforcement and phase exit (2026-10-11) -**Implementation and both independent review rounds complete; real CI pending.** +**Implementation, both independent review rounds and real CI complete.** The +[CI and exit closeout](#p02d-7-ci-and-phase-exit-closeout-2026-10-11) records +successful artifacts and the activated seventh required context. The always-running `public demo + lighthouse` job replaces the disabled placeholder. It installs the exact locked browser tools and managed Chromium, then invokes `make demo DEMO_MODE=ci` over the shared seven-service lifecycle. @@ -6184,7 +6189,7 @@ JSON/HTML reports, screenshots and sanitized workflow/proof receipts. The [individual exit evidence matrix](phase-02d-exit-evidence.md) maps all 36 Deliverables, 79 Completion Criteria, 39 exclusions and nine exit obligations. -It preserves prior human/PR evidence and names outstanding final CI receipts; +It preserves prior human/PR evidence and names the verified CI receipts; implementation alone does not constitute phase exit. **Criterion reconciliation.** Published roots cannot receive new translations @@ -6284,6 +6289,71 @@ Both review rounds and verified fixes are complete. Actual successful Linux PR CI, complete artifact receipts and authorized protection activation/read-back remain required before a positive phase-exit verdict. +#### P02d-7 CI and phase-exit closeout (2026-10-11) + +**Verdict: implementation and Phase 02d exit verification complete.** All four +P7 steps completed both fresh independent review rounds; verified findings are +fixed and committed. [PR #28](https://github.com/HodeTech/LearnStack/pull/28) +remains open for maintainer review and merge. No merge is claimed. + +All seven jobs passed in the first real +[PR run](https://github.com/HodeTech/LearnStack/actions/runs/38106505383) for source +head `8605fc94f7ba343d5cd7cc15d28001d99349735b`. GitHub tested synthetic merge +`15109c3a80a41ee7b6d6f9832880c7b2b13877f5`, whose parents are that source and +main `378346126949c90bb286b5217c24513fc2044f06`. The sanitized workflow receipt +records both identities. Downloaded TRX files confirm 1,705 unit, 203 architecture, +four contract, 180 Docker-free integration and 872 Docker integration passes: +2,964 backend tests, zero failures/skips. All three locale/branding/combined-scope +cases are present and Passed. Frontend job output confirms 1,444 web + 60 SDK +passes, zero failures/skips/todos; typecheck, lint, builds and SDK drift pass. +Meta, secret scan and OpenAPI compatibility pass too. + +The `public-demo-lighthouse` artifact contains exactly 27 JSON/HTML report pairs, +nine screenshots and two sanitized receipts; no private logs, profiles, env files +or keys are included. Report digests, nine URL/three-run census, configuration, +invocation, build and synthetic checkout identities match. The invocation is +`aab02c37c71c02569f71ef8829a96c09`; build is `yTmMjFs6JVE2ea_W-jFzX`. +Node 22.23.1, Lighthouse 13.5.0, Playwright 1.64.0 and Chromium revision 1248 / +156.0.8078.4 run on the four-CPU Ubuntu 24 hosted runner. Its sandbox-enabled +launch passes with the exact-executable temporary AppArmor prerequisite; +the owned policy removal passes afterward. + +All nine linked navigations and 24 planted visibility refusals pass. Every audit +has normalized initial JS gzip 106,359–106,553 bytes, CLS 0 and automated +accessibility 1. Per-URL timing medians remain advisory, not production p95. +All 134 API observations are 200, with zero 429, malformed or pending records: +six startup + two observer-control + 18 linked-browser + 3 × 36 audit calls. +Both TLS refusal controls and the actual audit-timeout control pass. Keycloak is +stopped before anonymous navigation. Setup/proof/cleanup take +228,127 / 650,154 / 11,041 ms; the proof stays inside 12 minutes and cleanup +inside 30 seconds. The complete job takes 15 minutes 51 seconds, within its +20-minute ceiling and absolute setup/proof deadline. + +After that successful run, the authorized API update adds only +`public demo + lighthouse` to main's required contexts. A fresh full read-back +confirms seven GitHub Actions contexts (`app_id: 15368`), strict up-to-date +checking, all six prior contexts retained and every unrelated protection setting +identical to the immediately preceding API response. The current list lives in +[CONTRIBUTING](../../.github/CONTRIBUTING.md#branch-protection-settings-on-main). + +The [individual evidence matrix](phase-02d-exit-evidence.md) reconciles all +36 deliverables, 79 criteria, 39 explicit exclusions and E01–E09. All 45 register +rows have dated Accepted closure links; no Phase 02d-owned Registered/Awaiting +catalogue rule remains. All four ADR-0056 rules are Implemented; corpus checks +confirm 175 Implemented catalogue entries and 21 Active/one Adopted standards. +The existing P6 human/VoiceOver evidence retains its original attribution and +limits; this closeout claims no new final-head manual WCAG or zoom audit. +Phase 05 preservation and P02b-0 premises are handed off above and in their +owning documents; no future-phase decision is accepted by this exit. +Closing-document checks pass all five CorpusConsistencyTests with zero skips; +the 33 changed/new Markdown files resolve 2,197 relative links and 694 fragments +with no errors. Added prose respects 88 columns; ADR-0056 changes are insertion-only. + +Closing documentation commits must also pass the seven required jobs before +merge; the [PR check rollup](https://github.com/HodeTech/LearnStack/pull/28/checks) +owns that moving-head result. Next after maintainer review and merge is +**P02b-0 re-verification and decision pass**, before Phase 02b implementation. + ### P02d-1 decision pass (2026-09-14) **Accepted — 2026-09-14, verified against `6c58343`.** The maintainer approved diff --git a/docs/standards/README.md b/docs/standards/README.md index 72fe952c..a65a96da 100644 --- a/docs/standards/README.md +++ b/docs/standards/README.md @@ -117,7 +117,7 @@ included. | 11 | [Security](11-security.md) | **Active** | No authentication yet, and the isolation half of this document is live and mechanical. Row Level Security with the four roles and the isolation suite that runs as `learnstack_app`; the tenancy edge, the trusted-hop predicate and the anonymous rate limiter from Packet 4; and, since Packet 10, § The out-of-band setters is mechanical — every announcer of a session variable is one the table names and each reader opens its transaction read-only, with `App_Role_Cannot_Enumerate_Tenants`, `App_Role_Cannot_Enumerate_Host_Map` and `Tenant_A_Cannot_Repoint_Tenant_B_Host` proving the role cannot read or repoint what the policies bar. Authentication and authorisation land in [Phase 02b](../roadmap/phase-02b-events-auth.md) and [Phase 03](../roadmap/phase-03-identity-admin.md). Three sections are **not** enforced by anything today and are the document's own carve-out: § Transport, § HTTP Headers and § CORS — nothing sets HSTS, `nosniff`, a CSP or an origin policy, at the edge or in either app. [Phase 11 § Secure headers](../roadmap/phase-11-production-hardening.md) owns them, at APISIX and in the ASP.NET layer beside it. | | 12 | [Infrastructure](12-infrastructure.md) | **Active** | Compose stack, `Makefile`, CI workflow, pre-commit hooks and secret scanning all live since Phase 01. | | 13 | [Documentation](13-documentation.md) | **Active** | Governs this corpus; the CI link audit walks changed Markdown. | -| 14 | [Git Workflow](14-git-workflow.md) | **Active** | Conventional Commits, hooks and required checks are live. Two branch-protection settings — `Require approvals` and `Do not allow bypassing` — are **deferred by maintainer decision (2026-08-10)** while the repository has one active contributor; the trigger and what activating them involves are recorded in [CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md). P02d-1 repaired and re-read the live required-check list on 2026-09-14: all five active jobs, including the current meta name and Docker integration, are required with GitHub Actions attribution and strict up-to-date checking. P02d-4 activates the sixth `openapi diff` context after a successful real job and verifies every other setting unchanged. [CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md#branch-protection-settings-on-main) owns the current list; the [P02d-4 delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-4-step-4-contract-sdk-and-ci) records activation verification. | +| 14 | [Git Workflow](14-git-workflow.md) | **Active** | Conventional Commits, hooks and required checks are live. Two branch-protection settings — `Require approvals` and `Do not allow bypassing` — are **deferred by maintainer decision (2026-08-10)** while the repository has one active contributor; the trigger and what activating them involves are recorded in [CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md). P02d-1 repaired and re-read the live required-check list on 2026-09-14: all five active jobs, including the current meta name and Docker integration, are required with GitHub Actions attribution and strict up-to-date checking. P02d-4 activates the sixth `openapi diff` context after a successful real job and verifies every other setting unchanged. P02d-7 activates the seventh `public demo + lighthouse` context after complete real PR success and verifies all six prior contexts, strict mode and every unrelated setting preserved; its [CI and exit closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-7-ci-and-phase-exit-closeout-2026-10-11) records the receipts. [CONTRIBUTING § Branch protection](../../.github/CONTRIBUTING.md#branch-protection-settings-on-main) owns the current list; the [P02d-4 delivery record](../roadmap/phase-02d-walking-skeleton.md#p02d-4-step-4-contract-sdk-and-ci) records activation verification. | | 15 | [Performance](15-performance.md) | **Active** | P02d-7 delivers the always-running `public demo + lighthouse` job under ADR-0056: nine seeded pages, 27 audits, actual-page/source/census controls and hard normalized gzip/CLS/automated-accessibility checks. Timing medians are advisory. The [Step 4 record](../roadmap/phase-02d-walking-skeleton.md#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11) owns real CI and required-check receipts. Production p95/field telemetry/load tests remain [Phase 11](../roadmap/phase-11-production-hardening.md). | | 16 | [Accessibility](16-accessibility.md) | **Active** | P6 delivers catalog/course/lesson and controlled-state semantics, all 31 active recommended jsx-a11y rules at error severity with per-rule actual-config dirty/clean controls, and real product HTML/Flight proofs. The [packet closeout](../roadmap/phase-02d-walking-skeleton.md#p02d-6-packet-closeout-2026-10-10) records keyboard/focus/reflow/contrast observations and the maintainer's passing VoiceOver smoke. This is enforcement for delivered public-page scope, not a full WCAG audit. P02d-7 adds hard Lighthouse automated accessibility and a narrow library browser walkthrough; full Playwright Test/axe remains [Phase 06](../roadmap/phase-06-renderer-admin-studio.md). | | 17 | [Code Review](17-code-review.md) | **Active** | Applied to every pull request merged so far; the zero-tolerance blocker list is in live use. | From e00395ade66ff95cfc5e440c14dd35dcdf57606e Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sun, 11 Oct 2026 06:13:58 +0300 Subject: [PATCH 20/92] docs(roadmap): align locale exit rows with verified CI Remove two stale pending suffixes after independently confirming both named cases Passed in the downloaded real PR integration receipt. ADR: 0056 --- docs/roadmap/phase-02d-exit-evidence.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/roadmap/phase-02d-exit-evidence.md b/docs/roadmap/phase-02d-exit-evidence.md index f99a7e08..8f04e3ea 100644 --- a/docs/roadmap/phase-02d-exit-evidence.md +++ b/docs/roadmap/phase-02d-exit-evidence.md @@ -126,13 +126,13 @@ checks supplement that record; they do not manufacture new human evidence. | C19 | No Tenancy or Education matrix row whose command ships still carries `(planned)`, and `Every_Shipped_Request_Is_Registered` and `CompositionRootCatalogueTests` pass for both roots. | AuditCoverageTests, CompositionRootCatalogueTests and Tenancy/Education matrices. | B, I, M | | C20 | A second seed run exits 0 and changes no Education, `tenant_locales` or `tenant_settings` row; each seeded row carries the `organization_id` **G14** names; each seed … | SeederTests.Running_the_seed_twice_changes_nothing_and_still_succeeds, exact inventory/process-repeat cases; Step1 audit-neutral repeat. | I, J | | C21 | The Phase 02a request-level suite (`TenantIsolationHttpTests`) still names the exact rows it expects: its customization cases assert `BeEquivalentTo` an enumerated per-tenant set that includes … | TenantIsolationHttpTests enumerated exact seed sets; SeederTests exact generations/counts; P2 writer derivation record. | I, M | -| C22 | With migration history unchanged, a request-level test adds a third locale — a `tenant_locales` row through the Tenancy locale command plus translation rows through … | PublicLocaleEvolutionHttpTests.Third_locale_authored_through_api_commands_serves_exact_new_urls_without_changing_existing_responses_or_migrations; local focused case passed; final I pending. | I, M | +| C22 | With migration history unchanged, a request-level test adds a third locale — a `tenant_locales` row through the Tenancy locale command plus translation rows through … | PublicLocaleEvolutionHttpTests.Third_locale_authored_through_api_commands_serves_exact_new_urls_without_changing_existing_responses_or_migrations; local focused case passed; downloaded real PR receipt I passed. | I, M | | C23 | An Education translation write for a locale absent from, or disabled in, the tenant's `tenant_locales` behaves as **G13** records, and a refused write commits … | EducationWriterTests.Locale_slug_lifecycle_and_stale_refusals_preserve_the_root_and_reserve_slugs_on_insert; Seeder exact canonical inventory. | I | | C24 | A lesson write whose body fails its bound revision returns `validation_failed` with details keyed by JSON pointer and persists no lesson or translation row; … | EducationWriterTests.Exact_active_bindings_and_existing_deprecated_pins_use_their_own_schema; EducationWriterValidationTests; Seeder exact non-card bindings. | B, I | | C25 | A duplicate slug written through the command **G11** names returns `Result.Fail(business_rule_violation)` and does not throw. A slug outside the shape **G9** settles is refused … | EducationWriterTests, EducationWriterValidationTests, EducationConstraintTests. | B, I | | C26 | A publication-state transition **G3** forbids answers `Result.Fail(business_rule_violation)` from the command, does not throw, and writes nothing (`backend integration`, plus a command or aggregate unit … | EducationAggregateTests/EducationWriterTests forbidden lifecycle and no mutation. | B, I | | C27 | A branding value outside **G16**'s grammar — `red;}body{background:url(//x)}`, a string containing `</style>` — never reaches a rendered document, and a refused write commits neither … | TenancyWriterTests branding refusal/atomicity, BrandingThemeTests, product theme defenses. | B, I, F | -| C28 | An organization-scoped branding command is refused without setting mutation; its Denied audit is retained, and the live organization host keeps the tenant palette. | PublicLocaleEvolutionHttpTests.Organization_branding_writer_is_refused_with_denied_audit_and_mapped_host_keeps_tenant_palette; local focused case passed; one expected Denied audit, no successful setting mutation; final I pending. | I, M | +| C28 | An organization-scoped branding command is refused without setting mutation; its Denied audit is retained, and the live organization host keeps the tenant palette. | PublicLocaleEvolutionHttpTests.Organization_branding_writer_is_refused_with_denied_audit_and_mapped_host_keeps_tenant_palette; local focused case passed; one expected Denied audit, no successful setting mutation; downloaded real PR receipt I passed. | I, M | | C29 | If **G19** includes a write-time rule, a lesson write holding a disallowed URL is refused with details naming the JSON pointer, and nothing is … | G19 Accepted text profile introduces no active URL sink or scheme write-time rule. | N | | C30 | Whatever **G18** refuses at save — which may include a presentation entry naming a property absent from `properties`, a malformed label map or a … | TextCardPresentationTests, customization command/schema validation tests. | B | | C31 | A course write naming a taxonomy revision or band key the tenant does not declare, including one only the other tenant declares, returns `validation_failed` … | EducationWriterTests and EducationWriterValidationTests taxonomy/band binding validation. | B, I | From d47ef17c8473e17e0950361dc35e419ba6c0825c Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sun, 11 Oct 2026 06:26:43 +0300 Subject: [PATCH 21/92] test(architecture): handle optional demo curricula consistently Keep the identity census aligned with the producer's nullable curriculum contract while preserving every tenant and present-curriculum assertion. The required demo inventory remains enforced separately. ADR: 0018, 0056 --- .../DomainGenericityTests.DemoIdentities.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.DemoIdentities.cs b/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.DemoIdentities.cs index ca773413..4ad97a35 100644 --- a/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.DemoIdentities.cs +++ b/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.DemoIdentities.cs @@ -76,7 +76,7 @@ public void Demo_Identity_Projection_Is_Complete_And_The_Frontend_Snapshot_Match values.Should().Contain(new DemoIdentity("band-label", label)); } } - var curriculum = tenant.Curriculum!; + if (tenant.Curriculum is not { } curriculum) continue; values.Should().Contain(new DemoIdentity("id", curriculum.Theme.Id.ToString("D"))); foreach (var course in curriculum.Courses) { From dca3a7b9a88acd7f013eb3b48fd84a3c30d6cc70 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sun, 11 Oct 2026 09:49:21 +0300 Subject: [PATCH 22/92] fix(dev): align demo tool versions and review evidence Keep installed tool metadata and receipt versions in one path while retaining independent pinned drift checks. Distinguish reviewed-head CI receipts from activation history and acceptance-time ADR wording. ADR: 0056 --- .github/CONTRIBUTING.md | 3 ++ ...demo-browser-and-lighthouse-enforcement.md | 14 +++++ docs/roadmap/phase-02d-exit-evidence.md | 10 ++-- docs/roadmap/phase-02d-walking-skeleton.md | 35 ++++++++++++ .../apps/web/scripts/public-demo-census.json | 6 +++ .../apps/web/scripts/public-demo-contract.mjs | 2 +- .../apps/web/scripts/public-demo-gatherer.mjs | 2 + .../web/scripts/public-demo-toolchain.d.mts | 16 ++++++ .../web/scripts/public-demo-toolchain.mjs | 39 ++++++++++++++ .../apps/web/scripts/public-demo-worker.mjs | 12 +++-- .../apps/web/scripts/verify-public-demo.mjs | 21 ++++---- .../apps/web/src/test/public-demo.test.ts | 54 ++++++++++++++++++- scripts/public-runtime.mjs | 6 +-- 13 files changed, 196 insertions(+), 24 deletions(-) create mode 100644 frontend/apps/web/scripts/public-demo-toolchain.d.mts create mode 100644 frontend/apps/web/scripts/public-demo-toolchain.mjs diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 9fdb78f9..255ab840 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -69,6 +69,9 @@ Configure these in **GitHub → Settings → Branches → Branch protection rule retained and every unrelated protection setting unchanged. The [CI and exit closeout](../docs/roadmap/phase-02d-walking-skeleton.md#p02d-7-ci-and-phase-exit-closeout-2026-10-11) records execution, artifact validation and activation proof. + The separate [reviewed-head verification](../docs/roadmap/phase-02d-walking-skeleton.md#reviewed-implementation-head-verification-2026-10-11) + identifies the exact implementation head inspected after activation; + subsequent heads still require all seven passing checks. - P02d-5 keeps the existing check names. The frontend job uses the guarded workspace test runner and native production/cleanup controls. Backend integration also builds a disposable Next app against the real API/PostgreSQL; diff --git a/docs/decisions/0056-public-demo-browser-and-lighthouse-enforcement.md b/docs/decisions/0056-public-demo-browser-and-lighthouse-enforcement.md index 992154d6..79098314 100644 --- a/docs/decisions/0056-public-demo-browser-and-lighthouse-enforcement.md +++ b/docs/decisions/0056-public-demo-browser-and-lighthouse-enforcement.md @@ -10,6 +10,11 @@ Accepted No implementation, passing audit or gate acceptance is claimed. +> **Acceptance-baseline clarification — 2026-10-11:** The preceding sentence +> records the state at acceptance, not current delivery status. +> [Amendment 2](#amendment-2--acceptance-and-reviewed-head-navigation-2026-10-11) +> distinguishes that baseline, activation history and reviewed-head evidence. + **Date:** 2026-10-11 **Deciders:** @cemil @@ -318,6 +323,15 @@ PR #28 remains subject to maintainer review and merge. This is delivery navigati the acceptance metadata, decision and feasibility obligations remain unchanged. Existing P6 human/VoiceOver evidence and Phase 06/11 scope retain their limits. +### Amendment 2 — Acceptance and reviewed-head navigation (2026-10-11) + +The original no-implementation sentence describes acceptance-time state. +Amendment 1 describes later delivery and the first real run used for activation. +The [reviewed-head verification](../roadmap/phase-02d-walking-skeleton.md#reviewed-implementation-head-verification-2026-10-11) +separately records source `d47ef17` and its downloaded successful CI artifacts. +Subsequent heads remain subject to all seven required checks. This clarification +changes no decision, tool version, budget or original acceptance metadata. + ## References - [P02d-7 package](../roadmap/phase-02d-walking-skeleton.md#p02d-7-decision-package-2026-10-10). diff --git a/docs/roadmap/phase-02d-exit-evidence.md b/docs/roadmap/phase-02d-exit-evidence.md index 8f04e3ea..54a9c1c3 100644 --- a/docs/roadmap/phase-02d-exit-evidence.md +++ b/docs/roadmap/phase-02d-exit-evidence.md @@ -31,11 +31,13 @@ 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 38106505383](https://github.com/HodeTech/LearnStack/actions/runs/38106505383) -supplies B/I/F/O/J for source `8605fc9`, tested merge `15109c3`; the closeout -records full identities and artifact validation. The +[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 documentation heads before merge; historic counts do not replace it. +on subsequent heads before merge; historic counts do not replace it. ## Local and manual evidence boundaries diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 009c573e..6a160f5e 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -6349,6 +6349,41 @@ Closing-document checks pass all five CorpusConsistencyTests with zero skips; the 33 changed/new Markdown files resolve 2,197 relative links and 694 fragments with no errors. Added prose respects 88 columns; ADR-0056 changes are insertion-only. +##### Reviewed implementation head verification (2026-10-11) + +The first run above owns activation history. The separately downloaded +[reviewed-head run](https://github.com/HodeTech/LearnStack/actions/runs/38108622191) +passes all seven jobs for source `d47ef17c8473e17e0950361dc35e419ba6c0825c`, +tested merge `ba44563af66fbd7744b6625673e61286a8e77fa4` with the same main parent. +Its invocation is `170c02e7741fbc6c79895ce718cecd89`; build is +`rlL6ubYmcf7_VQaPBN-fA`. TRX/frontend receipts confirm the same 2,964 backend +and 1,504 frontend passes, zero failures/skips/todos. + +All 27 report pairs, nine screenshots, nine navigations and 24 visibility +refusals pass. Report digests and source/build/invocation/configuration identities +match. Normalized gzip is 106,358–106,551 bytes, CLS 0 and automated +accessibility 1 throughout. Both TLS refusals, the audit-timeout control and all +134 API observations (200 only, no malformed/pending records) pass. +Setup/proof/cleanup take 214,616 / 644,207 / 12,962 ms; all cleanup flags pass. +Sandbox-enabled Chromium and owned AppArmor cleanup pass. These numbers belong +to the exact implementation head reviewed externally, not to later commits. + +##### External review follow-up (2026-10-11) + +The maintainer's review of `3783461..d47ef17` reported no Blocker/Major and +four bounded documentation/maintenance findings. All four are verified and +addressed: the reviewed-head receipt above supplements activation history; +runtime imports move to the file header; installed tool/browser metadata feeds +the shared census and remains checked against independent committed pins; and +ADR-0056 gains dated acceptance-baseline navigation without changing history. +Tool versions, budgets, admission and cleanup behavior are unchanged. + +Local validation passes 1,454 web + 60 SDK tests, zero failures/skips/todos, +typecheck, lint and all 203 architecture tests. Ten added cases cover installed +metadata/pin mismatch, malformed or missing browser metadata, stale census and +wrong-version report refusal, each retaining a clean passing control. Two fresh +independent remediation review rounds and full seven-job CI are pending. + Closing documentation commits must also pass the seven required jobs before merge; the [PR check rollup](https://github.com/HodeTech/LearnStack/pull/28/checks) owns that moving-head result. Next after maintainer review and merge is diff --git a/frontend/apps/web/scripts/public-demo-census.json b/frontend/apps/web/scripts/public-demo-census.json index 372adfbd..9e3944d8 100644 --- a/frontend/apps/web/scripts/public-demo-census.json +++ b/frontend/apps/web/scripts/public-demo-census.json @@ -1,4 +1,10 @@ { + "versions": { + "lighthouse": "13.5.0", + "playwright": "1.64.0", + "chromiumRevision": "1248", + "browserVersion": "156.0.8078.4" + }, "artifacts": [ { "id": "Accessibility" diff --git a/frontend/apps/web/scripts/public-demo-contract.mjs b/frontend/apps/web/scripts/public-demo-contract.mjs index 03b61dfe..073dc621 100644 --- a/frontend/apps/web/scripts/public-demo-contract.mjs +++ b/frontend/apps/web/scripts/public-demo-contract.mjs @@ -220,7 +220,7 @@ export function assertConfiguration(actual, expected) { } export function validateReport(lhr, expected, census) { - assert.equal(lhr.lighthouseVersion, '13.5.0'); + assert.equal(lhr.lighthouseVersion, census.versions.lighthouse); assert.equal(lhr.runtimeError, undefined, 'Runtime errors refuse the report'); assert.deepEqual(lhr.runWarnings, [], 'Incomplete/warning audit reports are refused'); for (const key of ['requestedUrl', 'mainDocumentUrl', 'finalDisplayedUrl']) diff --git a/frontend/apps/web/scripts/public-demo-gatherer.mjs b/frontend/apps/web/scripts/public-demo-gatherer.mjs index 725fd044..55d8d893 100644 --- a/frontend/apps/web/scripts/public-demo-gatherer.mjs +++ b/frontend/apps/web/scripts/public-demo-gatherer.mjs @@ -7,6 +7,7 @@ import { normalizedJavaScript, productDomSnapshot, } from './public-demo-contract.mjs'; +import { readDemoToolchain } from './public-demo-toolchain.mjs'; /** Observe the initial audited target; never refetch a body or navigate a second time. */ export class PublicDemoDocument extends BaseGatherer { @@ -184,6 +185,7 @@ export function configurationCensus(config) { // must not differ merely because Lighthouse represents them as undefined. return JSON.parse( JSON.stringify({ + versions: readDemoToolchain(), artifacts: config.artifacts .map((row) => ({ id: row.id, dependencies: row.dependencies })) .sort((a, b) => a.id.localeCompare(b.id)), diff --git a/frontend/apps/web/scripts/public-demo-toolchain.d.mts b/frontend/apps/web/scripts/public-demo-toolchain.d.mts new file mode 100644 index 00000000..8ab57a21 --- /dev/null +++ b/frontend/apps/web/scripts/public-demo-toolchain.d.mts @@ -0,0 +1,16 @@ +export interface DemoToolchain { + lighthouse: string; + playwright: string; + chromiumRevision: string; + browserVersion: string; +} +export function readDemoToolchain(): Readonly<DemoToolchain>; +export function toolchainVersions( + pins: { lighthouse: string; playwright: string }, + installed: { + lighthouse: string; + playwright: string; + core: string; + browsers: Array<{ name: string; revision: string; browserVersion?: string }>; + }, +): Readonly<DemoToolchain>; diff --git a/frontend/apps/web/scripts/public-demo-toolchain.mjs b/frontend/apps/web/scripts/public-demo-toolchain.mjs new file mode 100644 index 00000000..9816d25a --- /dev/null +++ b/frontend/apps/web/scripts/public-demo-toolchain.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; + +/** Match installed metadata to exact workspace pins before admitting audit tools. */ +export function toolchainVersions(pins, installed) { + for (const name of ['lighthouse', 'playwright']) { + assert.match(pins[name], /^\d+\.\d+\.\d+$/, 'Exact workspace tool pin'); + assert.equal(installed[name], pins[name], 'Installed ' + name + ' matches workspace pin'); + } + assert.equal(installed.core, installed.playwright, 'Playwright core matches its library'); + const chromium = installed.browsers.filter((browser) => browser.name === 'chromium'); + assert.equal(chromium.length, 1, 'One managed full Chromium declaration'); + assert.match(chromium[0].revision, /^\d+$/, 'Managed Chromium revision'); + assert.match(chromium[0].browserVersion, /^\d+\.\d+\.\d+\.\d+$/, 'Managed Chromium version'); + return Object.freeze({ + lighthouse: installed.lighthouse, + playwright: installed.playwright, + chromiumRevision: chromium[0].revision, + browserVersion: chromium[0].browserVersion, + }); +} + +export function readDemoToolchain() { + const require = createRequire(import.meta.url); + const playwrightManifest = require.resolve('playwright/package.json'); + const coreManifest = createRequire(playwrightManifest).resolve('playwright-core/package.json'); + const json = (path) => JSON.parse(readFileSync(path, 'utf8')); + return toolchainVersions( + json(new URL('../../../package.json', import.meta.url)).devDependencies, + { + lighthouse: require('lighthouse/package.json').version, + playwright: json(playwrightManifest).version, + core: json(coreManifest).version, + browsers: json(join(dirname(coreManifest), 'browsers.json')).browsers, + }, + ); +} diff --git a/frontend/apps/web/scripts/public-demo-worker.mjs b/frontend/apps/web/scripts/public-demo-worker.mjs index 7388488d..ae65056b 100644 --- a/frontend/apps/web/scripts/public-demo-worker.mjs +++ b/frontend/apps/web/scripts/public-demo-worker.mjs @@ -1,7 +1,6 @@ import assert from 'node:assert/strict'; import { readFileSync, writeFileSync } from 'node:fs'; import { createServer } from 'node:http'; -import { createRequire } from 'node:module'; import { join } from 'node:path'; import lighthouse, { generateReport } from 'lighthouse'; @@ -15,8 +14,8 @@ import { validateReport, } from './public-demo-contract.mjs'; import { resolvedDemoCensus, demoLighthouseConfiguration } from './public-demo-gatherer.mjs'; +import { readDemoToolchain } from './public-demo-toolchain.mjs'; -const require = createRequire(import.meta.url); const expectedCensus = JSON.parse( readFileSync(new URL('./public-demo-census.json', import.meta.url), 'utf8'), ); @@ -159,15 +158,18 @@ async function timeoutControl(port) { } async function main(input) { - assert.equal(require('playwright/package.json').version, '1.64.0'); - assert.equal(require('lighthouse/package.json').version, '13.5.0'); + assert.deepEqual(readDemoToolchain(), expectedCensus.versions, 'Pinned tool/browser census'); const browser = await chromium.connectOverCDP(`http://127.0.0.1:${input.port}`, { timeout: 10000, }); let browserVersion; try { browserVersion = browser.version(); - assert.equal(browserVersion, '156.0.8078.4', 'Pinned managed full Chromium version'); + assert.equal( + browserVersion, + expectedCensus.versions.browserVersion, + 'Pinned managed full Chromium version', + ); // The native browser starts with no product target. Retire its blank target. for (const context of browser.contexts()) for (const page of context.pages()) await page.close(); diff --git a/frontend/apps/web/scripts/verify-public-demo.mjs b/frontend/apps/web/scripts/verify-public-demo.mjs index 2a290563..8064524f 100644 --- a/frontend/apps/web/scripts/verify-public-demo.mjs +++ b/frontend/apps/web/scripts/verify-public-demo.mjs @@ -16,6 +16,9 @@ import { completionDelta, finalCompletionCensus } from '../../../../scripts/demo const root = fileURLToPath(new URL('../../../../', import.meta.url)); const worker = fileURLToPath(new URL('./public-demo-worker.mjs', import.meta.url)); const snapshot = (state) => JSON.parse(JSON.stringify(state)); +const census = JSON.parse( + readFileSync(new URL('./public-demo-census.json', import.meta.url), 'utf8'), +); /** Four paced batches over one unchanged production API/build/database. */ export async function verifyPublicDemo(runtime) { @@ -59,12 +62,12 @@ export async function verifyPublicDemo(runtime) { os: platform() + ' ' + release(), cpus: cpus().length, runnerImage: process.env.ImageOS ?? null, - versions: { lighthouse: '13.5.0', playwright: '1.64.0', chromiumRevision: '1248' }, - configDigest: digest( - JSON.stringify( - JSON.parse(readFileSync(new URL('./public-demo-census.json', import.meta.url), 'utf8')), - ), - ), + versions: { + lighthouse: census.versions.lighthouse, + playwright: census.versions.playwright, + chromiumRevision: census.versions.chromiumRevision, + }, + configDigest: digest(JSON.stringify(census)), setupMilliseconds: runtime.setupMilliseconds, batches: [], reports: [], @@ -72,9 +75,7 @@ export async function verifyPublicDemo(runtime) { proofMilliseconds: null, passed: false, }; - receipt.configuration = JSON.parse( - readFileSync(new URL('./public-demo-census.json', import.meta.url), 'utf8'), - ); + receipt.configuration = census; const expected = runtime.manifest.flatMap((lane) => ['catalog', 'course', 'lesson'].flatMap((kind) => [1, 2, 3].map((run) => ({ @@ -82,7 +83,7 @@ export async function verifyPublicDemo(runtime) { url: lane[kind + 'Url'], run, configDigest: receipt.configDigest, - browserVersion: '156.0.8078.4', + browserVersion: census.versions.browserVersion, })), ), ); diff --git a/frontend/apps/web/src/test/public-demo.test.ts b/frontend/apps/web/src/test/public-demo.test.ts index 73cccdd1..17e971d5 100644 --- a/frontend/apps/web/src/test/public-demo.test.ts +++ b/frontend/apps/web/src/test/public-demo.test.ts @@ -19,10 +19,16 @@ import { validateReport, } from '../../scripts/public-demo-contract.mjs'; import { PublicDemoDocument, resolvedDemoCensus } from '../../scripts/public-demo-gatherer.mjs'; +import { + readDemoToolchain, + toolchainVersions, + type DemoToolchain, +} from '../../scripts/public-demo-toolchain.mjs'; const census = JSON.parse( readFileSync(new URL('../../scripts/public-demo-census.json', import.meta.url), 'utf8'), ) as { + versions: DemoToolchain; audits: string[]; categories: Record<string, Array<{ id: string; weight: number }>>; }; @@ -237,7 +243,7 @@ function cleanReport() { ]) audits[id]!.numericValue = 0; return { - lighthouseVersion: '13.5.0', + lighthouseVersion: census.versions.lighthouse, runWarnings: [] as string[], requestedUrl: lane.lessonUrl, mainDocumentUrl: lane.lessonUrl, @@ -262,6 +268,50 @@ describe('Public_Lighthouse_Reports_Enforce_Budgets', () => { census, ); }); + it('derives the installed tool/browser versions and refuses an independently stale census', () => { + expect(readDemoToolchain()).toEqual(census.versions); + expect(() => + assertConfiguration( + { ...census, versions: { ...census.versions, chromiumRevision: 'stale' } }, + census, + ), + ).toThrow(); + expect(() => assertConfiguration(census, census)).not.toThrow(); + }); + it.each([ + 'lighthouse', + 'playwright', + 'core', + 'unpinned', + 'missing', + 'duplicate', + 'revision', + 'browser-version', + ])('refuses %s tool metadata without weakening clean admission', (kind) => { + const pins = { lighthouse: census.versions.lighthouse, playwright: census.versions.playwright }; + const clean = { + lighthouse: pins.lighthouse, + playwright: pins.playwright, + core: pins.playwright, + browsers: [ + { + name: 'chromium', + revision: census.versions.chromiumRevision, + browserVersion: census.versions.browserVersion, + }, + ], + }; + const installed = structuredClone(clean); + if (kind === 'lighthouse' || kind === 'playwright' || kind === 'core') + installed[kind] = '0.0.0'; + if (kind === 'unpinned') pins.playwright = '^' + pins.playwright; + if (kind === 'missing') installed.browsers = []; + if (kind === 'duplicate') installed.browsers.push({ ...installed.browsers[0]! }); + if (kind === 'revision') installed.browsers[0]!.revision = ''; + if (kind === 'browser-version') installed.browsers[0]!.browserVersion = 'unknown'; + expect(() => toolchainVersions(pins, installed)).toThrow(); + expect(toolchainVersions(census.versions, clean)).toEqual(census.versions); + }); it.each([ 'missing', 'runtime', @@ -273,6 +323,7 @@ describe('Public_Lighthouse_Reports_Enforce_Budgets', () => { 'accessibility', 'weights', 'redirect', + 'version', ])('refuses a planted %s report', (kind) => { const value = structuredClone(cleanReport()); if (kind === 'missing') delete value.audits[census.audits[0]!]; @@ -285,6 +336,7 @@ describe('Public_Lighthouse_Reports_Enforce_Budgets', () => { if (kind === 'accessibility') value.categories.accessibility!.score = 0.99; if (kind === 'weights') value.categories.accessibility!.auditRefs[0]!.weight = 999; if (kind === 'redirect') value.finalDisplayedUrl = lane.catalogUrl; + if (kind === 'version') value.lighthouseVersion = '0.0.0'; expect(() => validateReport(value, { url: lane.lessonUrl }, census)).toThrow(); expect(() => validateReport(cleanReport(), { url: lane.lessonUrl }, census)).not.toThrow(); }); diff --git a/scripts/public-runtime.mjs b/scripts/public-runtime.mjs index 33499264..69fc7a53 100644 --- a/scripts/public-runtime.mjs +++ b/scripts/public-runtime.mjs @@ -1,3 +1,6 @@ +import { realpathSync } from 'node:fs'; +import { join } from 'node:path'; + /** Keep privileged setup/worker credentials and arbitrary configuration out of runtime. */ export function publicApiEnvironment(values, configuration, base = process.env) { const environment = {}; @@ -15,9 +18,6 @@ export function publicApiEnvironment(values, configuration, base = process.env) Tenancy__TrustedHop__Secrets__0: configuration.secret, }; } -import { realpathSync } from 'node:fs'; -import { join } from 'node:path'; - /** Development configuration must not consult the workstation's user secrets. */ export function publicDemoApiEnvironment(values, configuration, privateRoot, base = process.env) { const profile = realpathSync(privateRoot); From aebefeeb1eeefd6a32acbfd6008e2516e51c84bf Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sun, 11 Oct 2026 09:55:39 +0300 Subject: [PATCH 23/92] docs(roadmap): record P7 review remediation verification Record both fresh independent approvals and local proof results while keeping later-head seven-job CI acceptance with the PR check rollup. ADR: 0056 --- docs/roadmap/phase-02d-walking-skeleton.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 6a160f5e..187e79d1 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -6381,8 +6381,13 @@ Tool versions, budgets, admission and cleanup behavior are unchanged. Local validation passes 1,454 web + 60 SDK tests, zero failures/skips/todos, typecheck, lint and all 203 architecture tests. Ten added cases cover installed metadata/pin mismatch, malformed or missing browser metadata, stale census and -wrong-version report refusal, each retaining a clean passing control. Two fresh -independent remediation review rounds and full seven-job CI are pending. +wrong-version report refusal, each retaining a clean passing control. Both fresh +independent remediation rounds reviewed `d47ef17..dca3a7b` and approved without +findings. Each independently checked installed dependency resolution, fail-closed +metadata/census comparison and the downloaded historical receipts. All five +corpus tests pass; the complete PR's 33 changed/new Markdown files resolve +2,201 relative links and 698 fragments without errors. The new remediation head +must still pass full seven-job CI through the PR rollup before merge. Closing documentation commits must also pass the seven required jobs before merge; the [PR check rollup](https://github.com/HodeTech/LearnStack/pull/28/checks) From cd6affdd990b8c9d6cfc1ac7e398905d7906b397 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sun, 11 Oct 2026 10:59:18 +0300 Subject: [PATCH 24/92] fix(dev): preserve independent demo cleanup and seed launch Keep the documented seed launcher argument-free. Attempt owned Compose teardown even when process cleanup or output observation fails, and retain private state until every cleanup stage succeeds. Preserve private error causes internally while keeping foreground diagnostics fixed. ADR: 0056 --- .gitignore | 3 + .../apps/web/src/test/demo-lifecycle.test.ts | 162 +++++++++++++++++- scripts/demo-http.mjs | 11 +- scripts/demo-process.d.mts | 2 +- scripts/demo-process.mjs | 40 +++-- scripts/demo.d.mts | 51 ++++++ scripts/demo.mjs | 104 ++++++----- scripts/seed.sh | 2 +- scripts/wait-compose.d.mts | 7 + 9 files changed, 326 insertions(+), 56 deletions(-) diff --git a/.gitignore b/.gitignore index bcd9a715..cdd5aa09 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,9 @@ dist/ build/ coverage/ +# Local test and browser proof artifacts +/artifacts/ + # Environment — track only the explicitly-listed *.example templates, # never real envs. `!*.example` would un-ignore every future `foo.example` # file in the tree (e.g. `infra/foo.conf.example`); the explicit allowlist diff --git a/frontend/apps/web/src/test/demo-lifecycle.test.ts b/frontend/apps/web/src/test/demo-lifecycle.test.ts index 12fe6034..b7e68af7 100644 --- a/frontend/apps/web/src/test/demo-lifecycle.test.ts +++ b/frontend/apps/web/src/test/demo-lifecycle.test.ts @@ -16,7 +16,11 @@ import { finalCompletionCensus, } from '../../../../../scripts/demo-observation.mjs'; import { DemoProcesses } from '../../../../../scripts/demo-process.mjs'; -import { isApiListenerEvent, validateCertificate } from '../../../../../scripts/demo.mjs'; +import { + disposeDemoResources, + isApiListenerEvent, + validateCertificate, +} from '../../../../../scripts/demo.mjs'; import { publicApiEnvironment, publicDemoApiEnvironment, @@ -88,6 +92,50 @@ describe('shared explicit demo stack selection', () => { expect(result.stderr).not.toContain('foreign'); } }); + it('launches the argument-free Seeder from the documented shell path', () => { + const root = temporary(); + for (const directory of ['scripts', 'backend', 'bin']) mkdirSync(join(root, directory)); + for (const file of ['seed.sh', 'connection-string.awk']) + writeFileSync( + join(root, 'scripts', file), + readFileSync(new URL(`../../../../../scripts/${file}`, import.meta.url)), + ); + writeFileSync( + join(root, '.env'), + 'ConnectionStrings__Migration=Username=learnstack_migration\nConnectionStrings__Default=Username=learnstack_app\n', + ); + for (const [name, body] of Object.entries({ + docker: '[ "$*" = "compose version" ]', + node: '[ "$*" = "scripts/wait-compose.mjs" ]', + dotnet: + '[ "$#" = 3 ] && [ "$1" = run ] && [ "$2" = --project ] && [ "$3" = src/LearnStack.Tools.Seeder ] && [ "$PWD" = "$TEST_ROOT/backend" ] && [ "$ConnectionStrings__Default" = Username=learnstack_app ] && printf launched > "$TEST_ROOT/launched"', + })) + writeFileSync(join(root, 'bin', name), `#!/bin/sh\n${body}\n`, { mode: 0o700 }); + const launch = () => + spawnSync('bash', [join(root, 'scripts/seed.sh')], { + cwd: root, + env: { + PATH: `${join(root, 'bin')}:${process.env.PATH}`, + TEST_ROOT: root, + NODE_ENV: 'test', + }, + encoding: 'utf8', + timeout: 5000, + }); + expect(launch().status).toBe(0); + expect(readFileSync(join(root, 'launched'), 'utf8')).toBe('launched'); + const script = join(root, 'scripts/seed.sh'); + writeFileSync( + script, + readFileSync(script, 'utf8').replace( + 'dotnet run --project src/LearnStack.Tools.Seeder', + 'dotnet run --project src/LearnStack.Tools.Seeder --nologo', + ), + ); + const refused = launch(); + expect(refused.status).not.toBe(0); + expect(refused.stderr).toContain('tenant seeding failed'); + }); it('reads the selected migration env file and refuses its runtime role before any database act', () => { const root = temporary(); writeFileSync( @@ -585,3 +633,115 @@ describe('owned real process lifecycle', () => { expect(() => processes.assertActive()).toThrow('daemon exited'); }); }); + +describe('independent owned cleanup attempts', () => { + it.each(['processes', 'observer', 'compose'])( + 'retains private state and failure after %s fails, without skipping other teardown', + async (stage) => { + const actions: string[] = []; + const cause = new Error('private diagnostic sentinel'); + const attempt = (name: string) => () => { + actions.push(name); + if (stage === name) throw cause; + }; + const error = await disposeDemoResources({ + processes: { dispose: async () => attempt('processes')() }, + finish: attempt('observer'), + teardown: attempt('compose'), + removePrivate: attempt('private'), + }).catch((error: unknown) => error); + expect(actions).toEqual(['processes', 'observer', 'compose']); + expect(error).toBeInstanceOf(AggregateError); + expect((error as AggregateError).errors).toEqual([cause]); + expect((error as Error).message).not.toContain(cause.message); + }, + ); + it.each([false, true])( + 'removes private state only after every applicable stage confirms cleanup (CI=%s)', + async (ci) => { + const actions: string[] = []; + await disposeDemoResources({ + processes: { + dispose: async () => { + actions.push('processes'); + }, + }, + finish: () => { + actions.push('observer'); + }, + teardown: ci + ? async () => { + actions.push('compose'); + } + : undefined, + removePrivate: () => { + actions.push('private'); + }, + }); + expect(actions).toEqual( + ci ? ['processes', 'observer', 'compose', 'private'] : ['processes', 'observer', 'private'], + ); + }, + ); + it('retains observer causes internally without printing private output', async () => { + const cause = new Error('private observer sentinel'); + const processes = new DemoProcesses(new AbortController().signal); + const error = await processes + .run(process.execPath, ['-e', 'process.stdout.write("output")'], { + label: 'Fixed observer stage', + observe: () => { + throw cause; + }, + }) + .catch((error: unknown) => error); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe('demo: Fixed observer stage failed'); + expect(((error as Error).cause as Error).cause).toBe(cause); + await processes.dispose(); + }); +}); + +describe('listener bind diagnostics', () => { + it.each(['EADDRINUSE', 'EACCES'])( + 'keeps %s distinct without exposing socket diagnostics', + (code) => { + const result = spawnSync( + process.execPath, + [ + '--input-type=module', + '-e', + ` + import net from 'node:net'; + import { EventEmitter } from 'node:events'; + import { syncBuiltinESMExports } from 'node:module'; + const cause = Object.assign(new Error('private bind diagnostic'), { code: process.argv[1] }); + net.createServer = () => { + const server = new EventEmitter(); + server.listen = () => queueMicrotask(() => server.emit('error', cause)); + return server; + }; + syncBuiltinESMExports(); + const { vacant } = await import(process.argv[2]); + try { await vacant(3000); process.exitCode = 2; } + catch (error) { console.log(JSON.stringify({ message: error.message, code: error.cause?.code })); } + `, + code, + new URL('../../../../../scripts/demo-http.mjs', import.meta.url).href, + ], + { + encoding: 'utf8', + timeout: 5000, + }, + ); + expect(result.status).toBe(0); + expect(result.stderr).toBe(''); + expect(JSON.parse(result.stdout)).toEqual({ + message: + code === 'EADDRINUSE' + ? 'Port 3000 is occupied; stop its owner before make demo' + : 'Demo listener bind check failed', + code, + }); + }, + ); +}); diff --git a/scripts/demo-http.mjs b/scripts/demo-http.mjs index 647f8614..9b930fd3 100644 --- a/scripts/demo-http.mjs +++ b/scripts/demo-http.mjs @@ -24,8 +24,15 @@ export async function boundedBody(response, maximum) { export function vacant(port) { return new Promise((resolve, reject) => { const server = createServer(); - server.once('error', () => - reject(new Error(`Port ${port} is occupied; stop its owner before make demo`)), + server.once('error', (cause) => + reject( + new Error( + cause.code === 'EADDRINUSE' + ? `Port ${port} is occupied; stop its owner before make demo` + : 'Demo listener bind check failed', + { cause }, + ), + ), ); server.listen(port, '127.0.0.1', () => server.close((error) => (error ? reject(error) : resolve())), diff --git a/scripts/demo-process.d.mts b/scripts/demo-process.d.mts index 6f1fbe68..96a9a11f 100644 --- a/scripts/demo-process.d.mts +++ b/scripts/demo-process.d.mts @@ -1,7 +1,7 @@ import type { ChildProcess } from 'node:child_process'; export function installDemoCancellation(cancel: () => void): () => void; export class DemoCommandError extends Error { - constructor(label: string); + constructor(label: string, cause?: unknown); } interface Options { cwd?: string; diff --git a/scripts/demo-process.mjs b/scripts/demo-process.mjs index ec6de503..f42b9827 100644 --- a/scripts/demo-process.mjs +++ b/scripts/demo-process.mjs @@ -3,8 +3,8 @@ import { stopTestChild } from '../frontend/apps/web/scripts/stop-test-child.mjs' /** Only fixed caller-owned stage labels reach the foreground CLI. */ export class DemoCommandError extends Error { - constructor(label) { - super(`demo: ${label} failed`); + constructor(label, cause) { + super(`demo: ${label} failed`, { cause }); } } @@ -60,8 +60,8 @@ export class DemoProcesses { let outputBytes = 0; let failed; for (const stream of [child.stdout, child.stderr]) { - stream.once('error', () => { - failed = new Error(`${label} output refused`); + stream.once('error', (cause) => { + failed ??= new Error(`${label} output refused`, { cause }); void stopTestChild(child); }); stream.on('data', (chunk) => { @@ -72,8 +72,8 @@ export class DemoProcesses { if (outputBytes > 2 * 1024 * 1024) throw new Error('Command output exceeds bound'); output.push(chunk); } - } catch { - failed = new Error(`${label} output refused`); + } catch (cause) { + failed ??= new Error(`${label} output refused`, { cause }); void stopTestChild(child); } }); @@ -85,16 +85,23 @@ export class DemoProcesses { const running = { child, exit, - result: () => ({ output: Buffer.concat(output).toString('utf8'), failed }), + result: () => ({ + output: Buffer.concat(output).toString('utf8'), + failed, + }), }; - this.#children.set(child, { closed, result: running.result, daemon: false }); + this.#children.set(child, { + closed, + result: running.result, + daemon: false, + }); return running; } daemon(command, args, options) { const running = this.start(command, args, options); this.#children.get(running.child).daemon = true; void running.exit.then(() => { - this.#failed = new DemoCommandError(`${options.label} exited`); + this.#failed = new DemoCommandError(`${options.label} exited`, running.result().failed); this.#failure(this.#failed); }); return running; @@ -116,7 +123,10 @@ export class DemoProcesses { this.failure.then(() => ({ code: 1 })), ]); if (result.code !== 0 || running.result().failed) - throw new DemoCommandError(options.label ?? command); + throw new DemoCommandError( + options.label ?? command, + running.result().failed ?? this.#failed, + ); this.assertActive(); return running.result().output; } finally { @@ -147,10 +157,16 @@ export class DemoProcesses { } finally { clearTimeout(timer); } - if (daemon && result().failed) throw new Error('Owned output was refused'); + if (daemon && result().failed) + throw new Error('Owned output was refused', { + cause: result().failed, + }); }), ); if (results.some((row) => row.status === 'rejected')) - throw new Error('Owned process cleanup failed; private files retained'); + throw new AggregateError( + results.filter((row) => row.status === 'rejected').map((row) => row.reason), + 'Owned process cleanup failed; private files retained', + ); } } diff --git a/scripts/demo.d.mts b/scripts/demo.d.mts index 920f28e3..1ffc8e2b 100644 --- a/scripts/demo.d.mts +++ b/scripts/demo.d.mts @@ -5,3 +5,54 @@ export function validateCertificate( hosts: string[], now?: number, ): import('node:crypto').X509Certificate; + +export function disposeDemoResources(options: { + processes: Pick<import('./demo-process.mjs').DemoProcesses, 'dispose'>; + finish?: () => void | Promise<void>; + teardown?: () => void | Promise<void>; + removePrivate: () => void | Promise<void>; +}): Promise<void>; +export function startDemo(mode: 'local' | 'ci'): Promise<{ + mode: 'local' | 'ci'; + manifest: DemoLane[]; + configuration: import('../frontend/apps/web/src/server/ingress').PublicServerConfiguration; + ca: string[] | Buffer; + leaf: import('node:crypto').X509Certificate; + privateRoot: string; + observation: { + apiReady: boolean; + webReady: boolean; + counts: Map<string, number>; + malformed: boolean; + }; + completions: import('./demo-observation.mjs').CompletionState; + processes: import('./demo-process.mjs').DemoProcesses; + signal: AbortSignal; + dispose(): Promise<{ + passed: true; + milliseconds: number; + processesStopped: true; + composeEmpty: boolean; + privateFilesRemoved: boolean; + environmentRemoved: boolean; + lockRemoved: boolean; + }>; + compose(args: string[], options?: { timeout?: number; capture?: boolean }): Promise<string>; + signalRequested(): boolean; + setupMilliseconds: number; +}>; + +interface DemoLane { + host: string; + locale: string; + displayName: string; + courseTitle: string; + lessonTitle: string; + catalogUrl: string; + courseUrl: string; + lessonUrl: string; + fields: { name: string; label: string; value: string }[]; + theme: Record<string, string>; + excludedTitles: string[]; + excludedContent: string[]; +} diff --git a/scripts/demo.mjs b/scripts/demo.mjs index 827a237c..3b59ae2b 100644 --- a/scripts/demo.mjs +++ b/scripts/demo.mjs @@ -52,6 +52,25 @@ export function validateCertificate(certificate, privateKey, hosts, now = Date.n return leaf; } +/** Attempt every owned teardown stage; private files survive any unconfirmed stage. */ +export async function disposeDemoResources({ processes, finish, teardown, removePrivate }) { + const failures = []; + for (const attempt of [() => processes.dispose(), finish, teardown]) { + if (!attempt) continue; + try { + await attempt(); + } catch (error) { + failures.push(error); + } + } + if (failures.length) + throw new AggregateError( + failures, + 'Owned cleanup could not be confirmed; private files retained', + ); + await removePrivate(); +} + /** Start the same seeded, production-built topology for foreground use and CI proof. */ export async function startDemo(mode) { if (!['local', 'ci'].includes(mode)) throw new Error('Use make demo DEMO_MODE=local or ci'); @@ -98,45 +117,52 @@ export async function startDemo(mode) { }; controller.abort(); try { - await processes.dispose(); - completions?.finish(); - // Cleanup has a separate signal: cancellation must not cancel teardown. - if (mode === 'ci' && startedStack) { - const cleanup = new DemoProcesses(new AbortController().signal); - try { - await cleanup.run( - 'docker', - [...selected.args, '--profile', '*', 'down', '--volumes', '--remove-orphans'], - { - cwd: selected.cwd, - env: selected.env, - label: 'Owned Compose teardown', - timeout: cleanupTimeout(20000), - }, - ); - const remaining = await cleanup.run( - 'docker', - [...selected.args, '--profile', '*', 'ps', '-a', '-q'], - { - cwd: selected.cwd, - env: selected.env, - label: 'Owned Compose teardown verification', - capture: true, - timeout: cleanupTimeout(5000), - }, - ); - if (remaining.trim()) throw new Error('Owned Compose cleanup incomplete'); - } finally { - await cleanup.dispose(); - } - } - if (ownedEnvironment !== undefined) { - if (readFileSync(rootEnv, 'utf8') !== ownedEnvironment) - throw new Error('CI environment changed; private files retained'); - unlinkSync(rootEnv); - } - if (privateRoot) rmSync(privateRoot, { recursive: true }); - if (locked) rmSync(lock, { recursive: true }); + await disposeDemoResources({ + processes, + finish: () => completions?.finish(), + // Cleanup has a separate signal: cancellation must not cancel teardown. + teardown: + mode === 'ci' && startedStack + ? async () => { + const cleanup = new DemoProcesses(new AbortController().signal); + try { + await cleanup.run( + 'docker', + [...selected.args, '--profile', '*', 'down', '--volumes', '--remove-orphans'], + { + cwd: selected.cwd, + env: selected.env, + label: 'Owned Compose teardown', + timeout: cleanupTimeout(20000), + }, + ); + const remaining = await cleanup.run( + 'docker', + [...selected.args, '--profile', '*', 'ps', '-a', '-q'], + { + cwd: selected.cwd, + env: selected.env, + label: 'Owned Compose teardown verification', + capture: true, + timeout: cleanupTimeout(5000), + }, + ); + if (remaining.trim()) throw new Error('Owned Compose cleanup incomplete'); + } finally { + await cleanup.dispose(); + } + } + : undefined, + removePrivate: () => { + if (ownedEnvironment !== undefined) { + if (readFileSync(rootEnv, 'utf8') !== ownedEnvironment) + throw new Error('CI environment changed; private files retained'); + unlinkSync(rootEnv); + } + if (privateRoot) rmSync(privateRoot, { recursive: true }); + if (locked) rmSync(lock, { recursive: true }); + }, + }); const milliseconds = Date.now() - started; if (mode === 'ci' && milliseconds > 30000) throw new Error('Owned cleanup deadline'); return { diff --git a/scripts/seed.sh b/scripts/seed.sh index 1787d318..60b892fd 100755 --- a/scripts/seed.sh +++ b/scripts/seed.sh @@ -127,7 +127,7 @@ fi # repository root no global.json applies and whichever SDK is newest answers, which is # not the one CI and `make migrate` use. if ! (cd backend && ConnectionStrings__Default="$seed_cs" \ - dotnet run --project src/LearnStack.Tools.Seeder --nologo); then + dotnet run --project src/LearnStack.Tools.Seeder); then red "seed: tenant seeding failed." red " Has the schema been applied? → make migrate" exit 1 diff --git a/scripts/wait-compose.d.mts b/scripts/wait-compose.d.mts index 0bb9bc20..a673e2f4 100644 --- a/scripts/wait-compose.d.mts +++ b/scripts/wait-compose.d.mts @@ -8,3 +8,10 @@ export function healthyServices( rows: { Service: string; State: string; Health?: string }[], postgresOnly?: boolean, ): boolean; + +export function waitCompose( + selected: ReturnType<typeof import('./compose.mjs').composeSelection>, + processes: import('./demo-process.mjs').DemoProcesses, + signal: AbortSignal, + postgresOnly?: boolean, +): Promise<void>; From db6bcdf3ab6604860e330c3a5c9fde0dabb8462f Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sun, 11 Oct 2026 11:11:01 +0300 Subject: [PATCH 25/92] test(architecture): close demo source census admission gaps Resolve supported local aliases and workspace exports transitively, refuse unclassified roots/formats and missing imports, and distinguish actual dependency/output roots from nested authored source. Cross-check backend sources against evaluated Compile inputs and refuse unmodeled build shapes. ADR: 0018, 0035 --- .../DomainGenericityTests.DemoIdentities.cs | 25 +- .../DomainGenericityTests.SourceCensus.cs | 90 +++++ .../21-architecture-tests-catalogue.md | 15 +- .../web/src/test/demo-genericity-sources.ts | 336 +++++++++++++++--- .../apps/web/src/test/demo-genericity.test.ts | 166 ++++++++- 5 files changed, 573 insertions(+), 59 deletions(-) create mode 100644 backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.SourceCensus.cs diff --git a/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.DemoIdentities.cs b/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.DemoIdentities.cs index 4ad97a35..3cdad383 100644 --- a/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.DemoIdentities.cs +++ b/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.DemoIdentities.cs @@ -16,9 +16,8 @@ public void Production_Code_Does_Not_Branch_On_Demo_Tenant_Literals() { var vocabulary = DemoIdentities.Create(); vocabulary.Values.Should().NotBeEmpty(); - var files = ProductionSourceRoots.SelectMany(root => Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)) - .Where(path => !path.Split(Path.DirectorySeparatorChar).Any(part => part is "bin" or "obj")) - .Where(path => Path.GetExtension(path) is ".cs" or ".json") + var allFiles = DemoProductionSourceCensus(); + var files = allFiles // Sole authoritative tenant declaration, never the whole Seeder project. .Where(path => Path.GetRelativePath(RepositoryPaths.BackendSrc(), path).Replace('\\', '/') != "LearnStack.Tools.Seeder/SeedData.cs").Order(StringComparer.Ordinal).ToArray(); @@ -112,6 +111,26 @@ public void Demo_Literal_Fence_Detects_Decoded_Identities_And_Admits_Platform_Da foreach (var value in new[] { identity, key, Guid.Parse(id).ToString("B").ToUpperInvariant(), Guid.Parse(id).ToString("N") }) DemoLiteralViolations("Probe.cs", $"class Probe {{ string Value = {JsonSerializer.Serialize(value)}; }}", vocabulary) .Should().ContainSingle("each backend identity/customization/alternate UUID spelling must be seen"); + var project = Path.GetFullPath("/repo/backend/src/Probe"); + var generated = new[] { Path.Combine(project, "bin"), Path.Combine(project, "obj") }; + foreach (var directory in new[] { "bin", "obj" }) + { + BackendDemoSourceKind(Path.Combine(project, "Features", directory, "Probe.cs"), generated) + .Should().Be(1, "only actual project outputs are excluded"); + BackendDemoSourceKind(Path.Combine(project, directory, "Generated.cs"), generated).Should().Be(0); + } + Action unknownFormat = () => _ = BackendDemoSourceKind(Path.Combine(project, "Probe.resx"), generated); + unknownFormat.Should().Throw<InvalidOperationException>(); + Action linkedInput = () => RequireCompileCoverage([Path.Combine(project, "Probe.cs")], ["/repo/shared/Linked.cs"]); + linkedInput.Should().Throw<InvalidOperationException>(); + RequireCompileCoverage([Path.Combine(project, "Probe.cs")], [Path.Combine(project, "Probe.cs")]); + Action dynamicCompile = () => RefuseExecutionTimeCompile("<Project><Target Name=\"Add\"><ItemGroup><Compile Include=\"../shared/Probe.cs\" /></ItemGroup></Target></Project>"); + dynamicCompile.Should().Throw<InvalidOperationException>(); + RefuseExecutionTimeCompile("<Project><ItemGroup><Compile Include=\"Probe.cs\" /></ItemGroup></Project>"); + Action importedTarget = () => RefuseExecutionTimeCompile("<Project><Import Project=\"../shared.targets\" /></Project>"); + importedTarget.Should().Throw<InvalidOperationException>(); + Action customSdk = () => RefuseExecutionTimeCompile("<Project Sdk=\"Custom.Sdk\" />"); + customSdk.Should().Throw<InvalidOperationException>(); var escaped = $"\\u{(int)identity[0]:x4}{identity[1..]}"; DemoLiteralViolations("Probe.cs", "class Probe { string Value = \"" + escaped + "\"; }", vocabulary) .Should().ContainSingle("Roslyn decodes escaped strings"); diff --git a/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.SourceCensus.cs b/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.SourceCensus.cs new file mode 100644 index 00000000..35911d8d --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.SourceCensus.cs @@ -0,0 +1,90 @@ +using System.Diagnostics; +using System.Text.Json; +using System.Xml.Linq; + +namespace LearnStack.Tests.Architecture; + +public sealed partial class DomainGenericityTests +{ + private static readonly string[] DemoOutputProperties = ["BaseOutputPath", "BaseIntermediateOutputPath"]; + + private static string[] DemoProductionSourceCensus() + { + var projects = ProductionSourceRoots.SelectMany(root => Directory.EnumerateFiles(root, "*.csproj", SearchOption.AllDirectories)) + .Order(StringComparer.Ordinal).ToArray(); + var evaluated = projects.Select(EvaluatedDemoCompile).ToArray(); + var outputs = evaluated.SelectMany(row => row.Outputs).Distinct(StringComparer.Ordinal).ToArray(); + var files = ProductionSourceRoots.SelectMany(root => Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)) + .Where(path => BackendDemoSourceKind(path, outputs) == 1).ToArray(); + // Evaluation includes explicit/linked Compile items as well as the SDK's default glob. + // A compiler input outside the supported census must refuse, never disappear. + RequireCompileCoverage(files, evaluated.SelectMany(row => row.Sources)); + foreach (var file in ProductionSourceRoots.SelectMany(root => Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)) + .Where(path => BackendDemoSourceKind(path, outputs) == 2) + .Concat([Path.Combine(RepositoryPaths.RepoRoot(), "backend/Directory.Build.props"), + Path.Combine(RepositoryPaths.RepoRoot(), "backend/Directory.Packages.props")]) + .Where(path => Path.GetExtension(path) is ".csproj" or ".props" or ".targets")) + RefuseExecutionTimeCompile(File.ReadAllText(file)); + return files; + } + + private static int BackendDemoSourceKind(string path, IEnumerable<string> outputs) + { + path = Path.GetFullPath(path); + if (outputs.Any(root => path.StartsWith(Path.TrimEndingDirectorySeparator(root) + Path.DirectorySeparatorChar, StringComparison.Ordinal))) + return 0; + if (Path.GetExtension(path) is ".cs" or ".json") return 1; + if (Path.GetExtension(path) is ".csproj" or ".props" or ".targets" or ".md" || Path.GetFileName(path) == ".editorconfig") return 2; + throw new InvalidOperationException($"Unclassified backend production source: {path}; Fix: review its census ownership."); + } + + private static void RequireCompileCoverage(IEnumerable<string> files, IEnumerable<string> compiled) + { + var admitted = files.Select(Path.GetFullPath).ToHashSet(StringComparer.Ordinal); + if (compiled.Any(path => !admitted.Contains(Path.GetFullPath(path)))) + throw new InvalidOperationException("Unclassified Compile input; Fix: include the linked source in the supported production census."); + } + + private static void RefuseExecutionTimeCompile(string source) + { + var document = XDocument.Parse(source); + const string modulesImport = "$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))"; + if (document.Descendants().Any(node => node.Name.LocalName is "Target" or "Sdk" + || node.Name.LocalName == "Import" && (string?)node.Attribute("Project") != modulesImport) + || document.Root?.Attribute("Sdk") is { } sdk && sdk.Value is not ("Microsoft.NET.Sdk" or "Microsoft.NET.Sdk.Web")) + throw new InvalidOperationException("Execution-time Compile/import shapes are unmodeled; Fix: review compiler-input admission before introducing a custom target or import."); + } + + private static (string[] Sources, string[] Outputs) EvaluatedDemoCompile(string project) + { + using var child = new Process + { + StartInfo = new ProcessStartInfo("dotnet") + { + ArgumentList = { "msbuild", project, "-getItem:Compile", "-getProperty:BaseOutputPath,BaseIntermediateOutputPath", "-p:Configuration=Release", "-nologo" }, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = Path.GetDirectoryName(project)!, + } + }; + child.Start(); + var output = child.StandardOutput.ReadToEndAsync(); + var error = child.StandardError.ReadToEndAsync(); + if (!child.WaitForExit(120_000)) + { + child.Kill(entireProcessTree: true); + child.WaitForExit(); + throw new InvalidOperationException("MSBuild Compile census deadline."); + } + Task.WaitAll(output, error); + if (child.ExitCode != 0) throw new InvalidOperationException("MSBuild Compile census failed."); + using var document = JsonDocument.Parse(output.Result); + var sources = document.RootElement.GetProperty("Items").GetProperty("Compile").EnumerateArray() + .Select(row => Path.GetFullPath(row.GetProperty("FullPath").GetString()!)).ToArray(); + var properties = document.RootElement.GetProperty("Properties"); + var outputs = DemoOutputProperties + .Select(name => properties.GetProperty(name).GetString()!) + .Select(path => Path.GetFullPath(path.Replace('\\', Path.DirectorySeparatorChar), Path.GetDirectoryName(project)!)).ToArray(); + return (sources, outputs); + } +} diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 0fb033a4..a24b817d 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -675,8 +675,19 @@ otherwise). text/attribute values are included. Frontend runtime imports cannot hide helpers behind test filenames or directories; exact standalone proof/development entries retain their bounded exemptions. - Generated-output roots are exact paths; local module URL suffixes resolve and - computed runtime imports fail rather than omit a helper. + Generated/dependency roots are exact paths. Local module URL suffixes, + supported tsconfig aliases, workspace exports, re-exports, import-equals and + literal `require.resolve` edges enter the closure. Missing/unsupported local + edges and unclassified repository roots/source formats fail. Installed external + packages are resolved under the known dependency roots; local/link targets + enter the census or refuse. Native `.server` edges require the actual pinned + TypeScript output mapping; the known stylesheet is an explicit inert asset. + Backend `DomainGenericityTests.SourceCensus.cs` cross-checks the source set with + Release MSBuild-evaluated `Compile` inputs and actual output/intermediate roots. + Nested `bin`/`obj` remain authored source. Linked inputs outside the census and + unmodeled custom targets/imports/SDKs refuse rather than silently omit code. + Dirty/clean controls cover these admission boundaries. Computed runtime imports + fail rather than omit a helper. The rule does not claim to prove dynamically assembled identities. `Production_Projects_Do_Not_Reference_The_Seeder` diff --git a/frontend/apps/web/src/test/demo-genericity-sources.ts b/frontend/apps/web/src/test/demo-genericity-sources.ts index 2c9542ba..f00d5458 100644 --- a/frontend/apps/web/src/test/demo-genericity-sources.ts +++ b/frontend/apps/web/src/test/demo-genericity-sources.ts @@ -1,3 +1,4 @@ +import { isBuiltin } from 'node:module'; import { posix } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -29,24 +30,256 @@ const generatedOutputs = new Set([ /** Only actual generated roots are omitted; nested source directories stay visible. */ export function omitDemoSourceDirectory(path: string): boolean { - return path.split('/').at(-1) === 'node_modules' || generatedOutputs.has(path); + return dependencyRoots.has(path) || generatedOutputs.has(path); } +const dependencyRoots = new Set([ + 'frontend/node_modules', + 'frontend/apps/web/node_modules', + 'frontend/packages/config/node_modules', + 'frontend/packages/sdk/node_modules', + 'frontend/packages/ui/node_modules', +]); +const sourceExtension = /\.(?:[cm]?[jt]sx?|json)$/; +const declaration = /\.d\.[cm]?[jt]sx?$/; +const nonRuntimeFiles = new Set([ + 'frontend/.gitignore', + 'frontend/.npmrc', + 'frontend/.nvmrc', + 'frontend/.prettierignore', + 'frontend/README.md', + 'frontend/apps/web/README.md', + 'frontend/apps/web/.env.local.example', + 'frontend/apps/web/src/app/globals.css', + 'frontend/apps/web/src/components/.gitkeep', + 'frontend/apps/web/tsconfig.tsbuildinfo', + 'frontend/packages/sdk/tsconfig.tsbuildinfo', + 'frontend/packages/ui/tsconfig.tsbuildinfo', + 'frontend/pnpm-lock.yaml', + 'frontend/pnpm-workspace.yaml', + 'scripts/adr-status.py', + 'scripts/assert-tests-ran.py', + 'scripts/connection-string.awk', + 'scripts/install-oasdiff.py', + 'scripts/openapi-ci.py', + 'scripts/openapi-policy.py', + 'scripts/seed.sh', + 'scripts/test-adr-status.py', + 'scripts/test-openapi-ci.py', +]); +const nonRuntimeRoots = new Set([ + '.claude', + '.config', + '.githooks', + '.github', + '.vscode', + 'docs', + 'infra', +]); +const rootFiles = new Set([ + '.env.example', + '.gitattributes', + '.gitignore', + '.leakwatch.yaml', + '.leakwatchignore', + '.prettierignore', + 'AGENTS.md', + 'CLAUDE.md', + 'Makefile', + 'README.md', +]); + +/** Refuse new roots/formats until their production or non-runtime ownership is explicit. */ +export function demoSourceClassification(path: string): 'runtime' | 'other' { + if (!path || path.startsWith('/') || path.split('/').some((part) => part === '..')) + throw new Error(`Unclassified repository source: ${path}`); + const root = path.split('/')[0]!; + if (nonRuntimeRoots.has(root) || rootFiles.has(path)) return 'other'; + // Backend source admission and evaluated Compile coverage are owned by its architecture test. + if (root === 'backend') { + if ( + /^backend\/(?:src|analyzers|tests|openapi)\//.test(path) || + /^backend\/(?:\.editorconfig|Directory\.(?:Build|Packages)\.props|LearnStack\.slnx|README\.md|global\.json)$/.test( + path, + ) + ) + return 'other'; + throw new Error(`Unclassified repository source: ${path}`); + } + if (root !== 'frontend' && root !== 'scripts') + throw new Error(`Unclassified repository source: ${path}`); + if (nonRuntimeFiles.has(path) || declaration.test(path)) return 'other'; + if ( + !sourceExtension.test(path) || + [...dependencyRoots, ...generatedOutputs].some((dir) => path.startsWith(dir + '/')) + ) + throw new Error(`Unclassified repository source: ${path}`); + return 'runtime'; +} + +export type ExternalModuleResolver = (from: string, specifier: string, anchors: string[]) => string; + function testSource(path: string): boolean { return ( path.startsWith('frontend/apps/web/src/test/') || /\.(?:test|spec)\.[cm]?[jt]sx?$/.test(path) ); } -/** A test name/directory cannot hide a helper imported by runtime code. */ -export function productionDemoSources(all: ReadonlyMap<string, string>): Map<string, string> { +/** A standalone exemption cannot hide a statically imported runtime helper. */ +export function productionDemoSources( + all: ReadonlyMap<string, string>, + external?: ExternalModuleResolver, +): Map<string, string> { + const refused = (path: string): never => { + throw new Error(`Unmodeled runtime import: ${path}`); + }; + const packages = [...all] + .filter(([path]) => path.endsWith('/package.json')) + .map(([path, source]) => ({ + root: posix.dirname(path), + manifest: JSON.parse(source), + })); + const generated = new Map<string, string>(); + const serverConfig = all.get('frontend/apps/web/tsconfig.server.json'); + if (serverConfig) { + const config = JSON.parse(serverConfig); + const expected = ['src/server/ingress.ts', 'src/server/public-admission-runtime.ts']; + if ( + config.compilerOptions?.rootDir !== 'src/server' || + config.compilerOptions?.outDir !== '.server' || + JSON.stringify([...config.include].sort()) !== JSON.stringify(expected) + ) + refused('tsconfig.server.json'); + for (const input of expected) + generated.set( + `frontend/apps/web/.server/${posix.basename(input, '.ts')}.js`, + `frontend/apps/web/${input}`, + ); + } + const candidates = (base: string) => [ + base, + ...['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts', '.mjs', '.cjs', '.json'].flatMap((ext) => [ + base + ext, + posix.join(base, 'index' + ext), + ]), + ...(/\.[cm]?jsx?$/.test(base) + ? [base.replace(/\.[cm]?jsx?$/, '.ts'), base.replace(/\.[cm]?jsx?$/, '.tsx')] + : []), + ]; + const local = (base: string, from: string): string[] => { + if (base === 'frontend/apps/web/src/app/globals.css') return all.has(base) ? [] : refused(from); + base = generated.get(base) ?? base; + const target = candidates(base).find((path) => all.has(path)); + if (!target || !sourceExtension.test(target) || declaration.test(target)) return refused(from); + return [target]; + }; + const mapped = (value: unknown, root: string, from: string): string[] => { + if (typeof value === 'string' && value.startsWith('./')) + return local(posix.normalize(posix.join(root, value)), from); + if (value && typeof value === 'object' && !Array.isArray(value)) { + const values = Object.values(value); + if (values.length) return values.flatMap((value) => mapped(value, root, from)); + } + return refused(from); + }; + const resolveEdge = (edge: string, path: string, anchors: string[] = []): string[] => { + if (isBuiltin(edge)) return []; + if (/^(?:file:|\/)/.test(edge)) return refused(path); + if (edge.startsWith('.')) { + if (anchors.length) return refused(path); + const filename = fileURLToPath(new URL(edge, `file:///learnstack/${path}`)); + if (!filename.startsWith('/learnstack/')) return refused(path); + return local(filename.slice('/learnstack/'.length), path); + } + // Match actual supported tsconfig paths rather than treating alias spelling as external. + let directory = posix.dirname(path); + while (directory !== '.') { + const source = all.get(posix.join(directory, 'tsconfig.json')); + if (source) { + const config = JSON.parse(source); + if (config.compilerOptions?.baseUrl) return refused(path); + for (const [pattern, replacements] of Object.entries(config.compilerOptions?.paths ?? {})) { + const parts = pattern.split('*'); + if (parts.length > 2 || !Array.isArray(replacements) || replacements.length === 0) + return refused(path); + const match = + parts.length === 1 + ? edge === pattern + : edge.startsWith(parts[0]!) && edge.endsWith(parts[1]!); + if (!match) continue; + const capture = + parts.length === 1 ? '' : edge.slice(parts[0]!.length, edge.length - parts[1]!.length); + return replacements.flatMap((value) => { + if (typeof value !== 'string' || !value.startsWith('./') || value.split('*').length > 2) + return refused(path); + return local(posix.normalize(posix.join(directory, value.replace('*', capture))), path); + }); + } + // Inherited alias maps are not silently ignored. The shipped inherited presets have none. + const checkExtends = (name: unknown, owner: string, seen = new Set<string>()) => { + if (name === undefined) return; + if (typeof name !== 'string' || seen.has(name)) return refused(path); + seen.add(name); + const target = name.startsWith('.') + ? candidates(posix.join(owner, name)).find((key) => all.has(key)) + : packages.flatMap((pkg) => + name.startsWith(pkg.manifest.name + '/') + ? mapped( + pkg.manifest.exports?.['./' + name.slice(pkg.manifest.name.length + 1)], + pkg.root, + path, + ) + : [], + )[0]; + if (!target) return refused(path); + const inherited = JSON.parse(all.get(target)!); + if (inherited.compilerOptions?.paths || inherited.compilerOptions?.baseUrl) + return refused(path); + checkExtends(inherited.extends, posix.dirname(target), seen); + }; + checkExtends(config.extends, directory); + break; + } + directory = posix.dirname(directory); + } + const pkg = packages.find( + (pkg) => edge === pkg.manifest.name || edge.startsWith(pkg.manifest.name + '/'), + ); + if (pkg) { + const key = + edge === pkg.manifest.name ? '.' : './' + edge.slice(pkg.manifest.name.length + 1); + return mapped( + pkg.manifest.exports?.[key] ?? (key === '.' ? pkg.manifest.main : undefined), + pkg.root, + path, + ); + } + // #imports and unmodeled aliases require explicit modeling; bare names are not an exemption. + if (edge.startsWith('#') || !external) return refused(path); + const target = external(path, edge, anchors); + if (all.has(target)) return local(target, path); + if (![...dependencyRoots].some((root) => target.startsWith(root + '/'))) return refused(path); + return []; + }; const result = new Map( - [...all].filter(([path]) => !testSource(path) && !exemptEntries.has(path)), + [...all].filter( + ([path]) => + sourceExtension.test(path) && + !declaration.test(path) && + !testSource(path) && + !exemptEntries.has(path), + ), ); for (const [path, source] of result) { if (path.endsWith('.json')) continue; const file = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true); - const imports: string[] = []; + const imports: { name: string; anchors?: string[] }[] = []; + const resolveCall = (node: ts.Node): node is ts.CallExpression => + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + ts.isIdentifier(node.expression.expression) && + node.expression.expression.text === 'require' && + node.expression.name.text === 'resolve'; const visit = (node: ts.Node) => { if ( ((ts.isImportDeclaration(node) && !node.importClause?.isTypeOnly) || @@ -54,60 +287,67 @@ export function productionDemoSources(all: ReadonlyMap<string, string>): Map<str node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier) ) - imports.push(node.moduleSpecifier.text); + imports.push({ name: node.moduleSpecifier.text }); + if ( + ts.isImportEqualsDeclaration(node) && + !node.isTypeOnly && + ts.isExternalModuleReference(node.moduleReference) + ) { + const value = node.moduleReference.expression; + if (!value || !ts.isStringLiteralLike(value)) return refused(path); + imports.push({ name: value.text }); + } if ( ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || - (ts.isIdentifier(node.expression) && node.expression.text === 'require')) + (ts.isIdentifier(node.expression) && node.expression.text === 'require') || + resolveCall(node)) ) { - let specifier = node.arguments[0]; - // ESLint resolves an external package before loading it. This remains a - // statically named edge; computed resolver inputs are refused below. - if ( - specifier && - ts.isCallExpression(specifier) && - ts.isPropertyAccessExpression(specifier.expression) && - ts.isIdentifier(specifier.expression.expression) && - specifier.expression.expression.text === 'require' && - specifier.expression.name.text === 'resolve' - ) { + const value = node.arguments[0]; + if (value && resolveCall(value)) { + ts.forEachChild(node, visit); + return; + } + if (!value || !ts.isStringLiteralLike(value)) return refused(path); + const anchors: string[] = []; + if (node.arguments.length > 1) { + const options = node.arguments[1]; if ( - specifier.arguments.length > 1 && - (!specifier.arguments[0] || - !ts.isStringLiteralLike(specifier.arguments[0]) || - specifier.arguments[0].text.startsWith('.')) + !resolveCall(node) || + value.text.startsWith('.') || + !options || + !ts.isObjectLiteralExpression(options) || + options.properties.length !== 1 ) - throw new Error(`Unmodeled runtime import: ${path}`); - specifier = specifier.arguments[0]; + return refused(path); + const property = options.properties[0]; + if ( + !property || + !ts.isPropertyAssignment(property) || + property.name.getText(file) !== 'paths' || + !ts.isArrayLiteralExpression(property.initializer) || + !property.initializer.elements.length + ) + return refused(path); + for (const anchor of property.initializer.elements) { + if ( + !resolveCall(anchor) || + anchor.arguments.length !== 1 || + !anchor.arguments[0] || + !ts.isStringLiteralLike(anchor.arguments[0]) + ) + return refused(path); + anchors.push(anchor.arguments[0].text); + } } - if (!specifier || !ts.isStringLiteralLike(specifier)) - throw new Error(`Unmodeled runtime import: ${path}`); - imports.push(specifier.text); + imports.push({ name: value.text, anchors }); } ts.forEachChild(node, visit); }; visit(file); - for (const edge of imports) { - if (/^(?:file:|\/)/.test(edge)) throw new Error(`Unmodeled runtime import: ${path}`); - if (!edge.startsWith('.')) continue; - // Node ESM resolves query/fragment and percent escapes as a module URL. - const resolved = new URL(edge, `file:///learnstack/${path}`); - const filename = fileURLToPath(resolved); - if (!filename.startsWith('/learnstack/')) - throw new Error(`Unmodeled runtime import: ${path}`); - const base = filename.slice('/learnstack/'.length); - const candidates = [ - base, - ...['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts', '.mjs', '.cjs', '.json'].flatMap( - (extension) => [base + extension, posix.join(base, 'index' + extension)], - ), - ]; - if (/\.[cm]?jsx?$/.test(base)) - candidates.push(base.replace(/\.[cm]?jsx?$/, '.ts'), base.replace(/\.[cm]?jsx?$/, '.tsx')); - const target = candidates.find((candidate) => all.has(candidate)); - // Exemptions apply only to standalone entries, never imported runtime helpers. - if (target) result.set(target, all.get(target)!); - } + for (const edge of imports) + for (const target of resolveEdge(edge.name, path, edge.anchors)) + result.set(target, all.get(target)!); } return result; } diff --git a/frontend/apps/web/src/test/demo-genericity.test.ts b/frontend/apps/web/src/test/demo-genericity.test.ts index 56709c4b..0cb5e2ba 100644 --- a/frontend/apps/web/src/test/demo-genericity.test.ts +++ b/frontend/apps/web/src/test/demo-genericity.test.ts @@ -1,28 +1,52 @@ // @vitest-environment node +import { execFileSync } from 'node:child_process'; import { readdirSync, readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import { demoIdentityFindings } from './demo-genericity-analysis'; -import { omitDemoSourceDirectory, productionDemoSources } from './demo-genericity-sources'; +import { + demoSourceClassification, + omitDemoSourceDirectory, + productionDemoSources, +} from './demo-genericity-sources'; import vocabulary from './fixtures/demo-identities.json'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../../../..'); function productionSources(): Map<string, string> { + const inventory = execFileSync( + 'git', + [ + 'ls-files', + '--cached', + '--others', + '--exclude-standard', + '--exclude=.claude/worktrees/', + '-z', + ], + { cwd: root, encoding: 'utf8' }, + ) + .split('\0') + .filter(Boolean); + for (const path of inventory) demoSourceClassification(path); const files = new Map<string, string>(); const visit = (directory: string) => { for (const entry of readdirSync(directory, { withFileTypes: true })) { const path = join(directory, entry.name); const name = relative(root, path).replaceAll('\\', '/'); - if (entry.isSymbolicLink() && entry.name !== 'node_modules') + if (entry.isSymbolicLink() && !omitDemoSourceDirectory(name)) throw new Error(`Unmodeled production symlink: ${name}`); if (entry.isDirectory()) { if (!omitDemoSourceDirectory(name)) visit(path); + } else if (entry.isSymbolicLink()) { + if (!omitDemoSourceDirectory(name)) + throw new Error(`Unmodeled production symlink: ${name}`); } else if ( - /\.(?:[cm]?[jt]sx?|json)$/.test(entry.name) && - !/\.d\.[cm]?[jt]sx?$/.test(entry.name) + demoSourceClassification(name) === 'runtime' || + name === 'frontend/apps/web/src/app/globals.css' ) { files.set(name, readFileSync(path, 'utf8')); } @@ -30,7 +54,11 @@ function productionSources(): Map<string, string> { }; visit(join(root, 'frontend')); visit(join(root, 'scripts')); - return productionDemoSources(files); + return productionDemoSources(files, (from, specifier, anchors) => { + let resolver = createRequire(join(root, from)); + for (const anchor of anchors) resolver = createRequire(resolver.resolve(anchor)); + return relative(root, resolver.resolve(specifier)).replaceAll('\\', '/'); + }); } describe('P02d-7 exact demo identity boundary', () => { @@ -197,7 +225,13 @@ describe('P02d-7 exact demo identity boundary', () => { expect(resolved.has(target)).toBe(true); expect(() => productionDemoSources( - new Map([[entry, "require(require.resolve('eslint-plugin-jsx-a11y', { paths: [] }));"]]), + new Map([ + [ + entry, + "require(require.resolve('eslint-plugin-jsx-a11y', { paths: [require.resolve('eslint-config-next')] }));", + ], + ]), + (_from, name) => `frontend/node_modules/${name}/index.js`, ), ).not.toThrow(); for (const source of [ @@ -222,9 +256,129 @@ describe('P02d-7 exact demo identity boundary', () => { 'frontend/apps/web/src/dist', 'frontend/apps/web/scripts/coverage', 'frontend/apps/web/src/.server', + 'scripts/node_modules', + 'frontend/apps/web/src/node_modules', ]) expect(omitDemoSourceDirectory(path)).toBe(false); }); + it('refuses unclassified production roots and formats while retaining declared non-runtime ownership', () => { + for (const path of [ + 'shared/probe.mjs', + 'frontend/apps/web/src/probe.vue', + 'scripts/probe.runtime', + 'backend/feature/Probe.cs', + ]) + expect(() => demoSourceClassification(path)).toThrow('Unclassified'); + for (const path of [ + 'scripts/demo.mjs', + 'scripts/node_modules/probe/index.js', + 'frontend/apps/web/src/test/probe.test.ts', + ]) + expect(demoSourceClassification(path)).toBe('runtime'); + for (const path of [ + 'README.md', + 'docs/example.md', + 'scripts/seed.sh', + 'frontend/apps/web/src/app/globals.css', + ]) + expect(demoSourceClassification(path)).toBe('other'); + }); + it('follows aliases, workspace exports, import-equals and transitive re-exports into exemptions', () => { + const entry = 'frontend/apps/web/src/server/probe.ts'; + const helper = 'frontend/apps/web/src/test/helper.ts'; + const configHelper = 'frontend/packages/config/helper.test.mjs'; + for (const source of [ + "import '@/test/helper';", + "export * from '@/test/helper';", + "import helper = require('@/test/helper');", + "require.resolve('@/test/helper');", + ]) { + const files = productionDemoSources( + new Map([ + ['frontend/apps/web/tsconfig.json', '{"compilerOptions":{"paths":{"@/*":["./src/*"]}}}'], + [ + 'frontend/packages/config/package.json', + '{"name":"@learnstack/config","exports":{"./helper":"./helper.test.mjs"}}', + ], + [entry, source], + [helper, "export * from '@learnstack/config/helper';"], + [configHelper, `export const branch = ${JSON.stringify(identity)};`], + ]), + ); + expect(files.has(helper)).toBe(true); + expect(files.has(configHelper)).toBe(true); + expect( + [...files].flatMap(([path, source]) => demoIdentityFindings(path, source, vocabulary)), + ).toEqual([identity]); + } + }); + it('refuses missing, unknown-format, inherited-alias and untrusted external edges', () => { + const entry = 'frontend/apps/web/scripts/entry.mjs'; + for (const edge of ['./missing.mjs', './helper.runtime', 'unknown-package', '#local']) + expect(() => + productionDemoSources( + new Map([ + [entry, `require(${JSON.stringify(edge)})`], + ['frontend/apps/web/scripts/helper.runtime', `specialize('${identity}')`], + ]), + ), + ).toThrow('Unmodeled'); + expect(() => + productionDemoSources( + new Map([[entry, "import 'linked-package';"]]), + () => '../foreign/index.js', + ), + ).toThrow('Unmodeled'); + const helper = 'frontend/apps/web/src/test/helper.ts'; + expect( + productionDemoSources( + new Map([ + [entry, "import 'linked-package';"], + [helper, 'export const clean = 1;'], + ]), + () => helper, + ).has(helper), + ).toBe(true); + expect(() => + productionDemoSources( + new Map([ + [entry, "import 'external-package';"], + ['frontend/apps/web/tsconfig.json', '{"extends":"./base.json"}'], + [ + 'frontend/apps/web/base.json', + '{"compilerOptions":{"paths":{"hidden/*":["./src/test/*"]}}}', + ], + ]), + () => 'frontend/node_modules/external-package/index.js', + ), + ).toThrow('Unmodeled'); + }); + it('admits only the validated native generated-output mapping and known stylesheet', () => { + const entry = 'scripts/demo.mjs'; + const source = 'frontend/apps/web/src/server/ingress.ts'; + const config = { + compilerOptions: { rootDir: 'src/server', outDir: '.server' }, + include: ['src/server/ingress.ts', 'src/server/public-admission-runtime.ts'], + }; + const files = new Map([ + [ + entry, + "import '../frontend/apps/web/.server/ingress.js'; import '../frontend/apps/web/src/app/globals.css';", + ], + [source, 'export const clean = true;'], + ['frontend/apps/web/src/app/globals.css', ':root { color: black }'], + ['frontend/apps/web/tsconfig.server.json', JSON.stringify(config)], + ]); + expect(productionDemoSources(files).has(source)).toBe(true); + files.set( + 'frontend/apps/web/tsconfig.server.json', + JSON.stringify({ + ...config, + compilerOptions: { ...config.compilerOptions, outDir: 'unmodeled' }, + }), + ); + expect(() => productionDemoSources(files)).toThrow('Unmodeled'); + }); it('fails loudly on unreadable sources and an empty or malformed vocabulary', () => { expect(() => demoIdentityFindings('probe.ts', 'const = ;', vocabulary)).toThrow('Unreadable'); expect(() => demoIdentityFindings('probe.json', '{', vocabulary)).toThrow(); From 90114f0bbcdc2ef280995aecfc4ad1eb2bb552df Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sun, 11 Oct 2026 11:19:30 +0300 Subject: [PATCH 26/92] test(architecture): refuse ambiguous demo census resolution Include competing alias and JS/TS targets conservatively, and select workspace exports only from owned manifests. Refuse implicit build hooks and project references outside the evaluated production project set. ADR: 0018, 0056 --- .../DomainGenericityTests.DemoIdentities.cs | 6 ++ .../DomainGenericityTests.SourceCensus.cs | 32 ++++++++-- .../21-architecture-tests-catalogue.md | 10 +++- .../web/src/test/demo-genericity-sources.ts | 39 ++++++++---- .../apps/web/src/test/demo-genericity.test.ts | 60 +++++++++++++++++++ 5 files changed, 130 insertions(+), 17 deletions(-) diff --git a/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.DemoIdentities.cs b/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.DemoIdentities.cs index 3cdad383..e96b9763 100644 --- a/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.DemoIdentities.cs +++ b/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.DemoIdentities.cs @@ -131,6 +131,12 @@ public void Demo_Literal_Fence_Detects_Decoded_Identities_And_Admits_Platform_Da importedTarget.Should().Throw<InvalidOperationException>(); Action customSdk = () => RefuseExecutionTimeCompile("<Project Sdk=\"Custom.Sdk\" />"); customSdk.Should().Throw<InvalidOperationException>(); + Action implicitImport = () => RefuseExecutionTimeCompile("<Project Sdk=\"Microsoft.NET.Sdk\"><PropertyGroup><DirectoryBuildTargetsPath>../../tests/CensusInjection.targets</DirectoryBuildTargetsPath></PropertyGroup></Project>"); + implicitImport.Should().Throw<InvalidOperationException>(); + RefuseExecutionTimeCompile("<Project Sdk=\"Microsoft.NET.Sdk\"><PropertyGroup><TargetFramework>net10.0</TargetFramework></PropertyGroup></Project>"); + Action externalProject = () => RequireProductionProjectCoverage([Path.Combine(project, "Probe.csproj")], ["/repo/backend/tests/RuntimeHelper/RuntimeHelper.csproj"]); + externalProject.Should().Throw<InvalidOperationException>(); + RequireProductionProjectCoverage([Path.Combine(project, "Probe.csproj")], [Path.Combine(project, "Probe.csproj")]); var escaped = $"\\u{(int)identity[0]:x4}{identity[1..]}"; DemoLiteralViolations("Probe.cs", "class Probe { string Value = \"" + escaped + "\"; }", vocabulary) .Should().ContainSingle("Roslyn decodes escaped strings"); diff --git a/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.SourceCensus.cs b/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.SourceCensus.cs index 35911d8d..b4c30c32 100644 --- a/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.SourceCensus.cs +++ b/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.SourceCensus.cs @@ -13,6 +13,7 @@ private static string[] DemoProductionSourceCensus() var projects = ProductionSourceRoots.SelectMany(root => Directory.EnumerateFiles(root, "*.csproj", SearchOption.AllDirectories)) .Order(StringComparer.Ordinal).ToArray(); var evaluated = projects.Select(EvaluatedDemoCompile).ToArray(); + RequireProductionProjectCoverage(projects, evaluated.SelectMany(row => row.References)); var outputs = evaluated.SelectMany(row => row.Outputs).Distinct(StringComparer.Ordinal).ToArray(); var files = ProductionSourceRoots.SelectMany(root => Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)) .Where(path => BackendDemoSourceKind(path, outputs) == 1).ToArray(); @@ -45,23 +46,44 @@ private static void RequireCompileCoverage(IEnumerable<string> files, IEnumerabl throw new InvalidOperationException("Unclassified Compile input; Fix: include the linked source in the supported production census."); } + private static void RequireProductionProjectCoverage(IEnumerable<string> projects, IEnumerable<string> references) + { + var admitted = projects.Select(Path.GetFullPath).ToHashSet(StringComparer.Ordinal); + if (references.Any(path => !admitted.Contains(Path.GetFullPath(path)))) + throw new InvalidOperationException("Unclassified production ProjectReference; Fix: include its compiler inputs in the supported census."); + } + + // Property-selected SDK hooks can import targets absent from evaluation-only Compile items. + // New build properties must be reviewed rather than silently expanding this bounded model. + private static readonly HashSet<string> DemoBuildProperties = new(StringComparer.Ordinal) + { + "AnalysisLevel", "AnalysisMode", "AssemblyName", "EnforceCodeStyleInBuild", + "EnforceExtendedAnalyzerRules", "GenerateDocumentationFile", "ImplicitUsings", "IsPackable", + "LangVersion", "ManagePackageVersionsCentrally", "NoWarn", "NuGetAuditLevel", "NuGetAuditMode", + "Nullable", "OutputType", "RootNamespace", "TargetFramework", "TreatWarningsAsErrors", + "UserSecretsId", "WarningsNotAsErrors" + }; + private static void RefuseExecutionTimeCompile(string source) { var document = XDocument.Parse(source); const string modulesImport = "$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))"; - if (document.Descendants().Any(node => node.Name.LocalName is "Target" or "Sdk" + if (document.Root?.Elements().Any(node => node.Name.LocalName is not ("PropertyGroup" or "ItemGroup" or "Import")) == true + || document.Descendants().Where(node => node.Name.LocalName == "PropertyGroup") + .SelectMany(node => node.Elements()).Any(node => !DemoBuildProperties.Contains(node.Name.LocalName)) + || document.Descendants().Any(node => node.Name.LocalName is "Target" or "Sdk" || node.Name.LocalName == "Import" && (string?)node.Attribute("Project") != modulesImport) || document.Root?.Attribute("Sdk") is { } sdk && sdk.Value is not ("Microsoft.NET.Sdk" or "Microsoft.NET.Sdk.Web")) throw new InvalidOperationException("Execution-time Compile/import shapes are unmodeled; Fix: review compiler-input admission before introducing a custom target or import."); } - private static (string[] Sources, string[] Outputs) EvaluatedDemoCompile(string project) + private static (string[] Sources, string[] Outputs, string[] References) EvaluatedDemoCompile(string project) { using var child = new Process { StartInfo = new ProcessStartInfo("dotnet") { - ArgumentList = { "msbuild", project, "-getItem:Compile", "-getProperty:BaseOutputPath,BaseIntermediateOutputPath", "-p:Configuration=Release", "-nologo" }, + ArgumentList = { "msbuild", project, "-getItem:Compile,ProjectReference", "-getProperty:BaseOutputPath,BaseIntermediateOutputPath", "-p:Configuration=Release", "-nologo" }, RedirectStandardOutput = true, RedirectStandardError = true, WorkingDirectory = Path.GetDirectoryName(project)!, @@ -81,10 +103,12 @@ private static (string[] Sources, string[] Outputs) EvaluatedDemoCompile(string using var document = JsonDocument.Parse(output.Result); var sources = document.RootElement.GetProperty("Items").GetProperty("Compile").EnumerateArray() .Select(row => Path.GetFullPath(row.GetProperty("FullPath").GetString()!)).ToArray(); + var references = document.RootElement.GetProperty("Items").GetProperty("ProjectReference").EnumerateArray() + .Select(row => Path.GetFullPath(row.GetProperty("FullPath").GetString()!)).ToArray(); var properties = document.RootElement.GetProperty("Properties"); var outputs = DemoOutputProperties .Select(name => properties.GetProperty(name).GetString()!) .Select(path => Path.GetFullPath(path.Replace('\\', Path.DirectorySeparatorChar), Path.GetDirectoryName(project)!)).ToArray(); - return (sources, outputs); + return (sources, outputs, references); } } diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index a24b817d..3a0f9603 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -677,15 +677,19 @@ otherwise). exact standalone proof/development entries retain their bounded exemptions. Generated/dependency roots are exact paths. Local module URL suffixes, supported tsconfig aliases, workspace exports, re-exports, import-equals and - literal `require.resolve` edges enter the closure. Missing/unsupported local + literal `require.resolve` edges enter the closure. Ambiguous local JS/TS and alias + targets are conservatively included; only owned workspace manifests select + workspace exports, and duplicate identities refuse. Missing/unsupported local edges and unclassified repository roots/source formats fail. Installed external packages are resolved under the known dependency roots; local/link targets enter the census or refuse. Native `.server` edges require the actual pinned TypeScript output mapping; the known stylesheet is an explicit inert asset. Backend `DomainGenericityTests.SourceCensus.cs` cross-checks the source set with - Release MSBuild-evaluated `Compile` inputs and actual output/intermediate roots. + Release MSBuild-evaluated `Compile` inputs, `ProjectReference` paths and actual + output/intermediate roots. Nested `bin`/`obj` remain authored source. Linked inputs outside the census and - unmodeled custom targets/imports/SDKs refuse rather than silently omit code. + unmodeled build properties, custom targets/imports/SDKs and references outside + the admitted production project set refuse rather than silently omit code. Dirty/clean controls cover these admission boundaries. Computed runtime imports fail rather than omit a helper. The rule does not claim diff --git a/frontend/apps/web/src/test/demo-genericity-sources.ts b/frontend/apps/web/src/test/demo-genericity-sources.ts index f00d5458..6330ab08 100644 --- a/frontend/apps/web/src/test/demo-genericity-sources.ts +++ b/frontend/apps/web/src/test/demo-genericity-sources.ts @@ -134,7 +134,7 @@ export function productionDemoSources( throw new Error(`Unmodeled runtime import: ${path}`); }; const packages = [...all] - .filter(([path]) => path.endsWith('/package.json')) + .filter(([path]) => /^frontend\/(?:apps|packages)\/[^/]+\/package\.json$/.test(path)) .map(([path, source]) => ({ root: posix.dirname(path), manifest: JSON.parse(source), @@ -169,9 +169,14 @@ export function productionDemoSources( const local = (base: string, from: string): string[] => { if (base === 'frontend/apps/web/src/app/globals.css') return all.has(base) ? [] : refused(from); base = generated.get(base) ?? base; - const target = candidates(base).find((path) => all.has(path)); - if (!target || !sourceExtension.test(target) || declaration.test(target)) return refused(from); - return [target]; + // Conservative union: native Node and TypeScript need not choose the same candidate. + const targets = candidates(base).filter((path) => all.has(path)); + if ( + !targets.length || + targets.some((target) => !sourceExtension.test(target) || declaration.test(target)) + ) + return refused(from); + return targets; }; const mapped = (value: unknown, root: string, from: string): string[] => { if (typeof value === 'string' && value.startsWith('./')) @@ -198,6 +203,7 @@ export function productionDemoSources( if (source) { const config = JSON.parse(source); if (config.compilerOptions?.baseUrl) return refused(path); + const aliases: string[] = []; for (const [pattern, replacements] of Object.entries(config.compilerOptions?.paths ?? {})) { const parts = pattern.split('*'); if (parts.length > 2 || !Array.isArray(replacements) || replacements.length === 0) @@ -209,11 +215,20 @@ export function productionDemoSources( if (!match) continue; const capture = parts.length === 1 ? '' : edge.slice(parts[0]!.length, edge.length - parts[1]!.length); - return replacements.flatMap((value) => { - if (typeof value !== 'string' || !value.startsWith('./') || value.split('*').length > 2) - return refused(path); - return local(posix.normalize(posix.join(directory, value.replace('*', capture))), path); - }); + aliases.push( + ...replacements.flatMap((value) => { + if ( + typeof value !== 'string' || + !value.startsWith('./') || + value.split('*').length > 2 + ) + return refused(path); + return local( + posix.normalize(posix.join(directory, value.replace('*', capture))), + path, + ); + }), + ); } // Inherited alias maps are not silently ignored. The shipped inherited presets have none. const checkExtends = (name: unknown, owner: string, seen = new Set<string>()) => { @@ -238,13 +253,17 @@ export function productionDemoSources( checkExtends(inherited.extends, posix.dirname(target), seen); }; checkExtends(config.extends, directory); + // Include every supported match instead of guessing TypeScript alias precedence. + if (aliases.length) return aliases; break; } directory = posix.dirname(directory); } - const pkg = packages.find( + const matches = packages.filter( (pkg) => edge === pkg.manifest.name || edge.startsWith(pkg.manifest.name + '/'), ); + if (matches.length > 1) return refused(path); + const pkg = matches[0]; if (pkg) { const key = edge === pkg.manifest.name ? '.' : './' + edge.slice(pkg.manifest.name.length + 1); diff --git a/frontend/apps/web/src/test/demo-genericity.test.ts b/frontend/apps/web/src/test/demo-genericity.test.ts index 0cb5e2ba..f19988c4 100644 --- a/frontend/apps/web/src/test/demo-genericity.test.ts +++ b/frontend/apps/web/src/test/demo-genericity.test.ts @@ -312,6 +312,66 @@ describe('P02d-7 exact demo identity boundary', () => { ).toEqual([identity]); } }); + it('includes ambiguous alias and native JS/TS targets and ignores fixture package identities', () => { + const entry = 'frontend/apps/web/src/server/probe.ts'; + const alias = 'frontend/apps/web/src/test/dirty.ts'; + const native = 'frontend/apps/web/src/test/native.js'; + const workspace = 'frontend/packages/sdk/src/runtime.test.ts'; + const files = productionDemoSources( + new Map([ + [ + 'frontend/apps/web/tsconfig.json', + JSON.stringify({ + compilerOptions: { + paths: { + '@/*': ['./src/*'], + '@/special/*': ['./src/test/*'], + }, + }, + }), + ], + [entry, "import '@/special/dirty'; import '@learnstack/sdk/runtime';"], + ['frontend/apps/web/src/special/dirty.ts', 'export const clean = true;'], + [alias, `export const special = ${JSON.stringify(identity)};`], + ['scripts/probe.cjs', "require('../frontend/apps/web/src/test/native');"], + ['frontend/apps/web/src/test/native.ts', 'export const clean = true;'], + [native, `exports.special = ${JSON.stringify(identity)};`], + [ + 'frontend/apps/web/src/test/package-fixture/package.json', + JSON.stringify({ + name: '@learnstack/sdk', + exports: { './runtime': './clean.ts' }, + }), + ], + ['frontend/apps/web/src/test/package-fixture/clean.ts', 'export const clean = true;'], + [ + 'frontend/packages/sdk/package.json', + JSON.stringify({ + name: '@learnstack/sdk', + exports: { './runtime': './src/runtime.test.ts' }, + }), + ], + [workspace, `export const special = ${JSON.stringify(identity)};`], + ]), + ); + for (const path of [alias, native, workspace]) { + expect(files.has(path)).toBe(true); + expect(demoIdentityFindings(path, files.get(path)!, vocabulary)).toEqual([identity]); + } + for (const path of [alias, native, workspace]) files.set(path, 'export const clean = true;'); + expect( + [...files].flatMap(([path, source]) => demoIdentityFindings(path, source, vocabulary)), + ).toEqual([]); + expect(() => + productionDemoSources( + new Map([ + [entry, "import '@learnstack/sdk/runtime';"], + ['frontend/packages/sdk/package.json', '{"name":"@learnstack/sdk"}'], + ['frontend/packages/duplicate/package.json', '{"name":"@learnstack/sdk"}'], + ]), + ), + ).toThrow('Unmodeled'); + }); it('refuses missing, unknown-format, inherited-alias and untrusted external edges', () => { const entry = 'frontend/apps/web/scripts/entry.mjs'; for (const edge of ['./missing.mjs', './helper.runtime', 'unknown-package', '#local']) From c6cd69f1b65955154a1e174b27b36354675031dc Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sun, 11 Oct 2026 11:22:02 +0300 Subject: [PATCH 27/92] test(web): inspect every inherited demo census config candidate Refuse inherited alias maps even when a same-basename source file would previously shadow the actual JSON configuration. ADR: 0018, 0056 --- .../web/src/test/demo-genericity-sources.ts | 22 ++++++++++--------- .../apps/web/src/test/demo-genericity.test.ts | 14 ++++++++++++ 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/frontend/apps/web/src/test/demo-genericity-sources.ts b/frontend/apps/web/src/test/demo-genericity-sources.ts index 6330ab08..741e8dd7 100644 --- a/frontend/apps/web/src/test/demo-genericity-sources.ts +++ b/frontend/apps/web/src/test/demo-genericity-sources.ts @@ -233,10 +233,9 @@ export function productionDemoSources( // Inherited alias maps are not silently ignored. The shipped inherited presets have none. const checkExtends = (name: unknown, owner: string, seen = new Set<string>()) => { if (name === undefined) return; - if (typeof name !== 'string' || seen.has(name)) return refused(path); - seen.add(name); - const target = name.startsWith('.') - ? candidates(posix.join(owner, name)).find((key) => all.has(key)) + if (typeof name !== 'string') return refused(path); + const targets = name.startsWith('.') + ? local(posix.normalize(posix.join(owner, name)), path) : packages.flatMap((pkg) => name.startsWith(pkg.manifest.name + '/') ? mapped( @@ -245,12 +244,15 @@ export function productionDemoSources( path, ) : [], - )[0]; - if (!target) return refused(path); - const inherited = JSON.parse(all.get(target)!); - if (inherited.compilerOptions?.paths || inherited.compilerOptions?.baseUrl) - return refused(path); - checkExtends(inherited.extends, posix.dirname(target), seen); + ); + if (!targets.length) return refused(path); + for (const target of targets) { + if (seen.has(target)) return refused(path); + const inherited = JSON.parse(all.get(target)!); + if (inherited.compilerOptions?.paths || inherited.compilerOptions?.baseUrl) + return refused(path); + checkExtends(inherited.extends, posix.dirname(target), new Set([...seen, target])); + } }; checkExtends(config.extends, directory); // Include every supported match instead of guessing TypeScript alias precedence. diff --git a/frontend/apps/web/src/test/demo-genericity.test.ts b/frontend/apps/web/src/test/demo-genericity.test.ts index f19988c4..407a7d49 100644 --- a/frontend/apps/web/src/test/demo-genericity.test.ts +++ b/frontend/apps/web/src/test/demo-genericity.test.ts @@ -389,6 +389,20 @@ describe('P02d-7 exact demo identity boundary', () => { () => '../foreign/index.js', ), ).toThrow('Unmodeled'); + expect(() => + productionDemoSources( + new Map([ + [entry, "import 'external-package';"], + ['frontend/apps/web/tsconfig.json', '{"extends":"./src/test/base"}'], + ['frontend/apps/web/src/test/base.ts', '{}'], + [ + 'frontend/apps/web/src/test/base.json', + '{"compilerOptions":{"paths":{"hidden/*":["./*"]}}}', + ], + ]), + () => 'frontend/node_modules/external-package/index.js', + ), + ).toThrow('Unmodeled'); const helper = 'frontend/apps/web/src/test/helper.ts'; expect( productionDemoSources( From 9a0d285ec6454ef23c4d8428b2583ddfa7ea30c8 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sun, 11 Oct 2026 11:25:16 +0300 Subject: [PATCH 28/92] test(dev): verify demo reconciliation and align delivery guidance Exercise independent network overlap and per-route timing aggregation in fast dirty/clean controls. Count completed navigations and restrict uploads to named public artifacts. Record verified review dispositions and align current P7 guidance without rewriting historical ADRs. ADR: 0018, 0056 --- .claude/skills/add-frontend-route/SKILL.md | 12 +-- .github/workflows/ci.yml | 6 +- .../architecture/04-technical-architecture.md | 6 +- docs/roadmap/phase-02d-walking-skeleton.md | 69 ++++++++++++++++ docs/standards/12-infrastructure.md | 7 +- docs/standards/17-code-review.md | 3 +- docs/standards/README.md | 2 +- .../web/scripts/public-demo-contract.d.mts | 14 ++++ .../apps/web/scripts/public-demo-contract.mjs | 40 ++++++++++ .../web/scripts/public-demo-gatherer.d.mts | 15 ++++ .../apps/web/scripts/public-demo-worker.mjs | 26 ++---- .../apps/web/scripts/verify-public-demo.mjs | 20 +---- .../apps/web/src/test/public-demo-ci.test.ts | 20 +++++ .../apps/web/src/test/public-demo.test.ts | 79 +++++++++++++++++++ 14 files changed, 269 insertions(+), 50 deletions(-) diff --git a/.claude/skills/add-frontend-route/SKILL.md b/.claude/skills/add-frontend-route/SKILL.md index f695a9c8..e0101fb5 100644 --- a/.claude/skills/add-frontend-route/SKILL.md +++ b/.claude/skills/add-frontend-route/SKILL.md @@ -315,11 +315,13 @@ records passing production replacement proofs, completed reviews and verified fi hosts/locales. If unavailable, evidence stays pending and completion is not claimed. HTTP/RSC assertions are not browser or assistive-technology proof. P02d-7 owns browser/demo and Lighthouse; Phase 11 owns web-vitals telemetry. -- Lighthouse budget check on representative public routes — CI's `lighthouse budget` - job remains deferred until P02d-7/G44/G45 after P6 pages; judge by reading until - that harness is implemented. Its remaining details are in the - [decision register](../../../docs/roadmap/phase-02d-walking-skeleton.md#the-decision-register); - the pass that closes it rewrites this bullet. +- CI's required `public demo + lighthouse` job exercises the seeded public + catalog/course/lesson routes under + [ADR-0056](../../../docs/decisions/0056-public-demo-browser-and-lighthouse-enforcement.md). + Preserve its production-page admission, hard budgets and owned cleanup. The + [P7 Step 4 record](../../../docs/roadmap/phase-02d-walking-skeleton.md#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11) + owns completed G44/G45 enforcement; Phase 06 still owns broader Playwright/axe + coverage. ## Validation diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b1095b58..864ac5f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -480,9 +480,9 @@ jobs: name: public-demo-lighthouse path: | artifacts/public-demo/receipt.json - artifacts/public-demo/*.json - artifacts/public-demo/*.html - artifacts/public-demo/*.png + artifacts/public-demo/audit-*.json + artifacts/public-demo/audit-*.html + artifacts/public-demo/browser-*.png artifacts/public-demo-ci/workflow.json if-no-files-found: error retention-days: 7 diff --git a/docs/architecture/04-technical-architecture.md b/docs/architecture/04-technical-architecture.md index 702ee59f..ec3183aa 100644 --- a/docs/architecture/04-technical-architecture.md +++ b/docs/architecture/04-technical-architecture.md @@ -232,8 +232,10 @@ Application projects run **outside** containers during active development for fa P02d-5 delivers G33's paired loopback API and native TLS web ingress, with isolated real-API production-rendering fixtures. The [accepted local setup](../roadmap/phase-02d-walking-skeleton.md#local-setup-and-ingress-details) -owns host, certificate and private configuration preparation. P02d-7 owns G45's -`make demo` lifecycle and browser evidence; that gate remains open. +owns host, certificate and private configuration preparation. P02d-7 delivers +G45's `make demo` lifecycle and browser evidence; the +[Step 4 record](../roadmap/phase-02d-walking-skeleton.md#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11) +owns completed reviews, CI enforcement and phase-exit verification. ## Observability diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 187e79d1..43aa7634 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -6389,6 +6389,75 @@ corpus tests pass; the complete PR's 33 changed/new Markdown files resolve 2,201 relative links and 698 fragments without errors. The new remediation head must still pass full seven-job CI through the PR rollup before merge. +##### Systematic review remediation (2026-10-11) + +The later review examined source `aebefee`. Its findings were re-verified against +current code before changes; this is a separate record from the historical +`d47ef17` review above. No new architecture decision or budget change is needed. +Work remains on `development`; PR #28 is not merged by this record. + +1. **Lifecycle and launcher — complete.** `cd6affd` removes the unsupported + Seeder launcher argument, attempts independent process/observer/Compose + cleanup stages even when a preceding stage fails, and retains failure causes + internally without echoing private diagnostics. Private files are removed + only after confirmed cleanup. Socket errors distinguish occupied ports from + other bind failures; declaration files describe the actual lifecycle API. + `/artifacts/` is ignored. All 34 lifecycle tests, lint and typecheck pass. + Two first-round reviewers and a fresh second-round reviewer approved without + findings. +2. **Genericity census — complete.** `db6bcdf`, `90114f0` and `c6cd69f` close + source-root/format admission and supported static import gaps. Local and + inherited-config candidate ambiguities are conservatively included or + refused; workspace manifests have explicit ownership. Backend evaluation + checks `Compile` and `ProjectReference` coverage, exact output roots and + bounded build shapes/properties. First-round frontend/backend reviewers + identified five verified resolution/build-admission gaps, fixed in + `90114f0`. The fresh second round found inherited-config shadowing, fixed in + `c6cd69f`; its same counterexample now refuses and its clean control passes. + All 18 frontend genericity cases, seven backend genericity cases, all 203 + architecture cases, lint, typecheck and targeted format pass. This remains a + bounded exact-literal/static-import fence, not arbitrary program analysis. +3. **Proof helpers and carriers — implementation in progress.** Actual worker + network-inventory reconciliation and verifier advisory aggregation are shared + with fast dirty/clean controls. Navigation counts come from completed + observations; upload globs admit only named reports/screenshots/receipts. + Current architecture, infrastructure, standards and route-authoring guidance + describe delivered G44/G45 enforcement. Both fresh review rounds and final + seven-job CI are pending for this step. + +**Other finding dispositions.** The gzip adapter equality checks wiring; the +independent Script observer checks source-window coverage, not a second byte +measurement. Existing fast tests already cover report admission, source-byte +normalization, completion accounting and gatherer cleanup; full orchestration, +TLS refusal, real visibility and report-file checks remain real lifecycle/CI +proofs. The added helpers supplement those proofs rather than replacing them. +The review's zero-fast-coverage claim was therefore broader than the actual gap. + +The reported PR-body count/gzip mismatch compared different receipts. Downloaded +[run 38119659152](https://github.com/HodeTech/LearnStack/actions/runs/38119659152) +for source `aebefee`, tested merge `0bb897c`, verifies 2,964 backend and 1,514 +frontend passes, gzip 106,358–106,552 bytes. Historical `d47ef17` numbers above +remain correct for that earlier head; neither substitutes for final remediation +CI. The newer proof took 619,309 ms inside the accepted 720,000 ms deadline; +cleanup took 10,595 ms inside 30,000 ms. No deadline increase is justified. + +Ordinal complete-value matching and strict readable JSON are accepted G20 +behavior, so host case-folding and JSON-comment tolerance are not introduced. +Below-fold laid-out content remains valid full-page evidence; adding a viewport +bottom cutoff would reject legitimate long lessons. Bounded child ownership +records are retained through process-group/EOF confirmation; no sustained leak +was demonstrated. The older `public-local` exit behavior, dormant platform +branches and test-project placement are unchanged maintenance suggestions. +Census regeneration instructions already name the authoritative Seeder command. + +Mutable Action tags are pre-existing throughout CI, with no accepted SHA-pinning +policy. This patch does not introduce a repository-wide dependency policy; +[Security Standards](../standards/11-security.md#dependency-hygiene) already assign +update automation to Phase 11. Accepted ADR-0053/0056 baseline and dated notes +remain historical; their then-pending wording is not rewritten as a correction. +Current carriers link delivered P7 evidence instead. No new human/WCAG evidence +is claimed by these orchestration and proof changes. + Closing documentation commits must also pass the seven required jobs before merge; the [PR check rollup](https://github.com/HodeTech/LearnStack/pull/28/checks) owns that moving-head result. Next after maintainer review and merge is diff --git a/docs/standards/12-infrastructure.md b/docs/standards/12-infrastructure.md index e5e1061a..ffd25ae5 100644 --- a/docs/standards/12-infrastructure.md +++ b/docs/standards/12-infrastructure.md @@ -93,8 +93,11 @@ P02d-5 delivers G33's loopback API/native TLS web ingress and paired private loc configuration under [ADR-0053](../decisions/0053-trusted-public-server-rendering.md). Follow the [local setup](../roadmap/phase-02d-walking-skeleton.md#local-setup-and-ingress-details) for explicit hosts and certificate trust preparation; neither is installed by scripts. -P02d-7 owns G45's `make demo` lifecycle and browser/TLS evidence, plus the remaining -G44 Lighthouse harness/assertions after P6. APISIX remains Phase 11. +P02d-7 delivers G45's `make demo` lifecycle, browser/TLS evidence and G44's +Lighthouse harness/assertions under +[ADR-0056](../decisions/0056-public-demo-browser-and-lighthouse-enforcement.md). +The [Step 4 record](../roadmap/phase-02d-walking-skeleton.md#p02d-7-step-4--ci-enforcement-and-phase-exit-2026-10-11) +owns enforcement and phase-exit verification. APISIX remains Phase 11. ### Healthchecks and the readiness gate diff --git a/docs/standards/17-code-review.md b/docs/standards/17-code-review.md index d2c1fd1c..6b23ba6c 100644 --- a/docs/standards/17-code-review.md +++ b/docs/standards/17-code-review.md @@ -71,7 +71,8 @@ Before requesting reviews, run the relevant checklist on your own diff: - [ ] Accessibility: keyboard reachability, labels and contrast reviewed; blocking JSX accessibility checks and the public browser/Lighthouse proof pass where applicable. Preserve the scoped manual evidence described below. -- [ ] Bundle size delta acceptable on public routes (< 200 KB gzipped per route). +- [ ] Public-route JavaScript meets the normalized gzip budget and measurement + scope in [Performance Standards](15-performance.md#frontend-rules). > **Accepted G43 enforcement is delivered.** P02d-6 supplies blocking `jsx-a11y` > rules with planted controls. P02d-7's shared browser/Lighthouse proof enforces diff --git a/docs/standards/README.md b/docs/standards/README.md index a65a96da..211483d4 100644 --- a/docs/standards/README.md +++ b/docs/standards/README.md @@ -81,7 +81,7 @@ loaders, chrome/states and atomic theme injection. Its owns foundation validation and review evidence. Steps 2–3 implement actual catalog/course/lesson views, pagination/metadata and blocking JSX accessibility controls. Step 4 implements automated product HTML/Flight proofs and completes -manual accessibility, including maintainer-reported VoiceOver. P7 owns +manual accessibility, including maintainer-reported VoiceOver. P7 delivers demo/browser/Lighthouse. ADR-0055 remediation delivers single-bootstrap admission and passing production replacement proofs. All three steps completed both independent review rounds. The diff --git a/frontend/apps/web/scripts/public-demo-contract.d.mts b/frontend/apps/web/scripts/public-demo-contract.d.mts index 6fd69a48..eaa16922 100644 --- a/frontend/apps/web/scripts/public-demo-contract.d.mts +++ b/frontend/apps/web/scripts/public-demo-contract.d.mts @@ -22,3 +22,17 @@ export function validateReport( census: unknown, ): Record<string, number>; export function reportInventory(rows: unknown, expected: unknown): void; + +export function reconcileScriptInventory( + log: Array<{ + method: string; + params: { type: string; requestId: string; response: { url: string } }; + }>, + inventory: Array<{ id: string; url: string }>, +): void; +export function reportAdvisories( + reports: Array<{ url: string; metrics: Record<string, number> }>, +): Array<{ + url: string; + metrics: Record<string, { median: number; threshold: number; warning: boolean }>; +}>; diff --git a/frontend/apps/web/scripts/public-demo-contract.mjs b/frontend/apps/web/scripts/public-demo-contract.mjs index 073dc621..bce0ca63 100644 --- a/frontend/apps/web/scripts/public-demo-contract.mjs +++ b/frontend/apps/web/scripts/public-demo-contract.mjs @@ -282,3 +282,43 @@ export function reportInventory(rows, expected) { assert.equal(row.browserVersion, expected[0].browserVersion, 'Exact audited browser'); } } + +/** Reconcile independent Lighthouse events with the complete source gatherer's window. */ +export function reconcileScriptInventory(log, inventory) { + const logged = log.filter( + (row) => row.method === 'Network.responseReceived' && row.params.type === 'Script', + ); + assert.ok(logged.length > 0, 'Positive independent Script network observer'); + for (const row of logged) + assert.ok( + inventory.some( + (item) => item.id === row.params.requestId && item.url === row.params.response.url, + ), + 'Script inventory overlap', + ); +} + +/** Timing metrics remain warnings; each URL owns exactly three finite samples. */ +export function reportAdvisories(reports) { + return [...new Set(reports.map((row) => row.url))].map((url) => ({ + url, + metrics: Object.fromEntries( + Object.entries({ + 'largest-contentful-paint': 1500, + 'server-response-time': 300, + 'total-blocking-time': 200, + }).map(([key, threshold]) => { + const samples = reports.filter((row) => row.url === url).map((row) => row.metrics[key]); + assert.equal(samples.length, 3, 'Exactly three advisory samples per URL'); + assert.ok( + samples.every( + (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0, + ), + 'Finite advisory samples', + ); + const median = samples.sort((a, b) => a - b)[1]; + return [key, { median, threshold, warning: median >= threshold }]; + }), + ), + })); +} diff --git a/frontend/apps/web/scripts/public-demo-gatherer.d.mts b/frontend/apps/web/scripts/public-demo-gatherer.d.mts index d390de65..dc1beb2b 100644 --- a/frontend/apps/web/scripts/public-demo-gatherer.d.mts +++ b/frontend/apps/web/scripts/public-demo-gatherer.d.mts @@ -5,3 +5,18 @@ export class PublicDemoDocument { export const demoLighthouseConfiguration: unknown; export function configurationCensus(config: unknown): unknown; export function resolvedDemoCensus(flags: unknown): Promise<unknown>; + +export class InitialJavaScriptGzip { + static readonly meta: { + id: string; + title: string; + description: string; + scoreDisplayMode: string; + requiredArtifacts: string[]; + }; + static audit(artifacts: { PublicDemoDocument: { gzip: { total: number } } }): { + score: number; + numericValue: number; + numericUnit: string; + }; +} diff --git a/frontend/apps/web/scripts/public-demo-worker.mjs b/frontend/apps/web/scripts/public-demo-worker.mjs index ae65056b..85bd8d23 100644 --- a/frontend/apps/web/scripts/public-demo-worker.mjs +++ b/frontend/apps/web/scripts/public-demo-worker.mjs @@ -11,6 +11,7 @@ import { assertConfiguration, digest, productDomSnapshot, + reconcileScriptInventory, validateReport, } from './public-demo-contract.mjs'; import { resolvedDemoCensus, demoLighthouseConfiguration } from './public-demo-gatherer.mjs'; @@ -50,6 +51,7 @@ async function observe(page, response, lane, kind) { async function browserPaths(browser, input) { let visibilityControls = 0; + let navigations = 0; for (const [laneIndex, lane] of input.lanes.entries()) { const context = await browser.newContext({ viewport: { width: 412, height: 823 }, @@ -70,6 +72,7 @@ async function browserPaths(browser, input) { ]); } await observe(page, response, lane, kind); + navigations++; await page.screenshot({ path: join(input.output, `browser-${laneIndex}-${kind}.png`), fullPage: true, @@ -106,7 +109,7 @@ async function browserPaths(browser, input) { await context.close(); } } - return visibilityControls; + return { navigations, visibilityControls }; } /** The pinned Lighthouse load deadline is a warning, which admission must refuse. */ @@ -192,11 +195,8 @@ async function main(input) { return; } if (input.mode === 'browser') { - const visibilityControls = await browserPaths(browser, input); - writeFileSync( - input.result, - JSON.stringify({ browserVersion, navigations: 9, visibilityControls }), - ); + const counts = await browserPaths(browser, input); + writeFileSync(input.result, JSON.stringify({ browserVersion, ...counts })); return; } if (input.mode !== 'timeout-control') { @@ -243,18 +243,8 @@ async function main(input) { const metrics = validateReport(result.lhr, input, expectedCensus); assert.equal(metrics['initial-js-gzip-bytes'], result.artifacts.PublicDemoDocument.gzip.total); const source = result.artifacts.PublicDemoDocument; - // Independently reconcile the overlap with Lighthouse's earlier network window. - const logged = result.artifacts.DevtoolsLog.filter( - (row) => row.method === 'Network.responseReceived' && row.params.type === 'Script', - ); - assert.ok(logged.length > 0, 'Positive independent Script network observer'); - for (const row of logged) - assert.ok( - source.inventory.some( - (item) => item.id === row.params.requestId && item.url === row.params.response.url, - ), - 'Script inventory overlap', - ); + // This is independent network-window coverage, not a second gzip measurement. + reconcileScriptInventory(result.artifacts.DevtoolsLog, source.inventory); const base = join(input.output, `audit-${input.laneIndex}-${input.kind}-${input.run}`); const report = JSON.stringify(result.lhr); writeFileSync(base + '.json', report, { flag: 'wx' }); diff --git a/frontend/apps/web/scripts/verify-public-demo.mjs b/frontend/apps/web/scripts/verify-public-demo.mjs index 8064524f..813682cc 100644 --- a/frontend/apps/web/scripts/verify-public-demo.mjs +++ b/frontend/apps/web/scripts/verify-public-demo.mjs @@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url'; import { chromium } from 'playwright'; -import { digest, reportInventory } from './public-demo-contract.mjs'; +import { digest, reportAdvisories, reportInventory } from './public-demo-contract.mjs'; import { stopTestChild } from './stop-test-child.mjs'; import { demoRequest } from '../../../../scripts/demo-http.mjs'; import { completionDelta, finalCompletionCensus } from '../../../../scripts/demo-observation.mjs'; @@ -246,23 +246,7 @@ export async function verifyPublicDemo(runtime) { 'Complete HTML report', ); } - receipt.advisories = [...new Set(receipt.reports.map((row) => row.url))].map((url) => ({ - url, - metrics: Object.fromEntries( - Object.entries({ - 'largest-contentful-paint': 1500, - 'server-response-time': 300, - 'total-blocking-time': 200, - }).map(([key, threshold]) => { - const samples = receipt.reports - .filter((row) => row.url === url) - .map((row) => row.metrics[key]) - .sort((a, b) => a - b); - const median = samples[1]; - return [key, { median, threshold, warning: median >= threshold }]; - }), - ), - })); + receipt.advisories = reportAdvisories(receipt.reports); remaining(1); receipt.proofMilliseconds = Date.now() - started; receipt.passed = true; diff --git a/frontend/apps/web/src/test/public-demo-ci.test.ts b/frontend/apps/web/src/test/public-demo-ci.test.ts index 0229ea1f..472060be 100644 --- a/frontend/apps/web/src/test/public-demo-ci.test.ts +++ b/frontend/apps/web/src/test/public-demo-ci.test.ts @@ -89,3 +89,23 @@ it.each(['1000', '999'])( expect(() => readFileSync(files.invocation)).toThrow(); }, ); + +it('uploads only named public reports, screenshots and sanitized receipts', () => { + const paths = (source: string) => { + const block = source.match(/ {10}path: \|\n((?: {12}.+\n)+)/)?.[1]; + if (!block) throw new Error('Missing actual workflow artifact allowlist'); + return block + .trim() + .split('\n') + .map((path) => path.trim()); + }; + const expected = [ + 'artifacts/public-demo/receipt.json', + 'artifacts/public-demo/audit-*.json', + 'artifacts/public-demo/audit-*.html', + 'artifacts/public-demo/browser-*.png', + 'artifacts/public-demo-ci/workflow.json', + ]; + expect(paths(job!)).toEqual(expected); + expect(paths(job!.replace('audit-*.json', '*.json'))).not.toEqual(expected); +}); diff --git a/frontend/apps/web/src/test/public-demo.test.ts b/frontend/apps/web/src/test/public-demo.test.ts index 17e971d5..13ab87ab 100644 --- a/frontend/apps/web/src/test/public-demo.test.ts +++ b/frontend/apps/web/src/test/public-demo.test.ts @@ -15,6 +15,8 @@ import { assertConfiguration, decodedSource, normalizedJavaScript, + reconcileScriptInventory, + reportAdvisories, reportInventory, validateReport, } from '../../scripts/public-demo-contract.mjs'; @@ -506,3 +508,80 @@ describe('actual initial source gatherer completeness controls', () => { }, ); }); + +describe('public proof independent reconciliation helpers', () => { + const script = { + method: 'Network.responseReceived', + params: { + type: 'Script', + requestId: 'request-one', + response: { url: 'https://public.example/chunk.js' }, + }, + }; + const matching = { id: script.params.requestId, url: script.params.response.url }; + it.each(['empty', 'missing-id', 'wrong-url', 'non-script'])( + 'refuses %s network overlap with a clean control', + (kind) => { + const log = + kind === 'empty' + ? [] + : kind === 'non-script' + ? [{ ...script, params: { ...script.params, type: 'Document' } }] + : [script]; + const rows = + kind === 'missing-id' + ? [{ ...matching, id: 'other' }] + : kind === 'wrong-url' + ? [{ ...matching, url: 'https://public.example/other.js' }] + : [matching]; + expect(() => reconcileScriptInventory(log, rows)).toThrow(); + expect(() => reconcileScriptInventory([script], [matching])).not.toThrow(); + }, + ); + const timing = (url: string, value: number) => ({ + url, + metrics: { + 'largest-contentful-paint': value * 1500, + 'server-response-time': value * 300, + 'total-blocking-time': value * 200, + }, + }); + it('sorts each URL independently and warns at the exact thresholds', () => { + const rows = [ + timing('one', 2), + timing('two', 0.8), + timing('one', 0.5), + timing('two', 0.2), + timing('one', 1), + timing('two', 0.4), + ]; + const result = reportAdvisories(rows); + expect(result).toEqual([ + { + url: 'one', + metrics: { + 'largest-contentful-paint': { median: 1500, threshold: 1500, warning: true }, + 'server-response-time': { median: 300, threshold: 300, warning: true }, + 'total-blocking-time': { median: 200, threshold: 200, warning: true }, + }, + }, + { + url: 'two', + metrics: { + 'largest-contentful-paint': { median: 600, threshold: 1500, warning: false }, + 'server-response-time': { median: 120, threshold: 300, warning: false }, + 'total-blocking-time': { median: 80, threshold: 200, warning: false }, + }, + }, + ]); + expect(rows[0]?.metrics['largest-contentful-paint']).toBe(3000); + }); + it.each(['missing', 'extra', 'nonfinite'])('refuses %s advisory samples', (kind) => { + const rows = [timing('one', 0.2), timing('one', 0.4), timing('one', 0.8)]; + expect(() => reportAdvisories(rows)).not.toThrow(); + if (kind === 'missing') rows.pop(); + if (kind === 'extra') rows.push(timing('one', 1)); + if (kind === 'nonfinite') rows[0]!.metrics['server-response-time'] = Number.NaN; + expect(() => reportAdvisories(rows)).toThrow(); + }); +}); From e3a32d2cfecd62e43a3f81a8674f8355775245e7 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sun, 11 Oct 2026 11:29:50 +0300 Subject: [PATCH 29/92] docs(roadmap): close systematic P7 remediation review rounds Record all three steps, verified fixes, both independent review rounds and completed local validation. Require final seven-job PR CI without attributing historical evidence to the new source head. ADR: 0018, 0056 --- docs/roadmap/phase-02d-walking-skeleton.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 43aa7634..abec701b 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -6417,13 +6417,22 @@ Work remains on `development`; PR #28 is not merged by this record. All 18 frontend genericity cases, seven backend genericity cases, all 203 architecture cases, lint, typecheck and targeted format pass. This remains a bounded exact-literal/static-import fence, not arbitrary program analysis. -3. **Proof helpers and carriers — implementation in progress.** Actual worker +3. **Proof helpers and carriers — complete.** `9a0d285` shares actual worker network-inventory reconciliation and verifier advisory aggregation are shared with fast dirty/clean controls. Navigation counts come from completed observations; upload globs admit only named reports/screenshots/receipts. Current architecture, infrastructure, standards and route-authoring guidance - describe delivered G44/G45 enforcement. Both fresh review rounds and final - seven-job CI are pending for this step. + describe delivered G44/G45 enforcement. Two first-round reviewers and a fresh + second-round reviewer approved without findings. The second round also + reproduced all historical advisory medians from the downloaded receipt. + All 128 focused tests, 1,537 frontend cases (1,477 web + 60 SDK) and 2,092 + Docker-free backend cases (1,705 unit + 203 architecture + four contract + + 180 integration) pass with zero failures/skips/todos. Release build, format, + lint, typecheck and production frontend builds pass; 15 sandbox-policy and + seven ADR-status controls pass. The complete PR's 36 Markdown files resolve + 2,289 relative links and 719 fragments; added prose respects 88 columns. + The final PR rollup must still pass all seven required jobs, including the + real Docker and 27-audit lifecycle proof, before merge. **Other finding dispositions.** The gzip adapter equality checks wiring; the independent Script observer checks source-window coverage, not a second byte From 4541d631f6e1ae96247307b93b9ca661f0991620 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sun, 11 Oct 2026 11:30:17 +0300 Subject: [PATCH 30/92] docs(roadmap): clarify proof helper closeout wording --- docs/roadmap/phase-02d-walking-skeleton.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index abec701b..a0355b10 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -6417,9 +6417,9 @@ Work remains on `development`; PR #28 is not merged by this record. All 18 frontend genericity cases, seven backend genericity cases, all 203 architecture cases, lint, typecheck and targeted format pass. This remains a bounded exact-literal/static-import fence, not arbitrary program analysis. -3. **Proof helpers and carriers — complete.** `9a0d285` shares actual worker - network-inventory reconciliation and verifier advisory aggregation are shared - with fast dirty/clean controls. Navigation counts come from completed +3. **Proof helpers and carriers — complete.** `9a0d285` extracts actual worker + network-inventory reconciliation and verifier advisory aggregation for fast + dirty/clean controls. Navigation counts come from completed observations; upload globs admit only named reports/screenshots/receipts. Current architecture, infrastructure, standards and route-authoring guidance describe delivered G44/G45 enforcement. Two first-round reviewers and a fresh From d16d2d72287f4703c2900c11040d0340debbb4d4 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sun, 11 Oct 2026 11:50:33 +0300 Subject: [PATCH 31/92] test(architecture): admit ignored local machine artifacts Keep documented environment and OS/editor/log files out of raw census classification only when Git ignores them. Preserve source/unknown format admission and evaluated compiler coverage. ADR: 0018, 0056 --- .../DomainGenericityTests.DemoIdentities.cs | 8 ++++ .../DomainGenericityTests.SourceCensus.cs | 41 +++++++++++++++++-- docs/roadmap/phase-02d-walking-skeleton.md | 8 ++++ .../21-architecture-tests-catalogue.md | 4 +- .../web/src/test/demo-genericity-sources.ts | 25 +++++++++++ .../apps/web/src/test/demo-genericity.test.ts | 31 +++++++++++++- 6 files changed, 112 insertions(+), 5 deletions(-) diff --git a/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.DemoIdentities.cs b/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.DemoIdentities.cs index e96b9763..ecd1b440 100644 --- a/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.DemoIdentities.cs +++ b/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.DemoIdentities.cs @@ -119,6 +119,14 @@ public void Demo_Literal_Fence_Detects_Decoded_Identities_And_Admits_Platform_Da .Should().Be(1, "only actual project outputs are excluded"); BackendDemoSourceKind(Path.Combine(project, directory, "Generated.cs"), generated).Should().Be(0); } + foreach (var name in new[] { ".env.local", ".DS_Store", "Thumbs.db", "editor.swp", "runtime.log" }) + { + var local = Path.Combine(RepositoryPaths.BackendSrc(), "LearnStack.Api", name); + IsDemoLocalArtifact(local).Should().BeTrue(); + OmitIgnoredDemoLocalFile(local).Should().BeTrue("the actual Git rules omit known machine files"); + } + foreach (var name in new[] { ".env.hidden.cs", ".env.hidden.json", "hidden.resx" }) + IsDemoLocalArtifact(Path.Combine(project, name)).Should().BeFalse("ignored source/unknown formats are not machine-file exemptions"); Action unknownFormat = () => _ = BackendDemoSourceKind(Path.Combine(project, "Probe.resx"), generated); unknownFormat.Should().Throw<InvalidOperationException>(); Action linkedInput = () => RequireCompileCoverage([Path.Combine(project, "Probe.cs")], ["/repo/shared/Linked.cs"]); diff --git a/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.SourceCensus.cs b/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.SourceCensus.cs index b4c30c32..181d18c7 100644 --- a/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.SourceCensus.cs +++ b/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.SourceCensus.cs @@ -15,12 +15,13 @@ private static string[] DemoProductionSourceCensus() var evaluated = projects.Select(EvaluatedDemoCompile).ToArray(); RequireProductionProjectCoverage(projects, evaluated.SelectMany(row => row.References)); var outputs = evaluated.SelectMany(row => row.Outputs).Distinct(StringComparer.Ordinal).ToArray(); - var files = ProductionSourceRoots.SelectMany(root => Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)) - .Where(path => BackendDemoSourceKind(path, outputs) == 1).ToArray(); + var census = ProductionSourceRoots.SelectMany(root => Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)) + .Where(path => !OmitIgnoredDemoLocalFile(path)).ToArray(); + var files = census.Where(path => BackendDemoSourceKind(path, outputs) == 1).ToArray(); // Evaluation includes explicit/linked Compile items as well as the SDK's default glob. // A compiler input outside the supported census must refuse, never disappear. RequireCompileCoverage(files, evaluated.SelectMany(row => row.Sources)); - foreach (var file in ProductionSourceRoots.SelectMany(root => Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)) + foreach (var file in census .Where(path => BackendDemoSourceKind(path, outputs) == 2) .Concat([Path.Combine(RepositoryPaths.RepoRoot(), "backend/Directory.Build.props"), Path.Combine(RepositoryPaths.RepoRoot(), "backend/Directory.Packages.props")]) @@ -29,6 +30,40 @@ private static string[] DemoProductionSourceCensus() return files; } + private static bool IsDemoLocalArtifact(string path) => Path.GetFileName(path) is + ".env" or ".env.local" or ".env.development" or ".env.test" or ".env.production" + or ".env.development.local" or ".env.test.local" or ".env.production.local" or ".DS_Store" or "Thumbs.db" + || Path.GetExtension(path) is ".swp" or ".log" or ".user" or ".suo" or ".rsuser"; + + private static bool OmitIgnoredDemoLocalFile(string path) + { + if (!IsDemoLocalArtifact(path)) return false; + using var child = new Process + { + StartInfo = new ProcessStartInfo("git") + { + ArgumentList = { "check-ignore", "--quiet", "--", path }, + WorkingDirectory = RepositoryPaths.RepoRoot(), + RedirectStandardError = true, + } + }; + child.Start(); + var error = child.StandardError.ReadToEndAsync(); + if (!child.WaitForExit(10_000)) + { + child.Kill(entireProcessTree: true); + child.WaitForExit(); + throw new InvalidOperationException("Git ignored-local-file admission deadline."); + } + error.GetAwaiter().GetResult(); + return child.ExitCode switch + { + 0 => true, + 1 => false, + _ => throw new InvalidOperationException("Git ignored-local-file admission failed.") + }; + } + private static int BackendDemoSourceKind(string path, IEnumerable<string> outputs) { path = Path.GetFullPath(path); diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index a0355b10..e230856d 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -6434,6 +6434,14 @@ Work remains on `development`; PR #28 is not merged by this record. The final PR rollup must still pass all seven required jobs, including the real Docker and 27-audit lifecycle proof, before merge. +**Late automatic-review follow-up.** Run 38125035732 passes all seven jobs for +source `4541d63`, including 2,964 backend and 1,537 frontend cases, the real +27-audit proof and cleanup. A later CodeRabbit comment exposed a local-DX gap: +ignored `.env.local` and OS/editor files reached raw filesystem classification. +The follow-up admits only explicitly known machine filenames that Git also +ignores. Ignored source files, unknown formats and compiler coverage retain +fail-closed behavior. Focused reviews and replacement final-head CI are pending. + **Other finding dispositions.** The gzip adapter equality checks wiring; the independent Script observer checks source-window coverage, not a second byte measurement. Existing fast tests already cover report admission, source-byte diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 3a0f9603..2c03a25b 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -675,7 +675,9 @@ otherwise). text/attribute values are included. Frontend runtime imports cannot hide helpers behind test filenames or directories; exact standalone proof/development entries retain their bounded exemptions. - Generated/dependency roots are exact paths. Local module URL suffixes, + Generated/dependency roots are exact paths. Known environment/OS/editor/log + files are omitted only when Git also ignores them; source and unknown formats + retain fail-closed admission. Local module URL suffixes, supported tsconfig aliases, workspace exports, re-exports, import-equals and literal `require.resolve` edges enter the closure. Ambiguous local JS/TS and alias targets are conservatively included; only owned workspace manifests select diff --git a/frontend/apps/web/src/test/demo-genericity-sources.ts b/frontend/apps/web/src/test/demo-genericity-sources.ts index 741e8dd7..de28b62a 100644 --- a/frontend/apps/web/src/test/demo-genericity-sources.ts +++ b/frontend/apps/web/src/test/demo-genericity-sources.ts @@ -117,6 +117,31 @@ export function demoSourceClassification(path: string): 'runtime' | 'other' { return 'runtime'; } +// A narrow machine-file exception, never a blanket exemption for ignored formats. +export function omitIgnoredDemoLocalFile( + path: string, + ignored: (path: string) => boolean, +): boolean { + const name = posix.basename(path); + const environment = new Set([ + '.env', + '.env.local', + '.env.development', + '.env.test', + '.env.production', + '.env.development.local', + '.env.test.local', + '.env.production.local', + ]); + return ( + (environment.has(name) || + name === '.DS_Store' || + name === 'Thumbs.db' || + /\.(?:swp|log|user|suo|rsuser)$/.test(name)) && + ignored(path) + ); +} + export type ExternalModuleResolver = (from: string, specifier: string, anchors: string[]) => string; function testSource(path: string): boolean { diff --git a/frontend/apps/web/src/test/demo-genericity.test.ts b/frontend/apps/web/src/test/demo-genericity.test.ts index 407a7d49..1d3f9b17 100644 --- a/frontend/apps/web/src/test/demo-genericity.test.ts +++ b/frontend/apps/web/src/test/demo-genericity.test.ts @@ -1,5 +1,5 @@ // @vitest-environment node -import { execFileSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; import { readdirSync, readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { dirname, join, relative, resolve } from 'node:path'; @@ -11,6 +11,7 @@ import { demoIdentityFindings } from './demo-genericity-analysis'; import { demoSourceClassification, omitDemoSourceDirectory, + omitIgnoredDemoLocalFile, productionDemoSources, } from './demo-genericity-sources'; import vocabulary from './fixtures/demo-identities.json'; @@ -37,6 +38,19 @@ function productionSources(): Map<string, string> { for (const entry of readdirSync(directory, { withFileTypes: true })) { const path = join(directory, entry.name); const name = relative(root, path).replaceAll('\\', '/'); + if ( + entry.isFile() && + omitIgnoredDemoLocalFile(name, (candidate) => { + const result = spawnSync('git', ['check-ignore', '--quiet', '--', candidate], { + cwd: root, + stdio: 'ignore', + }); + if (result.status !== 0 && result.status !== 1) + throw new Error('Git ignored-local-file admission failed'); + return result.status === 0; + }) + ) + continue; if (entry.isSymbolicLink() && !omitDemoSourceDirectory(name)) throw new Error(`Unmodeled production symlink: ${name}`); if (entry.isDirectory()) { @@ -261,6 +275,21 @@ describe('P02d-7 exact demo identity boundary', () => { ]) expect(omitDemoSourceDirectory(path)).toBe(false); }); + it('omits only ignored known machine files and retains ignored source and unknown formats', () => { + for (const name of ['.env.local', '.DS_Store', 'Thumbs.db', 'editor.swp', 'runtime.log']) { + const path = `frontend/apps/web/${name}`; + expect(omitIgnoredDemoLocalFile(path, () => true)).toBe(true); + expect(omitIgnoredDemoLocalFile(path, () => false)).toBe(false); + const result = spawnSync('git', ['check-ignore', '--quiet', '--', path], { + cwd: root, + stdio: 'ignore', + }); + expect(result.status).toBe(0); + } + for (const name of ['.env.hidden.ts', '.env.hidden.json', 'hidden.vue', 'hidden.resx']) + expect(omitIgnoredDemoLocalFile(`frontend/apps/web/${name}`, () => true)).toBe(false); + expect(() => demoSourceClassification('frontend/apps/web/hidden.vue')).toThrow('Unclassified'); + }); it('refuses unclassified production roots and formats while retaining declared non-runtime ownership', () => { for (const path of [ 'shared/probe.mjs', From 9343e99d7b11e76dbc36eb829f13c570f678fea2 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sun, 11 Oct 2026 11:58:00 +0300 Subject: [PATCH 32/92] test(architecture): refuse ignored MSBuild user hooks Do not treat .user files as inert machine artifacts: the SDK can import .csproj.user targets after the evaluation-only source census. Keep those formats refused and plant ignored-file negative controls. Record the second independent review counterexample without changing production behavior or expanding supported build shapes. --- .../DomainGenericityTests.DemoIdentities.cs | 7 ++++++- .../DomainGenericityTests.SourceCensus.cs | 2 +- docs/roadmap/phase-02d-walking-skeleton.md | 6 +++++- .../apps/web/src/test/demo-genericity-sources.ts | 2 +- .../apps/web/src/test/demo-genericity.test.ts | 15 ++++++++++++++- 5 files changed, 27 insertions(+), 5 deletions(-) diff --git a/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.DemoIdentities.cs b/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.DemoIdentities.cs index ecd1b440..8d2584fa 100644 --- a/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.DemoIdentities.cs +++ b/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.DemoIdentities.cs @@ -125,8 +125,13 @@ public void Demo_Literal_Fence_Detects_Decoded_Identities_And_Admits_Platform_Da IsDemoLocalArtifact(local).Should().BeTrue(); OmitIgnoredDemoLocalFile(local).Should().BeTrue("the actual Git rules omit known machine files"); } - foreach (var name in new[] { ".env.hidden.cs", ".env.hidden.json", "hidden.resx" }) + foreach (var name in new[] { ".env.hidden.cs", ".env.hidden.json", "hidden.resx", "Probe.csproj.user" }) IsDemoLocalArtifact(Path.Combine(project, name)).Should().BeFalse("ignored source/unknown formats are not machine-file exemptions"); + // MSBuild automatically imports .csproj.user, so ignore rules cannot exempt it. + OmitIgnoredDemoLocalFile(Path.Combine(RepositoryPaths.BackendSrc(), "LearnStack.Api", "Probe.csproj.user")) + .Should().BeFalse(); + Action ignoredBuildHook = () => _ = BackendDemoSourceKind(Path.Combine(project, "Probe.csproj.user"), generated); + ignoredBuildHook.Should().Throw<InvalidOperationException>(); Action unknownFormat = () => _ = BackendDemoSourceKind(Path.Combine(project, "Probe.resx"), generated); unknownFormat.Should().Throw<InvalidOperationException>(); Action linkedInput = () => RequireCompileCoverage([Path.Combine(project, "Probe.cs")], ["/repo/shared/Linked.cs"]); diff --git a/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.SourceCensus.cs b/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.SourceCensus.cs index 181d18c7..8807cc6d 100644 --- a/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.SourceCensus.cs +++ b/backend/tests/LearnStack.Tests.Architecture/DomainGenericityTests.SourceCensus.cs @@ -33,7 +33,7 @@ private static string[] DemoProductionSourceCensus() private static bool IsDemoLocalArtifact(string path) => Path.GetFileName(path) is ".env" or ".env.local" or ".env.development" or ".env.test" or ".env.production" or ".env.development.local" or ".env.test.local" or ".env.production.local" or ".DS_Store" or "Thumbs.db" - || Path.GetExtension(path) is ".swp" or ".log" or ".user" or ".suo" or ".rsuser"; + || Path.GetExtension(path) is ".swp" or ".log" or ".suo" or ".rsuser"; private static bool OmitIgnoredDemoLocalFile(string path) { diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index e230856d..eaef513d 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -6440,7 +6440,11 @@ source `4541d63`, including 2,964 backend and 1,537 frontend cases, the real ignored `.env.local` and OS/editor files reached raw filesystem classification. The follow-up admits only explicitly known machine filenames that Git also ignores. Ignored source files, unknown formats and compiler coverage retain -fail-closed behavior. Focused reviews and replacement final-head CI are pending. +fail-closed behavior. The first independent round approved `d16d2d7`. The fresh +second round reproduced an automatically imported `.csproj.user` build hook; +`.user` is therefore excluded from the machine-file exemption, with explicit +refusal controls. The same reviewer must verify this fix before closeout. +Replacement final-head CI remains required. **Other finding dispositions.** The gzip adapter equality checks wiring; the independent Script observer checks source-window coverage, not a second byte diff --git a/frontend/apps/web/src/test/demo-genericity-sources.ts b/frontend/apps/web/src/test/demo-genericity-sources.ts index de28b62a..07cb5e82 100644 --- a/frontend/apps/web/src/test/demo-genericity-sources.ts +++ b/frontend/apps/web/src/test/demo-genericity-sources.ts @@ -137,7 +137,7 @@ export function omitIgnoredDemoLocalFile( (environment.has(name) || name === '.DS_Store' || name === 'Thumbs.db' || - /\.(?:swp|log|user|suo|rsuser)$/.test(name)) && + /\.(?:swp|log|suo|rsuser)$/.test(name)) && ignored(path) ); } diff --git a/frontend/apps/web/src/test/demo-genericity.test.ts b/frontend/apps/web/src/test/demo-genericity.test.ts index 1d3f9b17..b1ad1c18 100644 --- a/frontend/apps/web/src/test/demo-genericity.test.ts +++ b/frontend/apps/web/src/test/demo-genericity.test.ts @@ -286,8 +286,21 @@ describe('P02d-7 exact demo identity boundary', () => { }); expect(result.status).toBe(0); } - for (const name of ['.env.hidden.ts', '.env.hidden.json', 'hidden.vue', 'hidden.resx']) + for (const name of [ + '.env.hidden.ts', + '.env.hidden.json', + 'hidden.vue', + 'hidden.resx', + 'Probe.csproj.user', + ]) expect(omitIgnoredDemoLocalFile(`frontend/apps/web/${name}`, () => true)).toBe(false); + // MSBuild automatically imports this ignored format; it is not inert machine data. + expect( + spawnSync('git', ['check-ignore', '--quiet', '--', 'frontend/apps/web/Probe.csproj.user'], { + cwd: root, + stdio: 'ignore', + }).status, + ).toBe(0); expect(() => demoSourceClassification('frontend/apps/web/hidden.vue')).toThrow('Unclassified'); }); it('refuses unclassified production roots and formats while retaining declared non-runtime ownership', () => { From ec86884f95eff93081dbcfeffb6e96f6835c5eb6 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sun, 11 Oct 2026 11:59:49 +0300 Subject: [PATCH 33/92] docs(roadmap): close ignored-machine-file review follow-up Record both independent reviews, the verified MSBuild counterexample and passing clean and rejection controls. Keep moving-head CI proof with the required PR rollup and preserve earlier dated receipts. --- docs/roadmap/phase-02d-walking-skeleton.md | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index eaef513d..fcd7a334 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -6442,9 +6442,16 @@ The follow-up admits only explicitly known machine filenames that Git also ignores. Ignored source files, unknown formats and compiler coverage retain fail-closed behavior. The first independent round approved `d16d2d7`. The fresh second round reproduced an automatically imported `.csproj.user` build hook; -`.user` is therefore excluded from the machine-file exemption, with explicit -refusal controls. The same reviewer must verify this fix before closeout. -Replacement final-head CI remains required. +`.user` is therefore excluded from the machine-file exemption in `9343e99`, +with explicit refusal controls. The reviewer independently re-ran the actual +collector: clean ignored files pass and the original build-hook counterexample +refuses. Both review rounds are complete, with no remaining findings. All 19 +frontend and seven backend genericity cases pass; the complete guarded frontend +rerun passes 1,538 cases (1,478 web + 60 SDK), zero failures/skips/todos. +Owned temporary ignored-file controls pass both actual raw collectors and are +removed afterward. Whitespace and the complete PR's link/fragment audit pass. +The final PR rollup below owns replacement final-head CI; all seven jobs remain +required before merge. **Other finding dispositions.** The gzip adapter equality checks wiring; the independent Script observer checks source-window coverage, not a second byte From 32a79515b1b5edf1adaa00b387484a94cf6038d7 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sun, 11 Oct 2026 12:06:58 +0300 Subject: [PATCH 34/92] test(cache): coordinate the noncooperative factory timeout proof Hold the original factory until its own cache timeout is observed, then release it and verify replacement and late-result behavior. This removes the race between two elapsed timers on loaded CI workers without changing production code, budgets or the asserted contract. Distinguish the test harness timeout and always release the factory. --- .../Caching/InMemoryCacheServiceTests.cs | 41 ++++++++++++------- docs/roadmap/phase-02d-walking-skeleton.md | 11 +++++ 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs index 74b7c67d..03737ae8 100644 --- a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Caching/InMemoryCacheServiceTests.cs @@ -1105,26 +1105,37 @@ public async Task A_Factory_That_Ignores_Its_Token_Is_Still_Bounded() var cache = new InMemoryCacheService( new FixedClock(Origin), MeterFactory, factoryTimeout: TimeSpan.FromMilliseconds(80)); var runs = 0; + var release = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously); - var overrunning = cache.GetOrSetAsync<string>(Key(), async _ => + var overrunning = cache.GetOrSetAsync(Key(), _ => { Interlocked.Increment(ref runs); - await Task.Delay(TimeSpan.FromMilliseconds(600), CancellationToken.None); - return "late"; + return release.Task; }); - await ((Func<Task>)(() => overrunning)).Should().ThrowAsync<TimeoutException>(); - - // The replacement waits for the overrunning factory rather than starting - // a second one beside it, and is not answered with the first caller's - // timeout for ever. - var replacement = await cache.GetOrSetAsync(Key(), _ => Task.FromResult("fresh")) - .WaitAsync(TestTimeout); - - replacement.Should().Be("fresh"); - runs.Should().Be(1, "no second factory ran alongside the first"); - (await cache.GetAsync<string>(Key())).Should().Be("fresh", - "a result that arrived after its deadline is not cached"); + try + { + // Only the cache's deadline can finish first; two elapsed timers can + // run in reverse order on a busy worker. The outer bound must never + // masquerade as the cache's own timeout if its deadline regresses. + var refusal = await ((Func<Task>)(() => overrunning.WaitAsync(TestTimeout))) + .Should().ThrowAsync<TimeoutException>(); + refusal.WithMessage("The cache factory for '*' did not complete within *s."); + + // A replacement waits for the original factory's real termination. + var replacement = cache.GetOrSetAsync(Key(), _ => Task.FromResult("fresh")); + replacement.IsCompleted.Should().BeFalse("the original factory has not been released"); + release.SetResult("late"); + + (await replacement.WaitAsync(TestTimeout)).Should().Be("fresh"); + runs.Should().Be(1, "no second factory ran alongside the first"); + (await cache.GetAsync<string>(Key())).Should().Be("fresh", + "a result that arrived after its deadline is not cached"); + } + finally + { + release.TrySetResult("late"); + } } [Theory] diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index fcd7a334..09795a8a 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -6453,6 +6453,17 @@ removed afterward. Whitespace and the complete PR's link/fragment audit pass. The final PR rollup below owns replacement final-head CI; all seven jobs remain required before merge. +**CI stability follow-up.** Run 38126790757 reports one unit-test failure in +`A_Factory_That_Ignores_Its_Token_Is_Still_Bounded`; all 203 architecture cases, +including the new admission controls, pass. This pre-existing test races an +80 ms cache deadline against a 600 ms factory delay. It now holds the factory +behind an explicit release signal until the cache's own timeout is observed, +checks that a replacement waits and retains the late-result/cache assertions. +The bounded test wait cannot masquerade as the cache timeout: its message must +identify the cache's own deadline. The release is guaranteed in `finally`. +Production cache behavior and its budget are unchanged. Focused verification +and both independent review rounds remain required before replacement CI. + **Other finding dispositions.** The gzip adapter equality checks wiring; the independent Script observer checks source-window coverage, not a second byte measurement. Existing fast tests already cover report admission, source-byte From 8d9d2195cfadf1d83807613ea22d792b7ce329f4 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sun, 11 Oct 2026 12:09:50 +0300 Subject: [PATCH 35/92] docs(roadmap): record cache timeout proof reviews Close both independent review rounds and local unit verification for the CI-exposed timing race. Preserve the failed-job disclosure and require replacement final-head CI before merge. --- docs/roadmap/phase-02d-walking-skeleton.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index 09795a8a..76887cdd 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -6461,8 +6461,12 @@ behind an explicit release signal until the cache's own timeout is observed, checks that a replacement waits and retains the late-result/cache assertions. The bounded test wait cannot masquerade as the cache timeout: its message must identify the cache's own deadline. The release is guaranteed in `finally`. -Production cache behavior and its budget are unchanged. Focused verification -and both independent review rounds remain required before replacement CI. +Production cache behavior and its budget are unchanged. `32a7951` delivers +this test-only fix. Fresh GPT-6.1-sol (high) and GPT-6-astra (high) reviews approve +in two separate rounds with no findings; the second reviewer independently +passes the focused Release case. All 51 cache cases and all 1,705 unit cases +pass with zero skips. Replacement final-head CI remains required by the rollup +below; the initial failed job is not counted as passing evidence. **Other finding dispositions.** The gzip adapter equality checks wiring; the independent Script observer checks source-window coverage, not a second byte From 6baeffb51539426af56ad9e72d54f884b12f7fbb Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 08:47:26 +0300 Subject: [PATCH 36/92] docs(roadmap): complete P02b-0 entry verification Separate delivered transport and public-site behavior from the pending durable-event and identity contracts before producer decisions begin. Record the verified code, Hub and CI baseline so later packets inherit explicit constraints instead of stale implementation claims. --- CLAUDE.md | 8 +- README.md | 6 +- docs/architecture/13-identity-and-auth.md | 8 + docs/architecture/15-event-and-outbox.md | 29 +-- docs/decisions/README.md | 4 +- docs/roadmap/README.md | 4 +- docs/roadmap/phase-02b-events-auth.md | 213 +++++++++++++++++----- 7 files changed, 210 insertions(+), 62 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 20fcbbab..f1e21d68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -162,7 +162,9 @@ 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) 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 phase-entry re-verification is next; +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 pass is next, before runtime changes. Phase 06 retains the full renderer/Studio expansion. **Phase 01** shipped the .NET 10 solution scaffold under `backend/` @@ -362,8 +364,8 @@ 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 next phase**. P02b-0 re-verifies its entry premises before implementation; - each later packet accepts its own gates in its decision pass. + — **the live phase**. P02b-0 entry verification is complete; P02b-1's decision + pass is next. Each later packet accepts its own gates before implementation. - [docs/roadmap/phase-02d-walking-skeleton.md](docs/roadmap/phase-02d-walking-skeleton.md) — complete and merged; the delivered public-site baseline and P02b-0 handoff. `02d` sorts after `02b`/`02c` but runs **before** them; the roadmap dependency diff --git a/README.md b/README.md index 0a8124b3..f5c8b4c3 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,9 @@ items, rules, custom fields and notification templates; their delivery is tracke ## Where it is today **Phase 01, Phase 02a and Phase 02d are complete.** Phase 02d is merged through -PR #28 on 2026-10-11; P02b-0 phase-entry re-verification is next. +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's producer decision pass is next, before implementation. [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, @@ -115,7 +117,7 @@ proof pass real CI; the seventh required context is active. The owns CI enforcement, reviews and the positive phase-exit verdict. [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 re-verification is next. The +owns final verification. P02b-0 is complete; P02b-1's decision pass is next. 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 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..8f7c1c3a 100644 --- a/docs/architecture/15-event-and-outbox.md +++ b/docs/architecture/15-event-and-outbox.md @@ -30,13 +30,15 @@ not land together. | Domain events collected and dispatched in-process, inside the ambient transaction | [Phase 02b](../roadmap/phase-02b-events-auth.md) | `Entity<TId>` has carried the raise-and-clear list since Phase 02a Packet 2; nothing collects it yet | | `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<T>` 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. [ADR-0038](../decisions/0038-cross-cutting-port-and-event-contracts.md) stands as the decision that Dapr is the @@ -520,8 +522,9 @@ 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 | |---|---| @@ -593,7 +596,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 +621,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. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index b1803f49..a7717824 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -140,8 +140,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.<n>` 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/roadmap/README.md b/docs/roadmap/README.md index e62376ec..333f4549 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -93,7 +93,9 @@ not deferred to the showcase phase. [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) - — **next: P02b-0 phase-entry re-verification; implementation not started**. + — **P02b-0 complete; next: P02b-1 producer decision pass**. Its + [entry audit](phase-02b-events-auth.md#p02b-0-entry-verification-2026-10-12) + re-verifies the register against code, corpus and Hub; runtime work has not started. - [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 a7085761..20c7b69b 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -1,10 +1,11 @@ # Phase 02b: Events, Background Jobs, Identity, and Session -**Entry ready — 2026-10-11.** Phase 02d is complete and merged through PR #28; +**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. P02b-0 is next and has not started. It re-verifies the -code, corpus and Hub premises; later packets accept their own gates in their -decision passes. This closeout starts no Phase 02b implementation. +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 pass is next; its dependent gates +remain open. No Phase 02b runtime implementation has started. **Phase 02d handoff — 2026-10-11.** P02b-0 re-verifies the delivered `/api/v1` public contracts, native HTTPS on retained `*.learnstack.local` seed hosts, @@ -175,12 +176,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 @@ -284,6 +287,129 @@ premise a row cites is re-verified at that pass rather than trusted. | 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 | +### 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. + ### Durable outbox dispatch The producer side is **planned, not shipped**: `IOutbox` does not exist and Packet 3's @@ -303,9 +429,10 @@ the table at all: two of the three paths never run `OutboxFlushBehavior`. `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 @@ -625,8 +752,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 @@ -635,13 +763,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: @@ -684,7 +813,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 @@ -709,9 +839,10 @@ 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. + 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. ### Tenant settings eager invalidation @@ -730,15 +861,16 @@ 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 + 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** decides it, and the case that proves it is a MUST-classified direct-write consumer, not only a consumer that sends a command. @@ -811,7 +943,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-<Topic>` rule name, the project trees it runs over, what it inspects, its `AnalyzerReleases` row, its catalogue entry, and the condition that @@ -895,16 +1028,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 @@ -986,7 +1117,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 From fffbd4d6a1193e14f52afbbce82e1e302d3cc701 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 09:13:41 +0300 Subject: [PATCH 37/92] docs(decisions): propose P02b-1 event producer contracts Record the coupled producer choices and bounded nesting replacement before runtime implementation. Keep the gates Proposed for maintainer approval, with a four-step plan and explicit P02b-3 seed handoff. ADR: 0057 --- CLAUDE.md | 6 +- docs/architecture/15-event-and-outbox.md | 7 + ...ctional-event-production-and-completion.md | 277 ++++++++++++++++++ docs/decisions/README.md | 1 + docs/glossary.md | 3 + docs/roadmap/phase-02b-events-auth.md | 140 ++++++++- 6 files changed, 430 insertions(+), 4 deletions(-) create mode 100644 docs/decisions/0057-transactional-event-production-and-completion.md diff --git a/CLAUDE.md b/CLAUDE.md index f1e21d68..35d3ff96 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -164,7 +164,8 @@ both review rounds. [PR #28](https://github.com/HodeTech/LearnStack/pull/28) is 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 pass is next, before runtime changes. +is complete; P02b-1's [producer decision package](docs/roadmap/phase-02b-events-auth.md#p02b-1-proposed-decision-package-2026-10-12) +and ADR-0057 are Proposed, awaiting approval before runtime changes. Phase 06 retains the full renderer/Studio expansion. **Phase 01** shipped the .NET 10 solution scaffold under `backend/` @@ -365,7 +366,8 @@ 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 - pass is next. Each later packet accepts its own gates before implementation. + package is Proposed, awaiting approval. Each later packet accepts its own gates + before implementation. - [docs/roadmap/phase-02d-walking-skeleton.md](docs/roadmap/phase-02d-walking-skeleton.md) — complete and merged; the delivered public-site baseline and P02b-0 handoff. `02d` sorts after `02b`/`02c` but runs **before** them; the roadmap dependency diff --git a/docs/architecture/15-event-and-outbox.md b/docs/architecture/15-event-and-outbox.md index 8f7c1c3a..e3f918dd 100644 --- a/docs/architecture/15-event-and-outbox.md +++ b/docs/architecture/15-event-and-outbox.md @@ -40,6 +40,13 @@ 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-proposed-decision-package-2026-10-12) +and [Proposed ADR-0057](../decisions/0057-transactional-event-production-and-completion.md) +recommend immediate enqueue, owner-only completion and a bounded replacement of +absorbed producer refusals. They accept no gate and change no shipped behavior. +The producer examples below remain target guidance pending that decision's +approval and carrier reconciliation; dispatcher/inbox choices remain P02b-2/3. + [ADR-0038](../decisions/0038-cross-cutting-port-and-event-contracts.md) stands as the decision that Dapr is the cross-process transport LearnStack uses. [ADR-0035](../decisions/0035-demand-gated-infrastructure.md) 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..5af731f4 --- /dev/null +++ b/docs/decisions/0057-transactional-event-production-and-completion.md @@ -0,0 +1,277 @@ +# ADR-0057: Transactional Event Production and Completion + +## Status + +**Proposed — approval pending.** No producer gate is Accepted and no +implementation is authorized by this draft. Approval includes the bounded +replacements below and the +[P02b-1 decision package](../roadmap/phase-02b-events-auth.md#p02b-1-proposed-decision-package-2026-10-12). + +**Date:** 2026-10-12 +**Deciders:** @cemil (approval pending) + +## Decision Drivers + +- Commit an integration event with its source state and MUST audit, including + provisioning through the Seeder's unresolved ambient tenant context. +- Give requests and future deliveries one completion protocol; step 7 alone + cannot flush work added after the request handler returns. +- Prevent a refused nested producer from leaving a publishable fact when its + outer caller absorbs the refusal. Joined frames are not savepoints. +- Establish a useful production subject without inventing cache invalidation + for readers that are already fresh or uncached. + +## Considered Options + +1. **Immediate transactional enqueue and owner-side completion** (recommended). + 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 + +The following contracts apply **if this proposal is accepted**. They authorize +P02b-1 producer implementation, not dispatcher or consumer delivery claims. + +### 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`. Maximum UTF-8 payload size is + 262,144 bytes; refusal happens before INSERT. +- 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 + +The unit exposes a read-only snapshot of the context it successfully announced +to PostgreSQL. It is bound to a transaction generation, cleared at every terminal +boundary and cannot be supplied by a module. Enqueue compares the event's tenant +to that snapshot, refusing empty/sentinel tenants, mismatches and stale scopes. +Provisioning uses its already-sanctioned announcement; it does not populate +`ITenantContextAccessor` or add a context writer or session-variable setter. + +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. + +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. + +P02b-3 implements two independent Customization subscriptions: one initializes +`card@1`, the other `plain@1`, the defaults already declared by +`BuiltInCustomizations`. Each owns one root, generation change, inbox outcome +and MUST audit on its delivery transaction. One may finish before the other; +retry/recovery resolves an incomplete pair. 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 re-verified an existing outbox table and transport seam, but no enqueue +port, collector, domain publisher or useful production consumer. Step 7 cannot +own completion for direct handlers or events dispatched after a command returns. +Provisioning announces a tenant only on the transaction; the Seeder has no +guaranteed Activity. Reading ambient HTTP context alone would reject its event. + +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 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. + +## 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 + +On approval, accept this record before runtime code and register the canonical +proofs below as Registered, not Implemented. Reconcile the P02b-1 gate vehicles +and their split ownership in the phase package. Add dated navigation/disclosure +notes, preserving Accepted bodies, naming these bounded replacements: + +- **ADR-0032:** step 7 becomes an invariant guard; owner completion dispatches + domain events before MUST audit. Its separate `event_id` column requirement + is replaced by `id = EventId`. +- **ADR-0040 § Nesting:** only an absorbed `Result.Fail` with frame/descendant + produced work gains automatic rollback-only. Existing ordinary refusal, + exception, read-only, setter and transaction-ownership rules survive. +- **ADR-0038:** the existing envelope/serializer/transport contracts survive; + the identifier-only producer payload policy is added. +- **ADR-0044 and ADR-0033:** common audit entry/owner duties are clarified for + non-MediatR frames; durability and refusal reconciliation remain unchanged. + +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. Do not add lease/inbox tables, dispatcher policies, job +runtime, auth, Hub endpoints or provider adapters in P02b-1. + +## Architecture Tests + +These are **proposed obligations**, not registered or passing tests. On approval +the catalogue owns their names, status, kind and enforcing tests. Reuse existing +entries for event naming/serialization, partition derivation and row correlation; +add these names only for 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. +- `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. + +## References + +- [ADR-0006: Events and Outbox](0006-events-and-outbox.md) +- [ADR-0010: Cross-Module Communication](0010-cross-module-communication.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-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-0042: Bounded Tenant Provisioning](0042-tenant-provisioning-cross-aggregate-transaction.md) +- [ADR-0044: Audit Write Path](0044-audit-write-path.md) +- [Events and Outbox](../architecture/15-event-and-outbox.md) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index a7717824..93a84064 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -78,6 +78,7 @@ an amendment is not a lifecycle status change. | # | Title | Topic | Target phase / decision point | |---|---|---|---| | 0049 | [Institution Sites and an Optional Course Marketplace](0049-institution-sites-and-course-marketplace.md) | Direction endorsed 2026-10-02; architecture still Proposed, no gate accepted | P02d-2 preparation hold released; remaining contracts before proposed Phase 09a's first consumers | +| 0057 | [Transactional Event Production and Completion](0057-transactional-event-production-and-completion.md) | Immediate enqueue, owner-only domain completion, produced-work refusal protection and the real default-initialization flow; approval pending | P02b-1, before producer runtime code; P02b-3 retains delivery and Seeder handoff | The maintainer endorsed the marketplace direction and accepted ADR-0050/0051 and the P02d-2 package on 2026-10-02. Their rows are now in Active ADRs; ADR-0049 remains diff --git a/docs/glossary.md b/docs/glossary.md index becf8322..6f6e9f05 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -114,6 +114,9 @@ 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** | Proposed per-invocation bookkeeping for event production, including nested requests. A sticky receipt records successful outbox INSERTs or domain-event promotion for the invocation and its ancestors; it is distinct from transaction and audit ownership. [ADR-0057](decisions/0057-transactional-event-production-and-completion.md) is Proposed, not implemented. | +| **Audit frame** | The boundary grouping classified operation intents and captured changes. Nested operations contribute intents; only the outermost frame reconciles and clears them. `AuditLogBehavior` currently supplies the MediatR boundary under [ADR-0044](decisions/0044-audit-write-path.md); [Proposed ADR-0057](decisions/0057-transactional-event-production-and-completion.md) extends common entry coordination to non-MediatR owners. | +| **Transaction announcement snapshot** | Proposed read-only 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. [ADR-0057](decisions/0057-transactional-event-production-and-completion.md) is Proposed, not implemented. | | **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. | diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 20c7b69b..da7a775f 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -4,8 +4,10 @@ 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 pass is next; its dependent gates -remain open. No Phase 02b runtime implementation has started. +corpus and Hub premises. P02b-1's +[decision package](#p02b-1-proposed-decision-package-2026-10-12) and ADR-0057 are +Proposed; its dependent gates remain open pending maintainer approval. No +Phase 02b runtime implementation has started. **Phase 02d handoff — 2026-10-11.** P02b-0 re-verifies the delivered `/api/v1` public contracts, native HTTPS on retained `*.learnstack.local` seed hosts, @@ -410,6 +412,140 @@ 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 proposed decision package (2026-10-12) + +**Proposed — approval pending.** This is the producer packet's decision pass, +not its implementation record. The existing register remains open; its Leaning +cells and the target sketches below are not accepted contracts. +[ADR-0057](../decisions/0057-transactional-event-production-and-completion.md) +is the proposed 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). The following code facts shape the proposal: + +- 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. + +#### Proposed gate answers and approval boundary + +The ADR owns exact semantics and bounds; this table owns gate timing. Approval +accepts this package and ADR-0057 together, followed by a decision-only acceptance +commit and Registered catalogue obligations **before runtime code**. + +| Gate / part | Proposed 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; dated ADR-0032 navigation; 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; dated ADR-0032 navigation 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's explicit ADR-0040 partial supersession, dated ADR-0044/0033/0040 navigation; production delivery/inbox atomicity remains P02b-3 | +| G18 payload | Identifier-only additional fields, bounded canonical payload, no copied personal data or serializer escape | ADR-0057 and dated ADR-0038 navigation; 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 on approval, publisher P02b-1, subscriptions plus Seeder handoff P02b-3 | + +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. + +On acceptance, reconcile the register's Vehicle cells to this new superseding +record and dated navigation. Update current producer guidance in architecture +15, standards 02/05/10/18/20 and the integration-event/review skills. Align this +phase's domain-event sketch to owner-only dispatch and outbox-only reactions, +removing its recursive-event target. Preserve consumer target guidance under +its existing decisions until P02b-2's G1 pass. +Align Tenancy/Customization event catalogues and Audit's consumes-none statement. +Remove unsupported settings-invalidation promises from this phase's scope, +deliverables and completion criteria as well as Tenancy's event catalogue: +uncached settings need no asynchronous invalidation. A later cache introduction +must carry its own measured need and owner; this packet does not manufacture one. + +#### Four implementation steps after approval + +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 | Read-only transaction announcement/generation, 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, 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 proposed; +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 ships a useful production publisher and persists pending rows. Dispatcher +claims and delivery do not run yet. P02b-3 must deliver both named defaults in +separate one-root subscriber transactions, 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. + +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. + +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. + ### Durable outbox dispatch The producer side is **planned, not shipped**: `IOutbox` does not exist and Packet 3's From 907ca355bf3d900aa303556def8570ede98da6f8 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 09:34:44 +0300 Subject: [PATCH 38/92] docs(decisions): clarify P02b-1 producer decision boundaries Separate bounded supersessions from additive seam and audit amendments before approval. Define the transaction snapshot and JSON admission boundaries, and make the G1-dependent subscriber handoff explicit. ADR: 0057 --- ...ctional-event-production-and-completion.md | 183 ++++++++++++++---- docs/roadmap/phase-02b-events-auth.md | 64 ++++-- 2 files changed, 187 insertions(+), 60 deletions(-) diff --git a/docs/decisions/0057-transactional-event-production-and-completion.md b/docs/decisions/0057-transactional-event-production-and-completion.md index 5af731f4..a01862e0 100644 --- a/docs/decisions/0057-transactional-event-production-and-completion.md +++ b/docs/decisions/0057-transactional-event-production-and-completion.md @@ -4,22 +4,35 @@ **Proposed — approval pending.** No producer gate is Accepted and no implementation is authorized by this draft. Approval includes the bounded -replacements below and the +supersessions below and the [P02b-1 decision package](../roadmap/phase-02b-events-auth.md#p02b-1-proposed-decision-package-2026-10-12). +**Bounded supersession on acceptance:** 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 (approval pending) ## Decision Drivers -- Commit an integration event with its source state and MUST audit, including - provisioning through the Seeder's unresolved ambient tenant context. -- Give requests and future deliveries one completion protocol; step 7 alone - cannot flush work added after the request handler returns. +- 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. -- Establish a useful production subject without inventing cache invalidation - for readers that are already fresh or uncached. + 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 @@ -56,8 +69,15 @@ Raw transactions without that frame cannot enqueue. - `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`. Maximum UTF-8 payload size is - 262,144 bytes; refusal happens before INSERT. + `IntegrationEventBase.PayloadJsonOptions`. The proposed + `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 proposal 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 @@ -66,12 +86,32 @@ Raw transactions without that frame cannot enqueue. ### Transaction metadata and payload boundary -The unit exposes a read-only snapshot of the context it successfully announced -to PostgreSQL. It is bound to a transaction generation, cleared at every terminal -boundary and cannot be supplied by a module. Enqueue compares the event's tenant -to that snapshot, refusing empty/sentinel tenants, mismatches and stale scopes. -Provisioning uses its already-sanctioned announcement; it does not populate -`ITenantContextAccessor` or add a context writer or session-variable setter. +`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 @@ -87,6 +127,13 @@ translation allocates a new integration `EventId` and uses the originating domai `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, @@ -163,13 +210,23 @@ Tenancy publishes `TenantCreatedIntegrationEventV1` on 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<N>` 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 owns one root, generation change, inbox outcome -and MUST audit on its delivery transaction. One may finish before the other; -retry/recovery resolves an incomplete pair. ADR-0042's provisioning exception -is unchanged; no cross-module write enters the producer transaction. +`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 @@ -188,11 +245,15 @@ source MUST audit already commits with the producer. ## Context -P02b-0 re-verified an existing outbox table and transport seam, but no enqueue -port, collector, domain publisher or useful production consumer. Step 7 cannot -own completion for direct handlers or events dispatched after a command returns. -Provisioning announces a tenant only on the transaction; the Seeder has no -guaranteed Activity. Reading ambient HTTP context alone would reject its event. +[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 @@ -200,9 +261,29 @@ 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 @@ -219,25 +300,36 @@ useful subscriber before P02b-3 closes; a test-only or no-op consumer is not one On approval, accept this record before runtime code and register the canonical proofs below as Registered, not Implemented. Reconcile the P02b-1 gate vehicles -and their split ownership in the phase package. Add dated navigation/disclosure -notes, preserving Accepted bodies, naming these bounded replacements: - -- **ADR-0032:** step 7 becomes an invariant guard; owner completion dispatches - domain events before MUST audit. Its separate `event_id` column requirement - is replaced by `id = EventId`. -- **ADR-0040 § Nesting:** only an absorbed `Result.Fail` with frame/descendant - produced work gains automatic rollback-only. Existing ordinary refusal, - exception, read-only, setter and transaction-ownership rules survive. -- **ADR-0038:** the existing envelope/serializer/transport contracts survive; - the identifier-only producer payload policy is added. -- **ADR-0044 and ADR-0033:** common audit entry/owner duties are clarified for - non-MediatR frames; durability and refusal reconciliation remain unchanged. +and their split ownership in the phase package. The instruments are distinct: + +| Affected contract | Instrument on acceptance | 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. Do not add lease/inbox tables, dispatcher policies, job -runtime, auth, Hub endpoints or provider adapters in P02b-1. +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 @@ -248,7 +340,10 @@ add these names only for 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. + 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. @@ -268,10 +363,16 @@ add these names only for obligations those entries do not already cover: - [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/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index da7a775f..05ded54c 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -269,11 +269,11 @@ 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) | +| 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 | [Proposed 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 on acceptance; phase-doc + [Events and Outbox § Producer pattern](../architecture/15-event-and-outbox.md#producer-pattern) | 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 | Derivation: [Proposed 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) | | 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 | +| 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: [Proposed ADR-0057](../decisions/0057-transactional-event-production-and-completion.md) bounded supersession of ADR-0032 Sub-decision 12 and its Architecture Tests column list, with a dated supersession disclosure on 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) | +| 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 | [Proposed 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 | | 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 | @@ -285,8 +285,8 @@ premise a row cites is re-verified at that pass rather than trusted. | 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 | +| 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 | [Proposed 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 on 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) | +| 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 | [Proposed ADR-0057](../decisions/0057-transactional-event-production-and-completion.md) and the P02b-1 package; reconcile Tenancy/Customization/Audit specs on 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 | | 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 | ### P02b-0 entry verification (2026-10-12) @@ -465,21 +465,23 @@ commit and Registered catalogue obligations **before runtime code**. | Gate / part | Proposed 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; dated ADR-0032 navigation; architecture producer-pattern reconciliation | +| 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; dated ADR-0032 navigation 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's explicit ADR-0040 partial supersession, dated ADR-0044/0033/0040 navigation; production delivery/inbox atomicity remains P02b-3 | -| G18 payload | Identifier-only additional fields, bounded canonical payload, no copied personal data or serializer escape | ADR-0057 and dated ADR-0038 navigation; 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 on approval, publisher P02b-1, subscriptions plus Seeder handoff P02b-3 | +| 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 / Architecture Tests column list; 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 on approval, 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. -On acceptance, reconcile the register's Vehicle cells to this new superseding -record and dated navigation. Update current producer guidance in architecture -15, standards 02/05/10/18/20 and the integration-event/review skills. Align this +The register's P02b-1 Vehicle cells now point to the Proposed instruments; none +is Accepted by this edit. On acceptance, disclose the exact bounded supersessions +and additive/clarifying amendments classified in ADR-0057. Update current +producer guidance in architecture 15, standards 02/05/10/18/20 and the +integration-event/review skills. Align this phase's domain-event sketch to owner-only dispatch and outbox-only reactions, removing its recursive-event target. Preserve consumer target guidance under its existing decisions until P02b-2's G1 pass. @@ -501,8 +503,8 @@ fixing and commit confirmed fixes; close both rounds before the next step. | Step | Deliverable | Required evidence before its review closeout | |---|---|---| -| 1 — Shared execution lifecycle | Read-only transaction announcement/generation, 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, duplicate/oversized/malformed/stale/read-only refusal, no extra connection or publish, MUST-audit failure atomicity | +| 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 | @@ -520,12 +522,15 @@ remains for maintainer review; a local pass does not imply merge. P02b-1 ships a useful production publisher and persists pending rows. Dispatcher claims and delivery do not run yet. P02b-3 must deliver both named defaults in -separate one-root subscriber transactions, preserve any authored/lifecycle -decision (including deletion), and prove redelivery and sibling failure recovery. +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. +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 @@ -540,6 +545,27 @@ 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. From b1576509ba60aa4d313432dff6e1019ac13555fa Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 09:50:37 +0300 Subject: [PATCH 39/92] docs(decisions): accept P02b-1 producer contracts Record maintainer approval before runtime changes and distinguish the bounded supersessions from additive seams and audit clarifications. Register producer proofs without claiming implemented delivery. ADR: 0057 --- .claude/skills/add-integration-event/SKILL.md | 84 ++++-- .claude/skills/code-review/SKILL.md | 22 +- CLAUDE.md | 8 +- docs/architecture/15-event-and-outbox.md | 109 +++++-- ...tion-handling-logging-and-observability.md | 18 ++ docs/decisions/0033-audit-durability-model.md | 22 ++ ...-cross-cutting-port-and-event-contracts.md | 15 + docs/decisions/0040-ambient-unit-of-work.md | 42 +++ docs/decisions/0044-audit-write-path.md | 22 ++ ...ctional-event-production-and-completion.md | 39 +-- docs/decisions/README.md | 2 +- docs/glossary.md | 6 +- docs/modules/audit/README.md | 7 + docs/modules/customization/README.md | 52 +++- docs/modules/tenancy/README.md | 33 +- docs/roadmap/phase-02b-events-auth.md | 282 +++++++++--------- docs/standards/02-backend-coding.md | 34 ++- docs/standards/05-database.md | 22 +- docs/standards/10-observability.md | 15 +- docs/standards/18-audit-coverage.md | 20 +- docs/standards/20-infrastructure-stack.md | 20 +- .../21-architecture-tests-catalogue.md | 109 ++++++- 22 files changed, 720 insertions(+), 263 deletions(-) diff --git a/.claude/skills/add-integration-event/SKILL.md b/.claude/skills/add-integration-event/SKILL.md index dba775f6..1b7595c0 100644 --- a/.claude/skills/add-integration-event/SKILL.md +++ b/.claude/skills/add-integration-event/SKILL.md @@ -24,6 +24,9 @@ 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 +implementation is pending; P02b-2/P02b-3 retain dispatcher/inbox decisions. ## When to use @@ -33,8 +36,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 +48,11 @@ The binding port and envelope contract is | Input | Required | Description | |-------|----------|-------------| -| Event name | Yes | `<Verb><Aggregate>IntegrationEventV<N>`, PascalCase + version suffix. | +| Event name | Yes | `<Aggregate><PastTenseFact>IntegrationEventV<N>`, 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 @@ -62,18 +67,14 @@ public sealed record EnrollmentCreatedIntegrationEventV1 : IntegrationEventBase 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. + // 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(); } ``` @@ -117,17 +118,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 +175,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 `<Consumer>.Application/IntegrationEvents/<HandlerName>.cs`: ```csharp -public sealed class CreateAuditEntryOnEnrollmentCreated( +public sealed class QueueWelcomeNotificationOnEnrollmentCreated( IInboxGuard inbox, - AuditDbContext db, - ILogger<CreateAuditEntryOnEnrollmentCreated> logger) + NotificationsDbContext db, + ILogger<QueueWelcomeNotificationOnEnrollmentCreated> logger) : IIntegrationEventHandler<EnrollmentCreatedIntegrationEventV1> { public async Task HandleAsync( @@ -174,8 +202,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 +214,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 +252,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 +264,13 @@ remains identical across transports. ### Step 6: Tests -Two tests minimum: +Follow the canonical Registered 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`. @@ -263,7 +299,7 @@ Two tests minimum: - **Forgetting `IInboxGuard`.** The architecture test catches it, but the symptom in production (Kafka redelivery → duplicate work) is silent until then. - **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..d6df9e0c 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: <one-paragraph restatement> — 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. + Five canonical proof obligations stay Registered until implemented; 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 35d3ff96..92fd25b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -164,8 +164,9 @@ both review rounds. [PR #28](https://github.com/HodeTech/LearnStack/pull/28) is 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-proposed-decision-package-2026-10-12) -and ADR-0057 are Proposed, awaiting approval before runtime changes. +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; runtime implementation and their two review rounds remain pending. Phase 06 retains the full renderer/Studio expansion. **Phase 01** shipped the .NET 10 solution scaffold under `backend/` @@ -366,7 +367,8 @@ 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 Proposed, awaiting approval. Each later packet accepts its own gates + package is Accepted — 2026-10-12; producer runtime work remains pending. + Each later packet accepts its own gates before implementation. - [docs/roadmap/phase-02d-walking-skeleton.md](docs/roadmap/phase-02d-walking-skeleton.md) — complete and merged; the delivered public-site baseline and P02b-0 handoff. diff --git a/docs/architecture/15-event-and-outbox.md b/docs/architecture/15-event-and-outbox.md index e3f918dd..2f1b6d63 100644 --- a/docs/architecture/15-event-and-outbox.md +++ b/docs/architecture/15-event-and-outbox.md @@ -40,12 +40,11 @@ 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-proposed-decision-package-2026-10-12) -and [Proposed ADR-0057](../decisions/0057-transactional-event-production-and-completion.md) -recommend immediate enqueue, owner-only completion and a bounded replacement of -absorbed producer refusals. They accept no gate and change no shipped behavior. -The producer examples below remain target guidance pending that decision's -approval and carrier reconciliation; dispatcher/inbox choices remain P02b-2/3. +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. Runtime implementation is pending. Producer guidance below states +the accepted contract; 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 @@ -191,7 +190,8 @@ processed by N consumers independently). ## Producer pattern -In a module's command handler: +**Accepted contract, implementation pending — ADR-0057.** In an enrolled +producer invocation on the announced, writable ambient transaction: ```csharp public async Task<Result<EnrollmentDto>> Handle(CreateEnrollmentCommand cmd, CancellationToken ct) @@ -199,7 +199,7 @@ public async Task<Result<EnrollmentDto>> 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, @@ -210,17 +210,79 @@ public async Task<Result<EnrollmentDto>> 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<EnrollmentDto>.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 event declares its partition source as its aggregate identifier or explicit +tenant-wide `TenantId`, in canonical GUID form; 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 + +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. Failed/canceled saves promote nothing; +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 @@ -666,9 +728,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`**: @@ -690,12 +753,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. @@ -703,8 +767,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 + `Outbox_Row_Carries_Correlation_Context` integration obligation includes + `partition_key` when P02b-1 implements enqueue, so a new enqueue path that + forgets it fails in CI rather than in a consumer. ## Dead-letter: two sides, two failure domains 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 index a01862e0..d85858fa 100644 --- a/docs/decisions/0057-transactional-event-production-and-completion.md +++ b/docs/decisions/0057-transactional-event-production-and-completion.md @@ -2,12 +2,11 @@ ## Status -**Proposed — approval pending.** No producer gate is Accepted and no -implementation is authorized by this draft. Approval includes the bounded -supersessions below and the -[P02b-1 decision package](../roadmap/phase-02b-events-auth.md#p02b-1-proposed-decision-package-2026-10-12). +**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 on acceptance:** ADR-0032 Sub-decision 2's step-7 +**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. @@ -15,7 +14,7 @@ Those records remain Accepted outside these clauses; this is not an ADR-0041 factual correction. **Date:** 2026-10-12 -**Deciders:** @cemil (approval pending) +**Deciders:** @cemil ## Decision Drivers @@ -36,7 +35,7 @@ factual correction. ## Considered Options -1. **Immediate transactional enqueue and owner-side completion** (recommended). +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. @@ -52,8 +51,9 @@ factual correction. ## Decision -The following contracts apply **if this proposal is accepted**. They authorize -P02b-1 producer implementation, not dispatcher or consumer delivery claims. +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 @@ -69,13 +69,13 @@ Raw transactions without that frame cannot enqueue. - `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 proposed + `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 proposal adds no migration or claim + 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 @@ -298,11 +298,11 @@ worker, job runtime, cache invalidation or new Hub crossing. ## Implementation Notes -On approval, accept this record before runtime code and register the canonical -proofs below as Registered, not Implemented. Reconcile the P02b-1 gate vehicles -and their split ownership in the phase package. The instruments are distinct: +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: -| Affected contract | Instrument on acceptance | Boundary | +| 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 | @@ -333,10 +333,11 @@ P02b-1 must not describe those consumers as already implemented. ## Architecture Tests -These are **proposed obligations**, not registered or passing tests. On approval -the catalogue owns their names, status, kind and enforcing tests. Reuse existing -entries for event naming/serialization, partition derivation and row correlation; -add these names only for obligations those entries do not already cover: +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, diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 93a84064..c5081d49 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -72,13 +72,13 @@ an amendment is not a lifecycle status change. | 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)); 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; five proofs Registered, runtime implementation pending. P02b-3 owns default-initialization subscriptions and Seeder handoff | ## Proposed ADRs | # | Title | Topic | Target phase / decision point | |---|---|---|---| | 0049 | [Institution Sites and an Optional Course Marketplace](0049-institution-sites-and-course-marketplace.md) | Direction endorsed 2026-10-02; architecture still Proposed, no gate accepted | P02d-2 preparation hold released; remaining contracts before proposed Phase 09a's first consumers | -| 0057 | [Transactional Event Production and Completion](0057-transactional-event-production-and-completion.md) | Immediate enqueue, owner-only domain completion, produced-work refusal protection and the real default-initialization flow; approval pending | P02b-1, before producer runtime code; P02b-3 retains delivery and Seeder handoff | The maintainer endorsed the marketplace direction and accepted ADR-0050/0051 and the P02d-2 package on 2026-10-02. Their rows are now in Active ADRs; ADR-0049 remains diff --git a/docs/glossary.md b/docs/glossary.md index 6f6e9f05..b4a0a54e 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -114,9 +114,9 @@ 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** | Proposed per-invocation bookkeeping for event production, including nested requests. A sticky receipt records successful outbox INSERTs or domain-event promotion for the invocation and its ancestors; it is distinct from transaction and audit ownership. [ADR-0057](decisions/0057-transactional-event-production-and-completion.md) is Proposed, not implemented. | -| **Audit frame** | The boundary grouping classified operation intents and captured changes. Nested operations contribute intents; only the outermost frame reconciles and clears them. `AuditLogBehavior` currently supplies the MediatR boundary under [ADR-0044](decisions/0044-audit-write-path.md); [Proposed ADR-0057](decisions/0057-transactional-event-production-and-completion.md) extends common entry coordination to non-MediatR owners. | -| **Transaction announcement snapshot** | Proposed read-only 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. [ADR-0057](decisions/0057-transactional-event-production-and-completion.md) is Proposed, not implemented. | +| **Producer frame** | Accepted per-invocation bookkeeping for event production, including nested requests. A sticky receipt records successful outbox INSERTs or domain-event promotion for the invocation and its ancestors; it is distinct from transaction and audit ownership. [ADR-0057](decisions/0057-transactional-event-production-and-completion.md) is Accepted, not implemented. | +| **Audit frame** | The boundary grouping classified operation intents and captured changes. Nested operations contribute intents; only the outermost frame reconciles and clears them. `AuditLogBehavior` currently supplies the MediatR boundary under [ADR-0044](decisions/0044-audit-write-path.md); [Accepted ADR-0057](decisions/0057-transactional-event-production-and-completion.md) extends common entry coordination to non-MediatR owners. | +| **Transaction announcement snapshot** | Accepted 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. [ADR-0057](decisions/0057-transactional-event-production-and-completion.md) is Accepted, not implemented. | | **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. | 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..8053b9fc 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,35 @@ 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. +**No runtime publisher yet.** Accepted +[ADR-0057](../../decisions/0057-transactional-event-production-and-completion.md#first-useful-production-flow) +authorizes P02b-1's completed-provisioning publisher. It 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 +535,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/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 05ded54c..413430d4 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -5,9 +5,10 @@ its [merge closeout](phase-02d-walking-skeleton.md#p02d-7-merge-and-closeout-202 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-proposed-decision-package-2026-10-12) and ADR-0057 are -Proposed; its dependent gates remain open pending maintainer approval. No -Phase 02b runtime implementation has started. +[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, with runtime work pending. **Phase 02d handoff — 2026-10-11.** P02b-0 re-verifies the delivered `/api/v1` public contracts, native HTTPS on retained `*.learnstack.local` seed hosts, @@ -266,28 +267,28 @@ 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 | [Proposed 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 on acceptance; phase-doc + [Events and Outbox § Producer pattern](../architecture/15-event-and-outbox.md#producer-pattern) | 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 | Derivation: [Proposed 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) | -| 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 | Stored identities: [Proposed ADR-0057](../decisions/0057-transactional-event-production-and-completion.md) bounded supersession of ADR-0032 Sub-decision 12 and its Architecture Tests column list, with a dated supersession disclosure on 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) | -| 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 | [Proposed 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 | -| 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 | [Proposed 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 on 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) | -| 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 | [Proposed ADR-0057](../decisions/0057-transactional-event-production-and-completion.md) and the P02b-1 package; reconcile Tenancy/Customization/Audit specs on 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 | -| 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 its Architecture Tests column list, 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) @@ -412,13 +413,15 @@ 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 proposed decision package (2026-10-12) +<a id="p02b-1-proposed-decision-package-2026-10-12"></a> -**Proposed — approval pending.** This is the producer packet's decision pass, -not its implementation record. The existing register remains open; its Leaning -cells and the target sketches below are not accepted contracts. -[ADR-0057](../decisions/0057-transactional-event-production-and-completion.md) -is the proposed authority for the coupled producer choices. It explicitly +### 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; runtime implementation remains pending. +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. @@ -435,7 +438,7 @@ 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). The following code facts shape the proposal: +(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 @@ -457,41 +460,38 @@ Two independent read-only audits examined producer/audit boundaries currently implemented consumer. Current seed verification pins their IDs, so P02b-3 needs an explicit handoff before enabling delivery races. -#### Proposed gate answers and approval boundary +#### Accepted gate answers and implementation boundary -The ADR owns exact semantics and bounds; this table owns gate timing. Approval -accepts this package and ADR-0057 together, followed by a decision-only acceptance -commit and Registered catalogue obligations **before runtime code**. +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 | Proposed answer | Acceptance carrier / remaining owner | +| 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 / Architecture Tests column list; 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 on approval, publisher P02b-1; G1 must settle independent subscription identities before P02b-3 subscriptions and Seeder handoff | +| 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 register's P02b-1 Vehicle cells now point to the Proposed instruments; none -is Accepted by this edit. On acceptance, disclose the exact bounded supersessions -and additive/clarifying amendments classified in ADR-0057. Update current -producer guidance in architecture 15, standards 02/05/10/18/20 and the -integration-event/review skills. Align this -phase's domain-event sketch to owner-only dispatch and outbox-only reactions, -removing its recursive-event target. Preserve consumer target guidance under -its existing decisions until P02b-2's G1 pass. -Align Tenancy/Customization event catalogues and Audit's consumes-none statement. -Remove unsupported settings-invalidation promises from this phase's scope, -deliverables and completion criteria as well as Tenancy's event catalogue: -uncached settings need no asynchronous invalidation. A later cache introduction -must carry its own measured need and owner; this packet does not manufacture one. - -#### Four implementation steps after approval +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**. @@ -511,7 +511,7 @@ fixing and commit confirmed fixes; close both rounds before the next step. 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 proposed; +`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 @@ -520,8 +520,9 @@ remains for maintainer review; a local pass does not imply merge. #### Named handoff to P02b-3 -P02b-1 ships a useful production publisher and persists pending rows. Dispatcher -claims and delivery do not run yet. P02b-3 must deliver both named defaults in +P02b-1 is authorized to supply the production publisher and pending rows. +Runtime implementation is pending; 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. @@ -572,6 +573,15 @@ 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. + ### Durable outbox dispatch The producer side is **planned, not shipped**: `IOutbox` does not exist and Packet 3's @@ -581,10 +591,11 @@ side. 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 @@ -655,47 +666,29 @@ defined row order. The completion criterion follows the gate, and #### Domain events collected and dispatched -`Entity<TId>` 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. +**Accepted, implementation pending — 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 @@ -1006,15 +999,21 @@ settles it; what the phase ships either way: illustrations rather than shipped helpers; P02b-7 replaces them with the real session API when it lands. -### Tenant settings eager invalidation +### 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. -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. +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 @@ -1033,15 +1032,18 @@ points it does not reach, and both of Packet 9's hand-offs land here. through its `DbContext`, which is the shape the corpus teaches as canonical, has its 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** - decides it, and the case that proves it is a MUST-classified direct-write consumer, + [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 @@ -1050,13 +1052,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 @@ -1082,7 +1086,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 @@ -1219,8 +1224,9 @@ command. The phase retains zero skips, per assembly. [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. +- `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 @@ -1242,8 +1248,8 @@ command. The phase retains zero skips, 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 @@ -1335,7 +1341,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, @@ -1423,17 +1431,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. @@ -1502,9 +1511,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/standards/02-backend-coding.md b/docs/standards/02-backend-coding.md index 42ecdd11..2a98dea7 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 collection. Runtime collection/dispatch remains P02b-1 work. +- 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<TId>` @@ -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<T>`. **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..ffd3602d 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 @@ -1391,7 +1393,7 @@ CREATE TABLE outbox_messages ( topic text NOT NULL, -- "learnstack.{module}.{aggregate}" partition_key text NOT NULL, -- ordering domain: aggregate id, else 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,22 @@ 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 implementation is +pending; 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..2599efbd 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,17 @@ 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. +Runtime producer enforcement is pending P02b-1. + 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/18-audit-coverage.md b/docs/standards/18-audit-coverage.md index 20c22923..28e529af 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,22 @@ 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, 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 would erase the outer request's intents. + Only the outermost **audit** frame reconciles and clears capture. 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 diff --git a/docs/standards/20-infrastructure-stack.md b/docs/standards/20-infrastructure-stack.md index 8c988757..3a916235 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: @@ -404,9 +406,21 @@ 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. Runtime producer enforcement remains P02b-1 work. +- 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..d90adc33 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -3112,13 +3112,16 @@ 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. +- **Phase:** 02b, P02b-1. #### `Hangfire_Job_Payloads_Include_TenantId` @@ -3169,18 +3172,94 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). #### `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. +- **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:** **Registered.** -- **Phase:** 02b. +- **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:** **Registered** — implementation pending. +- **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:** **Registered** — implementation pending. +- **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:** **Registered** — implementation pending. +- **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 and interface targets; 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:** **Registered** — implementation pending. +- **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:** **Registered** — implementation pending. +- **Phase:** 02b, P02b-1. #### `OutboxProcessor_NeverBlocks_OnSingleMessageFailure` From cbab31a676241f45b0417e1577b5d21cd5deaf5e Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 10:05:48 +0300 Subject: [PATCH 40/92] feat(kernel): share producer and audit execution lifecycle Keep transaction announcements and producer ancestry scoped to the actual owner so absorbed refusals cannot commit successful production. Reuse one audit lifecycle for requests and deferred direct handlers without letting an independent DI scope inherit another scope's reconciliation. ADR: 0040, 0044, 0057 --- CLAUDE.md | 5 +- .../Audit/AuditExecutionCoordinator.cs | 380 +++++++++++ .../Messaging/ProducerExecution.cs | 118 ++++ .../TransactionExecutionCoordinator.cs | 127 ++++ .../Pipeline/AuditLogBehavior.cs | 424 +----------- .../Pipeline/MediatRPipelineRegistration.cs | 10 + .../Pipeline/TransactionBehavior.cs | 276 +------- .../AuditStateCapture.cs | 12 +- .../Persistence/NpgsqlUnitOfWork.cs | 85 ++- .../Audit/IAuditStateCapture.cs | 36 +- .../Persistence/IUnitOfWork.cs | 40 +- .../Persistence/TenantContextAnnouncement.cs | 13 + .../PersistenceConventionTests.cs | 4 + .../CrossCuttingFoundationHttpTests.cs | 2 + .../Database/ProducerExecutionTests.cs | 602 ++++++++++++++++++ .../Database/PublicReadTransactionTests.cs | 22 +- .../Database/TenantProvisioningTests.cs | 22 +- .../Database/UnitOfWorkTests.cs | 407 +++++++++++- .../Pipeline/AuditLogBehaviorTests.cs | 289 ++++++++- .../Pipeline/TransactionBehaviorTests.cs | 15 +- .../CustomizationCommandTests.cs | 3 + docs/architecture/09-tenant-isolation.md | 16 +- docs/architecture/31-audit-subsystem.md | 581 +++++------------ docs/glossary.md | 12 +- docs/roadmap/phase-02b-events-auth.md | 58 +- docs/standards/11-security.md | 26 +- 26 files changed, 2333 insertions(+), 1252 deletions(-) create mode 100644 backend/src/LearnStack.Application/Audit/AuditExecutionCoordinator.cs create mode 100644 backend/src/LearnStack.Application/Messaging/ProducerExecution.cs create mode 100644 backend/src/LearnStack.Application/Persistence/TransactionExecutionCoordinator.cs create mode 100644 backend/src/LearnStack.SharedKernel/Persistence/TenantContextAnnouncement.cs create mode 100644 backend/tests/LearnStack.Tests.Integration/Database/ProducerExecutionTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 92fd25b8..02c28326 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -166,7 +166,10 @@ 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; runtime implementation and their two review rounds remain pending. +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) +tracks verification and the two pending independent review rounds. Steps 2–4 +remain pending. Phase 06 retains the full renderer/Studio expansion. **Phase 01** shipped the .NET 10 solution scaffold under `backend/` 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; + +/// <summary> +/// 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. +/// </summary> +/// <remarks> +/// <para> +/// Scoped with <see cref="IAuditStateCapture"/>. 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). +/// </para> +/// <para> +/// 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. +/// </para> +/// </remarks> +public sealed class AuditExecutionCoordinator( + IAuditCatalog catalog, + IAuditConfigService classifier, + IAuditStateCapture capture, + IAuditStore store, + ITenantContext tenantContext, + IClock clock, + IGuidFactory guidFactory, + ILogger<AuditExecutionCoordinator> logger) +{ + /// <summary> + /// Classifies the declared operation before invoking its deferred work. A direct + /// owner constructs its handler inside <paramref name="next"/>, after classification + /// and after its transaction coordinator has opened the unit of work. + /// </summary> + public async Task<TResponse> ExecuteAsync<TResponse>( + Type operationType, + IProvisionsTenant? provisioning, + Func<Task<TResponse>> 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<TResponse>(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(); + } + } + } + } + + /// <summary> + /// Writes whatever the business transaction did not. + /// </summary> + /// <remarks> + /// <para> + /// One place for every exit, because the exits are not distinguishable from inside the + /// pipeline: a refusal returns, an exception throws, and a cancelled <c>COMMIT</c> + /// does neither in a way this coordinator's <c>catch</c> can see. What separates them is + /// the <b>state</b> the owning unit-of-work frame recorded, which is a fact about the + /// database, and each intent's own <b>result</b>, which is a fact about its request — + /// <see cref="AuditDraftComposer.Reconciled"/> holds the rule that combines them. + /// </para> + /// <para> + /// <b>A MUST intent is re-written only when it is not already durable.</b> + /// <see cref="AuditIntentState.Committed"/> means the row went with the business + /// write; anything else means it did not, and the attempt is still the record. + /// <see cref="AuditIntentState.Indeterminate"/> prefers a duplicate to a loss — the + /// row goes out anyway, and the <c>23505</c> that may follow is positive evidence the + /// first one landed. + /// </para> + /// <para> + /// <b>A SHOULD or MAY intent is never in-transaction.</b> + /// <c>WritePendingAsync</c> flushes MUST alone, so these are written here, best effort, + /// with the outcome the request actually had. + /// </para> + /// <para> + /// <b>It never throws, and one row's failure never costs another its attempt.</b> This + /// runs from a <c>finally</c>, 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 <c>403</c> with a <c>500</c>, 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. + /// </para> + /// <para> + /// <b>One alert per failure, from whoever saw it.</b> The store reports a failed write + /// itself (IAuditStore), so that path logs a <c>Warning</c> 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 <c>Critical</c> here. + /// </para> + /// </remarks> + 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 + } + } + + /// <summary> + /// Parks one intent per audited <c>(resource, operation)</c>. + /// </summary> + /// <remarks> + /// Plural, and that is the correction ADR-0033 Amendment 2 § 1 makes: + /// <c>ProvisionTenantCommand</c> writes two aggregate roots on one transaction and the + /// Tenancy matrix classifies both MUST, so a singular reading would simply never write + /// the <c>Organization</c> row it promises. + /// </remarks> + 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)); + } + } + + /// <summary> + /// Denied for a refusal the authorization layer would answer 403 to, Failed otherwise. + /// </summary> + /// <remarks> + /// Keyed on the three codes <c>HttpStatusMap</c> answers <c>403</c> to, so the row's + /// outcome and the response's status cannot disagree. A validation failure is + /// <c>Failed</c>: it is a malformed request rather than a refused one, and counting it + /// as a denial would bury the probes the <c>denied</c> class exists to surface. + /// </remarks> + 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<ILogger, string, Exception?> LogUnclassified = + LoggerMessage.Define<string>( + 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<ILogger, string, Exception?> LogReconcileRowLost = + LoggerMessage.Define<string>( + 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<ILogger, string, Exception?> LogReconcileRowRefused = + LoggerMessage.Define<string>( + 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/ProducerExecution.cs b/backend/src/LearnStack.Application/Messaging/ProducerExecution.cs new file mode 100644 index 00000000..022aba79 --- /dev/null +++ b/backend/src/LearnStack.Application/Messaging/ProducerExecution.cs @@ -0,0 +1,118 @@ +using System.Diagnostics; +using LearnStack.SharedKernel.Persistence; + +namespace LearnStack.Application.Messaging; + +/// <summary> +/// Scoped producer ancestry and sticky successful-production receipts (ADR-0057). +/// One logical execution uses a scope; nested invocations run sequentially. +/// </summary> +public sealed class ProducerExecution(IUnitOfWork unitOfWork) +{ + private Invocation? _current; + + /// <summary>Whether an invocation is open, independently of tenant admission.</summary> + public bool HasInvocation => _current is not null; + + /// <summary> + /// Enrolls after announcement. An unresolved, event-free invocation is legal; + /// actual production must pass <see cref="RequireAnnouncement"/>. + /// </summary> + public Invocation BeginInvocation(bool isTransactionOwner) + { + 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?.HasOwner == true; + 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, ownedActivity); + _current = invocation; + return invocation; + } + + /// <summary> + /// Returns live authority for the enrolled, writable, unpoisoned frame only. + /// Retained snapshots and reused transaction objects cannot authorize a new unit. + /// </summary> + 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; + } + + /// <summary> + /// Called after successful outbox INSERT or successful-save promotion only. + /// Receipts survive child disposal and queue drain; they are not pending counts. + /// </summary> + public void RecordProducedWork() + { + _ = RequireAnnouncement(); + for (var invocation = _current; invocation is not null; invocation = invocation.Parent) + { + invocation.HasProducedWork = true; + } + } + + /// <summary>One invocation, retained by its coordinator through owner completion.</summary> + 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, Activity? ownedActivity) + { + _execution = execution; + Parent = parent; + Announcement = announcement; + HasOwner = hasOwner; + _ownedActivity = ownedActivity; + } + + internal Invocation? Parent { get; } + internal TenantContextAnnouncement? Announcement { get; } + internal bool HasOwner { get; } + + /// <summary>Successful production by this invocation or any descendant.</summary> + public bool HasProducedWork { get; internal set; } + + /// <inheritdoc /> + 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..b10c86b1 --- /dev/null +++ b/backend/src/LearnStack.Application/Persistence/TransactionExecutionCoordinator.cs @@ -0,0 +1,127 @@ +using LearnStack.Application.Messaging; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Results; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.Extensions.Logging; + +namespace LearnStack.Application.Persistence; + +/// <summary> +/// Shared transaction entry and owner completion for requests and direct work. +/// The outer audit coordinator retains classification/reconciliation ownership. +/// </summary> +public sealed class TransactionExecutionCoordinator( + IUnitOfWork unitOfWork, + ITenantContext tenantContext, + IAuditStore auditStore, + IAuditStateCapture capture, + ProducerExecution producer, + ILogger<TransactionExecutionCoordinator> logger) +{ + /// <summary> + /// Announces before dispatch, retains producer ancestry through completion, + /// and reports the actual durability boundary (ADR-0057). + /// </summary> + public async Task<TResponse> ExecuteAsync<TResponse>( + IProvisionsTenant? provisioning, + TransactionMode mode, + string operationName, + Func<Task<TResponse>> next, + CancellationToken cancellationToken = default) + where TResponse : IResultBase + { + ArgumentNullException.ThrowIfNull(next); + ArgumentException.ThrowIfNullOrWhiteSpace(operationName); + await using var scope = await unitOfWork.BeginTransactionAsync(mode, cancellationToken); + 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); + } + + using var invocation = producer.BeginInvocation(scope.IsOwner); + 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."); + } + + if (invocation.HasProducedWork) _ = producer.RequireAnnouncement(); + await auditStore.WritePendingAsync(unitOfWork, cancellationToken); + } + + committing = true; + try + { + await scope.CompleteAsync(cancellationToken); + } + catch (Exception commitFailure) + { + 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; + } + } + + private static readonly Action<ILogger, string, Exception?> LogRollbackFailure = + LoggerMessage.Define<string>( + 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; /// <summary> -/// MediatR pipeline behavior — step 3 of the canonical eight-step order -/// (<see href="../../../../docs/decisions/0032-exception-handling-logging-and-observability.md">ADR-0032 -/// § Sub-decision 2</see>). It classifies the request, parks one intent per audited -/// <c>(resource, operation)</c>, and reports the outcome. +/// MediatR pipeline step 3: delegates classification, intent attribution, outcomes and +/// outermost reconciliation to the shared <see cref="AuditExecutionCoordinator"/>. /// </summary> /// <remarks> -/// <para> -/// <b>It writes nothing on the success path.</b> A MUST-class row belongs on the business -/// transaction, immediately before <c>COMMIT</c>, and <c>TransactionBehavior</c> — which -/// owns that boundary — is what flushes it -/// (<see href="../../../../docs/decisions/0033-audit-durability-model.md">ADR-0033</see>). -/// 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. -/// </para> -/// <para> -/// <b>Two guarantees the Packet 3 shell established and this must not lose:</b> the -/// rethrow goes through <see cref="ExceptionDispatchInfo"/>, 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 -/// <c>MediatR_Pipeline_Order_Matches_Canonical_Sequence</c> asserts. Being outside the -/// transaction is what lets the reconcile step run <em>after</em> the commit resolved. -/// </para> -/// <para> -/// <b>It stays in <c>LearnStack.Application</c>.</b> The Packet 3 remark said it would -/// move to <c>LearnStack.Infrastructure.Audit</c>; ADR-0044 § 11 settles the other way, -/// because that move inverts the Application → Infrastructure dependency -/// <c>MediatRPipelineRegistration.CanonicalBehaviorOrder</c> and its architecture test -/// depend on, and that project's csproj forbids it besides. Only the ports it names moved. -/// </para> +/// 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. /// </remarks> -public sealed class AuditLogBehavior<TRequest, TResponse>( - IAuditCatalog catalog, - IAuditConfigService classifier, - IAuditStateCapture capture, - IAuditStore store, - ITenantContext tenantContext, - IClock clock, - IGuidFactory guidFactory, - ILogger<AuditLogBehavior<TRequest, TResponse>> logger) +public sealed class AuditLogBehavior<TRequest, TResponse>(AuditExecutionCoordinator coordinator) : IPipelineBehavior<TRequest, TResponse> where TRequest : notnull where TResponse : IResultBase { - public async Task<TResponse> Handle( + public Task<TResponse> Handle( TRequest request, RequestHandlerDelegate<TResponse> 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<TResponse>(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(); - } - } - } - } - - /// <summary> - /// Writes whatever the business transaction did not. - /// </summary> - /// <remarks> - /// <para> - /// One place for every exit, because the exits are not distinguishable from inside the - /// pipeline: a refusal returns, an exception throws, and a cancelled <c>COMMIT</c> - /// does neither in a way this behavior's <c>catch</c> can see. What separates them is - /// the <b>state</b> the owning unit-of-work frame recorded, which is a fact about the - /// database, and each intent's own <b>result</b>, which is a fact about its request — - /// <see cref="AuditDraftComposer.Reconciled"/> holds the rule that combines them. - /// </para> - /// <para> - /// <b>A MUST intent is re-written only when it is not already durable.</b> - /// <see cref="AuditIntentState.Committed"/> means the row went with the business - /// write; anything else means it did not, and the attempt is still the record. - /// <see cref="AuditIntentState.Indeterminate"/> prefers a duplicate to a loss — the - /// row goes out anyway, and the <c>23505</c> that may follow is positive evidence the - /// first one landed. - /// </para> - /// <para> - /// <b>A SHOULD or MAY intent is never in-transaction.</b> - /// <c>WritePendingAsync</c> flushes MUST alone, so these are written here, best effort, - /// with the outcome the request actually had. - /// </para> - /// <para> - /// <b>It never throws, and one row's failure never costs another its attempt.</b> This - /// runs from a <c>finally</c>, 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 <c>403</c> with a <c>500</c>, 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. - /// </para> - /// <para> - /// <b>One alert per failure, from whoever saw it.</b> The store reports a failed write - /// itself (IAuditStore), so that path logs a <c>Warning</c> 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 <c>Critical</c> here. - /// </para> - /// </remarks> - 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 - } - } - - /// <summary> - /// Parks one intent per audited <c>(resource, operation)</c>. - /// </summary> - /// <remarks> - /// Plural, and that is the correction ADR-0033 Amendment 2 § 1 makes: - /// <c>ProvisionTenantCommand</c> writes two aggregate roots on one transaction and the - /// Tenancy matrix classifies both MUST, so a singular reading would simply never write - /// the <c>Organization</c> row it promises. - /// </remarks> - 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); } - - /// <summary> - /// Denied for a refusal the authorization layer would answer 403 to, Failed otherwise. - /// </summary> - /// <remarks> - /// Keyed on the three codes <c>HttpStatusMap</c> answers <c>403</c> to, so the row's - /// outcome and the response's status cannot disagree. A validation failure is - /// <c>Failed</c>: it is a malformed request rather than a refused one, and counting it - /// as a denial would bury the probes the <c>denied</c> class exists to surface. - /// </remarks> - 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<ILogger, string, Exception?> LogUnclassified = - LoggerMessage.Define<string>( - 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<ILogger, string, Exception?> LogReconcileRowLost = - LoggerMessage.Define<string>( - 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<ILogger, string, Exception?> LogReconcileRowRefused = - LoggerMessage.Define<string>( - 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."); -} - -/// <summary> -/// Which <see cref="AuditLogBehavior{TRequest,TResponse}"/> frame is the outermost one. -/// </summary> -/// <remarks> -/// <para> -/// 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. -/// </para> -/// <para> -/// <see cref="AsyncLocal{T}"/> 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. -/// </para> -/// </remarks> -internal static class AuditFrame -{ - private static readonly AsyncLocal<bool> Active = new(); - - /// <summary>Marks a frame open. <c>true</c> when this frame is the outermost.</summary> - public static bool Enter() - { - if (Active.Value) - { - return false; - } - - Active.Value = true; - - return true; - } - - /// <summary>Closes the outermost frame.</summary> - 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..32f1f646 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,12 @@ 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<AuditExecutionCoordinator>(); + services.TryAddScoped<ProducerExecution>(); + services.TryAddScoped<TransactionExecutionCoordinator>(); + // 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/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; /// <summary> -/// 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-<c>Result</c> and rolls back on a fail-<c>Result</c> 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). /// </summary> -/// <remarks> -/// <para> -/// <b>Through <c>IUnitOfWork</c>, never a <c>DbContext</c>.</b> Per -/// <see href="../../../../docs/decisions/0040-ambient-unit-of-work.md">ADR-0040</see> -/// the unit of work owns one connection per scope and every module -/// <c>DbContext</c> 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 <c>SET LOCAL</c> and reads zero rows. -/// <c>TransactionBehavior_Does_Not_Reference_A_Module_Assembly</c> is the guard. -/// </para> -/// <para> -/// <b>No gate.</b> 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 <c>RequiresTransaction(request)</c> predicate would be a fourth -/// exemption defined nowhere. -/// </para> -/// <para> -/// <b>The commit is outside the catch, deliberately.</b> The filter -/// <c>when (!committing)</c> is what stops the cleanup path from running after a -/// faulted <c>COMMIT</c>. 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 -/// <c>OperationCanceledException</c>, a client that disconnects mid-commit is -/// audited as a failure, captured by <c>IErrorTrackingProvider</c> and answered -/// <c>500</c> instead of <c>499</c> — three ADR-0032 behaviours inverted at once. -/// A faulted commit also leaves the outcome genuinely unknown, which is -/// <see href="../../../../docs/decisions/0033-audit-durability-model.md">ADR-0033</see>'s -/// <c>Indeterminate</c>, not a failure to roll back. -/// </para> -/// <para> -/// <b>The exception path marks the unit; the fail-<c>Result</c> path does not.</b> -/// ADR-0040 § Nesting: an inner <c>Result.Fail</c> 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 -/// <c>MarkRollbackOnly</c> before failing its frame. -/// </para> -/// <para> -/// <b>The MUST-class audit write is the last statement before <c>COMMIT</c></b>, on the -/// owning frame only — <c>IAuditStore.WritePendingAsync(unitOfWork, ct)</c>, per ADR-0033 — -/// so the state change and its record commit together or neither does. The -/// <c>IAuditStateCapture</c> transitions beside it make the commit the only place -/// durability is claimed. Packet 6 shipped the boundary with the line reserved; -/// <see href="../../../../docs/roadmap/phase-02a-kernel-tenancy.md">Packet 9</see> wrote it. -/// </para> -/// </remarks> -public sealed class TransactionBehavior<TRequest, TResponse>( - IUnitOfWork unitOfWork, - ITenantContext tenantContext, - IAuditStore auditStore, - IAuditStateCapture capture, - ILogger<TransactionBehavior<TRequest, TResponse>> logger) +public sealed class TransactionBehavior<TRequest, TResponse>(TransactionExecutionCoordinator coordinator) : IPipelineBehavior<TRequest, TResponse> where TRequest : notnull where TResponse : IResultBase @@ -77,216 +20,15 @@ public sealed class TransactionBehavior<TRequest, TResponse>( ? TransactionMode.ReadOnly : TransactionMode.ReadWrite; - public async Task<TResponse> Handle( + /// <inheritdoc /> + public Task<TResponse> Handle( TRequest request, RequestHandlerDelegate<TResponse> 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<ILogger, string, Exception?> LogRollbackFailure = - LoggerMessage.Define<string>( - 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; /// </summary> /// <remarks> /// <para> -/// <b>Scoped, and the lifetime is the request rather than the transaction.</b> That is +/// <b>Scoped, and the lifetime is the execution rather than the transaction.</b> That is /// what makes <see cref="State"/> a value someone <em>sets</em> 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</see>). /// </para> /// <para> -/// <b>Not thread-safe, deliberately.</b> One DI scope is one request and one logical +/// <b>Not thread-safe, deliberately.</b> 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. /// </para> /// <para> /// It lives in <c>LearnStack.Infrastructure.Audit</c> rather than in the Audit module, @@ -65,6 +66,9 @@ public sealed class AuditStateCapture : IAuditStateCapture, IAuditSubject /// <inheritdoc /> public AuditIntentState State { get; private set; } = AuditIntentState.None; + /// <inheritdoc /> + public bool HasOpenFrame => _openFrames.Count != 0; + /// <summary> /// What faulted the <c>COMMIT</c>, when <see cref="State"/> is /// <see cref="AuditIntentState.Indeterminate"/>. @@ -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/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. /// </remarks> - 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<IUnitOfWorkScope> BeginTransactionAsync( if (_transaction is null) { + ClearTenantContextAnnouncement(); + try { _connection ??= _dataSource.CreateConnection(); @@ -147,6 +150,8 @@ public async Task<IUnitOfWorkScope> 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<IUnitOfWorkScope> 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<IUnitOfWorkScope> 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; } /// <inheritdoc /> @@ -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); } /// <inheritdoc /> @@ -366,6 +370,16 @@ transaction is not null && ReferenceEquals(transaction, _transaction) && _tenantContextIssued; + /// <inheritdoc /> + public TenantContextAnnouncement? GetTenantContextAnnouncement(DbTransaction? transaction) => + IsTenantContextIssuedOn(transaction) ? _tenantContextAnnouncement : null; + + private void ClearTenantContextAnnouncement() + { + _tenantContextIssued = false; + _tenantContextAnnouncement = null; + } + private static readonly Action<ILogger, string, Exception?> LogResolvedWithoutTenant = LoggerMessage.Define<string>( 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. /// </remarks> - 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; /// <summary> /// 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. /// </summary> /// <remarks> /// <para> -/// Registered <b>scoped</b> — one per request — and cleared once, by the outermost -/// behavior, in its <c>finally</c>. A joiner frame must not clear: it would erase the +/// Registered <b>scoped</b> and cleared once, by the outermost audit execution +/// coordinator, in its <c>finally</c>. A joiner frame must not clear: it would erase the /// outer request's intents and every snapshot before the owner had committed /// (<see href="../../../../docs/decisions/0033-audit-durability-model.md">ADR-0033 /// Amendment 2 § 2</see>). @@ -20,15 +20,17 @@ namespace LearnStack.SharedKernel.Audit; /// never inferred from what is in here. /// </para> /// <para> -/// <b>Frames.</b> Every <c>AuditLogBehavior</c> invocation — outermost or nested — opens a -/// frame before it declares and closes it on its way out. An intent belongs to the frame +/// <b>Frames.</b> 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 /// (<see href="../../../../docs/decisions/0044-audit-write-path.md">ADR-0044 Amendment 6 /// §§ 1, 3</see>). 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. /// </para> /// <para> /// A SharedKernel abstraction: it names no EF Core type, because the interceptor that @@ -37,24 +39,32 @@ namespace LearnStack.SharedKernel.Audit; /// </remarks> public interface IAuditStateCapture { - /// <summary>Every entity change captured so far, across every flush in the request.</summary> + /// <summary>Every entity change captured so far, across every flush in the execution.</summary> IReadOnlyList<CapturedEntityChange> Changes { get; } /// <summary>Records one captured change. Called by the interceptor, never by a handler.</summary> void Add(CapturedEntityChange change); /// <summary> - /// The intents declared for this request, in declaration order — one per audited + /// The intents declared for this execution, in declaration order — one per audited /// <c>(resource, operation)</c>. /// </summary> IReadOnlyList<AuditIntent> Intents { get; } /// <summary> - /// 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. /// </summary> AuditIntentState State { get; } + /// <summary>Whether this scoped capture has an open audit execution frame.</summary> + /// <remarks> + /// 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. + /// </remarks> + bool HasOpenFrame { get; } + /// <summary> /// The changes captured in the frame that declared <paramref name="intent"/> — the /// ones its own request wrote. What both writers compose a row from. @@ -62,17 +72,17 @@ public interface IAuditStateCapture /// <exception cref="InvalidOperationException">The intent was not declared here.</exception> IReadOnlyList<CapturedEntityChange> ChangesOf(AuditIntent intent); - /// <summary>Opens a frame. <c>AuditLogBehavior</c>, before it declares.</summary> + /// <summary>Opens a frame. The common audit coordinator, before it declares.</summary> void OpenFrame(); /// <summary> /// Closes the innermost frame and records <paramref name="result"/> on every intent it - /// declared. <c>AuditLogBehavior</c>, in its <c>finally</c>, on every way out. + /// declared. The common audit coordinator, in its <c>finally</c>, on every way out. /// </summary> /// <exception cref="InvalidOperationException">No frame is open.</exception> void CloseFrame(AuditIntentResult result); - /// <summary>Declares an intent in the innermost frame. <c>AuditLogBehavior</c>, step 3.</summary> + /// <summary>Declares an intent in the common coordinator's innermost frame.</summary> void DeclareIntent(AuditIntent intent); /// <summary> @@ -93,6 +103,6 @@ public interface IAuditStateCapture /// <summary><c>COMMIT</c> faulted; the server-side outcome is unknown.</summary> void MarkIndeterminate(Exception cause); - /// <summary>Empties the buffer. Once per request, by the outermost frame.</summary> + /// <summary>Empties the buffer. Once per execution, by the outermost audit frame.</summary> void Clear(); } diff --git a/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs b/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs index b85d2e9c..33022c65 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. /// </para> /// <para> -/// What escalates a frame's failure to the whole unit is -/// <see cref="MarkRollbackOnly"/>, and only two things call it: an exception — -/// <c>TransactionBehavior</c> marks the unit before rolling back on one — and a -/// caller doing so deliberately. An inner <c>Result.Fail</c> 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 <see cref="MarkRollbackOnly"/> 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 +/// <c>Result.Fail</c> outcomes remain absorbable under ADR-0040 § Nesting. /// </para> /// <para> /// <b>One command at a time.</b> One connection means the ambient transaction @@ -140,6 +138,16 @@ Task<IUnitOfWorkScope> BeginTransactionAsync( /// </remarks> bool IsTenantContextIssuedOn(DbTransaction? transaction); + /// <summary>Reads the successful tenant announcement for this unit's live transaction.</summary> + /// <remarks> + /// 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). + /// </remarks> + TenantContextAnnouncement? GetTenantContextAnnouncement(DbTransaction? transaction); + /// <summary> /// Announces the tenant a provisioning request is about to create. /// </summary> @@ -190,13 +198,10 @@ Task SetProvisioningTenantContextAsync( /// </summary> /// <remarks> /// <para> - /// <b>A joiner's failure does not poison the unit.</b> ADR-0040 § Nesting: - /// "an inner <c>Result.Fail</c> that the outer handler deliberately absorbs - /// is <i>not</i> a failure and does not mark it — only an exception, or an - /// explicit <see cref="MarkRollbackOnly"/>, does." So this resolves the - /// caller's frame and, on the outermost one, performs the actual - /// <c>ROLLBACK</c>; escalating to the whole unit is - /// <see cref="MarkRollbackOnly"/>'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 <see cref="MarkRollbackOnly"/>'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). /// </para> /// <para> /// <b>It is cleanup, so it never throws over the thing it is cleaning up @@ -215,9 +220,8 @@ Task SetProvisioningTenantContextAsync( /// of work — not just of the current transaction. /// </summary> /// <remarks> - /// An inner <c>Result.Fail</c> that an outer handler deliberately absorbs is - /// not a failure and does not mark it; an exception is, and - /// <c>TransactionBehavior</c> 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, <see cref="CommitAsync"/> throws and /// <see cref="BeginTransactionAsync"/> refuses to open a new transaction on /// the same unit — a scope that needs a fresh one takes a fresh scope, which @@ -290,8 +294,8 @@ public interface IUnitOfWorkScope : IAsyncDisposable /// <summary> /// Resolves this frame by failing it. On the owning frame that is the /// <c>ROLLBACK</c>; 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. /// </summary> 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; + +/// <summary>The normalized tenant scope successfully announced on one physical transaction.</summary> +/// <remarks> +/// 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). +/// </remarks> +public sealed record TenantContextAnnouncement( + long Generation, + TenantId TenantId, + OrganizationId? OrganizationId); diff --git a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs index 96ad6cb8..332af02b 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; @@ -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)); } 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/ProducerExecutionTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/ProducerExecutionTests.cs new file mode 100644 index 00000000..6c40e37a --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/ProducerExecutionTests.cs @@ -0,0 +1,602 @@ +using System.Diagnostics; +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.Tenant; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Localization; +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; + +/// <summary> +/// ADR-0057's shared execution boundary, before the actual collector/outbox arrive. +/// Receipts below are recorded explicitly after an observed successful database write; +/// these tests do not claim SaveChanges promotion or immediate outbox admission. +/// </summary> +[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 An_absorbed_producer_refusal_rolls_back_direct_and_descendant_work(bool descendant) + { + 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<TransactionExecutionCoordinator>(); + var producer = services.GetRequiredService<ProducerExecution>(); + var unit = services.GetRequiredService<IUnitOfWork>(); + var id = TenantContentTypeId.From(Guid.CreateVersion7()); + var observedWrite = false; + var absorbedRefusal = false; + + async Task<Result<None>> WriteAsync() + { + await WriteContentTypeAsync(services, id); + (await CountInUnitAsync(unit, "tenant_content_types", id.Value)).Should().Be(1); + observedWrite = true; + // Step 1 tests the receipt protocol only. Step 2/3 must prove that the + // actual INSERT/save-promotion sites, rather than callers, record it. + producer.RecordProducedWork(); + return Result.Ok(None.Value); + } + + var run = () => transaction.ExecuteAsync(null, TransactionMode.ReadWrite, "outer", async () => + { + var refused = await transaction.ExecuteAsync(null, TransactionMode.ReadWrite, "refused", async () => + { + if (descendant) + { + var child = await transaction.ExecuteAsync( + null, TransactionMode.ReadWrite, "successful-descendant", WriteAsync); + child.IsSuccess.Should().BeTrue(); + } + else + { + (await WriteAsync()).IsSuccess.Should().BeTrue(); + } + + return Result<None>.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<InvalidOperationException>() + .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); + producer.HasInvocation.Should().BeFalse(); + unit.Transaction.Should().BeNull(); + unit.GetTenantContextAnnouncement(unit.Transaction).Should().BeNull(); + } + + [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<TransactionExecutionCoordinator>(); + var unit = services.GetRequiredService<IUnitOfWork>(); + 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<None>.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<IAuditStateCapture>()); + var audit = DirectAudit(services, capture); + var id = TenantContentTypeId.From(Guid.CreateVersion7()); + var constructed = false; + + var result = await audit.ExecuteAsync(typeof(DirectHandler), null, + () => services.GetRequiredService<TransactionExecutionCoordinator>().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<TransactionExecutionCoordinator>().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<IUnitOfWork>().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<IAuditStateCapture>(); + var capture = new ObservedCapture(sharedCapture); + var id = OrganizationId.From(Guid.CreateVersion7()); + + var result = await DirectAudit(services, capture).ExecuteAsync(typeof(OffHandler), null, + () => services.GetRequiredService<TransactionExecutionCoordinator>().ExecuteAsync( + null, TransactionMode.ReadWrite, nameof(OffHandler), async () => + { + var nested = await services.GetRequiredService<IMediator>().Send( + new CreateOrganizationCommand(id, "direct-branch", "Direct branch")); + nested.IsSuccess.Should().BeTrue("the nested command is admitted: {0}", nested.Error); + (await CountInUnitAsync(services.GetRequiredService<IUnitOfWork>(), + "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<TransactionExecutionCoordinator>().ExecuteAsync( + null, TransactionMode.ReadWrite, nameof(DirectHandler), async () => + { + var result = await new DirectHandler(services).HandleAsync(id); + (await CountInUnitAsync(services.GetRequiredService<IUnitOfWork>(), + "tenant_content_types", id.Value)).Should().Be(1); + observed = true; + return result; + }), CancellationToken.None); + + var failure = (await run.Should().ThrowAsync<AuditWriteFailedException>()).Which; + failure.InnerException.Should().BeOfType<PostgresException>() + .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<Program>().WithWebHostBuilder(builder => + { + builder.UseSetting("ConnectionStrings:Default", database.AppConnectionString); + builder.ConfigureServices(services => services.AddSingleton<ITenantContextAccessor>( + 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<IAuditStateCapture>()); + 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<IAuditStateCapture>() + .Should().NotBeSameAs(outerServices.GetRequiredService<IAuditStateCapture>()); + innerServices.GetRequiredService<ProducerExecution>() + .Should().NotBeSameAs(outerServices.GetRequiredService<ProducerExecution>()); + innerServices.GetRequiredService<AuditExecutionCoordinator>() + .Should().NotBeSameAs(outerServices.GetRequiredService<AuditExecutionCoordinator>()); + innerServices.GetRequiredService<TransactionExecutionCoordinator>() + .Should().NotBeSameAs(outerServices.GetRequiredService<TransactionExecutionCoordinator>()); + + var nested = await innerServices.GetRequiredService<IMediator>().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<Result<None>> 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<ProducerExecution>().RequireAnnouncement().Generation; + return exit switch + { + "refusal" => Task.FromResult(Result<None>.Fail(Refusal)), + "cancel" => Task.FromCanceled<Result<None>>(cancellation.Token), + "fault" => Task.FromException<Result<None>>(fault), + _ => Task.FromResult(Result.Ok(None.Value)), + }; + } + + var run = () => DirectAudit(services).ExecuteAsync(typeof(OffHandler), null, + () => services.GetRequiredService<TransactionExecutionCoordinator>().ExecuteAsync( + null, TransactionMode.ReadWrite, "trace-probe", WorkAsync), CancellationToken.None); + + if (exit == "fault") + { + (await run.Should().ThrowAsync<InvalidOperationException>()).Which.Should().BeSameAs(fault); + } + else if (exit == "cancel") + { + (await run.Should().ThrowAsync<OperationCanceledException>()).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<TransactionExecutionCoordinator>().ExecuteAsync( + null, TransactionMode.ReadWrite, "same-scope", () => + { + services.GetRequiredService<ProducerExecution>().RequireAnnouncement().Generation + .Should().BeGreaterThan(generation); + return Task.FromResult(Result.Ok(None.Value)); + }), CancellationToken.None); + AssertClean(services); + Activity.Current.Should().BeNull(); + } + else + { + services.GetRequiredService<IUnitOfWork>().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<IAuditCatalog>()), + services.GetRequiredService<IAuditConfigService>(), + capture ?? services.GetRequiredService<IAuditStateCapture>(), + services.GetRequiredService<IAuditStore>(), + services.GetRequiredService<ITenantContext>(), + services.GetRequiredService<IClock>(), + services.GetRequiredService<IGuidFactory>(), + NullLogger<AuditExecutionCoordinator>.Instance); + + private static void AssertClean(IServiceProvider services) + { + var capture = services.GetRequiredService<IAuditStateCapture>(); + capture.HasOpenFrame.Should().BeFalse(); + capture.Intents.Should().BeEmpty(); + capture.Changes.Should().BeEmpty(); + capture.State.Should().Be(AuditIntentState.None); + services.GetRequiredService<ProducerExecution>().HasInvocation.Should().BeFalse(); + var unit = services.GetRequiredService<IUnitOfWork>(); + unit.Transaction.Should().BeNull(); + unit.GetTenantContextAnnouncement(unit.Transaction).Should().BeNull(); + } + + private static async Task WriteContentTypeAsync(IServiceProvider services, TenantContentTypeId id) + { + var db = services.GetRequiredService<CustomizationDbContext>(); + db.Add(TenantContentType.Create(id, TenantA.TenantId, Key(id), 1, + LocalizedText.From(("en", "Execution probe")), """{"type":"object"}""", "default-card", + services.GetRequiredService<IClock>(), 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<long> 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<long> 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<IReadOnlyList<AuditRow>> 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<AuditRow>(); + 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<IUnitOfWork>().HasActiveTransaction.Should().BeTrue(); + services.GetRequiredService<IAuditStateCapture>().HasOpenFrame.Should().BeTrue(); + services.GetRequiredService<ProducerExecution>().RequireAnnouncement().TenantId.Should().Be(TenantA.TenantId); + } + + public async Task<Result<None>> HandleAsync(TenantContentTypeId id) + { + await WriteContentTypeAsync(_services, id); + return Result.Ok(None.Value); + } + } + + private sealed class OffHandler; + private sealed class UnregisteredHandler; + + /// <summary>The real request catalogue remains intact; only controlled direct types are added.</summary> + 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<AuditCatalogEntry> All => actual.All; + } + + private sealed class ObservedCapture(IAuditStateCapture actual) : IAuditStateCapture + { + public int ClearCount { get; private set; } + public IReadOnlyList<CapturedEntityChange> Changes => actual.Changes; + public IReadOnlyList<AuditIntent> Intents => actual.Intents; + public AuditIntentState State => actual.State; + public bool HasOpenFrame => actual.HasOpenFrame; + public void Add(CapturedEntityChange change) => actual.Add(change); + public IReadOnlyList<CapturedEntityChange> 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..d8557a34 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; @@ -48,8 +50,9 @@ public async Task PublicSurface_Transactions_Refuse_Writes() var unit = scope.ServiceProvider.GetRequiredService<IUnitOfWork>(); var capture = new AuditStateCapture(); var behavior = new TransactionBehavior<PublicProbe, Result<string>>( - unit, Context, Store(scope.ServiceProvider, capture), capture, - NullLogger<TransactionBehavior<PublicProbe, Result<string>>>.Instance); + new TransactionExecutionCoordinator( + unit, Context, Store(scope.ServiceProvider, capture), capture, new ProducerExecution(unit), + NullLogger<TransactionExecutionCoordinator>.Instance)); var reached = false; var attempt = async () => await behavior.Handle(new PublicProbe(), async () => { @@ -141,12 +144,14 @@ public async Task Mixed_mode_refusal_poisons_an_outer_owner_even_when_absorbed( var store = Store(scope.ServiceProvider, capture); Func<Task> join = innerMode == TransactionMode.ReadOnly ? async () => await new TransactionBehavior<PublicProbe, Result<string>>( - unit, Context, store, capture, - NullLogger<TransactionBehavior<PublicProbe, Result<string>>>.Instance) + new TransactionExecutionCoordinator( + unit, Context, store, capture, new ProducerExecution(unit), + NullLogger<TransactionExecutionCoordinator>.Instance)) .Handle(new PublicProbe(), next, default) : async () => await new TransactionBehavior<WritableProbe, Result<string>>( - unit, Context, store, capture, - NullLogger<TransactionBehavior<WritableProbe, Result<string>>>.Instance) + new TransactionExecutionCoordinator( + unit, Context, store, capture, new ProducerExecution(unit), + NullLogger<TransactionExecutionCoordinator>.Instance)) .Handle(new WritableProbe(), next, default); await join.Should().ThrowAsync<InvalidOperationException>().WithMessage("*mixed-mode*"); handlerInvoked.Should().BeFalse(); @@ -206,8 +211,9 @@ public async Task Cancelled_handler_rolls_back_and_pooled_next_scope_is_writable var unit = scope.ServiceProvider.GetRequiredService<IUnitOfWork>(); var capture = new AuditStateCapture(); var behavior = new TransactionBehavior<PublicProbe, Result<string>>( - unit, Context, Store(scope.ServiceProvider, capture), capture, - NullLogger<TransactionBehavior<PublicProbe, Result<string>>>.Instance); + new TransactionExecutionCoordinator( + unit, Context, Store(scope.ServiceProvider, capture), capture, new ProducerExecution(unit), + NullLogger<TransactionExecutionCoordinator>.Instance)); var attempt = async () => await behavior.Handle(new PublicProbe(), () => { unit.Mode.Should().Be(TransactionMode.ReadOnly); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenantProvisioningTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenantProvisioningTests.cs index 67555a1c..cbcf886e 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/TenantProvisioningTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantProvisioningTests.cs @@ -2,6 +2,8 @@ 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.Modules.Tenancy.Application.Abstractions; @@ -300,11 +302,10 @@ public async Task A_request_that_does_not_provision_still_fails_closed_when_unre var unitOfWork = scope.ServiceProvider.GetRequiredService<IUnitOfWork>(); var behavior = new TransactionBehavior<Probe, Result<string>>( - unitOfWork, - UnresolvedTenantContext.Instance, - new NoOpAuditStore(), - new AuditStateCapture(), - NullLogger<TransactionBehavior<Probe, Result<string>>>.Instance); + new TransactionExecutionCoordinator( + unitOfWork, UnresolvedTenantContext.Instance, + new NoOpAuditStore(), new AuditStateCapture(), new ProducerExecution(unitOfWork), + NullLogger<TransactionExecutionCoordinator>.Instance)); var write = () => behavior.Handle( new Probe(), @@ -506,13 +507,12 @@ private static async Task<Result<ProvisionedTenantDto>> ProvisionAsync( await using var scope = provider.CreateAsyncScope(); var services = scope.ServiceProvider; + var unit = services.GetRequiredService<IUnitOfWork>(); var behavior = new TransactionBehavior<ProvisionTenantCommand, Result<ProvisionedTenantDto>>( - services.GetRequiredService<IUnitOfWork>(), - context ?? UnresolvedTenantContext.Instance, - new NoOpAuditStore(), - new AuditStateCapture(), - NullLogger<TransactionBehavior<ProvisionTenantCommand, Result<ProvisionedTenantDto>>> - .Instance); + new TransactionExecutionCoordinator( + unit, context ?? UnresolvedTenantContext.Instance, + new NoOpAuditStore(), new AuditStateCapture(), new ProducerExecution(unit), + NullLogger<TransactionExecutionCoordinator>.Instance)); return await behavior.Handle( command, diff --git a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs index c91a0df0..6c79644f 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<IUnitOfWork>(); + 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<ITenantContextAccessor>().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<IUnitOfWork>(); + OrganizationId? organization = organizationKind switch + { + "zero" => OrganizationId.From(Guid.Empty), + "uninitialized" => UnassignedId<OrganizationId>(), + _ => 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<IUnitOfWork>(); + 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<IUnitOfWork>(); + 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<ITenantContext>().IsResolved.Should().BeFalse(); + scope.ServiceProvider.GetRequiredService<ITenantContextAccessor>().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<IUnitOfWork>(); + 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<InvalidOperationException>().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<InvalidOperationException>().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<IUnitOfWork>(); + 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<OperationCanceledException>(); + 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<IUnitOfWork>(); + 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<IUnitOfWork>(); + 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<OperationCanceledException>(); + else (await announce.Should().ThrowAsync<InvalidOperationException>()).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<IUnitOfWork>(); + 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<OperationCanceledException>(); + else (await begin.Should().ThrowAsync<InvalidOperationException>()).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,16 @@ 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<Probe, Result<string>>( + var producer = new ProducerExecution(unitOfWork); + var coordinator = new TransactionExecutionCoordinator( unitOfWork, Resolved(SchemaFixture.TenantA, SchemaFixture.OrgA1), new NoOpAuditStore(), capture, - NullLogger<TransactionBehavior<Probe, Result<string>>>.Instance); - var inner = new TransactionBehavior<Probe, Result<string>>( - unitOfWork, - Resolved(SchemaFixture.TenantA, SchemaFixture.OrgA1), - new NoOpAuditStore(), - capture, - NullLogger<TransactionBehavior<Probe, Result<string>>>.Instance); + producer, + NullLogger<TransactionExecutionCoordinator>.Instance); + var outer = new TransactionBehavior<Probe, Result<string>>(coordinator); + var inner = new TransactionBehavior<Probe, Result<string>>(coordinator); var result = await outer.Handle( new Probe(), @@ -1057,6 +1390,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 +1406,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<IUnitOfWork>(); + 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 +1544,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<T>() + { + 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>(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Dispose() { } + public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, + Exception? exception, Func<TState, Exception?, string> formatter) + { + var message = formatter(state, exception); + if (message.StartsWith("Executing command: ", StringComparison.Ordinal) + && message.Contains(statement, StringComparison.Ordinal)) + onStatement(); + } + } + /// <summary>Collects what <see cref="NpgsqlUnitOfWork"/> logged.</summary> private sealed class CapturingLogger : ILogger<NpgsqlUnitOfWork> { diff --git a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/AuditLogBehaviorTests.cs b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/AuditLogBehaviorTests.cs index e77f87b7..44e27e64 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<AuditStateCapture>(); + services.AddScoped<RecordingAuditStore>(); + services.AddScoped(provider => Coordinator( + new FakeCatalog(AuditPipelineHarness.Entry(entityType: typeof(Subject))), + provider.GetRequiredService<RecordingAuditStore>(), + provider.GetRequiredService<AuditStateCapture>())); + + using var provider = services.BuildServiceProvider(); + using var outerScope = provider.CreateScope(); + var outerCapture = outerScope.ServiceProvider.GetRequiredService<AuditStateCapture>(); + var outerStore = outerScope.ServiceProvider.GetRequiredService<RecordingAuditStore>(); + var outer = outerScope.ServiceProvider.GetRequiredService<AuditExecutionCoordinator>(); + + await outer.ExecuteAsync( + typeof(DummyCommand), null, + async () => + { + outerCapture.Add(SubjectInsert("outer-1", "outer")); + + using var innerScope = provider.CreateScope(); + var innerCapture = innerScope.ServiceProvider.GetRequiredService<AuditStateCapture>(); + var innerStore = innerScope.ServiceProvider.GetRequiredService<RecordingAuditStore>(); + var inner = innerScope.ServiceProvider.GetRequiredService<AuditExecutionCoordinator>(); + 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<DummyCommand, Result<string>>(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<string>(new Error( + new LocalizedMessage(LocalizedMessage.RequiredPrefix + "forbidden")))), + "exception" => throw new InvalidOperationException("first execution failed"), + "cancellation" => Task.FromCanceled<Result<string>>(token), + _ => Task.FromResult(Result.Ok("first")), + }; + }, + token); + + if (ending == "exception") + { + await first.Should().ThrowAsync<InvalidOperationException>(); + } + else if (ending == "cancellation") + { + await first.Should().ThrowAsync<OperationCanceledException>(); + } + 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() { @@ -576,7 +803,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<DummyCommand, Result<string>>( + var behavior = new AuditLogBehavior<DummyCommand, Result<string>>(new AuditExecutionCoordinator( new FakeCatalog(AuditPipelineHarness.Entry()), new FakeClassifier(), capture, @@ -584,7 +811,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<AuditLogBehavior<DummyCommand, Result<string>>>.Instance); + NullLogger<AuditExecutionCoordinator>.Instance)); var result = await behavior.Handle( new DummyCommand(), @@ -843,7 +1070,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 +1106,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 +1140,21 @@ await behavior.Handle( } /// <summary> - /// A fresh request on the same flow is the outermost frame again — which it is not when - /// a previous request left <c>AuditFrame</c> 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. /// </summary> - 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 +1167,31 @@ private static CapturedEntityChange SubjectInsert(string id, string slug) => /// <summary>A stand-in for the aggregate an intent names.</summary> 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<DirectOrchestrator>(); + builder.MustAudit<DirectAuditedHandler>("tenancy.tenant.create", OperationType.Create, typeof(Subject)); + builder.MustAudit<DummyCommand>("tenancy.organization.create", OperationType.Create, typeof(Subject)); + } + } + /// <summary>Classifies the first entry at its declared tier and is cancelled on the second.</summary> private sealed class CancelledOnSecondClassifier : IAuditConfigService { @@ -956,7 +1209,15 @@ private static AuditLogBehavior<DummyCommand, Result<string>> Behavior( RecordingAuditStore store, AuditStateCapture? capture = null, IAuditConfigService? classifier = null, - ILogger<AuditLogBehavior<DummyCommand, Result<string>>>? logger = null) => + ILogger<AuditExecutionCoordinator>? logger = null) => + new(Coordinator(catalog, store, capture, classifier, logger)); + + private static AuditExecutionCoordinator Coordinator( + IAuditCatalog catalog, + RecordingAuditStore store, + AuditStateCapture? capture = null, + IAuditConfigService? classifier = null, + ILogger<AuditExecutionCoordinator>? logger = null) => new( catalog, classifier ?? new FakeClassifier(), @@ -965,10 +1226,10 @@ private static AuditLogBehavior<DummyCommand, Result<string>> Behavior( new HarnessTenantContext(), new HarnessClock(DateTimeOffset.UnixEpoch), new HarnessGuidFactory(), - logger ?? NullLogger<AuditLogBehavior<DummyCommand, Result<string>>>.Instance); + logger ?? NullLogger<AuditExecutionCoordinator>.Instance); /// <summary>Every log line the behavior writes, by level.</summary> - private sealed class CapturingLogger : ILogger<AuditLogBehavior<DummyCommand, Result<string>>> + private sealed class CapturingLogger : ILogger<AuditExecutionCoordinator> { public List<LogLevel> 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..2870aad3 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; @@ -45,8 +47,10 @@ public async Task Public_surface_selects_read_only_before_announcement_and_dispa { var unitOfWork = new RecordingUnitOfWork(); var behavior = new TransactionBehavior<PublicQuery, Result<string>>( - unitOfWork, UnresolvedTenantContext.Instance, new RecordingAuditStore(), - new AuditStateCapture(), NullLogger<TransactionBehavior<PublicQuery, Result<string>>>.Instance); + new TransactionExecutionCoordinator( + unitOfWork, UnresolvedTenantContext.Instance, new RecordingAuditStore(), + new AuditStateCapture(), new ProducerExecution(unitOfWork), + NullLogger<TransactionExecutionCoordinator>.Instance)); var result = await behavior.Handle(new PublicQuery(), () => { @@ -438,12 +442,13 @@ private static TransactionBehavior<DummyCommand, Result<string>> Build( IUnitOfWork unitOfWork, RecordingAuditStore? store = null, AuditStateCapture? capture = null) => - new( + new(new TransactionExecutionCoordinator( unitOfWork, UnresolvedTenantContext.Instance, store ?? new RecordingAuditStore(), capture ?? new AuditStateCapture(), - NullLogger<TransactionBehavior<DummyCommand, Result<string>>>.Instance); + new ProducerExecution(unitOfWork), + NullLogger<TransactionExecutionCoordinator>.Instance)); private static Task<Result<string>> Next(RecordingUnitOfWork unitOfWork, Result<string> result) { @@ -484,6 +489,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. 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/docs/architecture/09-tenant-isolation.md b/docs/architecture/09-tenant-isolation.md index 6250ae47..6b57bf00 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,19 @@ 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). +Outbox enqueue, successful-save collection and production delivery remain later +P02b-1/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/31-audit-subsystem.md b/docs/architecture/31-audit-subsystem.md index af05bcf7..9e792447 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)<br/>DECIDE"] + Cmd["Command / Query / Action"] --> Behavior["AuditLogBehavior (step 3)<br/>AuditExecutionCoordinator: DECIDE"] Behavior --> Config["IAuditConfigService.ClassifyAsync<br/>in-process catalogue + cached audit_config<br/>(no request-path DB read)"] Config -->|"not in the catalogue"| Closed["REJECT<br/>audit_unclassified_operation"] Config --> Intent["MUST-class: park ONE INTENT PER audited<br/>(resource, operation) in IAuditStateCapture<br/>(no DbContext)"] - Intent --> Tx["TransactionBehavior (step 6)<br/>BEGIN; SET LOCAL app.tenant_id"] + Intent --> Tx["TransactionBehavior (step 6)<br/>TransactionExecutionCoordinator: BEGIN; announce"] Tx --> Handler["Handler + OutboxFlush<br/>DbContext.SaveChangesAsync (1..n)"] Handler --> Capture["AuditChangeTrackerInterceptor<br/>snapshots every Added/Modified/Deleted entry<br/>into IAuditStateCapture (writes nothing)"] Capture --> Write["WRITE — IAuditStore.WritePendingAsync<br/>owning frame only; every pending intent<br/>on the ambient transaction"] Write --> Commit[("COMMIT — business rows + MUST audit row,<br/>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<br/>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. +The owner-only domain drain accepted by ADR-0057 remains subsequent P02b-1 work. 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<TRequest, TResponse>`** — 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<CapturedEntityChange> Changes => _changes; public IReadOnlyList<AuditIntent> 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,61 @@ 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<TRequest, TResponse>( - 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<AuditLogBehavior<TRequest, TResponse>> logger) + AuditExecutionCoordinator coordinator) : IPipelineBehavior<TRequest, TResponse> where TRequest : notnull where TResponse : IResultBase { - public async Task<TResponse> Handle(TRequest request, + public Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> 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<Result<T>> 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<TResponse>(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<bool> — 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. Production delivery/inbox wiring remains P02b-3 after G1. Step 1 +supplies the common entry and producer ancestry groundwork; it does not implement +outbox enqueue or domain-event collection. + ### 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 +655,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 +682,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 +690,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 +724,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 +787,68 @@ 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<TResponse> Handle(TRequest request, - RequestHandlerDelegate<TResponse> next, CancellationToken ct) +public sealed class TransactionBehavior<TRequest, TResponse>( + TransactionExecutionCoordinator coordinator) + : IPipelineBehavior<TRequest, TResponse> + 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<TResponse> Handle(TRequest request, + RequestHandlerDelegate<TResponse> 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. Enqueue, successful-save promotion and +owner-only domain drain remain subsequent P02b-1 steps. + +`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 +858,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 +1169,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 +1780,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 Step 1 | Common audit/transaction coordinators, capture-owned audit frames, producer ancestry/receipts and no-listener trace groundwork; enqueue and domain collection remain later steps. | | [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/glossary.md b/docs/glossary.md index b4a0a54e..6ee84809 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -114,9 +114,9 @@ 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** | Accepted per-invocation bookkeeping for event production, including nested requests. A sticky receipt records successful outbox INSERTs or domain-event promotion for the invocation and its ancestors; it is distinct from transaction and audit ownership. [ADR-0057](decisions/0057-transactional-event-production-and-completion.md) is Accepted, not implemented. | -| **Audit frame** | The boundary grouping classified operation intents and captured changes. Nested operations contribute intents; only the outermost frame reconciles and clears them. `AuditLogBehavior` currently supplies the MediatR boundary under [ADR-0044](decisions/0044-audit-write-path.md); [Accepted ADR-0057](decisions/0057-transactional-event-production-and-completion.md) extends common entry coordination to non-MediatR owners. | -| **Transaction announcement snapshot** | Accepted 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. [ADR-0057](decisions/0057-transactional-event-production-and-completion.md) is Accepted, not implemented. | +| **Producer frame** | Per-invocation bookkeeping implemented by scoped `ProducerExecution` in P02b-1 Step 1, including nested ancestry, sticky produced-work receipts and no-listener W3C Activity. Receipts are distinct from transaction and audit ownership. Actual outbox INSERT and successful-save promotion remain subsequent steps under [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** | Accepted 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 Step 1 implements the reader/generation lifecycle under [ADR-0057](decisions/0057-transactional-event-production-and-completion.md); outbox admission and collection remain subsequent steps. | | **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. | @@ -247,11 +247,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<Result<T>>` 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/<module>/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<ISaveChangesInterceptor>` 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<ISaveChangesInterceptor>` 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": <n>, "sha256": "<hex>"}` — 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). | diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 413430d4..285bb309 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -8,7 +8,10 @@ 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, with runtime work pending. +The four producer implementation steps are authorized. Step 1 lifecycle work is +implemented; its two independent review rounds remain pending. The +[Step 1 record](#p02b-1-step-1--shared-execution-lifecycle-2026-10-12) owns +verification. Immediate enqueue, collection and the publisher remain subsequent steps. **Phase 02d handoff — 2026-10-11.** P02b-0 re-verifies the delivered `/api/v1` public contracts, native HTTPS on retained `*.learnstack.local` seed hosts, @@ -419,7 +422,9 @@ post-merge CI receipt above owns that inherited evidence. **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; runtime implementation remains pending. +This is the producer decision record. The +[Step 1 record](#p02b-1-step-1--shared-execution-lifecycle-2026-10-12) tracks +partial runtime delivery; acceptance alone closes no implementation step. 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 @@ -582,6 +587,55 @@ 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) + +**Implementation checkpoint; both independent review rounds pending.** 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. + ### Durable outbox dispatch The producer side is **planned, not shipped**: `IOutbox` does not exist and Packet 3's diff --git a/docs/standards/11-security.md b/docs/standards/11-security.md index 09975612..a20fac5e 100644 --- a/docs/standards/11-security.md +++ b/docs/standards/11-security.md @@ -259,7 +259,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 +313,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 +336,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 +387,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 +682,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 From ab2c37dfba03c50671b87f236dfaf3ec02cdcd81 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 10:15:04 +0300 Subject: [PATCH 41/92] fix(kernel): fence producer ownership and prove all ancestors Prevent raw callers and stale transaction ancestors from granting producer ownership. Exercise a deeper refusal so an implementation that marks only one parent cannot silently pass the rollback proof. ADR: 0040, 0057 --- .../Messaging/ProducerExecution.cs | 8 +- .../Persistence/IUnitOfWork.cs | 7 +- .../Database/ProducerExecutionTests.cs | 106 ++++++++++++++++-- docs/roadmap/phase-02b-events-auth.md | 25 +++++ 4 files changed, 132 insertions(+), 14 deletions(-) diff --git a/backend/src/LearnStack.Application/Messaging/ProducerExecution.cs b/backend/src/LearnStack.Application/Messaging/ProducerExecution.cs index 022aba79..714ef981 100644 --- a/backend/src/LearnStack.Application/Messaging/ProducerExecution.cs +++ b/backend/src/LearnStack.Application/Messaging/ProducerExecution.cs @@ -18,7 +18,7 @@ public sealed class ProducerExecution(IUnitOfWork unitOfWork) /// Enrolls after announcement. An unresolved, event-free invocation is legal; /// actual production must pass <see cref="RequireAnnouncement"/>. /// </summary> - public Invocation BeginInvocation(bool isTransactionOwner) + internal Invocation BeginInvocation(bool isTransactionOwner) { if (isTransactionOwner && _current is not null) { @@ -28,7 +28,11 @@ public Invocation BeginInvocation(bool isTransactionOwner) 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?.HasOwner == true; + 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 }) { diff --git a/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs b/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs index 33022c65..08598c40 100644 --- a/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs +++ b/backend/src/LearnStack.SharedKernel/Persistence/IUnitOfWork.cs @@ -163,9 +163,10 @@ Task<IUnitOfWorkScope> BeginTransactionAsync( /// <para> /// <b>It does not widen the setter set.</b> /// <see href="../../../../docs/decisions/0040-ambient-unit-of-work.md">ADR-0040 - /// Amendment 7</see> closes that set at eight, and <c>TransactionBehavior</c> - /// 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</see> closes that set at eight. The common + /// <c>TransactionExecutionCoordinator</c> calls both methods; + /// <c>TransactionBehavior</c> is its request adapter. This is the same logical + /// setter announcing a different value for provisioning, not a ninth path. /// </para> /// <para> /// <b>Every way of misusing it throws rather than degrading.</b> No open transaction, diff --git a/backend/tests/LearnStack.Tests.Integration/Database/ProducerExecutionTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/ProducerExecutionTests.cs index 6c40e37a..326f7040 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/ProducerExecutionTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/ProducerExecutionTests.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Reflection; using System.Text.Json; using FluentAssertions; using LearnStack.Application.Audit; @@ -40,9 +41,10 @@ public sealed class ProducerExecutionTests(SchemaFixture schema) private static readonly ProbeContext TenantA = new(); [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task An_absorbed_producer_refusal_rolls_back_direct_and_descendant_work(bool descendant) + [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); @@ -70,16 +72,17 @@ async Task<Result<None>> WriteAsync() { var refused = await transaction.ExecuteAsync(null, TransactionMode.ReadWrite, "refused", async () => { - if (descendant) + async Task<Result<None>> DescendantAsync(int remainingDepth) { + if (remainingDepth == 0) return await WriteAsync(); var child = await transaction.ExecuteAsync( - null, TransactionMode.ReadWrite, "successful-descendant", WriteAsync); + null, TransactionMode.ReadWrite, "successful-descendant", + () => DescendantAsync(remainingDepth - 1)); child.IsSuccess.Should().BeTrue(); + return child; } - else - { - (await WriteAsync()).IsSuccess.Should().BeTrue(); - } + + (await DescendantAsync(descendantDepth)).IsSuccess.Should().BeTrue(); return Result<None>.Fail(Refusal); }); @@ -100,6 +103,91 @@ await run.Should().ThrowAsync<InvalidOperationException>() 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<IUnitOfWork>(); + var producer = services.GetRequiredService<ProducerExecution>(); + 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<Result<None>> WorkAsync() + { + reached = true; + var produce = () => producer.RequireAnnouncement(); + produce.Should().Throw<InvalidOperationException>().WithMessage("*enrolled writable frame*"); + return Task.FromResult(Result.Ok(None.Value)); + } + + if (join) + { + (await services.GetRequiredService<TransactionExecutionCoordinator>().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<IUnitOfWork>(); + var producer = services.GetRequiredService<ProducerExecution>(); + var coordinator = services.GetRequiredService<TransactionExecutionCoordinator>(); + var stop = new InvalidOperationException("Stop after stale ancestry proof"); + var reached = false; + + var run = () => DirectAudit(services).ExecuteAsync(typeof(OffHandler), null, + () => coordinator.ExecuteAsync<Result<None>>( + 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<InvalidOperationException>() + .WithMessage("*enrolled writable frame*"); + return Task.FromResult(Result.Ok(None.Value)); + })).IsSuccess.Should().BeTrue(); + + throw stop; + }), CancellationToken.None); + + (await run.Should().ThrowAsync<InvalidOperationException>()).Which.Should().BeSameAs(stop); + reached.Should().BeTrue(); + AssertClean(services); + } + [Fact] public async Task An_event_free_absorbed_refusal_can_commit_its_observed_mutation() { diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 285bb309..d64f4c1a 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -636,6 +636,31 @@ 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. + ### Durable outbox dispatch The producer side is **planned, not shipped**: `IOutbox` does not exist and Packet 3's From 3fd8e8a09ca07bc5d7324b48398c9483206f90f5 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 10:24:05 +0300 Subject: [PATCH 42/92] docs(events): close shared lifecycle review rounds Keep the lifecycle carriers aligned with the verified coordinator ownership before immediate outbox production is introduced. ADR: ADR-0057 --- CLAUDE.md | 4 ++-- .../PersistenceConventionTests.cs | 4 ++-- docs/roadmap/phase-02b-events-auth.md | 18 +++++++++++++++++- docs/standards/11-security.md | 3 +++ .../21-architecture-tests-catalogue.md | 9 +++++---- 5 files changed, 29 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 02c28326..813687da 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -168,8 +168,8 @@ is complete; P02b-1's [producer decision package](docs/roadmap/phase-02b-events- 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) -tracks verification and the two pending independent review rounds. Steps 2–4 -remain pending. +records passing verification and both completed independent review rounds. +Steps 2–4 remain pending. Phase 06 retains the full renderer/Studio expansion. **Phase 01** shipped the .NET 10 solution scaffold under `backend/` diff --git a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs index 332af02b..67db29ae 100644 --- a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs @@ -235,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 <ProjectReference> to a module would leave a reflection-only diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index d64f4c1a..0227dd72 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -589,7 +589,7 @@ by this decision record. ### P02b-1 Step 1 — Shared execution lifecycle (2026-10-12) -**Implementation checkpoint; both independent review rounds pending.** Work is +**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. @@ -661,6 +661,22 @@ 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. + ### Durable outbox dispatch The producer side is **planned, not shipped**: `IOutbox` does not exist and Packet 3's diff --git a/docs/standards/11-security.md b/docs/standards/11-security.md index a20fac5e..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 diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index d90adc33..a52e7c7c 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -1693,8 +1693,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 `<ProjectReference>` would leave a reflection-only check green. The @@ -2531,8 +2532,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 From 7541b7323e17aba340f27cac6736a258eeb08816 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 10:30:50 +0300 Subject: [PATCH 43/92] docs(events): align producer packet review status Keep the packet entry consistent with its completed Step 1 review record. ADR: ADR-0057 --- docs/roadmap/phase-02b-events-auth.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 0227dd72..634356b5 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -9,7 +9,7 @@ corpus and Hub premises. P02b-1's 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; its two independent review rounds remain pending. The +implemented; both independent review rounds passed. The [Step 1 record](#p02b-1-step-1--shared-execution-lifecycle-2026-10-12) owns verification. Immediate enqueue, collection and the publisher remain subsequent steps. From db882e03f5e34ebe1d25b01533c2594cdb9ea0dd Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 10:46:04 +0300 Subject: [PATCH 44/92] feat(kernel): persist outbox events in the admitted transaction Make successful enqueue an immediate atomic write with trusted execution metadata and explicit identifier-only routing. Refuse stale, foreign or unowned frames before persistence and retain production receipts across absorbed failures without changing event-free execution. ADR: 0057 --- .claude/skills/add-integration-event/SKILL.md | 12 +- CLAUDE.md | 4 +- .../PersistenceCompositionExtensions.cs | 3 + .../Messaging/ProducerExecution.cs | 57 +- .../TransactionExecutionCoordinator.cs | 9 +- .../Pipeline/OutboxFlushBehavior.cs | 33 +- .../Messaging/OutboxEventContract.cs | 199 ++++ .../Messaging/PostgresOutbox.cs | 69 ++ .../Messaging/IOutbox.cs | 12 + .../IntegrationEventPartitionAttribute.cs | 14 + .../Messaging/OutboxPayloadLimits.cs | 8 + .../SeedComposition.cs | 3 + .../Database/OutboxProducerTests.cs | 977 ++++++++++++++++++ .../Pipeline/TransactionBehaviorTests.cs | 48 + .../Messaging/OutboxContractTests.cs | 365 +++++++ docs/architecture/15-event-and-outbox.md | 17 +- docs/decisions/README.md | 2 +- docs/glossary.md | 2 + docs/roadmap/phase-02b-events-auth.md | 48 +- docs/standards/20-infrastructure-stack.md | 3 +- 20 files changed, 1844 insertions(+), 41 deletions(-) create mode 100644 backend/src/LearnStack.Infrastructure/Messaging/OutboxEventContract.cs create mode 100644 backend/src/LearnStack.Infrastructure/Messaging/PostgresOutbox.cs create mode 100644 backend/src/LearnStack.SharedKernel/Messaging/IOutbox.cs create mode 100644 backend/src/LearnStack.SharedKernel/Messaging/IntegrationEventPartitionAttribute.cs create mode 100644 backend/src/LearnStack.SharedKernel/Messaging/OutboxPayloadLimits.cs create mode 100644 backend/tests/LearnStack.Tests.Integration/Database/OutboxProducerTests.cs create mode 100644 backend/tests/LearnStack.Tests.Unit/SharedKernel/Messaging/OutboxContractTests.cs diff --git a/.claude/skills/add-integration-event/SKILL.md b/.claude/skills/add-integration-event/SKILL.md index 1b7595c0..96dce227 100644 --- a/.claude/skills/add-integration-event/SKILL.md +++ b/.claude/skills/add-integration-event/SKILL.md @@ -26,7 +26,9 @@ 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 -implementation is pending; P02b-2/P02b-3 retain dispatcher/inbox decisions. +Step 2 implements immediate enqueue and payload admission; successful-save +collection and the first publisher remain subsequent P02b-1 steps. +P02b-2/P02b-3 retain dispatcher/inbox decisions. ## When to use @@ -61,6 +63,7 @@ implementation is pending; P02b-2/P02b-3 retain dispatcher/inbox decisions. In `<Producer>.Application.Contracts/IntegrationEvents/<EventName>.cs`: ```csharp +[IntegrationEventPartition(nameof(EnrollmentId))] public sealed record EnrollmentCreatedIntegrationEventV1 : IntegrationEventBase { public required Guid EnrollmentId { get; init; } @@ -88,6 +91,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 diff --git a/CLAUDE.md b/CLAUDE.md index 813687da..2aea6729 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -169,7 +169,9 @@ 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. -Steps 2–4 remain pending. +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) +tracks verification and pending reviews. Steps 3/4 remain pending. Phase 06 retains the full renderer/Studio expansion. **Phase 01** shipped the .NET 10 solution scaffold under `backend/` diff --git a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs index 5e594b16..10e65b66 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; @@ -263,6 +265,7 @@ public static IServiceCollection AddLearnStackPersistence( services.AddHealthChecks().AddCheck<AuditHealthCheck>(AuditHealthCheck.Name); services.TryAddScoped<IAuditStore, PostgresAuditStore>(); + services.TryAddScoped<IOutbox, PostgresOutbox>(); // 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/Messaging/ProducerExecution.cs b/backend/src/LearnStack.Application/Messaging/ProducerExecution.cs index 714ef981..d3d59951 100644 --- a/backend/src/LearnStack.Application/Messaging/ProducerExecution.cs +++ b/backend/src/LearnStack.Application/Messaging/ProducerExecution.cs @@ -1,4 +1,6 @@ using System.Diagnostics; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Messaging; using LearnStack.SharedKernel.Persistence; namespace LearnStack.Application.Messaging; @@ -18,7 +20,7 @@ public sealed class ProducerExecution(IUnitOfWork unitOfWork) /// Enrolls after announcement. An unresolved, event-free invocation is legal; /// actual production must pass <see cref="RequireAnnouncement"/>. /// </summary> - internal Invocation BeginInvocation(bool isTransactionOwner) + internal Invocation BeginInvocation(bool isTransactionOwner, UserId? causalActor = null) { if (isTransactionOwner && _current is not null) { @@ -42,7 +44,8 @@ internal Invocation BeginInvocation(bool isTransactionOwner) .SetIdFormat(ActivityIdFormat.W3C).Start(); } - var invocation = new Invocation(this, _current, announcement, hasOwner, ownedActivity); + var invocation = new Invocation(this, _current, announcement, hasOwner, ownedActivity, + _current is null ? causalActor : _current.CausalActor); _current = invocation; return invocation; } @@ -67,6 +70,52 @@ public TenantContextAnnouncement RequireAnnouncement() return live; } + /// <summary>Refuses a store paired with another unit, even if announcements match.</summary> + public TenantContextAnnouncement RequireAnnouncement(IUnitOfWork expectedUnit) + { + if (!ReferenceEquals(unitOfWork, expectedUnit)) + { + throw new InvalidOperationException("The outbox must share its producer's unit of work."); + } + + return RequireAnnouncement(); + } + + /// <summary> + /// Builds dispatch metadata from the admitted frame, never producer arguments. + /// Organization authority comes from the SQL announcement even for a tenant-wide fact. + /// </summary> + 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 = Activity.Current?.Id; + if (traceparent is null) + { + throw new InvalidOperationException("An enrolled producer requires a W3C Activity."); + } + + return new IntegrationEventEnvelope(integrationEvent, traceparent, + announcement.OrganizationId?.Value, ActorUserId: _current!.CausalActor); + } + + /// <summary>Step 7 checks enrollment and authority without requiring a produced event.</summary> + 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."); + } + } + /// <summary> /// Called after successful outbox INSERT or successful-save promotion only. /// Receipts survive child disposal and queue drain; they are not pending counts. @@ -89,18 +138,20 @@ public sealed class Invocation : IDisposable internal Invocation( ProducerExecution execution, Invocation? parent, - TenantContextAnnouncement? announcement, bool hasOwner, Activity? ownedActivity) + TenantContextAnnouncement? announcement, bool hasOwner, Activity? ownedActivity, UserId? causalActor) { _execution = execution; Parent = parent; Announcement = announcement; HasOwner = hasOwner; _ownedActivity = ownedActivity; + CausalActor = causalActor; } internal Invocation? Parent { get; } internal TenantContextAnnouncement? Announcement { get; } internal bool HasOwner { get; } + internal UserId? CausalActor { get; } /// <summary>Successful production by this invocation or any descendant.</summary> public bool HasProducedWork { get; internal set; } diff --git a/backend/src/LearnStack.Application/Persistence/TransactionExecutionCoordinator.cs b/backend/src/LearnStack.Application/Persistence/TransactionExecutionCoordinator.cs index b10c86b1..84f16c12 100644 --- a/backend/src/LearnStack.Application/Persistence/TransactionExecutionCoordinator.cs +++ b/backend/src/LearnStack.Application/Persistence/TransactionExecutionCoordinator.cs @@ -1,5 +1,6 @@ using LearnStack.Application.Messaging; using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Identifiers; using LearnStack.SharedKernel.Persistence; using LearnStack.SharedKernel.Results; using LearnStack.SharedKernel.Tenancy; @@ -50,7 +51,13 @@ await unitOfWork.SetProvisioningTenantContextAsync( await unitOfWork.SetTenantContextAsync(tenantContext, cancellationToken); } - using var invocation = producer.BeginInvocation(scope.IsOwner); + // 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) { 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; /// <summary> -/// MediatR pipeline behavior — step 7 of the canonical 8-step order -/// (ADR-0032 § Sub-decision 2). Enrols <c>IOutbox</c> messages in the -/// current unit-of-work transaction; the outbox processor publishes them -/// via <c>IEventBus</c> on commit (see -/// <see href="../../../../docs/architecture/15-event-and-outbox.md">15-event-and-outbox.md</see>). +/// 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. /// </summary> -/// <remarks> -/// Phase 02a Packet 3 ships the <strong>shell</strong>: there is no -/// <c>IOutbox</c> 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. -/// </remarks> -public sealed class OutboxFlushBehavior<TRequest, TResponse> +public sealed class OutboxFlushBehavior<TRequest, TResponse>(ProducerExecution producer) : IPipelineBehavior<TRequest, TResponse> where TRequest : notnull where TResponse : IResultBase { - public Task<TResponse> Handle( + public async Task<TResponse> Handle( TRequest request, RequestHandlerDelegate<TResponse> 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.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; + +/// <summary>ADR-0057's closed payload shape and explicit identifier partition policy.</summary> +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<string>(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<IntegrationEventPartitionAttribute>(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<Guid> 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..945bfad8 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Messaging/PostgresOutbox.cs @@ -0,0 +1,69 @@ +using LearnStack.Application.Messaging; +using LearnStack.SharedKernel.Messaging; +using LearnStack.SharedKernel.Persistence; +using Npgsql; +using NpgsqlTypes; + +namespace LearnStack.Infrastructure.Messaging; + +/// <summary>Inserts immediately on the admitted producer's ambient transaction.</summary> +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) + """; + + /// <inheritdoc /> + 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."); + } + + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + producer.RecordProducedWork(); + } +} 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; + +/// <summary>Persists an integration fact on the enrolled producer's business transaction.</summary> +/// <remarks> +/// 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. +/// </remarks> +public interface IOutbox +{ + /// <summary>Inserts one event, preserving its identity and declared routing.</summary> + Task EnqueueAsync(IIntegrationEvent integrationEvent, CancellationToken cancellationToken = default); +} 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; + +/// <summary>Names the identifier from which a concrete event derives its partition key.</summary> +/// <remarks>Tenant-wide ordering requires an explicit declaration naming TenantId.</remarks> +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class IntegrationEventPartitionAttribute(string sourceProperty, bool tenantWide = false) + : Attribute +{ + /// <summary>The public GUID or approved strongly-typed identifier property.</summary> + public string SourceProperty { get; } = sourceProperty; + + /// <summary>Whether this event deliberately partitions on its tenant identifier.</summary> + 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; + +/// <summary>The producer's complete canonical UTF-8 payload budget (ADR-0057).</summary> +public static class OutboxPayloadLimits +{ + /// <summary>Inclusive; measured before INSERT, independently of JSONB storage size.</summary> + public const int MaxBytes = 256 * 1024; +} diff --git a/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs b/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs index c730e1e1..071080c5 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; @@ -206,6 +208,7 @@ public static ServiceProvider Build( services.TryAddSingleton<IAuditHealth, AuditHealth>(); services.AddScoped<IAuditStore, PostgresAuditStore>(); + services.TryAddScoped<IOutbox, PostgresOutbox>(); // 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/tests/LearnStack.Tests.Integration/Database/OutboxProducerTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/OutboxProducerTests.cs new file mode 100644 index 00000000..36f05ea0 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/OutboxProducerTests.cs @@ -0,0 +1,977 @@ +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; + +/// <summary> +/// 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. +/// </summary> +[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<Program>().WithWebHostBuilder(builder => + { + builder.UseSetting("ConnectionStrings:Default", database.AppConnectionString); + builder.ConfigureServices(services => services.AddSingleton<ITenantContextAccessor>( + new StaticTenantContextAccessor(context))); + }); + var root = api ? host.Services : seed; + await using var scope = root.CreateAsyncScope(); + var services = scope.ServiceProvider; + var unit = services.GetRequiredService<IUnitOfWork>(); + 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<IOutbox>().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<ProbeIntegrationEventV1>( + 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); + } + + [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<IOutbox>().EnqueueAsync(integrationEvent); + var row = (await RowInUnitAsync(services.GetRequiredService<IUnitOfWork>(), integrationEvent.EventId))!; + row.ActorUserId.Should().Be(hasCausalHuman ? HumanActor : null); + row.OrganizationId.Should().BeNull(); + return Result<None>.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<IUnitOfWork>(); + var organizationId = OrganizationId.From(Guid.CreateVersion7()); + var integrationEvent = Event(); + + var result = await RunAsync(services, async () => + { + var nested = await services.GetRequiredService<IMediator>().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<IAuditStateCapture>().State.Should().Be(AuditIntentState.Pending); + services.GetRequiredService<ProducerExecution>().HasInvocation.Should().BeTrue(); + await services.GetRequiredService<IOutbox>().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<TransactionExecutionCoordinator>(); + var unit = services.GetRequiredService<IUnitOfWork>(); + 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<Result<None>> ProduceAsync() + { + await WriteBusinessAsync(services, businessId); + await services.GetRequiredService<IOutbox>().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<None>.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<InvalidOperationException>().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<IUnitOfWork>(); + 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<IOutbox>().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<OperationCanceledException>()).Which.CancellationToken + .Should().Be(cancellation.Token); + } + else + { + (await run.Should().ThrowAsync<InvalidOperationException>()).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<IOutbox>().EnqueueAsync(integrationEvent); + var unit = services.GetRequiredService<IUnitOfWork>(); + (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<AuditWriteFailedException>()).Which; + failure.InnerException.Should().BeOfType<PostgresException>().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<IOutbox>().EnqueueAsync(integrationEvent); + await enqueue.Should().ThrowAsync<InvalidOperationException>(); + CurrentInvocation(services).HasProducedWork.Should().BeFalse(); + services.GetRequiredService<IUnitOfWork>().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<IUnitOfWork>(); + 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<Result<None>> ProduceAsync() + { + reached = true; + var enqueue = () => services.GetRequiredService<IOutbox>().EnqueueAsync(integrationEvent); + await enqueue.Should().ThrowAsync<InvalidOperationException>().WithMessage("*enrolled writable frame*"); + return Result.Ok(None.Value); + } + + var result = join + ? await services.GetRequiredService<TransactionExecutionCoordinator>().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<IUnitOfWork>(); + 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<IUnitOfWork>(); + var producer = services.GetRequiredService<ProducerExecution>(); + 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<InvalidOperationException>().WithMessage("*share its producer's unit of work*"); + CurrentInvocation(services).HasProducedWork.Should().BeFalse(); + (await RowInUnitAsync(foreignUnit, integrationEvent.EventId)).Should().BeNull(); + await services.GetRequiredService<IOutbox>().EnqueueAsync(integrationEvent); + (await RowInUnitAsync(unit, integrationEvent.EventId)).Should().NotBeNull( + "the matched production registration is the positive control"); + return Result<None>.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<IOutbox>().EnqueueAsync(integrationEvent); + await enqueue.Should().ThrowAsync<ArgumentException>().WithMessage("*OrganizationId*"); + CurrentInvocation(services).HasProducedWork.Should().BeFalse(); + await services.GetRequiredService<IOutbox>().EnqueueAsync(validControl); + (await RowInUnitAsync(services.GetRequiredService<IUnitOfWork>(), validControl.EventId)).Should().NotBeNull(); + return Result<None>.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<ITenantContext>().IsResolved.Should().BeFalse(); + services.GetRequiredService<ITenantContextAccessor>().Current!.IsResolved.Should().BeFalse(); + await services.GetRequiredService<IOutbox>().EnqueueAsync(integrationEvent); + var row = (await RowInUnitAsync(services.GetRequiredService<IUnitOfWork>(), integrationEvent.EventId))!; + row.TenantId.Should().Be(tenantId.Value); + row.OrganizationId.Should().BeNull(); + row.ActorUserId.Should().BeNull(); + return Result<None>.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<IUnitOfWork>(); + 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<ProducerExecution>().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<IOutbox>().EnqueueAsync(integrationEvent); + await enqueue.Should().ThrowAsync<InvalidOperationException>().WithMessage("*enrolled writable frame*"); + (await services.GetRequiredService<TransactionExecutionCoordinator>().ExecuteAsync( + null, TransactionMode.ReadWrite, "stale-ancestor-joiner", async () => + { + await enqueue.Should().ThrowAsync<InvalidOperationException>().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<InvalidOperationException>()).Which.Should().BeSameAs(stop); + reached.Should().BeTrue(); + AssertClean(services); + } + + [Fact] + public async Task A_duplicate_EventId_fails_the_real_INSERT_without_creating_a_child_receipt() + { + 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 = Event(); + var observed = false; + var duplicateAttempted = false; + + var run = () => RunAsync(services, async () => + { + await services.GetRequiredService<IOutbox>().EnqueueAsync(integrationEvent); + CurrentInvocation(services).HasProducedWork.Should().BeTrue(); + (await RowInUnitAsync(services.GetRequiredService<IUnitOfWork>(), integrationEvent.EventId)).Should().NotBeNull(); + observed = true; + return await services.GetRequiredService<TransactionExecutionCoordinator>().ExecuteAsync<Result<None>>( + null, TransactionMode.ReadWrite, "duplicate-child", async () => + { + CurrentInvocation(services).HasProducedWork.Should().BeFalse(); + var enqueue = () => services.GetRequiredService<IOutbox>().EnqueueAsync(integrationEvent); + var failure = (await enqueue.Should().ThrowAsync<PostgresException>()).Which; + failure.SqlState.Should().Be(PostgresErrorCodes.UniqueViolation); + CurrentInvocation(services).HasProducedWork.Should().BeFalse( + "the parent's successful INSERT cannot hide a receipt recorded before this failed INSERT"); + duplicateAttempted = true; + throw failure; + }); + }); + + (await run.Should().ThrowAsync<PostgresException>()).Which.SqlState.Should().Be(PostgresErrorCodes.UniqueViolation); + 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<ProducerExecution>().RequireAnnouncement().Generation; + await services.GetRequiredService<IOutbox>().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<ProducerExecution>().RequireAnnouncement().Generation.Should().BeGreaterThan(generation); + await services.GetRequiredService<IOutbox>().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<IOutbox>(); + if (extraBytes == 0) + { + await outbox.EnqueueAsync(integrationEvent); + var row = (await RowInUnitAsync(services.GetRequiredService<IUnitOfWork>(), 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<ArgumentException>().WithMessage("*262144*"); + CurrentInvocation(services).HasProducedWork.Should().BeFalse(); + (await RowInUnitAsync(services.GetRequiredService<IUnitOfWork>(), integrationEvent.EventId)).Should().BeNull(); + } + return Result<None>.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<Result<None>> RunAsync( + IServiceProvider services, Func<Task<Result<None>>> body, bool audited = false, + TransactionMode mode = TransactionMode.ReadWrite, IProvisionsTenant? provisioning = null) + { + var audit = new AuditExecutionCoordinator( + new ProbeCatalog(services.GetRequiredService<IAuditCatalog>()), + services.GetRequiredService<IAuditConfigService>(), + services.GetRequiredService<IAuditStateCapture>(), + services.GetRequiredService<IAuditStore>(), + services.GetRequiredService<ITenantContext>(), + services.GetRequiredService<IClock>(), + services.GetRequiredService<IGuidFactory>(), + NullLogger<AuditExecutionCoordinator>.Instance); + return audit.ExecuteAsync(audited ? typeof(DirectHandler) : typeof(OffHandler), provisioning, + () => services.GetRequiredService<TransactionExecutionCoordinator>().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<CustomizationDbContext>(); + 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<IClock>(), UserId.From(SchemaFixture.Actor))); + await db.SaveChangesAsync(); + } + + private static void AssertClean(IServiceProvider services) + { + var unit = services.GetRequiredService<IUnitOfWork>(); + unit.Transaction.Should().BeNull(); + unit.GetTenantContextAnnouncement(unit.Transaction).Should().BeNull(); + services.GetRequiredService<ProducerExecution>().HasInvocation.Should().BeFalse(); + var capture = services.GetRequiredService<IAuditStateCapture>(); + 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<ProducerExecution>())!; + + private static async Task<object?> 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<long> 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<long> 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<OutboxRow?> RowInUnitAsync(IUnitOfWork unit, Guid id) => + await ReadRowAsync((NpgsqlConnection)unit.Connection, (NpgsqlTransaction?)unit.Transaction, id); + + private static async Task<OutboxRow?> 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<OutboxRow?> 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<DateTimeOffset>(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<DateTimeOffset>(12), reader.GetInt32(13), + reader.IsDBNull(14) ? null : reader.GetString(14)); + } + + private static async Task<IReadOnlyList<AuditRow>> 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<AuditRow>(); + 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"); + + /// <summary> + /// A runtime event contract with one GUID-valued extra property. Only the + /// declaration's name varies; serialization keeps the canonical frozen options. + /// </summary> + 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("<Clone>$"); + 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 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<AuditCatalogEntry> All => actual.All; + } +} diff --git a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs index 2870aad3..b5221fc8 100644 --- a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs @@ -42,6 +42,54 @@ public sealed record DummyCommand : IRequest<Result<string>>; [PublicSurface] public sealed record PublicQuery : IRequest<Result<string>>; + [Fact] + public async Task Step_seven_refuses_dispatch_without_an_enrolled_invocation() + { + var producer = new ProducerExecution(new RecordingUnitOfWork()); + var guard = new OutboxFlushBehavior<DummyCommand, Result<string>>(producer); + var reached = false; + var run = () => guard.Handle(new DummyCommand(), () => + { + reached = true; + return Task.FromResult(Result.Ok("ok")); + }, default); + + await run.Should().ThrowAsync<InvalidOperationException>() + .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, + NullLogger<TransactionExecutionCoordinator>.Instance); + var guard = new OutboxFlushBehavior<DummyCommand, Result<string>>(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<InvalidOperationException>() + .WithMessage("*invocation*transaction*"); + unit.IsRollbackOnly.Should().BeTrue(); + producer.HasInvocation.Should().BeFalse(); + } + [Fact] public async Task Public_surface_selects_read_only_before_announcement_and_dispatch() { 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<Task>(); + 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<IdentifierEvent>(); + + 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<TypedPartitionEvent>()); + + serialized.PartitionKey.Should().Be(Aggregate.ToString("D")); + } + + [Fact] + public void Tenant_partitioning_is_accepted_only_when_deliberately_declared() + { + OutboxEventContract.Serialize(New<TenantPartitionEvent>()).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<ArgumentException>(); + } + + [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<ArgumentException>(); + 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<UnstableIdentifierEvent>()); + + serialize.Should().Throw<ArgumentException>().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<TopicEvent>()); + + if (accepted) serialize.Should().NotThrow(); + else serialize.Should().Throw<ArgumentException>(); + } + + [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<ArgumentException>().WithMessage("*262144 bytes*"); + } + + [Fact] + public async Task An_unenrolled_outbox_refuses_before_reading_connection_or_event_getters() + { + var unit = Substitute.For<IUnitOfWork>(); + var outbox = new PostgresOutbox(unit, new ProducerExecution(unit)); + UnstableIdentifierEvent.Reads = 0; + var enqueue = () => outbox.EnqueueAsync(New<UnstableIdentifierEvent>()); + + await enqueue.Should().ThrowAsync<InvalidOperationException>(); + UnstableIdentifierEvent.Reads.Should().Be(0); + _ = unit.DidNotReceive().Connection; + } + + [Fact] + public async Task Cancellation_refuses_before_acquiring_connection() + { + var unit = Substitute.For<IUnitOfWork>(); + var outbox = new PostgresOutbox(unit, new ProducerExecution(unit)); + using var cancellation = new CancellationTokenSource(); + await cancellation.CancelAsync(); + var enqueue = () => outbox.EnqueueAsync(New<IdentifierEvent>(), cancellation.Token); + + await enqueue.Should().ThrowAsync<OperationCanceledException>(); + _ = unit.DidNotReceive().Connection; + } + + private static T New<T>() 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<Guid> + { + 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<ConverterPayloadEvent> + { + 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/15-event-and-outbox.md b/docs/architecture/15-event-and-outbox.md index 2f1b6d63..a6492cdd 100644 --- a/docs/architecture/15-event-and-outbox.md +++ b/docs/architecture/15-event-and-outbox.md @@ -43,8 +43,10 @@ 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. Runtime implementation is pending. Producer guidance below states -the accepted contract; dispatcher/inbox choices remain P02b-2/P02b-3. +protection. P02b-1 Steps 1/2 implement shared execution and immediate enqueue; +successful-save collection and the first publisher remain Step 3. Producer +guidance below states the accepted contract; 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 @@ -190,7 +192,7 @@ processed by N consumers independently). ## Producer pattern -**Accepted contract, implementation pending — ADR-0057.** In an enrolled +**Immediate enqueue implemented by P02b-1 Step 2 — ADR-0057.** In an enrolled producer invocation on the announced, writable ambient transaction: ```csharp @@ -224,8 +226,10 @@ a matching generation/tenant, and writable non-poisoned state. Provisioning read 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 event declares its partition source as its aggregate identifier or explicit -tenant-wide `TenantId`, in canonical GUID form; enqueue persists that key. The +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 @@ -245,7 +249,8 @@ separately owned delivery question. ### Domain collection and owner completion -The shared SaveChanges interceptor stages source events and promotes only after +**Accepted; implementation remains P02b-1 Step 3.** 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. Failed/canceled saves promote nothing; repeated saves, detach and delete cannot duplicate or lose successful events. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index c5081d49..a1a3ae4f 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -72,7 +72,7 @@ an amendment is not a lifecycle status change. | 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)); 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; five proofs Registered, runtime implementation pending. P02b-3 owns default-initialization subscriptions and Seeder handoff | +| 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; five proofs Registered; Steps 1/2 implement shared execution and immediate enqueue, while collection/publisher and complete proof enforcement remain Steps 3/4. P02b-3 owns default-initialization subscriptions and Seeder handoff | ## Proposed ADRs diff --git a/docs/glossary.md b/docs/glossary.md index 6ee84809..59e70e5e 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -324,6 +324,8 @@ These entries distinguish P02d-2 contracts from P02d-3 internal read contracts: | **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<T>`, same `IInboxGuard`, same tenant-context restoration, same per-partition-key ordering as the durable path. The `OutboxProcessor` is the only sanctioned 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. | | **`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. | diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 634356b5..cd0be362 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -11,7 +11,9 @@ 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. Immediate enqueue, collection and the publisher remain subsequent steps. +verification. Step 2 implements immediate enqueue; its +[record](#p02b-1-step-2--immediate-outbox-production-2026-10-12) owns verification +and pending reviews. Collection and the publisher remain Step 3. **Phase 02d handoff — 2026-10-11.** P02b-0 re-verifies the delivered `/api/v1` public contracts, native HTTPS on retained `*.learnstack.local` seed hosts, @@ -677,11 +679,49 @@ 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) + +**Implementation checkpoint; both independent review rounds pending.** +`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. + ### 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 Step 2 implements `IOutbox` and replaces Packet 3's step-7 shell with +an enrollment/invariant guard. Owner-side domain collection and the first +publisher remain Step 3; 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 diff --git a/docs/standards/20-infrastructure-stack.md b/docs/standards/20-infrastructure-stack.md index 3a916235..f737c59d 100644 --- a/docs/standards/20-infrastructure-stack.md +++ b/docs/standards/20-infrastructure-stack.md @@ -415,7 +415,8 @@ Rules: 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. Runtime producer enforcement remains P02b-1 work. + DDL CHECK. P02b-1 Step 2 implements immediate enqueue admission; successful-save + collection and publisher enforcement remain subsequent steps. - 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; From 37d95bded5810e99e8c8e2f826a929bd4de82732 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 10:51:51 +0300 Subject: [PATCH 45/92] docs(events): reconcile the delivered outbox entry baseline Distinguish the phase-entry inventory from current producer delivery and record the first independent review round without rewriting entry evidence. ADR: 0057 --- docs/roadmap/phase-02b-events-auth.md | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index cd0be362..0ff89331 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -88,10 +88,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. @@ -716,6 +718,21 @@ 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. + ### Durable outbox dispatch P02b-1 Step 2 implements `IOutbox` and replaces Packet 3's step-7 shell with From 937e36a9f96dd30c88eafa6f22814bf6eb3ab447 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 10:59:45 +0300 Subject: [PATCH 46/92] test(kernel): prove enqueue and completion never publish A persisted event with no subscriber cannot reveal accidental delivery. Observe the resolved transport with a positive control and close both independent outbox review rounds against the passing database proof. ADR: 0057 --- CLAUDE.md | 3 +- .../Database/OutboxProducerTests.cs | 48 +++++++++++++++++++ docs/roadmap/phase-02b-events-auth.md | 22 ++++++++- 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 2aea6729..99efc99f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -171,7 +171,8 @@ authorized. Step 1 implements the shared snapshot/producer/audit lifecycle; its 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) -tracks verification and pending reviews. Steps 3/4 remain pending. +records passing verification and both completed review rounds. +Steps 3/4 remain pending. Phase 06 retains the full renderer/Studio expansion. **Phase 01** shipped the .NET 10 solution scaffold under `backend/` diff --git a/backend/tests/LearnStack.Tests.Integration/Database/OutboxProducerTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/OutboxProducerTests.cs index 36f05ea0..5de6a335 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/OutboxProducerTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/OutboxProducerTests.cs @@ -148,6 +148,43 @@ public async Task Both_roots_insert_the_complete_runtime_event_before_business_a 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<Program>().WithWebHostBuilder(builder => + { + builder.UseSetting("ConnectionStrings:Default", database.AppConnectionString); + builder.ConfigureServices(services => + { + services.AddSingleton<ITenantContextAccessor>(new StaticTenantContextAccessor(new ActingContext())); + services.AddSingleton<IEventBus>(transport); + }); + }); + await using var scope = host.Services.CreateAsyncScope(); + var services = scope.ServiceProvider; + services.GetRequiredService<IEventBus>().Should().BeSameAs(transport); + services.GetRequiredService<IOutbox>().Should().BeOfType<PostgresOutbox>(); + var integrationEvent = Event(); + + (await RunAsync(services, async () => + { + await services.GetRequiredService<IOutbox>().EnqueueAsync(integrationEvent); + (await RowInUnitAsync(services.GetRequiredService<IUnitOfWork>(), 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<IEventBus>().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)] @@ -916,6 +953,17 @@ private static IntegrationEventBase BoundaryEvent(string identifierName) 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, diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 0ff89331..c6d37b1a 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -13,7 +13,7 @@ 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 pending reviews. Collection and the publisher remain Step 3. +and both completed review rounds. Collection and the publisher remain Step 3. **Phase 02d handoff — 2026-10-11.** P02b-0 re-verifies the delivered `/api/v1` public contracts, native HTTPS on retained `*.learnstack.local` seed hosts, @@ -683,7 +683,7 @@ collection or completed P02b-1 delivery. ### P02b-1 Step 2 — Immediate outbox production (2026-10-12) -**Implementation checkpoint; both independent review rounds pending.** +**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. @@ -733,6 +733,24 @@ 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. + ### Durable outbox dispatch P02b-1 Step 2 implements `IOutbox` and replaces Packet 3's step-7 shell with From d6fbd8527806f9bf2b95e309c9a502613f8dc9a8 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 11:18:29 +0300 Subject: [PATCH 47/92] feat(kernel): complete saved domain facts before commit Collect facts only after their source save succeeds and retain their invocation origin until physical-owner completion. Translate completed tenant provisioning into one pending outbox row on the same commit as both business roots and MUST audit, with no subscriber delivery yet. ADR: 0057, 0040, 0042, 0044 --- .claude/skills/add-integration-event/SKILL.md | 5 +- CLAUDE.md | 8 +- .../PersistenceCompositionExtensions.cs | 2 + .../Messaging/DomainEventCollector.cs | 178 +++ .../Messaging/ProducerExecution.cs | 133 ++- .../TransactionExecutionCoordinator.cs | 23 +- .../Pipeline/MediatRPipelineRegistration.cs | 1 + .../DomainEventSaveChangesInterceptor.cs | 118 ++ .../ModuleDbContextRegistration.cs | 11 +- .../Domain/IHasDomainEvents.cs | 5 +- .../Identifiers/IGuidFactory.cs | 7 +- .../SeedComposition.cs | 2 + .../TenantCreatedIntegrationEventV1.cs | 14 + .../Tenant/ProvisionTenantCommand.cs | 11 +- .../Tenant/ProvisionTenantCommandHandler.cs | 5 +- .../Tenant/TenantCreatedDomainEventHandler.cs | 24 + .../Events/TenantCreatedDomainEvent.cs | 7 + .../Tenant.cs | 20 +- .../Database/DomainEventProductionTests.cs | 1002 +++++++++++++++++ .../Database/PublicReadTransactionTests.cs | 14 +- .../Database/TenantCreatedProducerTests.cs | 146 +++ .../Database/TenantProvisioningTests.cs | 17 +- .../Database/UnitOfWorkTests.cs | 1 + .../Pipeline/TransactionBehaviorTests.cs | 16 +- .../Tenancy/ProvisionTenantCommandTests.cs | 22 + .../Tenancy/TenantCreatedEventTests.cs | 67 ++ docs/architecture/15-event-and-outbox.md | 16 +- docs/decisions/README.md | 2 +- docs/glossary.md | 7 +- docs/modules/tenancy/README.md | 6 +- docs/modules/tenancy/audit.md | 7 + docs/modules/tenancy/permissions.md | 6 + docs/roadmap/phase-02b-events-auth.md | 58 +- docs/standards/20-infrastructure-stack.md | 5 +- 34 files changed, 1906 insertions(+), 60 deletions(-) create mode 100644 backend/src/LearnStack.Application/Messaging/DomainEventCollector.cs create mode 100644 backend/src/LearnStack.Infrastructure/Messaging/DomainEventSaveChangesInterceptor.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application.Contracts/IntegrationEvents/TenantCreatedIntegrationEventV1.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Tenant/TenantCreatedDomainEventHandler.cs create mode 100644 backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Domain/Events/TenantCreatedDomainEvent.cs create mode 100644 backend/tests/LearnStack.Tests.Integration/Database/DomainEventProductionTests.cs create mode 100644 backend/tests/LearnStack.Tests.Integration/Database/TenantCreatedProducerTests.cs create mode 100644 backend/tests/LearnStack.Tests.Unit/Modules/Tenancy/TenantCreatedEventTests.cs diff --git a/.claude/skills/add-integration-event/SKILL.md b/.claude/skills/add-integration-event/SKILL.md index 96dce227..33f5a395 100644 --- a/.claude/skills/add-integration-event/SKILL.md +++ b/.claude/skills/add-integration-event/SKILL.md @@ -26,8 +26,9 @@ 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 -Step 2 implements immediate enqueue and payload admission; successful-save -collection and the first publisher remain subsequent P02b-1 steps. +Steps 2/3 implement immediate enqueue, payload admission, successful-save +collection and the completed-provisioning publisher. Step 4 owns complete +production censuses and enforcement closeout. P02b-2/P02b-3 retain dispatcher/inbox decisions. ## When to use diff --git a/CLAUDE.md b/CLAUDE.md index 99efc99f..97882c2a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -172,7 +172,10 @@ 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. -Steps 3/4 remain pending. +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) +tracks validation and review status. Step 4 enforcement/closeout remains pending. Phase 06 retains the full renderer/Studio expansion. **Phase 01** shipped the .NET 10 solution scaffold under `backend/` @@ -373,7 +376,8 @@ 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; producer runtime work remains pending. + package is Accepted — 2026-10-12; Steps 1–3 implement the producer runtime, + while Step 4 owns complete enforcement and closeout. Each later packet accepts its own gates before implementation. - [docs/roadmap/phase-02d-walking-skeleton.md](docs/roadmap/phase-02d-walking-skeleton.md) diff --git a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs index 10e65b66..dde24e4c 100644 --- a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs +++ b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs @@ -218,6 +218,8 @@ public static IServiceCollection AddLearnStackPersistence( // idempotence this actually wants. services.TryAddEnumerable( ServiceDescriptor.Scoped<ISaveChangesInterceptor, AuditChangeTrackerInterceptor>()); + services.TryAddEnumerable( + ServiceDescriptor.Scoped<ISaveChangesInterceptor, DomainEventSaveChangesInterceptor>()); // PostgresAuditStore counts the durable-duplicate outcome, so it needs a meter // factory. AddMetrics is idempotent and the API host already calls it through diff --git a/backend/src/LearnStack.Application/Messaging/DomainEventCollector.cs b/backend/src/LearnStack.Application/Messaging/DomainEventCollector.cs new file mode 100644 index 00000000..c4e5f07a --- /dev/null +++ b/backend/src/LearnStack.Application/Messaging/DomainEventCollector.cs @@ -0,0 +1,178 @@ +using LearnStack.SharedKernel.Domain; +using MediatR; + +namespace LearnStack.Application.Messaging; + +/// <summary> +/// Stages saving-source snapshots, promotes successful saves, and retains facts until +/// the physical owner completes. Tracking retention serves cleanup, never save eligibility. +/// </summary> +public sealed class DomainEventCollector(ProducerExecution producer, IPublisher publisher) : IDisposable +{ + /// <summary>Total successful promotions allowed in one physical transaction.</summary> + public const int MaxEventsPerTransaction = 256; + + private readonly HashSet<IHasDomainEvents> _sources = new(ReferenceEqualityComparer.Instance); + private readonly Dictionary<object, List<PendingEvent>> _stages = new(ReferenceEqualityComparer.Instance); + private readonly HashSet<IDomainEvent> _promoted = new(ReferenceEqualityComparer.Instance); + private readonly Queue<PendingEvent> _queue = new(); + private readonly List<Action> _trackers = []; + private bool _draining; + private bool _disposed; + + /// <summary>Registers a context-lifetime observer, retained across transaction-state cleanup.</summary> + public void RegisterTracker(Action observeTrackedSources) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(observeTrackedSources); + _trackers.Add(observeTrackedSources); + } + + /// <summary>Reobserves already tracked sources at owner entry when a DI scope is reused.</summary> + public void RefreshTrackedSources() + { + ObjectDisposedException.ThrowIf(_disposed, this); + foreach (var observe in _trackers) observe(); + } + + /// <summary>Retains even an empty source so later uncollected raises are discarded at termination.</summary> + public void Observe(IHasDomainEvents source) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(source); + _sources.Add(source); + } + + /// <summary> + /// 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. + /// </summary> + public void Stage(object context, IEnumerable<IHasDomainEvents> 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<PendingEvent>(); + 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); + } + + /// <summary>Promotes only a successful, nonzero save; any post-save contract fault poisons the unit.</summary> + 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; + } + } + + /// <summary>Failed/canceled persistence discards only its stage, leaving source facts available to retry.</summary> + public void Abort(object context) + { + ArgumentNullException.ThrowIfNull(context); + _stages.Remove(context); + } + + /// <summary>The sole sequential notification dispatch site, called by the physical owner before audit.</summary> + 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; + } + } + + /// <summary>Owner-terminal cleanup; joiners never invoke it. Safe to repeat at scoped disposal.</summary> + public void Clear() + { + foreach (var source in _sources) source.ClearDomainEvents(); + _sources.Clear(); + _stages.Clear(); + _queue.Clear(); + _promoted.Clear(); + _draining = false; + } + + /// <inheritdoc /> + 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 index d3d59951..3f69e420 100644 --- a/backend/src/LearnStack.Application/Messaging/ProducerExecution.cs +++ b/backend/src/LearnStack.Application/Messaging/ProducerExecution.cs @@ -12,6 +12,7 @@ namespace LearnStack.Application.Messaging; public sealed class ProducerExecution(IUnitOfWork unitOfWork) { private Invocation? _current; + private DomainDispatch? _dispatch; /// <summary>Whether an invocation is open, independently of tenant admission.</summary> public bool HasInvocation => _current is not null; @@ -22,6 +23,12 @@ public sealed class ProducerExecution(IUnitOfWork unitOfWork) /// </summary> 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."); @@ -44,7 +51,7 @@ internal Invocation BeginInvocation(bool isTransactionOwner, UserId? causalActor .SetIdFormat(ActivityIdFormat.W3C).Start(); } - var invocation = new Invocation(this, _current, announcement, hasOwner, ownedActivity, + var invocation = new Invocation(this, _current, announcement, hasOwner, isTransactionOwner, ownedActivity, _current is null ? causalActor : _current.CausalActor); _current = invocation; return invocation; @@ -96,14 +103,81 @@ public IntegrationEventEnvelope CreateEnvelope(IIntegrationEvent integrationEven throw new InvalidOperationException("The event tenant must match the live announcement."); } - var traceparent = Activity.Current?.Id; - if (traceparent is null) + 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, ActorUserId: _current!.CausalActor); + announcement.OrganizationId?.Value, CausationId: _dispatch?.EventId, + ActorUserId: _dispatch is null ? _current!.CausalActor : _dispatch.Origin.CausalActor); + } + + /// <summary>Captures successful-save attribution without retaining ambient Activity state.</summary> + 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); + } + + /// <summary>Validates an origin against this physical transaction and its current owner.</summary> + 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; + } + + /// <summary> + /// Installs dispatch metadata synchronously; does not reopen the originating child + /// or alter AsyncLocal/Activity state across an asynchronous helper boundary. + /// </summary> + 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); } /// <summary>Step 7 checks enrollment and authority without requiring a produced event.</summary> @@ -123,12 +197,56 @@ internal void EnsureCurrentInvocation() public void RecordProducedWork() { _ = RequireAnnouncement(); - for (var invocation = _current; invocation is not null; invocation = invocation.Parent) + RecordReceipt(_dispatch?.Origin.Invocation ?? _current!); + } + + /// <summary>Records a saved fact against its retained invocation, including disposed children.</summary> + 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; } } + /// <summary>Opaque attribution captured at the saving boundary.</summary> + 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; + } + } + /// <summary>One invocation, retained by its coordinator through owner completion.</summary> public sealed class Invocation : IDisposable { @@ -138,12 +256,14 @@ public sealed class Invocation : IDisposable internal Invocation( ProducerExecution execution, Invocation? parent, - TenantContextAnnouncement? announcement, bool hasOwner, Activity? ownedActivity, UserId? causalActor) + TenantContextAnnouncement? announcement, bool hasOwner, bool isTransactionOwner, + Activity? ownedActivity, UserId? causalActor) { _execution = execution; Parent = parent; Announcement = announcement; HasOwner = hasOwner; + IsTransactionOwner = isTransactionOwner; _ownedActivity = ownedActivity; CausalActor = causalActor; } @@ -151,6 +271,7 @@ internal Invocation( internal Invocation? Parent { get; } internal TenantContextAnnouncement? Announcement { get; } internal bool HasOwner { get; } + internal bool IsTransactionOwner { get; } internal UserId? CausalActor { get; } /// <summary>Successful production by this invocation or any descendant.</summary> diff --git a/backend/src/LearnStack.Application/Persistence/TransactionExecutionCoordinator.cs b/backend/src/LearnStack.Application/Persistence/TransactionExecutionCoordinator.cs index 84f16c12..b1844a69 100644 --- a/backend/src/LearnStack.Application/Persistence/TransactionExecutionCoordinator.cs +++ b/backend/src/LearnStack.Application/Persistence/TransactionExecutionCoordinator.cs @@ -18,6 +18,7 @@ public sealed class TransactionExecutionCoordinator( IAuditStore auditStore, IAuditStateCapture capture, ProducerExecution producer, + DomainEventCollector collector, ILogger<TransactionExecutionCoordinator> logger) { /// <summary> @@ -34,7 +35,20 @@ public async Task<TResponse> ExecuteAsync<TResponse>( { ArgumentNullException.ThrowIfNull(next); ArgumentException.ThrowIfNullOrWhiteSpace(operationName); - await using var scope = await unitOfWork.BeginTransactionAsync(mode, cancellationToken); + 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 @@ -80,6 +94,7 @@ await unitOfWork.SetProvisioningTenantContextAsync( "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); } @@ -124,6 +139,12 @@ await unitOfWork.SetProvisioningTenantContextAsync( 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<ILogger, string, Exception?> LogRollbackFailure = diff --git a/backend/src/LearnStack.Application/Pipeline/MediatRPipelineRegistration.cs b/backend/src/LearnStack.Application/Pipeline/MediatRPipelineRegistration.cs index 32f1f646..f13e71d9 100644 --- a/backend/src/LearnStack.Application/Pipeline/MediatRPipelineRegistration.cs +++ b/backend/src/LearnStack.Application/Pipeline/MediatRPipelineRegistration.cs @@ -55,6 +55,7 @@ public static IServiceCollection AddLearnStackMediatRPipeline( // Behaviors adapt requests; direct owners reuse these same coordinators. services.TryAddScoped<AuditExecutionCoordinator>(); services.TryAddScoped<ProducerExecution>(); + services.TryAddScoped<DomainEventCollector>(); services.TryAddScoped<TransactionExecutionCoordinator>(); // MediatR 12.x throws if no assemblies are registered for handler diff --git a/backend/src/LearnStack.Infrastructure/Messaging/DomainEventSaveChangesInterceptor.cs b/backend/src/LearnStack.Infrastructure/Messaging/DomainEventSaveChangesInterceptor.cs new file mode 100644 index 00000000..083f8e72 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Messaging/DomainEventSaveChangesInterceptor.cs @@ -0,0 +1,118 @@ +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; + +/// <summary>Collects domain facts only from successful saves, while retaining tracked sources for terminal cleanup.</summary> +public sealed class DomainEventSaveChangesInterceptor(DomainEventCollector collector) : SaveChangesInterceptor +{ + private readonly ConditionalWeakTable<DbContext, object> _attached = new(); + + /// <summary>Attaches before a context begins tracking so empty and subsequently detached sources remain observable.</summary> + 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<DbContext>(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<IHasDomainEvents>()); + } + + public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result) + { + Stage(eventData.Context, result.HasResult); + return result; + } + + public override ValueTask<InterceptionResult<int>> SavingChangesAsync( + DbContextEventData eventData, InterceptionResult<int> 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<int> 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/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<TContext>(this IServiceColle + "SET LOCAL app.tenant_id."); } + var saveInterceptors = provider.GetServices<ISaveChangesInterceptor>().ToArray(); var options = new DbContextOptionsBuilder<TContext>() // 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<TContext>(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<TContext>(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<ISaveChangesInterceptor>()) + .AddInterceptors(saveInterceptors) .Options; // ActivatorUtilities, not Activator: a module context takes its @@ -175,6 +177,11 @@ public static IServiceCollection AddModuleDbContext<TContext>(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<TContext>(provider, options); + foreach (var interceptor in saveInterceptors.OfType<DomainEventSaveChangesInterceptor>()) + { + interceptor.Attach(context); + } + context.Database.UseTransaction(unitOfWork.Transaction); return context; 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; /// <summary> /// Marker every entity that raises <see cref="IDomainEvent"/> 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. /// </summary> 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; /// <remarks> /// Two minting paths exist per ADR-0031 (PostgreSQL 18) + ADR-0023: /// app-side <see cref="NewUuidV7"/> for aggregates that need the ID before -/// <c>SaveChangesAsync</c>, and DB-side <c>uuidv7()</c> DEFAULT for -/// high-volume append-only tables (<c>audit_log</c>, <c>outbox_messages</c>). -/// This factory covers the app-side path only. +/// <c>SaveChangesAsync</c>, and DB-side <c>uuidv7()</c> 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. /// </remarks> public interface IGuidFactory { diff --git a/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs b/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs index 071080c5..6851d07d 100644 --- a/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs +++ b/backend/src/LearnStack.Tools.Seeder/SeedComposition.cs @@ -159,6 +159,8 @@ public static ServiceProvider Build( // idempotence this actually wants. services.TryAddEnumerable( ServiceDescriptor.Scoped<ISaveChangesInterceptor, AuditChangeTrackerInterceptor>()); + services.TryAddEnumerable( + ServiceDescriptor.Scoped<ISaveChangesInterceptor, DomainEventSaveChangesInterceptor>()); // PostgresAuditStore counts the durable-duplicate outcome, so it needs a meter // factory. AddMetrics is idempotent and the API host already calls it through 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; + +/// <summary>A completed tenant provisioning, ordered deliberately within its tenant.</summary> +[IntegrationEventPartition(nameof(TenantId), tenantWide: true)] +public sealed record TenantCreatedIntegrationEventV1 : IntegrationEventBase +{ + /// <inheritdoc /> + public override string Topic => "learnstack.tenancy.tenant"; + + /// <inheritdoc /> + 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; /// </para> /// <para> /// <b>Anything added to that transaction inherits the assumption, so read this first.</b> -/// 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 <c>pk_tenants</c>. -/// 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. /// </para> /// <para> /// <b><c>[AllowsUnresolvedTenantContext]</c> and not <c>[PublicSurface]</c>.</b> 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<ProvisionTenantCommand, Result<ProvisionedTenantDto>> { public async Task<Result<ProvisionedTenantDto>> Handle( @@ -116,7 +117,7 @@ private async Task<Result<ProvisionedTenantDto>> 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; + +/// <summary>Translates the saved provisioning fact into one pending outbox row.</summary> +internal sealed class TenantCreatedDomainEventHandler(IOutbox outbox, IGuidFactory guids) + : INotificationHandler<TenantCreatedDomainEvent> +{ + 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; + +/// <summary>The tenant's initial default-organization assignment completes provisioning.</summary> +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 <see cref="Create"/> 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 + /// <see cref="TenantCreatedDomainEvent"/>; a later reassignment is not creation. /// </remarks> - 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); + } } /// <summary>Moves the tenant to a new lifecycle state.</summary> 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..7bf10450 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/DomainEventProductionTests.cs @@ -0,0 +1,1002 @@ +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; + +/// <summary> +/// 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. +/// </summary> +[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_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<IUnitOfWork>(); + await SchemaQueries.AssertApplicationRoleAsync(unit.Connection); + var db = services.GetRequiredService<CustomizationDbContext>(); + 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<None>.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<TransactionExecutionCoordinator>(); + var unit = services.GetRequiredService<IUnitOfWork>(); + TenantContentType? source = null; + var ancestors = new List<ProducerExecution.Invocation>(); + 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<Result<None>> 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<CustomizationDbContext>(); + 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<None>.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<InvalidOperationException>().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<CustomizationDbContext>(); + 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<TransactionExecutionCoordinator>().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<CustomizationDbContext>(); + 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<CustomizationDbContext>(); + 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<IUnitOfWork>(); + var coordinator = services.GetRequiredService<TransactionExecutionCoordinator>(); + var fact = Fact(); + ProducerExecution.Invocation? failedInvocation = null; + string? retryTrace = null; + TenantContentType? source = null; + + (await RunAsync(services, async () => + { + var db = services.GetRequiredService<CustomizationDbContext>(); + 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<OperationCanceledException>(); + } + else + { + var save = () => db.SaveChangesAsync(); + (await save.Should().ThrowAsync<DbUpdateException>()).Which.InnerException + .Should().BeOfType<PostgresException>().Which.SqlState.Should().Be(PostgresErrorCodes.UniqueViolation); + } + failedInvocation.HasProducedWork.Should().BeFalse(); + return Result<None>.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<CustomizationDbContext>(); + 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<IUnitOfWork>(); + TenantContentType? source = null; + var observed = false; + var caught = false; + + var run = () => RunAsync(services, async () => + { + source = Source(services); + var db = services.GetRequiredService<CustomizationDbContext>(); + 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<Result<None>> 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<TransactionExecutionCoordinator>().ExecuteAsync( + null, TransactionMode.ReadWrite, "absorbed-overflow", async () => + { + var overflow = OverflowAsync; + await overflow.Should().ThrowAsync<InvalidOperationException>(); + 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<InvalidOperationException>(); + 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<IUnitOfWork>(); + 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<CustomizationDbContext>(); + 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<OperationCanceledException>(); + else await run.Should().ThrowAsync<InvalidOperationException>().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<InvalidOperationException>().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<IUnitOfWork>(); + 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<CustomizationDbContext>(); + 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<IUnitOfWork>(); + 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<CustomizationDbContext>(); + 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<AuditWriteFailedException>()).Which.InnerException + .Should().BeOfType<PostgresException>().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<TransactionExecutionCoordinator>().ExecuteAsync( + null, TransactionMode.ReadWrite, "child-producer", async () => + { + var source = Source(services); + Raise(source, fact); + var db = services.GetRequiredService<CustomizationDbContext>(); + 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<ProbeIntegrationEventV1>(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<CustomizationDbContext>(); + 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<IUnitOfWork>(); + 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<ProducerExecution>(), [true, null])!; + source = Source(services); + var db = services.GetRequiredService<CustomizationDbContext>(); + 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<Program> Host( + string connectionString, DispatchObservation observation, ITenantContext? context = null) => + new WebApplicationFactory<Program>().WithWebHostBuilder(builder => + { + builder.UseSetting("ConnectionStrings:Default", connectionString); + builder.ConfigureServices(services => + { + services.AddSingleton<ITenantContextAccessor>(new StaticTenantContextAccessor(context ?? new ActingContext())); + services.AddScoped<INotificationHandler<ProbeDomainEvent>, ProbeNotificationHandler>(); + services.AddScoped<IOutbox>(provider => new ObservedOutbox( + new PostgresOutbox(provider.GetRequiredService<IUnitOfWork>(), provider.GetRequiredService<ProducerExecution>()), + observation)); + }); + }); + + private static Task<TResponse> RunAsync<TResponse>(IServiceProvider services, Func<Task<TResponse>> body, + bool audited = false, CancellationToken cancellationToken = default) + where TResponse : IResultBase + { + var audit = new AuditExecutionCoordinator(new ProbeCatalog(services.GetRequiredService<IAuditCatalog>()), + services.GetRequiredService<IAuditConfigService>(), services.GetRequiredService<IAuditStateCapture>(), + services.GetRequiredService<IAuditStore>(), services.GetRequiredService<ITenantContext>(), + services.GetRequiredService<IClock>(), services.GetRequiredService<IGuidFactory>(), + NullLogger<AuditExecutionCoordinator>.Instance); + return audit.ExecuteAsync(audited ? typeof(AuditedProbe) : typeof(SilentProbe), null, + () => services.GetRequiredService<TransactionExecutionCoordinator>().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<IClock>(), 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<IClock>(), 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<TenantContentTypeId>).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<ProducerExecution>())!; + + private static void AssertClean(IServiceProvider services) + { + var unit = services.GetRequiredService<IUnitOfWork>(); + unit.Transaction.Should().BeNull(); + unit.GetTenantContextAnnouncement(unit.Transaction).Should().BeNull(); + services.GetRequiredService<ProducerExecution>().HasInvocation.Should().BeFalse(); + var capture = services.GetRequiredService<IAuditStateCapture>(); + capture.HasOpenFrame.Should().BeFalse(); + capture.Intents.Should().BeEmpty(); + capture.Changes.Should().BeEmpty(); + } + + private async Task<DisposableSchemaDatabase> 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<object?> 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<long> 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<long> CountSourceAsync(string connectionString, Guid id) => + SourceScalarAsync(connectionString, "SELECT count(*) FROM tenant_content_types WHERE id = @id", id); + + private static Task<long> VersionAsync(string connectionString, Guid id) => + SourceScalarAsync(connectionString, "SELECT row_version FROM tenant_content_types WHERE id = @id", id); + + private static Task<long> AuditCountAsync(string connectionString) => + SourceScalarAsync(connectionString, "SELECT count(*) FROM audit_log WHERE operation = 'customization.content_type.domain_probe'", Guid.Empty); + + private static async Task<long> 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<long> 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<IReadOnlyList<OutboxRow>> OutboxInUnitAsync(IUnitOfWork unit) => + OutboxAsync((NpgsqlConnection)unit.Connection, (NpgsqlTransaction?)unit.Transaction); + + private static async Task<IReadOnlyList<OutboxRow>> 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<IReadOnlyList<OutboxRow>> 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<OutboxRow>(); + 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<ProbeDomainEvent> + { + 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<ProbeIntegrationEventV1> Events { get; } = []; + public Func<Task>? 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<AuditCatalogEntry> All => actual.All; + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PublicReadTransactionTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PublicReadTransactionTests.cs index d8557a34..136e2ca2 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PublicReadTransactionTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PublicReadTransactionTests.cs @@ -49,9 +49,11 @@ public async Task PublicSurface_Transactions_Refuse_Writes() { var unit = scope.ServiceProvider.GetRequiredService<IUnitOfWork>(); var capture = new AuditStateCapture(); + var producer = new ProducerExecution(unit); var behavior = new TransactionBehavior<PublicProbe, Result<string>>( new TransactionExecutionCoordinator( - unit, Context, Store(scope.ServiceProvider, capture), capture, new ProducerExecution(unit), + unit, Context, Store(scope.ServiceProvider, capture), capture, producer, + new DomainEventCollector(producer, new Mediator(scope.ServiceProvider)), NullLogger<TransactionExecutionCoordinator>.Instance)); var reached = false; var attempt = async () => await behavior.Handle(new PublicProbe(), async () => @@ -142,15 +144,17 @@ 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<Task> join = innerMode == TransactionMode.ReadOnly ? async () => await new TransactionBehavior<PublicProbe, Result<string>>( new TransactionExecutionCoordinator( - unit, Context, store, capture, new ProducerExecution(unit), + unit, Context, store, capture, producer, collector, NullLogger<TransactionExecutionCoordinator>.Instance)) .Handle(new PublicProbe(), next, default) : async () => await new TransactionBehavior<WritableProbe, Result<string>>( new TransactionExecutionCoordinator( - unit, Context, store, capture, new ProducerExecution(unit), + unit, Context, store, capture, producer, collector, NullLogger<TransactionExecutionCoordinator>.Instance)) .Handle(new WritableProbe(), next, default); await join.Should().ThrowAsync<InvalidOperationException>().WithMessage("*mixed-mode*"); @@ -210,9 +214,11 @@ public async Task Cancelled_handler_rolls_back_and_pooled_next_scope_is_writable { var unit = scope.ServiceProvider.GetRequiredService<IUnitOfWork>(); var capture = new AuditStateCapture(); + var producer = new ProducerExecution(unit); var behavior = new TransactionBehavior<PublicProbe, Result<string>>( new TransactionExecutionCoordinator( - unit, Context, Store(scope.ServiceProvider, capture), capture, new ProducerExecution(unit), + unit, Context, Store(scope.ServiceProvider, capture), capture, producer, + new DomainEventCollector(producer, new Mediator(scope.ServiceProvider)), NullLogger<TransactionExecutionCoordinator>.Instance)); var attempt = async () => await behavior.Handle(new PublicProbe(), () => { 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; + +/// <summary>Completed provisioning produces one pending fact and two MUST rows, on both real roots.</summary> +[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<Program>().WithWebHostBuilder(builder => + { + builder.UseSetting("ConnectionStrings:Default", database.AppConnectionString); + builder.ConfigureServices(services => services.AddSingleton<ITenantContextAccessor>( + 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<IOutbox>().Should().BeOfType<PostgresOutbox>(); + services.GetRequiredService<ITenantContext>().IsResolved.Should().BeFalse(); + + var send = () => services.GetRequiredService<ISender>().Send(command); + if (refuseAudit) + { + (await send.Should().ThrowAsync<AuditWriteFailedException>()).Which.Error.Code.Should().Be("audit_unavailable"); + } + else + { + var result = await send(); + result.IsSuccess.Should().BeTrue(); + result.Value!.DefaultOrganizationId.Should().Be(organization); + } + + services.GetRequiredService<IUnitOfWork>().Transaction.Should().BeNull(); + services.GetRequiredService<ProducerExecution>().HasInvocation.Should().BeFalse(); + services.GetRequiredService<IAuditStateCapture>().Intents.Should().BeEmpty(); + var tracked = services.GetRequiredService<TenancyDbContext>().ChangeTracker.Entries() + .Select(entry => entry.Entity).OfType<LearnStack.SharedKernel.Domain.IHasDomainEvents>().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<TenantCreatedIntegrationEventV1>( + row.GetString(9), IntegrationEventBase.PayloadJsonOptions)!; + decoded.EventId.Should().Be(eventId); + decoded.TenantId.Should().Be(tenant.Value); + decoded.OccurredAt.Should().BeCloseTo(row.GetFieldValue<DateTimeOffset>(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<object?> 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 cbcf886e..47ad6bab 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/TenantProvisioningTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantProvisioningTests.cs @@ -6,6 +6,7 @@ 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; @@ -13,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; @@ -304,7 +307,9 @@ public async Task A_request_that_does_not_provision_still_fails_closed_when_unre var behavior = new TransactionBehavior<Probe, Result<string>>( new TransactionExecutionCoordinator( unitOfWork, UnresolvedTenantContext.Instance, - new NoOpAuditStore(), new AuditStateCapture(), new ProducerExecution(unitOfWork), + new NoOpAuditStore(), new AuditStateCapture(), + scope.ServiceProvider.GetRequiredService<ProducerExecution>(), + scope.ServiceProvider.GetRequiredService<DomainEventCollector>(), NullLogger<TransactionExecutionCoordinator>.Instance)); var write = () => behavior.Handle( @@ -511,7 +516,9 @@ private static async Task<Result<ProvisionedTenantDto>> ProvisionAsync( var behavior = new TransactionBehavior<ProvisionTenantCommand, Result<ProvisionedTenantDto>>( new TransactionExecutionCoordinator( unit, context ?? UnresolvedTenantContext.Instance, - new NoOpAuditStore(), new AuditStateCapture(), new ProducerExecution(unit), + new NoOpAuditStore(), new AuditStateCapture(), + services.GetRequiredService<ProducerExecution>(), + services.GetRequiredService<DomainEventCollector>(), NullLogger<TransactionExecutionCoordinator>.Instance)); return await behavior.Handle( @@ -541,10 +548,15 @@ private ServiceProvider BuildProvider(NpgsqlDataSource? dataSource = null) sp.GetRequiredService<ITenantContextAccessor>().Current ?? UnresolvedTenantContext.Instance); services.AddScoped<IUnitOfWork, NpgsqlUnitOfWork>(); + services.AddScoped<ProducerExecution>(); + services.AddScoped<DomainEventCollector>(); + services.AddScoped<ISaveChangesInterceptor, DomainEventSaveChangesInterceptor>(); + services.AddScoped<IOutbox, PostgresOutbox>(); services.AddModuleDbContext<TenancyDbContext>(); services.AddScoped<ITenantWriteStore, TenantWriteStore>(); services.AddScoped<IOrganizationWriteStore, OrganizationWriteStore>(); services.AddSingleton<IClock>(Clock); + services.AddSingleton<IGuidFactory, SystemGuidFactory>(); 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 6c79644f..2046d5d5 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/UnitOfWorkTests.cs @@ -921,6 +921,7 @@ public async Task An_absorbed_inner_failure_still_commits_the_outer_work() new NoOpAuditStore(), capture, producer, + new DomainEventCollector(producer, new MediatR.Mediator(scope.ServiceProvider)), NullLogger<TransactionExecutionCoordinator>.Instance); var outer = new TransactionBehavior<Probe, Result<string>>(coordinator); var inner = new TransactionBehavior<Probe, Result<string>>(coordinator); diff --git a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs index b5221fc8..85a01d71 100644 --- a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs @@ -12,6 +12,7 @@ using LearnStack.SharedKernel.Tenancy; using MediatR; using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; using Xunit; namespace LearnStack.Tests.Unit.Application.Pipeline; @@ -68,6 +69,7 @@ public async Task Step_seven_preserves_event_free_enrollment_and_refuses_ended_a var producer = new ProducerExecution(unit); var coordinator = new TransactionExecutionCoordinator(unit, UnresolvedTenantContext.Instance, new RecordingAuditStore(), new AuditStateCapture(), producer, + new DomainEventCollector(producer, Substitute.For<IPublisher>()), NullLogger<TransactionExecutionCoordinator>.Instance); var guard = new OutboxFlushBehavior<DummyCommand, Result<string>>(producer); var reached = false; @@ -94,10 +96,12 @@ await run.Should().ThrowAsync<InvalidOperationException>() 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<PublicQuery, Result<string>>( new TransactionExecutionCoordinator( unitOfWork, UnresolvedTenantContext.Instance, new RecordingAuditStore(), - new AuditStateCapture(), new ProducerExecution(unitOfWork), + new AuditStateCapture(), producer, + new DomainEventCollector(producer, Substitute.For<IPublisher>()), NullLogger<TransactionExecutionCoordinator>.Instance)); var result = await behavior.Handle(new PublicQuery(), () => @@ -489,14 +493,18 @@ await Build(unitOfWork, store).Handle( private static TransactionBehavior<DummyCommand, Result<string>> Build( IUnitOfWork unitOfWork, RecordingAuditStore? store = null, - AuditStateCapture? capture = null) => - new(new TransactionExecutionCoordinator( + AuditStateCapture? capture = null) + { + var producer = new ProducerExecution(unitOfWork); + return new(new TransactionExecutionCoordinator( unitOfWork, UnresolvedTenantContext.Instance, store ?? new RecordingAuditStore(), capture ?? new AuditStateCapture(), - new ProducerExecution(unitOfWork), + producer, + new DomainEventCollector(producer, Substitute.For<IPublisher>()), NullLogger<TransactionExecutionCoordinator>.Instance)); + } private static Task<Result<string>> Next(RecordingUnitOfWork unitOfWork, Result<string> result) { 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<TenantCreatedDomainEvent>().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<ITenantWriteStore>(recording); services.AddSingleton<IOrganizationWriteStore>(recording); services.AddSingleton<IClock>(Clock); + services.AddSingleton<IGuidFactory, SystemGuidFactory>(); return services.BuildServiceProvider(); } @@ -431,6 +449,8 @@ private sealed class RecordingStores : ITenantWriteStore, IOrganizationWriteStor public List<TenantAggregate> Updated { get; } = []; + public List<int> TenantEventCounts { get; } = []; + public Task<TenantAggregate?> FindAsync(TenantId id, CancellationToken cancellationToken = default) => throw new InvalidOperationException("Provisioning does not read a tenant."); @@ -438,6 +458,7 @@ Task IAggregateWriteStore<TenantAggregate, TenantId>.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<TenantAggregate, TenantId>.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<TenantCreatedDomainEvent>().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<InvalidOperationException>(); + else assign.Should().Throw<ArgumentException>(); + 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/docs/architecture/15-event-and-outbox.md b/docs/architecture/15-event-and-outbox.md index a6492cdd..14e2174b 100644 --- a/docs/architecture/15-event-and-outbox.md +++ b/docs/architecture/15-event-and-outbox.md @@ -43,10 +43,10 @@ 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/2 implement shared execution and immediate enqueue; -successful-save collection and the first publisher remain Step 3. Producer -guidance below states the accepted contract; dispatcher/inbox choices remain -P02b-2/P02b-3. +protection. P02b-1 Steps 1–3 implement shared execution, immediate enqueue, +successful-save collection and the completed-provisioning publisher. Step 4 owns +the complete production censuses and enforcement closeout. 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 @@ -249,11 +249,13 @@ separately owned delivery question. ### Domain collection and owner completion -**Accepted; implementation remains P02b-1 Step 3.** The shared SaveChanges +**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. Failed/canceled saves promote nothing; -repeated saves, detach and delete cannot duplicate or lose successful events. +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. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index a1a3ae4f..1ce22e7d 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -72,7 +72,7 @@ an amendment is not a lifecycle status change. | 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)); 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; five proofs Registered; Steps 1/2 implement shared execution and immediate enqueue, while collection/publisher and complete proof enforcement remain Steps 3/4. P02b-3 owns default-initialization subscriptions and Seeder handoff | +| 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; five proofs Registered; Steps 1–3 implement shared execution, immediate enqueue, successful-save collection and the publisher. Step 4 owns complete proof enforcement. P02b-3 owns default-initialization subscriptions and Seeder handoff | ## Proposed ADRs diff --git a/docs/glossary.md b/docs/glossary.md index 59e70e5e..8de5c11f 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -114,9 +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 Step 1, including nested ancestry, sticky produced-work receipts and no-listener W3C Activity. Receipts are distinct from transaction and audit ownership. Actual outbox INSERT and successful-save promotion remain subsequent steps under [ADR-0057](decisions/0057-transactional-event-production-and-completion.md). | +| **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** | Accepted 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 Step 1 implements the reader/generation lifecycle under [ADR-0057](decisions/0057-transactional-event-production-and-completion.md); outbox admission and collection remain subsequent steps. | +| **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. | @@ -302,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<Guid>]`). Audit columns on `AuditableEntity<TId>` 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<TId>` / `AuditableEntity<TId>`** | The two aggregate bases in `LearnStack.SharedKernel.Domain`. `Entity<TId>` 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<TId>` 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<TId>` — never `AuditableEntity<TId>` — 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<T>` / `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<T>(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<Guid>(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. | diff --git a/docs/modules/tenancy/README.md b/docs/modules/tenancy/README.md index 8053b9fc..813ac087 100644 --- a/docs/modules/tenancy/README.md +++ b/docs/modules/tenancy/README.md @@ -484,9 +484,11 @@ never a cross-module join ## Integration-event catalogue -**No runtime publisher yet.** Accepted +**Completed-provisioning publisher implemented by P02b-1 Step 3.** Accepted [ADR-0057](../../decisions/0057-transactional-event-production-and-completion.md#first-useful-production-flow) -authorizes P02b-1's completed-provisioning publisher. It emits +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 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/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index c6d37b1a..86da2486 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -13,7 +13,9 @@ 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. Collection and the publisher remain Step 3. +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) +tracks verification and review status. Step 4 enforcement/closeout remains pending. **Phase 02d handoff — 2026-10-11.** P02b-0 re-verifies the delivered `/api/v1` public contracts, native HTTPS on retained `*.learnstack.local` seed hosts, @@ -530,7 +532,7 @@ 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. -Runtime implementation is pending; dispatcher claims and delivery belong to +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 @@ -751,11 +753,55 @@ 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 pending.** + +- 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. + ### Durable outbox dispatch -P02b-1 Step 2 implements `IOutbox` and replaces Packet 3's step-7 shell with -an enrollment/invariant guard. Owner-side domain collection and the first -publisher remain Step 3; dispatch and consumer delivery remain P02b-2/P02b-3. +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** @@ -836,7 +882,7 @@ defined row order. The completion criterion follows the gate, and #### Domain events collected and dispatched -**Accepted, implementation pending — ADR-0057.** The shared SaveChanges +**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; diff --git a/docs/standards/20-infrastructure-stack.md b/docs/standards/20-infrastructure-stack.md index f737c59d..5c5f11c9 100644 --- a/docs/standards/20-infrastructure-stack.md +++ b/docs/standards/20-infrastructure-stack.md @@ -415,8 +415,9 @@ Rules: 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 Step 2 implements immediate enqueue admission; successful-save - collection and publisher enforcement remain subsequent steps. + DDL CHECK. P02b-1 Steps 2/3 implement immediate enqueue, successful-save + collection and the completed-provisioning publisher; Step 4 owns the complete + production censuses and enforcement closeout. - 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; From 3473843431ecc11f7d2c3b69051d6851147271cc Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 11:29:30 +0300 Subject: [PATCH 48/92] docs(events): close domain completion review rounds Record two fresh independent reviews and their focused passing repeats before starting the producer enforcement step. ADR: 0057 --- docs/roadmap/phase-02b-events-auth.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 86da2486..6ab6becd 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -755,7 +755,7 @@ 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 pending.** +**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, @@ -797,6 +797,22 @@ 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. + ### Durable outbox dispatch P02b-1 Steps 2/3 implement `IOutbox`, replace Packet 3's step-7 shell with From b6e3932025a005af80e4419a2111d02f7e440d81 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 11:54:17 +0300 Subject: [PATCH 49/92] test(events): enforce producer contracts across production code Keep producer guarantees measurable across complete assembly censuses and transitive helper paths, with deliberate dirty and clean controls. Align the catalogue and authoring guidance with delivered producer capabilities while retaining dispatcher and consumer ownership in their named packets. ADR: 0057 Module: Tenancy, Customization, Audit --- .claude/skills/add-architecture-test/SKILL.md | 10 +- .claude/skills/add-integration-event/SKILL.md | 19 +- .claude/skills/code-review/SKILL.md | 4 +- CLAUDE.md | 7 +- README.md | 6 +- .../CrossCuttingFoundationTests.cs | 38 +- .../DomainEventHandlerTests.cs | 584 ++++++++++++++++++ .../IntegrationEventContractTests.cs | 503 +++++++++++++++ .../OutboxProducerConventionTests.cs | 204 ++++++ .../Database/AuditCaptureWiringTests.cs | 2 + .../Database/EducationPersistenceTests.cs | 2 + docs/architecture/15-event-and-outbox.md | 11 +- ...ctional-event-production-and-completion.md | 20 + docs/decisions/README.md | 2 +- docs/roadmap/README.md | 7 +- docs/roadmap/phase-02b-events-auth.md | 71 ++- docs/standards/02-backend-coding.md | 2 +- docs/standards/05-database.md | 5 +- docs/standards/10-observability.md | 3 +- docs/standards/20-infrastructure-stack.md | 6 +- .../21-architecture-tests-catalogue.md | 50 +- 21 files changed, 1474 insertions(+), 82 deletions(-) create mode 100644 backend/tests/LearnStack.Tests.Architecture/DomainEventHandlerTests.cs create mode 100644 backend/tests/LearnStack.Tests.Architecture/IntegrationEventContractTests.cs create mode 100644 backend/tests/LearnStack.Tests.Architecture/OutboxProducerConventionTests.cs 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 33f5a395..bb7f6bf0 100644 --- a/.claude/skills/add-integration-event/SKILL.md +++ b/.claude/skills/add-integration-event/SKILL.md @@ -27,8 +27,10 @@ The binding port and envelope contract is 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 owns complete -production censuses and enforcement closeout. +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 @@ -275,7 +277,7 @@ remains identical across transports. ### Step 6: Tests -Follow the canonical Registered obligations in +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 @@ -292,9 +294,9 @@ byte boundaries, SQL NULL metadata and terminal reuse. For delivered flows: - `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 @@ -307,8 +309,9 @@ byte boundaries, SQL NULL metadata and terminal reuse. For delivered flows: - **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` through the enrolled ambient `IUnitOfWork`; never `new TransactionScope`. - **Supplying a second topic at enqueue or subscription time.** The concrete diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md index d6df9e0c..af20dc97 100644 --- a/.claude/skills/code-review/SKILL.md +++ b/.claude/skills/code-review/SKILL.md @@ -300,8 +300,8 @@ Author intent: <one-paragraph restatement> `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. - Five canonical proof obligations stay Registered until implemented; require - production censuses and planted offenders rather than zero-subject passes. + 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 97882c2a..c145bdb2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -175,7 +175,10 @@ 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) -tracks validation and review status. Step 4 enforcement/closeout remains pending. +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) +tracks final validation and review status. Phase 06 retains the full renderer/Studio expansion. **Phase 01** shipped the .NET 10 solution scaffold under `backend/` @@ -377,7 +380,7 @@ 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, - while Step 4 owns complete enforcement and closeout. + 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) diff --git a/README.md b/README.md index f5c8b4c3..a35bf8d2 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,8 @@ items, rules, custom fields and notification templates; their delivery is tracke **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's producer decision pass is next, before implementation. +is complete. P02b-1's decision package and ADR-0057 are Accepted; its producer +runtime is implemented, and enforcement/closeout is in progress. [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, @@ -117,7 +118,8 @@ proof pass real CI; the seventh required context is active. The owns CI enforcement, reviews and the positive phase-exit verdict. [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 is complete; P02b-1's decision pass is next. The +owns final verification. P02b-0 is complete; P02b-1 implements transactional +event production. Its enforcement/closeout is in progress. 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 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..68089054 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/DomainEventHandlerTests.cs @@ -0,0 +1,584 @@ +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), + ]; + + /// <summary> + /// <see href="../../../docs/decisions/0057-transactional-event-production-and-completion.md#collection-completion-and-nested-refusals">ADR-0057</see>: + /// domain reactions translate facts to outbox messages; they cannot start another effect. + /// </summary> + [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<T> 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<Type, string> ForbiddenDependencies + { + get + { + var cases = new TheoryData<Type, string> + { + { typeof(ProbeEntity), "entity/root" }, + { typeof(IAggregateRoot<TenantId>), "entity/root" }, + { typeof(IHasDomainEvents), "entity/root" }, + { typeof(IAggregateWriteStore<ProbeEntity, TenantId>), "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<Type, bool> HelperPaths => new() + { + { typeof(DirectHelper), true }, { typeof(IndirectHelper), true }, { typeof(IHelper), true }, + { typeof(InheritedHelper), true }, { typeof(InheritedDependencyHelper), true }, { typeof(IGenericHelper<ProbeDomainEvent>), true }, + { typeof(AsyncHelper), true }, { typeof(LambdaHelper), true }, + { typeof(PureHelper), false }, { typeof(PureIndirectHelper), false }, { typeof(IPureHelper), false }, + { typeof(PureInheritedHelper), false }, { typeof(IGenericPureHelper<ProbeDomainEvent>), false }, + { typeof(PureAsyncHelper), false }, { typeof(PureLambdaHelper), 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<ProbeDomainEvent>)) + : 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<ModuleDefinition> ReadProductionModules() + { + var modules = new List<ModuleDefinition>(); + 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<ModuleDefinition> 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<TypeDefinition> roots, TypeGraph graph) + { + var offenders = new SortedSet<string>(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<MethodReference>()) + { + 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.IsInterface) + { + 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<ModuleDefinition> modules) + { + _types = modules.SelectMany(module => module.GetTypes()).ToDictionary(Key); + // Match generic interface definitions conservatively: all realizations are + // possible targets. Inherited interfaces and inherited implementations count. + _implementations = _types.Values.Where(type => !type.IsInterface) + .SelectMany(type => Hierarchy(type).Where(contract => Resolve(contract)?.IsInterface == true) + .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<GenericInstanceType>().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<TypeReference> Hierarchy(TypeReference reference) + { + var queue = new Queue<TypeReference>(); + var visited = new HashSet<string>(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<T> : INotificationHandler<T> where T : IDomainEvent + { + public Task Handle(T notification, CancellationToken cancellationToken) => Task.CompletedTask; + } + + private abstract class NotificationHandler<T> : INotificationHandler<T> where T : INotification + { + public Task Handle(T notification, CancellationToken cancellationToken) => Task.CompletedTask; + } + + private sealed class InheritedHandler : NotificationHandler<ProbeDomainEvent>; + + private sealed class OtherNotificationHandler : INotificationHandler<OtherNotification> + { + 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<ProbeDomainEvent> + { + 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<T>; + private abstract class GenericPureHelperBase<T> : IGenericPureHelper<T>; + private sealed class GenericPureHelper : GenericPureHelperBase<ProbeDomainEvent>; + + private static class PureAsyncHelper + { + public static async Task<object> Read() + { + await Task.Yield(); + return "fact"; + } + } + + private static class PureLambdaHelper + { + public static Func<object> 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<TenantId>, IAggregateRoot<TenantId>; + + 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<T>; + private abstract class GenericHelperBase<T> : IGenericHelper<T>; + + private sealed class GenericHelper : GenericHelperBase<ProbeDomainEvent> + { + public IUnitOfWork? Unit { get; } + } + + private static class AsyncHelper + { + public static async Task<object> Read() + { + await Task.Yield(); + return typeof(IUnitOfWork); + } + } + + private static class LambdaHelper + { + public static Func<object> 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; + +/// <summary>Production event contracts under ADR-0057, independent of enqueue's admission validator.</summary> +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), + ]; + + /// <summary>Standards 21 and ADR-0006: every production fact uses the common record contract.</summary> + [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"); + } + + /// <summary>ADR-0057: declare and derive canonical GUID partition keys for every concrete production event.</summary> + [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"); + } + + /// <summary>ADR-0057: a closed identifier-only shape and concrete-runtime JSON round-trip across production.</summary> + [Fact] + public void Integration_Event_Payloads_Contain_Only_Identifiers() + { + var events = ProductionEvents(); + var violations = events.SelectMany(PayloadShapeViolations).ToList(); + violations.AddRange(ControlViolations(PayloadShapeViolations, + [typeof(PayloadFact<string>), typeof(PayloadFact<object>), typeof(PayloadFact<List<Guid>>), + typeof(PayloadFact<Guid[]>), typeof(PayloadFact<byte[]>), typeof(PayloadFact<JsonElement>), + typeof(PayloadFact<UnapprovedId>), 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<string> 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("<Clone>$", BindingFlags.Instance | BindingFlags.Public)?.ReturnType == type; + + private static IEnumerable<Type> EventHierarchy(Type type) + { + for (var current = type; current is not null && current != typeof(IntegrationEventBase); current = current.BaseType) + yield return current; + } + + private static IEnumerable<string> 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<string>(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<string> PartitionViolations(Type type) + { + var prefix = $"{type.FullName}: "; + var declaration = type.GetCustomAttribute<IntegrationEventPartitionAttribute>(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<Guid> id when id.IsInitialized() => id.Value, + _ => null, + }; + + private static IEnumerable<string> PayloadRoundTripViolations(Type type) + { + if (PayloadShapeViolations(type).Any()) yield break; + for (var variation = 0; variation < 2; variation++) + { + var failures = new List<string>(); + 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<string> ControlViolations(Func<Type, IEnumerable<string>> 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<string> 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<T> : AggregateFact + { + public T Data { get; init; } = default!; + } + private sealed record InheritedTextFact : PayloadFact<string>; + private readonly record struct UnapprovedId(Guid Value) : IStronglyTypedId<Guid> + { + 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<Guid>().GetEnumerator(); + } + + [JsonConverter(typeof(FactConverter))] + private sealed record ConvertedFact : AggregateFact; + private sealed class FactConverter : JsonConverter<ConvertedFact> + { + 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..572942cd --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/OutboxProducerConventionTests.cs @@ -0,0 +1,204 @@ +using FluentAssertions; +using LearnStack.Infrastructure.Messaging; +using LearnStack.SharedKernel.Messaging; +using Mono.Cecil; +using Xunit; + +namespace LearnStack.Tests.Architecture; + +/// <summary>ADR-0057: enqueue borrows the admitted unit; only its owner commits.</summary> +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) }) + { + Offenders([module.GetType(probe.FullName!.Replace('+', '/'))], types).Should().NotBeEmpty( + $"Fix: the same producer walk must reject {probe.Name}"); + } + Offenders([module.GetType(typeof(CleanProbe).FullName!.Replace('+', '/'))], types).Should().BeEmpty(); + } + + [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<ModuleDefinition> ReadProductionModules() + { + var modules = new List<ModuleDefinition>(); + 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<ModuleDefinition> 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<string> Offenders(IEnumerable<TypeDefinition> roots, + Dictionary<(string Assembly, string Type), TypeDefinition> types) + { + var visited = new HashSet<(string Assembly, string Type)>(); + var offenders = new SortedSet<string>(StringComparer.Ordinal); + var queue = new Queue<TypeDefinition>(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.IsInterface) + { + 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<TypeReference>(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 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.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<ISaveChangesInterceptor, HarmlessInterceptor>(); services.AddLogging(); + services.AddLearnStackMediatRPipeline(); services.AddSingleton<ITenantContextAccessor>( new StaticTenantContextAccessor(new WiringContext())); services.AddTransient<ITenantContext>(sp => 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<ITenantContextAccessor>(new StaticTenantContextAccessor(tenant)); services.AddTransient<ITenantContext>(provider => diff --git a/docs/architecture/15-event-and-outbox.md b/docs/architecture/15-event-and-outbox.md index 14e2174b..15ea585c 100644 --- a/docs/architecture/15-event-and-outbox.md +++ b/docs/architecture/15-event-and-outbox.md @@ -26,8 +26,9 @@ 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<TId>` 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.** It already supplies the handler @@ -44,8 +45,10 @@ The [P02b-1 decision package](../roadmap/phase-02b-events-auth.md#p02b-1-decisio 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 owns -the complete production censuses and enforcement closeout. Dispatcher/inbox +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 diff --git a/docs/decisions/0057-transactional-event-production-and-completion.md b/docs/decisions/0057-transactional-event-production-and-completion.md index d85858fa..97516744 100644 --- a/docs/decisions/0057-transactional-event-production-and-completion.md +++ b/docs/decisions/0057-transactional-event-production-and-completion.md @@ -360,6 +360,26 @@ cover obligations those entries do not already cover: `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. + ## References - [ADR-0006: Events and Outbox](0006-events-and-outbox.md) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 1ce22e7d..3f92c63d 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -72,7 +72,7 @@ an amendment is not a lifecycle status change. | 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)); 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; five proofs Registered; Steps 1–3 implement shared execution, immediate enqueue, successful-save collection and the publisher. Step 4 owns complete proof enforcement. P02b-3 owns default-initialization subscriptions and Seeder handoff | +| 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 | ## Proposed ADRs diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 333f4549..56bcb650 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -93,9 +93,12 @@ not deferred to the showcase phase. [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 complete; next: P02b-1 producer decision pass**. Its + — **P02b-0 complete; P02b-1 producer enforcement in progress**. Its [entry audit](phase-02b-events-auth.md#p02b-0-entry-verification-2026-10-12) - re-verifies the register against code, corpus and Hub; runtime work has not started. + 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. + 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 6ab6becd..4575f1bc 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -15,7 +15,10 @@ 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) -tracks verification and review status. Step 4 enforcement/closeout remains pending. +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) tracks final +verification and review status. **Phase 02d handoff — 2026-10-11.** P02b-0 re-verifies the delivered `/api/v1` public contracts, native HTTPS on retained `*.learnstack.local` seed hosts, @@ -430,7 +433,9 @@ post-merge CI receipt above owns that inherited evidence. 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 -partial runtime delivery; acceptance alone closes no implementation step. +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 @@ -813,6 +818,44 @@ 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) + +**Implementation complete; full validation and independent reviews in progress.** +Work remains on `development`. This checkpoint does not claim PR approval, +required CI success or merge. + +- `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; all +1,764 unit, 261 architecture and four contract cases pass with zero +failures/skips. The 59 focused producer-guard cases overlap the architecture +total. The full integration run is in progress. It found two older test setups +that called persistence registration without the application pipeline, leaving +the new collector dependency absent; both fixtures now install the same shared +application registration as production. No runtime policy was weakened. + ### Durable outbox dispatch P02b-1 Steps 2/3 implement `IOutbox`, replace Packet 3's step-7 shell with @@ -1381,25 +1424,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 @@ -1454,8 +1497,8 @@ command. The phase retains zero skips, 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. +- `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. diff --git a/docs/standards/02-backend-coding.md b/docs/standards/02-backend-coding.md index 2a98dea7..eb528512 100644 --- a/docs/standards/02-backend-coding.md +++ b/docs/standards/02-backend-coding.md @@ -234,7 +234,7 @@ Rules: 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 collection. Runtime collection/dispatch remains P02b-1 work. + 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 diff --git a/docs/standards/05-database.md b/docs/standards/05-database.md index ffd3602d..c9b7ac22 100644 --- a/docs/standards/05-database.md +++ b/docs/standards/05-database.md @@ -1453,8 +1453,9 @@ 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 implementation is -pending; type-resolution outcomes, leases and inbox stay P02b-2/P02b-3 work. +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 — diff --git a/docs/standards/10-observability.md b/docs/standards/10-observability.md index 2599efbd..c1883972 100644 --- a/docs/standards/10-observability.md +++ b/docs/standards/10-observability.md @@ -86,7 +86,8 @@ 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. -Runtime producer enforcement is pending P02b-1. +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; diff --git a/docs/standards/20-infrastructure-stack.md b/docs/standards/20-infrastructure-stack.md index 5c5f11c9..5f29c25b 100644 --- a/docs/standards/20-infrastructure-stack.md +++ b/docs/standards/20-infrastructure-stack.md @@ -416,8 +416,10 @@ Rules: 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 owns the complete - production censuses and enforcement closeout. + 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; diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index a52e7c7c..323f174a 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 +**172 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. @@ -3121,7 +3123,8 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). [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.** +- **Status:** **Implemented** (`OutboxProducerTests`, `TenantCreatedProducerTests`); + actual stored values and unresolved provisioning run as `learnstack_app`. - **Phase:** 02b, P02b-1. #### `Hangfire_Job_Payloads_Include_TenantId` @@ -3168,7 +3171,8 @@ 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` @@ -3185,7 +3189,9 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.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:** **Registered.** +- **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` @@ -3203,7 +3209,10 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). - **Source:** [ADR-0057](../decisions/0057-transactional-event-production-and-completion.md). - **Type:** integration test (Testcontainers), with focused lifecycle unit cases. **Kind:** behavioural. -- **Status:** **Registered** — implementation pending. +- **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` @@ -3217,7 +3226,9 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). 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:** **Registered** — implementation pending. +- **Status:** **Implemented** (`ProducerExecutionTests`, `OutboxProducerTests`, + `DomainEventProductionTests`); actual INSERT/save receipts, every ancestor, event-free + controls and terminal reuse. - **Phase:** 02b, P02b-1. #### `Domain_Event_Completion_Is_Owner_Only` @@ -3232,7 +3243,9 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). 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:** **Registered** — implementation pending. +- **Status:** **Implemented** (`DomainEventProductionTests`, + `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` @@ -3244,7 +3257,10 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). 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:** **Registered** — implementation pending. +- **Status:** **Implemented** (`DomainEventHandlerTests`); assembly-scoped transitive + helper/interface 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` @@ -3259,7 +3275,8 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). - **Source:** [ADR-0057](../decisions/0057-transactional-event-production-and-completion.md). - **Type:** xUnit + reflection/IL/source scan and serialization probes. **Kind:** structural + behavioural. -- **Status:** **Registered** — implementation pending. +- **Status:** **Implemented** (`IntegrationEventContractTests`); independent reflection + checks, forbidden serialization/member controls and concrete runtime JSON probes. - **Phase:** 02b, P02b-1. #### `OutboxProcessor_NeverBlocks_OnSingleMessageFailure` @@ -3339,9 +3356,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 From 98c22a8f76583931761e19839a66289307a4dfd8 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 12:07:55 +0300 Subject: [PATCH 50/92] test(events): cover abstract and virtual helper dispatch Expand the producer and domain-handler walks to derived implementations so normal class dispatch cannot bypass their capability checks. Plant dirty and clean class paths and verify the interface-only regression fails. Reconcile the lease migration's existing-row premise. ADR: 0057 --- .../DomainEventHandlerTests.cs | 32 +++++++++++-- .../OutboxProducerConventionTests.cs | 21 +++++++-- docs/roadmap/phase-02b-events-auth.md | 45 +++++++++++++++---- .../21-architecture-tests-catalogue.md | 6 ++- 4 files changed, 86 insertions(+), 18 deletions(-) diff --git a/backend/tests/LearnStack.Tests.Architecture/DomainEventHandlerTests.cs b/backend/tests/LearnStack.Tests.Architecture/DomainEventHandlerTests.cs index 68089054..9c8f543e 100644 --- a/backend/tests/LearnStack.Tests.Architecture/DomainEventHandlerTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/DomainEventHandlerTests.cs @@ -134,9 +134,11 @@ public void Domain_handler_guard_rejects_each_forbidden_dependency(Type dependen { typeof(DirectHelper), true }, { typeof(IndirectHelper), true }, { typeof(IHelper), true }, { typeof(InheritedHelper), true }, { typeof(InheritedDependencyHelper), true }, { typeof(IGenericHelper<ProbeDomainEvent>), 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<ProbeDomainEvent>), false }, { typeof(PureAsyncHelper), false }, { typeof(PureLambdaHelper), false }, + { typeof(PureAbstractDispatchHelper), false }, { typeof(PureVirtualDispatchHelper), false }, }; [Theory] @@ -326,7 +328,7 @@ private static string[] Offenders(IEnumerable<TypeDefinition> roots, TypeGraph g if (IsTerminal(reference, graph)) continue; if (graph.Resolve(reference) is not { } helper) continue; queue.Enqueue((helper, $"{current.Path} -> {Label(helper)}")); - if (helper.IsInterface) + if (!helper.IsSealed) { foreach (var implementation in graph.Implementations(helper)) queue.Enqueue((implementation, $"{current.Path} -> {Label(helper)} -> {Label(implementation)}")); @@ -391,10 +393,11 @@ private sealed class TypeGraph public TypeGraph(IEnumerable<ModuleDefinition> modules) { _types = modules.SelectMany(module => module.GetTypes()).ToDictionary(Key); - // Match generic interface definitions conservatively: all realizations are - // possible targets. Inherited interfaces and inherited implementations count. + // 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)?.IsInterface == true) + .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) @@ -568,6 +571,27 @@ private sealed class GenericHelper : GenericHelperBase<ProbeDomainEvent> 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<object> Read() diff --git a/backend/tests/LearnStack.Tests.Architecture/OutboxProducerConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/OutboxProducerConventionTests.cs index 572942cd..65b84a1a 100644 --- a/backend/tests/LearnStack.Tests.Architecture/OutboxProducerConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/OutboxProducerConventionTests.cs @@ -39,12 +39,15 @@ public void Producer_walk_detects_connection_commit_publication_and_interface_he 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(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}"); } - Offenders([module.GetType(typeof(CleanProbe).FullName!.Replace('+', '/'))], types).Should().BeEmpty(); + 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] @@ -119,7 +122,7 @@ private static SortedSet<string> Offenders(IEnumerable<TypeDefinition> roots, if (reference.FullName == "LearnStack.SharedKernel.Persistence.IUnitOfWork") continue; if (!types.TryGetValue(Key(reference), out var helper)) continue; queue.Enqueue(helper); - if (helper.IsInterface) + if (!helper.IsSealed) { foreach (var target in types.Values.Where(candidate => !candidate.IsInterface && Implements(candidate, helper, types))) queue.Enqueue(target); @@ -189,6 +192,18 @@ 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(); } diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 4575f1bc..4c221acc 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -848,13 +848,39 @@ required CI success or merge. 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; all -1,764 unit, 261 architecture and four contract cases pass with zero -failures/skips. The 59 focused producer-guard cases overlap the architecture -total. The full integration run is in progress. It found two older test setups -that called persistence registration without the application pipeline, leaving -the new collector dependency absent; both fixtures now install the same shared -application registration as production. No runtime policy was weakened. +**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. Round 2 remains pending until these fixes are committed. ### Durable outbox dispatch @@ -1408,8 +1434,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 diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 323f174a..ec34bb75 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -3251,14 +3251,16 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). #### `Domain_Event_Handlers_Are_Outbox_Only` - **Asserts:** the complete production-module domain-handler census translates - facts to outbox only. Traverse helpers and interface targets; root mutation, + 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 traversal, all production layers and dirty/clean capability controls. + 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. From 1e799a8efe6ffdff7bc9b4b0eb887bf22bbbc97e Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 12:16:15 +0300 Subject: [PATCH 51/92] docs(roadmap): close P02b-1 implementation and review rounds Record complete producer delivery with exact local evidence, verified review fixes and preserved decision history. Keep PR approval separate and name the remaining dispatcher and consumer decision passes. ADR: 0057 --- CLAUDE.md | 5 +- README.md | 12 ++-- docs/architecture/15-event-and-outbox.md | 7 +-- docs/roadmap/README.md | 8 ++- docs/roadmap/phase-02b-events-auth.md | 76 ++++++++++++++++++++++-- 5 files changed, 89 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c145bdb2..aa206297 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -178,7 +178,10 @@ completed-provisioning publisher; its 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) -tracks final validation and review status. +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/` diff --git a/README.md b/README.md index a35bf8d2..57c56b11 100644 --- a/README.md +++ b/README.md @@ -67,8 +67,10 @@ items, rules, custom fields and notification templates; their delivery is tracke **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's decision package and ADR-0057 are Accepted; its producer -runtime is implemented, and enforcement/closeout is in progress. +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, @@ -118,8 +120,8 @@ proof pass real CI; the seventh required context is active. The owns CI enforcement, reviews and the positive phase-exit verdict. [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 is complete; P02b-1 implements transactional -event production. Its enforcement/closeout is in progress. The +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 @@ -131,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/docs/architecture/15-event-and-outbox.md b/docs/architecture/15-event-and-outbox.md index 15ea585c..a04ba121 100644 --- a/docs/architecture/15-event-and-outbox.md +++ b/docs/architecture/15-event-and-outbox.md @@ -777,10 +777,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 obligation includes - `partition_key` when P02b-1 implements enqueue, 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/roadmap/README.md b/docs/roadmap/README.md index 56bcb650..92a520e1 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -93,12 +93,14 @@ not deferred to the showcase phase. [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 complete; P02b-1 producer enforcement in progress**. Its + — **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. - Dispatcher/consumer work remains P02b-2/P02b-3. + 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 4c221acc..b1801ade 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -17,8 +17,10 @@ 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) tracks final -verification and review status. +[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, @@ -820,9 +822,10 @@ Complete production censuses and catalogue promotion remain Step 4 work. ### P02b-1 Step 4 — Producer enforcement and closeout (2026-10-12) -**Implementation complete; full validation and independent reviews in progress.** -Work remains on `development`. This checkpoint does not claim PR approval, -required CI success or merge. +**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 @@ -880,7 +883,68 @@ 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. Round 2 remains pending until these fixes are committed. +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 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. + +**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. ### Durable outbox dispatch From 11871a2536e715a72eb37bcf7c20b4d24d32e756 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 12:40:19 +0300 Subject: [PATCH 52/92] test(web): expose bounded browser admission failure phases Keep private browser diagnostics contained while distinguishing launch, hydration and document-fallback failures in the existing CI proof. The first PR run failed without identifying the browser operation; preserve all assertions and deadlines while making the next result actionable. ADR: 0055 --- docs/roadmap/phase-02b-events-auth.md | 16 ++- .../apps/web/scripts/admission-browser.d.mts | 2 + .../apps/web/scripts/admission-browser.mjs | 99 +++++++++++++++---- .../web/scripts/verify-public-admission.mjs | 6 +- .../web/src/test/admission-browser.test.ts | 26 ++++- 5 files changed, 127 insertions(+), 22 deletions(-) diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index b1801ade..9ae82f57 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -931,12 +931,26 @@ 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 source, OpenAPI contract or schema migration changes. The PR's +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. Assertions, deadlines and +production behavior are unchanged; a fresh CI result is still required. + **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. diff --git a/frontend/apps/web/scripts/admission-browser.d.mts b/frontend/apps/web/scripts/admission-browser.d.mts index 3ad70eb2..9c499f70 100644 --- a/frontend/apps/web/scripts/admission-browser.d.mts +++ b/frontend/apps/web/scripts/admission-browser.d.mts @@ -24,3 +24,5 @@ export function verifyBrowserFallback(options: { readonly secrets: readonly string[]; }): Promise<ReturnType<typeof createPrivateScanner>>; import type { createFixtureOwner, createPrivateScanner } from './fixture-support.mjs'; +export function browserFailureDetail(error: unknown): string; +export function atBrowserPhase<T>(phase: string, operation: () => T | Promise<T>): Promise<T>; diff --git a/frontend/apps/web/scripts/admission-browser.mjs b/frontend/apps/web/scripts/admission-browser.mjs index ccf79e46..fdfada21 100644 --- a/frontend/apps/web/scripts/admission-browser.mjs +++ b/frontend/apps/web/scripts/admission-browser.mjs @@ -9,6 +9,38 @@ 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-shutdown', +]); + +/** 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 { + const failure = new Error('Public admission browser proof failed'); + failure.admissionStage = phase; + throw failure; + } +} + /** 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) => { @@ -54,19 +86,34 @@ async function until(predicate, message, timeout = 20_000) { } /** 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 (browserFailureDetail(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,6 +161,7 @@ export async function verifyBrowserFallback({ failed = true; }); const portFile = join(profile, 'DevToolsActivePort'); + phase.value = 'browser-devtools'; const port = await until(() => { owner.assertActive(); assert.equal( @@ -125,6 +173,7 @@ export async function verifyBrowserFallback({ 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-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 + '/json/list', { signal: AbortSignal.timeout(5000), @@ -201,6 +250,7 @@ export async function verifyBrowserFallback({ } }; try { + phase.value = 'browser-protocol'; await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('Browser protocol startup deadline')), 5000); socket.once('open', () => { @@ -215,17 +265,22 @@ export async function verifyBrowserFallback({ 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', ); + 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 === ' + @@ -242,15 +297,23 @@ export async function verifyBrowserFallback({ ) ); }, 'Stock Next document fallback deadline'); + phase.value = 'browser-wire'; assertFlightFallback(events, origin, target, status); + phase.value = 'browser-completion'; 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'); + await atBrowserPhase('browser-shutdown', async () => { + rejectPending(); + socket.terminate(); + await stopTestChild(child); + scanner.finish(); + 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..e4752b24 100644 --- a/frontend/apps/web/src/test/admission-browser.test.ts +++ b/frontend/apps/web/src/test/admission-browser.test.ts @@ -1,7 +1,11 @@ // @vitest-environment node import { describe, expect, it } from 'vitest'; -import { assertFlightFallback } from '../../scripts/admission-browser.mjs'; +import { + assertFlightFallback, + atBrowserPhase, + browserFailureDetail, +} from '../../scripts/admission-browser.mjs'; const origin = 'https://fixture.localhost:3000'; const target = '/en/courses/fixture-course'; @@ -14,6 +18,26 @@ const flight = { const document = { url: origin + target, type: 'Document', rsc: false, status: 200 }; 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); + await expect(atBrowserPhase('unregistered', () => 42)).rejects.toThrow( + 'Browser diagnostic phase is registered', + ); + }); + it.each([429, 503])('accepts one refused Flight then recovered document: %s', (status) => { expect(() => assertFlightFallback( From 471d0a005dedf3d24be54fc02928bff942d48f51 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 12:42:14 +0300 Subject: [PATCH 53/92] docs(events): reconcile producer and pending delivery guarantees Align current-state references with delivered producer completion and keep durable ordering, dispatch and inbox obligations with their owning packets. Process-local serialization is not a durable ordering proof. ADR: 0057 --- docs/architecture/09-tenant-isolation.md | 5 +++-- docs/architecture/15-event-and-outbox.md | 9 +++++---- docs/architecture/31-audit-subsystem.md | 2 +- docs/glossary.md | 4 ++-- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/docs/architecture/09-tenant-isolation.md b/docs/architecture/09-tenant-isolation.md index 6b57bf00..fe05bbde 100644 --- a/docs/architecture/09-tenant-isolation.md +++ b/docs/architecture/09-tenant-isolation.md @@ -160,8 +160,9 @@ 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). -Outbox enqueue, successful-save collection and production delivery remain later -P02b-1/P02b-3 work. +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 diff --git a/docs/architecture/15-event-and-outbox.md b/docs/architecture/15-event-and-outbox.md index a04ba121..700e1234 100644 --- a/docs/architecture/15-event-and-outbox.md +++ b/docs/architecture/15-event-and-outbox.md @@ -118,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 | @@ -610,7 +611,7 @@ and inbox deduplication remain P02b-3 work, under G1/G6. | Same handler interface — `IIntegrationEventHandler<T>`, never a bare `INotificationHandler<T>` | 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( diff --git a/docs/architecture/31-audit-subsystem.md b/docs/architecture/31-audit-subsystem.md index 9e792447..d19a254c 100644 --- a/docs/architecture/31-audit-subsystem.md +++ b/docs/architecture/31-audit-subsystem.md @@ -109,7 +109,7 @@ 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. -The owner-only domain drain accepted by ADR-0057 remains subsequent P02b-1 work. +P02b-1 Step 3 delivers ADR-0057's owner-only domain drain before MUST audit/commit. Five components, separated concerns: diff --git a/docs/glossary.md b/docs/glossary.md index 8de5c11f..6b350580 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -324,11 +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<T>`, 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<T>`, 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). | From d5afc66e77af8292b51cbf82985ea24f2a9ae41d Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 12:43:17 +0300 Subject: [PATCH 54/92] docs(architecture): distinguish local event invocation ordering Remove the remaining durable-order equivalence claim found during the independent CI follow-up review. Partition serialization is process-local; P02b-2 G3 still owns ordering across commits and retries. ADR: 0057 --- docs/architecture/15-event-and-outbox.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/architecture/15-event-and-outbox.md b/docs/architecture/15-event-and-outbox.md index 700e1234..e9c7165c 100644 --- a/docs/architecture/15-event-and-outbox.md +++ b/docs/architecture/15-event-and-outbox.md @@ -584,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: From d38b0c577b1bbe198f4f232effee8591ee55201a Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 12:43:54 +0300 Subject: [PATCH 55/92] docs(events): align transport comments with ordering ownership Keep XML guidance consistent with the corrected architecture and glossary. Process-local invocation serialization does not decide durable ordering across concurrent commits or retry, which remains P02b-2 G3. ADR: 0057 --- .../Messaging/InProcessEventBus.cs | 4 ++-- .../Messaging/IPartitionSerializer.cs | 8 +++----- 2 files changed, 5 insertions(+), 7 deletions(-) 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; /// <summary>The process-local <see cref="IEventBus"/> transport.</summary> /// <remarks> -/// 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. /// </remarks> 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. /// </summary> /// <remarks> -/// 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. /// </remarks> public interface IPartitionSerializer { From a903b53266a91376799316764200fbdd9c630c89 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 12:50:37 +0300 Subject: [PATCH 56/92] test(web): preserve the first admission proof failure Always run browser cleanup without allowing its failure to replace the original diagnostic phase. A cleanup-only failure remains classified as shutdown, and neither path exposes private exception text. ADR: 0055 --- .../apps/web/scripts/admission-browser.d.mts | 4 + .../apps/web/scripts/admission-browser.mjs | 136 ++++++++++-------- .../web/src/test/admission-browser.test.ts | 34 +++++ 3 files changed, 118 insertions(+), 56 deletions(-) diff --git a/frontend/apps/web/scripts/admission-browser.d.mts b/frontend/apps/web/scripts/admission-browser.d.mts index 9c499f70..d0655911 100644 --- a/frontend/apps/web/scripts/admission-browser.d.mts +++ b/frontend/apps/web/scripts/admission-browser.d.mts @@ -26,3 +26,7 @@ export function verifyBrowserFallback(options: { import type { createFixtureOwner, createPrivateScanner } from './fixture-support.mjs'; export function browserFailureDetail(error: unknown): string; export function atBrowserPhase<T>(phase: string, operation: () => T | Promise<T>): Promise<T>; +export function withBrowserCleanup<T>( + operation: () => T | Promise<T>, + cleanup: () => void | Promise<void>, +): Promise<T>; diff --git a/frontend/apps/web/scripts/admission-browser.mjs b/frontend/apps/web/scripts/admission-browser.mjs index fdfada21..b5d2aaaa 100644 --- a/frontend/apps/web/scripts/admission-browser.mjs +++ b/frontend/apps/web/scripts/admission-browser.mjs @@ -41,6 +41,26 @@ export async function atBrowserPhase(phase, operation) { } } +/** 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) => { @@ -249,62 +269,66 @@ async function runBrowserFallback( return false; } }; - try { - 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')); + 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'); - 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', - ); - 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"))', + 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'); - phase.value = 'browser-wire'; - assertFlightFallback(events, origin, target, status); - phase.value = 'browser-completion'; - await afterNavigation(); - assert.equal(scanner.leaked, false, 'Browser diagnostics contain no private carrier'); - return scanner; - } finally { - await atBrowserPhase('browser-shutdown', async () => { + 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(); + assert.equal(scanner.leaked, false, 'Browser diagnostics contain no private carrier'); + return scanner; + }, + async () => { rejectPending(); socket.terminate(); await stopTestChild(child); @@ -314,6 +338,6 @@ async function runBrowserFallback( false, 'Browser shutdown diagnostics contain no private carrier', ); - }); - } + }, + ); } diff --git a/frontend/apps/web/src/test/admission-browser.test.ts b/frontend/apps/web/src/test/admission-browser.test.ts index e4752b24..af8b87d4 100644 --- a/frontend/apps/web/src/test/admission-browser.test.ts +++ b/frontend/apps/web/src/test/admission-browser.test.ts @@ -5,6 +5,7 @@ import { assertFlightFallback, atBrowserPhase, browserFailureDetail, + withBrowserCleanup, } from '../../scripts/admission-browser.mjs'; const origin = 'https://fixture.localhost:3000'; @@ -38,6 +39,39 @@ describe('stock browser Flight fallback observation', () => { ); }); + 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( From cb2bb6d9667fc888aa3d08ee3355db682ec7462d Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 12:55:21 +0300 Subject: [PATCH 57/92] docs(roadmap): record P02b-1 CI follow-up verification Retain the first failed browser receipt and distinguish the subsequent passing integration evidence from an established cause. Record bounded diagnostics, verified review corrections and their independent reviews; the PR checks own final-head CI approval. ADR: 0057 --- docs/roadmap/phase-02b-events-auth.md | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 9ae82f57..e3a4cadc 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -948,8 +948,26 @@ 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. Assertions, deadlines and -production behavior are unchanged; a fresh CI result is still required. +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 From d8fd919e2f986446cdfd853c9789988a97867de7 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 14:20:18 +0300 Subject: [PATCH 58/92] test(kernel): strengthen producer and audit proof controls Exercise actual outbox receipts and require positive intent and owner write observations so missing production work cannot pass these tests. ADR: 0057 --- .../Database/ProducerExecutionTests.cs | 20 +++++++++----- .../Pipeline/AuditLogBehaviorTests.cs | 7 ++++- .../Pipeline/TransactionBehaviorTests.cs | 27 +++++++++++-------- 3 files changed, 36 insertions(+), 18 deletions(-) diff --git a/backend/tests/LearnStack.Tests.Integration/Database/ProducerExecutionTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/ProducerExecutionTests.cs index 326f7040..20104809 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/ProducerExecutionTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/ProducerExecutionTests.cs @@ -7,10 +7,12 @@ 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; @@ -27,9 +29,9 @@ namespace LearnStack.Tests.Integration.Database; /// <summary> -/// ADR-0057's shared execution boundary, before the actual collector/outbox arrive. -/// Receipts below are recorded explicitly after an observed successful database write; -/// these tests do not claim SaveChanges promotion or immediate outbox admission. +/// ADR-0057's shared execution boundary, including real outbox receipts across +/// direct and nested invocations. Domain save-promotion proofs live in +/// <c>DomainEventProductionTests</c>. /// </summary> [Trait(RequiresDocker.Key, RequiresDocker.Value)] [Collection(SharedSchema.Name)] @@ -54,17 +56,22 @@ public async Task An_absorbed_producer_refusal_rolls_back_direct_and_descendant_ var producer = services.GetRequiredService<ProducerExecution>(); var unit = services.GetRequiredService<IUnitOfWork>(); var id = TenantContentTypeId.From(Guid.CreateVersion7()); + var integrationEvent = new TenantCreatedIntegrationEventV1 + { + EventId = Guid.CreateVersion7(), + OccurredAt = services.GetRequiredService<IClock>().UtcNow, + TenantId = TenantA.TenantId.Value, + }; var observedWrite = false; var absorbedRefusal = false; async Task<Result<None>> WriteAsync() { await WriteContentTypeAsync(services, id); + await services.GetRequiredService<IOutbox>().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; - // Step 1 tests the receipt protocol only. Step 2/3 must prove that the - // actual INSERT/save-promotion sites, rather than callers, record it. - producer.RecordProducedWork(); return Result.Ok(None.Value); } @@ -98,6 +105,7 @@ await run.Should().ThrowAsync<InvalidOperationException>() 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(); diff --git a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/AuditLogBehaviorTests.cs b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/AuditLogBehaviorTests.cs index 44e27e64..fb80a49b 100644 --- a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/AuditLogBehaviorTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/AuditLogBehaviorTests.cs @@ -403,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(); diff --git a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs index 85a01d71..e159a2de 100644 --- a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs @@ -385,21 +385,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] From 75fc79bc0d65108ddc0468eff7a65d4cf7923954 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 14:25:42 +0300 Subject: [PATCH 59/92] docs(roadmap): record external producer proof review Keep the review follow-up traceable to verified regressions, controlled mutations and independent reviews without claiming new runtime behavior. ADR: 0057 --- docs/roadmap/phase-02b-events-auth.md | 35 +++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index e3a4cadc..f59d552c 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -978,6 +978,41 @@ 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. + ### Durable outbox dispatch P02b-1 Steps 2/3 implement `IOutbox`, replace Packet 3's step-7 shell with From c7dae0c94156eb4e182dd7c1b0535599bc86f87d Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 15:18:41 +0300 Subject: [PATCH 60/92] fix(kernel): distinguish frame refusal from indeterminate commit A live transaction means frame completion refused before COMMIT. Route that failure through guarded rollback so durable audit records the known failure and cleanup cannot replace its original exception. Prove actual business/outbox rollback and raw event-save admission in both hosts. ADR: 0033, 0040, 0057 --- .../Messaging/DomainEventCollector.cs | 6 ++ .../TransactionExecutionCoordinator.cs | 10 ++- .../DomainEventSaveChangesInterceptor.cs | 5 ++ .../Database/DomainEventProductionTests.cs | 44 ++++++++++++ .../Database/ProducerExecutionTests.cs | 72 +++++++++++++++++++ .../Pipeline/TransactionBehaviorTests.cs | 30 +++++++- 6 files changed, 165 insertions(+), 2 deletions(-) diff --git a/backend/src/LearnStack.Application/Messaging/DomainEventCollector.cs b/backend/src/LearnStack.Application/Messaging/DomainEventCollector.cs index c4e5f07a..e521373f 100644 --- a/backend/src/LearnStack.Application/Messaging/DomainEventCollector.cs +++ b/backend/src/LearnStack.Application/Messaging/DomainEventCollector.cs @@ -47,6 +47,12 @@ public void Observe(IHasDomainEvents 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. /// </summary> + /// <remarks> + /// 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. + /// </remarks> public void Stage(object context, IEnumerable<IHasDomainEvents> savingSources) { ObjectDisposedException.ThrowIf(_disposed, this); diff --git a/backend/src/LearnStack.Application/Persistence/TransactionExecutionCoordinator.cs b/backend/src/LearnStack.Application/Persistence/TransactionExecutionCoordinator.cs index b1844a69..ff716e56 100644 --- a/backend/src/LearnStack.Application/Persistence/TransactionExecutionCoordinator.cs +++ b/backend/src/LearnStack.Application/Persistence/TransactionExecutionCoordinator.cs @@ -106,7 +106,15 @@ await unitOfWork.SetProvisioningTenantContextAsync( } catch (Exception commitFailure) { - if (scope.IsOwner) + 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); diff --git a/backend/src/LearnStack.Infrastructure/Messaging/DomainEventSaveChangesInterceptor.cs b/backend/src/LearnStack.Infrastructure/Messaging/DomainEventSaveChangesInterceptor.cs index 083f8e72..7b328c6b 100644 --- a/backend/src/LearnStack.Infrastructure/Messaging/DomainEventSaveChangesInterceptor.cs +++ b/backend/src/LearnStack.Infrastructure/Messaging/DomainEventSaveChangesInterceptor.cs @@ -8,6 +8,11 @@ namespace LearnStack.Infrastructure.Messaging; /// <summary>Collects domain facts only from successful saves, while retaining tracked sources for terminal cleanup.</summary> +/// <remarks> +/// 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. +/// </remarks> public sealed class DomainEventSaveChangesInterceptor(DomainEventCollector collector) : SaveChangesInterceptor { private readonly ConditionalWeakTable<DbContext, object> _attached = new(); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/DomainEventProductionTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/DomainEventProductionTests.cs index 7bf10450..fdf073d3 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/DomainEventProductionTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/DomainEventProductionTests.cs @@ -43,6 +43,50 @@ public sealed class DomainEventProductionTests(SchemaFixture schema) 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<IUnitOfWork>(); + 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<ProducerExecution>().HasInvocation.Should().BeFalse(); + var db = services.GetRequiredService<CustomizationDbContext>(); + + 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<InvalidOperationException>().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)] diff --git a/backend/tests/LearnStack.Tests.Integration/Database/ProducerExecutionTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/ProducerExecutionTests.cs index 20104809..07914ece 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/ProducerExecutionTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/ProducerExecutionTests.cs @@ -42,6 +42,78 @@ public sealed class ProducerExecutionTests(SchemaFixture schema) 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<TransactionExecutionCoordinator>(); + var unit = services.GetRequiredService<IUnitOfWork>(); + var capture = services.GetRequiredService<IAuditStateCapture>(); + var id = TenantContentTypeId.From(Guid.CreateVersion7()); + var integrationEvent = new TenantCreatedIntegrationEventV1 + { + EventId = Guid.CreateVersion7(), + OccurredAt = services.GetRequiredService<IClock>().UtcNow, + TenantId = TenantA.TenantId.Value, + }; + IUnitOfWorkScope? leaked = null; + var observed = false; + + async Task<Result<None>> 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<IOutbox>().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<InvalidOperationException>().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<InvalidOperationException>() + .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<InvalidOperationException>().WithMessage("*rollback-only*"); + } + finally + { + if (leaked is not null) await leaked.DisposeAsync(); + } + } + [Theory] [InlineData(0)] [InlineData(1)] diff --git a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs index e159a2de..08e1ea23 100644 --- a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/TransactionBehaviorTests.cs @@ -373,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<InvalidOperationException>()).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() { @@ -537,6 +560,9 @@ private sealed class RecordingUnitOfWork : IUnitOfWork /// <summary>Thrown by the outermost commit, to model a faulted COMMIT.</summary> public Exception? CommitFailure { get; init; } + /// <summary>Thrown while the frame is still live, before attempting COMMIT.</summary> + public Exception? CompletionFailure { get; init; } + /// <summary> /// Thrown by the rollback, to model the cleanup path failing on a /// connection the original exception already broke. @@ -653,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); From 3f4c121b2bdd806ba026b864d41c6434068acc66 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 15:25:22 +0300 Subject: [PATCH 61/92] fix(kernel): bound shared execution proofs and reconcile review records Keep shared transaction state inside the sequential-execution census, with exact exceptions for independent singleton flights. Distinguish private browser proof failures without exposing their source. Reconcile uncached settings ownership and correct the historical clause locators without changing accepted decisions. ADR: 0033, 0040, 0041, 0057 --- .claude/skills/add-integration-event/SKILL.md | 2 +- .../PersistenceConventionTests.cs | 99 ++++++++++++++++--- docs/architecture/15-event-and-outbox.md | 6 +- ...ctional-event-production-and-completion.md | 36 +++++++ docs/decisions/README.md | 17 ++-- docs/roadmap/phase-02b-events-auth.md | 48 ++++++++- docs/roadmap/phase-02d-exit-evidence.md | 10 +- docs/roadmap/phase-02d-walking-skeleton.md | 21 +++- docs/standards/05-database.md | 2 +- docs/standards/20-infrastructure-stack.md | 13 ++- .../21-architecture-tests-catalogue.md | 26 +++-- .../apps/web/scripts/admission-browser.d.mts | 21 +++- .../apps/web/scripts/admission-browser.mjs | 55 +++++++---- .../web/src/test/admission-browser.test.ts | 85 +++++++++++++++- 14 files changed, 374 insertions(+), 67 deletions(-) diff --git a/.claude/skills/add-integration-event/SKILL.md b/.claude/skills/add-integration-event/SKILL.md index bb7f6bf0..73c4cfbb 100644 --- a/.claude/skills/add-integration-event/SKILL.md +++ b/.claude/skills/add-integration-event/SKILL.md @@ -81,7 +81,7 @@ public sealed record EnrollmentCreatedIntegrationEventV1 : IntegrationEventBase // 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(); + public override string PartitionKey => EnrollmentId.ToString("D"); } ``` diff --git a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs index 67db29ae..d5bb70a7 100644 --- a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs @@ -285,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<string> 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() { @@ -335,16 +396,24 @@ public void The_Parallelism_Scan_Can_Actually_Fail() } /// <summary> - /// Module files allowed to fan out, by path under <c>backend/src/Modules</c>, each with why - /// none of its concurrent work touches the ambient connection. Empty. + /// Files allowed to fan out, by exact path under <c>backend/src</c>, each with why + /// its concurrent work does not use the scoped ambient connection. /// </summary> - private static readonly HashSet<string> ParallelSites = new(StringComparer.Ordinal); + private static readonly Dictionary<string, string> 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).", + }; /// <remarks> /// Two shapes. The named APIs, and the hand-rolled one: a call to an <c>…Async</c> 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 <c>backend/src</c> 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. /// </remarks> private static bool FansOut(string code) => FanOut().IsMatch(code) || StartedWithoutAwaiting().IsMatch(code); diff --git a/docs/architecture/15-event-and-outbox.md b/docs/architecture/15-event-and-outbox.md index e9c7165c..075a0630 100644 --- a/docs/architecture/15-event-and-outbox.md +++ b/docs/architecture/15-event-and-outbox.md @@ -312,9 +312,9 @@ public sealed class QueueWelcomeNotificationOnEnrollmentCreated( IInboxGuard inboxGuard, NotificationsDbContext db, ILogger<QueueWelcomeNotificationOnEnrollmentCreated> logger) - : IIntegrationEventHandler<EnrollmentCreatedIntegrationEvent> + : IIntegrationEventHandler<EnrollmentCreatedIntegrationEventV1> { - public async Task HandleAsync(EnrollmentCreatedIntegrationEvent @event, CancellationToken ct) + public async Task HandleAsync(EnrollmentCreatedIntegrationEventV1 @event, CancellationToken ct) { if (await inboxGuard.IsAlreadyProcessedAsync(@event.EventId, ct)) { @@ -751,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 diff --git a/docs/decisions/0057-transactional-event-production-and-completion.md b/docs/decisions/0057-transactional-event-production-and-completion.md index 97516744..4baae490 100644 --- a/docs/decisions/0057-transactional-event-production-and-completion.md +++ b/docs/decisions/0057-transactional-event-production-and-completion.md @@ -1,5 +1,12 @@ # 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 @@ -302,6 +309,13 @@ 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 | @@ -380,6 +394,28 @@ 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) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 3f92c63d..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<TPort>` (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<TPort>` (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 | @@ -72,7 +77,7 @@ an amendment is not a lifecycle status change. | 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)); 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 | +| 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 diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index f59d552c..c30a751e 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -287,7 +287,7 @@ premise a row cites is re-verified at that pass rather than trusted. | 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 its Architecture Tests column list, 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) | +| 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 | @@ -489,7 +489,7 @@ not an implementation or passing-test claim. |---|---|---| | 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 / Architecture Tests column list; dated supersession disclosure and Database Standards alignment; resolver failure policies remain 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 | @@ -1013,6 +1013,50 @@ 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. + +Focused verification passes 75 producer integration and 64 audit/transaction +unit cases, including overlap with the complete suites. Full regression and two +fresh independent review rounds remain in progress for this remediation. +PR review and merge remain pending. + ### Durable outbox dispatch P02b-1 Steps 2/3 implement `IOutbox`, replace Packet 3's step-7 shell with diff --git a/docs/roadmap/phase-02d-exit-evidence.md b/docs/roadmap/phase-02d-exit-evidence.md index 650b317a..5e986934 100644 --- a/docs/roadmap/phase-02d-exit-evidence.md +++ b/docs/roadmap/phase-02d-exit-evidence.md @@ -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,11 +265,11 @@ 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 +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. diff --git a/docs/roadmap/phase-02d-walking-skeleton.md b/docs/roadmap/phase-02d-walking-skeleton.md index aca5b3f8..66993062 100644 --- a/docs/roadmap/phase-02d-walking-skeleton.md +++ b/docs/roadmap/phase-02d-walking-skeleton.md @@ -286,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) | @@ -440,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 | @@ -6211,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 @@ -7270,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** @@ -7363,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/05-database.md b/docs/standards/05-database.md index c9b7ac22..736a8b03 100644 --- a/docs/standards/05-database.md +++ b/docs/standards/05-database.md @@ -1391,7 +1391,7 @@ 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, -- producer writes SQL NULL (ADR-0057) processed_at timestamptz NULL, diff --git a/docs/standards/20-infrastructure-stack.md b/docs/standards/20-infrastructure-stack.md index 5f29c25b..25c76716 100644 --- a/docs/standards/20-infrastructure-stack.md +++ b/docs/standards/20-infrastructure-stack.md @@ -247,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 | @@ -283,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 diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index ec34bb75..7f100395 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -105,7 +105,7 @@ not implemented is the failure mode this column exists to prevent. ### Implemented today -**172 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, @@ -1745,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. @@ -1759,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 @@ -3226,7 +3237,8 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). 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`, +- **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. @@ -3243,7 +3255,7 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). 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`, +- **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. diff --git a/frontend/apps/web/scripts/admission-browser.d.mts b/frontend/apps/web/scripts/admission-browser.d.mts index d0655911..287bb555 100644 --- a/frontend/apps/web/scripts/admission-browser.d.mts +++ b/frontend/apps/web/scripts/admission-browser.d.mts @@ -24,8 +24,27 @@ export function verifyBrowserFallback(options: { readonly secrets: readonly string[]; }): Promise<ReturnType<typeof createPrivateScanner>>; 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<T>(phase: string, operation: () => T | Promise<T>): Promise<T>; +export function atBrowserPhase<T>(phase: BrowserPhase, operation: () => T | Promise<T>): Promise<T>; +export function waitForBrowserDevTools(options: { + readonly assertActive: () => void; + readonly isLive: () => boolean; + readonly readPort: () => number | null; +}): Promise<number>; export function withBrowserCleanup<T>( operation: () => T | Promise<T>, cleanup: () => void | Promise<void>, diff --git a/frontend/apps/web/scripts/admission-browser.mjs b/frontend/apps/web/scripts/admission-browser.mjs index b5d2aaaa..4084421b 100644 --- a/frontend/apps/web/scripts/admission-browser.mjs +++ b/frontend/apps/web/scripts/admission-browser.mjs @@ -21,8 +21,10 @@ const browserPhases = new Set([ '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) { @@ -34,9 +36,11 @@ export async function atBrowserPhase(phase, operation) { assert.ok(browserPhases.has(phase), 'Browser diagnostic phase is registered'); try { return await operation(); - } catch { + } 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; } } @@ -105,6 +109,19 @@ 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(options) { const phase = { value: 'browser-launch' }; @@ -182,17 +199,15 @@ async function runBrowserFallback( }); const portFile = join(profile, 'DevToolsActivePort'); phase.value = 'browser-devtools'; - 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'); + 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 + '/json/list', { @@ -325,7 +340,9 @@ async function runBrowserFallback( assertFlightFallback(events, origin, target, status); phase.value = 'browser-completion'; await afterNavigation(); - assert.equal(scanner.leaked, false, 'Browser diagnostics contain no private carrier'); + await atBrowserPhase('browser-private-carrier', () => { + assert.equal(scanner.leaked, false, 'Browser diagnostics contain no private carrier'); + }); return scanner; }, async () => { @@ -333,11 +350,13 @@ async function runBrowserFallback( socket.terminate(); await stopTestChild(child); scanner.finish(); - assert.equal( - scanner.leaked, - false, - 'Browser shutdown diagnostics contain no private carrier', - ); + await atBrowserPhase('browser-private-carrier', () => { + assert.equal( + scanner.leaked, + false, + 'Browser shutdown diagnostics contain no private carrier', + ); + }); }, ); } diff --git a/frontend/apps/web/src/test/admission-browser.test.ts b/frontend/apps/web/src/test/admission-browser.test.ts index af8b87d4..2aff8cab 100644 --- a/frontend/apps/web/src/test/admission-browser.test.ts +++ b/frontend/apps/web/src/test/admission-browser.test.ts @@ -1,10 +1,11 @@ // @vitest-environment node -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { assertFlightFallback, atBrowserPhase, browserFailureDetail, + waitForBrowserDevTools, withBrowserCleanup, } from '../../scripts/admission-browser.mjs'; @@ -18,6 +19,8 @@ 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'; @@ -34,11 +37,91 @@ describe('stock browser Flight fallback observation', () => { 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('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; From 9b2fcf69ded60cc3099ce00a1060d02e0d84cd91 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 15:28:33 +0300 Subject: [PATCH 62/92] fix(web): authenticate sanitized browser proof failures A closed phase token alone cannot prove that an error was sanitized. Use the same private brand at the outer proof boundary, retaining sanitized first-error identity while refusing forged raw messages. ADR: 0055 --- .../apps/web/scripts/admission-browser.mjs | 2 +- .../web/src/test/admission-browser.test.ts | 41 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/frontend/apps/web/scripts/admission-browser.mjs b/frontend/apps/web/scripts/admission-browser.mjs index 4084421b..33ef54c2 100644 --- a/frontend/apps/web/scripts/admission-browser.mjs +++ b/frontend/apps/web/scripts/admission-browser.mjs @@ -128,7 +128,7 @@ export async function verifyBrowserFallback(options) { try { return await runBrowserFallback(options, phase); } catch (error) { - if (browserFailureDetail(error)) throw error; + if (sanitizedFailures.has(error)) throw error; return atBrowserPhase(phase.value, () => { throw error; }); diff --git a/frontend/apps/web/src/test/admission-browser.test.ts b/frontend/apps/web/src/test/admission-browser.test.ts index 2aff8cab..f7934145 100644 --- a/frontend/apps/web/src/test/admission-browser.test.ts +++ b/frontend/apps/web/src/test/admission-browser.test.ts @@ -5,9 +5,11 @@ 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'; @@ -105,6 +107,45 @@ describe('stock browser Flight fallback observation', () => { 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'); From b561c8ffe694d007cc95416a7f02fb8ea90ca867 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 15:33:43 +0300 Subject: [PATCH 63/92] docs(roadmap): record verified PR29 remediation and reviews Record the independently verified pre-commit audit correction, bounded proof changes and both completed review rounds against actual local regression counters. Preserve historical evidence and keep exact-head CI ownership separate from local results. ADR: 0033, 0040, 0041, 0057 --- docs/roadmap/phase-02b-events-auth.md | 27 ++++++++++++++++++++----- docs/roadmap/phase-02d-exit-evidence.md | 6 +++--- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index c30a751e..9f6a8803 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -1037,8 +1037,8 @@ 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 +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: @@ -1052,9 +1052,26 @@ 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. -Focused verification passes 75 producer integration and 64 audit/transaction -unit cases, including overlap with the complete suites. Full regression and two -fresh independent review rounds remain in progress for this remediation. +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. ### Durable outbox dispatch diff --git a/docs/roadmap/phase-02d-exit-evidence.md b/docs/roadmap/phase-02d-exit-evidence.md index 5e986934..6553ec2f 100644 --- a/docs/roadmap/phase-02d-exit-evidence.md +++ b/docs/roadmap/phase-02d-exit-evidence.md @@ -269,9 +269,9 @@ with its [reconciliation](phase-02d-walking-skeleton.md#p02d-7-step-4--ci-enforc 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 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. +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. The maintainer merged PR #28 on 2026-10-11; Phase 02d is closed. P02b-0 From 96e6f4d17701895f86284b1ccb6c5886876be224 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 16:20:21 +0300 Subject: [PATCH 64/92] fix(kernel): poison the unit after a failed outbox insert An absorbed SQL failure must not leave the producer unit eligible for completion. Mark it rollback-only at the insert boundary while retaining successful-production receipts only for completed inserts. Exercise both rethrow and absorption against PostgreSQL and align the audit ownership and delivered producer documentation with the shared coordinators. ADR: 0040, 0057 --- .../Messaging/PostgresOutbox.cs | 11 +++++++- .../Database/OutboxProducerTests.cs | 27 +++++++++++++++---- docs/architecture/31-audit-subsystem.md | 14 +++++----- docs/standards/02-backend-coding.md | 8 +++--- docs/standards/18-audit-coverage.md | 14 +++++----- .../21-architecture-tests-catalogue.md | 7 ++--- 6 files changed, 56 insertions(+), 25 deletions(-) diff --git a/backend/src/LearnStack.Infrastructure/Messaging/PostgresOutbox.cs b/backend/src/LearnStack.Infrastructure/Messaging/PostgresOutbox.cs index 945bfad8..ed10673c 100644 --- a/backend/src/LearnStack.Infrastructure/Messaging/PostgresOutbox.cs +++ b/backend/src/LearnStack.Infrastructure/Messaging/PostgresOutbox.cs @@ -63,7 +63,16 @@ public async Task EnqueueAsync(IIntegrationEvent integrationEvent, CancellationT throw new InvalidOperationException("The producer announcement changed before outbox INSERT."); } - await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + try + { + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + unitOfWork.MarkRollbackOnly(); + throw; + } + producer.RecordProducedWork(); } } diff --git a/backend/tests/LearnStack.Tests.Integration/Database/OutboxProducerTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/OutboxProducerTests.cs index 5de6a335..549cb9fd 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/OutboxProducerTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/OutboxProducerTests.cs @@ -605,22 +605,28 @@ public async Task A_retained_producer_frame_refuses_a_reused_transaction_object_ AssertClean(services); } - [Fact] - public async Task A_duplicate_EventId_fails_the_real_INSERT_without_creating_a_child_receipt() + [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<IUnitOfWork>(); var integrationEvent = Event(); var observed = false; var duplicateAttempted = false; + PostgresException? insertFailure = null; var run = () => RunAsync(services, async () => { await services.GetRequiredService<IOutbox>().EnqueueAsync(integrationEvent); - CurrentInvocation(services).HasProducedWork.Should().BeTrue(); - (await RowInUnitAsync(services.GetRequiredService<IUnitOfWork>(), integrationEvent.EventId)).Should().NotBeNull(); + 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<TransactionExecutionCoordinator>().ExecuteAsync<Result<None>>( null, TransactionMode.ReadWrite, "duplicate-child", async () => @@ -628,15 +634,26 @@ public async Task A_duplicate_EventId_fails_the_real_INSERT_without_creating_a_c CurrentInvocation(services).HasProducedWork.Should().BeFalse(); var enqueue = () => services.GetRequiredService<IOutbox>().EnqueueAsync(integrationEvent); var failure = (await enqueue.Should().ThrowAsync<PostgresException>()).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; }); }); - (await run.Should().ThrowAsync<PostgresException>()).Which.SqlState.Should().Be(PostgresErrorCodes.UniqueViolation); + if (absorb) + { + await run.Should().ThrowAsync<InvalidOperationException>().WithMessage("*rollback-only*"); + } + else + { + (await run.Should().ThrowAsync<PostgresException>()).Which.Should().BeSameAs(insertFailure); + } observed.Should().BeTrue(); duplicateAttempted.Should().BeTrue(); (await ReadRowAsync(schema.Postgres.AppConnectionString, integrationEvent.EventId)).Should().BeNull(); diff --git a/docs/architecture/31-audit-subsystem.md b/docs/architecture/31-audit-subsystem.md index d19a254c..8250c25a 100644 --- a/docs/architecture/31-audit-subsystem.md +++ b/docs/architecture/31-audit-subsystem.md @@ -629,9 +629,10 @@ 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. Production delivery/inbox wiring remains P02b-3 after G1. Step 1 -supplies the common entry and producer ancestry groundwork; it does not implement -outbox enqueue or domain-event collection. +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 @@ -840,8 +841,9 @@ 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. Enqueue, successful-save promotion and -owner-only domain drain remain subsequent P02b-1 steps. +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 @@ -1780,7 +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 Step 1 | Common audit/transaction coordinators, capture-owned audit frames, producer ancestry/receipts and no-listener trace groundwork; enqueue and domain collection remain later steps. | +| 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/standards/02-backend-coding.md b/docs/standards/02-backend-coding.md index eb528512..e6701326 100644 --- a/docs/standards/02-backend-coding.md +++ b/docs/standards/02-backend-coding.md @@ -322,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 diff --git a/docs/standards/18-audit-coverage.md b/docs/standards/18-audit-coverage.md index 28e529af..21d5cfe1 100644 --- a/docs/standards/18-audit-coverage.md +++ b/docs/standards/18-audit-coverage.md @@ -374,11 +374,13 @@ Rules: 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. - Only the outermost **audit** frame reconciles and clears capture. Accepted + 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 @@ -576,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/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 7f100395..a0dcfb7f 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -3155,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. From 8a97cd2b7c19bf54a1b0b1f1ad75175256417c9c Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 16:27:08 +0300 Subject: [PATCH 65/92] docs(roadmap): record outbox failure and ownership verification Record the reproduced INSERT failure, both independent review rounds and full backend regression without changing accepted decisions or claiming dispatcher or consumer delivery is implemented. ADR: 0057 --- docs/roadmap/phase-02b-events-auth.md | 34 +++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 9f6a8803..f56c648c 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -1074,6 +1074,40 @@ the frozen P02b-0 entry record remain byte-preserved. The 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 P02b-1 Steps 2/3 implement `IOutbox`, replace Packet 3's step-7 shell with From cc5de2625076724e9eb0dc696254b06117163f1b Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 18:34:49 +0300 Subject: [PATCH 66/92] docs(decisions): propose P02b-2 delivery and recovery Define the producer dispatcher and coupled subscriber recovery contract before enabling durable delivery. Preserve unready provisioning events and require fenced terminal transitions with atomic MUST audit. Record four implementation steps and two completed proposal review rounds. Keep ADR-0046 Proposed until maintainer approval; runtime and Accepted ADR bodies are unchanged. ADR: 0003, 0006, 0010, 0033, 0038, 0044, 0046, 0057 --- docs/architecture/15-event-and-outbox.md | 7 + ...046-durable-event-delivery-and-recovery.md | 363 ++++++++++++++++++ docs/decisions/README.md | 2 +- docs/roadmap/phase-02b-events-auth.md | 216 ++++++++++- 4 files changed, 579 insertions(+), 9 deletions(-) create mode 100644 docs/decisions/0046-durable-event-delivery-and-recovery.md diff --git a/docs/architecture/15-event-and-outbox.md b/docs/architecture/15-event-and-outbox.md index 075a0630..bd378a64 100644 --- a/docs/architecture/15-event-and-outbox.md +++ b/docs/architecture/15-event-and-outbox.md @@ -51,6 +51,13 @@ implements production-census guards and catalogue enforcement; its owns final verification and review status. Dispatcher/inbox choices remain P02b-2/P02b-3. +The [P02b-2 decision package](../roadmap/phase-02b-events-auth.md#p02b-2-decision-package-2026-10-12) +and [ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md) propose +leased producer recovery, atomic terminal audit and the coupled shared-inbox +subscriber contract. They await maintainer approval; no remaining gate or runtime +delivery is complete. The target examples below remain subject to that decision +pass and must not be copied as an implemented dispatcher. + [ADR-0038](../decisions/0038-cross-cutting-port-and-event-contracts.md) stands as the decision that Dapr is the cross-process transport LearnStack uses. [ADR-0035](../decisions/0035-demand-gated-infrastructure.md) diff --git a/docs/decisions/0046-durable-event-delivery-and-recovery.md b/docs/decisions/0046-durable-event-delivery-and-recovery.md new file mode 100644 index 00000000..9f62b9d9 --- /dev/null +++ b/docs/decisions/0046-durable-event-delivery-and-recovery.md @@ -0,0 +1,363 @@ +# ADR-0046: Durable Event Delivery and Recovery + +## Status + +Proposed — maintainer approval pending. No implementation or gate is complete. + +**Date:** 2026-10-12 +**Deciders:** @cemil (approval pending) + +The [P02b-2 decision package](../roadmap/phase-02b-events-auth.md#p02b-2-decision-package-2026-10-12) +owns implementation sequencing and numerical runtime bounds. This reserved record +decides producer dispatch and the coupled P02b-3 subscriber state machine before +either is implemented. Acceptance does not claim delivery. + +## Decision Drivers + +- P02b-1 already commits real `TenantCreatedIntegrationEventV1` rows. The current + bus acknowledges zero subscribers; its two defaults consumers arrive in P02b-3. + Starting dispatch without an explicit admission policy would discard their work. +- Producer retries must not repeatedly execute a sibling subscription that already + committed. Two consumers in the same module require distinct durable identities. +- The existing outbox role cannot insert audit rows. A terminal transition and its + MUST audit must commit together without granting business-table access. +- Leases, unknown outcomes and cancellation require at-least-once recovery, not an + exactly-once transport claim. A timeout cannot terminate arbitrary managed code. +- Four database roles, the ambient application transaction, tenant/organization + isolation and demand-gated cross-process transport remain the baseline. + +## Considered Options + +1. **Grant-bounded dispatcher and durable per-subscription delivery** (recommended). + Preserve required but unready work; separate producer and subscriber outcomes. +2. **Handler-owned, per-module inbox and repeated broadcast** (rejected). It + cannot distinguish two same-module subscriptions or isolate their retry budgets. +3. **Disable all production dispatch until P02b-3** (rejected). It avoids premature + acknowledgement by leaving P02b-2's production runtime unexercised. +4. **Audit terminalization on a separate connection** (rejected). A state change + can commit without its MUST row, or a success row without the state change. +5. **Strict head-of-line ordering per partition** (rejected). Poison and delayed + work would block later eligible facts; no current consumer requires that cost. +6. **Dapr/Kafka now** (rejected). No second consumer process triggers ADR-0035. + A broker would not decide inbox ownership, independent retries or audit policy. + +## Decision + +LearnStack proposes a leased, grant-bounded outbox dispatcher with explicit +subscription readiness, fenced retries and atomic audited terminalization. +Durable subscriber acceptance and a transport-owned shared inbox separate each +subscription's business outcome from producer dispatch. The following contract +becomes normative only on acceptance. + +### Producer dispatch and recovery + +- `id = EventId` and the assembly-qualified stored type identity survive ADR-0057. + Resolve only explicitly registered concrete contracts from known contract + assemblies, with explicit compatible aliases and retired identities. Never + load an assembly or resolve an arbitrary CLR type from stored text. Duplicate + identities, alias collisions and invalid registrations refuse composition. +- Deserialize using frozen `PayloadJsonOptions`. Check payload identity, tenant, + topic, partition and occurrence against the row; timestamp comparison accounts + for PostgreSQL microsecond precision. Validate the complete envelope and the + canonical reserialized payload's existing UTF-8 cap; `jsonb`'s text formatting + is not the producer byte-budget representation. Unknown, retired, malformed or + mismatched records request terminalization immediately with a closed reason. +- Construction-free manifests distinguish **Pending**, **Active** and explicitly + **IntentionallyUnsubscribed** contracts. Missing policy is not intentional + absence. Any required Pending subscription excludes that contract and its + aliases before acquisition; polling changes none of its payload, attempts, + lease, processed or terminal state. Active declarations without their required + handler registration refuse composition. Intentional absence may acknowledge + without publication; it is a separately tested policy, not a zero-handler guess. +- `TenantCreatedIntegrationEventV1` declares two required Pending subscriptions: + `customization.initialize-content-type-default.v1` and + `customization.initialize-taxonomy-default.v1`. P02b-3 activates them together + with durable acceptance. No placeholder consumer or global worker-disable flag + substitutes for this policy. +- Claim one eligible row just before invocation. A short transaction assigns a + fresh acquisition token to `locked_by`, stamps `locked_until` and increments + `attempts` once. Stable worker identity is diagnostic, not an ownership token. + Eligibility and expiry use database time. Lease fields are both null or both + present; processed and dead-lettered states are mutually exclusive. +- Every success, failure, release and terminal transition requires row identity, + the current token and an unexpired lease. Recovery first acquires its own fresh + token by atomically replacing an expired lease. A zero-row transition is lost + ownership: no late acknowledgement, error overwrite, lease clearance or audit. +- Six total attempts mean the initial claim and five retries, delayed by 1 s, + 5 s, 30 s, 5 min and 1 h. A claim committed before shutdown/crash counts even + when publication's outcome is unknown. Expired claims 1–5 enter backoff before + another normal claim; an expired sixth claim never invokes a seventh publish. +- An exhausted or invalid row records a closed `terminal_reason` request. A + terminalizer acquires it without incrementing attempts or publishing, then + atomically writes `dead_lettered_at` and its MUST audit. Audit failure retains + the request for terminalization retry; it cannot cause another delivery. + `terminal_reason` alone is not completed dead-letter state. Successful, retry + and terminal transitions clear their lease in the same short transaction. + Terminal state does not prove that no subscriber accepted an event: a timed-out + publication may already have committed durable acceptance. +- Dead-lettered rows are permanently excluded from dispatch and pending gauges. + A far-future `available_after` is not a terminal marker. Producer terminal rows + retain their envelope for investigation; operator replay is Phase 03, with a + separately specified reset/fencing contract before any endpoint ships. +- Eligible selection has a deterministic total order + `(available_after, occurred_at, id)`. This promises selection order for one + processor without failure, not enqueue/commit order, retry order or cross-process + order. Per-key in-process serialization does not strengthen that promise. + +### Subscriber acceptance and inbox ownership + +P02b-3 implements this coupled contract; P02b-2 does not claim an inbox or +effectively-once business effects. + +- Give each subscription an explicit stable ID, independent of handler class or + assembly name. A shared tenant-owned `inbox_messages` uses + `(tenant_id, subscription_id, event_id)` as its deduplication identity. +- The transport owns the inbox call. Inside an announced application-role + transaction, a uniqueness-backed claim serializes duplicate attempts **before + handler construction**. Business state, completed inbox marker, produced outbox, + MUST audit and fenced delivery-ledger completion commit together through + ADR-0040/0057's common owner lifecycle. Before owner commit, the ledger UPDATE + must match its token and database-time unexpired lease. A sticky completion gate + also refuses a timed-out/canceled attempt even if its token is unchanged. Zero + rows or a closed gate poison and roll back the whole transaction before COMMIT. + A failed transaction leaves neither completed inbox nor business effect. + Cancellation after physical COMMIT starts is Indeterminate under ADR-0033, + not guaranteed rollback; recover from the atomic ledger/inbox state. +- A separate tenant-owned delivery ledger retains each subscription's envelope, + attempts, availability, lease and terminal/replay state. A terminal subscriber + failure never marks its inbox processed. P02b-3 owns its canonical DDL, RLS, + exact grants and failure/terminal audit; no second dead-letter topic or duplicate + dead-letter table is required. +- `IEventBus.PublishAsync` keeps its non-generic envelope signature. Once P02b-3 + installs durable acceptance, success means every required delivery was durably + accepted, not that every handler succeeded. Duplicate acceptance is idempotent; + partial acceptance can safely be retried. Unknown acceptance leaves the producer + retryable. A subscriber scheduler retries one `(tenant, subscription, event)` + through an internal executor, never through another broadcast. +- The existing outbox role schedules cross-tenant delivery machinery using only + the minimal SELECT/status-UPDATE grants P02b-3 enumerates on that ledger. + The app role receives only tenant-RLS-protected acceptance and completion + permissions, including the named completion-column UPDATE; it cannot claim, + reset retry counters or schedule another tenant's work. Business execution + always opens a separate `learnstack_app` scope. It restores + the validated envelope tenant and organization before resolution, announces the + transaction, and executes as `UserId.SystemActor`; causal actor stays metadata. + Absence of the organization marker does not erase a present envelope org. A + truly tenant-wide fact deliberately carries null; the marker requires an org. +- One failed subscription cannot spend a sibling's or producer's retry budget. + Targeted replay resets only that subscription with fresh ownership fencing; + completed siblings remain deduplicated. P02b-3 supplies the internal replay + operation; Phase 03 supplies operator permission and HTTP surface. +- External effects need provider idempotency keys and reconciliation; an inbox + transaction cannot guarantee exactly once outside its database. Audit's + platform-scope erasure consumer requires its own privileged execution contract + in Phase 03, not a disguised ordinary application-role delivery. Its privileged + effect uses the audited `EnterPlatformAdminScope` path and idempotent progress/ + reconciliation; an app-role inbox cannot make two connections atomic. P3's + ordinary executor refuses privileged subscriptions until that explicit owner + supplies their execution contract. Phase 11 owns retention purge, not redaction. + +### Atomic terminal audit and privilege boundary + +- Keep the four-role model. Extend `learnstack_outbox_admin` only with the new + outbox status-column UPDATE grants and **INSERT-only** on `audit_log`; no audit + SELECT/UPDATE/DELETE, business-table access or privileged role memberships. + The audit grant belongs in a new Audit-chain migration, after `audit_log` exists. +- Add the fifth, narrow audit write seam + `IAuditStore.WriteEventTerminalAsync(AuditEntryDraft, DbConnection, + DbTransaction, CancellationToken)`. It verifies the declared terminal operation + and caller transaction, inserts only, and opens/commits no transaction itself. + P2 admits only the dispatcher terminal writer and the producer operation below. + P3 reserves the subscriber terminal writer and `platform.delivery.dead_letter`, + with stable subscription/event identity and the same-transaction proof; register + that operation only when its writer ships. Reject other callers, undeclared + operations and wrong operation class/type. `PostgresAuditStore` remains the only + audit SQL writer. Existing four writes retain their contracts. +- Declare `platform.outbox.dead_letter` off-path as `OperationClass.Must` and + `OperationType.SecurityEvent` in the Tenancy catalogue/matrix, beside the + existing infrastructure-owned `platform.admin_scope.enter` precedent. It needs + no synthetic business aggregate. The row records the stored real tenant/org, + SystemActor, event ID, closed reason, attempts and validated causal metadata. + Outcome success describes the committed terminal transition, not publication. + If stored correlation/causation/actor metadata is invalid, use a fresh validated + attempt trace and omit invalid causal values; never copy malformed metadata or + require a valid original envelope in order to audit its terminal refusal. +- The fenced terminal UPDATE and audit INSERT share one transaction. Failure of + either rolls back both; a stale claimant writes neither. An uncertain COMMIT is + reconciled from durable state before retrying; it cannot append a second audit + to an already terminal row. Metrics remain process observations, not an + exactly-once durable event ledger. +- Routine confined dispatcher bypass does not write one security-event audit per + poll/claim/mark. The exemption requires grant confinement, structured bounded + attempt outcomes, metrics/health and MUST-audited terminal transitions. It + changes no `EnterPlatformAdminScope` pre-entry audit or other bypass operation. + +### Diagnostics and execution bounds + +- Persist at most 256 UTF-8 bytes in `last_error`, enforced by a CHECK. Use closed + stage/reason/category codes, an optional allowlisted SQLSTATE and canonical + validated attempt traceparent. Never store exception messages, stacks, provider + details, SQL, connection strings, payloads or raw stored type text. Replace + existing free-form descriptors with a fixed legacy descriptor before the CHECK; + do not truncate potentially sensitive text. Preserve fact identity and state. +- New identity CHECKs refuse empty event/tenant IDs, the platform tenant sentinel + and empty optional organization IDs. Invalid legacy identities fail migration + for explicit repair; they are not silently rewritten or discarded to make an + audit insert possible. +- A named keyed, lazy dispatcher data source is API-host-owned, never Seeder- or + web-owned. Configuration validates the exact username without connecting or + exposing parse input. Boot verifies connectivity and every new physical + connection verifies exact session/current role, BYPASSRLS, NOSUPERUSER, + NOCREATEDB, NOCREATEROLE, NOREPLICATION and no privilege-escalating membership. +- Publish deadlines bound the dispatcher's wait, not arbitrary callback lifetime. + Cancel the callback, retain and observe its task-owned scope until it actually + finishes, and fence all late transitions. Do not dispose live resources or + accumulate unbounded abandoned tasks. Bounded overruns may let unrelated keys + progress; block their own partitions, mark health unhealthy and stop acquisition + when the approved outstanding-task bound is reached. Shutdown has a finite + wait; process exit and leases provide recovery, not forced managed-task killing. + P02b-3 must additionally fence its business transaction against late completion. +- Metrics have no tenant/event/partition/trace ID labels. Event types come from + the finite declared registry plus fixed unknown/retired/invalid categories. + Gauge callbacks read immutable memory snapshots, never perform SQL. Failed + refreshes retain their prior snapshot and become unhealthy rather than report + a fabricated empty queue. The outbox health check is registered, not publicly + mapped; `/healthz` stays liveness-only and Phase 11 owns readiness exposure. + +### Bounded supersession and additive seams + +On acceptance, the following replacements take effect only in their named scope. +They are new decisions, **not ADR-0041 corrections**. Original Accepted bodies +remain intact, with dated disclosure links naming this record and activation. + +| Authority | Replaced clause | Replacement / activation | +|---|---|---| +| [ADR-0006 Amendment 1](0006-events-and-outbox.md#amendment-1--dapr-pubsub-as-dispatch-transport-2026-05-18) | Consumer-side handler calls and own-module inbox/deduplication | Transport-owned shared inbox, stable subscription key; P02b-3 | +| [ADR-0010 Amendment 1](0010-cross-module-communication.md) | Idempotency's own-module inbox and handler-called guard, including its assertion | Shared transport-owned guard and corresponding catalogue rule; P02b-3 | +| [ADR-0038 Event contract](0038-cross-cutting-port-and-event-contracts.md#event-contract) | Publish reports immediate subscriber construction/invocation/disposal failures; future adapters preserve that outcome | Publish reports durable acceptance; separate subscription execution owns these failures; P02b-3 | +| [ADR-0003 Amendment 3](0003-tenant-isolation-defense-in-depth.md) | Outbox role restricted to outbox-only permissions | Named status writes plus audit INSERT in P02b-2; only named delivery machinery scheduling grants in P02b-3 | +| [ADR-0044 § 9](0044-audit-write-path.md#9-audit_log-and-audit_config-are-tenant-owned-org-scoped) | Outbox role has no `audit_log` permission | INSERT-only for confined atomic terminal audit; P02b-2 | +| [Audit Coverage](../standards/18-audit-coverage.md) | Every platform-bypass invocation receives a security-event row | Confined routine dispatcher exception above; P02b-2 | + +The fifth audit method is an **additive ADR-0044 seam amendment**, distinct from +the bounded grant-cell replacement above. Neither changes ADR-0033's +same-transaction guarantee. ADR-0033 receives a clarification of +that guarantee's new caller. P02b-3 adds inbox/delivery machinery to ADR-0044's +capture exclusions because explicit intents audit business effects; retry state +is not a second business aggregate. ADR-0023's inbox minting-list correction and +canonical Inbox DDL remain P02b-3 obligations, verified against acceptance history. +ADR-0038's envelope, frozen serializer, effective SystemActor, organization +restoration, per-key serialization and producer-only publication rules survive. + +## Context + +The current `PostgresOutbox` writes on the producer transaction, but no dispatcher +exists. `InProcessEventBus` constructs a scope per subscriber and collects their +failures. Treating that broadcast result as a durable per-subscriber outcome would +re-execute successful siblings. Treating zero handlers as unconditional success +would discard P1 provisioning events before their real subscribers exist. + +The current schema has neither leases nor explicit terminal state. The outbox +role can update four status columns and cannot write audit. Existing standalone +audit writes open another transaction; platform-scope writes have a different +credential and gate. Neither preserves atomic terminal-state auditing here. + +The queue and audit are first-party correctness machinery inside the existing +modular monolith. This selects no broker, fifth cross-module mechanism, new worker +host, operator endpoint or generic job runtime. Cross-process adapters still need +ADR-0035's trigger. G9 and ADR-0047 remain the next job-runtime decision, after P3. + +### What would change this decision + +A consumer requiring strict partition sequence, a second consumer process or +provider side effects without idempotent reconciliation requires a new bounded +decision. Schema aliases cannot silently turn an old fact into a breaking V2. + +## Consequences + +### Positive + +- P2 can activate its real production worker without losing P3's useful events. +- Subscriber deduplication and retries follow stable independent identities. +- Terminal state has a durable atomic audit and a privilege boundary tests can pin. +- Crash recovery, ordering and timeouts have falsifiable rather than absolute claims. + +### Negative + +- Pending contracts intentionally retain backlog until P3 activates their consumers. +- Terminalization adds a small explicit state machine and a narrow audit privilege. +- In-process callbacks that ignore cancellation can exhaust the bounded overrun + allowance. Health becomes unhealthy; an operator may need to restart the API. +- At-least-once recovery can repeat transport delivery; P3's inbox remains essential. + +### Neutral + +- No public contract changes; dead-letter operator routes belong to Phase 03. +- Erasure inventory belongs to P02b-8, privileged redaction to Phase 03 and + retention purge to Phase 11, matching the Audit module's existing ownership. +- The companion Hub roadmap's per-module-inbox dependency needs a coordinated + pointer update before P02b-3 closeout; this does not change Hub HTTPS contracts + or authorize Hub runtime work. + +## Implementation Notes + +P02b-2 implements producer claims/recovery/terminal audit, registry/manifests, +credential confinement, API lifecycle, diagnostics and health. P02b-3 implements +durable acceptance, inbox/delivery ledger, independent subscriber retries/audit, +targeted replay, both Customization defaults and Seeder semantic-ID handoff. +P3 must fence timed-out business transactions before enabling Active consumers. +It also reconciles existing bus/consumer exception logging and span capture with +its bounded failure policy; P2's minimized diagnostics cover the dispatcher-owned +path and do not claim existing transport telemetry is already minimized. + +At acceptance, reconcile Standards 05/10/18/20/21, architecture 15/31, glossary, +the phase register, Tenancy/Audit specs and applicable skills with the named +scope. Register tests before code; mark Implemented only with passing evidence. +Update `.env.example`, API/public-runtime/demo credential propagation and fixtures +with runtime delivery. An isolated test-owned worker replacement must not become +a production disabled-by-default switch. + +## Architecture Tests + +Existing rules to backfill or reconcile without weakening their intent: + +- `LearnStack_OutboxAdmin_Role_OnlyUsedBy_OutboxProcessor` — exact credential + confinement, with separate platform-key controls. +- `OutboxProcessor_NeverBlocks_OnSingleMessageFailure` — ordinary failure and + bounded cancellation-ignoring overrun, including safe scope ownership. +- `Outbox_Claim_IsHeld_Until_Dispatch_Completes` — two independent processors with + live leases; expiry, same-worker reacquisition and stale success/failure controls. +- `Integration_Event_Handlers_Use_InboxGuard` — P3 transport-owned invocation; + two same-module subscriptions and concurrent duplicates are distinct cases. + +Additional canonical rules to register on acceptance: + +- `Outbox_Retry_And_Terminal_Transitions_Are_Fenced`. +- `Outbox_Terminal_Transition_And_Must_Audit_Are_Atomic`. +- `Outbox_Type_Resolution_Is_Declared_And_Bounded`. +- `Outbox_Pending_Subscriptions_Preserve_Events`. +- `Outbox_Health_And_Metrics_Are_Bounded`. + +Behavioural rules live in integration tests; structural rules live in architecture +tests. Every structural census includes production subjects and planted dirty and +clean controls. P3 obligations remain Registered until P3 delivery. The package +names the decisive migration, deadline, contention, recovery and privilege proofs. + +## References + +- [ADR-0006](0006-events-and-outbox.md), [ADR-0010](0010-cross-module-communication.md) + and [ADR-0038](0038-cross-cutting-port-and-event-contracts.md). +- [ADR-0003](0003-tenant-isolation-defense-in-depth.md), + [ADR-0017](0017-tenant-organization-hierarchy.md), + [ADR-0036](0036-tenant-resolution-trusted-inputs.md), + [ADR-0040](0040-ambient-unit-of-work.md) and + [ADR-0057](0057-transactional-event-production-and-completion.md). +- [ADR-0033](0033-audit-durability-model.md), + [ADR-0044](0044-audit-write-path.md), + [Database Standards](../standards/05-database.md), + [Observability Standards](../standards/10-observability.md) and + [Events and Outbox](../architecture/15-event-and-outbox.md). +- [PostgreSQL 18 SELECT](https://www.postgresql.org/docs/18/sql-select.html) + (`SKIP LOCKED` queue use and locking privileges). +- [.NET hosted services](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services?view=aspnetcore-10.0) + (cooperative cancellation and bounded host shutdown). diff --git a/docs/decisions/README.md b/docs/decisions/README.md index c4f45bec..733cbc76 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -83,6 +83,7 @@ clauses and preserve the unaffected authority. | # | Title | Topic | Target phase / decision point | |---|---|---|---| +| 0046 | [Durable Event Delivery and Recovery](0046-durable-event-delivery-and-recovery.md) | Reserved delivery/inbox decision drafted 2026-10-12; approval pending, no gate accepted | P02b-2 before dispatcher code; same state-machine contract consumed by P02b-3; existing phase-exit SLA applies | | 0049 | [Institution Sites and an Optional Course Marketplace](0049-institution-sites-and-course-marketplace.md) | Direction endorsed 2026-10-02; architecture still Proposed, no gate accepted | P02d-2 preparation hold released; remaining contracts before proposed Phase 09a's first consumers | The maintainer endorsed the marketplace direction and accepted ADR-0050/0051 and @@ -146,7 +147,6 @@ 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.<n>` 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) | **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 diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index e46542db..b0b59896 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -23,6 +23,12 @@ merged** through [PR #29](https://github.com/HodeTech/LearnStack/pull/29) on 2026-10-12. The [merge closeout](#p02b-1-merge-and-closeout-2026-10-12) owns final verification and the next P02b-2 decision pass. +**P02b-2 decision pass — 2026-10-12.** The +[reviewed proposal](#p02b-2-decision-package-2026-10-12) and +[ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md) are Proposed, +awaiting maintainer approval. No dispatcher implementation has started and no +remaining gate closes from this draft. + **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 @@ -286,12 +292,12 @@ premise a row cites is re-verified at that pass rather than trusted. |---|---|---|---|---|---| | 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 | +| 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, deterministic eligible selection; no enqueue/commit-order guarantee | Derivation: [Accepted ADR-0057](../decisions/0057-transactional-event-production-and-completion.md). Ordering: Proposed ADR-0046 and the P02b-2 package, followed by [§ Ordering](../architecture/15-event-and-outbox.md#ordering) alignment at acceptance | 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 | Proposed ADR-0046 + dated ADR-0006 disclosure at acceptance; Database Standards § Outbox DDL, index and grant edits | 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 | +| 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`? | MUST `security-event`; producer `platform.outbox.dead_letter` and reserved P3 `platform.delivery.dead_letter`; fenced terminal transition and audit INSERT on the same short transaction through a narrow fifth write seam; failed audit retries terminalization without publication | Proposed ADR-0046 + bounded ADR-0044 audit-grant replacement, separate additive fifth-writer seam amendment and ADR-0033 same-transaction clarification at acceptance; `DeclareOffPath` registration and Audit Coverage baseline | 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 | Proposed ADR-0046 explicitly replaces the routine-dispatch coverage rule and bounds the ADR-0003 grant extension; dated disclosures at acceptance, not an ADR-0041 correction | 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 | @@ -1159,6 +1165,199 @@ records and all Accepted ADR files are byte-preserved. Full runtime suites are not rerun locally for this documentation-only change; the final PR artifacts above supply their evidence. +### P02b-2 decision package (2026-10-12) + +**Proposed — approval pending.** The documentation/code audit used `development` +at `b1f6df34b1d0db9dff77b355743d7bf8885947b3`, the P02b-1 merge closeout. +Three independent read-only audits covered delivery, privilege/audit and runtime/ +observability. They ran no tests and changed no files. The proposal uses reserved +[ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md); Accepted +ADR bodies and prior dated delivery records remain intact. Approval is required +before runtime code or the acceptance reconciliation below. + +#### Review findings and proposed answers + +The current bus's zero-handler success is unsafe for P1's real provisioning rows: +their consumers do not exist until P3. Separately, the outbox role cannot write +the MUST audit required by terminalization, and cancellation alone cannot bound +a callback that ignores its token. ADR-0046 explicitly resolves these gaps. + +| Gate / concern | Proposed answer | Delivery owner | +|---|---|---| +| G1 subscriber identity and isolation | Shared transport-owned inbox keyed by tenant, stable subscription ID and event ID; separate delivery ledger owns attempts, terminal state and targeted replay | Contract now; implementation P02b-3 | +| G3 ordering | Deterministic eligible selection by availability, occurrence and ID within one processor; no enqueue, commit, retry or cross-process ordering promise | P02b-2 | +| G4 attempts and terminal state | Six committed normal claims; five published retry delays; fresh-token crash recovery; explicit terminal request and audited terminal timestamp; no seventh publish | P02b-2 | +| G5 type and zero-subscriber policy | Declared concrete types/compatible aliases only; invalid/retired/unknown records terminalize; Pending required subscriptions preserve rows; only intentional absence acknowledges without publication | P02b-2; Active durable subscribers P02b-3 | +| G7 terminal audit | Fenced state transition plus MUST security-event on one transaction through `WriteEventTerminalAsync`; `platform.outbox.dead_letter` now, reserved `platform.delivery.dead_letter` when P3's writer ships; failed audit retries terminalization without delivery | P02b-2 producer; P02b-3 subscriber | +| G8 bypass | Explicit routine-dispatch exception, confined credential and exact grants; existing platform-scope entry audit unchanged | P02b-2 | +| G17 metric dimensions | No tenant labels; registry-bounded event types; separate backlog, eligible age, deferred backlog and heartbeat; immutable gauge snapshots | P02b-2 | +| G18 failure storage | Closed descriptor, at most 256 UTF-8 bytes with CHECK; replace free-form legacy descriptors without changing fact/state | P02b-2; erasure inventory P02b-8 | +| Credential topology | API-owned keyed lazy source; exact physical outbox-role checks; boot connectivity; no Seeder/web credential propagation | P02b-2 | +| G6/G19 dependency | Preserve common owner, envelope organization and effective SystemActor; activate both real defaults subscriptions only with durable acceptance and Seeder handoff | P02b-3 | + +The exact normative replacements and additive audit seam are enumerated in +[ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md#bounded-supersession-and-additive-seams). +They are not ADR-0041 errata. At acceptance, append dated disclosures to +ADR-0003/0006/0010/0038, the bounded audit-grant replacement disclosure and +separate fifth-writer seam amendment to ADR-0044, and the same-transaction +clarification to ADR-0033. Keep original acceptance blocks, +decisions and earlier amendments byte-preserved. G1's rule changes are recorded +now but their implementation remains Registered for P3. + +#### Runtime bounds and observation + +These are approval candidates, not implemented defaults. Options reject invalid +or unbounded values at composition; tests can inject time and tighter positive +bounds. Lease ownership always uses PostgreSQL time, never a caller's clock. + +| Setting / policy | Initial approved candidate | +|---|---| +| Worker and claim capacity | One worker; one just-in-time normal claim | +| Idle poll | 200 ms | +| SQL operation / complete terminal transaction deadline | 5 s | +| Publish wait deadline | 10 s | +| Lease | 30 s; validate margin for publish plus fenced transition | +| Progress health bound | 45 s without actual completed progress | +| Snapshot refresh | After completed iterations; idle target 5 s; stale at the 45 s health bound | +| Cancellation-ignoring callbacks | At most four retained tasks; block their partitions; unhealthy while any remain; stop new claims at four | +| Shutdown wait | At most 30 s; retain live task-owned resources until completion or process exit | +| Attempts and backoff | Six normal claims total; 1 s, 5 s, 30 s, 5 min, 1 h | +| Durable failure descriptor | Closed vocabulary, at most 256 UTF-8 bytes | + +A publish timeout cancels the callback and persists its fenced failure, or leaves +an unknown outcome for expired-lease recovery if persistence fails. The callback +keeps ownership of its scope until actual completion; its late result cannot +acknowledge the row. Exclude its partition from new local claims until it finishes. +Unrelated partitions can progress within the four-task allowance. Saturation +stops acquisition and reports unhealthy; no unbounded detached-task replacement +or forced managed-task termination is claimed. P3 must also fence its business +transaction before enabling Active production subscribers. + +Its completion gate must reject a canceled/timed-out attempt before COMMIT even +if failure persistence did not replace its token. Cancellation after physical +COMMIT starts remains Indeterminate under ADR-0033; P3 recovers from atomic +business/inbox/ledger state rather than promise rollback at that boundary. + +Pending count includes all unprocessed, nonterminal rows, including delayed, +leased, terminal-requested and manifest-deferred work. Eligible age excludes +future availability, live leases and held/locally blocked partitions; it is zero +when none qualify and never negative. Deferred count exposes manifest-held work. +Heartbeat advances only after actual bounded loop work, including an empty poll, +not from an independent timer. Failed refresh retains the last snapshot and +marks observation stale. Terminal-audit failure is unhealthy, never a fabricated +successful empty queue. `/healthz` remains liveness-only. + +Standards 10 owns the final metric inventory at acceptance. Preserve existing +outbox duration/failure/dead-letter/lease-loss names with registry-bounded +`event_type` where applicable. Add unlabelled +`learnstack_outbox_oldest_eligible_age_seconds`, +`learnstack_outbox_deferred_count` and +`learnstack_outbox_heartbeat_age_seconds`; pending count becomes unlabelled. +Inbox counters remain reserved for P3, and job metrics for P4. Gauge callbacks +perform no SQL. Dispatcher-owned logs, traces and failure descriptors use validated +correlation and bounded outcomes, without exception text, payload, raw stored type +or connection data. Current bus/consumer exception telemetry is a separate P3 +reconciliation obligation before Active production subscriptions; this packet +does not claim that existing transport diagnostics are already minimized. + +#### Four implementation steps after approval + +Each step implements and commits on `development`, then runs two fresh independent +review rounds. Verify findings against current code, fix valid findings, validate +and commit fixes before the next round/step. A clean review needs no empty commit. +The loop continues automatically unless a blocker or new maintainer decision +requires input. No branch switch or history rewrite is part of this package. + +1. **Decision reconciliation, schema and confined composition.** Accept ADR-0046 + and the gate parts above before code. Register catalogue obligations; update + canonical Outbox DDL/indexes/grants, metric labels, audit policy, architecture + and glossary. Add Platform-chain lease/terminal/descriptor/identity migration + and Audit-chain INSERT-grant migration. Declare registry/aliases/readiness, + keyed credential validation and physical-role guard. Adapt test-owned host + composition explicitly; production registration stays enabled. Validate exact + privilege sets, forward/down/reapply and invalid legacy input. No handler or + arbitrary type is constructed to discover subscription metadata. +2. **Durable transitions and terminal audit.** Implement parameterized claim, + recovery, success, backoff, terminal-request and atomic terminal-audit paths. + Add the confined fifth store method, off-path catalogue/matrix row and narrow + call-site guard. Prove token/expiry fences, same-worker reclaim, six-claim crash, + no seventh publish, uncertain completion and audit rollback on real PostgreSQL + using actual outbox/application roles. Intentional absence and Pending holds + must have distinct non-vacuous controls. +3. **Hosted lifecycle and observability.** Activate the API BackgroundService; + implement bounded waits/overruns/shutdown, progress health, safe traces and + snapshot metrics. Adapt the API launcher/demo environment allowlist and its + proofs without forwarding the credential to web/Seeder. Prove production + registration, startup rejection/valid controls, poison-row progress, ignored + cancellation, safe disposal and continued unrelated work within the bound. +4. **Enforcement and closeout.** Complete production-census and planted controls, + full Release regression/format, mandatory Docker proofs and applicable demo/ + frontend tooling checks. Reconcile current-state docs, actual test counts, + catalogue status and seven existing CI checks at the exact PR head. Create the + PR for maintainer review only after every step/review is complete; claim no + merge or P3 delivery from P2 closeout. + +#### Decisive proof obligations + +- Two independent processors contend under live leases; a release-on-read mutant + fails. Expiry allows redelivery; both stale success and stale failure lose the + token fence, including reacquisition by the same worker identity. This is not + a claim of two separate processes or exactly-once publication. +- Retry boundaries use controllable time/database availability, not wall-clock + sleeps. Crash recovery after claim six and audit refusal cannot cause a seventh + publish. Reconcile lost/uncertain terminal COMMIT before retrying so a completed + terminal audit is not duplicated. Terminal, delayed, leased, deferred and due + rows distinguish all queue gauges. +- Known V1 payloads remain readable after declared deployment aliases. Reverse + commits, equal occurrence times and retry overtaking falsify stronger ordering. + Unknown/retired/malformed/mismatched rows do not trigger arbitrary type loading. +- Real TenantCreated rows survive repeated Pending polls without attempts or + constructions. P3's activation proof must later deliver them to both stable IDs. +- Missing/malformed/wrong-user credentials refuse without secret text; valid + configuration is lazy; real physical guards reject elevated/transitive roles. + Audit INSERT is allowed; audit read/update/delete and business access are not. +- Cancellation-ignoring operations retain safe resource ownership, cannot write + late acknowledgements and cannot grow beyond the approved bound. A blocked or + saturated loop becomes unhealthy while `/healthz` still returns 200. +- Before P3 activates subscribers, its proof must include failed timeout + persistence followed by late business completion and timeout during physical + COMMIT on the final subscriber claim; atomic ledger/inbox state decides recovery. +- Structural censuses include production subjects and planted clean/dirty cases + for each key's resolution, publication, type loading and write confinement. + Behavioural controls mutate lease/terminal/audit safeguards to prove sensitivity. + +#### Scope retained by later owners + +P3 owns shared Inbox/delivery DDL and RLS, per-subscription business/audit frames, +retries, terminal audit and targeted replay, both Customization defaults and +Seeder identity handoff. That boundary is required by the packet dependency, +not an unowned deferral. The companion Hub roadmap's per-module inbox reference +needs a coordinated dependency-pointer update before P3 closeout; Hub remains +roadmap owner and this package adds no Hub endpoint or runtime work. P4 owns jobs; +Phase 03 owns operator routes/permissions; P02b-8 owns erasure inventory; Phase 11 +owns brokers, cross-process evidence, purge and readiness exposure. + +#### Proposal review and validation (2026-10-12) + +Two independent review rounds completed before maintainer approval. Round 1 used +separate GPT-6.1-sol xhigh delivery and security/corpus reviewers. Verified fixes +separate the audit-grant replacement from the additive fifth seam, allow safe +terminal auditing of malformed causal metadata, scope diagnostic minimization to +the dispatcher, and require atomic subscriber ledger completion plus a sticky +timeout gate. Cancellation after physical COMMIT starts remains Indeterminate. +Both reviewers approved the corrected proposal. + +Round 2 used fresh GPT-6.1-sol xhigh delivery and GPT-6-astra high corpus/privilege +reviewers. Both approved without actionable findings. Reviews were read-only; +they did not run runtime suites or claim implementation evidence. + +Documentation-only validation passes: 266 local Release architecture cases, +seven ADR lifecycle/workflow controls, 329 local links and 119 fragments across +the four changed Markdown files, prose wrapping and `git diff --check`. Prior +P02b-0/P02b-1 dated delivery records and all Accepted ADR files are byte-preserved. +Full runtime/Docker suites remain implementation-step obligations; no dispatcher, +inbox or gate delivery is claimed by this proposal. + ### Durable outbox dispatch P02b-1 Steps 2/3 implement `IOutbox`, replace Packet 3's step-7 shell with @@ -1236,10 +1435,11 @@ Dapr trigger verbatim — so it stays with Phase 11. `partition_key` is on the row and `InProcessEventBus` serialises per key within a process. What a key guarantees across commits, retries and processes is **G3**: no column obtainable from `outbox_messages` carries an unconditional per-key publish-order -guarantee — `occurred_at` is the transaction's start time and is identical for every row -one transaction writes, the `uuidv7()` id is minted at `INSERT`, a failed row's -`available_after` moves past its own successor, and an unordered `RETURNING` has no -defined row order. The completion criterion follows the gate, and +guarantee. P02b-1 supplies the fact's occurrence time and EventId explicitly at +enqueue; neither establishes commit order. A failed row's `available_after` +moves past its own successor, and an unordered `RETURNING` has no defined row +order. G3's proposed selection bound is in the P02b-2 package; it is not an +enqueue-order promise. The completion criterion follows the gate, and [§ Ordering](../architecture/15-event-and-outbox.md#ordering) is edited to match it. #### Domain events collected and dispatched From 4b010efcf67369165c871067f7883e86256bc8b1 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 19:18:02 +0300 Subject: [PATCH 67/92] docs(decisions): tighten P02b-2 recovery and audit contracts Bind terminal audit identity to the actual fenced transition and name negative controls for the bypass-role write boundary. Clarify Pending holds, publication eligibility, inbox completion and retry ownership. Record verified external-review dispositions and exact acceptance carrier replacements without changing Accepted ADRs or runtime code. Keep the revised decision package Proposed pending maintainer approval. ADR: 0046, 0044, 0057 --- ...046-durable-event-delivery-and-recovery.md | 151 ++++++++++++++---- docs/roadmap/phase-02b-events-auth.md | 86 ++++++++-- 2 files changed, 197 insertions(+), 40 deletions(-) diff --git a/docs/decisions/0046-durable-event-delivery-and-recovery.md b/docs/decisions/0046-durable-event-delivery-and-recovery.md index 9f62b9d9..906c4f2f 100644 --- a/docs/decisions/0046-durable-event-delivery-and-recovery.md +++ b/docs/decisions/0046-durable-event-delivery-and-recovery.md @@ -15,7 +15,7 @@ either is implemented. Acceptance does not claim delivery. ## Decision Drivers - P02b-1 already commits real `TenantCreatedIntegrationEventV1` rows. The current - bus acknowledges zero subscribers; its two defaults consumers arrive in P02b-3. + bus acknowledges zero subscribers; its two default consumers arrive in P02b-3. Starting dispatch without an explicit admission policy would discard their work. - Producer retries must not repeatedly execute a sibling subscription that already committed. Two consumers in the same module require distinct durable identities. @@ -56,7 +56,8 @@ becomes normative only on acceptance. assemblies, with explicit compatible aliases and retired identities. Never load an assembly or resolve an arbitrary CLR type from stored text. Duplicate identities, alias collisions and invalid registrations refuse composition. -- Deserialize using frozen `PayloadJsonOptions`. Check payload identity, tenant, +- For identities not held by the Pending policy below, deserialize using frozen + `PayloadJsonOptions`. Check payload identity, tenant, topic, partition and occurrence against the row; timestamp comparison accounts for PostgreSQL microsecond precision. Validate the complete envelope and the canonical reserialized payload's existing UTF-8 cap; `jsonb`'s text formatting @@ -66,20 +67,30 @@ becomes normative only on acceptance. **IntentionallyUnsubscribed** contracts. Missing policy is not intentional absence. Any required Pending subscription excludes that contract and its aliases before acquisition; polling changes none of its payload, attempts, - lease, processed or terminal state. Active declarations without their required + lease, processed or terminal state, even if its stored payload is malformed. + This hold precedes payload validation/terminalization; activation later admits + those checks. Active declarations without their required handler registration refuse composition. Intentional absence may acknowledge without publication; it is a separately tested policy, not a zero-handler guess. - `TenantCreatedIntegrationEventV1` declares two required Pending subscriptions: - `customization.initialize-content-type-default.v1` and - `customization.initialize-taxonomy-default.v1`. P02b-3 activates them together - with durable acceptance. No placeholder consumer or global worker-disable flag - substitutes for this policy. + `customization.initialize-content-type-default.v1` initializes the `card@1` + content type (`BuiltInCustomizations.Card`); + `customization.initialize-taxonomy-default.v1` initializes the `plain@1` level + taxonomy (`BuiltInCustomizations.Plain`). These are ADR-0057's existing two + effects, not two content types. P02b-3 activates them together with durable + acceptance. No placeholder consumer or global worker-disable flag substitutes + for this policy. - Claim one eligible row just before invocation. A short transaction assigns a fresh acquisition token to `locked_by`, stamps `locked_until` and increments `attempts` once. Stable worker identity is diagnostic, not an ownership token. Eligibility and expiry use database time. Lease fields are both null or both present; processed and dead-lettered states are mutually exclusive. -- Every success, failure, release and terminal transition requires row identity, +- Normal publication claims require `processed_at IS NULL`, + `dead_lettered_at IS NULL`, `terminal_reason IS NULL`, `attempts < 6`, due + availability and no live lease, and must pass manifest/local-partition admission. + Expired prior claims go through recovery before becoming publishable again. + Requested or exhausted rows enter terminalization only, never normal publication. +- Every success, failure, recovery and terminal transition requires row identity, the current token and an unexpired lease. Recovery first acquires its own fresh token by atomically replacing an expired lease. A zero-row transition is lost ownership: no late acknowledgement, error overwrite, lease clearance or audit. @@ -103,6 +114,9 @@ becomes normative only on acceptance. `(available_after, occurred_at, id)`. This promises selection order for one processor without failure, not enqueue/commit order, retry order or cross-process order. Per-key in-process serialization does not strengthen that promise. + The normal pending partial index uses that same three-column order and excludes + processed, terminal-requested and dead-lettered rows; recovery/terminalization + receive separate matching indexes in the canonical DDL and migration. ### Subscriber acceptance and inbox ownership @@ -110,7 +124,13 @@ P02b-3 implements this coupled contract; P02b-2 does not claim an inbox or effectively-once business effects. - Give each subscription an explicit stable ID, independent of handler class or - assembly name. A shared tenant-owned `inbox_messages` uses + assembly name. Its closed ASCII grammar is `{module}.{subscription}.v{N}`: + lowercase letter/digit module, lowercase letter/digit/hyphen subscription, + both starting with a letter, and positive decimal N without leading zeros. + N versions the subscription's deduplication semantics, not the event or built-in + revision. Renaming creates a new identity and needs an explicit migration/replay + decision; a handler refactor preserves its ID. Duplicate IDs refuse composition. + A shared tenant-owned `inbox_messages` uses `(tenant_id, subscription_id, event_id)` as its deduplication identity. - The transport owns the inbox call. Inside an announced application-role transaction, a uniqueness-backed claim serializes duplicate attempts **before @@ -123,28 +143,43 @@ effectively-once business effects. A failed transaction leaves neither completed inbox nor business effect. Cancellation after physical COMMIT starts is Indeterminate under ADR-0033, not guaranteed rollback; recover from the atomic ledger/inbox state. -- A separate tenant-owned delivery ledger retains each subscription's envelope, +- The inbox claim is an uncommitted row on that same business transaction; only + successful completion persists it with a completed marker. Rollback removes a + new claim. A committed completed row is never reset by targeted replay; the + delivery ledger owns incomplete/terminal/replay state, not a failed inbox row. +- A separate tenant-owned delivery ledger durably retains the validated envelope, attempts, availability, lease and terminal/replay state. A terminal subscriber failure never marks its inbox processed. P02b-3 owns its canonical DDL, RLS, exact grants and failure/terminal audit; no second dead-letter topic or duplicate - dead-letter table is required. + dead-letter table is required. Its envelope must survive producer-row purge. + P02b-3 must choose a per-subscription copy or shared tenant/event envelope before + DDL, name the resulting copies/references and retention dependency, and add all + retained envelope stores to P02b-8's erasure inventory. No new business payload + or arbitrary metadata is introduced by retaining the producer envelope. - `IEventBus.PublishAsync` keeps its non-generic envelope signature. Once P02b-3 installs durable acceptance, success means every required delivery was durably accepted, not that every handler succeeded. Duplicate acceptance is idempotent; partial acceptance can safely be retried. Unknown acceptance leaves the producer retryable. A subscriber scheduler retries one `(tenant, subscription, event)` - through an internal executor, never through another broadcast. + through an internal delivery executor, never through another broadcast. + The executor invokes that subscription, not `PublishAsync`; the outbox processor + remains the only production publisher and receives no new publication exception. - The existing outbox role schedules cross-tenant delivery machinery using only - the minimal SELECT/status-UPDATE grants P02b-3 enumerates on that ledger. + the column-level scheduling-identity/routing/lease/status SELECT and status-UPDATE + grants P02b-3 enumerates on that ledger; no envelope/payload SELECT is granted. The app role receives only tenant-RLS-protected acceptance and completion permissions, including the named completion-column UPDATE; it cannot claim, reset retry counters or schedule another tenant's work. Business execution always opens a separate `learnstack_app` scope. It restores the validated envelope tenant and organization before resolution, announces the - transaction, and executes as `UserId.SystemActor`; causal actor stays metadata. + transaction, reads and validates its envelope under RLS by claimed identity/token, + then constructs the handler and executes as `UserId.SystemActor`; causal actor + stays metadata. Absence of the organization marker does not erase a present envelope org. A truly tenant-wide fact deliberately carries null; the marker requires an org. -- One failed subscription cannot spend a sibling's or producer's retry budget. +- After durable acceptance, a subscription's business failure cannot spend a + sibling's or producer's retry budget. Failed/unknown durable acceptance still + consumes producer attempts; it is not a subscriber execution outcome. Targeted replay resets only that subscription with fresh ownership fencing; completed siblings remain deduplicated. P02b-3 supplies the internal replay operation; Phase 03 supplies operator permission and HTTP surface. @@ -164,29 +199,48 @@ effectively-once business effects. SELECT/UPDATE/DELETE, business-table access or privileged role memberships. The audit grant belongs in a new Audit-chain migration, after `audit_log` exists. - Add the fifth, narrow audit write seam - `IAuditStore.WriteEventTerminalAsync(AuditEntryDraft, DbConnection, - DbTransaction, CancellationToken)`. It verifies the declared terminal operation - and caller transaction, inserts only, and opens/commits no transaction itself. + `IAuditStore.WriteEventTerminalAsync(AuditEntryDraft, EventTerminalTransition, + DbConnection, DbTransaction, CancellationToken)`. The immutable transition + witness is minted only from the successful fenced terminal `UPDATE ... RETURNING` + in that live transaction, never from caller-supplied expected identity. Restricted + construction and a planted architecture census confine that minting path. + The witness binds connection/transaction, acquisition, actual stored tenant/org, + event, producer/subscriber kind, reason, attempts and, for a subscriber, its ID. + Before INSERT, the seam validates the declared operation/class/type, live caller + transaction and every corresponding draft identity against that witness; a + mismatch throws and requires rollback of both writes. It inserts only and + opens/commits no transaction itself. P2 admits only the dispatcher terminal writer and the producer operation below. P3 reserves the subscriber terminal writer and `platform.delivery.dead_letter`, - with stable subscription/event identity and the same-transaction proof; register - that operation only when its writer ships. Reject other callers, undeclared + as off-path MUST/SecurityEvent in the same Tenancy catalogue/matrix, with stable + subscription/event identity and the same-transaction proof; register that + operation only when its writer ships. Reject other callers, undeclared operations and wrong operation class/type. `PostgresAuditStore` remains the only audit SQL writer. Existing four writes retain their contracts. - Declare `platform.outbox.dead_letter` off-path as `OperationClass.Must` and `OperationType.SecurityEvent` in the Tenancy catalogue/matrix, beside the existing infrastructure-owned `platform.admin_scope.enter` precedent. It needs - no synthetic business aggregate. The row records the stored real tenant/org, + no synthetic business aggregate. SecurityEvent records the consequential + BYPASSRLS terminal state change; routine polling/claims retain the separate + bounded exemption below. The row records the stored real tenant/org, SystemActor, event ID, closed reason, attempts and validated causal metadata. Outcome success describes the committed terminal transition, not publication. If stored correlation/causation/actor metadata is invalid, use a fresh validated attempt trace and omit invalid causal values; never copy malformed metadata or require a valid original envelope in order to audit its terminal refusal. + Closed terminal reason/attempts/subscription values live in bounded metadata; + `AuditEntryDraft.Reason` remains reserved for platform-scope entry reasons. - The fenced terminal UPDATE and audit INSERT share one transaction. Failure of either rolls back both; a stale claimant writes neither. An uncertain COMMIT is reconciled from durable state before retrying; it cannot append a second audit to an already terminal row. Metrics remain process observations, not an exactly-once durable event ledger. +- The audit INSERT's tenant binding is a witness/call-path invariant, not RLS: + the outbox role bypasses that policy. Neither announcing GUCs nor INSERT-only + grants can bind it to one tenant. Tests must substitute another tenant, org or + event against a real fenced transition and prove both writes roll back, alongside + a matching organization-scoped success. This confines sanctioned code; it does + not sandbox arbitrary SQL using a compromised bypass credential. - Routine confined dispatcher bypass does not write one security-event audit per poll/claim/mark. The exemption requires grant confinement, structured bounded attempt outcomes, metrics/health and MUST-audited terminal transitions. It @@ -217,8 +271,9 @@ effectively-once business effects. when the approved outstanding-task bound is reached. Shutdown has a finite wait; process exit and leases provide recovery, not forced managed-task killing. P02b-3 must additionally fence its business transaction against late completion. -- Metrics have no tenant/event/partition/trace ID labels. Event types come from - the finite declared registry plus fixed unknown/retired/invalid categories. +- Metrics prohibit `tenant_id`, `event_id`, `partition_key`, trace identifiers and + other unbounded identity labels. `event_type` is allowed only from the finite + declared registry plus fixed unknown/retired/invalid categories. Gauge callbacks read immutable memory snapshots, never perform SQL. Failed refreshes retain their prior snapshot and become unhealthy rather than report a fabricated empty queue. The outbox health check is registered, not publicly @@ -233,15 +288,18 @@ remain intact, with dated disclosure links naming this record and activation. | Authority | Replaced clause | Replacement / activation | |---|---|---| | [ADR-0006 Amendment 1](0006-events-and-outbox.md#amendment-1--dapr-pubsub-as-dispatch-transport-2026-05-18) | Consumer-side handler calls and own-module inbox/deduplication | Transport-owned shared inbox, stable subscription key; P02b-3 | -| [ADR-0010 Amendment 1](0010-cross-module-communication.md) | Idempotency's own-module inbox and handler-called guard, including its assertion | Shared transport-owned guard and corresponding catalogue rule; P02b-3 | +| [ADR-0010 Amendment 1](0010-cross-module-communication.md#amendment-1--dapr-pubsub-as-outbox-dispatch-target-2026-05-18) | Idempotency's own-module inbox and handler-called guard, including its assertion | Shared transport-owned guard and corresponding catalogue rule; P02b-3 | | [ADR-0038 Event contract](0038-cross-cutting-port-and-event-contracts.md#event-contract) | Publish reports immediate subscriber construction/invocation/disposal failures; future adapters preserve that outcome | Publish reports durable acceptance; separate subscription execution owns these failures; P02b-3 | -| [ADR-0003 Amendment 3](0003-tenant-isolation-defense-in-depth.md) | Outbox role restricted to outbox-only permissions | Named status writes plus audit INSERT in P02b-2; only named delivery machinery scheduling grants in P02b-3 | +| [Database Standards GRANT matrix](../standards/05-database.md#grant-matrix) and [Outbox grants](../standards/05-database.md#outbox) | Outbox-only SELECT and four-column status UPDATE; no audit privilege | Named status writes plus audit INSERT in P02b-2; only named delivery machinery scheduling grants in P02b-3 | | [ADR-0044 § 9](0044-audit-write-path.md#9-audit_log-and-audit_config-are-tenant-owned-org-scoped) | Outbox role has no `audit_log` permission | INSERT-only for confined atomic terminal audit; P02b-2 | | [Audit Coverage](../standards/18-audit-coverage.md) | Every platform-bypass invocation receives a security-event row | Confined routine dispatcher exception above; P02b-2 | The fifth audit method is an **additive ADR-0044 seam amendment**, distinct from the bounded grant-cell replacement above. Neither changes ADR-0033's -same-transaction guarantee. ADR-0033 receives a clarification of +same-transaction guarantee. ADR-0003 Amendment 3's four roles and RLS postures +remain unchanged; its dated disclosure points to the actual grant-budget extension +in Database Standards, rather than claiming an outbox-only clause it never stated. +ADR-0033 receives a clarification of that guarantee's new caller. P02b-3 adds inbox/delivery machinery to ADR-0044's capture exclusions because explicit intents audit business effects; retry state is not a second business aggregate. ADR-0023's inbox minting-list correction and @@ -310,13 +368,35 @@ It also reconciles existing bus/consumer exception logging and span capture with its bounded failure policy; P2's minimized diagnostics cover the dispatcher-owned path and do not claim existing transport telemetry is already minimized. -At acceptance, reconcile Standards 05/10/18/20/21, architecture 15/31, glossary, +At acceptance, reconcile Standards 05/09/10/11/18/20/21, architecture 15/31, glossary, the phase register, Tenancy/Audit specs and applicable skills with the named scope. Register tests before code; mark Implemented only with passing evidence. Update `.env.example`, API/public-runtime/demo credential propagation and fixtures with runtime delivery. An isolated test-owned worker replacement must not become a production disabled-by-default switch. +The acceptance reconciliation explicitly replaces these non-ADR carriers, without +leaving competing current designs. Target P3 text stays labelled unimplemented: + +| Carrier | Text to reconcile | Replacement / activation | +|---|---|---| +| Architecture 15: claim protocol, simpler alternative and processor guarantees | BatchSize 100, processor-ID lease, batch-held fallback and lease clearing | One just-in-time claim, fresh acquisition token and only the fenced transitions above; P02b-2 | +| Architecture 15: Inbox table and consumer pattern; glossary `IInboxGuard` | Per-module table and handler-called guard | Shared transport-owned claim/completion contract; decision at acceptance, implementation P02b-3 | +| Architecture 15: Dead-letter, both sides | Future-availability terminal hack, `learnstack.dlq.{module}`, `dead_letter_messages`, broadcast replay and `OutboxStatusEndpoints` | Explicit producer terminal state and subscriber delivery-ledger terminal state; targeted replay internal P02b-3, operator routes Phase 03, broker adapter still demand-gated | +| Glossary `OutboxProcessor` and `locked_by` descriptions | Batch claim and stable processor identity | Single-row acquisition tokens; stable worker identity is bounded diagnostic context, not persisted ownership; P02b-2 | +| Standards 09: Outbox | Catch handler failures, then increment producer attempts and log exceptions | Claim-counted producer attempts, bounded dispatcher diagnostics, and separate post-acceptance subscriber outcomes in P02b-3 | +| Standards 10: Spans and Required Metrics | Dispatcher batch spans and tenant-labelled pending count | Per-attempt spans, unlabelled pending count and the package's bounded metric inventory; P02b-2 | +| Standards 18 join grammar and Standards 21 audit-matrix rule | Seven off-path rows | Reconcile actual/planned row enumeration with producer registration in P02b-2 and subscriber registration in P02b-3 | +| Standards 21 `Outbox_Claim_IsHeld_Until_Dispatch_Completes` | Unconditional exactly-once dispatch, batches and two processes | At most one live owner under two independent processor instances, plus expiry/crash redelivery; P02b-2 | +| Standards 21 `OutboxProcessor_NeverBlocks_OnSingleMessageFailure` | Rest-of-batch premise | Continued unrelated eligible work with ordinary failure and bounded overruns; P02b-2 | +| Phase 02b: packet targets, Durable outbox dispatch and Completion Criteria | Unconditional zero-subscriber acknowledgement, batch/terminal sketches and dispatcher-connection replay as a settled operator contract | Explicit intentional absence versus Pending hold, the claim/terminal rules above, and separately decided Phase 03 operator replay; reconcile at acceptance, preserve dated P02b-1 history | + +The superseded consumer subscription sketch does not introduce +`IModule.ConfigureEventHandlers`: no such repository type exists. P02b-3 uses +the declared manifest and the actual composition APIs. Historical P02b-1 "Next" +and "reserved and undrafted" statements are as-of records; the current P02b-2 +package and Proposed index own navigation to this drafted record. + ## Architecture Tests Existing rules to backfill or reconcile without weakening their intent: @@ -334,14 +414,21 @@ Additional canonical rules to register on acceptance: - `Outbox_Retry_And_Terminal_Transitions_Are_Fenced`. - `Outbox_Terminal_Transition_And_Must_Audit_Are_Atomic`. +- `Outbox_Terminal_Audit_Identity_Is_Row_Bound` — restricted witness construction + census and mismatched tenant/org/event/transaction rollback controls. - `Outbox_Type_Resolution_Is_Declared_And_Bounded`. - `Outbox_Pending_Subscriptions_Preserve_Events`. - `Outbox_Health_And_Metrics_Are_Bounded`. Behavioural rules live in integration tests; structural rules live in architecture tests. Every structural census includes production subjects and planted dirty and -clean controls. P3 obligations remain Registered until P3 delivery. The package -names the decisive migration, deadline, contention, recovery and privilege proofs. +clean controls. P02b-2's production subjects are the registered worker, resolver, +claim/transition store and audit writer, not nonexistent Active handlers. Real +Pending rows prove holds; test-owned Active subscriptions exercise the same shipped +publish/mark path with real PostgreSQL. Handler/durable-acceptance censuses remain +Registered until P02b-3 and must not pass on an empty production-handler set. +The package names the decisive migration, deadline, contention, recovery and +privilege proofs. ## References @@ -352,6 +439,12 @@ names the decisive migration, deadline, contention, recovery and privilege proof [ADR-0036](0036-tenant-resolution-trusted-inputs.md), [ADR-0040](0040-ambient-unit-of-work.md) and [ADR-0057](0057-transactional-event-production-and-completion.md). +- [ADR-0023](0023-strongly-typed-id-source-generator.md), + [ADR-0035](0035-demand-gated-infrastructure.md), + [ADR-0041](0041-correcting-false-statements-in-accepted-adrs.md) and + [ADR-0042](0042-tenant-provisioning-cross-aggregate-transaction.md). + [ADR-0047's reservation](README.md#open-adr-drafts) is the later job-runtime + decision, not an existing ADR file. - [ADR-0033](0033-audit-durability-model.md), [ADR-0044](0044-audit-write-path.md), [Database Standards](../standards/05-database.md), diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index b0b59896..7f6dd633 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -243,10 +243,12 @@ Decisions made or referenced in this phase: - [ADR-0044 Audit Write Path](../decisions/0044-audit-write-path.md) - [ADR-0045 Entitlement and Feature-Flag Socket](../decisions/0045-entitlement-and-feature-flag-socket.md) (the killswitch precedent for a mechanism shipped without its operator surface) -- Two reserved, undrafted records this phase must Accept before its dependent packets - start: **ADR-0046** (event delivery, the inbox and the subscriber failure contract) - and **ADR-0047** (the background-job runtime), both registered in - [decisions/README § Open ADR Drafts](../decisions/README.md#open-adr-drafts). +- Two reserved decisions must be Accepted before their dependent packets start: + [ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md) (delivery, + inbox and subscriber failure) is now Proposed in + [the decision index](../decisions/README.md#proposed-adrs); ADR-0047 (the job + runtime) remains undrafted in + [Open ADR Drafts](../decisions/README.md#open-adr-drafts). ## Scope @@ -1175,6 +1177,10 @@ observability. They ran no tests and changed no files. The proposal uses reserve ADR bodies and prior dated delivery records remain intact. Approval is required before runtime code or the acceptance reconciliation below. +The dated P02b-1 closeouts' "Next" and "reserved and undrafted" descriptions +record the state before this package. This current package and the Proposed index +replace those historical navigation instructions; the old records remain intact. + #### Review findings and proposed answers The current bus's zero-handler success is unsafe for P1's real provisioning rows: @@ -1188,7 +1194,7 @@ a callback that ignores its token. ADR-0046 explicitly resolves these gaps. | G3 ordering | Deterministic eligible selection by availability, occurrence and ID within one processor; no enqueue, commit, retry or cross-process ordering promise | P02b-2 | | G4 attempts and terminal state | Six committed normal claims; five published retry delays; fresh-token crash recovery; explicit terminal request and audited terminal timestamp; no seventh publish | P02b-2 | | G5 type and zero-subscriber policy | Declared concrete types/compatible aliases only; invalid/retired/unknown records terminalize; Pending required subscriptions preserve rows; only intentional absence acknowledges without publication | P02b-2; Active durable subscribers P02b-3 | -| G7 terminal audit | Fenced state transition plus MUST security-event on one transaction through `WriteEventTerminalAsync`; `platform.outbox.dead_letter` now, reserved `platform.delivery.dead_letter` when P3's writer ships; failed audit retries terminalization without delivery | P02b-2 producer; P02b-3 subscriber | +| G7 terminal audit | Fenced state transition plus MUST security-event on one transaction through `WriteEventTerminalAsync`, with a restricted transition witness binding audit tenant/org/event to the actual UPDATE result; `platform.outbox.dead_letter` now, reserved Tenancy-owned `platform.delivery.dead_letter` when P3's writer ships; failed audit retries terminalization without delivery | P02b-2 producer; P02b-3 subscriber | | G8 bypass | Explicit routine-dispatch exception, confined credential and exact grants; existing platform-scope entry audit unchanged | P02b-2 | | G17 metric dimensions | No tenant labels; registry-bounded event types; separate backlog, eligible age, deferred backlog and heartbeat; immutable gauge snapshots | P02b-2 | | G18 failure storage | Closed descriptor, at most 256 UTF-8 bytes with CHECK; replace free-form legacy descriptors without changing fact/state | P02b-2; erasure inventory P02b-8 | @@ -1198,7 +1204,8 @@ a callback that ignores its token. ADR-0046 explicitly resolves these gaps. The exact normative replacements and additive audit seam are enumerated in [ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md#bounded-supersession-and-additive-seams). They are not ADR-0041 errata. At acceptance, append dated disclosures to -ADR-0003/0006/0010/0038, the bounded audit-grant replacement disclosure and +ADR-0006/0010/0038, ADR-0003's unchanged role model and extended grant-budget +navigation, the bounded audit-grant replacement disclosure and separate fifth-writer seam amendment to ADR-0044, and the same-transaction clarification to ADR-0033. Keep original acceptance blocks, decisions and earlier amendments byte-preserved. G1's rule changes are recorded @@ -1239,9 +1246,11 @@ COMMIT starts remains Indeterminate under ADR-0033; P3 recovers from atomic business/inbox/ledger state rather than promise rollback at that boundary. Pending count includes all unprocessed, nonterminal rows, including delayed, -leased, terminal-requested and manifest-deferred work. Eligible age excludes -future availability, live leases and held/locally blocked partitions; it is zero -when none qualify and never negative. Deferred count exposes manifest-held work. +leased, terminal-requested and manifest-deferred work. Eligible age uses the exact +normal-publication claim predicate: it excludes terminal requests, exhausted +attempts, future availability, live or unrecovered expired claims, and held/locally +blocked partitions. It is zero when none qualify and never negative. Deferred +count exposes manifest-held work. Heartbeat advances only after actual bounded loop work, including an empty poll, not from an independent timer. Failed refresh retains the last snapshot and marks observation stale. Terminal-audit failure is unhealthy, never a fabricated @@ -1280,7 +1289,8 @@ requires input. No branch switch or history rewrite is part of this package. 2. **Durable transitions and terminal audit.** Implement parameterized claim, recovery, success, backoff, terminal-request and atomic terminal-audit paths. Add the confined fifth store method, off-path catalogue/matrix row and narrow - call-site guard. Prove token/expiry fences, same-worker reclaim, six-claim crash, + call-site/transition-witness guards. Prove token/expiry fences, same-worker + reclaim, six-claim crash, no seventh publish, uncertain completion and audit rollback on real PostgreSQL using actual outbox/application roles. Intentional absence and Pending holds must have distinct non-vacuous controls. @@ -1308,6 +1318,9 @@ requires input. No branch switch or history rewrite is part of this package. publish. Reconcile lost/uncertain terminal COMMIT before retrying so a completed terminal audit is not duplicated. Terminal, delayed, leased, deferred and due rows distinguish all queue gauges. +- Audit refusal on a terminal request with fewer than six attempts never makes + that row publishable again. Its pending contribution remains; its contribution + to oldest publish-eligible age is excluded. - Known V1 payloads remain readable after declared deployment aliases. Reverse commits, equal occurrence times and retry overtaking falsify stronger ordering. Unknown/retired/malformed/mismatched rows do not trigger arbitrary type loading. @@ -1316,6 +1329,11 @@ requires input. No branch switch or history rewrite is part of this package. - Missing/malformed/wrong-user credentials refuse without secret text; valid configuration is lazy; real physical guards reject elevated/transitive roles. Audit INSERT is allowed; audit read/update/delete and business access are not. +- Against the real bypass role, replace an audit draft's tenant, organization or + event while retaining the successful fenced transition's restricted witness. + Each mismatch must leave neither terminal state nor audit row committed. Prove + a matching organization-scoped success and rejection of stale/wrong-transaction + witnesses; GUC/RLS assertions cannot substitute for these controls. - Cancellation-ignoring operations retain safe resource ownership, cannot write late acknowledgements and cannot grow beyond the approved bound. A blocked or saturated loop becomes unhealthy while `/healthz` still returns 200. @@ -1326,6 +1344,14 @@ requires input. No branch switch or history rewrite is part of this package. for each key's resolution, publication, type loading and write confinement. Behavioural controls mutate lease/terminal/audit safeguards to prove sensitivity. +The P02b-2 publication proof uses the shipped BackgroundService, registry and +transition/audit stores on real PostgreSQL and actual roles. Keep real TenantCreated +subscriptions Pending. Only isolated test composition supplies an Active synthetic +contract and controlled transport/handler to exercise publish/mark, failures, +recovery and deadlines; intentional absence is a separate control. Assert nonempty +production machinery, without inventing a production subscriber. P3 owns handler +and durable-acceptance censuses when those production subjects exist. + #### Scope retained by later owners P3 owns shared Inbox/delivery DDL and RLS, per-subscription business/audit frames, @@ -1339,7 +1365,8 @@ owns brokers, cross-process evidence, purge and readiness exposure. #### Proposal review and validation (2026-10-12) -Two independent review rounds completed before maintainer approval. Round 1 used +At proposal commit `cc5de26`, two independent review rounds completed before +maintainer approval. Round 1 used separate GPT-6.1-sol xhigh delivery and security/corpus reviewers. Verified fixes separate the audit-grant replacement from the additive fifth seam, allow safe terminal auditing of malformed causal metadata, scope diagnostic minimization to @@ -1358,6 +1385,43 @@ P02b-0/P02b-1 dated delivery records and all Accepted ADR files are byte-preserv Full runtime/Docker suites remain implementation-step obligations; no dispatcher, inbox or gate delivery is claimed by this proposal. +#### External proposal review reconciliation (2026-10-12) + +Two external reports were checked against the current draft, accepted contracts, +canonical DDL and production code. Their reported line numbers are not authority. +This revision remains Proposed and changes no runtime or Accepted ADR body. + +| Finding | Verified disposition | +|---|---| +| Erasure assigned to Phase 11 | Already corrected in `cc5de26`: privileged redaction and its bounded caller contract belong to Phase 03; only retention purge belongs to Phase 11 | +| Two subscription IDs conflict with `card@1` / `plain@1` | Rejected: `BuiltInCustomizations.Card` is a `BuiltInContentType`; `Plain` is a `BuiltInTaxonomy`. ADR-0057 freezes those two independent effects, not two content types. The ADR now maps each ID explicitly | +| Bypass audit INSERT lacks row-bound identity proof | Valid: the seam now receives a restricted witness from the actual fenced UPDATE result; draft tenant/org/event/subscription must match it. Forged-identity and matching-success proofs are explicit | +| Terminal-request normal eligibility and undefined release | Explicit predicates now exclude terminal requests/exhausted attempts; recovery is separate. Pending holds precede payload validation, even for malformed payloads. Removed the unexplained release transition; success/retry/terminal paths clear leases | +| Inbox completion, identity grammar and replay ambiguity | Clarified uncommitted claim and atomic completion/rollback; completed inbox rows never reset; subscription IDs version deduplication semantics | +| Grant locator, legacy batch/exactly-once/DLQ designs and metric/off-path carriers | Named exact authorities and acceptance replacements. ADR-0003's role model remains unchanged; Database Standards owns actual grants. No competing current dead-letter design remains after acceptance | +| Envelope storage, narrow P3 SELECT, subscriber audit owner and P2 proof subjects | P3 must name durable envelope copies/references before DDL and inventory them for erasure; scheduler cannot read payload; subscriber audit is Tenancy-owned. P2 exercises shipped machinery with isolated test contracts while real subscriptions stay Pending | +| Final acceptance/handler failure and roadmap target checks | Failed/unknown durable acceptance consumes producer attempts; only accepted deliveries isolate business retry budgets. The phase's own undated zero-subscriber/replay sketches are now named acceptance replacements | +| Missing references and historical Next navigation | Added applicable ADR links and the ADR-0047 reservation link; added current navigation without rewriting dated P1 records. ADR-0045 is not used by this ADR and needs no artificial reference | + +The internal executor performs delivery, not publication; it receives no exception +to the outbox-only publisher rule. SecurityEvent records a consequential bypass +terminal transition, not routine polling. The selected order receives matching +normal-claim and recovery/terminal indexes. These are contract clarifications, +not claims that the proposed mechanisms have shipped. + +The revision received read-only GPT-6-astra high audit/privilege verification and +a fresh GPT-6-sol xhigh delivery/corpus review. The latter identified the Pending +validation priority, acceptance-failure budget and phase-carrier gaps recorded +above. Both reviewers approved after the verified fixes; neither claimed runtime +execution evidence. + +Revision validation passes: 266 Release architecture cases, seven ADR workflow +controls, 337 local links and 124 fragments across the four proposal Markdown +files, prose wrapping and `git diff --check`. Accepted ADR files and dated +P02b-0/P02b-1 delivery records remain byte-preserved. Only this package and the +Proposed ADR changed in the revision. Maintainer approval still precedes runtime +implementation and acceptance-carrier edits. + ### Durable outbox dispatch P02b-1 Steps 2/3 implement `IOutbox`, replace Packet 3's step-7 shell with From 192810b65f473fbd789bb25abb37003c2b6b41a8 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 21:03:15 +0300 Subject: [PATCH 68/92] docs(decisions): accept P02b-2 durable delivery contracts Record maintainer approval before runtime implementation, preserving prior accepted decisions and dated producer delivery evidence. Register the recovery and row-bound audit obligations without claiming delivery. ADR: 0046, 0003, 0006, 0010, 0033, 0038, 0044 Module: Tenancy, Audit --- .../0003-tenant-isolation-defense-in-depth.md | 16 ++ docs/decisions/0006-events-and-outbox.md | 20 +++ .../0010-cross-module-communication.md | 15 ++ docs/decisions/0033-audit-durability-model.md | 17 ++ ...-cross-cutting-port-and-event-contracts.md | 20 +++ docs/decisions/0044-audit-write-path.md | 48 ++++++ ...046-durable-event-delivery-and-recovery.md | 10 +- docs/decisions/README.md | 7 +- docs/modules/audit/README.md | 12 +- docs/modules/audit/audit.md | 7 + docs/modules/tenancy/README.md | 7 +- docs/modules/tenancy/audit.md | 19 ++- docs/modules/tenancy/permissions.md | 5 + docs/roadmap/phase-02b-events-auth.md | 85 ++++++--- .../21-architecture-tests-catalogue.md | 161 +++++++++++++++--- 15 files changed, 384 insertions(+), 65 deletions(-) diff --git a/docs/decisions/0003-tenant-isolation-defense-in-depth.md b/docs/decisions/0003-tenant-isolation-defense-in-depth.md index 96821572..8a325b40 100644 --- a/docs/decisions/0003-tenant-isolation-defense-in-depth.md +++ b/docs/decisions/0003-tenant-isolation-defense-in-depth.md @@ -473,3 +473,19 @@ visible to the application's separate connection and is not a valid test setup. - [PostgreSQL 18: Constraints](https://www.postgresql.org/docs/18/ddl-constraints.html) - [PostgreSQL 18: Generated columns](https://www.postgresql.org/docs/18/ddl-generated-columns.html) - [Education module spec](../modules/education/README.md) + +## Amendment 7 — Event-delivery grant-budget extension (2026-10-12) + +**Status: Accepted.** [ADR-0046](0046-durable-event-delivery-and-recovery.md#atomic-terminal-audit-and-privilege-boundary) +retains Amendment 3's four roles, separate credentials and RLS postures. Its +bounded grant-budget extension is canonical in +[Database Standards](../standards/05-database.md#grant-matrix): P02b-2 adds +named outbox status-column UPDATEs and INSERT-only on `audit_log` for confined, +atomic terminal auditing. P02b-3 adds only its separately enumerated delivery +scheduling grants, without envelope/payload SELECT or business-table access. + +This is an additive navigation disclosure, not an ADR-0041 correction or a claim +that this ADR contained an outbox-only grant clause. Original acceptance metadata, +body and prior amendments remain intact. Acceptance authorizes implementation; +[P02b-2's delivery record](../roadmap/phase-02b-events-auth.md#p02b-2-step-1--decision-schema-and-confined-composition-2026-10-12) +owns evidence. The platform-admin gate and pre-entry audit remain unchanged. diff --git a/docs/decisions/0006-events-and-outbox.md b/docs/decisions/0006-events-and-outbox.md index eb68b94d..9ced01a0 100644 --- a/docs/decisions/0006-events-and-outbox.md +++ b/docs/decisions/0006-events-and-outbox.md @@ -103,3 +103,23 @@ nothing dispatches from it until [Phase 02b](../roadmap/phase-02b-events-auth.md `learnstack_outbox_admin`'s column-scoped `UPDATE` grant in the same migration — a column added without extending it fails at runtime with `permission denied for table`. + +## Amendment 3 — Bounded delivery supersession by ADR-0046 (2026-10-12) + +**Status: Accepted.** [ADR-0046](0046-durable-event-delivery-and-recovery.md#bounded-supersession-and-additive-seams) +supersedes Amendment 1's consumer-side handler-called guard and own-module inbox. +P02b-3 implements a shared transport-owned inbox keyed by tenant, stable +subscription ID and event ID; its separate delivery ledger owns independent +retries, terminal state and targeted replay. Publish success then means durable +acceptance of every required delivery, not completed business effects. + +P02b-2 implements fresh-token leases, claim-counted attempts, explicit terminal +requests and atomic terminal audit under ADR-0046. Required Pending subscriptions +preserve rows before acquisition and validation; only declared intentional +absence acknowledges without publication. The canonical DDL/grants remain in +[Database Standards](../standards/05-database.md#outbox). + +These are bounded new decisions, not ADR-0041 corrections. Original bodies, +acceptance blocks and prior amendments remain intact. Envelope contracts and +at-least-once recovery survive; demand-gated Dapr/Kafka remain Phase 11. +Acceptance claims no dispatcher or inbox delivery. diff --git a/docs/decisions/0010-cross-module-communication.md b/docs/decisions/0010-cross-module-communication.md index 48ab0d1a..164091fe 100644 --- a/docs/decisions/0010-cross-module-communication.md +++ b/docs/decisions/0010-cross-module-communication.md @@ -154,3 +154,18 @@ The optional fourth segment is reserved for **Hub-side event-name suffixes only* LearnStack-core topics stay 3-segment by convention (`learnstack.{module}.{aggregate}`); the test does not reject 3-segment topics in either direction. This amendment clarifies the regex; no other rule changes. + +## Amendment 3 — Bounded inbox supersession by ADR-0046 (2026-10-12) + +**Status: Accepted.** [ADR-0046](0046-durable-event-delivery-and-recovery.md#subscriber-acceptance-and-inbox-ownership) +supersedes Amendment 1's own-module inbox, handler-called guard and corresponding +`Integration_Event_Handlers_Use_InboxGuard` assertion. The +[catalogue rule](../standards/21-architecture-tests-catalogue.md#integration_event_handlers_use_inboxguard) +now requires transport-owned claim/completion before handler construction and +atomic business completion. Two same-module subscriptions retain distinct stable +identities. P02b-3 implements and proves this contract; the rule stays Registered +until those production subjects exist. + +The four communication mechanisms remain unchanged. This is a bounded +supersession, not an ADR-0041 correction. Original acceptance metadata, body and +earlier amendments remain intact; acceptance claims no inbox delivery. diff --git a/docs/decisions/0033-audit-durability-model.md b/docs/decisions/0033-audit-durability-model.md index cca9c53e..40aaaf12 100644 --- a/docs/decisions/0033-audit-durability-model.md +++ b/docs/decisions/0033-audit-durability-model.md @@ -762,6 +762,23 @@ 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. +## Amendment 9 — Same-transaction event terminal audit (2026-10-12) + +**Status: Accepted.** [ADR-0046](0046-durable-event-delivery-and-recovery.md#atomic-terminal-audit-and-privilege-boundary) +applies this ADR's existing MUST same-transaction guarantee to a new confined +caller. A fenced event-terminal UPDATE and its complete MUST security-event +INSERT commit on one caller-owned transaction, or both roll back. The narrow +fifth `IAuditStore` write receives a restricted witness from the successful +UPDATE result, validates its row identity and live transaction, and opens or +commits no transaction itself. An uncertain COMMIT is reconciled from durable +state before retry; it does not justify a second terminal audit. + +P02b-2 supplies the producer caller; P02b-3 supplies the subscriber caller. +Existing four write paths, classification, refusal posture and append-only rows +retain their contracts. This is a clarification of the guarantee's new caller, +not an ADR-0041 correction. Original acceptance metadata, body and earlier +amendments remain intact. Acceptance authorizes implementation, not delivery. + ## 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 856a8537..3122c8d7 100644 --- a/docs/decisions/0038-cross-cutting-port-and-event-contracts.md +++ b/docs/decisions/0038-cross-cutting-port-and-event-contracts.md @@ -227,6 +227,26 @@ 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. +### Amendment 3 — Bounded publish-outcome supersession by ADR-0046 (2026-10-12) + +**Status: Accepted.** [ADR-0046](0046-durable-event-delivery-and-recovery.md#subscriber-acceptance-and-inbox-ownership) +supersedes only § Event contract's immediate constructor/invocation/disposal +failure outcome and the requirement that future adapters preserve that outcome. +When P02b-3 installs durable acceptance, `PublishAsync` succeeds after every +required subscription is durably accepted. Unknown/failed acceptance remains a +producer retry; independent subscription execution owns later handler failures. +Retries invoke the internal delivery executor, never another broadcast. + +The non-generic envelope signature, frozen serializer, effective SystemActor, +causal identity, organization restoration, per-key serialization and outbox-only +publication survive. Pending readiness holds in P02b-2 preserve the real +TenantCreated facts until both P02b-3 consumers are Active. Diagnostic +minimization of current transport/consumer exception telemetry is also P02b-3. + +This is a bounded new decision, not an ADR-0041 correction. Original acceptance +metadata, body and earlier amendments remain intact. Acceptance claims no durable +acceptance or consumer delivery. + ## References - [ADR-0006 — Events and Outbox](0006-events-and-outbox.md) diff --git a/docs/decisions/0044-audit-write-path.md b/docs/decisions/0044-audit-write-path.md index 05596eef..bda44db1 100644 --- a/docs/decisions/0044-audit-write-path.md +++ b/docs/decisions/0044-audit-write-path.md @@ -1105,6 +1105,54 @@ 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. +## Amendment 9 — Bounded terminal-audit grant replacement (2026-10-12) + +**Status: Accepted.** [ADR-0046](0046-durable-event-delivery-and-recovery.md#bounded-supersession-and-additive-seams) +supersedes only § 9's `audit_log` grant cell denying every permission to +`learnstack_outbox_admin`. P02b-2 grants INSERT-only for confined atomic terminal +audit, through a new Audit-chain migration after the table exists. No audit +SELECT/UPDATE/DELETE, business-table grant or privileged membership is added. +[Database Standards](../standards/05-database.md#grant-matrix) owns the exact grants. + +This is a bounded new decision, not an ADR-0041 correction. Original acceptance +metadata, body and prior amendments remain intact. The four-role model, RLS and +append-only controls survive. Acceptance claims no runtime grant delivery. + +## Amendment 10 — Additive event-terminal write seam (2026-10-12) + +**Status: Accepted.** [ADR-0046](0046-durable-event-delivery-and-recovery.md#atomic-terminal-audit-and-privilege-boundary) +adds the fifth write method: + +```csharp +IAuditStore.WriteEventTerminalAsync( + AuditEntryDraft draft, + EventTerminalTransition transition, + DbConnection connection, + DbTransaction transaction, + CancellationToken cancellationToken); +``` + +Only a successful fenced terminal `UPDATE ... RETURNING` mints its immutable, +restricted transition witness. The witness binds the actual stored tenant/org, +event, acquisition, producer/subscriber kind, reason, attempts, optional +subscription ID and live connection/transaction. The seam checks operation, +class/type and every corresponding draft identity before INSERT; mismatches +require rollback of both writes. It opens/commits no transaction, and +`PostgresAuditStore` remains the sole audit SQL writer. Existing four writes +retain their contracts. + +P02b-2 admits only the producer terminal writer and Tenancy-owned off-path +`platform.outbox.dead_letter` as MUST/SecurityEvent. P02b-3 reserves the subscriber +writer and `platform.delivery.dead_letter` under that same owner/class/type; +register each operation only when its writer ships. P02b-3 adds inbox/delivery +machinery to § 7's capture exclusions with reasons, because explicit intents +audit business effects and retry state is machinery. Those exclusions remain +unimplemented until P02b-3. + +This additive seam is separate from Amendment 9's grant replacement and does not +change ADR-0033's durability contract. Original acceptance metadata, body and +prior amendments remain intact. Acceptance authorizes implementation, not delivery. + ## References - [ADR-0033 Audit Durability Model](0033-audit-durability-model.md) — the durability diff --git a/docs/decisions/0046-durable-event-delivery-and-recovery.md b/docs/decisions/0046-durable-event-delivery-and-recovery.md index 906c4f2f..e53858ed 100644 --- a/docs/decisions/0046-durable-event-delivery-and-recovery.md +++ b/docs/decisions/0046-durable-event-delivery-and-recovery.md @@ -2,10 +2,10 @@ ## Status -Proposed — maintainer approval pending. No implementation or gate is complete. +Accepted — 2026-10-12. Implementation is authorized; delivery remains pending. **Date:** 2026-10-12 -**Deciders:** @cemil (approval pending) +**Deciders:** @cemil The [P02b-2 decision package](../roadmap/phase-02b-events-auth.md#p02b-2-decision-package-2026-10-12) owns implementation sequencing and numerical runtime bounds. This reserved record @@ -43,11 +43,11 @@ either is implemented. Acceptance does not claim delivery. ## Decision -LearnStack proposes a leased, grant-bounded outbox dispatcher with explicit +LearnStack uses a leased, grant-bounded outbox dispatcher with explicit subscription readiness, fenced retries and atomic audited terminalization. Durable subscriber acceptance and a transport-owned shared inbox separate each subscription's business outcome from producer dispatch. The following contract -becomes normative only on acceptance. +is normative; packet delivery records own implementation evidence. ### Producer dispatch and recovery @@ -395,7 +395,7 @@ The superseded consumer subscription sketch does not introduce `IModule.ConfigureEventHandlers`: no such repository type exists. P02b-3 uses the declared manifest and the actual composition APIs. Historical P02b-1 "Next" and "reserved and undrafted" statements are as-of records; the current P02b-2 -package and Proposed index own navigation to this drafted record. +package and Active index own navigation to this Accepted record. ## Architecture Tests diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 733cbc76..eb1e8689 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -23,6 +23,11 @@ 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. +[ADR-0046](0046-durable-event-delivery-and-recovery.md#bounded-supersession-and-additive-seams) +likewise replaces only the named delivery, inbox and audit-grant clauses. Dated +disclosures in ADR-0003/0006/0010/0033/0038/0044 preserve the earlier records and +name P02b-2/P02b-3 activation; acceptance claims no runtime delivery. + ## Active ADRs | # | Title | Topic | @@ -70,6 +75,7 @@ clauses and preserve the unaffected authority. | 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. [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 | +| 0046 | [Durable Event Delivery and Recovery](0046-durable-event-delivery-and-recovery.md) | Accepted 2026-10-12: fenced producer leases/retries, Pending readiness holds and atomic row-bound terminal audit; P02b-2 implementation authorized and pending. Shared inbox/durable subscription acceptance and independent retries remain P02b-3. Named bounded supersessions preserve earlier Accepted records. | | 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 | | 0052 | [Anonymous Public Read Boundary](0052-anonymous-public-read-boundary.md) | Accepted 2026-10-03: host-resolved bootstrap, immutable host ceiling, mandatory marked dispatch and read-only ambient frames; ADR-0036/0040 Amendment 8; P02d-4 Steps 1–3 deliver host provenance, physical read-only frames and all institution GET/HEAD reads; both Step 3 review rounds pass; Step 4 delivers OpenAPI/SDK/CI controls; both review rounds and focused fix verification pass; six real required CI jobs and live sixth-check activation verified; P02d-4 complete and merged through PR #25 on 2026-10-08 ([Amendment 13](0052-anonymous-public-read-boundary.md#amendment-13--merge-closeout-2026-10-08)) | @@ -83,7 +89,6 @@ clauses and preserve the unaffected authority. | # | Title | Topic | Target phase / decision point | |---|---|---|---| -| 0046 | [Durable Event Delivery and Recovery](0046-durable-event-delivery-and-recovery.md) | Reserved delivery/inbox decision drafted 2026-10-12; approval pending, no gate accepted | P02b-2 before dispatcher code; same state-machine contract consumed by P02b-3; existing phase-exit SLA applies | | 0049 | [Institution Sites and an Optional Course Marketplace](0049-institution-sites-and-course-marketplace.md) | Direction endorsed 2026-10-02; architecture still Proposed, no gate accepted | P02d-2 preparation hold released; remaining contracts before proposed Phase 09a's first consumers | The maintainer endorsed the marketplace direction and accepted ADR-0050/0051 and diff --git a/docs/modules/audit/README.md b/docs/modules/audit/README.md index b0faf188..261f433c 100644 --- a/docs/modules/audit/README.md +++ b/docs/modules/audit/README.md @@ -28,6 +28,14 @@ module's `IUserReferenceLocator` retention purge land in [Phase 11](../../roadmap/phase-11-production-hardening.md) against written triggers ([ADR-0035](../../decisions/0035-demand-gated-infrastructure.md)). +**ADR-0046 Accepted — 2026-10-12; fifth writer pending P02b-2.** The existing +four writes remain implemented. `WriteEventTerminalAsync` adds INSERT-only access +for the confined outbox role and validates the audit draft against a restricted +witness from the actual fenced terminal UPDATE on the caller's transaction. +Terminal state and MUST audit are atomic; this is not a standalone or platform- +scope write. Tenancy owns the producer/subscriber operation declarations in +[its matrix](../tenancy/audit.md), with subscriber implementation in P02b-3. + The third module spec in the repository, per [Documentation Standards § Per-Module Specifications](../../standards/13-documentation.md). @@ -345,7 +353,9 @@ 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. +per-effect audit under ADR-0046's accepted G1 contract. Business audit retains +the existing coordinator and durability model; explicit terminal audit uses the +separate fifth seam described above. ## Permission matrix diff --git a/docs/modules/audit/audit.md b/docs/modules/audit/audit.md index fe1e83da..d219f966 100644 --- a/docs/modules/audit/audit.md +++ b/docs/modules/audit/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). +Accepted [ADR-0046](../../decisions/0046-durable-event-delivery-and-recovery.md) +adds the confined terminal-audit seam in P02b-2, not an Audit-module operation. +`platform.outbox.dead_letter` and P02b-3's `platform.delivery.dead_letter` belong +to [Tenancy's off-path matrix](../tenancy/audit.md); both writers remain pending +at decision acceptance. Phase 03 owns privileged erasure execution and its bounded +caller contract; Phase 11 owns retention purge. + **The module that owns the log is audited like any other.** Reading someone's audit trail is itself a sensitive read; exporting it produces a copy that outlives the request; and redacting or purging a row is the one act in the system that removes diff --git a/docs/modules/tenancy/README.md b/docs/modules/tenancy/README.md index 813ac087..ff0d2438 100644 --- a/docs/modules/tenancy/README.md +++ b/docs/modules/tenancy/README.md @@ -497,7 +497,12 @@ 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. +[ADR-0046](../../decisions/0046-durable-event-delivery-and-recovery.md) accepts G1's +shared transport-owned inbox and separate delivery ledger. P02b-2 holds this +contract Pending, including aliases, before claim or payload validation. P02b-3 +activates `customization.initialize-content-type-default.v1` for the `card@1` +content type and `customization.initialize-taxonomy-default.v1` for the `plain@1` +taxonomy together with durable acceptance; there are no placeholder handlers. | Topic | Payload | Publisher | Consumers | Phase | |---|---|---|---|---| diff --git a/docs/modules/tenancy/audit.md b/docs/modules/tenancy/audit.md index ad78a2d7..0247e1ca 100644 --- a/docs/modules/tenancy/audit.md +++ b/docs/modules/tenancy/audit.md @@ -10,6 +10,13 @@ aggregate. A failed MUST write rolls back tenant, organization and outbox togeth The [integration-event catalogue](README.md#integration-event-catalogue) owns the flow; production dispatch and subscribers remain P02b-2/P02b-3. +**ADR-0046 Accepted — 2026-10-12; terminal writers pending.** The infrastructure +operations below belong to this catalogue/matrix, without a synthetic aggregate. +P02b-2 registers the producer terminal operation when its writer ships; P02b-3 +does the same for subscriber terminalization. Both use the restricted fifth +audit seam on the fenced state-transition transaction. Routine confined dispatch +has the explicit ADR-0046 exemption; platform-scope entry auditing is unchanged. + **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 @@ -36,7 +43,7 @@ separate allowlist. Verification request types are Off. Packet 7 ships four audited root writes through three commands: Tenant and default Organization provisioning, a separate Organization create, and host mapping creation. P02d-2 adds two locale commands (SHOULD over Tenant) and one branding command (MUST over -TenantSetting). Thirteen rows remain `(planned)`; five operations have no MediatR +TenantSetting). Fifteen rows remain `(planned)`; seven operations have no MediatR request and carry `(off-path)`. All four original writes are **MUST**. Their records commit with the business state, @@ -113,6 +120,8 @@ unmarked row is one a shipped command already raises. | Resource | Operation | Class | Why | |---|---|---|---| +| `outbox_messages` | `platform.outbox.dead_letter` `(off-path)` `(planned)` | **MUST** (`security-event`) | P02b-2's consequential BYPASSRLS terminal transition; state and row commit together through the row-bound fifth audit seam, with stored tenant/org/event identity and SystemActor ([ADR-0046](../../decisions/0046-durable-event-delivery-and-recovery.md)) | +| delivery ledger | `platform.delivery.dead_letter` `(off-path)` `(planned)` | **MUST** (`security-event`) | P02b-3 owns subscriber terminalization, stable subscription/event identity and the same atomic transition/audit guarantee; no writer or operation registration ships at acceptance | | `Tenant` | `tenancy.tenant.create` | **MUST** | The root of a customer's data; its existence is a contractual fact | | `Tenant` | `tenancy.tenant.change_status` `(planned)` | **MUST** | Suspension withdraws access; an operator must be able to say who and when | | `Tenant` | `tenancy.tenant.rename` `(planned)` | SHOULD | Presentational | @@ -158,7 +167,7 @@ classification and not drift — and a `(planned)` row whose command has since s fails, so the marker is a claim the rule re-checks on every run rather than an exemption. -Five rows sit outside that join in both directions, each marked `(off-path)` for the +Seven rows sit outside that join in both directions, each marked `(off-path)` for the reason its own cell gives: `platform.admin_scope.enter`, written on the fourth write path ([ADR-0044 § 10](../../decisions/0044-audit-write-path.md)); `tenancy.killswitch.toggle`, written inside `EnterPlatformAdminScope(reason)`; @@ -169,8 +178,10 @@ whose refreshing implementation lands in Phase 02c; and the two ([ADR-0033 Amendment 2](../../decisions/0033-audit-durability-model.md)). Three are registered by slug through `DeclareOffPath` because their writer ships — `platform.admin_scope.enter` and the two `tenancy.tenant_assertion.*` keys. -`tenancy.killswitch.toggle` and `tenancy.entitlement.refresh` also carry `(planned)` and -register by slug when Phase 03 and Phase 02c land their writers. Every other slug above is +`tenancy.killswitch.toggle`, `tenancy.entitlement.refresh`, +`platform.outbox.dead_letter` and `platform.delivery.dead_letter` also carry +`(planned)` and register by slug when Phase 03, Phase 02c, P02b-2 and P02b-3, +respectively, land their writers. Every other slug above is joined in both directions the day its command lands. ## P02d-2 contextual verification reads diff --git a/docs/modules/tenancy/permissions.md b/docs/modules/tenancy/permissions.md index 1649298f..784888bb 100644 --- a/docs/modules/tenancy/permissions.md +++ b/docs/modules/tenancy/permissions.md @@ -9,6 +9,11 @@ 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. +**ADR-0046 Accepted — 2026-10-12; implementation pending.** P02b-2's API-owned +dispatcher and terminal audit add no permission key or HTTP endpoint. P02b-3's +targeted replay is internal; Phase 03 separately decides operator permission and +routes. Neither grants a tenant administrator bypass-role access. + **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/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 7f6dd633..fdef8b32 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -24,10 +24,12 @@ merged** through [PR #29](https://github.com/HodeTech/LearnStack/pull/29) on verification and the next P02b-2 decision pass. **P02b-2 decision pass — 2026-10-12.** The -[reviewed proposal](#p02b-2-decision-package-2026-10-12) and -[ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md) are Proposed, -awaiting maintainer approval. No dispatcher implementation has started and no -remaining gate closes from this draft. +[decision package](#p02b-2-decision-package-2026-10-12) and +[ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md) are Accepted. +The four implementation steps are authorized on development. Step 1 is in +progress; its [record](#p02b-2-step-1--decision-schema-and-confined-composition-2026-10-12) +owns delivery evidence. Dispatcher delivery and all P02b-3 implementation remain +pending. **Phase 02d handoff — 2026-10-11.** P02b-0 re-verifies the delivered `/api/v1` public contracts, native HTTPS on retained `*.learnstack.local` seed hosts, @@ -292,25 +294,25 @@ premise a row cites is re-verified at that pass rather than trusted. | # | 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, deterministic eligible selection; no enqueue/commit-order guarantee | Derivation: [Accepted ADR-0057](../decisions/0057-transactional-event-production-and-completion.md). Ordering: Proposed ADR-0046 and the P02b-2 package, followed by [§ Ordering](../architecture/15-event-and-outbox.md#ordering) alignment at acceptance | 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 | Proposed ADR-0046 + dated ADR-0006 disclosure at acceptance; Database Standards § Outbox DDL, index and grant edits | 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`? | MUST `security-event`; producer `platform.outbox.dead_letter` and reserved P3 `platform.delivery.dead_letter`; fenced terminal transition and audit INSERT on the same short transaction through a narrow fifth write seam; failed audit retries terminalization without publication | Proposed ADR-0046 + bounded ADR-0044 audit-grant replacement, separate additive fifth-writer seam amendment and ADR-0033 same-transaction clarification at acceptance; `DeclareOffPath` registration and Audit Coverage baseline | 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 | Proposed ADR-0046 explicitly replaces the routine-dispatch coverage rule and bounds the ADR-0003 grant extension; dated disclosures at acceptance, not an ADR-0041 correction | P02b-2 | Open | +| 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) | Accepted — 2026-10-12; delivery pending | +| 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 under this 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, deterministic eligible selection; no enqueue/commit-order guarantee | Derivation: [Accepted ADR-0057](../decisions/0057-transactional-event-production-and-completion.md). Ordering: Accepted ADR-0046 and the P02b-2 package, followed by [§ Ordering](../architecture/15-event-and-outbox.md#ordering) alignment under this acceptance | P02b-1 (whether the key's derivation is checked structurally: the first event declares a key), P02b-2 (the ordering bound) | Derivation and ordering Accepted; ordering delivery pending 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 | Accepted ADR-0046 + dated ADR-0006 disclosure under this acceptance; Database Standards § Outbox DDL, index and grant edits | P02b-2 | Accepted — 2026-10-12; delivery pending | +| 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 under this 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 and resolution outcomes Accepted; dispatch pending 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 delivered; delivery/inbox contract Accepted, implementation pending 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`? | MUST `security-event`; producer `platform.outbox.dead_letter` and reserved P3 `platform.delivery.dead_letter`; fenced terminal transition and audit INSERT on the same short transaction through a narrow fifth write seam; failed audit retries terminalization without publication | Accepted ADR-0046 + bounded ADR-0044 audit-grant replacement, separate additive fifth-writer seam amendment and ADR-0033 same-transaction clarification under this acceptance; `DeclareOffPath` registration and Audit Coverage baseline | P02b-2 (the producer dead-letter row its criteria assert), P02b-3 | Accepted — 2026-10-12; delivery pending | +| 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 | Accepted ADR-0046 explicitly replaces the routine-dispatch coverage rule and bounds the ADR-0003 grant extension; dated disclosures under this acceptance, not an ADR-0041 correction | P02b-2 | Accepted — 2026-10-12; delivery pending | | 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 | +| G15 | What initiates a replay in this phase? | Targeted internal subscriber replay in P02b-3; producer/operator reset and fencing require a separate Phase 03 contract | 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) | Accepted — internal subscriber replay P02b-3; operator contract Phase 03 | | 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 | +| 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) | Event metrics Accepted — 2026-10-12; job metrics P02b-4 | +| 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 under this 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 and failure descriptors Accepted; erasure inventory open 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 under this 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) @@ -1169,26 +1171,28 @@ above supply their evidence. ### P02b-2 decision package (2026-10-12) -**Proposed — approval pending.** The documentation/code audit used `development` +**Accepted — 2026-10-12, @cemil.** The revised decision package and ADR-0046 +are approved, including all four implementation steps and their review loop. +The documentation/code audit used `development` at `b1f6df34b1d0db9dff77b355743d7bf8885947b3`, the P02b-1 merge closeout. Three independent read-only audits covered delivery, privilege/audit and runtime/ -observability. They ran no tests and changed no files. The proposal uses reserved -[ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md); Accepted -ADR bodies and prior dated delivery records remain intact. Approval is required -before runtime code or the acceptance reconciliation below. +observability. They ran no tests and changed no files. The accepted package uses +[ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md); earlier +Accepted ADR bodies and prior dated delivery records remain intact. The dated +acceptance record below distinguishes approval from implementation evidence. The dated P02b-1 closeouts' "Next" and "reserved and undrafted" descriptions -record the state before this package. This current package and the Proposed index +record the state before this package. This current package and the Active index replace those historical navigation instructions; the old records remain intact. -#### Review findings and proposed answers +#### Review findings and accepted answers The current bus's zero-handler success is unsafe for P1's real provisioning rows: their consumers do not exist until P3. Separately, the outbox role cannot write the MUST audit required by terminalization, and cancellation alone cannot bound a callback that ignores its token. ADR-0046 explicitly resolves these gaps. -| Gate / concern | Proposed answer | Delivery owner | +| Gate / concern | Accepted answer | Delivery owner | |---|---|---| | G1 subscriber identity and isolation | Shared transport-owned inbox keyed by tenant, stable subscription ID and event ID; separate delivery ledger owns attempts, terminal state and targeted replay | Contract now; implementation P02b-3 | | G3 ordering | Deterministic eligible selection by availability, occurrence and ID within one processor; no enqueue, commit, retry or cross-process ordering promise | P02b-2 | @@ -1213,11 +1217,11 @@ now but their implementation remains Registered for P3. #### Runtime bounds and observation -These are approval candidates, not implemented defaults. Options reject invalid +These approved bounds are not implemented defaults yet. Options reject invalid or unbounded values at composition; tests can inject time and tighter positive bounds. Lease ownership always uses PostgreSQL time, never a caller's clock. -| Setting / policy | Initial approved candidate | +| Setting / policy | Approved initial bound | |---|---| | Worker and claim capacity | One worker; one just-in-time normal claim | | Idle poll | 200 ms | @@ -1269,7 +1273,7 @@ or connection data. Current bus/consumer exception telemetry is a separate P3 reconciliation obligation before Active production subscriptions; this packet does not claim that existing transport diagnostics are already minimized. -#### Four implementation steps after approval +#### Four approved implementation steps Each step implements and commits on `development`, then runs two fresh independent review rounds. Verify findings against current code, fix valid findings, validate @@ -1422,6 +1426,33 @@ P02b-0/P02b-1 delivery records remain byte-preserved. Only this package and the Proposed ADR changed in the revision. Maintainer approval still precedes runtime implementation and acceptance-carrier edits. +### P02b-2 acceptance (2026-10-12) + +**Accepted by @cemil.** The maintainer approves the revised ADR-0046 and this +decision package, and authorizes all four implementation steps on development +with two fresh independent review rounds per step. The accepted decision is +committed before runtime edits. G1, G3's ordering, G4, G5's resolver/readiness, +G7, G8, G17's event metrics and G18's failure-descriptor parts are Accepted. +P02b-3 still owns durable acceptance, inbox/ledger DDL, independent subscriber +execution/retries, both real default subscriptions and Seeder handoff; job and +erasure gates retain their later owners. + +Acceptance appends bounded supersession disclosures and additive seams to the +named Accepted ADRs, reconciles the current standards/architecture/glossary/skill +carriers, and registers six catalogue obligations. Original ADR acceptance +metadata, decisions and prior amendments remain intact. Dated P02b-0/P02b-1 +records remain byte-preserved. Registered rules and planned audit operations +claim no runtime enforcement; delivery evidence belongs to each step below. + +### P02b-2 Step 1 — Decision, schema and confined composition (2026-10-12) + +**In progress.** Acceptance reconciliation is underway. No schema migration, +registry, role guard or dispatcher implementation is claimed by this record. +The six new rules are Registered; existing consumer rules remain Registered for +P02b-3. The approved schema/composition, migration and exact-grant proofs and +both independent review rounds remain pending. No runtime suite has been run +for this acceptance-only documentation change. + ### Durable outbox dispatch P02b-1 Steps 2/3 implement `IOutbox`, replace Packet 3's step-7 shell with diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index a0dcfb7f..4975f743 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -12,7 +12,9 @@ [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), [ADR-0057 Transactional Event Production and Completion](../decisions/0057-transactional-event-production-and-completion.md) -(producer lifecycle, payload and production-census proofs implemented). The +(producer lifecycle, payload and production-census proofs implemented), +[ADR-0046 Durable Event Delivery and Recovery](../decisions/0046-durable-event-delivery-and-recovery.md) +(accepted delivery rules Registered; P02b-2/P02b-3 proofs pending). The catalogue grows as subsequent ADRs and phases land their tests; per-test ownership stays with the originating ADR / standard. @@ -1787,14 +1789,18 @@ diagnostic above row security, not the boundary, as its own note says. #### `LearnStack_OutboxAdmin_Role_OnlyUsedBy_OutboxProcessor` -- **Asserts:** `ConnectionStrings:OutboxDispatcher` is resolved by `OutboxProcessor` - and nothing else. A `GRANT` names a role, not a code path — every handler in the - API process runs as the same role — so code-path confinement of a `BYPASSRLS` - credential is carried here or nowhere. -- **Source:** [05-database.md § GRANT matrix](05-database.md); ADR-0003 Amendment 3. -- **Type:** NetArchTest + DI registration inspection. **Kind:** structural. -- **Status:** **Awaiting backfill** — cited by the standard, no dispatcher yet. -- **Phase:** 02b. +- **Asserts:** only the confined dispatcher infrastructure resolves the named keyed, + lazy `ConnectionStrings:OutboxDispatcher` source in the API host. Seeder/web and + arbitrary keyed/service-provider resolution are refused. Physical connections + verify the exact outbox role, bounded attributes and memberships. A nonempty + production census has planted clean/dirty resolution and registration controls; + real role/grant probes include valid and elevated/transitive negative controls. +- **Source:** [05-database.md § GRANT matrix](05-database.md#grant-matrix); + [ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md). +- **Type:** architecture/DI census and real PostgreSQL role probes. + **Kind:** structural + startup + behavioural. +- **Status:** **Registered** — acceptance authorizes P02b-2 implementation. +- **Phase:** 02b, P02b-2. #### `CoreInfrastructure_DoesNotDependOn_AnyModule` @@ -2631,7 +2637,8 @@ which decides identity, multiplicity, capture and classification; the reverse, which also carries the anti-rot check and a guard against sweeping no rows at all. - **Off the request path.** Some audited operations are not MediatR requests at all. - Seven rows carry `(off-path)`: Tenancy's `platform.admin_scope.enter`, + Nine rows carry `(off-path)`: Tenancy's `platform.admin_scope.enter`, + `platform.outbox.dead_letter`, `platform.delivery.dead_letter`, `tenancy.killswitch.toggle`, `tenancy.entitlement.refresh`, `tenancy.tenant_assertion.reject` and `tenancy.tenant_assertion.anonymous_burst` — snake_case within each segment since @@ -2639,8 +2646,9 @@ which decides identity, multiplicity, capture and classification; one parser reads every audit slug and every permission key — and Audit's `audit.redaction.apply` and `audit.purge.apply`. They sit outside the request-type join in both directions and register by slug: the three whose writer ships through - `DeclareOffPath` today, the other four — which also carry `(planned)` — when their - writer lands. + `DeclareOffPath` today, the other six — which also carry `(planned)` — when their + writer lands. ADR-0046 assigns the producer terminal writer to P02b-2 and the + subscriber terminal writer to P02b-3; acceptance alone registers neither slug. Stated as one unconditional join, the rule is red on its first run: the two shipped matrices classify many more operations than `backend/src/Modules` has request types, and @@ -3165,13 +3173,20 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). #### `Integration_Event_Handlers_Use_InboxGuard` -- **Asserts:** every `IIntegrationEventHandler<T>` calls - `IInboxGuard.IsAlreadyProcessedAsync` before any business logic. -- **Source:** [20-infrastructure-stack.md § `IEventBus`](20-infrastructure-stack.md); - [ADR-0006](../decisions/0006-events-and-outbox.md). -- **Type:** xUnit + IL / source scan. **Kind:** structural. -- **Status:** **Registered.** -- **Phase:** 02b. +- **Asserts:** the transport claims the shared inbox before constructing each + declared subscription's handler. Business state, completed inbox, produced + outbox, MUST audit and fenced ledger completion share the announced app-role + transaction. Concurrent duplicates serialize; two same-module subscriptions + have distinct identities. Stale tokens, expired leases and a closed timeout + gate roll back all business work. Completed inbox rows survive targeted replay. + A nonempty production census and planted dirty/clean controls prevent an empty + handler set from appearing enforced. +- **Source:** [ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md#subscriber-acceptance-and-inbox-ownership), + superseding ADR-0006/0010's handler-called guard assertion. +- **Type:** architecture call-path census and integration tests (Testcontainers). + **Kind:** structural + behavioural. +- **Status:** **Registered** — transport-owned inbox/delivery remains unimplemented. +- **Phase:** 02b, P02b-3. #### `Integration_Events_Inherit_From_IntegrationEventBase` @@ -3296,8 +3311,11 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). #### `OutboxProcessor_NeverBlocks_OnSingleMessageFailure` -- **Asserts:** one poisoned message does not prevent the rest of its batch from being - dispatched. +- **Asserts:** ordinary publication failure permits unrelated eligible work to + progress. A callback ignoring cancellation retains its live task-owned scope, + blocks its own partition and makes health unhealthy; unrelated keys progress + within the approved four-task bound. Saturation stops acquisition, late results + cannot acknowledge, and shutdown waits finitely without disposing live resources. - **Source:** [15-event-and-outbox.md § Architecture tests](../architecture/15-event-and-outbox.md); Phase 02b deliverable. - **Type:** **integration** test (Testcontainers). **Kind:** behavioural. @@ -3306,16 +3324,107 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). #### `Outbox_Claim_IsHeld_Until_Dispatch_Completes` -- **Asserts:** two concurrent `OutboxProcessor` instances draining one pending batch - dispatch each message **exactly once** — the claim is held for the duration of the - dispatch, not released when the row is read. Requires **two** processors: a - single-processor test passes against the broken protocol. +- **Asserts:** two independent processor instances have at most one live owner of + a row. A claim commits before publication and is fenced by a fresh acquisition + token and database-time lease. Release-on-read controls fail. Expiry/crash permits + redelivery; same-worker reacquisition rejects both stale success and stale failure. + This proves live ownership and at-least-once recovery, not exactly-once publication. - **Source:** [Phase 02b](../roadmap/phase-02b-events-auth.md); [15-event-and-outbox.md](../architecture/15-event-and-outbox.md). -- **Type:** **integration** test (Testcontainers, two processes). **Kind:** behavioural. +- **Type:** **integration** test (Testcontainers, two independent instances). + **Kind:** behavioural. - **Status:** **Registered.** - **Phase:** 02b. +#### `Outbox_Retry_And_Terminal_Transitions_Are_Fenced` + +- **Asserts:** Normal claims count once, stop at six and use the five approved delays. + Expired claims require fresh-token recovery; a sixth expired claim never publishes a + seventh time. Every transition checks identity, token and database-time expiry. + Terminal requests never reenter normal publication, including after audit refusal. +- **Source:** [ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md); + [P02b-2 proof obligations](../roadmap/phase-02b-events-auth.md#decisive-proof-obligations). +- **Type:** production architecture census with planted controls where structural; + real PostgreSQL/runtime integration proofs where behavioural. + **Kind:** behavioural. +- **Status:** **Registered** — no implementation evidence yet. +- **Phase:** 02b, P02b-2. + +#### `Outbox_Terminal_Transition_And_Must_Audit_Are_Atomic` + +- **Asserts:** The fenced terminal UPDATE and MUST/SecurityEvent INSERT commit + together. Failed audit or stale ownership commits neither; terminalization retries + never publish. Unknown COMMIT reconciles durable state before retry and cannot + duplicate the terminal audit. Exact real-role grants allow only the declared writes. +- **Source:** [ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md); + [P02b-2 proof obligations](../roadmap/phase-02b-events-auth.md#decisive-proof-obligations). +- **Type:** production architecture census with planted controls where structural; + real PostgreSQL/runtime integration proofs where behavioural. + **Kind:** behavioural. +- **Status:** **Registered** — no implementation evidence yet. +- **Phase:** 02b, P02b-2. + +#### `Outbox_Terminal_Audit_Identity_Is_Row_Bound` + +- **Asserts:** Only the successful fenced UPDATE result mints a restricted + EventTerminalTransition witness. A nonempty production mint/call-site census has + planted clean/dirty controls. Real bypass-role proofs substitute tenant, + organization, event and transaction against that witness and roll back both writes; + matching organization-scoped identity succeeds. +- **Source:** [ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md); + [P02b-2 proof obligations](../roadmap/phase-02b-events-auth.md#decisive-proof-obligations). +- **Type:** production architecture census with planted controls where structural; + real PostgreSQL/runtime integration proofs where behavioural. + **Kind:** structural + behavioural. +- **Status:** **Registered** — no implementation evidence yet. +- **Phase:** 02b, P02b-2. + +#### `Outbox_Type_Resolution_Is_Declared_And_Bounded` + +- **Asserts:** Only declared concrete contract identities and explicit compatible + aliases resolve; collisions refuse composition. Unknown/retired/malformed/mismatched + data terminalizes without arbitrary type loading. Frozen options, canonical UTF-8 + admission, microsecond timestamp comparison and full row/envelope agreement have + positive and negative controls. +- **Source:** [ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md); + [P02b-2 proof obligations](../roadmap/phase-02b-events-auth.md#decisive-proof-obligations). +- **Type:** production architecture census with planted controls where structural; + real PostgreSQL/runtime integration proofs where behavioural. + **Kind:** structural + startup + behavioural. +- **Status:** **Registered** — no implementation evidence yet. +- **Phase:** 02b, P02b-2. + +#### `Outbox_Pending_Subscriptions_Preserve_Events` + +- **Asserts:** Real TenantCreated rows and aliases remain unchanged through repeated + Pending polls, even with malformed stored payloads: no acquisition, attempts, + validation or handler construction. Declared intentional absence is a distinct + tested acknowledgement policy. Test-owned Active contracts exercise the shipped + publish/mark path; real subscriptions stay Pending until P02b-3. +- **Source:** [ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md); + [P02b-2 proof obligations](../roadmap/phase-02b-events-auth.md#decisive-proof-obligations). +- **Type:** production architecture census with planted controls where structural; + real PostgreSQL/runtime integration proofs where behavioural. + **Kind:** structural + behavioural. +- **Status:** **Registered** — no implementation evidence yet. +- **Phase:** 02b, P02b-2. + +#### `Outbox_Health_And_Metrics_Are_Bounded` + +- **Asserts:** Production worker registration, bounded deadlines/overruns/shutdown and + real progress health have nonempty subjects and planted controls. Gauge callbacks + read immutable snapshots without SQL; failed refresh retains stale data. + Due/delayed/leased/deferred/terminal-requested/terminal rows distinguish gauges. + Labels and diagnostics prohibit unbounded identities or sensitive + exception/type/payload/connection data; /healthz remains liveness-only. +- **Source:** [ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md); + [P02b-2 proof obligations](../roadmap/phase-02b-events-auth.md#decisive-proof-obligations). +- **Type:** production architecture census with planted controls where structural; + real PostgreSQL/runtime integration proofs where behavioural. + **Kind:** structural + behavioural. +- **Status:** **Registered** — no implementation evidence yet. +- **Phase:** 02b, P02b-2. + ### Demand-gated: lands with its adapter These rules are agreed but cannot be written until the technology they constrain is From d9bc77b24e6491bc9886c332ebd5a79ca96db712 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 21:20:09 +0300 Subject: [PATCH 69/92] feat(kernel): add confined outbox dispatch foundation Establish the accepted lease state and explicit event readiness before activating dispatch. Constrain the worker credential at configuration and physical-connection boundaries while preserving legacy event facts. ADR: 0046, 0003, 0033, 0044 Module: Tenancy, Audit --- .claude/skills/add-audit-coverage/SKILL.md | 19 +- .claude/skills/add-integration-event/SKILL.md | 154 ++--- .claude/skills/standards-check/SKILL.md | 8 +- .claude/skills/wire-dapr-pubsub/SKILL.md | 62 +- CLAUDE.md | 22 +- .../OutboxCompositionExtensions.cs | 34 + .../PersistenceCompositionExtensions.cs | 15 +- .../Messaging/OutboxDispatcherDataSource.cs | 98 +++ .../Messaging/OutboxEventRegistry.cs | 358 ++++++++++ ...0355_add_outbox_dispatch_state.Designer.cs | 29 + ...0261012180355_add_outbox_dispatch_state.cs | 102 +++ .../PlatformDbContextModelSnapshot.cs | 2 +- .../Persistence/PlatformDbContext.cs | 20 +- ...33_grant_outbox_terminal_audit.Designer.cs | 231 +++++++ ...61012180433_grant_outbox_terminal_audit.cs | 25 + .../PersistenceConventionTests.cs | 6 +- .../PlatformAdminScopeConventionTests.cs | 14 +- .../Database/DisposableSchemaDatabase.cs | 2 + .../Database/OutboxDispatchMigrationTests.cs | 224 ++++++ .../OutboxDispatcherRoleGuardTests.cs | 162 +++++ .../Database/PlatformSchemaTests.cs | 9 +- .../Database/TenancySchemaTests.cs | 5 +- .../OutboxDispatcherDataSourceGuardTests.cs | 98 +++ .../OutboxProductionCompositionTests.cs | 38 + .../Messaging/OutboxEventRegistryTests.cs | 228 ++++++ docs/architecture/15-event-and-outbox.md | 648 ++++++------------ docs/architecture/31-audit-subsystem.md | 189 ++--- docs/glossary.md | 13 +- docs/modules/customization/README.md | 21 +- docs/roadmap/phase-02b-events-auth.md | 503 +++++++------- docs/standards/05-database.md | 116 +++- docs/standards/09-error-handling.md | 27 +- docs/standards/10-observability.md | 40 +- docs/standards/11-security.md | 28 +- docs/standards/18-audit-coverage.md | 39 +- docs/standards/20-infrastructure-stack.md | 65 +- 36 files changed, 2611 insertions(+), 1043 deletions(-) create mode 100644 backend/src/LearnStack.Api/Composition/OutboxCompositionExtensions.cs create mode 100644 backend/src/LearnStack.Infrastructure/Messaging/OutboxDispatcherDataSource.cs create mode 100644 backend/src/LearnStack.Infrastructure/Messaging/OutboxEventRegistry.cs create mode 100644 backend/src/LearnStack.Infrastructure/Persistence/Migrations/20261012180355_add_outbox_dispatch_state.Designer.cs create mode 100644 backend/src/LearnStack.Infrastructure/Persistence/Migrations/20261012180355_add_outbox_dispatch_state.cs create mode 100644 backend/src/Modules/Audit/LearnStack.Modules.Audit.Infrastructure/Persistence/Migrations/20261012180433_grant_outbox_terminal_audit.Designer.cs create mode 100644 backend/src/Modules/Audit/LearnStack.Modules.Audit.Infrastructure/Persistence/Migrations/20261012180433_grant_outbox_terminal_audit.cs create mode 100644 backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatchMigrationTests.cs create mode 100644 backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatcherRoleGuardTests.cs create mode 100644 backend/tests/LearnStack.Tests.Unit/Api/Composition/OutboxDispatcherDataSourceGuardTests.cs create mode 100644 backend/tests/LearnStack.Tests.Unit/Api/Composition/OutboxProductionCompositionTests.cs create mode 100644 backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/OutboxEventRegistryTests.cs diff --git a/.claude/skills/add-audit-coverage/SKILL.md b/.claude/skills/add-audit-coverage/SKILL.md index 5bf0bc99..30908562 100644 --- a/.claude/skills/add-audit-coverage/SKILL.md +++ b/.claude/skills/add-audit-coverage/SKILL.md @@ -105,12 +105,14 @@ parsed from the other, and three rules in `AuditCoverageTests` join them — whose command has since shipped **fails**. - **`(off-path)`** marks an operation that is not a MediatR request at all. Those rows sit outside the request-type join in both directions and register by slug rather than - by type. Seven rows carry it: Tenancy's `platform.admin_scope.enter`, + by type. Nine rows carry it: Tenancy's `platform.admin_scope.enter`, `tenancy.killswitch.toggle`, `tenancy.entitlement.refresh`, - `tenancy.tenant_assertion.reject` and `tenancy.tenant_assertion.anonymous_burst`, and + `tenancy.tenant_assertion.reject`, `tenancy.tenant_assertion.anonymous_burst`, + `platform.outbox.dead_letter` and `platform.delivery.dead_letter`, and Audit's `audit.redaction.apply` and `audit.purge.apply`. The three whose writer ships are - registered through `DeclareOffPath`; the other four also carry `(planned)` and register - when their writer lands. + registered through `DeclareOffPath`; the other six also carry `(planned)` and + register when their writer lands. ADR-0046 reserves the Tenancy-owned producer + terminal operation for P02b-2 and subscriber terminal operation for P02b-3. - **A row carries both markers where both are true** — off the request path *and* ahead of the code that will write it. @@ -529,8 +531,13 @@ passes even when every policy is inert. See aggregate into another module's context inverts the dependency direction. - **`UPDATE`ing an audit row to add detail after the fact.** There is no second phase. The row is composed complete at the commit boundary, `IAuditStore` has **no update - method** — it has four *write* methods, `WritePendingAsync`, `WriteStandaloneAsync`, - `WriteBestEffortAsync` and `WritePlatformScopeAsync`, and that is a different thing — + method**. Its four delivered write methods are `WritePendingAsync`, + `WriteStandaloneAsync`, `WriteBestEffortAsync` and `WritePlatformScopeAsync`. + ADR-0046 accepts a fifth, P02b-2 pending, + `WriteEventTerminalAsync(AuditEntryDraft, EventTerminalTransition, DbConnection, + DbTransaction, CancellationToken)` for confined terminal writers. Its restricted + witness binds audit identity to the actual fenced UPDATE; handlers cannot call + this seam. None is an update method, and `learnstack_app` holds no `UPDATE` privilege on `audit_log`. - **Truncating snapshots silently.** Over `JsonValue.MaxRowBytes` the value becomes the elision record of Step 4b, in the column's own JSON type. Never an empty object, never diff --git a/.claude/skills/add-integration-event/SKILL.md b/.claude/skills/add-integration-event/SKILL.md index 73c4cfbb..17eb8f4f 100644 --- a/.claude/skills/add-integration-event/SKILL.md +++ b/.claude/skills/add-integration-event/SKILL.md @@ -3,10 +3,11 @@ name: add-integration-event description: > Publish or consume an integration event across modules using the outbox pattern, `IEventBus` (`InProcessEventBus` today; Dapr pub/sub → Kafka on the Phase 11 - trigger), and per-module inbox idempotency. + trigger), and shared transport-owned inbox idempotency. USE FOR: declaring a new versioned `<Something>IntegrationEventVN`, wiring a module to publish it via `IOutbox.EnqueueAsync`, and wiring another module to - consume it via `IIntegrationEventHandler<T>` + `IInboxGuard`. DO NOT USE FOR: + consume it via `IIntegrationEventHandler<T>` under the transport-owned inbox + boundary. DO NOT USE FOR: intra-module domain events (those use plain MediatR `INotification`, in-process), provider webhook receivers (they have their own pattern), or Hub-side events (those live in the `learnstack-hub` repo). @@ -31,7 +32,10 @@ collection and the completed-provisioning publisher. Step 4 implements the production censuses and catalogue enforcement; the [delivery record](../../../docs/roadmap/phase-02b-events-auth.md#p02b-1-step-4--producer-enforcement-and-closeout-2026-10-12) owns final verification and review status. -P02b-2/P02b-3 retain dispatcher/inbox decisions. +Accepted [ADR-0046](../../../docs/decisions/0046-durable-event-delivery-and-recovery.md) +owns dispatcher recovery, subscription readiness and the shared inbox contract. +P02b-2 implements producer dispatch; P02b-3 still owns durable acceptance, +independent execution, canonical inbox/ledger DDL and real subscriptions. ## When to use @@ -80,7 +84,7 @@ public sealed record EnrollmentCreatedIntegrationEventV1 : IntegrationEventBase public override string Topic => "learnstack.enrollment.enrollment"; // Declare EnrollmentId as the partition source. ADR-0057 accepts canonical - // identifier derivation; G3 durable publish ordering remains P02b-2. + // identifier derivation; ADR-0046 bounds eligible selection, not publish order. public override string PartitionKey => EnrollmentId.ToString("D"); } ``` @@ -153,7 +157,8 @@ Rules: causation and optional causal actor come from trusted execution. That boundary guarantees W3C Activity without a listener. Domain translation mints a new integration EventId and uses its domain EventId as causation. A non-marked - event does not erase an announced organization; G1 delivery scope stays open. + event does not erase an announced organization. ADR-0046 preserves that envelope + organization during delivery; a non-marked type never widens its scope. - The owner drains successful-save events FIFO before invariants/MUST audit and commit, at most 256 per transaction. Domain handlers only translate to outbox: no mutation/write stores, nested sends, further raises, publication or providers. @@ -186,80 +191,44 @@ neither derives a second answer. The architecture test `Integration_Event_TopicNames_FollowConvention` enforces the pattern; deviation fails the build. -### Step 4: Consumer — handler + inbox guard - -**Inherited target, pending P02b-2 G1 and P02b-3 delivery implementation.** This -example does not decide inbox ownership, deduplication identity or delivery scope. -The accepted first flow uses independent Customization `card@1`/`plain@1` -subscriptions for `TenantCreatedIntegrationEventV1`, not an Audit mirror. G1 must -prevent one subscription from suppressing its sibling before those handlers ship. -P02b-3 owns create-only preservation, per-effect MUST audit and the semantic -key/version Seeder handoff described in ADR-0057. - -In `<Consumer>.Application/IntegrationEvents/<HandlerName>.cs`: - -```csharp -public sealed class QueueWelcomeNotificationOnEnrollmentCreated( - IInboxGuard inbox, - NotificationsDbContext db, - ILogger<QueueWelcomeNotificationOnEnrollmentCreated> logger) - : IIntegrationEventHandler<EnrollmentCreatedIntegrationEventV1> -{ - public async Task HandleAsync( - EnrollmentCreatedIntegrationEventV1 @event, CancellationToken ct) - { - if (await inbox.IsAlreadyProcessedAsync(@event.EventId, ct)) - { - logger.LogDebug("Duplicate event {EventId} skipped", @event.EventId); - return; - } - - // ... business logic ... - var dispatch = NotificationDispatch.QueueWelcome( - @event.LearnerId, @event.EnrollmentId); - db.NotificationDispatches.Add(dispatch); - - inbox.MarkAsProcessed(@event.EventId, @event.GetType().Name); - await db.SaveChangesAsync(ct); // atomic business write + inbox marker - } -} -``` - -Rules: - -- Under the inherited consumer target, **every** `IIntegrationEventHandler` invokes - `IInboxGuard.IsAlreadyProcessedAsync` - before any business logic. The architecture test - `Integration_Event_Handlers_Use_InboxGuard` holds every handler to it from - [Phase 02b](../../../docs/roadmap/phase-02b-events-auth.md), which ships the guard; it - is registered, not running, until then. -- `MarkAsProcessed` writes on the same **transaction** as the business write — the ambient - one the transport opens per delivery through `IUnitOfWork` - ([ADR-0040](../../../docs/decisions/0040-ambient-unit-of-work.md)) — so the inbox marker - and the business write commit atomically. -- The **transport** — not middleware — restores tenant context from - `@event.TenantId` before your handler runs, and puts the publisher's own back - afterwards. Read it through `ITenantContext` as usual; don't read it from - anywhere else. -- **Organization comes from the envelope, and it has to.** An earlier version of - this skill said it was deliberately not restored, reasoning that inventing an - organization scope would narrow queries the producer never narrowed. Under the - canonical Row Level Security policy the reasoning inverts: with - `app.organization_id` unset, an organization-scoped row evaluates - `false OR NULL OR NULL`, and a NULL policy result is false — so an absent - organization *hides* every organization-scoped row and `WITH CHECK` rejects - writing one. Widening is the `app.scope = 'tenant'` hatch, not an absent value. -- **The effective actor is always `UserId.SystemActor`.** A human named by the - envelope remains separate causal audit metadata (`CausalActorUserId`); an - asynchronous consumer never impersonates that human. -- **Your handler's constructor must do nothing but assign fields.** Each - subscription gets its own async DI scope and exactly one handler construction. - Constructor, handler and disposal failures are contained to that subscription, - so healthy siblings still run. +### Step 4: Consumer — transport-owned inbox boundary + +**Accepted target, pending P02b-3 implementation.** The transport owns the shared +`inbox_messages` guard; business handlers do not inject or call `IInboxGuard`. +Deduplication is `(tenant_id, subscription_id, event_id)`, with a stable versioned +subscription ID independent of handler type names. The separate delivery ledger +owns attempts, leases, terminal state and targeted replay. + +Before constructing a handler, the executor opens an app-role unit of work, +announces envelope tenant and organization, and acquires the uncommitted inbox +claim. Completed duplicates skip construction. The completed inbox marker, +business changes, produced outbox, MUST audit and fenced ledger completion commit +on that one transaction; rollback removes a new claim. A replay never resets a +completed inbox row or republishes to healthy siblings. + +The effective actor is `UserId.SystemActor`; an envelope's human actor remains +separate trusted causal metadata. Envelope organization is preserved even for a +non-marked event type. Restore context before resolving any handler, store or +module `DbContext`, and restore the caller's context afterwards. + +A handler constructor only assigns fields. Construction, execution and disposal +failures belong to that subscription's own retry budget. Timeouts poison the +transaction owner; late work cannot commit success. An uncertain physical COMMIT +is Indeterminate and recovers from atomic ledger/inbox state. Ordinary app-role +delivery refuses privileged erasure work; Phase 03 owns that separate audited +boundary. External effects need idempotency keyed by subscription/event, or an +outbox intent; database rollback alone cannot undo them. + +The first real flow has two independent Customization subscriptions: +`customization.initialize-content-type-default.v1` installs `card@1` content type; +`customization.initialize-taxonomy-default.v1` installs `plain@1` taxonomy. Both +remain Pending until P02b-3 supplies durable acceptance, create-only preservation, +per-effect MUST audit and the semantic key/version Seeder handoff. Neither may +suppress its sibling or overwrite authored, retired or deleted definitions. ### Step 5: Subscription registration -Today, expose the consumer assembly to the composition root so the +The current baseline exposes the consumer assembly to the composition root so the construction-free registry discovers and registers its concrete handlers: ```csharp @@ -271,9 +240,14 @@ builder.AddLearnStackCrossCuttingFoundation( Today `InProcessEventBus` resolves the concrete `IIntegrationEventHandler<T>` directly from DI in that subscription's async scope. MediatR is not involved. There is no shipped -`AddDaprSubscription<T>` helper; do not invent one. Phase 11's Dapr adapter -invokes the same event-declared topic and handler contract, so the handler code -remains identical across transports. +`AddDaprSubscription<T>` helper; do not invent one. The accepted construction-free +manifest adds stable subscription IDs, compatible +type aliases and required Pending/Active readiness. Pending holds the producer row +before acquisition or payload validation. Only explicit intentional absence may +acknowledge a known type without publication. P02b-3 makes Active publication mean +durable acceptance of all matching deliveries, independently of business handling. +Do not invent a registration API before its implementation ships. Phase 11 must +preserve these contracts when it adds Dapr. ### Step 6: Tests @@ -287,8 +261,10 @@ byte boundaries, SQL NULL metadata and terminal reuse. For delivered flows: 1. **Producer test** — exercise the handler, assert an `outbox_messages` row exists with the right `type`, `topic`, `payload`. -2. **Consumer test** — feed the event into the handler twice; assert the business - write happens exactly once (idempotency via `IInboxGuard`). +2. **Consumer test, P02b-3** — deliver duplicates through the real transport and + ledger, including concurrent duplicates and sibling subscriptions. Assert one + completed inbox/business effect per subscription/event, atomic rollback, + independent retry budgets and recovery after an uncertain final-claim COMMIT. ## Validation @@ -296,22 +272,22 @@ byte boundaries, SQL NULL metadata and terminal reuse. For delivered flows: - `LearnStack.Tests.Architecture` is green — `Integration_Event_TopicNames_FollowConvention` and `Integration_Events_Inherit_From_IntegrationEventBase` run today. `Integration_Event_Handlers_Use_InboxGuard` remains Registered for P02b-3's - consumer delivery after G1. Check + transport-owned consumer delivery. Check [the catalogue](../../../docs/standards/21-architecture-tests-catalogue.md) for their status rather than assuming a net is under you. -- An integration test confirms the round-trip: handler enqueues → outbox row - created → outbox processor dispatches → consumer handles + writes business - state + inbox row. -- Sending the same event twice writes the consumer's business state exactly once. +- P02b-2 proves producer recovery and terminal audit independently of Pending + subscriptions. P02b-3 proves the round-trip through durable acceptance and the + shared inbox; do not claim that proof before those components ship. +- P02b-3 duplicate delivery yields one committed business effect per subscription. ## Common pitfalls - **Crossing modules with a domain event.** Domain events stay inside the module that owns the aggregate. The moment another module needs to react, it must be an integration event. -- **Forgetting `IInboxGuard`.** The consumer rule remains Registered until - P02b-3; do not infer enforcement from its name. Consumer delivery must prove - redelivery cannot duplicate its business effect. +- **Moving the inbox guard into a handler.** The transport must claim before + construction. The rule remains Registered until P02b-3; its name alone proves + no enforcement. Redelivery must preserve independent sibling effects. - **Writing the outbox row in a separate transaction.** Use `IOutbox.EnqueueAsync` through the enrolled ambient `IUnitOfWork`; never `new TransactionScope`. - **Supplying a second topic at enqueue or subscription time.** The concrete diff --git a/.claude/skills/standards-check/SKILL.md b/.claude/skills/standards-check/SKILL.md index 347b60f6..aa03371b 100644 --- a/.claude/skills/standards-check/SKILL.md +++ b/.claude/skills/standards-check/SKILL.md @@ -375,8 +375,12 @@ domain the diff doesn't touch. [30-api-gateway.md § 2](../../../docs/architecture/30-api-gateway.md). - [ ] Hub contract surface still satisfies ADR-0034's two invariants; any new endpoint carries its ADR and lands in both repositories. -- [ ] Outbox + inbox usage correct (atomic with aggregate write; inbox guard - in every consumer). +- [ ] Producer INSERT uses its announced transaction. ADR-0046 dispatcher + transitions use fresh tokens and atomic terminal audit; transport-owned shared + inbox acquisition precedes handler construction (P02b-3 pending). +- [ ] Manifest readiness preserves required Pending rows. Active publication + means durable acceptance; subscriber retry/terminal/replay state is independent + of the producer budget. Check catalogue status before claiming enforcement. ### Step 4 — Architecture-test coverage diff --git a/.claude/skills/wire-dapr-pubsub/SKILL.md b/.claude/skills/wire-dapr-pubsub/SKILL.md index fedc89f4..40149f55 100644 --- a/.claude/skills/wire-dapr-pubsub/SKILL.md +++ b/.claude/skills/wire-dapr-pubsub/SKILL.md @@ -21,7 +21,11 @@ ADR-0035's Phase 11 trigger has fired, also wire the Dapr adapter without changi that contract. The wiring contract is in [ADR-0038](../../../docs/decisions/0038-cross-cutting-port-and-event-contracts.md) and [29-dapr-integration.md](../../../docs/architecture/29-dapr-integration.md); -this skill is the **mechanical** check-list. +this skill is the **mechanical** check-list. Accepted +[ADR-0046](../../../docs/decisions/0046-durable-event-delivery-and-recovery.md) +adds construction-free stable subscription identities, Pending/Active readiness +and durable acceptance. Those consumer components remain P02b-3 work; Phase 11 +must preserve their contracts rather than reintroduce handler-owned deduplication. ## When to use @@ -126,7 +130,8 @@ await outbox.EnqueueAsync(new EnrollmentCreatedIntegrationEventV1 { ... }, ct); await db.SaveChangesAsync(ct); ``` -The `OutboxProcessor` (BackgroundService) polls `outbox_messages`, constructs an +The accepted P02b-2 `OutboxProcessor` target (BackgroundService) polls +`outbox_messages`, constructs an `IntegrationEventEnvelope`, and calls `IEventBus.PublishAsync(envelope)`. `DaprEventBus.PublishAsync` publishes to `envelope.Topic` with the envelope's delivery fields as metadata — see @@ -152,23 +157,32 @@ register the concrete handler type: ```csharp builder.AddLearnStackCrossCuttingFoundation( deploymentMode, - typeof(CreateAuditEntryOnUserDeleted).Assembly); + typeof(QueueWelcomeNotificationOnEnrollmentCreated).Assembly); ``` There is no shipped `AddDaprSubscription<T>` helper or `[Topic]` attribute. Do not invent one. The event declares its topic and the construction-free handler registry supplies the current subscription metadata. -The future Phase 11 subscription pipeline must preserve this behavior: +P02b-3 adds the declared manifest: stable versioned subscription IDs, compatible +aliases, and required Pending/Active readiness. Pending subscriptions hold producer +rows before acquisition; Active publish returns only after every matching delivery +is durably accepted. A separate executor owns independent per-subscription retries, +terminal state and targeted replay. Neither publication nor replay invokes healthy +sibling business handlers again. No current registration helper is claimed here. + +The future Phase 11 subscription pipeline must preserve the accepted behavior: 1. The sidecar **discovers** subscriptions with `GET /dapr/subscribe`, which returns the topic-to-route table; it then **delivers** each event with an HTTP `POST` to the route that table named. They are two different calls, and there is no endpoint called `/dapr/subscribe-endpoint`. -2. LearnStack's Phase 11 Dapr adapter deserialises the envelope, restores - `TenantContext` from the event and envelope, and resolves the matching - `IIntegrationEventHandler<T>` directly from DI. -3. The `IIntegrationEventHandler<T>` runs with `IInboxGuard` protection. +2. The adapter validates the envelope and durably accepts deliveries before + acknowledging the broker; transport acceptance is separate from business work. +3. The executor opens the common app-role transaction, restores tenant and + envelope organization, and acquires the shared inbox claim before resolving + `IIntegrationEventHandler<T>`. Inbox completion, business state, produced + outbox, MUST audit and fenced delivery completion commit atomically. ### Step 5: Current transport (`InProcessEventBus`) @@ -229,20 +243,19 @@ await daprClient.PublishByteEventAsync( }); ``` -Kafka guarantees ordering **within a partition**. Cross-partition ordering does -not exist; design around it. +ADR-0046 guarantees deterministic eligible selection by availability, occurrence +and ID within one processor. It promises no enqueue, commit, retry or cross-process +order. Kafka partition ordering cannot strengthen the producer guarantee; document +any future broker-specific bound in Phase 11. ### Step 8: Observability -Phase 11 must add and verify the transport metrics governed by the observability -standard; they are not automatic in the current in-process implementation: - -- `learnstack_outbox_dispatch_duration_seconds{event_type}` -- `learnstack_outbox_dispatch_failed_total{event_type}` -- `learnstack_inbox_dedup_total{module, event_type}` - -Keep tags low-cardinality and confirm the names against the implementation and -dashboard when the adapter lands. +[Observability Standards](../../../docs/standards/10-observability.md#required-metrics) +owns event metric names, bounded dimensions, snapshot gauges and health deadlines. +P02b-2 implements producer dispatch diagnostics; P02b-3 implements subscriber +execution/dedup diagnostics and minimizes the existing transport diagnostics. +Phase 11 adds adapter binding evidence. Do not move these producer/subscriber +obligations to the adapter or infer delivery from a registered metric name. ## Validation @@ -253,8 +266,9 @@ dashboard when the adapter lands. - When doing Phase 02b consumer work, add/keep its registered inheritance and inbox-guard rules. When doing Phase 11 adapter work, add/keep the Dapr binding and direct-provider boundary rules owned by that phase. -- Today, an in-process integration test confirms publish → dispatch → consume → - inbox marker and the current transport's consumer span. +- The current baseline has no durable inbox. P02b-3 must prove acceptance plus + atomic inbox/business completion, concurrent duplicates, independent sibling + retries, sticky timeout and uncertain-COMMIT recovery on the real delivery path. - When Phase 11's adapter exists, its Dapr/Testcontainers binding test and transport metrics pass as additional validation. @@ -275,9 +289,9 @@ dashboard when the adapter lands. - **Switching on deployment mode before the trigger.** All modes use `InProcessEventBus` today. Add the Dapr branch only with the Phase 11 adapter and its integration suite. -- **Ordering assumption across topics.** Kafka does not order across topics or - cross-partition within a topic. If your design needs strict ordering, use - partition keys and document the assumption. +- **Assuming partition keys prove publish order.** The producer promises only + deterministic eligible selection. Retries and concurrent commits can reorder + delivery even when the future broker preserves its own partition order. - **Component YAML drift between dev and prod.** Keep one canonical YAML in `infra/dapr/components/`; environment overrides only via Vault-injected metadata fields (broker URL, auth). diff --git a/CLAUDE.md b/CLAUDE.md index 54c3de1e..8f76d1a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -182,7 +182,14 @@ records passing full backend verification and both completed review rounds. **P02b-1 is complete and merged** through [PR #29](https://github.com/HodeTech/LearnStack/pull/29) on 2026-10-12. The [merge closeout](docs/roadmap/phase-02b-events-auth.md#p02b-1-merge-and-closeout-2026-10-12) -owns final verification and the next P02b-2 decision pass. +owns final verification. **P02b-2's revised decision package and ADR-0046 are +Accepted — 2026-10-12.** The four implementation steps and two independent review +rounds per step are authorized on development. The +[Step 1 record](docs/roadmap/phase-02b-events-auth.md#p02b-2-step-1--decision-schema-and-confined-composition-2026-10-12) +owns current progress. Acceptance reconciles delivery contracts and registers six +rules; it claims no dispatcher, inbox or subscriber delivery. P02b-3 still owns +durable acceptance, shared inbox/ledger, independent retries, both real default +subscriptions and Seeder handoff. Phase 06 retains the full renderer/Studio expansion. **Phase 01** shipped the .NET 10 solution scaffold under `backend/` @@ -384,8 +391,9 @@ 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 is complete and merged through PR #29 on 2026-10-12. Its four steps deliver the producer - runtime and production-census enforcement. Next is P02b-2's decision pass; - each later packet accepts its own gates before implementation. + runtime and production-census enforcement. P02b-2's revised package/ADR-0046 are + Accepted, with Step 1 in progress; producer dispatch delivery remains pending. + P02b-3 owns consumer/inbox delivery; each later packet accepts its gates first. - [docs/roadmap/phase-02d-walking-skeleton.md](docs/roadmap/phase-02d-walking-skeleton.md) — complete and merged; the delivered public-site baseline and P02b-0 handoff. `02d` sorts after `02b`/`02c` but runs **before** them; the roadmap dependency @@ -630,9 +638,11 @@ rules: `ISecretProvider`. - Read `DeploymentMode` from inside a module — the composition root branches once, modules never. -- Write `audit_log`, `platform_entitlement_cache`, or `outbox_messages` - directly — use `IAuditStore`, `IEntitlementProvider.RefreshAsync`, - `IOutbox`. +- Write `audit_log`, `platform_entitlement_cache`, or producer `outbox_messages` + rows directly — use `IAuditStore`, `IEntitlementProvider.RefreshAsync`, `IOutbox`. + Only ADR-0046's confined dispatcher writes outbox status through fenced + parameterized SQL; its terminal UPDATE and narrow witness-bound audit INSERT + share one transaction. That accepted exception remains pending P02b-2 delivery. - Write rows through EF Core's set-based APIs — `ExecuteUpdate`, `ExecuteDelete`, `ExecuteSql*`. The audit capture sees only what the `ChangeTracker` holds ([ADR-0044 § 7](docs/decisions/0044-audit-write-path.md)), so such a write commits with diff --git a/backend/src/LearnStack.Api/Composition/OutboxCompositionExtensions.cs b/backend/src/LearnStack.Api/Composition/OutboxCompositionExtensions.cs new file mode 100644 index 00000000..38f0cc47 --- /dev/null +++ b/backend/src/LearnStack.Api/Composition/OutboxCompositionExtensions.cs @@ -0,0 +1,34 @@ +using LearnStack.Infrastructure.Messaging; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Npgsql; + +namespace LearnStack.Api.Composition; + +/// <summary>Owns the dispatcher credential in the API composition root.</summary> +public static class OutboxCompositionExtensions +{ + public static IServiceCollection AddLearnStackOutboxDispatcher( + this IServiceCollection services, IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + var connectionString = configuration.GetConnectionString(OutboxDispatcherDataSource.ConnectionName); + if (!string.IsNullOrWhiteSpace(connectionString)) + { + OutboxDispatcherDataSource.Validate(connectionString); + } + + // The keyed source is container-owned. Lazy defers construction while + // construction-only test hosts remove the production worker; the worker's + // startup must resolve and open it, including when configuration is absent. + services.TryAddKeyedSingleton<NpgsqlDataSource>( + OutboxDispatcherDataSource.Key, + (_, _) => OutboxDispatcherDataSource.Build(connectionString)); + services.TryAddKeyedSingleton<Lazy<NpgsqlDataSource>>( + OutboxDispatcherDataSource.Key, + (provider, key) => new Lazy<NpgsqlDataSource>( + () => provider.GetRequiredKeyedService<NpgsqlDataSource>(key))); + return services; + } +} diff --git a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs index dde24e4c..19e2fc63 100644 --- a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs +++ b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs @@ -2,6 +2,7 @@ using LearnStack.Modules.Customization.Application.Contracts.Definitions; using LearnStack.Modules.Tenancy.Application.Contracts.Locales; using LearnStack.Modules.Tenancy.Application.Contracts.PublicReads; +using LearnStack.Modules.Tenancy.Application.Contracts.IntegrationEvents; using LearnStack.Modules.Education.Application.Audit; using LearnStack.Infrastructure.Audit; using LearnStack.Modules.Customization.Application.Audit; @@ -83,8 +84,9 @@ namespace LearnStack.Api.Composition; /// resolution boundary. /// </para> /// <para> -/// <c>ConnectionStrings:OutboxDispatcher</c> is still deliberately absent; it lands -/// with its consumer in Phase 02b. +/// <c>ConnectionStrings:OutboxDispatcher</c> is keyed and lazy through the separate +/// confined outbox composition. Its mandatory worker startup verifies connectivity; +/// module contexts and the Seeder never resolve it (ADR-0046). /// </para> /// </remarks> public static class PersistenceCompositionExtensions @@ -100,6 +102,15 @@ public static IServiceCollection AddLearnStackPersistence( ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(configuration); + services.AddLearnStackOutboxDispatcher(configuration); + services.TryAddSingleton(provider => OutboxEventRegistry.Create( + [OutboxEventRegistration.Required( + typeof(TenantCreatedIntegrationEventV1), + [OutboxSubscriptionManifest.Pending("customization.initialize-content-type-default.v1"), + OutboxSubscriptionManifest.Pending("customization.initialize-taxonomy-default.v1")])], + [], provider.GetRequiredService<IntegrationEventHandlerRegistry>(), + [typeof(TenantCreatedIntegrationEventV1).Assembly])); + var connectionString = configuration.GetConnectionString(DefaultConnectionName); // Validated at boot when the key is present; built on first use either way. diff --git a/backend/src/LearnStack.Infrastructure/Messaging/OutboxDispatcherDataSource.cs b/backend/src/LearnStack.Infrastructure/Messaging/OutboxDispatcherDataSource.cs new file mode 100644 index 00000000..8390061a --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Messaging/OutboxDispatcherDataSource.cs @@ -0,0 +1,98 @@ +using Npgsql; + +namespace LearnStack.Infrastructure.Messaging; + +/// <summary>Builds the grant-bounded pool reserved for outbox dispatch.</summary> +public static class OutboxDispatcherDataSource +{ + public const string ConnectionName = "OutboxDispatcher"; + public const string Key = "outbox-dispatcher"; + public const string Role = "learnstack_outbox_admin"; + + /// <summary>Builds a guarded pool without opening a connection.</summary> + public static NpgsqlDataSource Build(string? connectionString) + { + var parsed = ParseAndValidate(connectionString); + + try + { + var builder = new NpgsqlDataSourceBuilder(parsed.ConnectionString); + builder.UsePhysicalConnectionInitializer( + connection => RequireConfinedRole(connection, async: false).GetAwaiter().GetResult(), + connection => RequireConfinedRole(connection, async: true)); + return builder.Build(); + } + catch (Exception failure) when (failure is ArgumentException or FormatException or InvalidOperationException) + { + // Driver configuration errors may contain supplied values. Do not + // attach them as inner exceptions or repeat any connection field. + throw InvalidConfiguration(); + } + } + + /// <summary>Checks configuration without building the pool or connecting.</summary> + public static void Validate(string? connectionString) => _ = ParseAndValidate(connectionString); + + private static NpgsqlConnectionStringBuilder ParseAndValidate(string? connectionString) + { + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new InvalidOperationException( + $"ConnectionStrings:{ConnectionName} is not configured. See .env.example."); + } + + NpgsqlConnectionStringBuilder parsed; + try + { + parsed = new NpgsqlConnectionStringBuilder(connectionString); + } + catch (Exception failure) when (failure is ArgumentException or FormatException) + { + throw InvalidConfiguration(); + } + + if (!string.Equals(parsed.Username, Role, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"ConnectionStrings:{ConnectionName} must name the dispatcher role. See .env.example."); + } + + return parsed; + } + + private static InvalidOperationException InvalidConfiguration() => new( + $"ConnectionStrings:{ConnectionName} is not a valid Npgsql key/value connection string. See .env.example."); + + private static async Task RequireConfinedRole(NpgsqlConnection connection, bool async) + { + await using var command = connection.CreateCommand(); + command.CommandTimeout = 5; + command.CommandText = + """ + SELECT EXISTS ( + SELECT 1 FROM pg_roles role + WHERE role.rolname = @role + AND session_user = @role AND current_user = @role + AND role.rolbypassrls + AND NOT role.rolsuper + AND NOT role.rolcreatedb + AND NOT role.rolcreaterole + AND NOT role.rolreplication + AND NOT EXISTS ( + SELECT 1 FROM pg_roles reachable + WHERE reachable.oid <> role.oid + AND pg_has_role(role.oid, reachable.oid, 'MEMBER'))) + """; + command.Parameters.AddWithValue("role", Role); + + var confined = async + ? await command.ExecuteScalarAsync() + : command.ExecuteScalar(); + + if (confined is not true) + { + throw new InvalidOperationException( + "The outbox dispatcher connection has an invalid role, attributes or memberships."); + } + } +} diff --git a/backend/src/LearnStack.Infrastructure/Messaging/OutboxEventRegistry.cs b/backend/src/LearnStack.Infrastructure/Messaging/OutboxEventRegistry.cs new file mode 100644 index 00000000..631492ee --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Messaging/OutboxEventRegistry.cs @@ -0,0 +1,358 @@ +using System.Collections.Frozen; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using LearnStack.SharedKernel.Messaging; + +namespace LearnStack.Infrastructure.Messaging; + +/// <summary>The declared delivery policy for one stored integration-event identity.</summary> +public enum OutboxEventDisposition +{ + Pending, + Active, + IntentionallyUnsubscribed, + Retired, + Unknown, +} + +/// <summary>Readiness of one stable, independently deduplicated subscription.</summary> +public enum OutboxSubscriptionReadiness +{ + Pending, + Active, +} + +/// <summary>Construction-free metadata for one subscription.</summary> +public sealed class OutboxSubscriptionManifest +{ + private OutboxSubscriptionManifest( + string id, OutboxSubscriptionReadiness readiness, Type? handlerType) + { + Id = id; + Readiness = readiness; + HandlerType = handlerType; + } + + public string Id { get; } + + public OutboxSubscriptionReadiness Readiness { get; } + + /// <summary>Only an Active subscription names a concrete handler.</summary> + public Type? HandlerType { get; } + + public static OutboxSubscriptionManifest Pending(string id) => + new(id, OutboxSubscriptionReadiness.Pending, null); + + public static OutboxSubscriptionManifest Active(string id, Type handlerType) => + new(id, OutboxSubscriptionReadiness.Active, handlerType); +} + +/// <summary>A concrete contract, its declared aliases and its delivery policy.</summary> +public sealed class OutboxEventRegistration +{ + private OutboxEventRegistration( + Type eventType, + IReadOnlyList<OutboxSubscriptionManifest> subscriptions, + IReadOnlyList<string> aliases, + bool intentionallyUnsubscribed) + { + EventType = eventType; + Subscriptions = subscriptions; + CompatibleAliases = aliases; + IsIntentionallyUnsubscribed = intentionallyUnsubscribed; + } + + public Type EventType { get; } + + public IReadOnlyList<OutboxSubscriptionManifest> Subscriptions { get; } + + public IReadOnlyList<string> CompatibleAliases { get; } + + public bool IsIntentionallyUnsubscribed { get; } + + public static OutboxEventRegistration Required( + Type eventType, + IEnumerable<OutboxSubscriptionManifest> subscriptions, + params string[] compatibleAliases) + { + ArgumentNullException.ThrowIfNull(subscriptions); + ArgumentNullException.ThrowIfNull(compatibleAliases); + return new OutboxEventRegistration( + eventType, + Array.AsReadOnly(subscriptions.ToArray()), + Array.AsReadOnly(compatibleAliases.ToArray()), + intentionallyUnsubscribed: false); + } + + public static OutboxEventRegistration IntentionallyUnsubscribed( + Type eventType, + params string[] compatibleAliases) + { + ArgumentNullException.ThrowIfNull(compatibleAliases); + return new OutboxEventRegistration( + eventType, + Array.Empty<OutboxSubscriptionManifest>(), + Array.AsReadOnly(compatibleAliases.ToArray()), + intentionallyUnsubscribed: true); + } +} + +/// <summary>A bounded lookup result; stored text never becomes a CLR type lookup.</summary> +public sealed record OutboxEventResolution( + OutboxEventDisposition Disposition, + Type? EventType, + string EventTypeLabel, + IReadOnlyList<OutboxSubscriptionManifest> Subscriptions); + +/// <summary> +/// Exact stored-type resolver and readiness manifest for the durable outbox. +/// </summary> +/// <remarks> +/// The registry is assembled from known contract types and the existing handler +/// registry. It never constructs a handler or calls <c>Type.GetType</c> on a row. +/// Pending identities include all declared compatible aliases, so the dispatcher +/// can exclude them before reading or validating a payload. +/// </remarks> +public sealed class OutboxEventRegistry +{ + private static readonly IReadOnlyList<OutboxSubscriptionManifest> NoSubscriptions = + Array.Empty<OutboxSubscriptionManifest>(); + + private readonly FrozenDictionary<string, OutboxEventResolution> _known; + private readonly FrozenSet<string> _retired; + + private OutboxEventRegistry( + FrozenDictionary<string, OutboxEventResolution> known, + FrozenSet<string> retired, + IReadOnlyList<string> pendingIdentities, + IReadOnlyList<string> eventTypeLabels) + { + _known = known; + _retired = retired; + PendingIdentities = pendingIdentities; + EventTypeLabels = eventTypeLabels; + } + + /// <summary>Exact canonical and alias identities held before acquisition.</summary> + public IReadOnlyList<string> PendingIdentities { get; } + + /// <summary>The finite labels allowed for declared event types in metrics.</summary> + public IReadOnlyList<string> EventTypeLabels { get; } + + /// <summary> + /// Builds a closed policy from explicitly known assemblies and concrete types. + /// Active entries must match the construction-free handler census exactly. + /// </summary> + public static OutboxEventRegistry Create( + IEnumerable<OutboxEventRegistration> registrations, + IEnumerable<string> retiredIdentities, + IntegrationEventHandlerRegistry handlers, + IEnumerable<Assembly> knownContractAssemblies) + { + ArgumentNullException.ThrowIfNull(registrations); + ArgumentNullException.ThrowIfNull(retiredIdentities); + ArgumentNullException.ThrowIfNull(handlers); + ArgumentNullException.ThrowIfNull(knownContractAssemblies); + + var assemblies = knownContractAssemblies.ToHashSet(); + if (assemblies.Count == 0) + { + throw new InvalidOperationException("The outbox registry needs a known contract assembly."); + } + + var known = new Dictionary<string, OutboxEventResolution>(StringComparer.Ordinal); + var pending = new List<string>(); + var subscriptionIds = new HashSet<string>(StringComparer.Ordinal); + var activeHandlers = new HashSet<(Type EventType, Type HandlerType)>(); + var declaredTypes = new HashSet<Type>(); + + foreach (var registration in registrations) + { + if (registration is null) + { + throw new InvalidOperationException("An outbox contract registration is null."); + } + + var eventType = registration.EventType; + if (eventType is null || !assemblies.Contains(eventType.Assembly) + || !eventType.IsClass || eventType.IsAbstract || eventType.ContainsGenericParameters + || !typeof(IntegrationEventBase).IsAssignableFrom(eventType) + || !declaredTypes.Add(eventType)) + { + throw new InvalidOperationException( + "Outbox contracts must be distinct concrete IntegrationEventBase types " + + "from explicitly known contract assemblies."); + } + + var subscriptions = registration.Subscriptions; + if (registration.IsIntentionallyUnsubscribed == (subscriptions.Count > 0)) + { + throw new InvalidOperationException( + "An outbox contract needs required subscriptions or explicit intentional absence."); + } + + foreach (var subscription in subscriptions) + { + if (subscription is null || !ValidSubscriptionId(subscription.Id) + || !subscriptionIds.Add(subscription.Id)) + { + throw new InvalidOperationException( + "Outbox subscription IDs must be valid and globally unique."); + } + + if (subscription.Readiness == OutboxSubscriptionReadiness.Pending) + { + if (subscription.HandlerType is not null) + { + throw new InvalidOperationException("Pending subscriptions cannot name a handler."); + } + } + else if (subscription.Readiness == OutboxSubscriptionReadiness.Active + && subscription.HandlerType is { IsClass: true, IsAbstract: false } handlerType) + { + if (!activeHandlers.Add((eventType, handlerType))) + { + throw new InvalidOperationException( + "One handler cannot stand for two independent Active subscriptions."); + } + } + else + { + throw new InvalidOperationException( + "An Active subscription needs a concrete handler type."); + } + } + + var disposition = registration.IsIntentionallyUnsubscribed + ? OutboxEventDisposition.IntentionallyUnsubscribed + : subscriptions.Any(static subscription => + subscription.Readiness == OutboxSubscriptionReadiness.Pending) + ? OutboxEventDisposition.Pending + : OutboxEventDisposition.Active; + + var resolution = new OutboxEventResolution( + disposition, eventType, eventType.Name, subscriptions); + AddIdentity(eventType.AssemblyQualifiedName, resolution); + foreach (var alias in registration.CompatibleAliases) + { + AddIdentity(alias, resolution); + } + } + + if (declaredTypes.Count == 0) + { + throw new InvalidOperationException("The outbox registry cannot have an empty contract census."); + } + + var retired = new HashSet<string>(StringComparer.Ordinal); + foreach (var identity in retiredIdentities) + { + if (!ValidIdentity(identity) || known.ContainsKey(identity) || !retired.Add(identity)) + { + throw new InvalidOperationException( + "Retired outbox identities must be nonempty, unique and distinct from live identities."); + } + } + + var discovered = handlers.All.ToArray(); + if (discovered.Length != activeHandlers.Count + || discovered.Any(subscription => + !activeHandlers.Contains((subscription.EventType, subscription.HandlerType)))) + { + throw new InvalidOperationException( + "Every Active outbox subscription must have exactly one discovered handler, " + + "and every discovered handler must have an Active declaration."); + } + + return new OutboxEventRegistry( + known.ToFrozenDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal), + retired.ToFrozenSet(StringComparer.Ordinal), + Array.AsReadOnly(pending.Order(StringComparer.Ordinal).ToArray()), + Array.AsReadOnly(declaredTypes.Select(type => type.Name) + .Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal).ToArray())); + + void AddIdentity(string? identity, OutboxEventResolution resolution) + { + if (!ValidIdentity(identity) || !known.TryAdd(identity, resolution)) + { + throw new InvalidOperationException( + "Canonical and compatible outbox identities must be nonempty and unique."); + } + + if (resolution.Disposition == OutboxEventDisposition.Pending) + { + pending.Add(identity); + } + } + } + + /// <summary>Looks up exact stored text without loading or reflecting over that text.</summary> + public OutboxEventResolution Resolve(string? storedIdentity) + { + if (storedIdentity is not null && _known.TryGetValue(storedIdentity, out var known)) + { + return known; + } + + return storedIdentity is not null && _retired.Contains(storedIdentity) + ? new OutboxEventResolution(OutboxEventDisposition.Retired, null, "retired", NoSubscriptions) + : new OutboxEventResolution(OutboxEventDisposition.Unknown, null, "unknown", NoSubscriptions); + } + + private static bool ValidIdentity([NotNullWhen(true)] string? identity) => + !string.IsNullOrWhiteSpace(identity) + && string.Equals(identity, identity.Trim(), StringComparison.Ordinal); + + private static bool ValidSubscriptionId(string? id) + { + if (id is null) return false; + + var firstDot = id.IndexOf('.'); + var lastDot = id.LastIndexOf('.'); + if (firstDot < 1 || lastDot <= firstDot + 1 || lastDot == id.Length - 1) + { + return false; + } + + var module = id.AsSpan(0, firstDot); + var subscription = id.AsSpan(firstDot + 1, lastDot - firstDot - 1); + var version = id.AsSpan(lastDot + 1); + return IsLowerLetter(module[0]) + && IsAsciiTail(module[1..], allowHyphen: false) + && IsLowerLetter(subscription[0]) + && IsAsciiTail(subscription[1..], allowHyphen: true) + && version.Length >= 2 && version[0] == 'v' + && version[1] is >= '1' and <= '9' + && IsDecimalTail(version[2..]); + } + + private static bool IsLowerLetter(char value) => value is >= 'a' and <= 'z'; + + private static bool IsAsciiTail(ReadOnlySpan<char> value, bool allowHyphen) + { + foreach (var character in value) + { + if (!IsLowerLetter(character) + && character is not (>= '0' and <= '9') + && (!allowHyphen || character != '-')) + { + return false; + } + } + + return true; + } + + private static bool IsDecimalTail(ReadOnlySpan<char> value) + { + foreach (var character in value) + { + if (character is not (>= '0' and <= '9')) + { + return false; + } + } + + return true; + } +} diff --git a/backend/src/LearnStack.Infrastructure/Persistence/Migrations/20261012180355_add_outbox_dispatch_state.Designer.cs b/backend/src/LearnStack.Infrastructure/Persistence/Migrations/20261012180355_add_outbox_dispatch_state.Designer.cs new file mode 100644 index 00000000..fbee5fa3 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Persistence/Migrations/20261012180355_add_outbox_dispatch_state.Designer.cs @@ -0,0 +1,29 @@ +// <auto-generated /> +using LearnStack.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LearnStack.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(PlatformDbContext))] + [Migration("20261012180355_add_outbox_dispatch_state")] + partial class add_outbox_dispatch_state + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/LearnStack.Infrastructure/Persistence/Migrations/20261012180355_add_outbox_dispatch_state.cs b/backend/src/LearnStack.Infrastructure/Persistence/Migrations/20261012180355_add_outbox_dispatch_state.cs new file mode 100644 index 00000000..84f41714 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Persistence/Migrations/20261012180355_add_outbox_dispatch_state.cs @@ -0,0 +1,102 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LearnStack.Infrastructure.Persistence.Migrations +{ + /// <inheritdoc /> + public partial class add_outbox_dispatch_state : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + // ADR-0046: legacy diagnostics are deliberately not retained. This + // owner-only, transactional backfill temporarily removes FORCE so + // it reaches every tenant, then restores it before the migration + // commits. Invalid legacy identities fail the CHECK without repair. + migrationBuilder.Sql(""" + ALTER TABLE outbox_messages NO FORCE ROW LEVEL SECURITY; + UPDATE outbox_messages + SET last_error = 'migration:legacy:redacted' + WHERE last_error IS NOT NULL; + ALTER TABLE outbox_messages FORCE ROW LEVEL SECURITY; + + ALTER TABLE outbox_messages + ADD COLUMN locked_by text NULL, + ADD COLUMN locked_until timestamptz NULL, + ADD COLUMN terminal_reason text NULL, + ADD COLUMN dead_lettered_at timestamptz NULL, + ADD CONSTRAINT ck_outbox_messages_identity CHECK ( + id <> '00000000-0000-0000-0000-000000000000'::uuid + AND tenant_id NOT IN ('00000000-0000-0000-0000-000000000000'::uuid, + '00000000-0000-7000-8000-000000000002'::uuid) + AND (organization_id IS NULL OR + organization_id <> '00000000-0000-0000-0000-000000000000'::uuid)), + ADD CONSTRAINT ck_outbox_messages_attempts CHECK (attempts >= 0), + ADD CONSTRAINT ck_outbox_messages_last_error_size CHECK ( + last_error IS NULL OR octet_length(last_error) <= 256), + ADD CONSTRAINT ck_outbox_messages_lease CHECK ( + (locked_by IS NULL AND locked_until IS NULL) OR + (locked_by IS NOT NULL AND length(locked_by) > 0 AND locked_until IS NOT NULL)), + ADD CONSTRAINT ck_outbox_messages_terminal_reason CHECK ( + terminal_reason IS NULL OR terminal_reason IN ( + 'unknown_type', 'retired_type', 'invalid_envelope', 'retry_exhausted')), + ADD CONSTRAINT ck_outbox_messages_state CHECK ( + (processed_at IS NULL OR + (dead_lettered_at IS NULL AND terminal_reason IS NULL AND locked_by IS NULL)) + AND (dead_lettered_at IS NULL OR + (processed_at IS NULL AND terminal_reason IS NOT NULL AND locked_by IS NULL))); + + DROP INDEX ix_outbox_messages_pending; + DROP INDEX ix_outbox_messages_tenant_pending; + CREATE INDEX ix_outbox_messages_pending + ON outbox_messages (available_after, occurred_at, id) + WHERE processed_at IS NULL AND dead_lettered_at IS NULL + AND terminal_reason IS NULL AND attempts < 6 AND locked_by IS NULL; + CREATE INDEX ix_outbox_messages_tenant_pending + ON outbox_messages (tenant_id, available_after) + WHERE processed_at IS NULL AND dead_lettered_at IS NULL; + CREATE INDEX ix_outbox_messages_recovery + ON outbox_messages (locked_until, occurred_at, id) + WHERE processed_at IS NULL AND dead_lettered_at IS NULL + AND terminal_reason IS NULL AND locked_by IS NOT NULL; + CREATE INDEX ix_outbox_messages_terminal + ON outbox_messages (available_after, occurred_at, id) + WHERE processed_at IS NULL AND dead_lettered_at IS NULL AND terminal_reason IS NOT NULL; + + GRANT UPDATE (locked_by, locked_until, terminal_reason, dead_lettered_at) + ON outbox_messages TO learnstack_outbox_admin; + """); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + // Schema reversal for disposable databases only. Sanitized legacy + // diagnostics cannot be restored; live rollback must be forward-fix. + migrationBuilder.Sql(""" + REVOKE UPDATE (locked_by, locked_until, terminal_reason, dead_lettered_at) + ON outbox_messages FROM learnstack_outbox_admin; + DROP INDEX ix_outbox_messages_terminal; + DROP INDEX ix_outbox_messages_recovery; + DROP INDEX ix_outbox_messages_pending; + DROP INDEX ix_outbox_messages_tenant_pending; + ALTER TABLE outbox_messages + DROP CONSTRAINT ck_outbox_messages_state, + DROP CONSTRAINT ck_outbox_messages_terminal_reason, + DROP CONSTRAINT ck_outbox_messages_lease, + DROP CONSTRAINT ck_outbox_messages_last_error_size, + DROP CONSTRAINT ck_outbox_messages_attempts, + DROP CONSTRAINT ck_outbox_messages_identity, + DROP COLUMN dead_lettered_at, + DROP COLUMN terminal_reason, + DROP COLUMN locked_until, + DROP COLUMN locked_by; + CREATE INDEX ix_outbox_messages_pending + ON outbox_messages (available_after) WHERE processed_at IS NULL; + CREATE INDEX ix_outbox_messages_tenant_pending + ON outbox_messages (tenant_id, available_after) WHERE processed_at IS NULL; + """); + } + } +} diff --git a/backend/src/LearnStack.Infrastructure/Persistence/Migrations/PlatformDbContextModelSnapshot.cs b/backend/src/LearnStack.Infrastructure/Persistence/Migrations/PlatformDbContextModelSnapshot.cs index 025218fd..b1614d25 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/Migrations/PlatformDbContextModelSnapshot.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/Migrations/PlatformDbContextModelSnapshot.cs @@ -16,7 +16,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.0") + .HasAnnotation("ProductVersion", "10.0.12") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); diff --git a/backend/src/LearnStack.Infrastructure/Persistence/PlatformDbContext.cs b/backend/src/LearnStack.Infrastructure/Persistence/PlatformDbContext.cs index 65cd0b0b..cc0aeb98 100644 --- a/backend/src/LearnStack.Infrastructure/Persistence/PlatformDbContext.cs +++ b/backend/src/LearnStack.Infrastructure/Persistence/PlatformDbContext.cs @@ -11,9 +11,9 @@ namespace LearnStack.Infrastructure.Persistence; /// are reached through SharedKernel ports by any module's handler and read by /// infrastructure that belongs to none of them. <c>IIdempotencyStore</c> ships /// today, behind <c>InMemoryIdempotencyStore</c> until its ADR-0035 trigger -/// fires; <c>IOutbox</c> does not exist yet and lands with the dispatcher in -/// <see href="../../../../docs/roadmap/phase-02b-events-auth.md">Phase 02b</see>. -/// Nothing writes either table at runtime — Packet 6 shipped the tables. Putting +/// fires; <c>IOutbox</c> enqueues immediately on the ambient transaction under +/// <see href="../../../../docs/decisions/0057-transactional-event-production-and-completion.md">ADR-0057</see>. +/// Dispatcher leases and terminal state are decided by ADR-0046. Putting /// them in a module's context would make every other module's use of the outbox a /// dependency on that module — the shape /// <see href="../../../../docs/architecture/15-event-and-outbox.md">15-event-and-outbox.md</see> @@ -21,15 +21,11 @@ namespace LearnStack.Infrastructure.Persistence; /// module. /// </para> /// <para> -/// It is the second <c>DbContext</c>, and that does <b>not</b> yet make -/// <see href="../../../../docs/decisions/0040-ambient-unit-of-work.md">ADR-0040</see>'s -/// central property testable. That property is several contexts enlisted on one -/// connection so <c>SET LOCAL</c> protects every statement, and the enlistment -/// machinery — <c>IUnitOfWork</c>, the shared registration helper, the -/// <c>TransactionBehavior</c> body — lands in step 6; ADR-0040 § What Packet 6 -/// can and cannot prove says the property becomes observable in Phase 03, with -/// the second <b>module</b> context. This context exists for the reason below and -/// no other. +/// This migration-only context has no tracked entities. Runtime outbox SQL uses +/// the announced <c>IUnitOfWork</c> connection, while confined dispatch uses its +/// own role and short transactions. Module contexts enlist through the shared +/// <see href="../../../../docs/decisions/0040-ambient-unit-of-work.md">ADR-0040</see> +/// registration and never acquire a dispatcher connection. /// </para> /// <para> /// Both tables are <b>tenant-owned, tenant-wide</b> despite living outside a diff --git a/backend/src/Modules/Audit/LearnStack.Modules.Audit.Infrastructure/Persistence/Migrations/20261012180433_grant_outbox_terminal_audit.Designer.cs b/backend/src/Modules/Audit/LearnStack.Modules.Audit.Infrastructure/Persistence/Migrations/20261012180433_grant_outbox_terminal_audit.Designer.cs new file mode 100644 index 00000000..a461c101 --- /dev/null +++ b/backend/src/Modules/Audit/LearnStack.Modules.Audit.Infrastructure/Persistence/Migrations/20261012180433_grant_outbox_terminal_audit.Designer.cs @@ -0,0 +1,231 @@ +// <auto-generated /> +using System; +using System.Net; +using LearnStack.Modules.Audit.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LearnStack.Modules.Audit.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AuditDbContext))] + [Migration("20261012180433_grant_outbox_terminal_audit")] + partial class grant_outbox_terminal_audit + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("LearnStack.Modules.Audit.Domain.AuditConfig", b => + { + b.Property<Guid>("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<DateTimeOffset>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid") + .HasColumnName("created_by"); + + b.Property<DateTimeOffset?>("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property<Guid?>("DeletedBy") + .HasColumnType("uuid") + .HasColumnName("deleted_by"); + + b.Property<bool>("IsEnabled") + .HasColumnType("boolean") + .HasColumnName("is_enabled"); + + b.Property<string>("ModuleName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("module"); + + b.Property<string>("Operation") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)") + .HasColumnName("operation"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property<DateTimeOffset?>("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property<Guid?>("UpdatedBy") + .HasColumnType("uuid") + .HasColumnName("updated_by"); + + b.Property<long>("Version") + .IsConcurrencyToken() + .HasColumnType("bigint") + .HasDefaultValue(0L) + .HasColumnName("row_version"); + + b.HasKey("Id") + .HasName("pk_audit_config"); + + b.HasIndex("TenantId", "ModuleName", "Operation") + .IsUnique() + .HasDatabaseName("ux_audit_config_tenant_id_module_operation") + .HasFilter("deleted_at IS NULL"); + + b.ToTable("audit_config", (string)null); + }); + + modelBuilder.Entity("LearnStack.Modules.Audit.Domain.AuditEntry", b => + { + b.Property<Guid>("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<DateTimeOffset>("Timestamp") + .HasColumnType("timestamp with time zone") + .HasColumnName("timestamp"); + + b.Property<string>("ActorEmail") + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("actor_email"); + + b.Property<Guid?>("ActorUserId") + .HasColumnType("uuid") + .HasColumnName("actor_user_id"); + + b.Property<string>("AfterState") + .HasColumnType("jsonb") + .HasColumnName("after_state"); + + b.Property<string>("BeforeState") + .HasColumnType("jsonb") + .HasColumnName("before_state"); + + b.Property<string>("Changes") + .HasColumnType("jsonb") + .HasColumnName("changes"); + + b.Property<string>("CorrelationId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("correlation_id"); + + b.Property<string>("EntityId") + .HasColumnType("text") + .HasColumnName("entity_id"); + + b.Property<string>("EntityType") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("entity_type"); + + b.Property<string>("ErrorKey") + .HasMaxLength(150) + .HasColumnType("character varying(150)") + .HasColumnName("error_key"); + + b.Property<IPAddress>("IpAddress") + .HasColumnType("inet") + .HasColumnName("ip_address"); + + b.Property<string>("Metadata") + .HasColumnType("jsonb") + .HasColumnName("metadata"); + + b.Property<string>("ModuleName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("module"); + + b.Property<string>("Operation") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)") + .HasColumnName("operation"); + + b.Property<string>("OperationClass") + .IsRequired() + .HasColumnType("text") + .HasColumnName("operation_class"); + + b.Property<string>("OperationType") + .IsRequired() + .HasColumnType("text") + .HasColumnName("operation_type"); + + b.Property<Guid?>("OrganizationId") + .HasColumnType("uuid") + .HasColumnName("organization_id"); + + b.Property<string>("Outcome") + .IsRequired() + .HasColumnType("text") + .HasColumnName("outcome"); + + b.Property<string>("Reason") + .HasMaxLength(500) + .HasColumnType("character varying(500)") + .HasColumnName("reason"); + + b.Property<Guid>("TenantId") + .HasColumnType("uuid") + .HasColumnName("tenant_id"); + + b.Property<string>("UserAgent") + .HasMaxLength(512) + .HasColumnType("character varying(512)") + .HasColumnName("user_agent"); + + b.HasKey("Id", "Timestamp") + .HasName("audit_log_pkey"); + + b.HasIndex("CorrelationId") + .HasDatabaseName("ix_audit_log_correlation_id") + .HasFilter("correlation_id IS NOT NULL"); + + b.HasIndex("ActorUserId", "Timestamp") + .IsDescending(false, true) + .HasDatabaseName("ix_audit_log_actor_user_id_timestamp") + .HasFilter("actor_user_id IS NOT NULL"); + + b.HasIndex("TenantId", "Timestamp") + .IsDescending(false, true) + .HasDatabaseName("ix_audit_log_tenant_id_timestamp"); + + b.HasIndex("ModuleName", "Operation", "Timestamp") + .IsDescending(false, false, true) + .HasDatabaseName("ix_audit_log_module_operation_timestamp"); + + b.ToTable("audit_log", null, t => + { + t.HasCheckConstraint("ck_audit_log_operation_class", "operation_class IN ('Must', 'Should', 'May')"); + + t.HasCheckConstraint("ck_audit_log_operation_type", "operation_type IN ('Create', 'Update', 'Delete', 'ReadSensitive', 'SecurityEvent', 'PlatformAdmin', 'Action')"); + + t.HasCheckConstraint("ck_audit_log_outcome", "outcome IN ('success', 'denied', 'failed', 'indeterminate')"); + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Modules/Audit/LearnStack.Modules.Audit.Infrastructure/Persistence/Migrations/20261012180433_grant_outbox_terminal_audit.cs b/backend/src/Modules/Audit/LearnStack.Modules.Audit.Infrastructure/Persistence/Migrations/20261012180433_grant_outbox_terminal_audit.cs new file mode 100644 index 00000000..9d97f118 --- /dev/null +++ b/backend/src/Modules/Audit/LearnStack.Modules.Audit.Infrastructure/Persistence/Migrations/20261012180433_grant_outbox_terminal_audit.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LearnStack.Modules.Audit.Infrastructure.Persistence.Migrations +{ + /// <inheritdoc /> + public partial class grant_outbox_terminal_audit : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + // ADR-0046: the confined terminal writer shares the dispatcher's + // transaction. The role receives no audit read/update/delete access. + migrationBuilder.Sql("GRANT INSERT ON audit_log TO learnstack_outbox_admin;"); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + // Disposable-schema reversal only; live recovery uses a forward fix. + migrationBuilder.Sql("REVOKE INSERT ON audit_log FROM learnstack_outbox_admin;"); + } + } +} diff --git a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs index d5bb70a7..00ba892c 100644 --- a/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/PersistenceConventionTests.cs @@ -170,12 +170,13 @@ public void Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork() // SET LOCAL, so every read through it returns zero rows under the // corrected policy — silently. // - // Eight files under backend/src may reach for a connection at all: the five + // Nine files under backend/src may reach for a connection at all: the five // design-time factories, where a connection string is the point — one per // migration chain, and a module that ships a schema ships one; the shared // module helper, which passes a connection rather than a string; the API's // separately guarded platform-role builder; and the shared application-role - // builder used by both runtime hosts. A ninth is a new decision. + // builder used by both runtime hosts; and ADR-0046's confined dispatcher + // builder. Another site requires a new decision, not a wildcard exemption. // // The scan covers the raw constructors as well as `UseNpgsql` and // `AddDbContext`, because a call site that opened its own @@ -214,6 +215,7 @@ public void Module_DbContexts_Enlist_In_The_Ambient_UnitOfWork() // Both runtime hosts delegate application-role construction to this // shared guard; the API retains its separate platform-role builder. "Persistence/ApplicationDataSource.cs", + "Messaging/OutboxDispatcherDataSource.cs", ]); } diff --git a/backend/tests/LearnStack.Tests.Architecture/PlatformAdminScopeConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/PlatformAdminScopeConventionTests.cs index 0538cbb9..03ec6652 100644 --- a/backend/tests/LearnStack.Tests.Architecture/PlatformAdminScopeConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/PlatformAdminScopeConventionTests.cs @@ -27,6 +27,11 @@ public sealed class PlatformAdminScopeConventionTests private const string CompositionFile = "LearnStack.Api/Composition/PersistenceCompositionExtensions.cs"; + // A distinct keyed credential, confined by ADR-0046. Naming it explicitly + // preserves this global resolution fence while permitting its approved root. + private const string OutboxCompositionFile = + "LearnStack.Api/Composition/OutboxCompositionExtensions.cs"; + [Fact] public void Platform_DataSource_Resolved_Only_By_PlatformAdminScope() { @@ -48,7 +53,7 @@ public void Platform_DataSource_Resolved_Only_By_PlatformAdminScope() .ToList(); resolvers.Should().BeEquivalentTo( - [ScopeFile, CompositionFile], + [ScopeFile, CompositionFile, OutboxCompositionFile], "the scope injects the keyed source and the composition root registers it; a " + "third resolver is a second path to a credential that sees every tenant"); @@ -65,8 +70,11 @@ public void Platform_DataSource_Resolved_Only_By_PlatformAdminScope() // is a rule matching its own vocabulary rather than a credential. SourceScan.FilesContaining(SourceScan.SourceRoot, "GetConnectionString", except: null) .Should().BeEquivalentTo( - [CompositionFile], - "one file reads credentials, so one file decides what is done with them"); + [CompositionFile, OutboxCompositionFile], + "each credential has an explicitly confined composition root"); + + File.ReadAllText(Path.Combine(SourceScan.SourceRoot, OutboxCompositionFile)) + .Should().NotContain("PlatformAdmin", "outbox composition must never read the platform credential"); // Leg 2b — the OTHER way to read the same value. GetConnectionString(name) is // sugar for configuration[$"ConnectionStrings:{name}"], so the indexer form diff --git a/backend/tests/LearnStack.Tests.Integration/Database/DisposableSchemaDatabase.cs b/backend/tests/LearnStack.Tests.Integration/Database/DisposableSchemaDatabase.cs index d9ae4113..efb48f21 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/DisposableSchemaDatabase.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/DisposableSchemaDatabase.cs @@ -21,11 +21,13 @@ private DisposableSchemaDatabase(PostgresFixture postgres) MigrationConnectionString = For(postgres.MigrationConnectionString); AppConnectionString = For(postgres.AppConnectionString); PlatformConnectionString = For(postgres.PlatformConnectionString); + OutboxConnectionString = For(postgres.OutboxConnectionString); } public string MigrationConnectionString { get; } public string AppConnectionString { get; } public string PlatformConnectionString { get; } + public string OutboxConnectionString { get; } public static async Task<DisposableSchemaDatabase> CreateAsync( PostgresFixture postgres, bool applyMigrations = true) diff --git a/backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatchMigrationTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatchMigrationTests.cs new file mode 100644 index 00000000..74445d5b --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatchMigrationTests.cs @@ -0,0 +1,224 @@ +using System.Data.Common; +using FluentAssertions; +using LearnStack.Infrastructure.Persistence; +using LearnStack.Modules.Audit.Infrastructure.Persistence; +using LearnStack.SharedKernel.Tenancy; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +[Collection(SharedSchema.Name)] +public sealed class OutboxDispatchMigrationTests(SchemaFixture schema) +{ + private const string PlatformPredecessor = "20260828085701_create_platform_infrastructure_tables"; + private const string AuditPredecessor = "20260914114341_audit_log_org_insert_scope"; + private const string SanitizedLegacy = "migration:legacy:redacted"; + + [Fact] + public async Task Legacy_facts_survive_up_down_reapply_and_diagnostics_are_redacted_across_tenants() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres, applyMigrations: false); + await using var context = Platform(database); + await context.GetService<IMigrator>().MigrateAsync(PlatformPredecessor); + await InsertLegacyAsync(database, Guid.NewGuid(), SchemaFixture.TenantA, null, processed: false); + await InsertLegacyAsync(database, Guid.NewGuid(), SchemaFixture.TenantB, SchemaFixture.OrgB1, processed: true); + var facts = await FactsAsync(database); + + await context.Database.MigrateAsync(); + context.Database.HasPendingModelChanges().Should().BeFalse(); + (await FactsAsync(database)).Should().Be(facts, "migration must not change attempts, availability or event facts"); + await AssertLegacyDiagnosticsAndRlsAsync(database); + await AssertIndexAsync(database, "ix_outbox_messages_pending", + ["available_after, occurred_at, id", "dead_lettered_at IS NULL", "terminal_reason IS NULL", "attempts < 6", "locked_by IS NULL"]); + await AssertIndexAsync(database, "ix_outbox_messages_recovery", ["locked_until, occurred_at, id", "locked_by IS NOT NULL"]); + await AssertIndexAsync(database, "ix_outbox_messages_terminal", ["terminal_reason IS NOT NULL"]); + + await context.GetService<IMigrator>().MigrateAsync(PlatformPredecessor); + (await FactsAsync(database)).Should().Be(facts); + await using (var owner = await PostgresFixture.OpenAsync(database.MigrationConnectionString)) + { + (await SchemaQueries.CountAsync(owner, """ + SELECT count(*) FROM information_schema.columns + WHERE table_name = 'outbox_messages' + AND column_name IN ('locked_by','locked_until','terminal_reason','dead_lettered_at') + """)).Should().Be(0); + (await SchemaQueries.CountAsync(owner, """ + SELECT count(*) FROM pg_constraint + WHERE conrelid = 'outbox_messages'::regclass AND conname LIKE 'ck_outbox_messages_%' + """)).Should().Be(0); + } + + await context.Database.MigrateAsync(); + (await FactsAsync(database)).Should().Be(facts); + await AssertLegacyDiagnosticsAndRlsAsync(database); + } + + [Theory] + [InlineData("id", "00000000-0000-0000-0000-000000000000")] + [InlineData("tenant", "00000000-0000-0000-0000-000000000000")] + [InlineData("tenant", "00000000-0000-7000-8000-000000000002")] + [InlineData("organization", "00000000-0000-0000-0000-000000000000")] + public async Task Invalid_legacy_identity_refuses_migration_without_repair(string member, string invalid) + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres, applyMigrations: false); + await using var context = Platform(database); + await context.GetService<IMigrator>().MigrateAsync(PlatformPredecessor); + await InsertLegacyAsync(database, + member == "id" ? Guid.Parse(invalid) : Guid.NewGuid(), + member == "tenant" ? Guid.Parse(invalid) : SchemaFixture.TenantA, + member == "organization" ? Guid.Parse(invalid) : null, + processed: false); + var facts = await FactsAsync(database); + var apply = async () => await context.Database.MigrateAsync(); + var error = (await apply.Should().ThrowAsync<PostgresException>()).Which; + error.ConstraintName.Should().Be("ck_outbox_messages_identity"); + (await FactsAsync(database)).Should().Be(facts); + (await context.Database.GetAppliedMigrationsAsync()).Last().Should().Be(PlatformPredecessor); + await using var owner = await PostgresFixture.OpenAsync(database.MigrationConnectionString); + await using var check = new NpgsqlCommand("SELECT relforcerowsecurity FROM pg_class WHERE oid = 'outbox_messages'::regclass", (NpgsqlConnection)owner); + (await check.ExecuteScalarAsync()).Should().Be(true, "the failed transactional backfill must restore FORCE RLS"); + await using var outbox = await PostgresFixture.OpenAsync(database.OutboxConnectionString); + (await SchemaQueries.ReadStringsAsync(outbox, "SELECT last_error FROM outbox_messages")) + .Should().ContainSingle().Which.Should().StartWith("private legacy"); + } + + [Theory] + [InlineData("last_error = repeat('é',128)", null)] + [InlineData("last_error = repeat('é',129)", "ck_outbox_messages_last_error_size")] + [InlineData("locked_by = 'token'", "ck_outbox_messages_lease")] + [InlineData("locked_by = 'token', locked_until = now() + interval '30 seconds'", null)] + [InlineData("locked_by = '', locked_until = now()", "ck_outbox_messages_lease")] + [InlineData("terminal_reason = 'unknown_type'", null)] + [InlineData("terminal_reason = 'not_declared'", "ck_outbox_messages_terminal_reason")] + [InlineData("dead_lettered_at = now()", "ck_outbox_messages_state")] + [InlineData("dead_lettered_at = now(), terminal_reason = 'retry_exhausted'", null)] + [InlineData("processed_at = now(), terminal_reason = 'unknown_type'", "ck_outbox_messages_state")] + [InlineData("attempts = -1", "ck_outbox_messages_attempts")] + [InlineData("attempts = 7", null)] + public async Task Dispatcher_state_checks_have_accepting_and_refusing_controls(string assignment, string? constraint) + { + // Literal theory statements are test-owned, never interpolated caller input. + // Legacy exhausted counts above six remain facts; normal claiming enforces + // the six-attempt ceiling, rather than a migration rewriting that history. + await using var connection = await PostgresFixture.OpenAsync(schema.Postgres.OutboxConnectionString); + await using var transaction = await connection.BeginTransactionAsync(); + await using var update = new NpgsqlCommand( + $"UPDATE outbox_messages SET {assignment} WHERE tenant_id = @tenant", + (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); + update.Parameters.AddWithValue("tenant", SchemaFixture.TenantA); + var act = async () => (await update.ExecuteNonQueryAsync()).Should().BeGreaterThan(0, "a CHECK proof must touch a real row"); + if (constraint is null) + { + await act.Should().NotThrowAsync(); + } + else + { + (await act.Should().ThrowAsync<PostgresException>()).Which.ConstraintName.Should().Be(constraint); + } + await transaction.RollbackAsync(); + } + + [Theory] + [InlineData("SELECT * FROM audit_log")] + [InlineData("UPDATE audit_log SET outcome = 'success'")] + [InlineData("DELETE FROM audit_log")] + [InlineData("SELECT * FROM tenants")] + [InlineData("UPDATE outbox_messages SET type = 'other'")] + [InlineData("UPDATE outbox_messages SET payload = '{}'::jsonb")] + [InlineData("DELETE FROM outbox_messages")] + public async Task Outbox_role_has_no_audit_read_mutation_or_business_access(string sql) + { + await using var connection = await PostgresFixture.OpenAsync(schema.Postgres.OutboxConnectionString); + await using var transaction = await connection.BeginTransactionAsync(); + await using var command = new NpgsqlCommand(sql, (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); + var act = async () => await command.ExecuteNonQueryAsync(); + (await act.Should().ThrowAsync<PostgresException>()).Which.SqlState.Should().Be(PostgresErrorCodes.InsufficientPrivilege); + await transaction.RollbackAsync(); + } + + [Fact] + public async Task Audit_grant_has_its_own_chain_and_is_removed_by_down_then_reapplied() + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres); + await using var context = new AuditDbContext(new DbContextOptionsBuilder<AuditDbContext>() + .UseNpgsql(database.MigrationConnectionString, + provider => provider.MigrationsHistoryTable(AuditDbContextFactory.HistoryTable)).Options, + StaticTenantContextAccessor.Unresolved); + context.Database.HasPendingModelChanges().Should().BeFalse(); + await AssertAuditInsertGrantAsync(database, expected: true); + await context.GetService<IMigrator>().MigrateAsync(AuditPredecessor); + await AssertAuditInsertGrantAsync(database, expected: false); + await context.Database.MigrateAsync(); + await AssertAuditInsertGrantAsync(database, expected: true); + } + + private static PlatformDbContext Platform(DisposableSchemaDatabase database) => + new(new DbContextOptionsBuilder<PlatformDbContext>().UseNpgsql(database.MigrationConnectionString, + provider => provider.MigrationsHistoryTable(PlatformDbContextFactory.HistoryTable)).Options); + + private static async Task InsertLegacyAsync(DisposableSchemaDatabase database, Guid id, Guid tenant, Guid? organization, bool processed) + { + await using var app = await PostgresFixture.OpenAsync(database.AppConnectionString); + await using var transaction = await app.BeginTransactionAsync(); + await SchemaQueries.AssertApplicationRoleAsync(app); + await SchemaQueries.SetTenantAsync(app, transaction, tenant); + await SchemaQueries.ExecuteAsync(app, transaction, """ + INSERT INTO outbox_messages + (id, tenant_id, organization_id, correlation_id, type, topic, partition_key, + payload, metadata, attempts, processed_at, available_after, last_error) + VALUES (@id, @tenant, @organization, 'bad-legacy-trace', 'legacy-contract', + 'learnstack.tenancy.tenant', @partition, '{"legacy":true}', '{"legacy":true}', + 7, CASE WHEN @processed THEN now() ELSE NULL END, now() + interval '5 minutes', + 'private legacy ' || repeat('é',1000)) + """, ("id", id), ("tenant", tenant), ("organization", organization.HasValue ? organization.Value : DBNull.Value), + ("partition", tenant.ToString()), ("processed", processed)); + await transaction.CommitAsync(); + } + + private static async Task<string> FactsAsync(DisposableSchemaDatabase database) + { + await using var outbox = await PostgresFixture.OpenAsync(database.OutboxConnectionString); + await using var command = new NpgsqlCommand(""" + SELECT jsonb_agg(to_jsonb(o) - 'last_error' - 'locked_by' - 'locked_until' + - 'terminal_reason' - 'dead_lettered_at' ORDER BY id)::text FROM outbox_messages o + """, (NpgsqlConnection)outbox); + return (string)(await command.ExecuteScalarAsync())!; + } + + private static async Task AssertLegacyDiagnosticsAndRlsAsync(DisposableSchemaDatabase database) + { + await using var outbox = await PostgresFixture.OpenAsync(database.OutboxConnectionString); + (await SchemaQueries.ReadStringsAsync(outbox, "SELECT last_error FROM outbox_messages ORDER BY id")) + .Should().Equal(SanitizedLegacy, SanitizedLegacy); + await using var app = await PostgresFixture.OpenAsync(database.AppConnectionString); + (await SchemaQueries.CountAsync(app, "SELECT count(*) FROM outbox_messages")).Should().Be(0, + "unresolved app context must remain fail-closed after the owner backfill"); + await using var owner = await PostgresFixture.OpenAsync(database.MigrationConnectionString); + await using var command = new NpgsqlCommand("SELECT relrowsecurity AND relforcerowsecurity FROM pg_class WHERE oid = 'outbox_messages'::regclass", (NpgsqlConnection)owner); + (await command.ExecuteScalarAsync()).Should().Be(true); + } + + private static async Task AssertIndexAsync(DisposableSchemaDatabase database, string name, string[] fragments) + { + await using var owner = await PostgresFixture.OpenAsync(database.MigrationConnectionString); + await using var command = new NpgsqlCommand("SELECT indexdef FROM pg_indexes WHERE schemaname = 'public' AND indexname = @name", (NpgsqlConnection)owner); + command.Parameters.AddWithValue("name", name); + var definition = (string)(await command.ExecuteScalarAsync())!; + foreach (var fragment in fragments) + { + definition.Should().Contain(fragment); + } + } + + private static async Task AssertAuditInsertGrantAsync(DisposableSchemaDatabase database, bool expected) + { + await using var owner = await PostgresFixture.OpenAsync(database.MigrationConnectionString); + await using var command = new NpgsqlCommand("SELECT has_table_privilege('learnstack_outbox_admin', 'audit_log', 'INSERT')", (NpgsqlConnection)owner); + (await command.ExecuteScalarAsync()).Should().Be(expected); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatcherRoleGuardTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatcherRoleGuardTests.cs new file mode 100644 index 00000000..a784c433 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatcherRoleGuardTests.cs @@ -0,0 +1,162 @@ +using FluentAssertions; +using LearnStack.Infrastructure.Messaging; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +// Role mutations belong to this class's container, never the shared schema suite. +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +public sealed class OutboxDispatcherRoleGuardTests(PostgresFixture postgres) : IClassFixture<PostgresFixture> +{ + [Fact] + public async Task The_actual_outbox_role_connects_with_its_exact_bounded_attributes() + { + await using var source = OutboxDispatcherDataSource.Build(postgres.OutboxConnectionString); + await using var connection = await source.OpenConnectionAsync(); + await using var command = new NpgsqlCommand( + """ + SELECT session_user, current_user, rolbypassrls, + rolsuper, rolcreatedb, rolcreaterole, rolreplication + FROM pg_roles WHERE rolname = current_user + """, connection); + await using var reader = await command.ExecuteReaderAsync(); + + (await reader.ReadAsync()).Should().BeTrue(); + reader.GetString(0).Should().Be(OutboxDispatcherDataSource.Role); + reader.GetString(1).Should().Be(OutboxDispatcherDataSource.Role); + reader.GetBoolean(2).Should().BeTrue(); + for (var ordinal = 3; ordinal <= 6; ordinal++) reader.GetBoolean(ordinal).Should().BeFalse(); + (await reader.ReadAsync()).Should().BeFalse(); + } + + [Theory] + [InlineData("NOBYPASSRLS", "BYPASSRLS", "NOT rolbypassrls", false)] + [InlineData("NOBYPASSRLS", "BYPASSRLS", "NOT rolbypassrls", true)] + [InlineData("SUPERUSER", "NOSUPERUSER", "rolsuper", false)] + [InlineData("SUPERUSER", "NOSUPERUSER", "rolsuper", true)] + [InlineData("CREATEDB", "NOCREATEDB", "rolcreatedb", false)] + [InlineData("CREATEDB", "NOCREATEDB", "rolcreatedb", true)] + [InlineData("CREATEROLE", "NOCREATEROLE", "rolcreaterole", false)] + [InlineData("CREATEROLE", "NOCREATEROLE", "rolcreaterole", true)] + [InlineData("REPLICATION", "NOREPLICATION", "rolreplication", false)] + [InlineData("REPLICATION", "NOREPLICATION", "rolreplication", true)] + public async Task Every_physical_connection_refuses_an_invalid_attribute_and_accepts_its_restoration( + string altered, string restored, string predicate, bool synchronous) + { + await using var source = OutboxDispatcherDataSource.Build(postgres.OutboxConnectionString); + // Occupy the first physical connection so the rejection exercises a new + // connector in an already-used pool, not just the pool's first connection. + await using var first = await source.OpenConnectionAsync(); + try + { + await postgres.ExecuteAsSuperuserAsync($"ALTER ROLE learnstack_outbox_admin {altered}"); + await using var raw = await PostgresFixture.OpenAsync(postgres.OutboxConnectionString); + await using var truth = new NpgsqlCommand( + $"SELECT {predicate} FROM pg_roles WHERE rolname = current_user", (NpgsqlConnection)raw); + (await truth.ExecuteScalarAsync()).Should().Be(true, "the invalid server attribute must actually be present"); + + await AssertGuardRefusesAsync(source, synchronous); + } + finally + { + await postgres.ExecuteAsSuperuserAsync($"ALTER ROLE learnstack_outbox_admin {restored}"); + } + + await using var healthy = await source.OpenConnectionAsync(); + healthy.State.Should().Be(System.Data.ConnectionState.Open); + healthy.ProcessID.Should().NotBe(first.ProcessID); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task Foreign_membership_is_refused_directly_and_through_a_bridge_then_restoration_connects( + bool throughBridge, bool synchronous) + { + await using var source = OutboxDispatcherDataSource.Build(postgres.OutboxConnectionString); + try + { + await postgres.ExecuteAsSuperuserAsync(throughBridge + ? "CREATE ROLE outbox_guard_bridge NOLOGIN; " + + "GRANT learnstack_migration TO outbox_guard_bridge; " + + "GRANT outbox_guard_bridge TO learnstack_outbox_admin" + : "GRANT learnstack_migration TO learnstack_outbox_admin"); + await using var raw = await PostgresFixture.OpenAsync(postgres.OutboxConnectionString); + await using var truth = new NpgsqlCommand( + "SELECT pg_has_role(current_user, 'learnstack_migration', 'MEMBER')", (NpgsqlConnection)raw); + (await truth.ExecuteScalarAsync()).Should().Be(true, "the ownership escalation must be reachable"); + + await AssertGuardRefusesAsync(source, synchronous); + } + finally + { + await postgres.ExecuteAsSuperuserAsync(throughBridge + ? "DROP ROLE IF EXISTS outbox_guard_bridge" + : "REVOKE learnstack_migration FROM learnstack_outbox_admin"); + } + + await using var healthy = await source.OpenConnectionAsync(); + healthy.State.Should().Be(System.Data.ConnectionState.Open); + } + + [Fact] + public async Task A_startup_role_switch_cannot_change_the_dispatcher_current_role() + { + try + { + await postgres.ExecuteAsSuperuserAsync( + "CREATE ROLE outbox_guard_switched NOLOGIN; " + + "GRANT outbox_guard_switched TO learnstack_outbox_admin"); + var switched = new NpgsqlConnectionStringBuilder(postgres.OutboxConnectionString) + { + Options = "-c role=outbox_guard_switched", + }.ConnectionString; + await using var raw = await PostgresFixture.OpenAsync(switched); + await using var truth = new NpgsqlCommand("SELECT session_user, current_user", (NpgsqlConnection)raw); + await using (var reader = await truth.ExecuteReaderAsync()) + { + (await reader.ReadAsync()).Should().BeTrue(); + reader.GetString(0).Should().Be(OutboxDispatcherDataSource.Role); + reader.GetString(1).Should().Be("outbox_guard_switched"); + } + + await using var source = OutboxDispatcherDataSource.Build(switched); + await AssertGuardRefusesAsync(source, synchronous: false); + } + finally + { + await postgres.ExecuteAsSuperuserAsync("DROP ROLE IF EXISTS outbox_guard_switched"); + } + + await using var restored = OutboxDispatcherDataSource.Build(postgres.OutboxConnectionString); + await using var healthy = await restored.OpenConnectionAsync(); + healthy.State.Should().Be(System.Data.ConnectionState.Open); + } + + private static async Task AssertGuardRefusesAsync(NpgsqlDataSource source, bool synchronous) + { + InvalidOperationException failure; + if (synchronous) + { + var open = () => + { + using var connection = source.OpenConnection(); + }; + failure = open.Should().Throw<InvalidOperationException>().Which; + } + else + { + var open = async () => + { + await using var connection = await source.OpenConnectionAsync(); + }; + failure = (await open.Should().ThrowAsync<InvalidOperationException>()).Which; + } + + failure.Message.Should().Be("The outbox dispatcher connection has an invalid role, attributes or memberships."); + failure.InnerException.Should().BeNull(); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PlatformSchemaTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PlatformSchemaTests.cs index 18953605..27fdc994 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PlatformSchemaTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PlatformSchemaTests.cs @@ -80,14 +80,13 @@ public async Task TheApplicationRoleCanOnlyEnqueue(string statement) } [Fact] - public async Task TheDispatcherHoldsExactlyTheFourColumnUpdateGrant() + public async Task TheDispatcherHoldsExactlyTheEightStatusColumnUpdateGrant() { // BYPASSRLS bypasses policies, not GRANTs, so this column list is the // whole of the bound on learnstack_outbox_admin. SELECT ... FOR UPDATE // SKIP LOCKED works with a column-level grant, so no table-wide UPDATE is - // needed — and when locked_by / locked_until land in Phase 02b, that - // migration extends this grant or the dispatcher fails at runtime with - // `permission denied for table`. + // needed. ADR-0046 extends the original four columns only with the lease + // pair and terminal request/commit markers; payload and identity stay read-only. await using var connection = await PostgresFixture.OpenAsync(_schema.Postgres.MigrationConnectionString); await using var command = new NpgsqlCommand( """ @@ -99,7 +98,7 @@ FROM information_schema.column_privileges """, (NpgsqlConnection)connection); (await command.ExecuteScalarAsync()).Should() - .Be("attempts,available_after,last_error,processed_at"); + .Be("attempts,available_after,dead_lettered_at,last_error,locked_by,locked_until,processed_at,terminal_reason"); } [Fact] diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs index 9aaed12b..caf1b085 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenancySchemaTests.cs @@ -1227,11 +1227,12 @@ AND grantee IN ('learnstack_app', 'learnstack_platform', 'learnstack_outbox_admi "learnstack_platform tenant_level_taxonomy_items SELECT", "learnstack_platform customization_generations SELECT", "learnstack_outbox_admin outbox_messages SELECT", + "learnstack_outbox_admin audit_log INSERT", // The Audit chain. learnstack_platform's column-restricted UPDATE on // audit_log does NOT appear here — information_schema.role_table_grants // reports table-level privileges only — so the six-column list has its own - // assertion in AuditSchemaTests, the way the dispatcher's four-column grant + // assertion in AuditSchemaTests, the way the dispatcher's eight-column grant // on outbox_messages does in PlatformSchemaTests. A matrix row missing its // column half reads as a narrower grant than the one that shipped. "learnstack_app audit_log INSERT,SELECT", @@ -1248,6 +1249,6 @@ AND grantee IN ('learnstack_app', 'learnstack_platform', 'learnstack_outbox_admi "learnstack_platform lesson_translations SELECT", ], "every line is one line of the migration chains' grant matrices, and " - + "learnstack_outbox_admin holds nothing beyond the outbox"); + + "learnstack_outbox_admin holds only outbox SELECT/status UPDATE and terminal audit INSERT"); } } diff --git a/backend/tests/LearnStack.Tests.Unit/Api/Composition/OutboxDispatcherDataSourceGuardTests.cs b/backend/tests/LearnStack.Tests.Unit/Api/Composition/OutboxDispatcherDataSourceGuardTests.cs new file mode 100644 index 00000000..907868f7 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Api/Composition/OutboxDispatcherDataSourceGuardTests.cs @@ -0,0 +1,98 @@ +using FluentAssertions; +using LearnStack.Api.Composition; +using LearnStack.Infrastructure.Messaging; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Unit.Api.Composition; + +public sealed class OutboxDispatcherDataSourceGuardTests +{ + private const string Valid = + "Host=localhost;Port=1;Database=learnstack;Username=learnstack_outbox_admin;Password=synthetic-secret-canary"; // leakwatch:ignore + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("Host=localhost;Username=synthetic-username-canary;Password=synthetic-secret-canary")] // leakwatch:ignore + [InlineData("Host=localhost;Username=LEARNSTACK_OUTBOX_ADMIN;Password=synthetic-secret-canary")] // leakwatch:ignore + [InlineData("Host=localhost;Password=synthetic-secret-canary")] // leakwatch:ignore + [InlineData("Host=localhost;Port=synthetic-secret-canary;Username=synthetic-username-canary")] + [InlineData("postgres://synthetic-username-canary:synthetic-secret-canary@localhost/database")] // leakwatch:ignore + [InlineData("synthetic-secret-canary=value;Username=synthetic-username-canary")] + public void Validate_and_build_refuse_invalid_credentials_without_echo_or_inner_exception(string? value) + { + AssertSafeRefusal(() => OutboxDispatcherDataSource.Validate(value)); + AssertSafeRefusal(() => OutboxDispatcherDataSource.Build(value)); + } + + [Fact] + public void A_valid_credential_validates_and_builds_without_connecting() + { + OutboxDispatcherDataSource.Validate(Valid); + using var source = OutboxDispatcherDataSource.Build(Valid); + source.Should().NotBeNull(); + } + + [Theory] + [InlineData("Host=localhost;Username=synthetic-username-canary;Password=synthetic-secret-canary")] // leakwatch:ignore + [InlineData("postgres://synthetic-username-canary:synthetic-secret-canary@localhost/database")] // leakwatch:ignore + [InlineData("Host=localhost;Port=synthetic-secret-canary;Username=learnstack_outbox_admin")] + public void Present_invalid_configuration_is_refused_during_registration(string value) + { + var services = new ServiceCollection(); + AssertSafeRefusal(() => services.AddLearnStackOutboxDispatcher(Configuration(value))); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Absent_configuration_is_deferred_until_the_keyed_source_is_resolved(string? value) + { + var services = new ServiceCollection(); + services.AddLearnStackOutboxDispatcher(Configuration(value)); + using var provider = services.BuildServiceProvider(); + var lazy = provider.GetRequiredKeyedService<Lazy<NpgsqlDataSource>>(OutboxDispatcherDataSource.Key); + + lazy.IsValueCreated.Should().BeFalse(); + AssertSafeRefusal(() => _ = lazy.Value); + } + + [Fact] + public void Registration_is_keyed_lazy_and_owns_one_shared_source() + { + var services = new ServiceCollection(); + services.AddLearnStackOutboxDispatcher(Configuration(Valid)); + services.AddLearnStackOutboxDispatcher(Configuration(Valid)); + using var provider = services.BuildServiceProvider(); + var lazy = provider.GetRequiredKeyedService<Lazy<NpgsqlDataSource>>(OutboxDispatcherDataSource.Key); + + provider.GetService<NpgsqlDataSource>().Should().BeNull(); + lazy.IsValueCreated.Should().BeFalse(); + var source = lazy.Value; + source.Should().BeSameAs(provider.GetRequiredKeyedService<NpgsqlDataSource>(OutboxDispatcherDataSource.Key)); + source.Should().BeSameAs(lazy.Value); + services.Count(descriptor => descriptor.IsKeyedService + && descriptor.ServiceType == typeof(NpgsqlDataSource) + && Equals(descriptor.ServiceKey, OutboxDispatcherDataSource.Key)).Should().Be(1); + } + + private static IConfiguration Configuration(string? value) => new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary<string, string?> + { + [$"ConnectionStrings:{OutboxDispatcherDataSource.ConnectionName}"] = value, + }) + .Build(); + + private static void AssertSafeRefusal(Action action) + { + var failure = action.Should().Throw<InvalidOperationException>().Which; + failure.Message.Should().Contain("ConnectionStrings:OutboxDispatcher"); + failure.InnerException.Should().BeNull(); + failure.ToString().Should().NotContain("synthetic-secret-canary").And.NotContain("synthetic-username-canary"); + } +} diff --git a/backend/tests/LearnStack.Tests.Unit/Api/Composition/OutboxProductionCompositionTests.cs b/backend/tests/LearnStack.Tests.Unit/Api/Composition/OutboxProductionCompositionTests.cs new file mode 100644 index 00000000..d63a39f5 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Api/Composition/OutboxProductionCompositionTests.cs @@ -0,0 +1,38 @@ +using FluentAssertions; +using LearnStack.Api.Composition; +using LearnStack.Infrastructure.Messaging; +using LearnStack.Modules.Tenancy.Application.Contracts.IntegrationEvents; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace LearnStack.Tests.Unit.Api.Composition; + +public sealed class OutboxProductionCompositionTests +{ + [Fact] + public void Real_provisioning_contract_remains_pending_for_both_distinct_default_consumers() + { + var handlers = IntegrationEventHandlerRegistry.Discover( + typeof(LearnStack.Modules.Tenancy.Application.AssemblyMarker).Assembly, + typeof(LearnStack.Modules.Customization.Application.AssemblyMarker).Assembly, + typeof(LearnStack.Modules.Education.Application.AssemblyMarker).Assembly); + var services = new ServiceCollection(); + services.AddSingleton(handlers); + services.AddLearnStackPersistence(new ConfigurationBuilder().Build()); + using var provider = services.BuildServiceProvider(); + var registry = provider.GetRequiredService<OutboxEventRegistry>(); + var identity = typeof(TenantCreatedIntegrationEventV1).AssemblyQualifiedName!; + + registry.PendingIdentities.Should().ContainSingle().Which.Should().Be(identity); + var resolution = registry.Resolve(identity); + resolution.EventType.Should().Be<TenantCreatedIntegrationEventV1>(); + resolution.Disposition.Should().Be(OutboxEventDisposition.Pending); + resolution.Subscriptions.Select(subscription => subscription.Id).Should().BeEquivalentTo( + ["customization.initialize-content-type-default.v1", "customization.initialize-taxonomy-default.v1"]); + resolution.Subscriptions.Should().OnlyContain(subscription => + subscription.Readiness == OutboxSubscriptionReadiness.Pending && subscription.HandlerType == null); + handlers.For(typeof(TenantCreatedIntegrationEventV1)).Should().BeEmpty( + "P02b-3 activates real consumers; P02b-2 must preserve their facts"); + } +} diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/OutboxEventRegistryTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/OutboxEventRegistryTests.cs new file mode 100644 index 00000000..a618018e --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/OutboxEventRegistryTests.cs @@ -0,0 +1,228 @@ +using FluentAssertions; +using LearnStack.Infrastructure.Messaging; +using LearnStack.SharedKernel.Messaging; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace LearnStack.Tests.Unit.Infrastructure.Messaging; + +public sealed class OutboxEventRegistryTests +{ + private const string ContentTypeSubscription = "customization.initialize-content-type-default.v1"; + private const string TaxonomySubscription = "customization.initialize-taxonomy-default.v1"; + private const string EarlierIdentity = "LearnStack.Previous.TenantCreatedIntegrationEventV1, LearnStack.Previous.Contracts"; + + [Fact] + public void Pending_holds_the_canonical_identity_and_every_alias_without_constructing_handlers() + { + ThrowingHandler.Constructions = 0; + var handlers = Handlers(services => + services.AddScoped<IIntegrationEventHandler<OtherEvent>, ThrowingHandler>()); + + // A separate Active declaration covers the discovered handler. Merely building + // the manifest must not resolve it, including on the Pending lookup path. + var registry = Create( + [ + OutboxEventRegistration.Required(typeof(SampleEvent), + [ + OutboxSubscriptionManifest.Pending(ContentTypeSubscription), + OutboxSubscriptionManifest.Pending(TaxonomySubscription), + ], EarlierIdentity), + OutboxEventRegistration.Required(typeof(OtherEvent), + [OutboxSubscriptionManifest.Active("test.other.v1", typeof(ThrowingHandler))]), + ], handlers: handlers); + + var canonical = typeof(SampleEvent).AssemblyQualifiedName!; + registry.PendingIdentities.Should().BeEquivalentTo([canonical, EarlierIdentity]); + registry.Resolve(canonical).Disposition.Should().Be(OutboxEventDisposition.Pending); + registry.Resolve(EarlierIdentity).EventType.Should().Be<SampleEvent>(); + registry.Resolve(EarlierIdentity).Subscriptions.Select(subscription => subscription.Id) + .Should().BeEquivalentTo([ContentTypeSubscription, TaxonomySubscription]); + ThrowingHandler.Constructions.Should().Be(0); + } + + [Fact] + public void Active_and_intentional_absence_have_distinct_exact_outcomes() + { + var registry = Create( + [ + OutboxEventRegistration.Required(typeof(SampleEvent), + [OutboxSubscriptionManifest.Active("test.sample.v1", typeof(SampleHandler))]), + OutboxEventRegistration.IntentionallyUnsubscribed(typeof(OtherEvent), EarlierIdentity), + ], + handlers: Handlers(services => + services.AddScoped<IIntegrationEventHandler<SampleEvent>, SampleHandler>())); + + registry.Resolve(typeof(SampleEvent).AssemblyQualifiedName).Disposition + .Should().Be(OutboxEventDisposition.Active); + registry.Resolve(EarlierIdentity).Disposition + .Should().Be(OutboxEventDisposition.IntentionallyUnsubscribed); + registry.PendingIdentities.Should().BeEmpty(); + registry.Resolve(typeof(OtherEvent).AssemblyQualifiedName).Subscriptions.Should().BeEmpty(); + } + + [Fact] + public void Unknown_and_retired_names_never_resolve_a_type_from_stored_text() + { + var registry = Create( + [OutboxEventRegistration.IntentionallyUnsubscribed(typeof(SampleEvent))], + retired: [EarlierIdentity]); + + var retired = registry.Resolve(EarlierIdentity); + retired.Disposition.Should().Be(OutboxEventDisposition.Retired); + retired.EventType.Should().BeNull(); + retired.EventTypeLabel.Should().Be("retired"); + + // This is a loadable CLR name in the test assembly, but it was never declared. + // Resolving it would make a database row an assembly-loading instruction. + var undeclared = registry.Resolve(typeof(OtherEvent).AssemblyQualifiedName); + undeclared.Disposition.Should().Be(OutboxEventDisposition.Unknown); + undeclared.EventType.Should().BeNull(); + undeclared.EventTypeLabel.Should().Be("unknown"); + } + + [Theory] + [InlineData("test.sample.v0")] + [InlineData("test.sample.v01")] + [InlineData("Test.sample.v1")] + [InlineData("test.Sample.v1")] + [InlineData("test.sample.v1 ")] + [InlineData("test..v1")] + [InlineData("test.sample.v1.extra")] + [InlineData("test-scope.sample.v1")] + public void Subscription_ids_follow_the_closed_stable_grammar(string id) + { + var build = () => Create( + [OutboxEventRegistration.Required(typeof(SampleEvent), + [OutboxSubscriptionManifest.Pending(id)])]); + + build.Should().Throw<InvalidOperationException>(); + } + + [Fact] + public void Alias_and_retired_collisions_refuse_composition() + { + var canonical = typeof(SampleEvent).AssemblyQualifiedName!; + Action duplicateAlias = () => Create( + [OutboxEventRegistration.IntentionallyUnsubscribed(typeof(SampleEvent), + EarlierIdentity, EarlierIdentity)]); + Action canonicalAlias = () => Create( + [OutboxEventRegistration.IntentionallyUnsubscribed(typeof(SampleEvent), canonical)]); + Action retiredAlias = () => Create( + [OutboxEventRegistration.IntentionallyUnsubscribed(typeof(SampleEvent), EarlierIdentity)], + retired: [EarlierIdentity]); + Action duplicateEvent = () => Create( + [ + OutboxEventRegistration.IntentionallyUnsubscribed(typeof(SampleEvent)), + OutboxEventRegistration.IntentionallyUnsubscribed(typeof(SampleEvent)), + ]); + + duplicateAlias.Should().Throw<InvalidOperationException>(); + canonicalAlias.Should().Throw<InvalidOperationException>(); + retiredAlias.Should().Throw<InvalidOperationException>(); + duplicateEvent.Should().Throw<InvalidOperationException>(); + } + + [Fact] + public void A_subscription_id_cannot_name_two_independent_consumers() + { + var build = () => Create( + [ + OutboxEventRegistration.Required(typeof(SampleEvent), + [OutboxSubscriptionManifest.Pending(ContentTypeSubscription)]), + OutboxEventRegistration.Required(typeof(OtherEvent), + [OutboxSubscriptionManifest.Pending(ContentTypeSubscription)]), + ]); + + build.Should().Throw<InvalidOperationException>(); + } + + [Fact] + public void Active_manifest_and_discovered_handlers_must_match_in_both_directions() + { + Action missingHandler = () => Create( + [OutboxEventRegistration.Required(typeof(SampleEvent), + [OutboxSubscriptionManifest.Active("test.sample.v1", typeof(SampleHandler))])]); + Action undeclaredHandler = () => Create( + [OutboxEventRegistration.IntentionallyUnsubscribed(typeof(SampleEvent))], + handlers: Handlers(services => + services.AddScoped<IIntegrationEventHandler<SampleEvent>, SampleHandler>())); + Action pendingHandler = () => Create( + [OutboxEventRegistration.Required(typeof(SampleEvent), + [OutboxSubscriptionManifest.Pending(ContentTypeSubscription)])], + handlers: Handlers(services => + services.AddScoped<IIntegrationEventHandler<SampleEvent>, SampleHandler>())); + + missingHandler.Should().Throw<InvalidOperationException>(); + undeclaredHandler.Should().Throw<InvalidOperationException>(); + pendingHandler.Should().Throw<InvalidOperationException>(); + } + + [Fact] + public void A_contract_outside_the_explicit_assembly_allowlist_is_rejected() + { + var build = () => OutboxEventRegistry.Create( + [OutboxEventRegistration.IntentionallyUnsubscribed(typeof(SampleEvent))], + [], Handlers(), [typeof(IntegrationEventBase).Assembly]); + + build.Should().Throw<InvalidOperationException>(); + } + + [Fact] + public void An_empty_contract_census_cannot_make_every_row_unknown() + { + var build = () => Create([]); + + build.Should().Throw<InvalidOperationException>(); + } + + private static OutboxEventRegistry Create( + IEnumerable<OutboxEventRegistration> registrations, + IEnumerable<string>? retired = null, + IntegrationEventHandlerRegistry? handlers = null) => + OutboxEventRegistry.Create( + registrations, + retired ?? [], + handlers ?? Handlers(), + [typeof(SampleEvent).Assembly]); + + private static IntegrationEventHandlerRegistry Handlers( + Action<ServiceCollection>? register = null) + { + var services = new ServiceCollection(); + register?.Invoke(services); + return IntegrationEventHandlerRegistry.FromServiceDescriptors(services); + } + + private sealed record SampleEvent : IntegrationEventBase + { + public override string Topic => "learnstack.test.sample"; + public override string PartitionKey => TenantId.ToString("D"); + } + + private sealed record OtherEvent : IntegrationEventBase + { + public override string Topic => "learnstack.test.other"; + public override string PartitionKey => TenantId.ToString("D"); + } + + private sealed class SampleHandler : IIntegrationEventHandler<SampleEvent> + { + public Task HandleAsync(SampleEvent integrationEvent, CancellationToken cancellationToken = default) => + Task.CompletedTask; + } + + private sealed class ThrowingHandler : IIntegrationEventHandler<OtherEvent> + { + public static int Constructions; + + public ThrowingHandler() + { + Constructions++; + throw new InvalidOperationException("Handler construction was reached."); + } + + public Task HandleAsync(OtherEvent integrationEvent, CancellationToken cancellationToken = default) => + Task.CompletedTask; + } +} diff --git a/docs/architecture/15-event-and-outbox.md b/docs/architecture/15-event-and-outbox.md index bd378a64..608b5de6 100644 --- a/docs/architecture/15-event-and-outbox.md +++ b/docs/architecture/15-event-and-outbox.md @@ -38,8 +38,8 @@ 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. +transactions, inbox and recovery. Accepted ADR-0046 now assigns inbox ownership +and stable subscriber identity; implementation remains P02b-3. 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) @@ -48,15 +48,16 @@ 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. +owns final verification and review status. ADR-0046 accepts dispatcher/inbox +contracts; implementation remains P02b-2/P02b-3. The [P02b-2 decision package](../roadmap/phase-02b-events-auth.md#p02b-2-decision-package-2026-10-12) -and [ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md) propose -leased producer recovery, atomic terminal audit and the coupled shared-inbox -subscriber contract. They await maintainer approval; no remaining gate or runtime -delivery is complete. The target examples below remain subject to that decision -pass and must not be copied as an implemented dispatcher. +and [Accepted ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md) +authorize fenced producer recovery, atomic terminal audit and the coupled shared +inbox/subscriber contract. Step 1 schema/composition is implemented with reviews +pending; no dispatcher or inbox delivery +is claimed by acceptance. Numerical runtime bounds live in the package, canonical +DDL/grants in Standards 05 and metric names in Standards 10. [ADR-0038](../decisions/0038-cross-cutting-port-and-event-contracts.md) stands as the decision that Dapr is the @@ -76,44 +77,29 @@ decides when it arrives, and the answer is "when a second process exists". ```mermaid sequenceDiagram - participant Module as Owning Module + participant Producer as Owning module participant DB as PostgreSQL - participant Outbox as outbox_messages - participant Processor as OutboxProcessor (BackgroundService) + participant Worker as OutboxProcessor participant Bus as IEventBus - participant Transport as Transport (in-process dispatcher, or Dapr to Kafka) - participant Consumer as Consuming Module - participant Inbox as inbox_messages - - Module->>DB: Aggregate state change - Module->>Outbox: INSERT outbox row (same transaction) - DB->>DB: COMMIT (atomic: aggregate + outbox row) - - loop Polling (every 200ms; configurable) - Processor->>Outbox: CLAIM — UPDATE SET locked_by, locked_until<br/>WHERE id IN (SELECT ... FOR UPDATE SKIP LOCKED)<br/>RETURNING *; the claim survives the COMMIT - Processor->>Bus: PublishAsync(envelope)<br/>topic + correlation from the row - Bus->>Transport: deliver - Processor->>Outbox: UPDATE processed_at = now()<br/>WHERE id = @id AND locked_by = @me - end - - Transport->>Consumer: deliver to subscribed handler - Consumer->>Consumer: Restore tenant context from event.TenantId - Consumer->>Inbox: IsAlreadyProcessedAsync(event.EventId) - alt Already processed - Inbox-->>Consumer: skip - else New event - Consumer->>Consumer: Handle business logic - Consumer->>Inbox: MarkAsProcessed(event.EventId, event type name) - Consumer->>DB: SaveChangesAsync (business write + inbox marker, atomic) - end + participant Executor as P02b-3 delivery executor + Producer->>DB: State + outbox + MUST audit on one transaction + Producer->>DB: COMMIT + Worker->>DB: Claim one eligible row, fresh token + lease + attempt + Worker->>Bus: Publish validated envelope outside claim transaction + Bus->>DB: P02b-3 durable acceptance of required subscriptions + Worker->>DB: Fenced processed transition + Executor->>DB: App-role transaction + tenant/org announcement + Executor->>DB: Shared inbox claim before handler construction + Executor->>DB: Business + inbox + outbox + MUST audit + fenced completion + Executor->>DB: COMMIT ``` -Read as text: the producer writes the aggregate change and the outbox row in one -transaction. A processor **claims** a batch of unprocessed rows by stamping a lease on -them, and the claim outlives the claiming transaction. It publishes each claimed row and -marks it processed only if it still holds the lease. The transport delivers to the -consumer, which restores tenant context, deduplicates through its inbox, and commits the -business write and the inbox marker together. +Text fallback: the producer commits state, outbox and audit together. The worker +claims one admitted row immediately before publishing and fences its acknowledgement. +Required Pending subscriptions hold rows before acquisition. P02b-3 durably accepts +required subscriptions, then executes each on its own app-role transaction. Shared +inbox completion and business/audit/ledger completion commit together. Only producer +production exists today; worker and delivery execution remain their named packets. ## Outbox table @@ -125,81 +111,54 @@ 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. 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 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 | - -Both additions go into the canonical DDL in Standards 05 when their phase ships; this -document does not carry a second copy of the table. - -The pending index therefore covers `(available_after)` filtered on -`processed_at IS NULL`, and the claim predicate additionally reads -`locked_until IS NULL OR locked_until < now()`. +The routing key is declared by the event and copied to persistence. It does not +establish durable publish order. Accepted P02b-2 adds paired `locked_by` / +`locked_until` lease fields, `terminal_reason` and `dead_lettered_at`; the former +is a fresh acquisition token, never stable worker identity. `available_after` +controls due time and retry backoff. Canonical DDL/indexes are in Standards 05, +implemented by P02b-2 Step 1 with reviews pending; dispatcher execution remains +a later step. ### Why the `learnstack_outbox_admin` role bypasses RLS — and what bounds the bypass -The OutboxProcessor must dispatch events for **every** tenant from a single -BackgroundService instance; setting `app.tenant_id` per row would defeat the -`FOR UPDATE SKIP LOCKED` batching pattern. The role bypasses RLS by design, and -the bypass is bounded by: - -- **Scope by grant, and grant is the only bound.** `BYPASSRLS` bypasses *policies*, not - `GRANT`s: a role holding the attribute with no privilege on a table gets - `permission denied for table`. The role holds `SELECT` on `outbox_messages` plus a - **column-level** `UPDATE (processed_at, attempts, last_error, available_after)` — and - nothing anywhere else. It has no `DELETE` (purging processed rows is a - `learnstack_platform` operation) and cannot touch `payload`, `tenant_id`, `topic` or - `type`. `SELECT … FOR UPDATE SKIP LOCKED` works with a column-level `UPDATE` grant, so - the claim protocol needs no table-wide `UPDATE`. When `locked_by` and `locked_until` - land in [Phase 02b](../roadmap/phase-02b-events-auth.md), that migration extends the - column list; a column added without extending it fails at runtime with - `permission denied for table`, which is the intended loud failure. Because grant scope - is the *only* thing bounding the bypass, no privilege is ever granted to `PUBLIC` and - no `ALTER DEFAULT PRIVILEGES` grant exists — either would silently un-bound this role - the next time a table is created. -- **No connection sharing.** The role has its own login credential and its own - connection string (`ConnectionStrings:OutboxDispatcher`), present only in the worker - host that runs `OutboxProcessor`. It is not reached by `SET ROLE` from - `learnstack_app`, which is not a member of it. No MediatR handler or API endpoint ever - runs under this role (architecture test - `LearnStack_OutboxAdmin_Role_OnlyUsedBy_OutboxProcessor` enforces this). -- **Audited on use**. Every dispatch attempt produces a structured log entry - with `event_id`, `tenant_id`, `event_type`, and outcome. Bypass-as-such is - not separately audited (the dispatch outcome is); a misuse pattern would - surface as out-of-band tenants in the dispatch log. -- **Permission rationale anchored in ADR-0006 Amendment 1**. The outbox-as- - durable-buffer + Dapr-as-transport pattern requires a single batch-fetcher; - the RLS bypass is the minimum privilege escalation that makes that pattern - work. It is not an exception to ADR-0003 — it is the only legitimate path. - -`learnstack_outbox_admin` is one of the **four** roles in the database model fixed by -[ADR-0003 Amendment 3](../decisions/0003-tenant-isolation-defense-in-depth.md) — -`learnstack_migration` (owner, `NOBYPASSRLS`), `learnstack_app` (runtime, -`NOBYPASSRLS`), `learnstack_platform` (audited cross-tenant admin, `BYPASSRLS`), and -this one. Cross-tenant audit reads use `learnstack_platform` through the audited -`EnterPlatformAdminScope(reason)` path; there is no separate audit role. Adding a fifth -role requires an ADR — every `BYPASSRLS` role is a hole in the isolation model that has -to earn its existence by grant scope, DI-scope isolation, and log discoverability. - -## Inbox table (per consumer module) - -```sql -CREATE TABLE inbox_messages ( - event_id uuid PRIMARY KEY, - event_type text NOT NULL, - received_at timestamptz NOT NULL DEFAULT now(), - processed_at timestamptz NULL -); -``` - -Each module has its own inbox table; deduplication is per-module (a single event can be -processed by N consumers independently). +One API-host BackgroundService schedules every tenant. The existing bypass role +uses its own named keyed, lazy data source; no Seeder/web forwarding or app-role +membership is allowed. Configuration validates the exact username without +connecting or disclosing parse input. Step 3 will require worker-startup +connectivity; every new +physical connection verifies exact session/current role, bounded attributes and +no privilege-escalating membership. + +[Database Standards](../standards/05-database.md#grant-matrix) owns the exact grant +budget: named outbox status UPDATEs and audit INSERT-only in P02b-2, with no audit +read/update/delete or business access. P02b-3 may add only enumerated delivery +identity/routing/lease/status SELECT and status UPDATE, never payload SELECT. +It executes business work separately as `learnstack_app` under tenant RLS. + +Routine confined poll/claim/mark writes bounded structured outcomes and metrics, +under ADR-0046's explicit audit exemption. Terminal state and MUST/SecurityEvent +audit commit atomically; a restricted witness binds the audit to the actual fenced +UPDATE result, because GUCs and INSERT-only cannot tenant-bind a bypass credential. +The platform-admin gate and pre-entry audit remain unchanged. Four roles and +separate credentials remain the closed model; the code-path and role rules are +Registered until their P02b-2 proof ships. + +<a id="inbox-table-per-consumer-module"></a> + +## Inbox table (shared, transport-owned) + +**Accepted ADR-0046 target; implementation P02b-3.** One tenant-owned +`inbox_messages` deduplicates `(tenant_id, subscription_id, event_id)`. The +transport claims before handler construction on the same announced app-role +transaction as business work. New claims roll back on failure; only successful +completion persists a completed row. Completed rows are never reset by replay. + +The separate delivery ledger retains validated envelopes and owns independent +attempts, availability, leases and terminal/replay state. P02b-3 chooses envelope +copies/references and retention dependencies before its canonical DDL, then +P02b-8 inventories all retained stores for erasure. DDL, class/RLS and exact grants +belong in [Database Standards § Inbox](../standards/05-database.md#inbox); this +document carries no second schema. ## Producer pattern @@ -286,7 +245,7 @@ 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 +P02b-3 owns delivery/inbox wiring under ADR-0046; the P02b-1 fixture proves that entry without claiming delivered subscribers. ### First useful flow @@ -294,8 +253,9 @@ that entry without claiming delivered subscribers. 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, +back-reference completion. P02b-3 implements independent Customization +subscriptions for the untouched `card@1` content type (`BuiltInCustomizations.Card`) +and `plain@1` taxonomy (`BuiltInCustomizations.Plain`); 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, @@ -314,35 +274,27 @@ everything. ## Consumer pattern -```csharp -public sealed class QueueWelcomeNotificationOnEnrollmentCreated( - IInboxGuard inboxGuard, - NotificationsDbContext db, - ILogger<QueueWelcomeNotificationOnEnrollmentCreated> logger) - : IIntegrationEventHandler<EnrollmentCreatedIntegrationEventV1> -{ - public async Task HandleAsync(EnrollmentCreatedIntegrationEventV1 @event, CancellationToken ct) - { - if (await inboxGuard.IsAlreadyProcessedAsync(@event.EventId, ct)) - { - logger.LogDebug("Duplicate event {EventId} skipped", @event.EventId); - return; - } - - // Business logic: the consumer writes its OWN module's aggregate, through its - // own DbContext. Not another module's tables, and never audit_log — see below. - var dispatch = NotificationDispatch.QueueWelcome(@event.LearnerId, @event.EnrollmentId); - db.NotificationDispatches.Add(dispatch); - - inboxGuard.MarkAsProcessed(@event.EventId, @event.GetType().Name); - await db.SaveChangesAsync(ct); // business write + inbox marker, atomic - } -} -``` - -The handler's effective identity is `UserId.SystemActor` (see [Rules](#rules)), and -[Audit Coverage Standards](../standards/18-audit-coverage.md) treats a state-mutating -handler as a `system` actor for audit purposes. The handler composes no audit row itself. +**Accepted target, unimplemented until P02b-3.** Each subscription has an explicit +stable `{module}.{subscription}.v{N}` ID, independent of handler class/assembly. +ADR-0046 owns its closed grammar and version/migration rules. The transport opens +an app-role transaction, restores validated tenant and organization, announces RLS +context, and claims the shared inbox before constructing the handler. Absence of +the organization marker never erases a present envelope organization; a marked +event requires one. Effective actor is `UserId.SystemActor`; human actor remains +causal metadata. + +The common transaction/audit owner classifies the handler and refuses undeclared +mutating work. A handler changes only its own root and saves; it calls neither +`IInboxGuard` nor `IEventBus` nor `IAuditStore`. The owner drains produced facts, +writes MUST audit and completes inbox/ledger atomically. Before COMMIT, ledger +completion checks the current token and database-time expiry, plus a sticky gate +that refuses timed-out/canceled execution. Zero rows or a closed gate poison and +roll back the whole transaction. Cancellation after physical COMMIT starts is +Indeterminate; recover from atomic durable state, not a rollback assumption. + +External effects require provider idempotency and reconciliation. Privileged +Audit erasure belongs to its separate Phase 03 execution contract; P3's ordinary +executor refuses it. An app-role inbox cannot make two connections atomic. ### Audit rides this outbox for fan-out, never for the audit row @@ -389,129 +341,63 @@ poll, which is neither. ### The claim protocol -The row lock has to survive the transaction that takes it, which means the claim must be -**written to the row**, not held in the lock table: - -```csharp -public sealed class OutboxProcessor : BackgroundService -{ - private const int BatchSize = 100; - private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(200); - private static readonly TimeSpan LeaseDuration = TimeSpan.FromSeconds(60); - - private readonly string _processorId = $"{Environment.MachineName}:{Guid.NewGuid():N}"; - - protected override async Task ExecuteAsync(CancellationToken ct) - { - while (!ct.IsCancellationRequested) - { - using var scope = _serviceProvider.CreateAsyncScope(); - var processed = await ProcessBatchAsync(scope.ServiceProvider, ct); - - if (processed == 0) - await Task.Delay(PollInterval, ct); - } - } - - private async Task<int> ProcessBatchAsync(IServiceProvider services, CancellationToken ct) - { - var db = services.GetRequiredService<OutboxDbContext>(); - var eventBus = services.GetRequiredService<IEventBus>(); - // Connection runs as learnstack_outbox_admin: BYPASSRLS skips the policy, - // and the GRANT is what bounds what it can reach (see § the bypass bounds). - - // 1. CLAIM. One statement, its own short transaction. The lease is written to the - // row, so it outlives the commit that releases the physical locks. - var batch = await db.Set<OutboxMessage>() - .FromSqlInterpolated($@" - UPDATE outbox_messages - SET locked_by = {_processorId}, - locked_until = now() + {LeaseDuration}, - attempts = attempts + 1 - WHERE id IN ( - SELECT id FROM outbox_messages - WHERE processed_at IS NULL - AND available_after <= now() - AND (locked_until IS NULL OR locked_until < now()) - ORDER BY occurred_at - LIMIT {BatchSize} - FOR UPDATE SKIP LOCKED) - RETURNING *") - .ToListAsync(ct); - - if (batch.Count == 0) return 0; - - // 2. PUBLISH. One short transaction per message. A single poisoned message does - // not roll back the batch. - foreach (var msg in batch) - { - await using var perMessageTx = await db.Database.BeginTransactionAsync(ct); - try - { - var eventInstance = JsonSerializer.Deserialize(msg.Payload, Type.GetType(msg.Type)!); - await eventBus.PublishAsync( - new IntegrationEventEnvelope( - (IIntegrationEvent)eventInstance!, - msg.CorrelationId, - msg.OrganizationId, - msg.CausationId, - msg.ActorUserId is { } actor ? UserId.From(actor) : null), - ct); - msg.MarkProcessed(_clock.UtcNow, _processorId); // no-op if the lease was lost - } - catch (Exception ex) - { - msg.RecordFailure(ex.Message, GetBackoffDuration(msg.Attempts)); - _logger.LogWarning(ex, "Outbox dispatch failed for {EventId} (attempt {Attempt})", - msg.Id, msg.Attempts); - } - await db.SaveChangesAsync(ct); - await perMessageTx.CommitAsync(ct); - } - return batch.Count; - } -} -``` - -`MarkProcessed` writes `processed_at` **and** clears the lease under the predicate -`locked_by = @me`, so a processor whose lease expired mid-batch — because the transport -hung past `LeaseDuration` — cannot overwrite the state of whichever processor took the -row next. A lost lease is logged at warning level and surfaces on the -`learnstack_outbox_lease_lost_total` counter; a non-zero rate means `LeaseDuration` is -shorter than the transport's tail latency and must be raised. +**Accepted ADR-0046 target; runtime P02b-2.** The processor admits a construction-free +manifest policy before acquisition. Any required Pending subscription holds the +contract and all aliases unchanged, even if stored payload is malformed. Active +requires declared handler registration. Only explicit intentional absence may +acknowledge without publication; zero handler discovery is not that policy. + +One short transaction claims one eligible row immediately before invocation, +assigns a fresh canonical UUID token to `locked_by`, stamps `locked_until` and +increments `attempts` once. Stable worker identity is diagnostic context only. +Normal eligibility excludes processed, dead-lettered, terminal-requested and +exhausted rows; it requires due availability, no unrecovered lease and manifest/ +local-partition admission. Selection is `(available_after, occurred_at, id)`. +Database time governs eligibility and expiry. + +For admitted records, resolve only declared concrete contracts and explicit +compatible aliases/retirements. Never load arbitrary assemblies or CLR types from +stored text. Deserialize with frozen options; validate row/envelope identity, +tenant, topic, partition, occurrence and canonical UTF-8 payload bound. Timestamp +comparison accounts for PostgreSQL microsecond precision. Invalid/unknown/retired +records request terminalization with a closed reason. + +Every success, failure, recovery and terminal transition checks row ID, current +token and unexpired lease. Recovery atomically replaces an expired token first. +Zero rows means lost ownership and permits no late acknowledgement, error change, +lease clearance or audit. Expired claims 1–5 enter backoff; claim six can only +terminalize. Six normal claims total use 1 s, 5 s, 30 s, 5 min and 1 h delays. +A committed claim counts even if a crash makes publication unknown. + +A terminal request is not completed dead-letter state. Terminalization acquires +without another attempt or publication, then atomically writes terminal state +and MUST audit using the row-bound transition witness. Audit failure retains the +request for retry. Success/retry/terminal transitions clear leases in the same +short transaction. Uncertain COMMIT is reconciled from durable state before retry. + +Publish deadlines bound waiting, not arbitrary callback lifetime. Retain and +observe a cancellation-ignoring task's scope until it finishes; block its local +partition and fence its late result. Unrelated keys progress within the approved +four-task limit; any overrun is unhealthy and saturation stops new claims. +Shutdown waits at most the package's finite bound, without disposing live resources. ### The simpler alternative, and its cost -Holding one transaction open across the whole batch — `BEGIN`, `SELECT ... FOR UPDATE -SKIP LOCKED`, publish each, `UPDATE processed_at`, `COMMIT` — is also correct, and is an -acceptable implementation of the same contract. Its costs are real and must be accepted -knowingly: - -- The transaction stays open for the duration of every network publish in the batch, - pinning the `VACUUM` horizon and the oldest transaction id for that long. -- One poisoned message rolls back the batch unless every publish is wrapped in a - savepoint, which reintroduces most of the complexity the approach was meant to avoid. -- A crash mid-batch republishes everything already published in it. - -The lease is the specified design. The batch-held transaction is the acceptable fallback -if a deployment cannot add the two columns. Whichever ships, the choice is made once, in -[Phase 02b](../roadmap/phase-02b-events-auth.md), and written into the dispatcher — not -left to each reader of this document. +The former batch-held transaction fallback is superseded by ADR-0046. P02b-2 uses +one just-in-time leased claim and short fenced transitions; it holds no transaction +across publication. A deployment-specific alternative requires a new decision. ### What the processor guarantees -- **At-least-once delivery.** Duplicates are expected; consumers deduplicate through - `IInboxGuard`. This is the contract, not a defect. -- **At most one active claimant per row.** Two processors cannot both hold a live lease - on the same row, so concurrent polling does not multiply dispatches. Duplicates arise - only from lease expiry and crash recovery. -- **Batch independence.** Each message gets its own transaction; one failure does not - roll back the batch. -- **Bounded retry.** Failed messages retry with exponential backoff (1s, 5s, 30s, 5min, - 1h). On reaching `MaxAttempts` the row moves to the producer-side dead-letter state - described below. -- **Horizontal scalability.** `SKIP LOCKED` plus the lease predicate lets N processors - across N pods drain the same table without coordination. +- At most one live owner per row under token/expiry fencing. Crash or lease expiry + can redeliver; transport publication is at-least-once. +- Deterministic selection for one processor without failure, with no enqueue, + commit, retry or cross-process order promise. +- Ordinary failure and bounded overruns permit unrelated eligible work to progress. +- Pending real subscriptions retain useful facts until P02b-3 activates durable + acceptance. No placeholder consumer or global worker-disable switch is used. +- Terminal state commits with MUST audit; it does not prove that a timed-out + transport never accepted the event. P02b-3's inbox is still required. ## `IEventBus` and `IntegrationEventEnvelope` @@ -593,7 +479,8 @@ public sealed class DaprEventBus(DaprClient daprClient) : IEventBus `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. +publish order across commits and retries; ADR-0046 accepts only deterministic +eligible selection within one processor. Topic naming convention: `learnstack.{module}.{aggregate}`. Examples: @@ -608,119 +495,34 @@ Topic naming convention: `learnstack.{module}.{aggregate}`. Examples: ## `InProcessEventBus` -Until the Dapr adapter's trigger fires, `InProcessEventBus` is the only registered -`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<T>`, never a bare `INotificationHandler<T>` | 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 | -| 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( - IServiceScopeFactory scopeFactory, - ITenantContextAccessor tenantAccessor, - IPartitionSerializer partitions, - IntegrationEventHandlerRegistry handlers, - ILogger<InProcessEventBus> logger) : IEventBus -{ - public Task PublishAsync( - IntegrationEventEnvelope envelope, CancellationToken ct = default) - { - ArgumentNullException.ThrowIfNull(envelope); - - // Returned as a cancelled task rather than thrown inline: a - // fire-and-forget call site must not crash its caller synchronously. - if (ct.IsCancellationRequested) return Task.FromCanceled(ct); - - return partitions.RunSequentiallyFor( - envelope.PartitionKey, () => DispatchAsync(envelope, ct)); - } - - private async Task DispatchAsync(IntegrationEventEnvelope envelope, CancellationToken ct) - { - // The outer save/restore covers the FULL dispatch. Context is set before - // subscription lookup and therefore before any handler can be resolved; - // constructors observe the event tenant rather than the publisher tenant. - var previous = tenantAccessor.Current; - tenantAccessor.Current = EventTenantContext.FromEnvelope(envelope); - - try - { - var subscriptions = handlers.For(envelope.Event.GetType()); - List<Exception>? failures = null; - - foreach (var subscription in subscriptions) - { - ct.ThrowIfCancellationRequested(); - try - { - // DeliverAsync creates ONE ASYNC SCOPE and resolves exactly - // this subscription's concrete handler. A constructor or - // disposal failure cannot deny a healthy sibling. It also - // starts the subscription's consumer activity and supplies - // its module name through EventTenantContext. - await DeliverAsync(subscription, envelope, ct); - } - catch (OperationCanceledException) when (ct.IsCancellationRequested) - { - throw; // shutdown: do not start later subscriptions - } - catch (Exception ex) - { - // Poison-message containment remains per subscription. - (failures ??= []).Add(ex); - } - } - - // One failure rethrown as itself; several as an AggregateException. - } - finally - { - tenantAccessor.Current = previous; - } - } // Inbox ownership and its call site remain Phase 02b G1. -} -``` - -The listing is abridged — the shipped class also logs each failure with event, tenant -and partition key, refuses a `null` Task from a handler by name, and wraps an -`OperationCanceledException` raised by a token other than the publish token, so an -outbox processor cannot read "the handler gave up" as "we are shutting down". See -`backend/src/LearnStack.Infrastructure/Messaging/InProcessEventBus.cs`. - -**`PartitionSerializer` refuses reentrant work for the key it is already inside.** -Queuing work for a key from within that key's own work is a deadlock by construction: -the new unit chains behind a tail that cannot complete until the current one returns. -An earlier attempt ran such a call inline, reasoning the caller *is* the sequence — which -is unsound, because an `AsyncLocal` marker flows into every task started inside a unit, -so a fire-and-forget spawn inherited it and ran concurrently with the unit it should have -queued behind. Detection is the same either way; only the action differs. A false -positive that throws is loud and diagnosable; one that runs inline is a silent -concurrency violation. The caller that hits it is publishing from inside a handler, -which [Standards 20](../standards/20-infrastructure-stack.md) already forbids — a -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. 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. +`InProcessEventBus` remains the only registered transport in every deployment +mode until ADR-0035's Phase 11 trigger fires. The shipped baseline constructs one +async scope per discovered subscription, restores ambient tenant/organization, +serializes invocation per key and collects constructor/handler/disposal failures. +It opens no business transaction and has no durable inbox or subscriber ledger. +Its current exception logs/spans need P02b-3's bounded diagnostic reconciliation +before Active production consumers; P02b-2 minimizes its own dispatcher path. + +P02b-3 changes publish success to durable acceptance of every required delivery. +Duplicate acceptance is idempotent; partial or unknown acceptance can be retried. +The independent scheduler then invokes one internal delivery executor for each +subscription, with the shared inbox and app-role owner described above. Subscriber +business failure spends neither sibling nor producer attempts after acceptance. +The executor never rebroadcasts and receives no publication-rule exception. + +`PartitionSerializer` refuses reentrant work for its current key. Queuing behind +itself would deadlock; running inline would let inherited AsyncLocal state permit +concurrent work. Handlers produce outbox rows rather than publish directly. +Process-local serialization does not strengthen durable ordering. Extraction to +a second process remains the named Phase 11 trigger, with the same accepted +identity, envelope, acceptance and independent execution contracts. ## Rules - Integration events are **versioned**. Breaking changes ship a new versioned record (`UserCreatedIntegrationEventV2`); old version remains supported during a migration window. -- Consumers are **idempotent** via `IInboxGuard` (per-module inbox table). +- Consumers are **idempotent** through the shared transport-owned inbox (P02b-3). - Mandatory on every integration event: `EventId`, `TenantId`, `OccurredAt`, `PartitionKey`. The first three are `required`; the fourth is abstract. - Declared by the event and read by the envelope: `Topic`, the channel @@ -747,8 +549,9 @@ the choice. ## Ordering 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. +publish-order guarantee. ADR-0046 selects eligible rows in total order +`(available_after, occurred_at, id)` for one processor without failure. This is no +enqueue/commit/retry/cross-process promise; strict head-of-line blocking is rejected. `InProcessEventBus` serializes invocation per key within its process. The rule, therefore, is that **every outbox row carries a non-null `partition_key`**: @@ -776,8 +579,8 @@ domain; deriving it does not itself establish durable publish order. Consequences worth stating plainly: -- Same-aggregate events share a declared key. Durable order across commits/retries - remains G3's P02b-2 decision. +- Same-aggregate events share a declared key. Retry overtaking and reverse commits + can change arrival order; equal occurrence times use ID only as a selection tie. - 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. @@ -791,48 +594,32 @@ Consequences worth stating plainly: ## Dead-letter: two sides, two failure domains -The distinction the earlier draft of this document missed: a producer-side dispatch -success means "the transport accepted the bytes". It says nothing about whether any -consumer processed them. The two sides fail independently and need separate handling. +Producer acceptance and subscriber business completion are independent outcomes. +A timeout can leave acceptance unknown, so producer terminal state does not imply +that no subscription accepted the fact. ### Producer side — dispatch never succeeded -The outbox row reaches `MaxAttempts` without a successful `PublishAsync`. - -- The row stays in the table with `processed_at IS NULL`, `attempts >= MaxAttempts`, and - the final `last_error`. -- `available_after` is pushed beyond the retry horizon so the poller stops picking it up. -- It surfaces on the `OutboxStatusEndpoints` admin API and on the - `learnstack_outbox_deadletter_total{event_type}` counter; the alert is on the counter's - rate, not on the table's size. -- Recovery is a manual replay: an operator clears the lease, resets `attempts`, and - resets `available_after`. +Invalid/unknown/retired records or exhausted claims request `terminal_reason`. +Normal publication excludes that row permanently. The terminalizer writes +`dead_lettered_at` and Tenancy-owned `platform.outbox.dead_letter` MUST/SecurityEvent +on one fenced transaction, retaining the envelope for investigation. Audit failure +retains terminal work for retry without publication; completed terminal rows leave +pending gauges. A far-future availability time is never a terminal marker. +Phase 03 must decide producer reset/fencing before any operator replay endpoint. ### Subscriber side — dispatch succeeded, handling did not -A consumer's handler throws on every attempt: a malformed payload, a schema version it -cannot read, a permanently failing downstream dependency. The producer's row is long -since `processed_at`-stamped, and no amount of producer-side retry will help. - -- The transport retries the handler with backoff. On exhausting its retry policy it - routes the message to a **dead-letter topic**, `learnstack.dlq.{module}` — one per - subscribing module, three segments, satisfying - `Dapr_PubSub_TopicNames_FollowConvention`. Under Dapr this is the subscription's - `deadLetterTopic`; under `InProcessEventBus` the equivalent is a row in - `dead_letter_messages` carrying the same envelope. -- **The inbox row is not marked processed.** A dead-lettered message has not been - handled, and recording it as handled would make a later replay a silent no-op. -- Every module subscribes to its own dead-letter topic with a handler that does nothing - but persist the envelope, emit `learnstack_inbox_deadletter_total{module, event_type}`, - and raise an operational alert. A dead-letter topic nobody consumes is a queue that - grows until the broker's retention silently deletes the evidence. -- Recovery is an operator-initiated replay from the dead-letter store back onto the - original topic. Because the inbox never marked the event processed, the replayed - message is handled normally; because `IInboxGuard` is still in the path, a replay that - races a late success is still idempotent. -- Poison-message containment is per subscription. One module's dead-lettered event does - not block another module's consumption of the same topic — each subscription has its - own offset and its own dead-letter path. +**P02b-3 target.** Each stable subscription has its own durable ledger budget, +retained envelope and terminal/replay state. Terminal failure leaves no completed +inbox row and writes Tenancy-owned `platform.delivery.dead_letter` atomically with +terminal state. No `learnstack.dlq.*` topic or duplicate dead-letter table is needed. + +Targeted internal replay resets only that subscription with fresh ownership fencing +and invokes its executor, never `PublishAsync`. Completed siblings remain inbox +deduplicated; a completed inbox is never reset. Phase 03 owns operator permission, +HTTP routes and the separately decided producer replay contract. P02b-8 owns erasure +inventory, Phase 03 privileged redaction and Phase 11 retention purge. ## Service extraction readiness @@ -857,13 +644,13 @@ separate service later: - `Integration_Events_Inherit_From_IntegrationEventBase` — every type implementing `IIntegrationEvent` extends `IntegrationEventBase` (which carries `EventId`, `OccurredAt`, `TenantId` and declares `Topic` / `PartitionKey`). -- `Integration_Event_Handlers_Use_InboxGuard` — every `IIntegrationEventHandler<T>` - implementation invokes `IInboxGuard.IsAlreadyProcessedAsync` before processing. +- `Integration_Event_Handlers_Use_InboxGuard` — Registered for P02b-3's shared + transport-owned claim before construction and atomic fenced completion. - `Dapr_PubSub_TopicNames_FollowConvention` — Phase 11 checks that Dapr component bindings agree with the already-validated topics declared by events. It is not a current `[Topic]`-attribute scan; no such attribute exists. - `OutboxProcessor_NeverBlocks_OnSingleMessageFailure` — integration test asserts one - poisoned message doesn't prevent others in the batch from processing. + failed attempt or bounded overrun permits unrelated eligible work to progress. - `Integration_Event_Handler_Restores_Tenant_Context` — catalogued in [Architecture Tests Catalogue](../standards/21-architecture-tests-catalogue.md); runs against whichever transport is registered, so `InProcessEventBus` is held to the same @@ -877,24 +664,25 @@ separate service later: ## Observability -- Outbox lag: `learnstack_outbox_pending_count{tenant_id}` (gauge). -- Dispatch duration: `learnstack_outbox_dispatch_duration_seconds{event_type}` (histogram). -- Dispatch failures: `learnstack_outbox_dispatch_failed_total{event_type}` (counter). -- Producer-side dead letter: `learnstack_outbox_deadletter_total{event_type}` (counter). -- Lost leases: `learnstack_outbox_lease_lost_total` (counter) — non-zero means the lease - is shorter than the transport's tail latency. -- Inbox dedup: `learnstack_inbox_dedup_total{module, event_type}` (counter). -- Subscriber-side dead letter: `learnstack_inbox_deadletter_total{module, event_type}` - (counter). - -The two dead-letter counters are **separate signals on separate dashboards**. A -producer-side spike means the transport is down; a subscriber-side spike means a handler -or a payload is broken. Collapsing them into one "events failed" number destroys the only -information that distinguishes the two incidents. Once the Dapr adapter lands, both sit -alongside Dapr's own `dapr_component_pubsub_*` metrics. +[Observability Standards § Required Metrics](../standards/10-observability.md#required-metrics) +owns names, labels and queue semantics. P02b-2 emits unlabelled pending, deferred, +oldest-eligible-age and heartbeat gauges from immutable snapshots, plus bounded +attempt duration/failure/terminal/lease-loss observations. Gauges perform no SQL; +refresh failure preserves stale data and makes health unhealthy. Per-attempt spans +and closed diagnostics contain no raw exception/type/payload/connection data. + +P02b-3 adds dedup/subscriber-terminal counters with bounded module/event-type labels. +Producer and subscriber terminal outcomes remain separate observations: acceptance +failure and business execution failure have different retry owners. Metrics are +process observations, not an exactly-once durable event ledger. `outbox` is +registered and not publicly mapped; `/healthz` remains liveness-only, with Phase 11 +owning readiness exposure and dashboards. ## References +- [ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md) — accepted + bounded delivery supersessions, readiness, terminal audit and shared inbox contract. + - ADR-0006 (Amendment 1) — Events and Outbox; dispatch transport. - ADR-0010 (Amendment 1) — Cross-Module Communication; outbox dispatch target. - [ADR-0038](../decisions/0038-cross-cutting-port-and-event-contracts.md) — Dapr choice, diff --git a/docs/architecture/31-audit-subsystem.md b/docs/architecture/31-audit-subsystem.md index 8250c25a..cb7dd95e 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-0046 (Durable Event Delivery and Recovery)](../decisions/0046-durable-event-delivery-and-recovery.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). @@ -77,6 +78,44 @@ Redaction, projection and external fan-out happen after the commit, reading the row; none of them updates it. ADR-0016's "audit never blocks business logic" is preserved for that second stage and withdrawn for the first. +### Confined event-terminal audit + +**Accepted ADR-0046; fifth seam pending P02b-2.** Existing four delivered writes +retain their contracts. The new narrow method is: + +```csharp +IAuditStore.WriteEventTerminalAsync( + AuditEntryDraft draft, + EventTerminalTransition transition, + DbConnection connection, + DbTransaction transaction, + CancellationToken cancellationToken); +``` + +A successful fenced terminal `UPDATE ... RETURNING` alone mints the immutable +restricted witness. It binds actual stored tenant/org/event, acquisition, +producer/subscriber kind, closed reason, attempts, optional subscription ID and +live connection/transaction. The store checks declared MUST/SecurityEvent +operation, draft identity and transaction before INSERT, opens/commits nothing +and remains the sole audit SQL writer. Mismatch rolls back both state and audit. +GUCs cannot bind a bypass INSERT; real tenant/org/event/transaction mismatch proofs +and a matching organization-scoped positive control are required. + +P02b-2 supplies `platform.outbox.dead_letter`; P02b-3 supplies reserved +`platform.delivery.dead_letter`, both off-path under Tenancy. Register each only +when its writer ships. Outcome success describes terminal state, not publication; +invalid causal fields are omitted in favor of a fresh validated attempt trace. +Closed terminal values use bounded metadata; `Reason` stays for platform entry. +Audit failure retains terminal work for retry without delivery; uncertain COMMIT +is reconciled before retry and cannot duplicate a completed terminal audit. + +The existing outbox role gains audit INSERT-only in the Audit chain, with exact +grants owned by [Database Standards](../standards/05-database.md#grant-matrix). +Routine confined poll/claim/mark uses the accepted bounded audit exemption; +platform-admin pre-entry audit and other bypass operations are unchanged. +P02b-3 adds inbox/delivery machinery to capture exclusions with reasons, because +explicit intents audit business effects; these exclusions remain pending P3. + ## 2. Pipeline overview ```mermaid @@ -632,7 +671,7 @@ 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. +delivery/inbox wiring remains P02b-3 under Accepted ADR-0046. ### The two catalogue faces, and the three columns nothing fills yet @@ -1467,128 +1506,21 @@ retention purge, § 9). It is not an exception carved out of the append-only rul convention — it is the shape the `audit_log_append_only_guard` trigger in § 7 was written to permit, and nothing else. -```csharp -// LearnStack.Modules.Audit.Infrastructure.IntegrationEvents -public sealed class UserGdprDeletedIntegrationEventHandler( - AuditDbContext db, - IPlatformAdminScope platformScope, - IAuditStore auditStore, - IInboxGuard inboxGuard, - IClock clock, // the draft's Timestamp; the column's DEFAULT is a backstop - IGuidFactory guidFactory, // AuditEntryId has no New() (ADR-0023 Amendment 9) - IEnumerable<IUserReferenceLocator> userReferenceLocators) - : IIntegrationEventHandler<UserGdprDeletedIntegrationEvent> -{ - public async Task HandleAsync(UserGdprDeletedIntegrationEvent @event, CancellationToken ct) - { - // 1. Idempotent: inbox guard. - if (await inboxGuard.IsAlreadyProcessedAsync(@event.EventId, ct)) return; - - // 2. learnstack_app holds no UPDATE privilege on audit_log, so the redaction runs - // as learnstack_platform — on the HANDLE's connection. The injected - // AuditDbContext is bound to IUnitOfWork.Connection (ADR-0040) and stays on - // the request's learnstack_app connection whatever scope surrounds it, so - // issuing the UPDATE through `db` would raise 42501 rather than redact - // anything. Entering the scope writes its own SecurityEvent row, committed - // before the handle is returned (ADR-0044 § 10). - await using var handle = await platformScope.EnterAsync( - reason: $"gdpr-redaction:{@event.UserId}", ct); - - // 3. Actor PII only. The payload columns are NOT touched here. A blanket - // jsonb_set('{redacted}', 'true') would (a) not redact anything — it adds a - // flag and leaves the PII in place — and (b) raise - // 'cannot set path in scalar' on any snapshot that is a JSON scalar or - // array. Payload redaction belongs to the per-module locator below, which - // knows which JSON paths in its own snapshots reference a user. - await using (var redact = handle.Connection.CreateCommand()) - { - redact.Transaction = handle.Transaction; - redact.CommandText = @" - UPDATE audit_log - SET actor_email = '***REDACTED***', -- SensitiveTokenCatalog.RedactedValue - ip_address = NULL, - user_agent = '***REDACTED***' - WHERE actor_user_id = @actor - AND tenant_id = @tenant"; - redact.Parameters.Add(new NpgsqlParameter("actor", @event.UserId)); - redact.Parameters.Add(new NpgsqlParameter("tenant", @event.TenantId)); - await redact.ExecuteNonQueryAsync(ct); - } - - // 4. Payload references, per module. Each locator issues column-restricted - // UPDATEs against before_state / after_state / changes only — on the same - // handle, because they need the same privilege for the same reason. - foreach (var locator in userReferenceLocators) - await locator.RedactReferencesAsync(handle, @event.UserId, @event.TenantId, ct); - - // 5. The result row — ON THE HANDLE'S TRANSACTION, before its commit. The redaction - // is a MUST-class security event, and the row recording it commits with the - // redaction or not at all (Audit Coverage: a MUST row is never written after the - // transaction it describes has committed). An earlier version of this listing - // committed the handle first and wrote the row standalone afterwards: a process - // that stopped between the two left rows permanently redacted and no record of - // it. The scope-entry row step 2 committed proves the scope was entered, not what - // was done inside it — two events, two rows. - // - // THE PORT DOES NOT SANCTION THIS CALL YET. WritePlatformScopeAsync writes on a - // caller's platform transaction, and its contract admits one caller: the scope's - // own entry row. Phase 03 extends it to this second, bounded caller — or gives the - // result row a method of its own — by amending ADR-0044 § 10 before the handler - // lands. The ORDER shown here is what that decision may not change. - // - // Constructed explicitly: AuditEntryDraft has no factory, AuditEntryId has no - // New() (ADR-0023 Amendment 9), and Timestamp comes from IClock. Every member is - // `required`, so the compiler refuses a construction that omits one and every - // value arrives under its own name — ELEVEN of the twenty-two fields are string?, - // six of them consecutive, and a transposition lands in the one table whose rows - // nothing can correct. The metadata carries the event id: see step 6. - await auditStore.WritePlatformScopeAsync( - new AuditEntryDraft - { - Id = AuditEntryId.From(guidFactory.NewUuidV7()), - TenantId = TenantId.From(@event.TenantId), - OrganizationId = null, - ActorUserId = null, - ActorEmail = null, - ModuleName = "audit", - Operation = "audit.redaction.apply", - OperationType = OperationType.SecurityEvent, - OperationClass = OperationClass.Must, - EntityType = null, - EntityId = null, - Outcome = AuditOutcome.Success, - ErrorKey = null, - Reason = $"gdpr-redaction:{@event.UserId}", - BeforeState = null, - AfterState = null, - Changes = null, - CorrelationId = null, - IpAddress = null, - UserAgent = null, - Timestamp = clock.UtcNow, - Metadata = JsonSerializer.Serialize( - new { subjectUserId = @event.UserId, eventId = @event.EventId }), - }, - handle.Connection, - handle.Transaction, - ct); - - // The redaction, the locators' updates and the row recording them: one commit. - await handle.CommitAsync(ct); - - // 6. The inbox mark. It cannot join that commit — it is learnstack_app work on the - // ambient connection, a different connection from the handle's — so a process - // that stops between the two leaves the event unmarked, and it is delivered - // again. The redelivery must therefore be harmless, and Phase 03 makes it so with - // the port decision above: the redaction is idempotent by construction (the same - // columns set to the same sentinel), and before redacting, the handler looks on - // the handle for a result row whose metadata carries this event id — finding one, - // it marks the inbox and stops, so one erasure never records two results. - inboxGuard.MarkAsProcessed(@event.EventId, @event.GetType().Name); - await db.SaveChangesAsync(ct); - } -} -``` +**Phase 03 target; no privileged consumer is shipped.** ADR-0046 requires a +separate privileged execution contract before this consumer can become Active. +P02b-3's ordinary app-role executor refuses privileged subscriptions. Its shared +inbox cannot atomically complete a platform-role transaction on another connection. +The former handler-owned inbox sketch is superseded as the current target. + +Phase 03 uses audited `EnterPlatformAdminScope(reason)` and specifies bounded +result-audit access before the handler ships. Scope entry commits its own audit +before work; the redaction and its MUST result audit then share the platform +working transaction. Per-module `IUserReferenceLocator` implementations redact +only declared payload references through that same handle. Idempotent progress +and durable event reconciliation prevent duplicate result audits after a crash; +no two-connection atomicity or external exactly-once effect is promised. The +ordinary event-terminal fifth seam authorizes no privileged redaction caller. +Phase 11 owns retention purge, not redaction. `actor_user_id`, `module`, `operation`, `operation_type`, `operation_class`, `outcome`, `correlation_id` and `timestamp` are **never** redacted; the trigger rejects an `UPDATE` @@ -1689,14 +1621,15 @@ update it constrains. `(planned)` row whose command has since shipped **fails**, which is what stops the marker from becoming an escape. - *Off the request path.* Operations that are not MediatR requests at all carry - `(off-path)` and are outside both directions. Seven rows carry it — Tenancy's - `platform.admin_scope.enter`, `tenancy.killswitch.toggle`, + `(off-path)` and are outside both directions. Nine rows carry it — Tenancy's + `platform.admin_scope.enter`, `platform.outbox.dead_letter`, + `platform.delivery.dead_letter`, `tenancy.killswitch.toggle`, `tenancy.entitlement.refresh` and [ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md)'s `tenancy.tenant_assertion.reject` and `tenancy.tenant_assertion.anonymous_burst`, and Audit's `audit.redaction.apply` and `audit.purge.apply`. They register by slug: the - three whose writer ships through `DeclareOffPath`, the other four — also `(planned)` — - when their writer lands. + three whose writer ships through `DeclareOffPath`, the other six — also + `(planned)` — when their writer lands. - *An `Off` registration carries no slug*, so it raises no matrix row and is outside both directions too (§ 5). 2. `AuditEntry_Inherits_Entity_Not_AuditableEntity` — append-only by construction: an @@ -1783,6 +1716,8 @@ for MUST-class would lock in exactly the defect ADR-0033 removes. |-------|-------------| | [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. | +| P02b-2 (Accepted; pending) | Confined producer terminal state and MUST audit through row-bound fifth seam; Audit-chain INSERT-only grant, bounded routine-dispatch exemption and decisive real-role atomicity/identity proofs. | +| P02b-3 (Accepted contract; pending) | Shared transport-owned inbox and independent delivery ledger; per-subscription audit frames and Tenancy-owned terminal audit. Ordinary executor refuses privileged subscriptions. | | [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/glossary.md b/docs/glossary.md index 6b350580..077ec499 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -324,7 +324,7 @@ 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. It supplies `IIntegrationEventHandler<T>`, 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. | +| **`IEventBus`** | Non-generic envelope publication port. `InProcessEventBus` is the only registered implementation until the Phase 11 trigger. P02b-3 changes success to durable acceptance by every required subscription, then independent execution owns business outcomes; failed/unknown acceptance remains producer retry. The outbox processor remains the only production publisher; subscriber retries invoke an internal executor. Current ambient restoration/per-key invocation exists; durable acceptance/inbox do not. See [ADR-0046](decisions/0046-durable-event-delivery-and-recovery.md). | | **`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*. | @@ -341,9 +341,16 @@ These entries distinguish P02d-2 contracts from P02d-3 internal read contracts: | **`IFeatureFlags`** | The **only** module-facing entitlement read: `IsEnabledAsync(FeatureKey)` and `GetLimitAsync(LimitKey)` returning `long`. It resolves the tenant from `ITenantContext` and throws when there is none, so module code can never name another tenant. It **composes** rather than queries: a plan-projected key resolves through `IEntitlementProvider.GetAsync` — not by reading `platform_entitlement_cache` — a tenant-flag key reads `tenant_feature_flags`, and the killswitch overlay is applied last and wins over both. Its implementation lives in `LearnStack.Modules.Tenancy.Infrastructure`, because `tenant_feature_flags` is that module's table, and is registered at the composition root. `IFeatureFlagService` and `IUsageLimitService` are **withdrawn**, not renamed ([ADR-0021](decisions/0021-feature-based-entitlement.md)). Per [ADR-0045 § 2](decisions/0045-entitlement-and-feature-flag-socket.md). | | **`IHostToTenantResolver` / `HostResolution`** | Interface for host → `(tenant_id, organization_id?)` resolution, returning the `HostResolution(TenantId, OrganizationId?)` record or null. Reads `platform_host_to_tenant` and **nothing else** — never the Hub, because an anonymous page load must not depend on a control plane being reachable ([ADR-0034](decisions/0034-hub-contract-surface-invariant.md)). | | **APISIX** | The gateway in standalone YAML-reload mode per [ADR-0015](decisions/0015-api-gateway-apisix.md). The intended tenant-facing ingress, demand-gated to [Phase 11](roadmap/phase-11-production-hardening.md); until then ASP.NET middleware carries the same responsibilities in-process. `/api/internal/*` is never proxied by it — that listener is mTLS-only inside the pod. | -| **`OutboxProcessor`** | The BackgroundService that **claims** batches of `outbox_messages` by writing a lease (`locked_by` / `locked_until`) under `FOR UPDATE SKIP LOCKED`, dispatches each through `IEventBus`, and handles retry / dead-letter. The lease is written to the row rather than held in the transaction, because `FOR UPDATE` locks end when the transaction ends. Delivery is **at-least-once**; consumer-side `IInboxGuard` is what makes duplicates safe. See [15-event-and-outbox.md](architecture/15-event-and-outbox.md). | +| **`OutboxProcessor`** | The API-host BackgroundService that claims one admitted outbox row immediately before invocation, with a fresh acquisition token in `locked_by`, a database-time lease and one counted attempt. Token/expiry-fenced transitions own retry and explicit audited terminal state; stable worker identity is diagnostic only. Accepted [ADR-0046](decisions/0046-durable-event-delivery-and-recovery.md) authorizes P02b-2 implementation, which remains pending; at-least-once recovery still needs P02b-3 inbox deduplication. | | **`IUnitOfWork`** | The ambient unit of work: **one database connection per scope**, and the transaction on it ([ADR-0040](decisions/0040-ambient-unit-of-work.md)). Every module `DbContext` resolved in the scope is built on that connection and enlisted in that transaction, and `IAuditStore` and `IOutbox` reach it through the same seam. The connection count is a correctness property, not a performance one: `SET LOCAL app.tenant_id` is connection- **and** transaction-local, so a context on its own connection reads zero rows from every tenant-owned table — silently. It exposes no `SaveChangesAsync`; contexts save themselves and it owns only the transaction boundary. [ADR-0040 Amendment 8](decisions/0040-ambient-unit-of-work.md#amendment-8--read-only-public-frames-2026-10-03) accepts explicit read-only/read-write intent, defaulting to writable: same-mode joins preserve ownership, mixed-mode joins poison the unit, and successful read-only completion permits a later writer. P02d-4 Step 1 delivers these modes and lifecycle controls; poison still requires a fresh scope. | -| **`IInboxGuard`** | The per-module inbox-deduplication helper. Every integration-event handler must call `IsAlreadyProcessedAsync` before business logic and `MarkAsProcessed` inside the same **transaction** as the business write — the ambient one the transport opens per delivery through `IUnitOfWork` ([ADR-0033](decisions/0033-audit-durability-model.md), [ADR-0040](decisions/0040-ambient-unit-of-work.md)). "The same `SaveChanges`" was the earlier formulation and ADR-0033 withdrew it. | +| **`IInboxGuard`** | The Accepted shared, transport-owned inbox claim/completion seam, pending P02b-3. Deduplication is `(tenant_id, subscription_id, event_id)`, claimed before handler construction on the announced app-role transaction. Business state, completed inbox, produced outbox, MUST audit and fenced ledger completion commit together; rollback removes a new claim. Handlers do not call the guard, and targeted replay never resets a completed inbox. See [ADR-0046](decisions/0046-durable-event-delivery-and-recovery.md#subscriber-acceptance-and-inbox-ownership). | +| **Inbox** | Shared tenant-owned completion record for one stable subscription/event pair. A new claim is uncommitted business-transaction work; only successful completion persists it. The delivery ledger owns failed/terminal/replay state. Canonical DDL remains P02b-3 in [Database Standards](standards/05-database.md#inbox). | +| **Integration-event subscription** | One explicitly identified consumer reaction, independent of handler class or assembly. Its stable `{module}.{subscription}.v{N}` ID versions deduplication semantics, not event schema or built-in revision. Renaming needs a migration/replay decision; a handler refactor preserves identity. [ADR-0046](decisions/0046-durable-event-delivery-and-recovery.md#subscriber-acceptance-and-inbox-ownership) owns the exact grammar. | +| **Subscription readiness** | Construction-free Pending, Active or IntentionallyUnsubscribed declaration. Any required Pending subscription holds its contract/aliases before acquisition and validation. Active requires a real registered handler; intentional absence is explicit, never inferred from zero discovered handlers. Accepted P02b-2 policy under [ADR-0046](decisions/0046-durable-event-delivery-and-recovery.md). | +| **Outbox lease** | Paired `locked_by` / `locked_until` fields; the former holds a fresh canonical UUID acquisition token, the latter a database-time expiry. Every transition checks both current token and unexpired lease. Recovery replaces expired ownership before another transition. Stable worker identity confers no ownership. | +| **Dead letter** | Explicit completed terminal state after invalid data or exhausted attempts, distinct from a terminal request or delayed availability. Producer terminal state retains its envelope and commits with MUST audit; subscriber terminal state belongs to its independent delivery ledger and never completes its inbox. Operator reset/replay is separately owned by Phase 03. | +| **`EventTerminalTransition`** | Restricted immutable witness minted only from the successful fenced terminal UPDATE result in its live transaction. It binds actual row identity, acquisition and terminal values to `WriteEventTerminalAsync`; substituting draft tenant/org/event or transaction requires rollback of state and audit. Accepted fifth seam, pending P02b-2. | + | **`DeploymentMode`** | Enum (`Development \| SaaS \| Dedicated \| SelfHostedOnline \| SelfHostedAirGapped`) read at the composition root to select provider implementations. Modules never read this enum. | ## Hub & Licensing diff --git a/docs/modules/customization/README.md b/docs/modules/customization/README.md index 5abefb79..bd1a6724 100644 --- a/docs/modules/customization/README.md +++ b/docs/modules/customization/README.md @@ -181,9 +181,10 @@ an Active winner from another revision is never retired or adopted by seed. ### Primary integration-event flow: provisioning defaults -**Accepted flow, runtime subscriptions pending P02b-3 after G1.** +**Accepted ADR-0046/0057 flow; runtime subscriptions pending P02b-3.** [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 +authorizes independent `card@1` content-type and `plain@1` taxonomy initialization +on completed tenant provisioning: ```mermaid @@ -196,7 +197,7 @@ flowchart LR P --> PT[One root + generation + inbox + MUST audit] ``` -Text fallback: Tenancy persists the completed-provisioning fact; after G1, +Text fallback: Tenancy persists the completed-provisioning fact; in P02b-3, 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. @@ -293,15 +294,17 @@ keeps the library's types inside it. ## Integration-event catalogue **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 +useful effects; ADR-0046 fixes the stable identities and shared transport-owned +inbox. P02b-2 manifests both required subscriptions Pending, holding real rows and +aliases before acquisition/validation; P02b-3 activates both with durable +acceptance. 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 | +| Consumed topic | Event | Stable subscription | Independent effect | Owner | +|---|---|---|---|---| +| `learnstack.tenancy.tenant` | `TenantCreatedIntegrationEventV1` | `customization.initialize-content-type-default.v1` | Create untouched `card@1` content type (`BuiltInCustomizations.Card`) | P02b-3; Pending until then | +| `learnstack.tenancy.tenant` | `TenantCreatedIntegrationEventV1` | `customization.initialize-taxonomy-default.v1` | Create untouched `plain@1` taxonomy (`BuiltInCustomizations.Plain`) | P02b-3; Pending until then | Initialization preserves Draft, Active, Deprecated, successor and deliberately deleted definitions; it never overwrites, republishes or resurrects them. P02b-3 diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index fdef8b32..f6d7b2ae 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -21,7 +21,7 @@ production-census guards and catalogue enforcement; its backend verification and both completed review rounds. **P02b-1 is complete and merged** through [PR #29](https://github.com/HodeTech/LearnStack/pull/29) on 2026-10-12. The [merge closeout](#p02b-1-merge-and-closeout-2026-10-12) owns final -verification and the next P02b-2 decision pass. +verification. The Accepted P02b-2 package below owns the current dispatcher work. **P02b-2 decision pass — 2026-10-12.** The [decision package](#p02b-2-decision-package-2026-10-12) and @@ -78,20 +78,12 @@ keeps the phase from re-deciding settled questions or repairing repaired defects `InProcessEventBus` serialising handler invocation per key — shipped in [Packet 5](phase-02a-kernel-tenancy.md#delivery-record-packet-5) under [ADR-0038](../decisions/0038-cross-cutting-port-and-event-contracts.md). -- **The claim protocol.** - [Events and Outbox § The claim protocol](../architecture/15-event-and-outbox.md#the-claim-protocol) - specifies the lease, and - [§ The simpler alternative, and its cost](../architecture/15-event-and-outbox.md#the-simpler-alternative-and-its-cost) - demotes the batch-held transaction to a fallback for a deployment that cannot add the - two columns. [ADR-0006 Amendment 2](../decisions/0006-events-and-outbox.md) already - assigns `locked_by` / `locked_until` and the matching `learnstack_outbox_admin` - column-grant extension to this phase's migration. This phase **implements** that - design; it does not choose between candidates. -- **The delivery contract.** At-least-once dispatch with consumer-side idempotency. - [§ The claim problem](../architecture/15-event-and-outbox.md#the-claim-problem) - already withdrew the "no double dispatch" claim, and - [§ What the processor guarantees](../architecture/15-event-and-outbox.md#what-the-processor-guarantees) - bounds duplicates to crash recovery and lease expiry. +- **The claim and delivery protocol.** Accepted + [ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md) supersedes the + batch-held fallback and unbounded ordering/exactly-once sketches. P02b-2 uses + fresh acquisition tokens, short claim transactions, six normal claims and + explicit terminal state. Dispatch stays at least once; P02b-3's shared inbox + provides per-subscription database idempotency. - **The ambient unit of work**, and the delivery shape [ADR-0040 § Consumers do not have a request](../decisions/0040-ambient-unit-of-work.md#consumers-do-not-have-a-request-and-still-need-all-of-this) assigns to this phase by name: `BeginTransactionAsync` → `SetTenantContextAsync` → @@ -107,7 +99,7 @@ keeps the phase from re-deciding settled questions or repairing repaired defects 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` +`inbox_messages`, the delivered dispatcher, 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` @@ -145,16 +137,14 @@ composition-root registration. Each item below is named somewhere as adjacent to this phase's work. Each has an owner. -- **An operator-facing recovery API.** This phase ships replay as a dispatcher-level - operation with its tests (§ The consumer side). The route space, the Platform-scope - permission key, the cross-tenant read and the runbook land in - [Phase 03](phase-03-identity-admin.md) beside the killswitch toggle, on the - [ADR-0045 Amendment 1](../decisions/0045-entitlement-and-feature-flag-socket.md) - precedent — `EnterPlatformAdminScope`'s registered gate is `DenyAllPlatformAdminGate`, - which refuses everyone until then. Whether a platform URL space exists at all is a - question that - [ADR-0036 § The platform-admin override is not a resolution source](../decisions/0036-tenant-resolution-trusted-inputs.md#the-platform-admin-override-is-not-a-resolution-source) - says needs its own decision record. +- **An operator-facing recovery API.** P02b-3 supplies internal targeted + subscriber replay through its delivery ledger and executor. Producer reset, + reason/permission, operator fencing and the runbook remain + [Phase 03](phase-03-identity-admin.md) work. No operator outbox reset is settled + here. `EnterPlatformAdminScope` still denies everyone until Phase 03; the URL + space needs the record + [ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md#the-platform-admin-override-is-not-a-resolution-source) + requires. - **The Hangfire dashboard.** No route is mapped in any deployment mode here. [Phase 03](phase-03-identity-admin.md) maps it behind the Platform-scope permission and carries the [ADR-0015](../decisions/0015-api-gateway-apisix.md) amendment its @@ -245,11 +235,9 @@ Decisions made or referenced in this phase: - [ADR-0044 Audit Write Path](../decisions/0044-audit-write-path.md) - [ADR-0045 Entitlement and Feature-Flag Socket](../decisions/0045-entitlement-and-feature-flag-socket.md) (the killswitch precedent for a mechanism shipped without its operator surface) -- Two reserved decisions must be Accepted before their dependent packets start: - [ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md) (delivery, - inbox and subscriber failure) is now Proposed in - [the decision index](../decisions/README.md#proposed-adrs); ADR-0047 (the job - runtime) remains undrafted in +- [ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md) is Accepted + with its bounded delivery supersessions and separate additive audit seam. + ADR-0047 (job runtime) remains undrafted in [Open ADR Drafts](../decisions/README.md#open-adr-drafts). ## Scope @@ -265,8 +253,8 @@ Accepted, and its catalogue rows registered, before its first line of code. |---|---|---| | **P02b-0** | Phase entry: every premise the register below cites re-verified against `HEAD` — the code, the corpus, the Hub repository — and the register corrected where Phase 02d moved it. It drafts no record: each gate closes in the decision pass of the first packet it shapes, and each glossary headword this phase needs — `inbox`, outbox lease, dead letter, audit frame, job activator, integration-event consumer — is written in the pass whose gate fixes its meaning | [Phase 02d](phase-02d-walking-skeleton.md) exits | | **P02b-1** | **Complete and merged — [PR #29 closeout](#p02b-1-merge-and-closeout-2026-10-12).** Producer: `IOutbox`, the `OutboxFlushBehavior` invariant guard, successful-save domain-event collection and owner-only completion, and the first event with its enqueue path | G2, G3 (the key's derivation), G5 (the stored identities), G6, G18 (the payload rule), G19 Accepted | -| **P02b-2** | Dispatcher: the lease migration and grant extension, claim / publish / mark, backoff, the producer terminal state, the credential and its boot guard, the metrics and the health check | G1, G3, G4, G5, G7, G8, G17, G18 (`last_error`) Accepted; P02b-1 | -| **P02b-3** | Consumer: the inbox DDL, `IInboxGuard`, the per-delivery transaction, the sample flow's consumer, per-subscription attempts, the dead-letter store and its audit row, replay as a dispatcher operation | G1, G6, G7 Accepted; P02b-2 | +| **P02b-2** | **Decision Accepted; Step 1 in progress.** Dispatcher: the lease migration and grant extension, claim / publish / mark, backoff, the producer terminal state, the credential and its boot guard, the metrics and the health check | G1, G3, G4, G5, G7, G8, G17, G18 (`last_error`) Accepted; P02b-1 | +| **P02b-3** | Consumer: durable acceptance, shared inbox/ledger DDL and `IInboxGuard`, the common per-delivery transaction, both real defaults subscriptions and Seeder handoff, independent attempts and audited terminal state, targeted internal ledger replay | G1, G6, G7 Accepted; P02b-2 | | **P02b-4** | Background jobs: the runner, its storage, the tenant-context hook, the enqueue guard, no job definition | G9 Accepted; P02b-3 | | **P02b-5** | API authentication: JwtBearer wiring and its insertion order, claim population, the host/JWT cross-check through the existing recorder, token-keyed rate limits, the 401 rows, one authenticated endpoint | G10, G11, G12 (the BFF's client), G14, G20 Accepted | | **P02b-6** | Realm and seed: the shared client scope, the mappers, the user-profile declaration, UUID attributes agreeing with `SeedData`, the warm-realm reconciler, the Keycloak test fixture | G12, G13 (the access-token lifetime's authority) Accepted; P02b-5 | @@ -294,11 +282,11 @@ premise a row cites is re-verified at that pass rather than trusted. | # | 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) | Accepted — 2026-10-12; delivery pending | +| 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` keyed by tenant/stable subscription/event, transport-claimed before construction; a separate delivery ledger owns retries, terminal state and targeted replay; completion shares the business/audit/outbox transaction | **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) | Accepted — 2026-10-12; delivery pending | | 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 under this 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, deterministic eligible selection; no enqueue/commit-order guarantee | Derivation: [Accepted ADR-0057](../decisions/0057-transactional-event-production-and-completion.md). Ordering: Accepted ADR-0046 and the P02b-2 package, followed by [§ Ordering](../architecture/15-event-and-outbox.md#ordering) alignment under this acceptance | P02b-1 (whether the key's derivation is checked structurally: the first event declares a key), P02b-2 (the ordering bound) | Derivation and ordering Accepted; ordering delivery pending 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 | Accepted ADR-0046 + dated ADR-0006 disclosure under this acceptance; Database Standards § Outbox DDL, index and grant edits | P02b-2 | Accepted — 2026-10-12; delivery pending | -| 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 under this 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 and resolution outcomes Accepted; dispatch pending 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 | 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 under this acceptance; Database Standards alignment. ADR-0046 accepts declared resolution, Pending holds and intentional absence; dispatch runtime remains P02b-2 | P02b-1 (the stored type identity and the row's identity, both written at enqueue), P02b-2 (unresolvable, retired and unsubscribed types) | Stored identities and resolution outcomes Accepted; dispatch pending 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 delivered; delivery/inbox contract Accepted, implementation pending 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`? | MUST `security-event`; producer `platform.outbox.dead_letter` and reserved P3 `platform.delivery.dead_letter`; fenced terminal transition and audit INSERT on the same short transaction through a narrow fifth write seam; failed audit retries terminalization without publication | Accepted ADR-0046 + bounded ADR-0044 audit-grant replacement, separate additive fifth-writer seam amendment and ADR-0033 same-transaction clarification under this acceptance; `DeclareOffPath` registration and Audit Coverage baseline | P02b-2 (the producer dead-letter row its criteria assert), P02b-3 | Accepted — 2026-10-12; delivery pending | | 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 | Accepted ADR-0046 explicitly replaces the routine-dispatch coverage rule and bounds the ADR-0003 grant extension; dated disclosures under this acceptance, not an ADR-0041 correction | P02b-2 | Accepted — 2026-10-12; delivery pending | @@ -312,7 +300,7 @@ premise a row cites is re-verified at that pass rather than trusted. | 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) | Event metrics Accepted — 2026-10-12; job metrics P02b-4 | | 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 under this 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 and failure descriptors Accepted; erasure inventory open 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 under this 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 | +| 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 under this acceptance. ADR-0046 accepts distinct subscription deduplication and envelope delivery scope; 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 and delivery contract Accepted; subscriptions/Seeder handoff pending P02b-3 | | 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) @@ -1446,12 +1434,45 @@ claim no runtime enforcement; delivery evidence belongs to each step below. ### P02b-2 Step 1 — Decision, schema and confined composition (2026-10-12) -**In progress.** Acceptance reconciliation is underway. No schema migration, -registry, role guard or dispatcher implementation is claimed by this record. -The six new rules are Registered; existing consumer rules remain Registered for -P02b-3. The approved schema/composition, migration and exact-grant proofs and -both independent review rounds remain pending. No runtime suite has been run -for this acceptance-only documentation change. +**Implemented; both independent review rounds pending.** Decision commit +`192810b` precedes runtime edits. Step 1 adds the Platform-chain migration +`20261012180355_add_outbox_dispatch_state` and Audit-chain migration +`20261012180433_grant_outbox_terminal_audit`. Existing migration bodies remain +unchanged. New state/identity/UTF-8 CHECKs and eligibility/recovery/terminal +indexes accompany exactly eight outbox status-column UPDATE privileges and +INSERT-only audit access. Legacy diagnostic replacement spans both tenants; +invalid legacy identities reject the migration and roll back its owner-only +backfill. Disposable-schema down/reapply preserves event facts and retains +redaction; live rollback requires a forward fix. + +The API registers a keyed, lazy, DI-owned outbox source with exact configured +username checks and physical session/current-role, attribute and membership +checks. Missing configuration fails upon resolution; Step 3's mandatory worker +startup will resolve it and prove connectivity. Seeder/web receive no new source. +The immutable contract/alias/readiness registry constructs no handlers. Real +TenantCreated has its two distinct Pending subscriptions; Active, intentional +absence, unknown and retired identities have separate controls. The two existing +connection-resolution censuses explicitly admit the approved dispatcher builder +and composition root, without wildcard exemptions. + +**Verification before review:** `CI=true` Release build has zero warnings/errors. +TRX counters and process exit codes record zero failed and zero skipped cases: + +| Suite | Passed | Scope | +|---|---:|---| +| Unit | 1,800 | Complete unit suite, including registry/composition/configuration controls | +| Architecture | 266 | Complete non-skippable suite | +| Contract | 4 | Served OpenAPI/public SDK contract suite | +| Integration, no Docker | 180 | Complete HTTP suite | +| Integration, Docker | 128 | Migration/role/schema/grant/rollback classes affected by Step 1 | + +The Docker slice includes `OutboxDispatchMigrationTests`, +`OutboxDispatcherRoleGuardTests`, `PlatformSchemaTests`, `TenancySchemaTests`, +`AuditSchemaTests` and `MigrationRollbackTests`. Role-mutation proofs own a +separate container; destructive schema probes own disposable databases. These +counts describe this step's completed checks, not a full Docker regression. +The six new catalogue rules remain Registered: transition/audit/worker proof +lands in later steps. No dispatcher execution or P02b-3 delivery is claimed. ### Durable outbox dispatch @@ -1499,43 +1520,37 @@ Dapr trigger verbatim — so it stays with Phase 11. #### The dispatcher -- **The lease.** The claim is a durable `UPDATE` stamping `locked_by` / `locked_until` - inside a short transaction; dispatch runs outside it; the claim predicate itself - reclaims an expired lease, so there is no sweeper. Every state transition carries the - ownership fence, failure included — a stale processor must not overwrite a new - claimant's success or clear its lease. -- **The migration** adds both columns *and* extends `learnstack_outbox_admin`'s - column-scoped `UPDATE` grant in the same change. ADR-0006 Amendment 2 requires that - pairing and Database Standards § Outbox already predicts the runtime failure of - omitting it: `permission denied for table`. The integration case - `PlatformSchemaTests.TheDispatcherHoldsExactlyTheFourColumnUpdateGrant` pins the - current four columns by exact equality, and is re-pinned — and renamed — here. -- **Retry** is driven by `available_after` with `attempts` and `last_error` on the row, - on the schedule - [Infrastructure Stack Standards § Outbox and Inbox](../standards/20-infrastructure-stack.md#outbox-and-inbox) - publishes. That document publishes the schedule and a maximum of five retries; **G4** - fixes what counts as an attempt against that number and what marks a row terminal, and - **G18** fixes what `last_error` may contain — both Accepted before this packet writes - the canonical DDL, which is a canonical artifact. -- **The SQL form.** The claim, the lease, the mark and the terminal transition are - parameterised commands on the dispatcher's own data source, never EF's set-based APIs - — which CLAUDE.md and `No_Set_Based_Write_Bypasses_The_Audit_Capture` forbid, and - whose `FromSqlInterpolated` cousin that rule cannot see is the other wrong answer. The - platform tables already follow this shape: their `DbContext` maps no entity types. -- **A per-delivery deadline**, so one hanging handler cannot stall dispatch for every - tenant, and so a timeout is not misread as shutdown. +- **The lease.** A committed normal claim uses a fresh canonical UUID token in + `locked_by`, database-time expiry and one just-in-time row. Every acknowledgement, + failure and terminal transition checks row ID, token and unexpired lease. + Recovery first replaces an expired token with a fresh one; it never publishes + or increments attempts. Stale callbacks cannot clear a later lease. +- **The migration** adds lease/terminal columns and exact status-column grants + together. [Database Standards § Outbox](../standards/05-database.md#outbox) owns + canonical DDL/checks/indexes. Audit-chain INSERT-only privilege enables the + separate fifth audit writer on the same confined transaction. +- **Retry** counts six committed normal claims, including claim-before-crash. + The five delays are 1 s, 5 s, 30 s, 5 min and 1 h. Failure clears the lease; + exhausted or invalid rows request terminalization without another publication. + A failed terminal MUST audit rolls back terminal state and retries only + terminalization. Stored descriptors are closed codes capped at 256 UTF-8 bytes. +- **The SQL form.** Parameterized commands use only the confined data source; + no EF materialization or set-based API owns dispatcher status. Required Pending + subscriptions and their aliases are excluded before claim/payload validation; + intentional absence is the only known-type zero-subscriber acknowledgement. +- **Deadlines.** The accepted bounds are in the P02b-2 package. Ignored + cancellation retains and observes the task, blocks its local partition and + makes health unhealthy; four retained overruns stop new claims. Late work + cannot acknowledge the row. There is no forced task termination promise. #### Ordering -`partition_key` is on the row and `InProcessEventBus` serialises per key within a -process. What a key guarantees across commits, retries and processes is **G3**: no -column obtainable from `outbox_messages` carries an unconditional per-key publish-order -guarantee. P02b-1 supplies the fact's occurrence time and EventId explicitly at -enqueue; neither establishes commit order. A failed row's `available_after` -moves past its own successor, and an unordered `RETURNING` has no defined row -order. G3's proposed selection bound is in the P02b-2 package; it is not an -enqueue-order promise. The completion criterion follows the gate, and -[§ Ordering](../architecture/15-event-and-outbox.md#ordering) is edited to match it. +ADR-0046 accepts deterministic eligible selection by +`(available_after, occurred_at, id)` within one processor. It promises no enqueue, +commit, retry, cross-process or unconditional same-key publish order. Interleaved +transactions and retry deferral are proof subjects. Partition-key derivation +remains the separately delivered ADR-0057 contract; a key alone proves no durable +sequence. See [Ordering](../architecture/15-event-and-outbox.md#ordering). #### Domain events collected and dispatched @@ -1565,78 +1580,45 @@ every terminal path clears transaction-scoped source/collector state exactly onc #### The consumer side -- **`inbox_messages` and `IInboxGuard`**, built from the canonical DDL that lands in - Database Standards § Inbox — beside § Outbox, on ADR-0006 Amendment 2's precedent, - with its table class, its policy taken by link to - [§ Table classes](../standards/05-database.md#table-classes) rather than copied, and - its grants. Today the only inbox DDL in the corpus is four columns keyed - `event_id uuid PRIMARY KEY` in an architecture document, Database Standards has no - inbox section, and ADR-0023 lists the table as DB-side-minting while Database - Standards says its key is the producing envelope's `EventId`. **G1** settles the shape - and the erratum lands with it. -- **One unit of work per `(event, consumer)` delivery**, opened by the transport: - `BeginTransactionAsync`, then `SetTenantContextAsync`, then the handler, then commit - or roll back. Both statements run **before** the handler is resolved, because a module - `DbContext` built outside the ambient transaction is refused — and the shipped - transport resolves the handler first, so this is a change to it, not a description of - it. A construction failure therefore happens inside a transaction that must roll back, - and counts as one failed attempt for that pair. -- **Tenant and organization context restored from the envelope** in every handler scope. - A consumer that runs without tenant context writes rows Row Level Security rejects, or - worse, does not. `EventTenantContext` restores from the payload's `TenantId`; the - row's `tenant_id` is the value Row Level Security checked on insert, and the two are - required to agree before dispatch. -- **The subscriber failure contract**, under **G1**. Two committed designs currently - contradict each other: this phase's per-`(event, consumer)` attempts with a terminal - state inside `inbox_messages`, and - [§ Dead-letter](../architecture/15-event-and-outbox.md#dead-letter-two-sides-two-failure-domains)'s - dead-letter destination with the inbox row deliberately left unmarked. They are - mutually exclusive, the four-column inbox cannot hold attempts or a consumer - dimension, and the requirement both are trying to meet is the same: one poisoned - consumer must not dead-letter an event that four other consumers handled cleanly. The - losing text is deleted, not left beside the winner. -- **A named owner for every unhandled delivery's next attempt.** The shipped - `InProcessEventBus` rethrows a handler failure out of `PublishAsync`, and - [Error Handling Standards § Outbox](../standards/09-error-handling.md#outbox) charges - any `PublishAsync` exception to the outbox row — so today one poisoned consumer - increments the *producer's* attempts and redelivers to every subscription. ADR-0038's - one-method port also cannot address a single subscription. Whatever G1 decides, the - record names the component that redelivers and where the per-subscription attempt - count survives the delivery transaction's rollback. -- **Dead-lettering emits its counter and writes a MUST-class audit row.** A silently - dropped integration event is a data-integrity event, not a log line. No slug, class, - catalogue source or matrix row exists for it today, and Audit Coverage Standards makes - catalogue → matrix total with no exemption, so **G7** is what makes it buildable. -- **Replay is a dispatcher operation** here: reset the dead-lettered pair and redeliver - to that one subscription through the guard, with integration tests. It runs on the - dispatcher's own connection, because no API-reachable role may `UPDATE` an outbox row - — `learnstack_platform` holds `SELECT, DELETE` — and the operator surface is Phase - 03's (§ Explicitly not in this phase). +**Accepted contract; implementation pending P02b-3.** Construction-free manifests +carry stable versioned subscription IDs, compatible aliases and required +Pending/Active readiness. Active publication durably accepts every matching +delivery without running business handlers. The independent executor owns retry +budgets, leases and terminal state in a separate delivery ledger. + +One shared `inbox_messages` is keyed by `(tenant_id, subscription_id, event_id)`. +The transport opens the app-role unit of work, announces envelope tenant and +organization, then claims before handler construction. Completed duplicates skip +construction. Business state, completed inbox, produced outbox, MUST audit and +fenced ledger completion share one physical transaction. Rollback removes a new +claim; completed inbox rows never reset. Ordinary delivery uses SystemActor and +refuses privileged work; Phase 03 owns erasure's separate audited boundary. + +Timeout poisons the business owner, preventing late success. An uncertain COMMIT +is Indeterminate, including the final claim; atomic ledger/inbox state decides +recovery. The bypass scheduler sees only enumerated identity/routing/status +columns, never envelope payload. P02b-3 supplies canonical DDL, exact RLS/grants, +capture exclusions and the ADR-0023 minting correction before runtime. + +A poisoned subscription leaves siblings untouched. Its fenced terminal state and +Tenancy-owned `platform.delivery.dead_letter` MUST security-event commit together. +Internal replay resets only that ledger pair and invokes its executor, never +`PublishAsync`. Phase 03 retains producer reset and operator permission/reason, +fencing and surface contracts. Phase 11 retains purge intervals and broker +binding. External effects need subscription/event idempotency or an outbox intent. #### Observability [Observability Standards § Required Metrics](../standards/10-observability.md#required-metrics) -owns metric names and -[Events and Outbox § Observability](../architecture/15-event-and-outbox.md#observability) -specifies the seven this subsystem needs. This phase emits all seven in those spellings -and registers the four Observability Standards does not yet carry — the producer-side -dead-letter counter, the lost-lease counter, the inbox dedup counter and the -subscriber-side dead-letter counter. It re-spells none of the three that document -already holds, and the only names it adds beyond the seven are the two liveness signals -below, registered in the same place under **G17**. - -- The two dead-letter counters stay separate signals on separate dashboards, for the - reason `architecture/15` gives: a producer-side spike means the transport is down, a - subscriber-side spike means a handler or a payload is broken. -- A pending **count** is not a **lag**. A terminal row keeps `processed_at IS NULL`, so - a naive count includes rows that will never be scheduled again, and a small stuck - queue is worse than a large one draining. An oldest-eligible-row age metric lands with - the dispatcher, and the alerting row that reads a raw count is corrected with it. -- Dispatcher liveness is an `outbox` health check plus a heartbeat gauge, following - `audit`'s registered-not-mapped precedent — not the job scheduler's surface. -- Label sets are settled by **G17** before the first series ships, in the decision pass - of `P02b-2`: a label added afterwards touches every emitter, and a series shipped with - a guessed label cannot be renamed once a dashboard and an alert read it. +is the sole inventory of names and dimensions. P02b-2 supplies producer duration, +failure, terminal and lease-loss diagnostics, immutable backlog/eligible-age/ +deferred/heartbeat snapshots and registered `outbox` health. No metric has a +tenant label; event-type labels come only from the bounded registry. Required +Pending rows, completed and terminal rows are excluded from normal eligibility. +P02b-3 owns independent subscriber terminal/dedup signals and minimizes the old +transport diagnostics. Separate producer/subscriber failure signals describe +separate budgets. Health is registered, not mapped; `/healthz` stays liveness and +Phase 11 owns `/readyz` and production alert windows. ### Background jobs @@ -1880,8 +1862,9 @@ 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 +Under ADR-0046, P02b-3 supplies independent Customization subscriptions for the +`card@1` content type and `plain@1` taxonomy. 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. @@ -1907,7 +1890,7 @@ points it does not reach, and both of Packet 9's hand-offs land here. **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 + it in production delivery under ADR-0046. 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 @@ -1935,20 +1918,18 @@ points it does not reach, and both of Packet 9's hand-offs land here. 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 - transition and insert the row on one transaction: `learnstack_app` has no `UPDATE` on - `outbox_messages` and `learnstack_outbox_admin` has nothing at all on `audit_log`. The - record names the class, the slug, the transaction, the ordering against the state - change and the failure posture; the slugs are registered off-path and carry their - matrix rows in the module that writes them. Poisoned-job handling inherits the same - shape. -- **The dispatcher's `BYPASSRLS` posture**, under **G8**. Audit Coverage Standards - requires a `security-event` row on every platform-bypass invocation and lists RLS - bypass as MUST; the only exemption is a sentence in an architecture document, which - cannot grant it. A per-invocation row on a 200 ms polling loop is a different - table-growth curve from a per-request one, and this is the first `BYPASSRLS` - credential that actually runs. +- **Dead-letter transitions — Accepted ADR-0046.** Producer terminal state and + Tenancy-owned `platform.outbox.dead_letter` MUST security-event commit on one + short confined-role transaction. `WriteEventTerminalAsync` validates its + restricted `EventTerminalTransition` witness against the actual fenced UPDATE + and draft identity. Audit failure rolls back state and retries terminalization, + without another publication. The corresponding subscriber operation remains + P02b-3. Register each planned off-path slug only with its writer. +- **Routine dispatcher bypass — Accepted ADR-0046.** The confined credential, + exact grants, bounded diagnostics and atomic terminal audit explicitly exempt + routine polls/claims/acknowledgements/retries from per-invocation security audit. + Existing `EnterPlatformAdminScope` entry audit and all other bypass obligations + remain unchanged. This is no general background-job exemption. - **The rejected-assertion row names its actor once there is one.** The recorder writes a null actor on the authenticated tier today, because no principal exists in the process and `IsAuthenticated` is a tier rather than an identity. ADR-0036 makes the @@ -2080,16 +2061,18 @@ command. The phase retains zero skips, per assembly. ## Deliverables -- **ADR-0046 Accepted** — event delivery, the inbox table's canonical shape, and the - subscriber failure contract as one state machine: subscription identity, attempts, - backoff, cancellation, terminal transitions and replay, with the losing design - reconciled out of its carriers. +- **ADR-0046 Accepted** — event delivery, the shared inbox contract and separate + delivery + ledger: stable subscription identity, readiness, durable acceptance, attempts, + cancellation, terminal transitions and targeted replay. Canonical inbox/ledger + DDL remains P02b-3 work; acceptance claims no consumer runtime. - **ADR-0047 Accepted** — the background-job runtime, with the dated ADR-0003 Amendment its storage decision owes. - **The dated amendments the register names**, each Accepted before the code it governs: - ADR-0044 / ADR-0033 / ADR-0040 for the delivery and domain-event frames (G6), ADR-0033 - for the dead-letter writer (G7), ADR-0006 or ADR-0003 for the dispatcher's bypass - posture (G8), ADR-0006 for the retry contract (G4), ADR-0036 for the matrix staging + ADR-0044 / ADR-0033 / ADR-0040 for the delivery and domain-event frames (G6), + ADR-0046 with ADR-0044/0033 disclosures and fifth-writer seam + for terminal audit (G7), ADR-0046 with ADR-0003/0006 disclosures for bounded + bypass/retry (G8/G4), ADR-0036 for the matrix staging (G11), ADR-0032 for `LS0002` (G16), ADR-0038 for the payload-content rule (G18). - **Database Standards § Inbox** — the canonical inbox DDL beside § Outbox, with its table class, its policy taken by link and its grants; plus the erratum ADR-0023's @@ -2098,9 +2081,10 @@ command. The phase retains zero skips, per assembly. [Phase 11](phase-11-production-hardening.md) picks the purge interval that honours it. - `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. +- `TenantCreatedIntegrationEventV1` from completed provisioning; P02b-3 + independent Customization subscriptions install untouched `card@1` content-type + and `plain@1` taxonomy 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 @@ -2114,10 +2098,11 @@ command. The phase retains zero skips, per assembly. - `inbox_messages` and `IInboxGuard` over the canonical DDL, the per-`(event, consumer)` delivery transaction opened before handler construction, and tenant and organization context restored in every handler scope from the envelope. -- A subscriber-side dead-letter path in the shape G1 settles: a terminal outcome per +- A subscriber-side ledger and terminal path accepted by ADR-0046: a terminal + outcome per subscription, a sanitized failure descriptor, its counter, its MUST-class audit row, - and a replay that re-enters the inbox-guarded path — as a dispatcher operation, with - the operator surface deferred to Phase 03. + and targeted internal replay through the executor/inbox boundary. Producer + reset and operator permission/reason/fencing remain Phase 03 contracts. - A worked sample flow — enqueue → outbox → dispatch → idempotent consumption — placed where G19 settles, between modules that hold domain code, so the module-scoped sweeps have a subject. @@ -2131,12 +2116,10 @@ command. The phase retains zero skips, per assembly. - The background-job runner, its storage on the path ADR-0047 names, its tenant-context hook, its enqueue-time guard over four fields, its package pins — and no job definition and no dashboard route. -- The `outbox` health check registered and not mapped, plus six rows in - [Observability Standards § Required Metrics](../standards/10-observability.md#required-metrics) - — the four event counters in the spellings - [Events and Outbox](../architecture/15-event-and-outbox.md#observability) already - uses, and the dispatcher heartbeat gauge and the oldest-eligible-row age metric under - **G17**. +- The registered, unmapped `outbox` health check and accepted producer event + metrics in + [Observability Standards](../standards/10-observability.md#required-metrics), + including bounded immutable snapshots. Subscriber signals remain P02b-3. - `UseAuthentication` and `UseAuthorization` at their two pinned positions, the claim signal populated at the resolver's reserved site, one JWT authority for `/api/v1/*` with its full validation contract written into @@ -2223,23 +2206,17 @@ shape depends on an open gate say so, and are written when that gate is Accepted activity's trace, whose `tenant_id` and `organization_id` equal the announced context, and whose `partition_key` equals the event's — and the assertion fails on a null `partition_key`. -- Two `OutboxProcessor` instances draining one pending batch, **both holding unexpired - leases**, publish each message once; the claim `UPDATE` succeeds as - `learnstack_outbox_admin` and fails with `permission denied for table` against the - pre-extension grant. A companion of the same harness against a release-on-read claim - sees duplicates. -- A lease that expires mid-dispatch is reclaimed, the first processor's success **and** - failure writes are both no-ops under the ownership fence, the lost-lease counter - increments, and the consumer sees one business effect. -- A producer dead-letter writes exactly one MUST-class row under the slug **G7** names, - and a forced failure of that write produces the behaviour G7 chose — the transition - unmade and retried, or the duplicate that ordering accepts. The subscriber side - already has both halves; the producer side needs them more, because its transition and - its audit row are made by two roles. -- A failed dispatch retries on the published backoff with its lease cleared — so the - first retry runs at its stated delay rather than at the lease duration — reaches the - terminal state G4 names at the stated attempt, increments the producer dead-letter - counter, and leaves the pending gauge. +- Two coordinators on one PostgreSQL store force an interleaving with at most + one live owner. Claims use the exact confined-role grant and fresh acquisition + tokens; this proves neither two OS processes nor exactly-once publication. +- Expiry/recovery replaces the token, and stale success/failure/terminal writes + affect zero rows. The six-claim crash case never publishes a seventh time. +- Producer terminal state and exactly one `platform.outbox.dead_letter` MUST + security-event commit atomically. Audit refusal rolls back terminal state and + permits only terminalization retry; no second publication consumes a budget. +- Retry uses the five accepted delays, clears the lease and stores only closed + bounded descriptors. Terminal requests and exhausted rows leave normal + eligibility; successful terminalization leaves backlog snapshots. - A blocked dispatcher loop turns the `outbox` health check unhealthy within a bounded number of poll intervals, while `/healthz` still answers 200. - With one terminal row, one row whose `available_after` is in the future and one due @@ -2250,53 +2227,43 @@ shape depends on an open gate say so, and are written when that gate is Accepted value names its key and the expected form; a valid one registers without connecting; and no message carries the password. The four cases already exist for the application data source and are copied rather than re-derived. -- A payload written by a shipped event's V1 is read by V2-deployment code without loss; - a row naming a retired type dead-letters at once under its named reason; a dispatched - event with no registered subscription marks its row processed and increments no - failure counter. -- The same-key ordering criterion is written when **G3** is Accepted. Whatever it - asserts, it runs interleaved producing transactions — the first to begin commits last - — because a same-key pair written with a wide clock gap by one processor is green - against a dispatcher that reorders whenever two producing transactions overlap. - -**Consumer side** - -- One envelope delivered twice, sequentially and then concurrently, yields one business - row and one processed inbox record, and the dedup counter increments once; the same - case with the guard check removed fails. -- A handler that marks the inbox and then throws commits neither, and the next - redelivery writes exactly once. -- A fan-out where consumer 1 commits and consumer 2 throws leaves consumer 1's write - committed, consumer 2's rolled back, consumer 2's failure recorded durably **after** - that rollback, and exactly one of the two retry budgets advanced. The test names the - component that owns consumer 2's next attempt. -- A handler that throws on every delivery reaches the terminal state for **that consumer - only**, leaves the other consumers of the same event unaffected, accumulates no - attempts on the producer's row, increments the subscriber dead-letter counter, and - produces exactly one MUST-class audit row under the slug G7 names. -- A reset `(event, consumer)` pair is redelivered to that subscription and to no other, - and a late success racing the reset is absorbed by the guard. -- The same event delivered to two handlers in one module runs both, and neither one's - inbox record makes the other skip. -- An event of tenant B published during tenant A's request is handled with tenant B - resolved **and** `app.tenant_id` set to B, before the handler is constructed; the - handler's write is visible to B and invisible to A; and a transport variant that skips - the context statement fails the same case. No shipped case asserts `app.tenant_id`; - `InProcessEventBusTests` proves the accessor and the scoped context in-process only. -- A consumer whose constructor takes a module write store resolves — true only if the - delivery transaction is open before the handler is resolved. -- Each module-assembly event sweep reports a non-zero subject count as its own leg, so a - sample flow that lives only in a test assembly fails the phase rather than passing - three rules over nothing. -- A tenant-wide event published from an organization-scoped request is consumed with the - organization scope **G1** settles, and its consumer can write the rows that scope - permits rather than failing obscurely. -- An inbox record written under tenant A is invisible to a `learnstack_app` session - announcing tenant B, and all module migration chains still apply together on an empty - database. -- A dispatch failure whose exception message embeds a value the sensitive-token - catalogue matches leaves no trace of it in any persisted failure field, and the length - bound refuses a longer value. +- Declared compatible aliases deserialize through frozen payload options; + unknown/retired/invalid rows terminalize under closed reasons. Pending required + subscriptions, including aliases, preserve rows byte-exact before claim and + validation. Only intentional absence acknowledges without publication. +- Deterministic eligible selection is tested with interleaved commits and retry + deferral; no unconditional same-key publish-order assertion is retained. +- Overrun tasks are retained/observed, block local partitions and make health + unhealthy. Unrelated work progresses until the cap of four stops new claims; + late completion never acknowledges the row. + +**Consumer side — P02b-3** + +- Sequential/concurrent duplicates yield one completed inbox/business effect per + stable subscription/event. Distinct sibling subscriptions both execute. A + completed duplicate skips handler construction; removing transport guard + acquisition makes the proof fail. +- Faulted business work commits no business/inbox/outbox success. A poisoned + subscriber advances only its own durable budget after rollback, leaves producer + and healthy siblings unaffected and terminalizes atomically with its MUST audit. +- Targeted internal replay invokes only its executor; completed inbox state is + never reset. Late work cannot defeat the new fence. +- Envelope tenant and organization are announced before resolving handlers, + stores or module contexts. SystemActor is effective identity; a human actor is + causal metadata only. Non-marked event types preserve envelope organization. +- Construction failure and cancellation count under the subscriber policy. A + timeout poisons the owner; uncertain physical COMMIT, including on the final + claim, resolves from atomic ledger/inbox completion without repeating success. +- Every production module-assembly sweep reports its nonzero subjects. Both real + Customization defaults subscriptions, one-root audits, authored/lifecycle + preservation and semantic key/version Seeder handoff pass through the actual + delivery boundary. +- Inbox/ledger RLS isolates tenants; scheduler column-only grants cannot read + payload or perform business writes. Migration chains apply together. Ordinary + app-role execution refuses privileged work and external effects require their + own idempotency or outbox intent. +- Persisted diagnostics reject raw exception/payload/identity text and values over + 256 UTF-8 bytes; no sensitive token leaks through retained failure descriptors. **Audit** @@ -2317,9 +2284,10 @@ shape depends on an open gate say so, and are written when that gate is Accepted 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. +- Ignored cancellation poisons the owner and blocks late success. Retained tasks + remain observed and hold their local partition; unrelated work progresses + within the accepted cap. An uncertain physical COMMIT is Indeterminate and + recovers from atomic durable state rather than assuming rollback. - A frame whose captures name an aggregate root that no intent in the frame declares produces the behaviour G6 records. - A consumer handling an event caused by user U writes a row whose metadata names U and @@ -2433,12 +2401,10 @@ shape depends on an open gate say so, and are written when that gate is Accepted analyzer. - The phase's authenticated endpoint appears in the generated OpenAPI document and in the regenerated SDK surface. -- Every metric this phase emits is spelled as its owner spells it — the seven names - [Events and Outbox § Observability](../architecture/15-event-and-outbox.md#observability) - already carries, and the two liveness names **G17** registers in - [Observability Standards § Required Metrics](../standards/10-observability.md#required-metrics) - — asserted against literal strings taken from that table rather than against the - emitting type's own constant, which is true by construction. +- Every event metric uses the literal name and bounded dimensions in + [Observability Standards](../standards/10-observability.md#required-metrics). + Gauge snapshots agree atomically; Pending, deferred, terminal and normal + eligibility are tested as distinct states. Job dimensions remain G9/G17 work. - **No row in the [catalogue](../standards/21-architecture-tests-catalogue.md) whose Phase names 02b is still Registered or Awaiting backfill.** Structural rows are green in the `backend` check and behavioural rows in the `backend integration` check, which @@ -2469,21 +2435,16 @@ shape depends on an open gate say so, and are written when that gate is Accepted reproduces the failure, and a single-processor run of the concurrency test passes against the broken protocol. The mitigation is two processors with a forced interleaving, plus the lease-expiry companion, landing **with** the fix. -- **The partition key is persisted but meaningless.** - [§ Ordering](../architecture/15-event-and-outbox.md#ordering) admits two shapes: the - aggregate id for an event about an instance, and `TenantId` chosen deliberately for a - tenant-wide fact. The failure modes are the other two — a key derived from `EventId`, - which is a partition of one, and a per-type default, which is one partition for every - tenant. The catalogued rule asserts non-blankness and detects neither, so **G3** asks - whether the derivation is checkable structurally, and the ordering criterion it - settles is what observes order. -- **Dead-lettering becomes invisible.** A terminal state that appears only in a database - column is silent data loss. The counter and the audit row are part of the deliverable, - and the audit row has no sanctioned writer until G7 closes — which is why G7 blocks - the packet rather than trailing it. -- **The two dead-letter designs are both implemented.** They contradict each other in - the two carriers that describe them, so an implementer who reads one and a reviewer - who reads the other both pass. G1 picks one and deletes the other. +- **Partition identity is mistaken for durable order.** ADR-0057 enforces the + declared aggregate/tenant key. ADR-0046 separately bounds deterministic eligible + selection; interleaved commits and deferred retries must remain proof subjects. +- **Terminal audit is detached from state.** The witness must come from the + actual fenced transition and bind draft tenant/organization/event. A failed + MUST insert rolls back terminal state and retries no business publication. +- **Consumer delivery leaks into the producer budget.** P02b-3 must durably accept + before acknowledgement and use its separate executor/ledger. Shared inbox + deduplication uses stable subscription identity, preserving healthy siblings + and completed outcomes during targeted replay. - **The Keycloak mapper regresses, or never reaches a warm workstation.** Realm JSON is regenerated by hand and by the admin console, and a mapper is easy to lose; worse, the import is consumed once, so a fresh-container test is green while every existing @@ -2538,9 +2499,9 @@ shape depends on an open gate say so, and are written when that gate is Accepted - The host-vs-JWT cross-check returns 404 and writes one row under Packet 9's existing operation key with a non-null actor and `metadata.assertionSource = jwt-claim`. - The sample integration event flows enqueue → outbox → dispatch → idempotent - consumption; two dispatchers holding live leases publish each message once; and a - **forced redelivery** produces one business effect — the check that exercises the - guard, which "no duplicate handling" alone does not. + consumption. Forced interleaving proves one live producer owner; publication + stays at least once. **Forced redelivery** produces one committed business + effect per subscription through the actual shared inbox boundary. - A poisoned consumer dead-letters visibly instead of looping: the terminal state for that consumer only, its counter, and its audit row. - A MUST-classified direct-write consumer produces exactly one row for its delivery, and diff --git a/docs/standards/05-database.md b/docs/standards/05-database.md index 736a8b03..33b9f147 100644 --- a/docs/standards/05-database.md +++ b/docs/standards/05-database.md @@ -28,6 +28,8 @@ 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). +Delivery/recovery additions derive from +[ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md). PostgreSQL schema, EF Core, and migration conventions. @@ -886,7 +888,7 @@ closed; a fifth role requires an ADR. | `learnstack_migration` | `ConnectionStrings:Migration` | the deploy job / `make migrate` only — **never** in API or worker runtime configuration | `dotnet ef database update`; **owns** every table | `NOBYPASSRLS`; `FORCE ROW LEVEL SECURITY` denies ownership any bypass | | `learnstack_app` | `ConnectionStrings:Default` | API host, worker host | every request-path and job-path `DbContext` | `NOBYPASSRLS` | | `learnstack_platform` | `ConnectionStrings:PlatformAdmin` | API host, worker host | `EnterPlatformAdminScope(reason)` and nothing else | `BYPASSRLS` | -| `learnstack_outbox_admin` | `ConnectionStrings:OutboxDispatcher` | the worker host that runs `OutboxProcessor` | `OutboxProcessor` and nothing else | `BYPASSRLS` | +| `learnstack_outbox_admin` | `ConnectionStrings:OutboxDispatcher` | API host only; never Seeder or web | confined dispatcher infrastructure only | `BYPASSRLS` | **Passwords arrive from the environment, through `\getenv`.** The four `:'name'` placeholders below are **psql client variables**, and the @@ -992,18 +994,28 @@ privileges implicitly. | `platform_host_to_tenant` | `SELECT, INSERT, UPDATE, DELETE` | `SELECT, INSERT, UPDATE, DELETE` | — | | `platform_killswitches` | `SELECT` | `SELECT, INSERT, UPDATE, DELETE` | — | | `idempotency_keys` | `SELECT, INSERT, UPDATE` | `SELECT, DELETE` | — | -| `outbox_messages` | `SELECT, INSERT` | `SELECT, DELETE` | `SELECT`, `UPDATE (processed_at, attempts, last_error, available_after)` | +| `outbox_messages` | `SELECT, INSERT` | `SELECT, DELETE` | `SELECT`, `UPDATE (processed_at, attempts, last_error, available_after, locked_by, locked_until, terminal_reason, dead_lettered_at)` (P02b-2 Step 1) | | `tenant_content_types` | `SELECT, INSERT, UPDATE, DELETE` | `SELECT` | — | | `tenant_level_taxonomies` | `SELECT, INSERT, UPDATE, DELETE` | `SELECT` | — | | `tenant_level_taxonomy_items` | `SELECT, INSERT, UPDATE, DELETE` | `SELECT` | — | | `customization_generations` | `SELECT, INSERT, UPDATE` | `SELECT` | — | -| `audit_log` | `SELECT, INSERT` | `SELECT, INSERT, DELETE`, `UPDATE (actor_email, ip_address, user_agent, before_state, after_state, changes)` | — | +| `audit_log` | `SELECT, INSERT` | `SELECT, INSERT, DELETE`, `UPDATE (actor_email, ip_address, user_agent, before_state, after_state, changes)` | `INSERT` only (P02b-2 Step 1) | | `audit_config` | `SELECT` | `SELECT` | — | | `courses` | `SELECT, INSERT, UPDATE` | `SELECT` | — | | `lessons` | `SELECT, INSERT, UPDATE` | `SELECT` | — | | `course_translations` | `SELECT, INSERT` | `SELECT` | — | | `lesson_translations` | `SELECT, INSERT` | `SELECT` | — | +ADR-0046 accepts the two P02b-2 cells above. Step 1 implements their migrations +and exact-grant proofs; both independent review rounds remain pending in the +[delivery record](../roadmap/phase-02b-events-auth.md#p02b-2-step-1--decision-schema-and-confined-composition-2026-10-12). +Outbox status grants land in the Platform chain. The audit +INSERT grant lands in a new Audit-chain migration after `audit_log` exists, never +in initdb. No audit SELECT/UPDATE/DELETE, business access or role membership is +added. P02b-3 separately enumerates column-level scheduling grants on its delivery +ledger; it grants no envelope/payload SELECT to the bypass scheduler and only +RLS-protected acceptance/completion permissions to `learnstack_app`. + The four Education grants are **implemented in P02d-1 Step 2** and checked against the applied schema. Root UPDATE supports publication and concurrency; satellite UPDATE/DELETE and root DELETE have no Phase 02d command and are not @@ -1380,6 +1392,20 @@ Public read models consumed by other modules: ## Outbox +**ADR-0046 schema implemented in P02b-2 Step 1; reviews pending.** The canonical +DDL below includes lease/terminal/identity/descriptor changes. Dispatcher execution +and terminal auditing remain later P02b-2 steps. Existing +migrations remain immutable; a new Platform-chain migration replaces free-form +legacy `last_error` with `migration:legacy:redacted` before its byte CHECK. +This specific ADR-0046 Platform migration temporarily uses owner-only `NO FORCE` +for that platform-wide diagnostic backfill, then restores `FORCE` before commit. +The migration is atomic: any failure rolls back both the data change and the +security setting. It widens no runtime policy, role or grant and does not change +the per-tenant data-migration rule above. Invalid legacy identities fail the whole +migration for explicit repair; facts/state are not silently rewritten. Existing +attempts above six remain representable and route +to terminalization, never another normal publication. + ```sql CREATE TABLE outbox_messages ( id uuid PRIMARY KEY DEFAULT uuidv7(), -- producer supplies EventId @@ -1397,15 +1423,47 @@ CREATE TABLE outbox_messages ( processed_at timestamptz NULL, attempts int NOT NULL DEFAULT 0, last_error text NULL, - available_after timestamptz NOT NULL DEFAULT now() + available_after timestamptz NOT NULL DEFAULT now(), + locked_by text NULL, -- fresh canonical UUID acquisition token + locked_until timestamptz NULL, + terminal_reason text NULL, + dead_lettered_at timestamptz NULL, + CONSTRAINT ck_outbox_messages_identity CHECK ( + id <> '00000000-0000-0000-0000-000000000000'::uuid + AND tenant_id NOT IN ('00000000-0000-0000-0000-000000000000'::uuid, + '00000000-0000-7000-8000-000000000002'::uuid) + AND (organization_id IS NULL OR + organization_id <> '00000000-0000-0000-0000-000000000000'::uuid)), + CONSTRAINT ck_outbox_messages_attempts CHECK (attempts >= 0), + CONSTRAINT ck_outbox_messages_last_error_size CHECK ( + last_error IS NULL OR octet_length(last_error) <= 256), + CONSTRAINT ck_outbox_messages_lease CHECK ( + (locked_by IS NULL AND locked_until IS NULL) OR + (locked_by IS NOT NULL AND length(locked_by) > 0 AND locked_until IS NOT NULL)), + CONSTRAINT ck_outbox_messages_terminal_reason CHECK ( + terminal_reason IS NULL OR terminal_reason IN ( + 'unknown_type', 'retired_type', 'invalid_envelope', 'retry_exhausted')), + CONSTRAINT ck_outbox_messages_state CHECK ( + (processed_at IS NULL OR + (dead_lettered_at IS NULL AND terminal_reason IS NULL AND locked_by IS NULL)) + AND (dead_lettered_at IS NULL OR + (processed_at IS NULL AND terminal_reason IS NOT NULL AND locked_by IS NULL))) ); CREATE INDEX ix_outbox_messages_pending - ON outbox_messages (available_after) - WHERE processed_at IS NULL; + ON outbox_messages (available_after, occurred_at, id) + WHERE processed_at IS NULL AND dead_lettered_at IS NULL + AND terminal_reason IS NULL AND attempts < 6 AND locked_by IS NULL; CREATE INDEX ix_outbox_messages_tenant_pending ON outbox_messages (tenant_id, available_after) - WHERE processed_at IS NULL; + WHERE processed_at IS NULL AND dead_lettered_at IS NULL; +CREATE INDEX ix_outbox_messages_recovery + ON outbox_messages (locked_until, occurred_at, id) + WHERE processed_at IS NULL AND dead_lettered_at IS NULL + AND terminal_reason IS NULL AND locked_by IS NOT NULL; +CREATE INDEX ix_outbox_messages_terminal + ON outbox_messages (available_after, occurred_at, id) + WHERE processed_at IS NULL AND dead_lettered_at IS NULL AND terminal_reason IS NOT NULL; ALTER TABLE outbox_messages ENABLE ROW LEVEL SECURITY; ALTER TABLE outbox_messages FORCE ROW LEVEL SECURITY; @@ -1425,7 +1483,8 @@ CREATE POLICY outbox_messages_isolation ON outbox_messages GRANT SELECT, INSERT ON outbox_messages TO learnstack_app; GRANT SELECT, DELETE ON outbox_messages TO learnstack_platform; GRANT SELECT ON outbox_messages TO learnstack_outbox_admin; -GRANT UPDATE (processed_at, attempts, last_error, available_after) +GRANT UPDATE (processed_at, attempts, last_error, available_after, + locked_by, locked_until, terminal_reason, dead_lettered_at) ON outbox_messages TO learnstack_outbox_admin; -- The OutboxProcessor connects as learnstack_outbox_admin, which holds BYPASSRLS so it @@ -1434,10 +1493,9 @@ GRANT UPDATE (processed_at, attempts, last_error, available_after) -- learnstack_app, inside the business transaction, and WITH CHECK pins the row to the -- caller's tenant. BYPASSRLS bypasses policies, not GRANTs — the column list above is -- what actually bounds the dispatcher, and SELECT ... FOR UPDATE SKIP LOCKED works with --- a column-level UPDATE grant, so no table-wide UPDATE is needed. When locked_by and --- locked_until land in Phase 02b, that migration extends this grant; a column added --- without extending it fails at runtime with `permission denied for table`. --- See ADR-0006 Amendment 1 and 15-event-and-outbox.md. +-- a column-level UPDATE grant, so no table-wide UPDATE is needed. The P02b-2 migration +-- adds the status columns and their grants together. INSERT-only on audit_log belongs +-- in the Audit chain, not this DDL. See ADR-0046 and 15-event-and-outbox.md. ``` [ADR-0057](../decisions/0057-transactional-event-production-and-completion.md) @@ -1454,8 +1512,8 @@ 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. +producer boundary. ADR-0046 accepts producer recovery and the shared inbox +contract; producer runtime remains P02b-2, inbox/delivery runtime P02b-3. `organization_id` is `uuid NULL` and mirrors the organization context of the transaction that enqueued the row — @@ -1485,6 +1543,36 @@ on developer discipline. The earlier justification for nullability — that APIS so its contract cannot rest on a component that is not there yet. The architecture deep dive lives in [15-event-and-outbox.md](../architecture/15-event-and-outbox.md). + +Normal publication eligibility requires no processed/dead-lettered/terminal-request +state, fewer than six attempts, due availability, no lease awaiting recovery and +manifest/local-partition admission. Required Pending contracts and aliases are +excluded before acquisition and payload validation. Every transition checks ID, +current token and database-time unexpired lease. Recovery first replaces an +expired token; attempts count committed normal claims only. Terminalization +claims without incrementing attempts or publishing, then writes terminal state +and its MUST audit atomically. See +[ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md#producer-dispatch-and-recovery). + +## Inbox + +**Accepted contract; DDL/grants pending P02b-3.** One shared tenant-owned +`inbox_messages` deduplicates by `(tenant_id, subscription_id, event_id)`. +The transport's uncommitted claim precedes handler construction; completed inbox, +business state, produced outbox, MUST audit and fenced ledger completion share +one announced app-role transaction. Rollback removes a new claim; completed rows +are never reset by targeted replay. A separate delivery ledger owns attempts, +leases, terminal/replay state and retained validated envelopes. + +P02b-3 supplies canonical DDL, table-class assignments, exact RLS/grants and +ADR-0023's minting-list correction before runtime. It first chooses durable +envelope copies/references and their retention dependency; P02b-8 inventories +every retained store for erasure. The bypass scheduler receives only enumerated +identity/routing/lease/status column SELECT and status UPDATE, never payload +SELECT; the app role receives tenant-RLS-protected acceptance/completion, +including the named completion-column UPDATE, never cross-tenant scheduling. +The canonical RLS templates remain in [Table classes](#table-classes). + ## Idempotency `idempotency_keys` is the schema the durable `IIdempotencyStore` will read and diff --git a/docs/standards/09-error-handling.md b/docs/standards/09-error-handling.md index e2546573..5bfff218 100644 --- a/docs/standards/09-error-handling.md +++ b/docs/standards/09-error-handling.md @@ -8,6 +8,8 @@ Public UI message mapping derives from [ADR-0027](../decisions/0027-frontend-i18 and the [Accepted P02d-6 package](../roadmap/phase-02d-walking-skeleton.md#p02d-6-decision-package-2026-10-09). Bootstrap response ownership and renderer Retry-After qualification derive from [ADR-0055](../decisions/0055-public-renderer-bootstrap-failures.md). +Delivery/recovery additions derive from +[ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md). How LearnStack represents, propagates, surfaces, and recovers from failures. @@ -562,9 +564,28 @@ exception types (`LiveKit.NET.LiveKitException`, `Stripe.StripeException`, ## Outbox -- Outbox dispatcher catches all exceptions from a handler, logs them, increments `attempts`, sets `available_after = now() + backoff`. -- Poison messages (max attempts reached) are dead-lettered. -- Dashboard alert when dead-letter count > 0. +**Accepted ADR-0046; runtime delivery pending P02b-2/P02b-3.** + +- Each committed normal claim increments producer `attempts` once, including an + unknown crash outcome. Six total claims use retry delays 1 s, 5 s, 30 s, 5 min + and 1 h. Expired claims recover with fresh tokens; claim six cannot publish again. +- Required Pending subscriptions preserve rows before acquisition/validation. + Only declared intentional absence may acknowledge without publication. +- Unknown/retired/invalid records request explicit terminalization. Terminal + requests never publish; terminal UPDATE and MUST audit share one transaction. + Audit refusal retains terminal work for retry and makes health unhealthy. +- Every success/failure/recovery/terminal transition is token- and expiry-fenced. + A timeout bounds the wait; cancel and retain the task-owned scope until actual + completion. Late results cannot acknowledge or overwrite a new owner. +- Persist only closed stage/reason/category codes, optional allowlisted SQLSTATE + and validated attempt traceparent in `last_error`, at most 256 UTF-8 bytes. + Dispatcher logs/traces likewise exclude exception text/stacks, payloads, raw + stored type and connection data. Existing transport exception telemetry is a + P02b-3 reconciliation obligation before Active production consumers. +- After P02b-3 durable acceptance, subscriber business failure spends only its + independent ledger budget. Failed/unknown acceptance still spends producer + attempts. Subscriber retry invokes one internal delivery executor, never a + broadcast; operator recovery routes and producer reset/fencing are Phase 03. ## Webhooks diff --git a/docs/standards/10-observability.md b/docs/standards/10-observability.md index c1883972..0504bde0 100644 --- a/docs/standards/10-observability.md +++ b/docs/standards/10-observability.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) (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). +Delivery/recovery additions derive from +[ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md). Three signals — logs, traces, metrics — bound by a single correlation id. Everything we ship is observable from day one. @@ -151,7 +153,7 @@ Auto-instrument: - Entity Framework Core queries. - MediatR commands and queries. - Hangfire job invocations. -- Outbox dispatcher batches. +- Outbox dispatcher attempts (P02b-2 target; no batch span). - Valkey client calls. - SeaweedFS/S3 SDK calls. - LiveKit provider calls. @@ -223,9 +225,16 @@ backend treat business rejections as system failures. | `learnstack_http_request_duration_seconds` | histogram | route, method, status | | `learnstack_http_request_total` | counter | route, method, status | | `learnstack_db_query_duration_seconds` | histogram | module, operation | -| `learnstack_outbox_pending_count` | gauge | tenant | +| `learnstack_outbox_pending_count` | gauge | — | | `learnstack_outbox_dispatch_duration_seconds` | histogram | event_type | | `learnstack_outbox_dispatch_failed_total` | counter | event_type | +| `learnstack_outbox_deadletter_total` | counter | event_type | +| `learnstack_outbox_lease_lost_total` | counter | — | +| `learnstack_outbox_oldest_eligible_age_seconds` | gauge | — | +| `learnstack_outbox_deferred_count` | gauge | — | +| `learnstack_outbox_heartbeat_age_seconds` | gauge | — | +| `learnstack_inbox_dedup_total` | counter | module, event_type (P02b-3 reserved) | +| `learnstack_inbox_deadletter_total` | counter | module, event_type (P02b-3 reserved) | | `learnstack_job_duration_seconds` | histogram | job_name | | `learnstack_job_failed_total` | counter | job_name | | `learnstack_provider_request_duration_seconds` | histogram | provider, operation, outcome | @@ -244,6 +253,27 @@ backend treat business rejections as system failures. | `learnstack_entitlement_source_total` | counter | `source` | | `learnstack_entitlement_unresolved_total` | counter | — | +Outbox metrics above are Accepted under ADR-0046 and pending P02b-2; inbox +counters remain P02b-3 and job metrics P02b-4. `event_type` comes only from the +finite declared registry plus fixed unknown/retired/invalid categories. Never +label event metrics by tenant, event, partition, subscription or trace identity. + +Pending count includes all unprocessed, non-dead-lettered rows, including delayed, +leased, terminal-requested and manifest-deferred work. Oldest eligible age uses +the exact normal claim predicate, excluding future, exhausted, terminal-requested, +live/unrecovered-expired leases and manifest/local partition holds. It is zero +when no row qualifies and never negative. Deferred count isolates manifest holds. +Heartbeat advances after actual bounded loop work, including empty polls. + +Gauges read immutable memory snapshots and perform no SQL. Refresh follows +completed iterations, targets 5 s idle and becomes stale at the approved 45 s +health bound. Failure retains the prior snapshot and reports unhealthy. The +[P02b-2 bounds](../roadmap/phase-02b-events-auth.md#runtime-bounds-and-observation) +own deadlines and task limits. Dispatcher-owned logs/spans use validated +correlation and bounded outcomes, without exception text/stacks, payload, raw +stored type or connection data. P02b-3 reconciles existing transport/consumer +exception telemetry before Active consumers; no current minimization is claimed. + Cache `cache.name` is a governed, low-cardinality family from the Standards 20 inventory (`hub:host-map`, `hub:entitlement`, `identity:permissions`, `tenancy:feature-flags`, `tenancy:settings`, `audit:config`, or @@ -279,8 +309,10 @@ touched. | Check | Registered as | Unhealthy when | |---|---|---| | Audit write path | `audit` | The most recent MUST-class standalone audit write failed and no later one has succeeded | +| Outbox dispatcher (P02b-2 target) | `outbox` | No actual progress or observation refresh for 45 s, any retained callback overrun, or terminal-audit failure | -The `audit` check is registered in Phase 02a Packet 9 and **not mapped**: `/healthz` stays +The `audit` check is registered in Phase 02a Packet 9; P02b-2 registers `outbox`. +Both are **not mapped**: `/healthz` stays a liveness probe, because a process that cannot write audit rows is still worth leaving alive. The readiness surface that reads the check, and the deployment-level backstop that stops serving past a configured unhealthy window, are demand-gated to @@ -347,7 +379,7 @@ The first set of dashboards (Phase 11 deliverable): |-------|-----------|----------| | API 5xx rate | > 1% for 5 min | page | | API latency p95 | > 2× budget for 10 min | page | -| Outbox pending count | > 10k for 10 min | page | +| Outbox oldest eligible age / heartbeat | > 45 s; validate production window in Phase 11 | page | | Background job failures | > 5% for 10 min | page | | Classroom join failure | > 5% for 5 min | page | | Disk usage (DB) | > 80% | warn | diff --git a/docs/standards/11-security.md b/docs/standards/11-security.md index 70e76745..cd5374fe 100644 --- a/docs/standards/11-security.md +++ b/docs/standards/11-security.md @@ -24,6 +24,8 @@ Public-read additions derive from 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). +Delivery/recovery additions derive from +[ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md). Producer announcement and common execution ownership derive from [ADR-0057](../decisions/0057-transactional-event-production-and-completion.md). @@ -239,6 +241,29 @@ for the full strategy. Standards-side: `SET LOCAL` inside the ambient transaction — see § Tenant Context immediately below, which is the single authority for that placement. +### Confined event dispatcher + +ADR-0046 accepts this P02b-2 boundary. Step 1 implements source/role guards; +reviews remain pending. Step 3 still owns mandatory worker startup resolution +and connectivity proof. The named keyed, lazy dispatcher source belongs only to +the API host. Configuration validates the exact username without connecting or +echoing parse input. Worker startup must verify connectivity. Every new physical +connection verifies exact session/current +`learnstack_outbox_admin`, BYPASSRLS, NOSUPERUSER, NOCREATEDB, NOCREATEROLE, +NOREPLICATION and no privilege-escalating membership. No Seeder/web forwarding or +shared app credential is allowed. [Database Standards](05-database.md#grant-matrix) +owns exact status grants and audit INSERT-only; no business access or audit read, +update or delete is admitted. + +Routine confined poll/claim/mark receives ADR-0046's bounded audit exception; +terminal state is MUST/SecurityEvent and atomic with its row-bound witness audit. +INSERT-only and GUCs cannot tenant-bind a bypass credential. Real mismatch controls +must roll back terminal state and audit together. Existing platform-admin entry +audit and gate stay unchanged. P02b-3 opens separate app-role business scopes, +restores validated tenant/organization and announces RLS context before reading +the envelope and constructing a handler. The ordinary executor refuses privileged +subscriptions until their Phase 03 execution contract exists. + ## Tenant Context **This section is the single authority for RLS session-variable placement.** Every other @@ -671,7 +696,8 @@ and the edge has no way to know it. ## Audit Log -Every privileged operation writes an audit log entry per +Every privileged operation writes an audit log entry, subject to the confined +routine dispatcher exception above, per [ADR-0033 Audit Durability Model](../decisions/0033-audit-durability-model.md) (supersedes ADR-0016), [ADR-0044 The Audit Write Path](../decisions/0044-audit-write-path.md) and the diff --git a/docs/standards/18-audit-coverage.md b/docs/standards/18-audit-coverage.md index 21d5cfe1..90ed1165 100644 --- a/docs/standards/18-audit-coverage.md +++ b/docs/standards/18-audit-coverage.md @@ -10,6 +10,8 @@ subsystem's context), [01-architecture-standards.md](01-architecture-standards.md). Producer additions derive from [ADR-0057](../decisions/0057-transactional-event-production-and-completion.md). +Delivery/recovery additions derive from +[ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.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 @@ -151,18 +153,20 @@ will raise them — which this standard requires it to do. - **`(planned)` is re-checked, not an escape.** A row still marked `(planned)` whose command has since shipped **fails**. The marker is a claim the rule tests on every run, which is what stops the scoping from becoming a hole. -- **`(off-path)` is for operations that are not MediatR requests at all.** Seven rows - carry it: Tenancy's `platform.admin_scope.enter`, `tenancy.killswitch.toggle`, +- **`(off-path)` is for operations that are not MediatR requests at all.** Nine rows + carry it: Tenancy's `platform.admin_scope.enter`, `platform.outbox.dead_letter`, + `platform.delivery.dead_letter`, `tenancy.killswitch.toggle`, `tenancy.entitlement.refresh` and the two tenant-assertion keys of § Baseline Coverage, and Audit's `audit.redaction.apply` and `audit.purge.apply`. They sit outside the request-type join in **both** directions and register by slug rather than by type — the - three whose writer ships through `DeclareOffPath` today, the other four when their - writer lands. + three whose writer ships through `DeclareOffPath` today, the other six when their + writer lands. Producer terminal registration belongs to P02b-2; subscriber + terminal registration belongs to P02b-3. Both are planned until their writers ship. - **One operation, one row.** A slug is classified in exactly one row across every matrix. A copy is a second answer that passes every comparison the moment it agrees, so the matrix → catalogue direction refuses it, and the catalogue → matrix direction compares every row that carries a slug rather than the first. -- **A row can carry both markers**, and four do. `tenancy.killswitch.toggle` is +- **A row can carry both markers**, and six do. `tenancy.killswitch.toggle` is `(off-path)` because every write runs inside `EnterPlatformAdminScope(reason)` rather than through the pipeline, and `(planned)` because Packet 9 ships the table, the policies and the read path but no writer — @@ -383,7 +387,7 @@ Rules: 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 + opens it before handler construction in P02b-3 under ADR-0046, 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. @@ -492,7 +496,25 @@ table above is the policy those deliverables implement, not a description of tod - Failed `denied` outcomes are audited even though no state changed. - Background jobs that mutate MUST-audit resources receive the `actor` via job payload (operator id, or the seed of a system actor with a stable id) and write the entry under that identity. - Integration event handlers that mutate state are treated as actors of type `system` and audited accordingly. -- Platform-bypass code paths (`IgnoreQueryFilters()` etc.) write a `security-event` entry on every invocation. +- Platform-bypass code paths (`IgnoreQueryFilters()` etc.) write a `security-event` + entry on every invocation, with ADR-0046's confined routine dispatcher exception: + polling/claim/mark uses exact grant confinement, bounded structured attempt + outcomes, metrics/health and MUST-audited terminal transitions. This exception + changes no platform-admin pre-entry audit or other bypass operation. +- `WriteEventTerminalAsync(AuditEntryDraft, EventTerminalTransition, DbConnection, + DbTransaction, CancellationToken)` is the narrow fifth write, pending P02b-2. + Only a successful fenced UPDATE result mints the restricted witness binding + actual tenant/org/event/acquisition/kind/reason/attempts/subscription and live + transaction. Validate declared MUST/SecurityEvent operation and matching draft + before INSERT; mismatch rolls back both writes. No transaction is opened or + committed by the seam. `PostgresAuditStore` remains the sole audit SQL writer. + Tenancy owns off-path `platform.outbox.dead_letter` (P02b-2) and reserved + `platform.delivery.dead_letter` (P02b-3); register only with each writer. + Outcome success records terminal state, not publication. Invalid causal metadata + is omitted and a fresh validated attempt trace is used. Closed terminal values + live in bounded metadata; `AuditEntryDraft.Reason` remains for platform entry. + Audit refusal retries terminalization without delivery. Because the bypass + role ignores RLS, witness/call-path proof binds identity, not announced GUCs. - Entering `EnterPlatformAdminScope(reason)` writes that row through `IAuditStore.WritePlatformScopeAsync` — a fourth **write** method, not an update method — on the scope's own platform-role connection and **before** the operation @@ -503,7 +525,8 @@ table above is the policy those deliverables implement, not a description of tod disposed without `CommitAsync` rolls back, taking the record of the bypass with it. That is the row [Database Standards § How `EnterPlatformAdminScope(reason)` reaches `learnstack_platform`](05-database.md) calls "committed on its own". -- **The row's tenant is the tenant the ambient transaction announced**, never a value off +- **An ordinary audit intent's tenant is the tenant the ambient transaction + announced**, never a value off the request payload: `ITenantContext.TenantId` under a resolved context, `IProvisionsTenant.ProvisioningTenantId` for provisioning under an unresolved one, and `TenantId.PlatformSentinel` — the reserved `00000000-0000-7000-8000-000000000002`, not diff --git a/docs/standards/20-infrastructure-stack.md b/docs/standards/20-infrastructure-stack.md index 25c76716..424db841 100644 --- a/docs/standards/20-infrastructure-stack.md +++ b/docs/standards/20-infrastructure-stack.md @@ -17,6 +17,8 @@ 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). +Delivery/recovery additions derive from +[ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md). Three audit decisions are referenced here but **not owned** here, and the split matters because they are easy to conflate: @@ -27,8 +29,9 @@ intent multiplicity, capture, classification, and which assembly each type lives [ADR-0028](../decisions/0028-audit-log-partition-management.md) owns the `audit_log` **partition lifecycle**. This standard records that partitioning is demand-gated, where the audit types live, and that modules never write `audit_log` directly. Assembly -placement and the four-method write path in § Audit Plumbing are ADR-0044's, which is -why it — alone of the three — is also named in the header above. +placement and the delivered four-method write path in § Audit Plumbing are ADR-0044's; +ADR-0046 adds the pending confined fifth seam; both write-path authorities are +named in the header above. This standard defines how application code uses the foundation infrastructure introduced in the 2026-05-18 redesign: Dapr building blocks, the APISIX gateway, the Hub HTTPS @@ -181,10 +184,15 @@ adapter replaces `InProcessEventBus`. [21-architecture-tests-catalogue.md](21-architecture-tests-catalogue.md). - Hub-side topics use the same `learnstack.hub.*` prefix (`learnstack.hub.entitlement`, `learnstack.hub.custom-domain.activated`). -- Consumers implement `IIntegrationEventHandler<TEvent>` and **must** invoke - `IInboxGuard.IsAlreadyProcessedAsync` before any business logic. Phase 02b adds the - architecture test `Integration_Event_Handlers_Use_InboxGuard` with the first real - consumers; it is registered in the catalogue today. +- Consumers implement `IIntegrationEventHandler<TEvent>`. Accepted ADR-0046 assigns + shared inbox claim/completion to the transport, before handler construction. + `Integration_Event_Handlers_Use_InboxGuard` is Registered for P02b-3's nonempty + production census and real atomicity/duplicate proofs; handlers do not own the guard. +- Construction-free readiness declares Pending, Active or intentional absence. + Any required Pending subscription holds its contract/aliases before acquisition + and validation; Active declarations require actual handler registration. + P02b-3 publish success means every required subscription was durably accepted; + later execution failures belong to independent subscription ledgers. - Cross-instance L1-cache invalidation rides on `learnstack.cache.invalidation` (a small payload of `(tenant_id, cache_key)`). Modules that maintain L1 caches subscribe here. @@ -430,16 +438,24 @@ Rules: 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 - message has its own transaction; one failure does not roll back the batch. -- Retry backoff: 1s, 5s, 30s, 5min, 1h. After max retries (5), the message is - dead-lettered and surfaces via the `OutboxStatusEndpoints` admin API; manual - intervention is required. -- Consumers are **idempotent** via per-module `inbox_messages` tables. The - `IInboxGuard.MarkAsProcessed(eventId, eventTypeName)` write enrolls in the consumer's - business `DbContext` so the inbox marker and the business write commit atomically. +- ADR-0046 accepts a single just-in-time normal claim with a fresh acquisition + token and database-time lease. Every transition is token/expiry fenced; expired + claims recover before publication. Six claims total use delays 1 s, 5 s, 30 s, + 5 min and 1 h. Terminal requests never publish or increment attempts; explicit + terminal state commits atomically with MUST audit through the confined fifth seam. +- P02b-2's API-owned dispatcher source uses the existing bypass role with exact + status grants and audit INSERT-only. [Database Standards](05-database.md#grant-matrix) + owns grants and canonical schema. No batch-held fallback, EF set-based write, + arbitrary type loading or free-form persisted exception descriptor is permitted. +- P02b-3 implements the shared transport-owned inbox keyed by tenant/subscription/ + event. Its claim precedes handler construction; business state, inbox completion, + produced outbox, MUST audit and fenced ledger completion share the app-role owner. + Independent delivery ledgers retain validated envelopes and own subscriber + retries/terminal/replay. Targeted replay invokes one internal executor, never + broadcast; the outbox processor remains the only production publisher. +- Operator routes, permissions and producer reset/fencing belong to Phase 03. + Brokers and retention purge remain demand-gated Phase 11. Acceptance claims no + dispatcher, inbox or effectively-once delivery implementation. Full deep dive: [15-event-and-outbox.md](../architecture/15-event-and-outbox.md). @@ -627,9 +643,9 @@ are the Hub's public API, governed by the Hub repository. classification per [18-audit-coverage.md](18-audit-coverage.md)) and the MediatR `AuditLogBehavior`; there is no per-module audit code. - `IAuditStore` is the only sanctioned write path; the architecture test - `Modules_Do_Not_Write_AuditLog_Directly` is the rule that enforces it. The store - carries **four** write - methods and no update method: `WritePendingAsync` on the ambient transaction, the two + `Modules_Do_Not_Write_AuditLog_Directly` is the rule that enforces it. The + delivered store carries **four** write methods and no update method: + `WritePendingAsync` on the ambient transaction, the two standalone writers (`WriteStandaloneAsync`, `WriteBestEffortAsync`), and `WritePlatformScopeAsync`, whose only caller is `EnterPlatformAdminScope(reason)` — it writes its security-event row on its own platform-role connection before the @@ -638,6 +654,17 @@ are the Hub's public API, governed by the Hub repository. ([ADR-0044 § 10](../decisions/0044-audit-write-path.md), [05-database.md § How `EnterPlatformAdminScope(reason)` reaches `learnstack_platform`](05-database.md)). +Accepted ADR-0046 adds the confined fifth method +`WriteEventTerminalAsync(AuditEntryDraft, EventTerminalTransition, DbConnection, +DbTransaction, CancellationToken)`; P02b-2 implementation remains pending. Its +restricted witness comes only from the actual successful fenced terminal UPDATE. +The store validates declared operation/class/type, row identity and live caller +transaction, then INSERTs without opening/committing a transaction. Existing four +writes retain their contracts. The terminal UPDATE and audit commit together, or +both roll back. Tenancy owns producer terminal audit in P02b-2 and subscriber +terminal audit in P02b-3. See +[Audit Coverage](18-audit-coverage.md#required-behaviours). + ## Background Jobs and Hangfire - Long-running domain work (cohort enrollments, recording transcoding) goes on From 6a1345501435ce94b5df5794dcc7cf87dd7294c5 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 21:24:52 +0300 Subject: [PATCH 70/92] fix(docs): reconcile remaining shared inbox guidance Keep mandatory reviewer instructions aligned with the accepted transport ownership while preserving the earlier dated architecture note. ADR: 0046 --- .claude/skills/code-review/SKILL.md | 2 +- docs/architecture/10-cross-module-contracts.md | 8 ++++++++ docs/roadmap/phase-02b-events-auth.md | 13 ++++++++++++- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md index af20dc97..df60c1f7 100644 --- a/.claude/skills/code-review/SKILL.md +++ b/.claude/skills/code-review/SKILL.md @@ -107,7 +107,7 @@ Walk the diff for correctness issues. Common shapes: | **Async/await** | `.Result` / `.Wait()` deadlock risk; cancellation token threaded through; `ConfigureAwait(false)` where the library expects it. | | **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`. | +| **Idempotency** | Command with external side effects (LiveKit room, Stripe charge) missing `Idempotency-Key` handling. Under ADR-0046, transport claims the shared inbox before handler construction; P02b-3 implements it. Handlers do not call the guard themselves. | | **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. | diff --git a/docs/architecture/10-cross-module-contracts.md b/docs/architecture/10-cross-module-contracts.md index 7e67abcb..4623064a 100644 --- a/docs/architecture/10-cross-module-contracts.md +++ b/docs/architecture/10-cross-module-contracts.md @@ -13,6 +13,14 @@ Modules collaborate only through explicit contracts. This keeps the modular mono > (Mechanism #2), and read-model projections (Mechanism #4) are unchanged. See > [15-event-and-outbox.md](15-event-and-outbox.md) for the full producer/consumer flow. +**2026-10-12 delivery decision.** +[ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md) supersedes the +2026-08-26 note's per-module inbox placement: P02b-3 owns a shared transport-claimed +inbox keyed by tenant, stable subscription and event. Claiming precedes handler +construction; handlers do not call the guard. The four communication mechanisms +and demand-gated broker trigger remain unchanged. This records the accepted target, +not delivered subscriber machinery. + ## Allowed Contract Types ```mermaid diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index f6d7b2ae..f2638843 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -1434,7 +1434,7 @@ claim no runtime enforcement; delivery evidence belongs to each step below. ### P02b-2 Step 1 — Decision, schema and confined composition (2026-10-12) -**Implemented; both independent review rounds pending.** Decision commit +**Implemented; review round 1 complete, round 2 pending.** Decision commit `192810b` precedes runtime edits. Step 1 adds the Platform-chain migration `20261012180355_add_outbox_dispatch_state` and Audit-chain migration `20261012180433_grant_outbox_terminal_audit`. Existing migration bodies remain @@ -1474,6 +1474,17 @@ counts describe this step's completed checks, not a full Docker regression. The six new catalogue rules remain Registered: transition/audit/worker proof lands in later steps. No dispatcher execution or P02b-3 delivery is claimed. +#### Step 1 review round 1 + +Fresh read-only GPT-6-astra xhigh security/schema and GPT-6.1-sol xhigh +corpus/test reviewers examined `192810b..d9bc77b`. Security review found no +actionable defect. Corpus review found a stale handler-owned inbox instruction +in the code-review skill and an unqualified historical per-module inbox note in +Architecture 10. Both were verified and reconciled to ADR-0046's transport-owned +P02b-3 target. Original dated notes remain intact. No runtime change was needed; +the existing test evidence above remains applicable. The correction passes local +file/fragment checks, prose wrapping and `git diff --check`. + ### Durable outbox dispatch P02b-1 Steps 2/3 implement `IOutbox`, replace Packet 3's step-7 shell with From b6fc7140854237b45d62e591f413356bda5a09d6 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 21:28:59 +0300 Subject: [PATCH 71/92] fix(kernel): refuse open generic active outbox handlers Fail invalid delivery composition before a stored event can be claimed. Exercise the existing discovery gap and a closed generic accepting control, with a mutation check proving the refusal is load-bearing. ADR: 0046 --- .../Messaging/OutboxEventRegistry.cs | 6 +++- .../Messaging/OutboxEventRegistryTests.cs | 34 +++++++++++++++++++ docs/roadmap/phase-02b-events-auth.md | 17 +++++++++- 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/backend/src/LearnStack.Infrastructure/Messaging/OutboxEventRegistry.cs b/backend/src/LearnStack.Infrastructure/Messaging/OutboxEventRegistry.cs index 631492ee..bad98752 100644 --- a/backend/src/LearnStack.Infrastructure/Messaging/OutboxEventRegistry.cs +++ b/backend/src/LearnStack.Infrastructure/Messaging/OutboxEventRegistry.cs @@ -208,7 +208,11 @@ public static OutboxEventRegistry Create( } } else if (subscription.Readiness == OutboxSubscriptionReadiness.Active - && subscription.HandlerType is { IsClass: true, IsAbstract: false } handlerType) + && subscription.HandlerType is + { + IsClass: true, IsAbstract: false, + ContainsGenericParameters: false + } handlerType) { if (!activeHandlers.Add((eventType, handlerType))) { diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/OutboxEventRegistryTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/OutboxEventRegistryTests.cs index a618018e..10edf9ac 100644 --- a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/OutboxEventRegistryTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/OutboxEventRegistryTests.cs @@ -158,6 +158,34 @@ public void Active_manifest_and_discovered_handlers_must_match_in_both_direction pendingHandler.Should().Throw<InvalidOperationException>(); } + [Fact] + public void Active_handlers_must_be_closed_constructible_types() + { + var open = typeof(GenericHandler<>); + var discovered = IntegrationEventHandlerRegistry.Discover(typeof(SampleEvent).Assembly); + discovered.All.Should().Contain(subscription => + subscription.EventType == typeof(SampleEvent) && subscription.HandlerType == open, + "the discovery census alone does not reject an open handler"); + + var openHandlers = IntegrationEventHandlerRegistry.FromServiceDescriptors( + [ServiceDescriptor.Scoped(typeof(IIntegrationEventHandler<SampleEvent>), open)]); + var build = () => Create( + [OutboxEventRegistration.Required(typeof(SampleEvent), + [OutboxSubscriptionManifest.Active("test.sample.v1", open)])], + handlers: openHandlers); + build.Should().Throw<InvalidOperationException>().WithMessage("*concrete handler*"); + + var closed = typeof(GenericHandler<int>); + var closedHandlers = IntegrationEventHandlerRegistry.FromServiceDescriptors( + [ServiceDescriptor.Scoped(typeof(IIntegrationEventHandler<SampleEvent>), closed)]); + var control = Create( + [OutboxEventRegistration.Required(typeof(SampleEvent), + [OutboxSubscriptionManifest.Active("test.sample.v1", closed)])], + handlers: closedHandlers); + control.Resolve(typeof(SampleEvent).AssemblyQualifiedName).Disposition + .Should().Be(OutboxEventDisposition.Active); + } + [Fact] public void A_contract_outside_the_explicit_assembly_allowlist_is_rejected() { @@ -212,6 +240,12 @@ public Task HandleAsync(SampleEvent integrationEvent, CancellationToken cancella Task.CompletedTask; } + private sealed class GenericHandler<T> : IIntegrationEventHandler<SampleEvent> + { + public Task HandleAsync(SampleEvent integrationEvent, CancellationToken cancellationToken = default) => + Task.CompletedTask; + } + private sealed class ThrowingHandler : IIntegrationEventHandler<OtherEvent> { public static int Constructions; diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index f2638843..7777631e 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -1434,7 +1434,8 @@ claim no runtime enforcement; delivery evidence belongs to each step below. ### P02b-2 Step 1 — Decision, schema and confined composition (2026-10-12) -**Implemented; review round 1 complete, round 2 pending.** Decision commit +**Implemented; both independent review rounds complete; fix review pending.** +Decision commit `192810b` precedes runtime edits. Step 1 adds the Platform-chain migration `20261012180355_add_outbox_dispatch_state` and Audit-chain migration `20261012180433_grant_outbox_terminal_audit`. Existing migration bodies remain @@ -1485,6 +1486,20 @@ P02b-3 target. Original dated notes remain intact. No runtime change was needed; the existing test evidence above remains applicable. The correction passes local file/fragment checks, prose wrapping and `git diff --check`. +#### Step 1 review round 2 + +Fresh GPT-6.1-sol xhigh security/composition and GPT-6-astra high corpus/proof +reviewers examined `192810b..6a13455`. Corpus review approved without findings. +The composition review verified that an open generic Active handler could match +the discovery census despite being unconstructible. Active admission now requires +a closed type. A regression proves discovery sees the open generic offender, +refuses its matching manifest and accepts a closed generic control. The complete +Release unit suite passes 1,801 cases; the complete architecture suite passes +266, both with zero failures/skips. A fresh focused review checks this correction +before Step 2 begins. Removing the closed-type check makes the new regression +fail; restoring it makes all 17 registry cases pass. No migration, grant or +Pending policy changed. + ### Durable outbox dispatch P02b-1 Steps 2/3 implement `IOutbox`, replace Packet 3's step-7 shell with From c6ceaa9d2ffe3a72591635f4639cd58668cfdde6 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 21:30:36 +0300 Subject: [PATCH 72/92] docs(roadmap): close P02b-2 foundation review rounds Record independent verification of the composition fix and synchronize current delivery status before starting fenced transitions. ADR: 0046 --- CLAUDE.md | 3 ++- docs/architecture/15-event-and-outbox.md | 3 ++- docs/roadmap/phase-02b-events-auth.md | 10 ++++++++-- docs/standards/05-database.md | 3 ++- docs/standards/11-security.md | 2 +- 5 files changed, 15 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8f76d1a0..c37b4c11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -392,7 +392,8 @@ Then read the phase that is live and the one it stands on: — **the live phase**. P02b-0 entry verification is complete; P02b-1 is complete and merged through PR #29 on 2026-10-12. Its four steps deliver the producer runtime and production-census enforcement. P02b-2's revised package/ADR-0046 are - Accepted, with Step 1 in progress; producer dispatch delivery remains pending. + Accepted, with Step 1 and both review rounds complete; producer dispatch + delivery remains pending. P02b-3 owns consumer/inbox delivery; each later packet accepts its gates first. - [docs/roadmap/phase-02d-walking-skeleton.md](docs/roadmap/phase-02d-walking-skeleton.md) — complete and merged; the delivered public-site baseline and P02b-0 handoff. diff --git a/docs/architecture/15-event-and-outbox.md b/docs/architecture/15-event-and-outbox.md index 608b5de6..62a9b081 100644 --- a/docs/architecture/15-event-and-outbox.md +++ b/docs/architecture/15-event-and-outbox.md @@ -116,7 +116,8 @@ establish durable publish order. Accepted P02b-2 adds paired `locked_by` / `locked_until` lease fields, `terminal_reason` and `dead_lettered_at`; the former is a fresh acquisition token, never stable worker identity. `available_after` controls due time and retry backoff. Canonical DDL/indexes are in Standards 05, -implemented by P02b-2 Step 1 with reviews pending; dispatcher execution remains +implemented by P02b-2 Step 1 with both review rounds complete; dispatcher execution +remains a later step. ### Why the `learnstack_outbox_admin` role bypasses RLS — and what bounds the bypass diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 7777631e..ef2d645e 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -253,7 +253,7 @@ Accepted, and its catalogue rows registered, before its first line of code. |---|---|---| | **P02b-0** | Phase entry: every premise the register below cites re-verified against `HEAD` — the code, the corpus, the Hub repository — and the register corrected where Phase 02d moved it. It drafts no record: each gate closes in the decision pass of the first packet it shapes, and each glossary headword this phase needs — `inbox`, outbox lease, dead letter, audit frame, job activator, integration-event consumer — is written in the pass whose gate fixes its meaning | [Phase 02d](phase-02d-walking-skeleton.md) exits | | **P02b-1** | **Complete and merged — [PR #29 closeout](#p02b-1-merge-and-closeout-2026-10-12).** Producer: `IOutbox`, the `OutboxFlushBehavior` invariant guard, successful-save domain-event collection and owner-only completion, and the first event with its enqueue path | G2, G3 (the key's derivation), G5 (the stored identities), G6, G18 (the payload rule), G19 Accepted | -| **P02b-2** | **Decision Accepted; Step 1 in progress.** Dispatcher: the lease migration and grant extension, claim / publish / mark, backoff, the producer terminal state, the credential and its boot guard, the metrics and the health check | G1, G3, G4, G5, G7, G8, G17, G18 (`last_error`) Accepted; P02b-1 | +| **P02b-2** | **Decision Accepted; Step 1 complete.** Dispatcher: the lease migration and grant extension, claim / publish / mark, backoff, the producer terminal state, the credential and its boot guard, the metrics and the health check | G1, G3, G4, G5, G7, G8, G17, G18 (`last_error`) Accepted; P02b-1 | | **P02b-3** | Consumer: durable acceptance, shared inbox/ledger DDL and `IInboxGuard`, the common per-delivery transaction, both real defaults subscriptions and Seeder handoff, independent attempts and audited terminal state, targeted internal ledger replay | G1, G6, G7 Accepted; P02b-2 | | **P02b-4** | Background jobs: the runner, its storage, the tenant-context hook, the enqueue guard, no job definition | G9 Accepted; P02b-3 | | **P02b-5** | API authentication: JwtBearer wiring and its insertion order, claim population, the host/JWT cross-check through the existing recorder, token-keyed rate limits, the 401 rows, one authenticated endpoint | G10, G11, G12 (the BFF's client), G14, G20 Accepted | @@ -1434,7 +1434,7 @@ claim no runtime enforcement; delivery evidence belongs to each step below. ### P02b-2 Step 1 — Decision, schema and confined composition (2026-10-12) -**Implemented; both independent review rounds complete; fix review pending.** +**Implemented; both independent review rounds and fresh fix review complete.** Decision commit `192810b` precedes runtime edits. Step 1 adds the Platform-chain migration `20261012180355_add_outbox_dispatch_state` and Audit-chain migration @@ -1500,6 +1500,12 @@ before Step 2 begins. Removing the closed-type check makes the new regression fail; restoring it makes all 17 registry cases pass. No migration, grant or Pending policy changed. +Fresh GPT-6.1-sol high focused review of `6a13455..b6fc714` approved the correction +without findings and independently checked its accepting/rejecting controls and +saved TRX counters. The completed Step 1 commits are `d9bc77b`, `6a13455` and +`b6fc714`; the next authorized implementation step is durable transitions and +terminal audit. + ### Durable outbox dispatch P02b-1 Steps 2/3 implement `IOutbox`, replace Packet 3's step-7 shell with diff --git a/docs/standards/05-database.md b/docs/standards/05-database.md index 33b9f147..5c1fa4f5 100644 --- a/docs/standards/05-database.md +++ b/docs/standards/05-database.md @@ -1392,7 +1392,8 @@ Public read models consumed by other modules: ## Outbox -**ADR-0046 schema implemented in P02b-2 Step 1; reviews pending.** The canonical +**ADR-0046 schema implemented in P02b-2 Step 1; both review rounds complete.** +The canonical DDL below includes lease/terminal/identity/descriptor changes. Dispatcher execution and terminal auditing remain later P02b-2 steps. Existing migrations remain immutable; a new Platform-chain migration replaces free-form diff --git a/docs/standards/11-security.md b/docs/standards/11-security.md index cd5374fe..75b1ecf0 100644 --- a/docs/standards/11-security.md +++ b/docs/standards/11-security.md @@ -244,7 +244,7 @@ for the full strategy. Standards-side: ### Confined event dispatcher ADR-0046 accepts this P02b-2 boundary. Step 1 implements source/role guards; -reviews remain pending. Step 3 still owns mandatory worker startup resolution +both review rounds are complete. Step 3 still owns mandatory worker startup resolution and connectivity proof. The named keyed, lazy dispatcher source belongs only to the API host. Configuration validates the exact username without connecting or echoing parse input. Worker startup must verify connectivity. Every new physical From 79761135e556bdec7c8e168d7b629b15313c5518 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 21:51:58 +0300 Subject: [PATCH 73/92] feat(kernel): add fenced outbox transitions and terminal audit Keep committed dispatch claims recoverable with database-time ownership, closed retries and terminal requests. Bind the fifth audit writer to the actual fenced UPDATE so state and MUST audit commit together. Prove expiry and identity refusal on real roles, including a lost COMMIT response and mutation controls for each critical safeguard. ADR: 0046, 0044, 0033 Module: Tenancy --- .claude/skills/add-audit-coverage/SKILL.md | 11 +- CLAUDE.md | 6 +- .../PostgresAuditStore.cs | 47 +- .../Messaging/OutboxDispatchModels.cs | 21 + .../Messaging/OutboxDispatchOptions.cs | 30 + .../Messaging/OutboxDispatchStore.cs | 290 +++++ .../Messaging/OutboxEnvelopeReader.cs | 52 + .../Audit/EventTerminalTransition.cs | 168 +++ .../Audit/IAuditStore.cs | 23 +- .../Audit/TenancyAuditCatalogSource.cs | 4 +- .../AuditConventionTests.cs | 8 +- .../OutboxDispatchConventionTests.cs | 603 +++++++++ .../Database/AuditStoreTests.cs | 14 +- .../Database/CommitResponseDroppingProxy.cs | 164 +++ .../Database/NoOpAuditStore.cs | 8 + .../Database/OutboxDispatchStoreTests.cs | 1073 +++++++++++++++++ .../Database/PlatformAdminScopeTests.cs | 9 + .../Database/PublicReadTransactionTests.cs | 2 +- .../Database/TenantAssertionAuditTests.cs | 6 +- .../AuditingTenantAssertionRecorderTests.cs | 11 +- .../Pipeline/AuditPipelineHarness.cs | 8 + .../Messaging/OutboxDispatchOptionsTests.cs | 44 + .../Messaging/OutboxEnvelopeReaderTests.cs | 131 ++ docs/architecture/15-event-and-outbox.md | 4 +- docs/architecture/31-audit-subsystem.md | 7 +- docs/glossary.md | 4 +- docs/modules/tenancy/audit.md | 38 +- docs/roadmap/phase-02b-events-auth.md | 79 +- docs/standards/05-database.md | 8 +- docs/standards/18-audit-coverage.md | 5 +- .../21-architecture-tests-catalogue.md | 48 +- 31 files changed, 2857 insertions(+), 69 deletions(-) create mode 100644 backend/src/LearnStack.Infrastructure/Messaging/OutboxDispatchModels.cs create mode 100644 backend/src/LearnStack.Infrastructure/Messaging/OutboxDispatchOptions.cs create mode 100644 backend/src/LearnStack.Infrastructure/Messaging/OutboxDispatchStore.cs create mode 100644 backend/src/LearnStack.Infrastructure/Messaging/OutboxEnvelopeReader.cs create mode 100644 backend/src/LearnStack.SharedKernel/Audit/EventTerminalTransition.cs create mode 100644 backend/tests/LearnStack.Tests.Architecture/OutboxDispatchConventionTests.cs create mode 100644 backend/tests/LearnStack.Tests.Integration/Database/CommitResponseDroppingProxy.cs create mode 100644 backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatchStoreTests.cs create mode 100644 backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/OutboxDispatchOptionsTests.cs create mode 100644 backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/OutboxEnvelopeReaderTests.cs diff --git a/.claude/skills/add-audit-coverage/SKILL.md b/.claude/skills/add-audit-coverage/SKILL.md index 30908562..9ca776cf 100644 --- a/.claude/skills/add-audit-coverage/SKILL.md +++ b/.claude/skills/add-audit-coverage/SKILL.md @@ -109,10 +109,11 @@ parsed from the other, and three rules in `AuditCoverageTests` join them — `tenancy.killswitch.toggle`, `tenancy.entitlement.refresh`, `tenancy.tenant_assertion.reject`, `tenancy.tenant_assertion.anonymous_burst`, `platform.outbox.dead_letter` and `platform.delivery.dead_letter`, and - Audit's `audit.redaction.apply` and `audit.purge.apply`. The three whose writer ships are - registered through `DeclareOffPath`; the other six also carry `(planned)` and - register when their writer lands. ADR-0046 reserves the Tenancy-owned producer - terminal operation for P02b-2 and subscriber terminal operation for P02b-3. + Audit's `audit.redaction.apply` and `audit.purge.apply`. The four whose writer + ships are registered through `DeclareOffPath`; the other five also carry + `(planned)` and register when their writer lands. ADR-0046 reserves the + Tenancy-owned producer terminal operation for P02b-2 and subscriber terminal + operation for P02b-3. - **A row carries both markers where both are true** — off the request path *and* ahead of the code that will write it. @@ -533,7 +534,7 @@ passes even when every policy is inert. See The row is composed complete at the commit boundary, `IAuditStore` has **no update method**. Its four delivered write methods are `WritePendingAsync`, `WriteStandaloneAsync`, `WriteBestEffortAsync` and `WritePlatformScopeAsync`. - ADR-0046 accepts a fifth, P02b-2 pending, + ADR-0046 adds the fifth, implemented in P02b-2 Step 2, `WriteEventTerminalAsync(AuditEntryDraft, EventTerminalTransition, DbConnection, DbTransaction, CancellationToken)` for confined terminal writers. Its restricted witness binds audit identity to the actual fenced UPDATE; handlers cannot call diff --git a/CLAUDE.md b/CLAUDE.md index c37b4c11..e1b3b75b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -186,8 +186,10 @@ owns final verification. **P02b-2's revised decision package and ADR-0046 are Accepted — 2026-10-12.** The four implementation steps and two independent review rounds per step are authorized on development. The [Step 1 record](docs/roadmap/phase-02b-events-auth.md#p02b-2-step-1--decision-schema-and-confined-composition-2026-10-12) -owns current progress. Acceptance reconciles delivery contracts and registers six -rules; it claims no dispatcher, inbox or subscriber delivery. P02b-3 still owns +records both completed Step 1 reviews. Step 2 implements fenced transitions and +row-bound terminal MUST audit; its verification and reviews are in progress. +Hosted execution remains Step 3. Acceptance registers six delivery obligations; +it claims no inbox or subscriber delivery. P02b-3 still owns durable acceptance, shared inbox/ledger, independent retries, both real default subscriptions and Seeder handoff. Phase 06 retains the full renderer/Studio expansion. diff --git a/backend/src/LearnStack.Infrastructure.Audit/PostgresAuditStore.cs b/backend/src/LearnStack.Infrastructure.Audit/PostgresAuditStore.cs index 177dcc78..f3d51356 100644 --- a/backend/src/LearnStack.Infrastructure.Audit/PostgresAuditStore.cs +++ b/backend/src/LearnStack.Infrastructure.Audit/PostgresAuditStore.cs @@ -74,6 +74,7 @@ public sealed class PostgresAuditStore : IAuditStore private readonly Lazy<NpgsqlDataSource> _dataSource; private readonly ILogger<PostgresAuditStore> _logger; private readonly IAuditHealth _health; + private readonly IAuditCatalog _catalog; private readonly Counter<long> _durableDuplicates; private readonly Counter<long> _standaloneWriteFailures; @@ -82,7 +83,8 @@ public PostgresAuditStore( Lazy<NpgsqlDataSource> dataSource, ILogger<PostgresAuditStore> logger, IMeterFactory meterFactory, - IAuditHealth health) + IAuditHealth health, + IAuditCatalog catalog) { ArgumentNullException.ThrowIfNull(meterFactory); @@ -90,6 +92,7 @@ public PostgresAuditStore( _dataSource = dataSource; _logger = logger; _health = health ?? throw new ArgumentNullException(nameof(health)); + _catalog = catalog ?? throw new ArgumentNullException(nameof(catalog)); var meter = meterFactory.Create(MeterName); _durableDuplicates = meter.CreateCounter<long>(DurableDuplicateCounterName); @@ -376,6 +379,44 @@ public async Task WritePlatformScopeAsync( } } + /// <inheritdoc /> + public async Task WriteEventTerminalAsync( + AuditEntryDraft entry, + EventTerminalTransition transition, + DbConnection connection, + DbTransaction transaction, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(transition); + transition.EnsureMatches(entry, connection, transaction); + + const string operation = "platform.outbox.dead_letter"; + if (!_catalog.TryGetOffPath(operation, out var declared) + || declared.Operation != operation || declared.ModuleName != "platform" + || declared.OperationClass != OperationClass.Must + || declared.OperationType != OperationType.SecurityEvent || declared.EntityType is not null + || entry.Operation != declared.Operation || entry.ModuleName != declared.ModuleName + || entry.OperationClass != declared.OperationClass || entry.OperationType != declared.OperationType + || transition.Kind != "producer" || transition.SubscriptionId is not null) + { + throw new ArgumentException("The terminal audit requires its declared producer MUST security event.", nameof(entry)); + } + + try + { + await using var command = connection.CreateCommand(); + command.CommandText = InsertSql; + command.Transaction = transaction; + Bind(command.Parameters, entry); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + catch (DbException failure) + { + throw new AuditWriteFailedException( + "The terminal audit could not be written; its state transition must roll back.", failure); + } + } + /// <summary> /// <c>BEGIN; SET LOCAL app.tenant_id; SET LOCAL app.organization_id; INSERT; COMMIT</c> /// on a connection of this call's own. @@ -429,8 +470,8 @@ private async Task WriteOwnTransactionAsync( /// <summary>Binds one row's parameters, for a command or for one command of a batch.</summary> /// <remarks> /// Onto the parameter collection rather than the command, because the in-transaction - /// path writes through a <see cref="DbBatch"/> and the other three through a - /// <see cref="DbCommand"/>, and one binding is what keeps the four writing one row + /// path writes through a <see cref="DbBatch"/> and the other four through a + /// <see cref="DbCommand"/>, and one binding is what keeps the five writing one row /// shape. /// </remarks> private static void Bind(DbParameterCollection parameters, AuditEntryDraft entry) diff --git a/backend/src/LearnStack.Infrastructure/Messaging/OutboxDispatchModels.cs b/backend/src/LearnStack.Infrastructure/Messaging/OutboxDispatchModels.cs new file mode 100644 index 00000000..785f63a1 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Messaging/OutboxDispatchModels.cs @@ -0,0 +1,21 @@ +using LearnStack.SharedKernel.Messaging; + +namespace LearnStack.Infrastructure.Messaging; + +public enum OutboxClaimKind { Normal, Recovery, Terminal } +public enum OutboxTerminalReason { UnknownType, RetiredType, InvalidEnvelope, RetryExhausted } +public enum OutboxFailureReason { PublishFailed, PublishTimeout, Shutdown, UnknownOutcome } + +/// <summary>A committed acquisition; its token is meaningful only until database-time expiry.</summary> +public sealed record OutboxDispatchClaim( + Guid Id, Guid TenantId, Guid? OrganizationId, string Token, int Attempts, + DateTimeOffset LockedUntil, OutboxClaimKind Kind, string Type, string Topic, + string PartitionKey, DateTimeOffset OccurredAt, string CorrelationId, + Guid? CausationId, Guid? ActorUserId, string Payload, bool MetadataIsNull); + +/// <summary>One database observation; gauge callbacks consume only this immutable value.</summary> +public sealed record OutboxQueueSnapshot(long PendingCount, long DeferredCount, double OldestEligibleAgeSeconds); + +public sealed record OutboxEnvelopeReadResult( + OutboxEventDisposition Disposition, string EventTypeLabel, + IntegrationEventEnvelope? Envelope, OutboxTerminalReason? TerminalReason); diff --git a/backend/src/LearnStack.Infrastructure/Messaging/OutboxDispatchOptions.cs b/backend/src/LearnStack.Infrastructure/Messaging/OutboxDispatchOptions.cs new file mode 100644 index 00000000..9ec2ffb5 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Messaging/OutboxDispatchOptions.cs @@ -0,0 +1,30 @@ +namespace LearnStack.Infrastructure.Messaging; + +/// <summary>Finite execution bounds for ADR-0046's single dispatcher.</summary> +public sealed record OutboxDispatchOptions +{ + public TimeSpan SqlTimeout { get; init; } = TimeSpan.FromSeconds(5); + public TimeSpan PublishTimeout { get; init; } = TimeSpan.FromSeconds(10); + public TimeSpan LeaseDuration { get; init; } = TimeSpan.FromSeconds(30); + public TimeSpan IdlePoll { get; init; } = TimeSpan.FromMilliseconds(200); + public TimeSpan ProgressTimeout { get; init; } = TimeSpan.FromSeconds(45); + public TimeSpan SnapshotInterval { get; init; } = TimeSpan.FromSeconds(5); + public TimeSpan ShutdownTimeout { get; init; } = TimeSpan.FromSeconds(30); + public int MaxOutstandingPublishes { get; init; } = 4; + + public void Validate() + { + if (SqlTimeout <= TimeSpan.Zero || SqlTimeout > TimeSpan.FromSeconds(5) + || PublishTimeout <= TimeSpan.Zero || PublishTimeout > TimeSpan.FromSeconds(10) + || LeaseDuration <= PublishTimeout + SqlTimeout + SqlTimeout + || LeaseDuration > TimeSpan.FromSeconds(30) + || IdlePoll <= TimeSpan.Zero || IdlePoll > TimeSpan.FromSeconds(1) + || SnapshotInterval <= TimeSpan.Zero || SnapshotInterval > TimeSpan.FromSeconds(5) + || ProgressTimeout <= LeaseDuration || ProgressTimeout > TimeSpan.FromSeconds(45) + || ShutdownTimeout <= TimeSpan.Zero || ShutdownTimeout > TimeSpan.FromSeconds(30) + || MaxOutstandingPublishes is < 1 or > 4) + { + throw new ArgumentException("Outbox execution bounds are invalid."); + } + } +} diff --git a/backend/src/LearnStack.Infrastructure/Messaging/OutboxDispatchStore.cs b/backend/src/LearnStack.Infrastructure/Messaging/OutboxDispatchStore.cs new file mode 100644 index 00000000..56f97079 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Messaging/OutboxDispatchStore.cs @@ -0,0 +1,290 @@ +using System.Data.Common; +using LearnStack.SharedKernel.Audit; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Time; +using Microsoft.Extensions.DependencyInjection; +using Npgsql; +using NpgsqlTypes; + +namespace LearnStack.Infrastructure.Messaging; + +/// <summary>Short, database-time fenced dispatcher transitions on its confined role.</summary> +public sealed class OutboxDispatchStore : EventTerminalTransition.MintingSource +{ + private const string Incomplete = "processed_at IS NULL AND dead_lettered_at IS NULL"; + private const string Normal = Incomplete + " AND terminal_reason IS NULL AND attempts < 6" + + " AND available_after <= clock_timestamp() AND locked_by IS NULL"; + private const string Admitted = " AND NOT (type = ANY(@pending)) AND NOT (partition_key = ANY(@blocked))"; + private const string ClaimColumns = "id, tenant_id, organization_id, locked_by, attempts, locked_until," + + " type, topic, partition_key, occurred_at, correlation_id, causation_id, actor_user_id, payload::text, metadata IS NULL"; + private const string Fence = "id = @id AND locked_by = @token AND locked_until > clock_timestamp()" + + " AND " + Incomplete; + + private readonly Lazy<NpgsqlDataSource> _source; + private readonly OutboxEventRegistry _registry; + private readonly OutboxDispatchOptions _options; + private readonly IGuidFactory _ids; + private readonly IClock _clock; + private readonly IServiceScopeFactory _scopes; + + public OutboxDispatchStore(Lazy<NpgsqlDataSource> source, OutboxEventRegistry registry, + OutboxDispatchOptions options, IGuidFactory ids, IClock clock, IServiceScopeFactory scopes) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(registry); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(ids); + ArgumentNullException.ThrowIfNull(clock); + ArgumentNullException.ThrowIfNull(scopes); + options.Validate(); + _source = source; + _registry = registry; + _options = options; + _ids = ids; + _clock = clock; + _scopes = scopes; + } + + public Task<OutboxDispatchClaim?> TryClaimAsync(IReadOnlyCollection<string> blockedPartitions, + CancellationToken cancellationToken = default) => + AcquireAsync(OutboxClaimKind.Normal, Normal, blockedPartitions, cancellationToken); + + public Task<OutboxDispatchClaim?> TryAcquireRecoveryAsync(IReadOnlyCollection<string> blockedPartitions, + CancellationToken cancellationToken = default) => + AcquireAsync(OutboxClaimKind.Recovery, Incomplete + " AND terminal_reason IS NULL AND" + + " ((locked_until IS NOT NULL AND locked_until <= clock_timestamp())" + + " OR (locked_by IS NULL AND attempts >= 6))", blockedPartitions, cancellationToken); + + public Task<OutboxDispatchClaim?> TryClaimTerminalAsync(IReadOnlyCollection<string> blockedPartitions, + CancellationToken cancellationToken = default) => + AcquireAsync(OutboxClaimKind.Terminal, Incomplete + " AND terminal_reason IS NOT NULL" + + " AND available_after <= clock_timestamp()" + + " AND (locked_until IS NULL OR locked_until <= clock_timestamp())", blockedPartitions, cancellationToken); + + private async Task<OutboxDispatchClaim?> AcquireAsync(OutboxClaimKind kind, string eligibility, + IReadOnlyCollection<string> blocked, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(blocked); + using var deadline = Deadline(cancellationToken); + await using var connection = await _source.Value.OpenConnectionAsync(deadline.Token).ConfigureAwait(false); + await using var transaction = await connection.BeginTransactionAsync(deadline.Token).ConfigureAwait(false); + await using var command = new NpgsqlCommand($""" + WITH candidate AS ( + SELECT id FROM outbox_messages WHERE {eligibility}{Admitted} + ORDER BY available_after, occurred_at, id FOR UPDATE SKIP LOCKED LIMIT 1 + ) + UPDATE outbox_messages SET locked_by = @token, + locked_until = clock_timestamp() + @lease, + attempts = attempts + @increment + WHERE id = (SELECT id FROM candidate) + RETURNING {ClaimColumns} + """, connection, transaction); + Configure(command); + AddAdmission(command, blocked); + command.Parameters.AddWithValue("token", _ids.NewUuidV4().ToString("D")); + command.Parameters.AddWithValue("lease", _options.LeaseDuration); + command.Parameters.AddWithValue("increment", kind == OutboxClaimKind.Normal ? 1 : 0); + OutboxDispatchClaim? result; + await using (var reader = await command.ExecuteReaderAsync(deadline.Token).ConfigureAwait(false)) + { + result = await reader.ReadAsync(deadline.Token).ConfigureAwait(false) ? ReadClaim(reader, kind) : null; + } + + await transaction.CommitAsync(deadline.Token).ConfigureAwait(false); + return result; + } + + public Task<bool> CompleteAsync(OutboxDispatchClaim claim, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(claim); + if (claim.Kind != OutboxClaimKind.Normal) + throw new ArgumentException("Only a normal publication claim can complete.", nameof(claim)); + return UpdateAsync(claim, "processed_at = clock_timestamp(), last_error = NULL," + + " locked_by = NULL, locked_until = NULL", " AND terminal_reason IS NULL", null, cancellationToken); + } + + public Task<bool> FailAsync(OutboxDispatchClaim claim, OutboxFailureReason reason, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(claim); + if (claim.Kind == OutboxClaimKind.Terminal) + throw new ArgumentException("Terminal requests cannot spend a publication retry.", nameof(claim)); + var descriptor = reason switch + { + OutboxFailureReason.PublishFailed => "publish:failed:provider", + OutboxFailureReason.PublishTimeout => "publish:timeout:deadline", + OutboxFailureReason.Shutdown => "publish:canceled:shutdown", + OutboxFailureReason.UnknownOutcome => "recovery:unknown:expired", + _ => throw new ArgumentOutOfRangeException(nameof(reason)), + }; + return UpdateAsync(claim, """ + terminal_reason = CASE WHEN attempts >= 6 THEN 'retry_exhausted' ELSE NULL END, + available_after = clock_timestamp() + CASE attempts + WHEN 1 THEN interval '1 second' WHEN 2 THEN interval '5 seconds' + WHEN 3 THEN interval '30 seconds' WHEN 4 THEN interval '5 minutes' + WHEN 5 THEN interval '1 hour' ELSE interval '0 seconds' END, + last_error = @descriptor, locked_by = NULL, locked_until = NULL + """, " AND terminal_reason IS NULL", descriptor, cancellationToken); + } + + public Task<bool> RequestTerminalAsync(OutboxDispatchClaim claim, OutboxTerminalReason reason, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(claim); + var code = TerminalCode(reason); + return UpdateAsync(claim, "terminal_reason = @reason, available_after = clock_timestamp()," + + " last_error = @descriptor, locked_by = NULL, locked_until = NULL", + " AND terminal_reason IS NULL", "terminal:" + code + ":closed", cancellationToken, code); + } + + private async Task<bool> UpdateAsync(OutboxDispatchClaim claim, string assignments, string extra, + string? descriptor, CancellationToken cancellationToken, string? reason = null) + { + using var deadline = Deadline(cancellationToken); + await using var connection = await _source.Value.OpenConnectionAsync(deadline.Token).ConfigureAwait(false); + await using var command = new NpgsqlCommand( + $"UPDATE outbox_messages SET {assignments} WHERE {Fence}{extra}", connection); + Configure(command); + AddFence(command, claim); + if (descriptor is not null) command.Parameters.AddWithValue("descriptor", descriptor); + if (reason is not null) command.Parameters.AddWithValue("reason", reason); + return await command.ExecuteNonQueryAsync(deadline.Token).ConfigureAwait(false) == 1; + } + + /// <summary>Fences, mints the actual returned-row witness, inserts MUST audit, then commits both.</summary> + public async Task<bool> TryTerminalizeAsync(OutboxDispatchClaim claim, string validatedAttemptTraceparent, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(claim); + if (claim.Kind != OutboxClaimKind.Terminal) + throw new ArgumentException("Terminalization requires its own acquisition.", nameof(claim)); + using var deadline = Deadline(cancellationToken); + await using var connection = await _source.Value.OpenConnectionAsync(deadline.Token).ConfigureAwait(false); + await using var transaction = await connection.BeginTransactionAsync(deadline.Token).ConfigureAwait(false); + await using var command = new NpgsqlCommand($""" + UPDATE outbox_messages SET dead_lettered_at = clock_timestamp(), + locked_by = NULL, locked_until = NULL + WHERE {Fence} AND terminal_reason IS NOT NULL + RETURNING id, tenant_id, organization_id, terminal_reason, attempts, + old.locked_by AS acquisition_token, correlation_id, causation_id, actor_user_id + """, connection, transaction); + Configure(command); + AddFence(command, claim); + EventTerminalTransition witness; + await using (var reader = await command.ExecuteReaderAsync(deadline.Token).ConfigureAwait(false)) + { + if (!await reader.ReadAsync(deadline.Token).ConfigureAwait(false)) return false; + witness = FromProducerUpdate(connection, transaction, reader, validatedAttemptTraceparent); + } + + await using var scope = _scopes.CreateAsyncScope(); + var audit = scope.ServiceProvider.GetRequiredService<IAuditStore>(); + var draft = new AuditEntryDraft + { + Id = AuditEntryId.From(_ids.NewUuidV7()), + TenantId = TenantId.From(witness.TenantId), + OrganizationId = witness.OrganizationId is { } organization ? OrganizationId.From(organization) : null, + ActorUserId = UserId.SystemActor, + ActorEmail = null, + ModuleName = "platform", + Operation = "platform.outbox.dead_letter", + OperationType = OperationType.SecurityEvent, + OperationClass = OperationClass.Must, + EntityType = null, + EntityId = null, + Outcome = AuditOutcome.Success, + ErrorKey = null, + Reason = null, + BeforeState = null, + AfterState = null, + Changes = null, + CorrelationId = witness.CorrelationId, + IpAddress = null, + UserAgent = null, + Timestamp = _clock.UtcNow, + Metadata = witness.Metadata, + }; + await audit.WriteEventTerminalAsync(draft, witness, connection, transaction, deadline.Token).ConfigureAwait(false); + try + { + await transaction.CommitAsync(deadline.Token).ConfigureAwait(false); + return true; + } + catch (Exception exception) when (exception is NpgsqlException or OperationCanceledException or IOException) + { + // COMMIT may have reached PostgreSQL. Reconcile on a fresh bounded connection; + // a durable terminal row already includes its audit and must never be inserted again. + if (await IsDurablyTerminalAsync(claim.Id).ConfigureAwait(false)) return true; + throw; + } + } + + private async Task<bool> IsDurablyTerminalAsync(Guid id) + { + using var deadline = Deadline(CancellationToken.None); + await using var connection = await _source.Value.OpenConnectionAsync(deadline.Token).ConfigureAwait(false); + await using var command = new NpgsqlCommand( + "SELECT EXISTS (SELECT 1 FROM outbox_messages WHERE id = @id AND dead_lettered_at IS NOT NULL)", connection); + Configure(command); + command.Parameters.AddWithValue("id", id); + return (bool)(await command.ExecuteScalarAsync(deadline.Token).ConfigureAwait(false))!; + } + + public async Task<OutboxQueueSnapshot> ReadSnapshotAsync(IReadOnlyCollection<string> blockedPartitions, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(blockedPartitions); + using var deadline = Deadline(cancellationToken); + await using var connection = await _source.Value.OpenConnectionAsync(deadline.Token).ConfigureAwait(false); + await using var command = new NpgsqlCommand($""" + SELECT count(*), count(*) FILTER (WHERE type = ANY(@pending)), + COALESCE(GREATEST(0, extract(epoch FROM clock_timestamp() - min(occurred_at) + FILTER (WHERE {Normal}{Admitted}))), 0)::double precision + FROM outbox_messages WHERE {Incomplete} + """, connection); + Configure(command); + AddAdmission(command, blockedPartitions); + await using var reader = await command.ExecuteReaderAsync(deadline.Token).ConfigureAwait(false); + await reader.ReadAsync(deadline.Token).ConfigureAwait(false); + return new(reader.GetInt64(0), reader.GetInt64(1), reader.GetDouble(2)); + } + + private static OutboxDispatchClaim ReadClaim(DbDataReader row, OutboxClaimKind kind) => + new(row.GetGuid(0), row.GetGuid(1), row.IsDBNull(2) ? null : row.GetGuid(2), + row.GetString(3), row.GetInt32(4), row.GetFieldValue<DateTimeOffset>(5), kind, + row.GetString(6), row.GetString(7), row.GetString(8), row.GetFieldValue<DateTimeOffset>(9), + row.GetString(10), row.IsDBNull(11) ? null : row.GetGuid(11), + row.IsDBNull(12) ? null : row.GetGuid(12), row.GetString(13), row.GetBoolean(14)); + + private CancellationTokenSource Deadline(CancellationToken cancellationToken) + { + var source = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + source.CancelAfter(_options.SqlTimeout); + return source; + } + + private void Configure(NpgsqlCommand command) => + command.CommandTimeout = Math.Max(1, (int)Math.Ceiling(_options.SqlTimeout.TotalSeconds)); + + private void AddAdmission(NpgsqlCommand command, IReadOnlyCollection<string> blocked) + { + command.Parameters.AddWithValue("pending", NpgsqlDbType.Array | NpgsqlDbType.Text, + _registry.PendingIdentities.ToArray()); + command.Parameters.AddWithValue("blocked", NpgsqlDbType.Array | NpgsqlDbType.Text, blocked.ToArray()); + } + + private static void AddFence(NpgsqlCommand command, OutboxDispatchClaim claim) + { + command.Parameters.AddWithValue("id", claim.Id); + command.Parameters.AddWithValue("token", claim.Token); + } + + private static string TerminalCode(OutboxTerminalReason reason) => reason switch + { + OutboxTerminalReason.UnknownType => "unknown_type", + OutboxTerminalReason.RetiredType => "retired_type", + OutboxTerminalReason.InvalidEnvelope => "invalid_envelope", + OutboxTerminalReason.RetryExhausted => "retry_exhausted", + _ => throw new ArgumentOutOfRangeException(nameof(reason)), + }; +} diff --git a/backend/src/LearnStack.Infrastructure/Messaging/OutboxEnvelopeReader.cs b/backend/src/LearnStack.Infrastructure/Messaging/OutboxEnvelopeReader.cs new file mode 100644 index 00000000..e7dc591a --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Messaging/OutboxEnvelopeReader.cs @@ -0,0 +1,52 @@ +using System.Text.Json; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Messaging; + +namespace LearnStack.Infrastructure.Messaging; + +/// <summary>Validates a stored fact using only the registry's declared CLR contract.</summary> +public sealed class OutboxEnvelopeReader(OutboxEventRegistry registry) +{ + public OutboxEnvelopeReadResult Read(OutboxDispatchClaim claim) + { + ArgumentNullException.ThrowIfNull(claim); + var resolution = registry.Resolve(claim.Type); + // A Pending hold precedes validation, including deliberately malformed payloads. + if (resolution.Disposition == OutboxEventDisposition.Pending) + return new(resolution.Disposition, resolution.EventTypeLabel, null, null); + if (resolution.Disposition == OutboxEventDisposition.Unknown) + return new(resolution.Disposition, "unknown", null, OutboxTerminalReason.UnknownType); + if (resolution.Disposition == OutboxEventDisposition.Retired) + return new(resolution.Disposition, "retired", null, OutboxTerminalReason.RetiredType); + + try + { + if (!claim.MetadataIsNull || claim.TenantId == Guid.Empty + || claim.TenantId == TenantId.PlatformSentinel.Value || claim.OrganizationId == Guid.Empty) + return Invalid(); + + var fact = JsonSerializer.Deserialize(claim.Payload, resolution.EventType!, + IntegrationEventBase.PayloadJsonOptions) as IntegrationEventBase; + if (fact is null) return Invalid(); + var serialized = OutboxEventContract.Serialize(fact); + if (serialized.EventId != claim.Id || serialized.TenantId != claim.TenantId + || serialized.Topic != claim.Topic || serialized.PartitionKey != claim.PartitionKey + || serialized.OccurredAt.UtcTicks / 10 != claim.OccurredAt.UtcTicks / 10) + return Invalid(); + + var envelope = new IntegrationEventEnvelope(fact, claim.CorrelationId, + claim.OrganizationId, claim.CausationId, + claim.ActorUserId is { } actor ? UserId.From(actor) : null); + return new(resolution.Disposition, resolution.EventTypeLabel, envelope, null); + } + catch (Exception exception) when (exception is JsonException or ArgumentException + or InvalidOperationException or NotSupportedException or KeyNotFoundException) + { + // Stored data is untrusted; neither its text nor parser diagnostics leave this boundary. + return Invalid(); + } + + static OutboxEnvelopeReadResult Invalid() => + new(OutboxEventDisposition.Unknown, "invalid", null, OutboxTerminalReason.InvalidEnvelope); + } +} diff --git a/backend/src/LearnStack.SharedKernel/Audit/EventTerminalTransition.cs b/backend/src/LearnStack.SharedKernel/Audit/EventTerminalTransition.cs new file mode 100644 index 00000000..f84db3c0 --- /dev/null +++ b/backend/src/LearnStack.SharedKernel/Audit/EventTerminalTransition.cs @@ -0,0 +1,168 @@ +using System.Buffers; +using System.Data; +using System.Data.Common; +using System.Diagnostics; +using System.Globalization; +using System.Text; +using System.Text.Json; +using LearnStack.SharedKernel.Identifiers; + +namespace LearnStack.SharedKernel.Audit; + +/// <summary>The immutable result of one fenced terminal UPDATE in its live transaction.</summary> +/// <remarks> +/// Construction is private; the protected minting seam is confined to the producer +/// transition store by the production architecture census. It grants no friend-assembly +/// access to other SharedKernel authority. Subscriber minting belongs to P02b-3. +/// </remarks> +public sealed class EventTerminalTransition +{ + private readonly DbConnection _connection; + private readonly DbTransaction _transaction; + + private EventTerminalTransition( + DbConnection connection, + DbTransaction transaction, + DbDataReader returnedRow, + string validatedAttemptTraceparent) + { + EnsureLiveTransaction(connection, transaction); + if (!TryCanonicalTraceparent(validatedAttemptTraceparent, out var attempt) + || !string.Equals(attempt, validatedAttemptTraceparent, StringComparison.Ordinal)) + { + throw new ArgumentException("The terminal attempt requires a canonical W3C traceparent."); + } + + if (returnedRow.FieldCount != 9) + { + throw new ArgumentException("The terminal UPDATE must return its complete bound row."); + } + + _connection = connection; + _transaction = transaction; + EventId = returnedRow.GetGuid(0); + TenantId = returnedRow.GetGuid(1); + OrganizationId = returnedRow.IsDBNull(2) ? null : returnedRow.GetGuid(2); + Reason = returnedRow.GetString(3); + Attempts = returnedRow.GetInt32(4); + AcquisitionToken = returnedRow.GetString(5); + + if (EventId == Guid.Empty || TenantId == Guid.Empty + || TenantId == Identifiers.TenantId.PlatformSentinel.Value + || OrganizationId == Guid.Empty || Attempts < 0 + || Reason is not ("unknown_type" or "retired_type" or "invalid_envelope" or "retry_exhausted") + || !Guid.TryParseExact(AcquisitionToken, "D", out var token) + || token == Guid.Empty + || !string.Equals(AcquisitionToken, token.ToString("D"), StringComparison.Ordinal)) + { + throw new ArgumentException("The terminal UPDATE returned invalid transition identity."); + } + + Guid? causationId = returnedRow.IsDBNull(7) ? null : returnedRow.GetGuid(7); + Guid? causalActorUserId = returnedRow.IsDBNull(8) ? null : returnedRow.GetGuid(8); + CorrelationId = TryCanonicalTraceparent( + returnedRow.IsDBNull(6) ? null : returnedRow.GetString(6), out var original) + && causationId != Guid.Empty && causalActorUserId != Guid.Empty + ? original : attempt; + Metadata = ComposeMetadata(causationId, causalActorUserId); + } + + public Guid EventId { get; } + public Guid TenantId { get; } + public Guid? OrganizationId { get; } + public string Reason { get; } + public int Attempts { get; } + public string AcquisitionToken { get; } + public string CorrelationId { get; } + public string Metadata { get; } + public string Kind { get; } = "producer"; + public string? SubscriptionId { get; } + + /// <summary>Refuses a draft or transaction that does not describe this exact transition.</summary> + public void EnsureMatches( + AuditEntryDraft entry, DbConnection connection, DbTransaction transaction) + { + ArgumentNullException.ThrowIfNull(entry); + ArgumentNullException.ThrowIfNull(connection); + ArgumentNullException.ThrowIfNull(transaction); + EnsureLiveTransaction(connection, transaction); + + if (!ReferenceEquals(_connection, connection) || !ReferenceEquals(_transaction, transaction) + || !entry.Id.IsInitialized() || entry.Id.Value == Guid.Empty + || !entry.TenantId.IsInitialized() || entry.TenantId.Value != TenantId + || (entry.OrganizationId is { } organization && !organization.IsInitialized()) + || entry.OrganizationId?.Value != OrganizationId + || !string.Equals(entry.Metadata, Metadata, StringComparison.Ordinal) + || !string.Equals(entry.CorrelationId, CorrelationId, StringComparison.Ordinal) + || entry.ActorUserId is not { } actor || !actor.IsInitialized() || actor != UserId.SystemActor + || entry.ActorEmail is not null + || entry.Outcome != AuditOutcome.Success || entry.ErrorKey is not null + || entry.Reason is not null || entry.EntityType is not null || entry.EntityId is not null + || entry.BeforeState is not null || entry.AfterState is not null || entry.Changes is not null + || entry.IpAddress is not null || entry.UserAgent is not null) + { + throw new ArgumentException("The terminal audit draft does not match its live transition.", nameof(entry)); + } + } + + /// <summary>The only construction capability; its production subtype and call site are closed.</summary> +#pragma warning disable CA1034 // Nesting permits private witness construction without SharedKernel friend assemblies. + public abstract class MintingSource +#pragma warning restore CA1034 + { + protected MintingSource() { } + + /// <summary>Reads the actual nine-column result of the successful fenced producer UPDATE.</summary> + protected static EventTerminalTransition FromProducerUpdate( + DbConnection connection, + DbTransaction transaction, + DbDataReader returnedRow, + string validatedAttemptTraceparent) + { + ArgumentNullException.ThrowIfNull(connection); + ArgumentNullException.ThrowIfNull(transaction); + ArgumentNullException.ThrowIfNull(returnedRow); + return new EventTerminalTransition(connection, transaction, returnedRow, validatedAttemptTraceparent); + } + } + + private static void EnsureLiveTransaction(DbConnection connection, DbTransaction transaction) + { + if (connection.State != ConnectionState.Open || !ReferenceEquals(transaction.Connection, connection)) + { + throw new ArgumentException("A terminal transition requires the caller's live transaction."); + } + } + + private string ComposeMetadata(Guid? causationId, Guid? causalActorUserId) + { + var buffer = new ArrayBufferWriter<byte>(); + using (var writer = new Utf8JsonWriter(buffer)) + { + writer.WriteStartObject(); + writer.WriteString("event_id", EventId); + writer.WriteString("reason", Reason); + writer.WriteNumber("attempts", Attempts); + if (causationId is { } causation && causation != Guid.Empty) + writer.WriteString("causation_id", causation); + if (causalActorUserId is { } actor && actor != Guid.Empty) + writer.WriteString("causal_actor_user_id", actor); + writer.WriteEndObject(); + } + + return Encoding.UTF8.GetString(buffer.WrittenSpan); + } + + private static bool TryCanonicalTraceparent(string? value, out string canonical) + { + if (ActivityContext.TryParse(value, traceState: null, out var context)) + { + canonical = string.Concat("00-", context.TraceId.ToHexString(), "-", context.SpanId.ToHexString(), + "-", ((byte)context.TraceFlags).ToString("x2", CultureInfo.InvariantCulture)); + return true; + } + + canonical = string.Empty; + return false; + } +} diff --git a/backend/src/LearnStack.SharedKernel/Audit/IAuditStore.cs b/backend/src/LearnStack.SharedKernel/Audit/IAuditStore.cs index 9503c46b..f8fcb625 100644 --- a/backend/src/LearnStack.SharedKernel/Audit/IAuditStore.cs +++ b/backend/src/LearnStack.SharedKernel/Audit/IAuditStore.cs @@ -4,7 +4,7 @@ namespace LearnStack.SharedKernel.Audit; /// <summary> -/// The only sanctioned way to put a row in <c>audit_log</c>. Four writes, and +/// The only sanctioned way to put a row in <c>audit_log</c>. Five writes, and /// <b>no update</b>. /// </summary> /// <remarks> @@ -26,6 +26,11 @@ namespace LearnStack.SharedKernel.Audit; /// path does not own, as a role the other three never use, and <em>before</em> the /// operation it describes. "No update method" is untouched — this is a fourth write. /// </para> +/// <para> +/// ADR-0046 adds <see cref="WriteEventTerminalAsync"/> as the fifth write: a +/// terminal transition and its MUST row share the dispatcher's own transaction, +/// with a restricted witness binding the draft to the actual fenced UPDATE. +/// </para> /// </remarks> public interface IAuditStore { @@ -119,4 +124,20 @@ Task WritePlatformScopeAsync( DbConnection connection, DbTransaction transaction, CancellationToken cancellationToken = default); + + /// <summary>Inserts a MUST terminal audit on the successful transition's live transaction.</summary> + /// <remarks> + /// The producer transition store is the only caller in P02b-2. The operation + /// must be declared off-path as MUST/SecurityEvent, and the draft must match + /// the actual UPDATE result carried by <paramref name="transition"/>. + /// Any mismatch or write failure requires the caller to roll back the state + /// transition. This seam opens and commits no transaction. P02b-3 owns the + /// future subscriber writer; the existing four writes retain their contracts. + /// </remarks> + Task WriteEventTerminalAsync( + AuditEntryDraft entry, + EventTerminalTransition transition, + DbConnection connection, + DbTransaction transaction, + CancellationToken cancellationToken = default); } diff --git a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Audit/TenancyAuditCatalogSource.cs b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Audit/TenancyAuditCatalogSource.cs index 22fe6e60..2d0df4f0 100644 --- a/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Audit/TenancyAuditCatalogSource.cs +++ b/backend/src/Modules/Tenancy/LearnStack.Modules.Tenancy.Application/Audit/TenancyAuditCatalogSource.cs @@ -15,7 +15,7 @@ namespace LearnStack.Modules.Tenancy.Application.Audit; /// <para> /// <b>Only the operations whose command exists.</b> /// <see href="../../../../../docs/modules/tenancy/audit.md">The matrix</see> carries -/// thirteen more rows marked <c>(planned)</c> — classification ahead of code — and +/// additional rows marked <c>(planned)</c> — classification ahead of code — and /// registering one of those would claim a writer that does not exist, which is the half /// of the join that has no way to notice. /// </para> @@ -78,6 +78,8 @@ public void Describe(IAuditCatalogBuilder builder) // to name the same one. .DeclareOffPath( "platform.admin_scope.enter", OperationType.SecurityEvent, OperationClass.Must) + .DeclareOffPath( + "platform.outbox.dead_letter", OperationType.SecurityEvent, OperationClass.Must) .DeclareOffPath( "tenancy.tenant_assertion.reject", OperationType.SecurityEvent, OperationClass.Must) .DeclareOffPath( diff --git a/backend/tests/LearnStack.Tests.Architecture/AuditConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/AuditConventionTests.cs index 66a31adc..46adcd5a 100644 --- a/backend/tests/LearnStack.Tests.Architecture/AuditConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/AuditConventionTests.cs @@ -305,7 +305,7 @@ public void Modules_Do_Not_Write_AuditLog_Directly() inserting.Should().Equal( ["LearnStack.Infrastructure.Audit/PostgresAuditStore.cs"], - "PostgresAuditStore's four writes are the only SQL that adds an audit row"); + "PostgresAuditStore's five writes are the only SQL that adds an audit row"); // The context's exemption is for the MAPPING: a change-tracker write placed inside // AuditDbContext would be invisible to the leg above, because its caller names only the @@ -515,13 +515,13 @@ public void The_AppendOnly_Sweep_Can_Actually_Fail() public void IAuditStore_Exposes_No_Update_Method() { // The other half of the same claim, and the one a reader checks first. ADR-0033 - // says four writes and no update; a fifth method whose name says otherwise would - // make the port contradict the table's own triggers. + // and ADR-0046 now authorize five writes and no update; another method would + // make the port contradict its closed write contract. typeof(IAuditStore).GetMethods() .Select(method => method.Name) .Should().BeEquivalentTo( "WritePendingAsync", "WriteStandaloneAsync", "WriteBestEffortAsync", - "WritePlatformScopeAsync"); + "WritePlatformScopeAsync", "WriteEventTerminalAsync"); } /// <summary> diff --git a/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchConventionTests.cs new file mode 100644 index 00000000..21585024 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchConventionTests.cs @@ -0,0 +1,603 @@ +using FluentAssertions; +using Mono.Cecil; +using Mono.Cecil.Cil; +using Xunit; + +namespace LearnStack.Tests.Architecture; + +/// <summary>ADR-0046: the dispatcher owns the restricted terminal-transition capability.</summary> +public sealed class OutboxDispatchConventionTests +{ + private const string KernelAssembly = "LearnStack.SharedKernel"; + private const string InfrastructureAssembly = "LearnStack.Infrastructure"; + private const string AuditAssembly = "LearnStack.Infrastructure.Audit"; + private const string Witness = "LearnStack.SharedKernel.Audit.EventTerminalTransition"; + private const string MintingSource = Witness + "/MintingSource"; + private const string Store = "LearnStack.Infrastructure.Messaging.OutboxDispatchStore"; + private const string AuditPort = "LearnStack.SharedKernel.Audit.IAuditStore"; + private const string AuditStore = "LearnStack.Infrastructure.Audit.PostgresAuditStore"; + private const string Mint = "FromProducerUpdate"; + private const string Terminalize = "TryTerminalizeAsync"; + private const string TerminalWrite = "WriteEventTerminalAsync"; + private static readonly string[] MintParameters = + [ + "System.Data.Common.DbConnection", "System.Data.Common.DbTransaction", + "System.Data.Common.DbDataReader", "System.String", + ]; + private static readonly string[] TerminalWriteParameters = + [ + "LearnStack.SharedKernel.Audit.AuditEntryDraft", Witness, + "System.Data.Common.DbConnection", "System.Data.Common.DbTransaction", + "System.Threading.CancellationToken", + ]; + private static readonly string[] ResolutionRoots = + [ + "LearnStack.Infrastructure.Messaging.OutboxEventRegistry", + "LearnStack.Infrastructure.Messaging.OutboxEnvelopeReader", + Store, + ]; + + /// <summary> + /// ADR-0046, Atomic terminal audit and privilege boundary: restricted minting and + /// fifth-write callers are closed over every production assembly. PostgreSQL tests + /// separately prove that the live UPDATE result and draft identity agree. + /// </summary> + [Fact] + public void Outbox_Terminal_Audit_Identity_Is_Row_Bound() + { + using var census = ProductionCensus.Read(); + TerminalOffenders(census).Should().BeEmpty( + "Fix: keep the immutable private witness, its sole dispatcher minting subtype, " + + "and the terminal writer/call sites closed; keep row/transaction identity checks in the real audit seam"); + } + + [Fact] + public void Terminal_census_detects_extra_derivers_constructors_and_mint_callers() + { + using var census = ProductionCensus.Read(); + var source = census.Types[(KernelAssembly, MintingSource)]; + var factory = source.Methods.Single(method => method.Name == Mint); + var constructor = census.Types[(KernelAssembly, Witness)].Methods.Single(method => method.IsConstructor); + var application = census.Modules.Single(module => module.Assembly.Name.Name == "LearnStack.Application"); + + AssertTerminalMutation(census, "a second minting subtype in another production assembly", () => + { + var type = AddProbe(application, "AdditionalMintingSource"); + type.BaseType = application.ImportReference(source); + return () => application.Types.Remove(type); + }); + AssertTerminalMutation(census, "an inherited minting subtype", () => + { + var parent = AddProbe(application, "AbstractMintingSource"); + parent.Attributes |= TypeAttributes.Abstract; + parent.BaseType = application.ImportReference(source); + var child = AddProbe(application, "InheritedMintingSource"); + child.BaseType = parent; + return () => { application.Types.Remove(child); application.Types.Remove(parent); }; + }); + foreach (var opcode in new[] { OpCodes.Call, OpCodes.Ldftn }) + { + AssertTerminalMutation(census, "a cross-assembly mint helper using " + opcode.Name, () => + AddCallProbe(application, "AdditionalMintCaller", factory, opcode)); + } + AssertTerminalMutation(census, "a direct private witness constructor call", () => + AddCallProbe(application, "AdditionalWitnessConstructor", constructor, OpCodes.Newobj)); + AssertTerminalMutation(census, "an async mint helper with an identified unauthorized owner", () => + AddAsyncCallProbe(application, "AsyncMintCaller", factory)); + AssertTerminalMutation(census, "a helper on the sanctioned dispatcher type", () => + { + var type = census.Types[(InfrastructureAssembly, Store)]; + var method = AddCall(type, "UnrelatedHelper", factory, OpCodes.Call); + return () => type.Methods.Remove(method); + }); + AssertTerminalMutation(census, "a generated name that has no real async owner", () => + { + var type = census.Types[(InfrastructureAssembly, Store)]; + var generated = new TypeDefinition(string.Empty, "<TryTerminalizeAsync>d__999", TypeAttributes.NestedPrivate); + generated.BaseType = type.Module.TypeSystem.Object; + type.NestedTypes.Add(generated); + AddCall(generated, "MoveNext", factory, OpCodes.Call); + return () => type.NestedTypes.Remove(generated); + }); + } + + [Fact] + public void Terminal_census_detects_interface_concrete_delegate_async_and_extra_writer_paths() + { + using var census = ProductionCensus.Read(); + var port = census.Types[(KernelAssembly, AuditPort)]; + var writer = census.Types[(AuditAssembly, AuditStore)]; + var interfaceWrite = port.Methods.Single(method => method.Name == TerminalWrite); + var concreteWrite = writer.Methods.Single(method => method.Name == TerminalWrite); + var application = census.Modules.Single(module => module.Assembly.Name.Name == "LearnStack.Application"); + + foreach (var target in new[] { interfaceWrite, concreteWrite }) + foreach (var opcode in new[] { OpCodes.Call, OpCodes.Callvirt, OpCodes.Ldftn, OpCodes.Ldvirtftn }) + { + AssertTerminalMutation(census, "an extra " + target.DeclaringType.Name + " caller using " + opcode.Name, () => + AddCallProbe(application, "AdditionalTerminalWriter", target, opcode)); + } + AssertTerminalMutation(census, "an async helper with an identified unauthorized owner", () => + AddAsyncCallProbe(application, "AsyncTerminalWriter", interfaceWrite)); + AssertTerminalMutation(census, "a second IAuditStore implementation", () => + { + var type = AddProbe(application, "AdditionalAuditStore"); + type.Interfaces.Add(new InterfaceImplementation(application.ImportReference(port))); + var method = new MethodDefinition(TerminalWrite, MethodAttributes.Public, + application.ImportReference(interfaceWrite.ReturnType)); + foreach (var parameter in interfaceWrite.Parameters) + method.Parameters.Add(new ParameterDefinition(application.ImportReference(parameter.ParameterType))); + type.Methods.Add(method); + return () => application.Types.Remove(type); + }); + AssertTerminalMutation(census, "an inherited concrete IAuditStore implementation", () => + { + var type = AddProbe(application, "InheritedAuditStore"); + type.BaseType = application.ImportReference(writer); + return () => application.Types.Remove(type); + }); + } + + [Fact] + public void Terminal_census_detects_mutability_accessibility_missing_subjects_and_friend_access() + { + using var census = ProductionCensus.Read(); + var witness = census.Types[(KernelAssembly, Witness)]; + var source = census.Types[(KernelAssembly, MintingSource)]; + var factory = source.Methods.Single(method => method.Name == Mint); + var constructor = witness.Methods.Single(method => method.IsConstructor); + AssertTerminalMutation(census, "an unsealed witness", () => + ChangeAttributes(() => witness.Attributes, value => witness.Attributes = value, + witness.Attributes & ~TypeAttributes.Sealed)); + AssertTerminalMutation(census, "a public witness constructor", () => + ChangeAttributes(() => constructor.Attributes, value => constructor.Attributes = value, + (constructor.Attributes & ~MethodAttributes.MemberAccessMask) | MethodAttributes.Public)); + AssertTerminalMutation(census, "a mutable witness field", () => + { + var field = witness.Fields.First(field => !field.IsStatic); + return ChangeAttributes(() => field.Attributes, value => field.Attributes = value, + field.Attributes & ~FieldAttributes.InitOnly); + }); + AssertTerminalMutation(census, "a witness setter", () => + { + var property = witness.Properties.First(); + var setter = new MethodDefinition("set_" + property.Name, MethodAttributes.Public, witness.Module.TypeSystem.Void); + var original = property.SetMethod; + property.SetMethod = setter; + return () => property.SetMethod = original; + }); + AssertTerminalMutation(census, "a public minting seam", () => + ChangeAttributes(() => factory.Attributes, value => factory.Attributes = value, + (factory.Attributes & ~MethodAttributes.MemberAccessMask) | MethodAttributes.Public)); + AssertTerminalMutation(census, "a caller-identity mint signature", () => + { + var parameter = factory.Parameters[2]; + var original = parameter.ParameterType; + parameter.ParameterType = witness.Module.TypeSystem.String; + return () => parameter.ParameterType = original; + }); + AssertTerminalMutation(census, "a caller-identity witness constructor", () => + { + var parameter = constructor.Parameters[2]; + var original = parameter.ParameterType; + parameter.ParameterType = witness.Module.TypeSystem.String; + return () => parameter.ParameterType = original; + }); + foreach (var name in new[] { Mint, TerminalWrite }) + { + AssertTerminalMutation(census, "an empty " + name + " call-site census", () => + { + var owner = census.Types.Values.SelectMany(type => type.Methods).Single(method => method.HasBody + && method.Body.Instructions.Any(instruction => instruction.Operand is MethodReference target + && target.Name == name)); + var instruction = owner.Body.Instructions.Single(instruction => instruction.Operand is MethodReference target + && target.Name == name); + var index = owner.Body.Instructions.IndexOf(instruction); + owner.Body.Instructions.RemoveAt(index); + return () => owner.Body.Instructions.Insert(index, instruction); + }); + } + AssertTerminalMutation(census, "a missing dispatcher", () => + { + var type = census.Types[(InfrastructureAssembly, Store)]; + var module = type.Module; + module.Types.Remove(type); + return () => module.Types.Add(type); + }); + AssertTerminalMutation(census, "a SharedKernel friend assembly", () => + { + var module = witness.Module; + var attribute = new CustomAttribute(module.ImportReference( + typeof(System.Runtime.CompilerServices.InternalsVisibleToAttribute).GetConstructor([typeof(string)])!)); + attribute.ConstructorArguments.Add(new CustomAttributeArgument(module.TypeSystem.String, "ReviewProbe")); + module.Assembly.CustomAttributes.Add(attribute); + return () => module.Assembly.CustomAttributes.Remove(attribute); + }); + AssertTerminalMutation(census, "an audit-store constructor without the required catalogue", () => + { + var type = census.Types[(AuditAssembly, AuditStore)]; + var method = new MethodDefinition(".ctor", MethodAttributes.Public | MethodAttributes.SpecialName + | MethodAttributes.RTSpecialName, type.Module.TypeSystem.Void); + type.Methods.Add(method); + return () => type.Methods.Remove(method); + }); + } + + /// <summary> + /// ADR-0046: persisted type text selects a declared registry entry. This bounded + /// production helper walk bans named dynamic-loading/construction APIs; it does + /// not claim arbitrary reflection or string-dataflow analysis. + /// </summary> + [Fact] + public void Outbox_Type_Resolution_Is_Declared_And_Bounded() + { + using var census = ProductionCensus.Read(); + ResolutionOffenders(census).Should().BeEmpty( + "Fix: resolve stored identities only through the declared registry and deserialize its EventType; " + + "remove dynamic type loading or Activator construction from the resolver/validator/store helper graph"); + } + + [Fact] + public void Resolution_walk_detects_loading_construction_async_and_polymorphic_helpers() + { + using var census = ProductionCensus.Read(); + var application = census.Modules.Single(module => module.Assembly.Name.Name == "LearnStack.Application"); + var root = census.Types[(InfrastructureAssembly, ResolutionRoots[0])]; + var targets = new[] + { + typeof(Type).GetMethod(nameof(Type.GetType), [typeof(string)])!, + typeof(System.Reflection.Assembly).GetMethod(nameof(System.Reflection.Assembly.Load), [typeof(string)])!, + typeof(System.Reflection.Assembly).GetMethod(nameof(System.Reflection.Assembly.LoadFrom), [typeof(string)])!, + typeof(System.Reflection.Assembly).GetMethod(nameof(System.Reflection.Assembly.GetType), [typeof(string)])!, + typeof(Activator).GetMethod(nameof(Activator.CreateInstance), [typeof(Type)])!, + }; + foreach (var target in targets) + foreach (var opcode in new[] { OpCodes.Call, OpCodes.Ldftn }) + { + AssertResolutionMutation(census, target.DeclaringType!.Name + "." + target.Name + " using " + opcode.Name, () => + { + var helper = AddProbe(application, "DynamicResolutionHelper"); + AddCall(helper, "Resolve", application.ImportReference(target), opcode); + var field = new FieldDefinition("ReviewHelper", FieldAttributes.Private, root.Module.ImportReference(helper)); + root.Fields.Add(field); + return () => { root.Fields.Remove(field); application.Types.Remove(helper); }; + }); + } + AssertResolutionMutation(census, "an async cross-assembly resolver helper", () => + { + var helper = AddProbe(application, "AsyncResolutionHelper"); + var undoAsync = AddAsyncCall(helper, "Resolve", application.ImportReference(targets[0])); + var field = new FieldDefinition("ReviewHelper", FieldAttributes.Private, root.Module.ImportReference(helper)); + root.Fields.Add(field); + return () => { root.Fields.Remove(field); undoAsync(); application.Types.Remove(helper); }; + }); + foreach (var isInterface in new[] { true, false }) + { + AssertResolutionMutation(census, "a " + (isInterface ? "interface" : "virtual") + " resolver helper", () => + { + var contract = AddProbe(application, "ResolutionContract"); + if (isInterface) contract.Attributes |= TypeAttributes.Interface | TypeAttributes.Abstract; + var helper = AddProbe(application, "ResolutionImplementation"); + if (isInterface) helper.Interfaces.Add(new InterfaceImplementation(contract)); + else helper.BaseType = contract; + AddCall(helper, "Resolve", application.ImportReference(targets[0]), OpCodes.Call); + var field = new FieldDefinition("ReviewHelper", FieldAttributes.Private, root.Module.ImportReference(contract)); + root.Fields.Add(field); + return () => { root.Fields.Remove(field); application.Types.Remove(helper); application.Types.Remove(contract); }; + }); + } + } + + [Fact] + public void Resolution_walk_preserves_declared_type_metadata_and_clean_helpers() + { + using var census = ProductionCensus.Read(); + var application = census.Modules.Single(module => module.Assembly.Name.Name == "LearnStack.Application"); + var root = census.Types[(InfrastructureAssembly, ResolutionRoots[0])]; + var helper = AddProbe(application, "DeclaredTypeHelper"); + AddCall(helper, "ReadKnownAssembly", application.ImportReference( + typeof(Type).GetProperty(nameof(Type.Assembly))!.GetMethod!), OpCodes.Callvirt); + AddCall(helper, "ReadRuntimeType", application.ImportReference( + typeof(object).GetMethod(nameof(GetType))!), OpCodes.Callvirt); + var field = new FieldDefinition("ReviewHelper", FieldAttributes.Private, root.Module.ImportReference(helper)); + root.Fields.Add(field); + try + { + ResolutionOffenders(census).Should().BeEmpty( + "Fix: preserve metadata reads of already declared types and clean cross-assembly helpers"); + } + finally + { + root.Fields.Remove(field); + application.Types.Remove(helper); + } + } + + private static SortedSet<string> TerminalOffenders(ProductionCensus census) + { + var offenders = new SortedSet<string>(StringComparer.Ordinal); + var types = census.Types; + CheckSet(offenders, "production assemblies", census.Modules.Select(module => module.Assembly.Name.Name), ProductionAssemblies.Names()); + foreach (var key in new[] { (KernelAssembly, Witness), (KernelAssembly, MintingSource), + (InfrastructureAssembly, Store), (KernelAssembly, AuditPort), (AuditAssembly, AuditStore) }) + { + if (!types.ContainsKey(key)) offenders.Add("Missing production subject: " + key.Item2); + } + if (offenders.Count != 0) return offenders; + + var witness = types[(KernelAssembly, Witness)]; + var source = types[(KernelAssembly, MintingSource)]; + var store = types[(InfrastructureAssembly, Store)]; + var port = types[(KernelAssembly, AuditPort)]; + var auditStore = types[(AuditAssembly, AuditStore)]; + var constructors = witness.Methods.Where(method => method.IsConstructor && !method.IsStatic).ToArray(); + if (!witness.IsSealed || witness.BaseType?.FullName != "System.Object") offenders.Add(Witness + ": must be sealed with no custom base"); + if (constructors.Length != 1 || constructors.Any(method => !method.IsPrivate || !ParametersMatch(method, MintParameters))) + offenders.Add(Witness + ": requires exactly one private live-reader constructor"); + if (witness.Fields.All(field => field.IsStatic) || witness.Fields.Any(field => !field.IsStatic && !field.IsInitOnly)) + offenders.Add(Witness + ": instance fields must be nonempty and readonly"); + if (witness.Properties.Count == 0 || witness.Properties.Any(property => property.SetMethod is not null)) + offenders.Add(Witness + ": properties must be nonempty and getter-only"); + if (!source.IsNestedPublic || !source.IsAbstract || source.IsSealed) + offenders.Add(MintingSource + ": requires the public abstract nested minting capability"); + if (source.Methods.Where(method => method.IsConstructor && !method.IsStatic).Any(method => !method.IsFamily)) + offenders.Add(MintingSource + ": constructors must be protected"); + if (witness.Module.Assembly.CustomAttributes.Any(attribute => + attribute.AttributeType.FullName == "System.Runtime.CompilerServices.InternalsVisibleToAttribute")) + offenders.Add(KernelAssembly + ": friend assemblies reopen restricted authority"); + + var factories = source.Methods.Where(method => method.Name == Mint).ToArray(); + if (factories.Length != 1 || factories.Any(method => !method.IsFamily || !method.IsStatic + || method.ReturnType.FullName != Witness || !ParametersMatch(method, MintParameters))) + offenders.Add(MintingSource + "." + Mint + ": requires the protected static live-reader mint signature"); + var terminalizers = store.Methods.Where(method => method.Name == Terminalize).ToArray(); + if (terminalizers.Length != 1) offenders.Add(Store + "." + Terminalize + ": requires one concrete terminalization owner"); + CheckSet(offenders, "minting subtypes", types.Values.Where(type => Derives(type, source, types)).Select(TypeId), [TypeId(store)]); + CheckSet(offenders, "witness-producing methods", types.Values.SelectMany(type => type.Methods) + .Where(method => method.ReturnType.FullName == Witness).Select(MethodId), factories.Select(MethodId)); + + var writers = types.Values.Where(type => !type.IsInterface && !type.IsAbstract && Derives(type, port, types)).ToArray(); + CheckSet(offenders, "audit store implementations", writers.Select(TypeId), [TypeId(auditStore)]); + var declarations = types.Values.Where(type => SameType(type, port) || Derives(type, port, types)) + .SelectMany(type => type.Methods).Where(IsTerminalWriteName).ToArray(); + CheckSet(offenders, "terminal audit declarations", declarations.Select(method => TypeId(method.DeclaringType) + "." + method.Name), + [TypeId(port) + "." + TerminalWrite, TypeId(auditStore) + "." + TerminalWrite]); + foreach (var method in declarations.Where(method => method.ReturnType.FullName != "System.Threading.Tasks.Task" + || !ParametersMatch(method, TerminalWriteParameters))) + offenders.Add(MethodId(method) + ": wrong fifth-write signature"); + var auditConstructors = auditStore.Methods.Where(method => method.IsConstructor && !method.IsStatic).ToArray(); + if (auditConstructors.Length == 0 || auditConstructors.Any(method => method.Parameters.All(parameter => + parameter.ParameterType.FullName != "LearnStack.SharedKernel.Audit.IAuditCatalog"))) + offenders.Add(AuditStore + ": every constructor must require IAuditCatalog"); + + var sites = CallSites(census).ToArray(); + CheckSet(offenders, "witness constructor call sites", sites.Where(site => site.Target.Name == ".ctor" + && SameType(site.Target.DeclaringType, witness)).Select(site => site.Owner), factories.Select(MethodId)); + CheckSet(offenders, "mint call sites", sites.Where(site => site.Target.Name == Mint + && (SameType(site.Target.DeclaringType, source) || Derives(site.Target.DeclaringType, source, types))) + .Select(site => site.Owner), terminalizers.Select(MethodId)); + CheckSet(offenders, "terminal audit call sites", sites.Where(site => IsTerminalWriteName(site.Target) + && (SameType(site.Target.DeclaringType, port) || Derives(site.Target.DeclaringType, port, types))) + .Select(site => site.Owner), terminalizers.Select(MethodId)); + return offenders; + } + + private static SortedSet<string> ResolutionOffenders(ProductionCensus census) + { + var offenders = new SortedSet<string>(StringComparer.Ordinal); + var types = census.Types; + CheckSet(offenders, "production assemblies", census.Modules.Select(module => module.Assembly.Name.Name), ProductionAssemblies.Names()); + foreach (var name in ResolutionRoots) + if (!types.ContainsKey((InfrastructureAssembly, name))) offenders.Add("Missing production resolution subject: " + name); + if (offenders.Count != 0) return offenders; + + var pending = new Queue<TypeDefinition>(ResolutionRoots.Select(name => types[(InfrastructureAssembly, name)])); + var visited = new HashSet<(string Assembly, string Type)>(); + while (pending.TryDequeue(out var type)) + { + if (!visited.Add(Key(type))) continue; + 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 MethodReference target && DynamicResolution(target)) + offenders.Add(MethodId(method) + " -> " + target.FullName); + } + foreach (var reference in Il.ReferencedTypeReferences(type)) + { + if (!types.TryGetValue(Key(reference), out var helper)) continue; + pending.Enqueue(helper); + if (helper.IsSealed) continue; + foreach (var implementation in types.Values.Where(candidate => !candidate.IsInterface + && Derives(candidate, helper, types))) + pending.Enqueue(implementation); + } + } + return offenders; + } + + private static bool DynamicResolution(MethodReference target) => target.DeclaringType.FullName switch + { + "System.Type" => target.Name == "GetType", + "System.Reflection.Assembly" => target.Name is "GetType" or "Load" or "LoadFrom" or "LoadFile" or "UnsafeLoadFrom", + "System.Activator" => target.Name is "CreateInstance" or "CreateInstanceFrom", + _ => false, + }; + + private static IEnumerable<(string Owner, MethodReference Target)> CallSites(ProductionCensus census) + { + // Attribute mapping is deliberate: a nested type merely named + // <TryTerminalizeAsync>d__123 must not inherit that owner's authority. + var asyncOwners = new Dictionary<(string Assembly, string Type), string>(); + foreach (var method in census.Types.Values.SelectMany(type => type.Methods)) + foreach (var attribute in method.CustomAttributes.Where(attribute => + attribute.AttributeType.FullName is "System.Runtime.CompilerServices.AsyncStateMachineAttribute" + or "System.Runtime.CompilerServices.IteratorStateMachineAttribute" + or "System.Runtime.CompilerServices.AsyncIteratorStateMachineAttribute")) + { + if (attribute.ConstructorArguments.FirstOrDefault().Value is TypeReference generated) + asyncOwners[Key(generated)] = MethodId(method); + } + foreach (var method in census.Types.Values.SelectMany(type => type.Methods).Where(method => method.HasBody)) + { + var owner = method.Name == "MoveNext" && asyncOwners.TryGetValue(Key(method.DeclaringType), out var original) + ? original : MethodId(method); + foreach (var instruction in method.Body.Instructions) + { + // Includes call/callvirt, newobj, ldftn and ldvirtftn. A delegate + // cannot hide a capability just because its invocation occurs later. + if (instruction.Operand is MethodReference target) yield return (owner, target); + } + } + } + + private static bool Derives(TypeReference candidate, TypeDefinition contract, + Dictionary<(string Assembly, string Type), TypeDefinition> types) + { + if (!types.TryGetValue(Key(candidate), out var definition)) return false; + var pending = new Queue<TypeReference>(definition.Interfaces.Select(item => item.InterfaceType)); + if (definition.BaseType is { } parent) pending.Enqueue(parent); + var visited = new HashSet<(string Assembly, string Type)>(); + while (pending.TryDequeue(out var reference)) + { + if (!visited.Add(Key(reference))) continue; + if (SameType(reference, contract)) return true; + if (!types.TryGetValue(Key(reference), out var inherited)) continue; + foreach (var item in inherited.Interfaces) pending.Enqueue(item.InterfaceType); + if (inherited.BaseType is { } baseType) pending.Enqueue(baseType); + } + return false; + } + + private static bool IsTerminalWriteName(MethodReference method) => + method.Name == TerminalWrite || method.Name.EndsWith("." + TerminalWrite, StringComparison.Ordinal); + + private static bool ParametersMatch(MethodReference method, string[] names) => + method.Parameters.Select(parameter => parameter.ParameterType.FullName).SequenceEqual(names, StringComparer.Ordinal); + + private static bool SameType(TypeReference left, TypeReference right) => Key(left) == Key(right); + + private static (string Assembly, string Type) Key(TypeReference reference) + { + if (reference is TypeSpecification specification) return Key(specification.ElementType); + return (reference.Scope is AssemblyNameReference assembly ? assembly.Name + : reference.Scope is ModuleDefinition module ? module.Assembly.Name.Name : reference.Scope.Name, + reference.FullName); + } + + private static string TypeId(TypeReference type) => Key(type).Assembly + ":" + type.FullName; + private static string MethodId(MethodReference method) => Key(method.DeclaringType).Assembly + ":" + method.FullName; + + private static void CheckSet(SortedSet<string> offenders, string label, IEnumerable<string> actual, IEnumerable<string> expected) + { + var found = actual.Order(StringComparer.Ordinal).ToArray(); + var required = expected.Order(StringComparer.Ordinal).ToArray(); + if (!found.SequenceEqual(required, StringComparer.Ordinal)) + offenders.Add(label + ": expected [" + string.Join(", ", required) + "]; actual [" + string.Join(", ", found) + "]"); + } + + private static void AssertTerminalMutation(ProductionCensus census, string description, Func<Action> mutate) => + AssertMutation(census, description, mutate, TerminalOffenders); + + private static void AssertResolutionMutation(ProductionCensus census, string description, Func<Action> mutate) => + AssertMutation(census, description, mutate, ResolutionOffenders); + + private static void AssertMutation(ProductionCensus census, string description, Func<Action> mutate, + Func<ProductionCensus, SortedSet<string>> detector) + { + detector(census).Should().BeEmpty("Fix: the planted control requires a clean nonempty production baseline"); + var undo = mutate(); + try + { + detector(census).Should().NotBeEmpty("Fix: the same production detector must reject " + description); + } + finally + { + undo(); + } + detector(census).Should().BeEmpty("Fix: removing the planted offender must restore a clean production census"); + } + + private static Action ChangeAttributes<T>(Func<T> read, Action<T> write, T replacement) + { + var original = read(); + write(replacement); + return () => write(original); + } + + private static TypeDefinition AddProbe(ModuleDefinition module, string name) + { + var type = new TypeDefinition("ReviewProbe", name, TypeAttributes.Public | TypeAttributes.Class, + module.TypeSystem.Object); + module.Types.Add(type); + return type; + } + + private static MethodDefinition AddCall(TypeDefinition type, string name, MethodReference target, OpCode opcode) + { + var method = new MethodDefinition(name, MethodAttributes.Public | MethodAttributes.Static, type.Module.TypeSystem.Void); + method.Body.Instructions.Add(Instruction.Create(opcode, type.Module.ImportReference(target))); + method.Body.Instructions.Add(Instruction.Create(OpCodes.Ret)); + type.Methods.Add(method); + return method; + } + + private static Action AddCallProbe(ModuleDefinition module, string name, MethodReference target, OpCode opcode) + { + var type = AddProbe(module, name); + AddCall(type, "Run", target, opcode); + return () => module.Types.Remove(type); + } + + private static Action AddAsyncCallProbe(ModuleDefinition module, string name, MethodReference target) + { + var type = AddProbe(module, name); + var undo = AddAsyncCall(type, "Run", target); + return () => { undo(); module.Types.Remove(type); }; + } + + private static Action AddAsyncCall(TypeDefinition type, string name, MethodReference target) + { + var module = type.Module; + var owner = new MethodDefinition(name, MethodAttributes.Public | MethodAttributes.Static, module.TypeSystem.Void); + type.Methods.Add(owner); + var generated = new TypeDefinition(string.Empty, "<" + name + ">d__999", TypeAttributes.NestedPrivate, + module.TypeSystem.Object); + type.NestedTypes.Add(generated); + AddCall(generated, "MoveNext", target, OpCodes.Call); + var attribute = new CustomAttribute(module.ImportReference( + typeof(System.Runtime.CompilerServices.AsyncStateMachineAttribute).GetConstructor([typeof(Type)])!)); + attribute.ConstructorArguments.Add(new CustomAttributeArgument(module.ImportReference(typeof(Type)), generated)); + owner.CustomAttributes.Add(attribute); + return () => { type.NestedTypes.Remove(generated); type.Methods.Remove(owner); }; + } + + private sealed class ProductionCensus : IDisposable + { + private ProductionCensus(List<ModuleDefinition> modules) => Modules = modules; + public List<ModuleDefinition> Modules { get; } + // Rebuild after each planted mutation so additions/removals are part of the + // same census rather than hidden behind the original type-map snapshot. + public Dictionary<(string Assembly, string Type), TypeDefinition> Types => + Modules.SelectMany(module => module.GetTypes()).ToDictionary(Key); + + public static ProductionCensus Read() + { + var modules = new List<ModuleDefinition>(); + try + { + foreach (var assembly in ProductionAssemblies.All()) modules.Add(ModuleDefinition.ReadModule(assembly.Location)); + return new ProductionCensus(modules); + } + catch + { + foreach (var module in modules) module.Dispose(); + throw; + } + } + + public void Dispose() + { + foreach (var module in Modules) module.Dispose(); + } + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/AuditStoreTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/AuditStoreTests.cs index 70e33408..a474336a 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/AuditStoreTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/AuditStoreTests.cs @@ -407,7 +407,8 @@ public async Task A_best_effort_failure_is_logged_and_dropped() new Lazy<NpgsqlDataSource>(() => dataSource), logger, MeterFactory, - health); + health, + new AuditCatalog([])); // TenantB is a real tenant, but the draft's own announcement makes the row legal — // so to force a failure the row has to break something the announcement cannot @@ -756,7 +757,8 @@ public async Task A_best_effort_write_swallows_a_data_source_that_cannot_be_buil new Lazy<NpgsqlDataSource>(() => throw new InvalidOperationException("no credential")), NullLogger<PostgresAuditStore>.Instance, MeterFactory, - new AuditHealth()); + new AuditHealth(), + new AuditCatalog([])); var bestEffort = async () => await store.WriteBestEffortAsync(Draft(organizationId: null)); @@ -940,7 +942,8 @@ public async Task A_standalone_failure_is_Critical_counted_and_leaves_the_check_ new Lazy<NpgsqlDataSource>(() => dataSource), logger, MeterFactory, - health); + health, + new AuditCatalog([])); var counted = 0L; using var listener = new MeterListener(); @@ -995,7 +998,8 @@ public async Task A_failure_the_store_cannot_translate_is_still_Critical_counted () => throw new InvalidOperationException("ConnectionStrings:Default is absent.")), logger, MeterFactory, - health); + health, + new AuditCatalog([])); var counted = 0L; using var listener = new MeterListener(); @@ -1112,7 +1116,7 @@ private static PostgresAuditStore Store(AuditStateCapture capture, NpgsqlDataSou private static PostgresAuditStore Store( AuditStateCapture capture, NpgsqlDataSource dataSource, IAuditHealth health) => new(capture, new Lazy<NpgsqlDataSource>(() => dataSource), - NullLogger<PostgresAuditStore>.Instance, MeterFactory, health); + NullLogger<PostgresAuditStore>.Instance, MeterFactory, health, new AuditCatalog([])); /// <summary>A real meter factory, so the counter the store increments is a real one.</summary> private static readonly IMeterFactory MeterFactory = diff --git a/backend/tests/LearnStack.Tests.Integration/Database/CommitResponseDroppingProxy.cs b/backend/tests/LearnStack.Tests.Integration/Database/CommitResponseDroppingProxy.cs new file mode 100644 index 00000000..7b1356a9 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/CommitResponseDroppingProxy.cs @@ -0,0 +1,164 @@ +using System.Buffers.Binary; +using System.Net; +using System.Net.Sockets; +using Npgsql; + +namespace LearnStack.Tests.Integration.Database; + +/// <summary> +/// Forwards a real PostgreSQL session, dropping exactly one completed COMMIT's +/// acknowledgement. It introduces an unknown client outcome after physical commit. +/// </summary> +internal sealed class CommitResponseDroppingProxy : IAsyncDisposable +{ + private readonly TcpListener _listener = new(IPAddress.Loopback, 0); + private readonly CancellationTokenSource _stopping = new(TimeSpan.FromSeconds(30)); + private readonly List<Task> _sessions = []; + private readonly string _targetHost; + private readonly int _targetPort; + private readonly Task _accepting; + private int _droppedCommitResponses; + private int _acceptedConnections; + + public CommitResponseDroppingProxy(string connectionString) + { + var target = new NpgsqlConnectionStringBuilder(connectionString); + _targetHost = target.Host ?? throw new ArgumentException("The test proxy requires a PostgreSQL host.", nameof(connectionString)); + _targetPort = target.Port; + _listener.Start(); + ConnectionString = new NpgsqlConnectionStringBuilder(connectionString) + { + Host = IPAddress.Loopback.ToString(), + Port = ((IPEndPoint)_listener.LocalEndpoint).Port, + // Test-only plaintext forwarding makes protocol frames observable; + // production credentials and data-source validation are unchanged. + SslMode = SslMode.Disable, + GssEncryptionMode = GssEncryptionMode.Disable, + Pooling = false, + Timeout = 5, + }.ConnectionString; + _accepting = AcceptAsync(); + } + + public string ConnectionString { get; } + public int DroppedCommitResponses => Volatile.Read(ref _droppedCommitResponses); + public int AcceptedConnections => Volatile.Read(ref _acceptedConnections); + + public async ValueTask DisposeAsync() + { + await _stopping.CancelAsync(); + _listener.Stop(); + try + { + await _accepting.WaitAsync(TimeSpan.FromSeconds(5)); + // AcceptAsync alone mutates the list; it has completed before this read. + await Task.WhenAll(_sessions).WaitAsync(TimeSpan.FromSeconds(5)); + } + finally + { + _stopping.Dispose(); + } + } + + private async Task AcceptAsync() + { + try + { + while (!_stopping.IsCancellationRequested) + { + var client = await _listener.AcceptTcpClientAsync(_stopping.Token); + Interlocked.Increment(ref _acceptedConnections); + _sessions.Add(ForwardSessionAsync(client)); + } + } + catch (OperationCanceledException) when (_stopping.IsCancellationRequested) + { + // Listener shutdown is an owned cancellation, not a swallowed fault. + } + catch (SocketException) when (_stopping.IsCancellationRequested) + { + // Stop can close the listener before cancellation reaches Accept. + } + } + + private async Task ForwardSessionAsync(TcpClient client) + { + using (client) + using (var server = new TcpClient()) + using (var stopping = CancellationTokenSource.CreateLinkedTokenSource(_stopping.Token)) + { + client.NoDelay = true; + server.NoDelay = true; + await server.ConnectAsync(_targetHost, _targetPort, stopping.Token); + await using var clientStream = client.GetStream(); + await using var serverStream = server.GetStream(); + var toServer = clientStream.CopyToAsync(serverStream, stopping.Token); + var toClient = ForwardResponsesAsync(serverStream, clientStream, stopping.Token); + var completed = await Task.WhenAny(toServer, toClient); + try + { + await completed; + } + catch (OperationCanceledException) when (_stopping.IsCancellationRequested) + { + // The proxy's finite lifetime or disposal owns this cancellation. + } + finally + { + await stopping.CancelAsync(); + client.Close(); + server.Close(); + await ObserveStoppedPumpAsync(toServer, stopping.Token); + await ObserveStoppedPumpAsync(toClient, stopping.Token); + } + } + } + + private async Task ForwardResponsesAsync(NetworkStream server, NetworkStream client, CancellationToken cancellationToken) + { + // Backend frames are a type byte, big-endian length including the length + // itself, then a body. CommandComplete 'C' carries a null-terminated tag. + // https://www.postgresql.org/docs/18/protocol-message-formats.html + var header = new byte[5]; + while (await server.ReadAsync(header.AsMemory(0, 1), cancellationToken) != 0) + { + await server.ReadExactlyAsync(header.AsMemory(1, 4), cancellationToken); + var length = BinaryPrimitives.ReadInt32BigEndian(header.AsSpan(1)); + if (length is < 4 or > 1_048_576) + throw new InvalidDataException("The test proxy received an invalid PostgreSQL response frame."); + + var body = new byte[length - 4]; + await server.ReadExactlyAsync(body, cancellationToken); + if (header[0] == (byte)'C' && body.AsSpan().SequenceEqual("COMMIT\0"u8) + && Interlocked.CompareExchange(ref _droppedCommitResponses, 1, 0) == 0) + { + // The real server has committed. Forward neither CommandComplete + // nor its following ReadyForQuery; disposal closes this socket. + return; + } + + await client.WriteAsync(header, cancellationToken); + await client.WriteAsync(body, cancellationToken); + } + } + + private static async Task ObserveStoppedPumpAsync(Task pump, CancellationToken stopped) + { + try + { + await pump; + } + catch (OperationCanceledException) when (stopped.IsCancellationRequested) + { + // The other pump ended and this socket's ownership has been canceled. + } + catch (IOException) when (stopped.IsCancellationRequested) + { + // Closing an owned socket also wakes a pending read/write with IO failure. + } + catch (ObjectDisposedException) when (stopped.IsCancellationRequested) + { + // Cleanup can close the socket before the canceled pump resumes. + } + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/NoOpAuditStore.cs b/backend/tests/LearnStack.Tests.Integration/Database/NoOpAuditStore.cs index 7cffe483..193efbc1 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/NoOpAuditStore.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/NoOpAuditStore.cs @@ -31,6 +31,14 @@ public Task WriteStandaloneAsync(AuditEntryDraft entry, CancellationToken cancel public Task WriteBestEffortAsync(AuditEntryDraft entry, CancellationToken cancellationToken = default) => Task.CompletedTask; + public Task WriteEventTerminalAsync( + AuditEntryDraft entry, + EventTerminalTransition transition, + System.Data.Common.DbConnection connection, + System.Data.Common.DbTransaction transaction, + CancellationToken cancellationToken = default) => + throw new NotSupportedException("The terminal-event path has its own cases."); + public Task WritePlatformScopeAsync( AuditEntryDraft entry, DbConnection connection, diff --git a/backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatchStoreTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatchStoreTests.cs new file mode 100644 index 00000000..174c4c30 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatchStoreTests.cs @@ -0,0 +1,1073 @@ +using System.Data.Common; +using System.Text.Json; +using FluentAssertions; +using LearnStack.Infrastructure.Audit; +using LearnStack.Infrastructure.Messaging; +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.Messaging; +using LearnStack.SharedKernel.Persistence; +using LearnStack.SharedKernel.Time; +using LearnStack.Tools.Seeder; +using MediatR; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Npgsql; +using NpgsqlTypes; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +/// <summary>Real committed leases, fenced transitions and witness-bound terminal audit.</summary> +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +[Collection(SharedSchema.Name)] +public sealed class OutboxDispatchStoreTests(SchemaFixture schema) +{ + private const string Traceparent = "00-11111111111111111111111111111111-2222222222222222-01"; + private const string AttemptTraceparent = "00-33333333333333333333333333333333-4444444444444444-01"; + private const string EarlierTenantIdentity = "LearnStack.Previous.TenantCreatedIntegrationEventV1, LearnStack.Previous.Contracts"; + private static readonly DateTimeOffset Occurred = new(2026, 10, 1, 12, 0, 0, TimeSpan.Zero); + private static readonly IReadOnlyCollection<string> NoBlockedPartitions = Array.Empty<string>(); + + [Fact] + public async Task Two_independent_stores_contend_for_one_committed_live_lease() + { + await using var test = await TestDatabase.CreateAsync(schema.Postgres); + var id = await test.InsertAsync(); + var first = test.Store(); + var second = test.Store(); + + var claims = await Task.WhenAll(first.TryClaimAsync(NoBlockedPartitions), second.TryClaimAsync(NoBlockedPartitions)); + + var claim = claims.OfType<OutboxDispatchClaim>().Should().ContainSingle().Which; + claim.Id.Should().Be(id); + claim.Kind.Should().Be(OutboxClaimKind.Normal); + claim.Attempts.Should().Be(1); + Guid.ParseExact(claim.Token, "D").Should().NotBeEmpty(); + var durable = await test.ReadAsync(id); + durable.Token.Should().Be(claim.Token); + durable.Attempts.Should().Be(1); + durable.LockedUntil.Should().Be(claim.LockedUntil); + // Reading the winner must not release the database lease. This second poll + // catches a release-on-read implementation even if the first race serialized. + (await first.TryClaimAsync(NoBlockedPartitions)).Should().BeNull(); + (await second.TryClaimAsync(NoBlockedPartitions)).Should().BeNull(); + (await test.ReadAsync(id)).Should().Be(durable); + } + + [Fact] + public async Task Concurrent_stores_claim_distinct_rows_without_owning_a_batch() + { + await using var test = await TestDatabase.CreateAsync(schema.Postgres); + var ids = new[] { await test.InsertAsync(), await test.InsertAsync() }; + + var claims = await Task.WhenAll(test.Store().TryClaimAsync(NoBlockedPartitions), test.Store().TryClaimAsync(NoBlockedPartitions)); + + claims.Should().OnlyContain(claim => claim != null); + claims.Select(claim => claim!.Id).Should().BeEquivalentTo(ids); + claims.Select(claim => claim!.Token).Should().OnlyHaveUniqueItems(); + (await test.Store().TryClaimAsync(NoBlockedPartitions)).Should().BeNull(); + } + + [Fact] + public async Task A_live_success_commits_processed_state_and_clears_its_lease_once() + { + await using var test = await TestDatabase.CreateAsync(schema.Postgres); + var id = await test.InsertAsync(); + var store = test.Store(); + var claim = (await store.TryClaimAsync(NoBlockedPartitions))!; + + (await store.CompleteAsync(claim)).Should().BeTrue(); + + var row = await test.ReadAsync(id); + row.ProcessedAt.Should().NotBeNull(); + row.DeadLetteredAt.Should().BeNull(); + row.TerminalReason.Should().BeNull(); + row.Token.Should().BeNull(); + row.LockedUntil.Should().BeNull(); + row.Attempts.Should().Be(1); + (await store.CompleteAsync(claim)).Should().BeFalse(); + (await store.FailAsync(claim, OutboxFailureReason.PublishFailed)).Should().BeFalse(); + (await store.TryClaimAsync(NoBlockedPartitions)).Should().BeNull(); + (await test.ReadAsync(id)).Should().Be(row); + (await test.AuditCountAsync()).Should().Be(0); + } + + [Theory] + [InlineData("success")] + [InlineData("failure")] + [InlineData("terminal-request")] + public async Task Expiry_alone_refuses_every_transition_without_changing_state(string transition) + { + await using var test = await TestDatabase.CreateAsync(schema.Postgres); + var id = await test.InsertAsync(); + var store = test.Store(); + var claim = (await store.TryClaimAsync(NoBlockedPartitions))!; + await test.ExpireAsync(id); + var before = await test.ReadAsync(id); + + (await TransitionAsync(store, claim, transition)).Should().BeFalse(); + + (await test.ReadAsync(id)).Should().Be(before); + (await test.AuditCountAsync()).Should().Be(0); + (await store.TryClaimAsync(NoBlockedPartitions)).Should().BeNull("an expired claim requires recovery before publication"); + } + + [Theory] + [InlineData("success")] + [InlineData("failure")] + [InlineData("terminal-request")] + public async Task Recovery_by_the_same_store_replaces_the_token_and_fences_every_stale_transition(string transition) + { + await using var test = await TestDatabase.CreateAsync(schema.Postgres); + var id = await test.InsertAsync(); + var store = test.Store(); + var stale = (await store.TryClaimAsync(NoBlockedPartitions))!; + await test.ExpireAsync(id); + + var recovery = (await store.TryAcquireRecoveryAsync(NoBlockedPartitions))!; + + recovery.Id.Should().Be(id); + recovery.Kind.Should().Be(OutboxClaimKind.Recovery); + recovery.Token.Should().NotBe(stale.Token); + recovery.Attempts.Should().Be(stale.Attempts); + var before = await test.ReadAsync(id); + (await TransitionAsync(store, stale, transition)).Should().BeFalse(); + (await test.ReadAsync(id)).Should().Be(before); + (await test.AuditCountAsync()).Should().Be(0); + (await store.FailAsync(recovery, OutboxFailureReason.UnknownOutcome)).Should().BeTrue(); + await test.MakeDueAsync(id); + var fresh = (await store.TryClaimAsync(NoBlockedPartitions))!; + fresh.Token.Should().NotBe(stale.Token).And.NotBe(recovery.Token); + fresh.Attempts.Should().Be(2); + (await store.CompleteAsync(fresh)).Should().BeTrue(); + } + + [Fact] + public async Task Expired_terminal_acquisition_loses_its_fence_before_and_after_fresh_terminal_reacquisition() + { + await using var test = await TestDatabase.CreateAsync(schema.Postgres); + var id = await test.InsertAsync(); + var store = test.Store(); + var normal = (await store.TryClaimAsync(NoBlockedPartitions))!; + (await store.RequestTerminalAsync(normal, OutboxTerminalReason.InvalidEnvelope)).Should().BeTrue(); + var stale = (await store.TryClaimTerminalAsync(NoBlockedPartitions))!; + await test.ExpireAsync(id); + var expired = await test.ReadAsync(id); + + (await store.TryTerminalizeAsync(stale, AttemptTraceparent)).Should().BeFalse(); + (await test.ReadAsync(id)).Should().Be(expired); + var fresh = (await store.TryClaimTerminalAsync(NoBlockedPartitions))!; + fresh.Token.Should().NotBe(stale.Token); + fresh.Attempts.Should().Be(1); + var reacquired = await test.ReadAsync(id); + (await store.TryTerminalizeAsync(stale, AttemptTraceparent)).Should().BeFalse(); + (await test.ReadAsync(id)).Should().Be(reacquired); + (await test.AuditCountAsync()).Should().Be(0); + (await store.TryTerminalizeAsync(fresh, AttemptTraceparent)).Should().BeTrue(); + (await test.AuditCountAsync()).Should().Be(1); + } + + [Theory] + [InlineData(1, 1)] + [InlineData(2, 5)] + [InlineData(3, 30)] + [InlineData(4, 300)] + [InlineData(5, 3600)] + public async Task Committed_failures_schedule_the_five_database_time_backoffs_without_sleeping(int attempt, int seconds) + { + await using var test = await TestDatabase.CreateAsync(schema.Postgres); + var id = await test.InsertAsync(attempts: attempt - 1); + // A caller clock decades away must not decide expiry or availability. + var store = test.Store(clock: new FixedClock(Occurred.AddYears(50))); + var claim = (await store.TryClaimAsync(NoBlockedPartitions))!; + claim.Attempts.Should().Be(attempt); + var before = await test.DatabaseNowAsync(); + + (await store.FailAsync(claim, OutboxFailureReason.PublishFailed)).Should().BeTrue(); + + var after = await test.DatabaseNowAsync(); + var row = await test.ReadAsync(id); + AssertBackoff(row, attempt, seconds, before, after); + (await store.TryClaimAsync(NoBlockedPartitions)).Should().BeNull(); + await test.MakeDueAsync(id); + (await store.TryClaimAsync(NoBlockedPartitions))!.Attempts.Should().Be(attempt + 1); + } + + [Theory] + [InlineData(1, 1)] + [InlineData(2, 5)] + [InlineData(3, 30)] + [InlineData(4, 300)] + [InlineData(5, 3600)] + public async Task Expired_claims_recover_into_backoff_without_spending_another_attempt(int attempt, int seconds) + { + await using var test = await TestDatabase.CreateAsync(schema.Postgres); + var id = await test.InsertAsync(attempts: attempt - 1); + var store = test.Store(); + var crashed = (await store.TryClaimAsync(NoBlockedPartitions))!; + await test.ExpireAsync(id); + (await store.TryClaimAsync(NoBlockedPartitions)).Should().BeNull(); + var recovery = (await store.TryAcquireRecoveryAsync(NoBlockedPartitions))!; + recovery.Attempts.Should().Be(attempt); + recovery.Token.Should().NotBe(crashed.Token); + var before = await test.DatabaseNowAsync(); + + (await store.FailAsync(recovery, OutboxFailureReason.UnknownOutcome)).Should().BeTrue(); + + AssertBackoff(await test.ReadAsync(id), attempt, seconds, before, await test.DatabaseNowAsync()); + (await store.TryAcquireRecoveryAsync(NoBlockedPartitions)).Should().BeNull(); + (await store.TryClaimAsync(NoBlockedPartitions)).Should().BeNull(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Failed_or_crashed_sixth_claim_requests_terminalization_and_never_claims_a_seventh(bool crash) + { + await using var test = await TestDatabase.CreateAsync(schema.Postgres); + var id = await test.InsertAsync(); + var store = test.Store(); + for (var attempt = 1; attempt <= 5; attempt++) + { + var earlier = (await store.TryClaimAsync(NoBlockedPartitions))!; + earlier.Attempts.Should().Be(attempt); + (await store.FailAsync(earlier, OutboxFailureReason.PublishFailed)).Should().BeTrue(); + await test.MakeDueAsync(id); + } + var sixth = (await store.TryClaimAsync(NoBlockedPartitions))!; + sixth.Attempts.Should().Be(6); + if (crash) + { + await test.ExpireAsync(id); + (await store.TryClaimAsync(NoBlockedPartitions)).Should().BeNull(); + sixth = (await store.TryAcquireRecoveryAsync(NoBlockedPartitions))!; + sixth.Kind.Should().Be(OutboxClaimKind.Recovery); + } + + (await store.FailAsync(sixth, crash ? OutboxFailureReason.UnknownOutcome : OutboxFailureReason.PublishTimeout)).Should().BeTrue(); + + var requested = await test.ReadAsync(id); + requested.Attempts.Should().Be(6); + requested.TerminalReason.Should().Be("retry_exhausted"); + requested.DeadLetteredAt.Should().BeNull(); + requested.Token.Should().BeNull(); + await test.MakeDueAsync(id); + (await store.TryClaimAsync(NoBlockedPartitions)).Should().BeNull(); + var terminal = (await store.TryClaimTerminalAsync(NoBlockedPartitions))!; + terminal.Kind.Should().Be(OutboxClaimKind.Terminal); + terminal.Attempts.Should().Be(6); + (await store.TryTerminalizeAsync(terminal, AttemptTraceparent)).Should().BeTrue(); + (await store.TryClaimAsync(NoBlockedPartitions)).Should().BeNull(); + (await test.ReadAsync(id)).Attempts.Should().Be(6); + (await test.AuditCountAsync()).Should().Be(1); + } + + [Fact] + public async Task Exhausted_unleased_legacy_work_is_terminal_only_without_an_extra_attempt() + { + await using var test = await TestDatabase.CreateAsync(schema.Postgres); + var id = await test.InsertAsync(attempts: 8); + var store = test.Store(); + + (await store.TryClaimAsync(NoBlockedPartitions)).Should().BeNull(); + var recovery = (await store.TryAcquireRecoveryAsync(NoBlockedPartitions))!; + recovery.Attempts.Should().Be(8); + (await store.FailAsync(recovery, OutboxFailureReason.UnknownOutcome)).Should().BeTrue(); + var terminal = (await store.TryClaimTerminalAsync(NoBlockedPartitions))!; + + terminal.Id.Should().Be(id); + terminal.Attempts.Should().Be(8); + (await store.TryTerminalizeAsync(terminal, AttemptTraceparent)).Should().BeTrue(); + (await test.ReadAsync(id)).TerminalReason.Should().Be("retry_exhausted"); + (await test.ReadAsync(id)).Attempts.Should().Be(8); + } + + [Theory] + [InlineData(OutboxTerminalReason.UnknownType, "unknown_type")] + [InlineData(OutboxTerminalReason.RetiredType, "retired_type")] + [InlineData(OutboxTerminalReason.InvalidEnvelope, "invalid_envelope")] + public async Task A_terminal_request_before_six_attempts_cannot_be_claimed_for_publication(OutboxTerminalReason reason, string storedReason) + { + await using var test = await TestDatabase.CreateAsync(schema.Postgres); + var id = await test.InsertAsync(); + var store = test.Store(); + var claim = (await store.TryClaimAsync(NoBlockedPartitions))!; + + (await store.RequestTerminalAsync(claim, reason)).Should().BeTrue(); + + var row = await test.ReadAsync(id); + row.TerminalReason.Should().Be(storedReason); + row.Attempts.Should().Be(1); + row.ProcessedAt.Should().BeNull(); + row.DeadLetteredAt.Should().BeNull(); + row.Token.Should().BeNull(); + row.LockedUntil.Should().BeNull(); + await test.MakeDueAsync(id); + (await store.TryClaimAsync(NoBlockedPartitions)).Should().BeNull(); + (await store.TryAcquireRecoveryAsync(NoBlockedPartitions)).Should().BeNull(); + var terminal = (await store.TryClaimTerminalAsync(NoBlockedPartitions))!; + terminal.Attempts.Should().Be(1); + terminal.Token.Should().NotBe(claim.Token); + (await store.TryTerminalizeAsync(terminal, AttemptTraceparent)).Should().BeTrue(); + } + + [Fact] + public async Task Refused_audit_rolls_back_the_terminal_update_and_retains_the_request_for_audited_retry() + { + await using var test = await TestDatabase.CreateAsync(schema.Postgres); + var id = await test.InsertAsync(organization: SchemaFixture.OrgA1); + var store = test.Store(); + var normal = (await store.TryClaimAsync(NoBlockedPartitions))!; + (await store.RequestTerminalAsync(normal, OutboxTerminalReason.InvalidEnvelope)).Should().BeTrue(); + var terminal = (await store.TryClaimTerminalAsync(NoBlockedPartitions))!; + var before = await test.ReadAsync(id); + + try + { + await test.AuditInsertGrantAsync(grant: false); + var terminalize = () => store.TryTerminalizeAsync(terminal, AttemptTraceparent); + await terminalize.Should().ThrowAsync<AuditWriteFailedException>(); + (await test.ReadAsync(id)).Should().Be(before, "the failed INSERT and terminal UPDATE share one transaction"); + (await test.AuditCountAsync()).Should().Be(0); + var snapshot = await store.ReadSnapshotAsync(NoBlockedPartitions); + snapshot.PendingCount.Should().Be(1); + snapshot.DeferredCount.Should().Be(0); + snapshot.OldestEligibleAgeSeconds.Should().Be(0); + await test.MakeDueAsync(id); + (await store.TryClaimAsync(NoBlockedPartitions)).Should().BeNull(); + await test.ExpireAsync(id); + (await store.TryClaimAsync(NoBlockedPartitions)).Should().BeNull("terminal intent survives time advancing"); + } + finally + { + await test.AuditInsertGrantAsync(grant: true); + } + + var retry = (await store.TryClaimTerminalAsync(NoBlockedPartitions))!; + retry.Token.Should().NotBe(terminal.Token); + retry.Attempts.Should().Be(1); + (await store.TryTerminalizeAsync(retry, AttemptTraceparent)).Should().BeTrue(); + (await test.AuditCountAsync()).Should().Be(1); + (await test.ReadAsync(id)).DeadLetteredAt.Should().NotBeNull(); + (await store.TryTerminalizeAsync(terminal, AttemptTraceparent)).Should().BeFalse(); + (await store.TryTerminalizeAsync(retry, AttemptTraceparent)).Should().BeFalse(); + (await store.TryClaimTerminalAsync(NoBlockedPartitions)).Should().BeNull(); + (await test.AuditCountAsync()).Should().Be(1, "a committed terminal retry cannot append another audit"); + } + + [Fact] + public async Task A_lost_physical_COMMIT_response_reconciles_durable_terminal_state_without_duplicate_audit() + { + await using var test = await TestDatabase.CreateAsync(schema.Postgres); + var id = await test.InsertAsync(organization: SchemaFixture.OrgA1); + var direct = test.Store(); + var normal = (await direct.TryClaimAsync(NoBlockedPartitions))!; + (await direct.RequestTerminalAsync(normal, OutboxTerminalReason.InvalidEnvelope)).Should().BeTrue(); + var terminal = (await direct.TryClaimTerminalAsync(NoBlockedPartitions))!; + // Acquisitions use the direct source. The first explicit COMMIT crossing + // this separate proxy can therefore only be the audited terminal COMMIT. + await using var proxy = new CommitResponseDroppingProxy(test.OutboxConnectionString); + await using var source = OutboxDispatcherDataSource.Build(proxy.ConnectionString); + var uncertain = test.Store(dispatchSource: source); + + (await uncertain.TryTerminalizeAsync(terminal, AttemptTraceparent).WaitAsync(TimeSpan.FromSeconds(15))) + .Should().BeTrue("the fresh reconciliation connection observes the already committed terminal state"); + + proxy.DroppedCommitResponses.Should().Be(1, "a real server COMMIT acknowledgement must have been suppressed"); + proxy.AcceptedConnections.Should().BeGreaterThanOrEqualTo(2, + "the broken commit connection cannot answer the durability reconciliation"); + var row = await test.ReadAsync(id); + row.DeadLetteredAt.Should().NotBeNull(); + row.ProcessedAt.Should().BeNull(); + row.Token.Should().BeNull(); + (await test.AuditCountAsync()).Should().Be(1); + (await uncertain.TryTerminalizeAsync(terminal, AttemptTraceparent)).Should().BeFalse(); + (await uncertain.TryClaimTerminalAsync(NoBlockedPartitions)).Should().BeNull(); + (await test.AuditCountAsync()).Should().Be(1); + proxy.DroppedCommitResponses.Should().Be(1); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Terminal_audit_uses_the_stored_tenant_organization_and_validated_causal_metadata(bool invalidCausalMetadata) + { + await using var test = await TestDatabase.CreateAsync(schema.Postgres); + var causation = invalidCausalMetadata ? Guid.Empty : Guid.CreateVersion7(); + var causalActor = invalidCausalMetadata ? Guid.Empty : SchemaFixture.Actor; + var id = await test.InsertAsync(organization: SchemaFixture.OrgA1, + correlation: invalidCausalMetadata ? "private-invalid-trace" : Traceparent, + causation: causation, actor: causalActor, payload: "{}"); + var store = test.Store(); + var claim = (await store.TryClaimAsync(NoBlockedPartitions))!; + (await store.RequestTerminalAsync(claim, OutboxTerminalReason.InvalidEnvelope)).Should().BeTrue(); + var terminal = (await store.TryClaimTerminalAsync(NoBlockedPartitions))!; + + (await store.TryTerminalizeAsync(terminal, AttemptTraceparent)).Should().BeTrue(); + + var audit = await test.ReadAuditAsync(); + audit.Tenant.Should().Be(SchemaFixture.TenantA); + audit.Organization.Should().Be(SchemaFixture.OrgA1); + audit.Actor.Should().Be(UserId.SystemActor.Value); + audit.Module.Should().Be("platform"); + audit.Operation.Should().Be("platform.outbox.dead_letter"); + audit.Class.Should().Be("Must"); + audit.Type.Should().Be("SecurityEvent"); + audit.Outcome.Should().Be("success"); + audit.Reason.Should().BeNull("the platform-entry reason field is not a terminal descriptor"); + audit.Correlation.Should().Be(invalidCausalMetadata ? AttemptTraceparent : Traceparent); + using var metadata = JsonDocument.Parse(audit.Metadata); + metadata.RootElement.GetProperty("event_id").GetGuid().Should().Be(id); + metadata.RootElement.GetProperty("reason").GetString().Should().Be("invalid_envelope"); + metadata.RootElement.GetProperty("attempts").GetInt32().Should().Be(1); + if (invalidCausalMetadata) + { + metadata.RootElement.TryGetProperty("causation_id", out _).Should().BeFalse(); + metadata.RootElement.TryGetProperty("causal_actor_user_id", out _).Should().BeFalse(); + audit.Metadata.Should().NotContain("private-invalid-trace"); + } + else + { + metadata.RootElement.GetProperty("causation_id").GetGuid().Should().Be(causation); + metadata.RootElement.GetProperty("causal_actor_user_id").GetGuid().Should().Be(causalActor); + } + (await test.ReadAsync(id)).DeadLetteredAt.Should().NotBeNull(); + } + + [Theory] + [InlineData("tenant")] + [InlineData("organization")] + [InlineData("event")] + [InlineData("operation")] + [InlineData("class")] + [InlineData("type")] + [InlineData("transaction")] + public async Task Substituting_a_draft_or_transaction_against_the_actual_UPDATE_witness_rolls_back_both_writes(string substitution) + { + await using var test = await TestDatabase.CreateAsync(schema.Postgres); + var id = await test.InsertAsync(organization: SchemaFixture.OrgA1); + var calls = 0; + var store = test.Store(decorate: inner => new TerminalAuditDecorator(inner, + async (draft, witness, connection, transaction, cancellationToken) => + { + calls++; + witness.EventId.Should().Be(id); + witness.TenantId.Should().Be(SchemaFixture.TenantA); + witness.OrganizationId.Should().Be(SchemaFixture.OrgA1); + // This witness came from the real successful UPDATE: its state is + // visible on that live bypass-role transaction before the INSERT. + await using var truth = new NpgsqlCommand( + "SELECT dead_lettered_at IS NOT NULL FROM outbox_messages WHERE id = @id", + (NpgsqlConnection)connection, (NpgsqlTransaction)transaction); + truth.Parameters.AddWithValue("id", id); + (await truth.ExecuteScalarAsync(cancellationToken)).Should().Be(true); + + if (substitution == "transaction") + { + await using var otherConnection = await test.DispatchSource.OpenConnectionAsync(cancellationToken); + await using var otherTransaction = await otherConnection.BeginTransactionAsync(cancellationToken); + await inner.WriteEventTerminalAsync(draft, witness, otherConnection, otherTransaction, cancellationToken); + return; + } + + var changed = substitution switch + { + "tenant" => draft with { TenantId = TenantId.From(SchemaFixture.TenantB) }, + "organization" => draft with { OrganizationId = OrganizationId.From(SchemaFixture.OrgA2) }, + "event" => draft with { Metadata = ReplaceEventIdentity(draft.Metadata!) }, + "operation" => draft with { Operation = "platform.delivery.dead_letter" }, + "class" => draft with { OperationClass = OperationClass.Should }, + "type" => draft with { OperationType = OperationType.Update }, + _ => throw new ArgumentOutOfRangeException(nameof(substitution)), + }; + await inner.WriteEventTerminalAsync(changed, witness, connection, transaction, cancellationToken); + })); + var normal = (await store.TryClaimAsync(NoBlockedPartitions))!; + (await store.RequestTerminalAsync(normal, OutboxTerminalReason.InvalidEnvelope)).Should().BeTrue(); + var terminal = (await store.TryClaimTerminalAsync(NoBlockedPartitions))!; + var before = await test.ReadAsync(id); + + var attempt = () => store.TryTerminalizeAsync(terminal, AttemptTraceparent); + await attempt.Should().ThrowAsync<ArgumentException>(); + + calls.Should().Be(1, "the refusal must exercise the real audit seam after a successful terminal UPDATE"); + (await test.ReadAsync(id)).Should().Be(before); + (await test.AuditCountAsync()).Should().Be(0); + // The same still-live terminal claim succeeds with the real unmodified seam. + (await test.Store().TryTerminalizeAsync(terminal, AttemptTraceparent)).Should().BeTrue(); + (await test.AuditCountAsync()).Should().Be(1); + } + + [Fact] + public async Task A_witness_from_a_committed_terminal_transaction_cannot_be_reused_on_a_later_real_transition() + { + await using var test = await TestDatabase.CreateAsync(schema.Postgres); + EventTerminalTransition? previous = null; + var calls = 0; + var store = test.Store(decorate: inner => new TerminalAuditDecorator(inner, + async (draft, witness, connection, transaction, cancellationToken) => + { + calls++; + var supplied = previous ?? witness; + await inner.WriteEventTerminalAsync(draft, supplied, connection, transaction, cancellationToken); + previous = witness; + })); + var firstId = await test.InsertAsync(); + var first = (await store.TryClaimAsync(NoBlockedPartitions))!; + (await store.RequestTerminalAsync(first, OutboxTerminalReason.InvalidEnvelope)).Should().BeTrue(); + var firstTerminal = (await store.TryClaimTerminalAsync(NoBlockedPartitions))!; + (await store.TryTerminalizeAsync(firstTerminal, AttemptTraceparent)).Should().BeTrue(); + previous.Should().NotBeNull(); + previous!.EventId.Should().Be(firstId); + var secondId = await test.InsertAsync(); + var second = (await store.TryClaimAsync(NoBlockedPartitions))!; + (await store.RequestTerminalAsync(second, OutboxTerminalReason.InvalidEnvelope)).Should().BeTrue(); + var secondTerminal = (await store.TryClaimTerminalAsync(NoBlockedPartitions))!; + var before = await test.ReadAsync(secondId); + + var attempt = () => store.TryTerminalizeAsync(secondTerminal, AttemptTraceparent); + await attempt.Should().ThrowAsync<ArgumentException>(); + + calls.Should().Be(2); + (await test.ReadAsync(secondId)).Should().Be(before); + (await test.AuditCountAsync()).Should().Be(1); + (await test.Store().TryTerminalizeAsync(secondTerminal, AttemptTraceparent)).Should().BeTrue(); + (await test.AuditCountAsync()).Should().Be(2); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(true, true)] + public async Task Real_TenantCreated_contracts_and_aliases_are_held_before_any_payload_validation(bool alias, bool malformed) + { + await using var test = await TestDatabase.CreateAsync(schema.Postgres, seedTenant: false); + await using (var scope = test.Provider.CreateAsyncScope()) + { + var result = await scope.ServiceProvider.GetRequiredService<ISender>().Send(new ProvisionTenantCommand( + TenantId.From(SchemaFixture.TenantA), "dispatch-probe", "Dispatch probe", + OrganizationId.From(SchemaFixture.OrgA1), "hq", "HQ")); + result.IsSuccess.Should().BeTrue(); + } + var produced = await test.SingleIdAsync(); + var fact = new TenantCreatedIntegrationEventV1 + { + EventId = Guid.CreateVersion7(), + TenantId = SchemaFixture.TenantA, + OccurredAt = Occurred, + }; + var id = await test.InsertAsync(fact: fact, + type: alias ? EarlierTenantIdentity : typeof(TenantCreatedIntegrationEventV1).AssemblyQualifiedName, + payload: malformed ? "{}" : fact.ToPayloadJson()); + var beforeProduced = await test.ReadAsync(produced); + var before = await test.ReadAsync(id); + var store = test.Store(registry: PendingRegistry()); + + for (var poll = 0; poll < 3; poll++) + { + (await store.TryClaimAsync(NoBlockedPartitions)).Should().BeNull(); + (await store.TryAcquireRecoveryAsync(NoBlockedPartitions)).Should().BeNull(); + (await store.TryClaimTerminalAsync(NoBlockedPartitions)).Should().BeNull(); + } + + (await test.ReadAsync(produced)).Should().Be(beforeProduced); + (await test.ReadAsync(id)).Should().Be(before); + before.Attempts.Should().Be(0); + before.Token.Should().BeNull(); + before.TerminalReason.Should().BeNull(); + var snapshot = await store.ReadSnapshotAsync(NoBlockedPartitions); + snapshot.PendingCount.Should().Be(2); + snapshot.DeferredCount.Should().Be(2); + snapshot.OldestEligibleAgeSeconds.Should().Be(0); + (await test.AuditCountAsync()).Should().Be(0, "provisioning audits are separate from the terminal operation"); + } + + [Fact] + public async Task An_intentionally_unsubscribed_contract_remains_eligible_for_explicit_acknowledgement() + { + await using var test = await TestDatabase.CreateAsync(schema.Postgres); + var id = await test.InsertAsync(); + var registry = EligibleRegistry(); + registry.Resolve(typeof(DispatchEventV1).AssemblyQualifiedName).Disposition + .Should().Be(OutboxEventDisposition.IntentionallyUnsubscribed); + var store = test.Store(registry: registry); + + var claim = (await store.TryClaimAsync(NoBlockedPartitions))!; + claim.Id.Should().Be(id); + (await store.CompleteAsync(claim)).Should().BeTrue(); + + (await test.ReadAsync(id)).ProcessedAt.Should().NotBeNull(); + (await test.AuditCountAsync()).Should().Be(0); + } + + [Fact] + public async Task Selection_orders_by_availability_then_occurrence_then_ID_regardless_of_insert_order() + { + await using var test = await TestDatabase.CreateAsync(schema.Postgres); + var low = Guid.Parse("10000000-0000-7000-8000-000000000001"); + var high = Guid.Parse("20000000-0000-7000-8000-000000000001"); + var olderOccurrence = Guid.Parse("30000000-0000-7000-8000-000000000001"); + var earlierAvailability = Guid.Parse("40000000-0000-7000-8000-000000000001"); + await test.InsertAsync(id: high, available: Occurred, occurred: Occurred); + await test.InsertAsync(id: low, available: Occurred, occurred: Occurred); + await test.InsertAsync(id: olderOccurrence, available: Occurred, occurred: Occurred.AddMinutes(-1)); + await test.InsertAsync(id: earlierAvailability, available: Occurred.AddMinutes(-1), occurred: Occurred.AddMinutes(1)); + var store = test.Store(); + var selected = new List<Guid>(); + + for (var poll = 0; poll < 4; poll++) + { + var claim = (await store.TryClaimAsync(NoBlockedPartitions))!; + selected.Add(claim.Id); + (await store.CompleteAsync(claim)).Should().BeTrue(); + } + + selected.Should().Equal(earlierAvailability, olderOccurrence, low, high); + } + + [Fact] + public async Task Delayed_failure_allows_later_work_on_the_same_partition_to_overtake() + { + await using var test = await TestDatabase.CreateAsync(schema.Postgres); + var partition = Guid.CreateVersion7(); + var first = await test.InsertAsync(partition: partition, available: Occurred); + var second = await test.InsertAsync(partition: partition, available: Occurred.AddMinutes(1)); + var store = test.Store(); + var failed = (await store.TryClaimAsync(NoBlockedPartitions))!; + failed.Id.Should().Be(first); + (await store.FailAsync(failed, OutboxFailureReason.PublishFailed)).Should().BeTrue(); + + var later = (await store.TryClaimAsync(NoBlockedPartitions))!; + + later.Id.Should().Be(second); + later.PartitionKey.Should().Be(failed.PartitionKey); + (await store.CompleteAsync(later)).Should().BeTrue(); + (await test.ReadAsync(first)).ProcessedAt.Should().BeNull(); + } + + [Fact] + public async Task Locally_blocked_partitions_exclude_normal_recovery_and_terminal_acquisition() + { + await using var test = await TestDatabase.CreateAsync(schema.Postgres); + var blocked = Guid.CreateVersion7(); + var id = await test.InsertAsync(partition: blocked); + IReadOnlyCollection<string> partitions = [blocked.ToString("D")]; + var store = test.Store(); + (await store.TryClaimAsync(partitions)).Should().BeNull(); + var normal = (await store.TryClaimAsync(NoBlockedPartitions))!; + await test.ExpireAsync(id); + (await store.TryAcquireRecoveryAsync(partitions)).Should().BeNull(); + var recovery = (await store.TryAcquireRecoveryAsync(NoBlockedPartitions))!; + (await store.RequestTerminalAsync(recovery, OutboxTerminalReason.InvalidEnvelope)).Should().BeTrue(); + (await store.TryClaimTerminalAsync(partitions)).Should().BeNull(); + var terminal = (await store.TryClaimTerminalAsync(NoBlockedPartitions))!; + terminal.Token.Should().NotBe(normal.Token).And.NotBe(recovery.Token); + (await store.TryTerminalizeAsync(terminal, AttemptTraceparent)).Should().BeTrue(); + } + + [Fact] + public async Task Queue_snapshot_counts_every_pending_state_but_ages_only_publish_eligible_work() + { + await using var test = await TestDatabase.CreateAsync(schema.Postgres); + var now = await test.DatabaseNowAsync(); + var due = await test.InsertAsync(occurred: now.AddMinutes(-5)); + await test.InsertAsync(available: now.AddHours(1), occurred: now.AddHours(-10)); + var leased = await test.InsertAsync(occurred: now.AddHours(-9)); + await test.SetStateAsync(leased, "locked_by = '11111111-1111-4111-8111-111111111111', locked_until = now() + interval '30 seconds'"); + var expired = await test.InsertAsync(occurred: now.AddHours(-8)); + await test.SetStateAsync(expired, "locked_by = '22222222-2222-4222-8222-222222222222', locked_until = now() - interval '1 second'"); + var requested = await test.InsertAsync(occurred: now.AddHours(-7)); + await test.SetStateAsync(requested, "terminal_reason = 'invalid_envelope'"); + await test.InsertAsync(attempts: 6, occurred: now.AddHours(-6)); + await test.InsertAsync(fact: new TenantCreatedIntegrationEventV1 + { + EventId = Guid.CreateVersion7(), + TenantId = SchemaFixture.TenantA, + OccurredAt = now.AddHours(-5), + }); + var blockedPartition = Guid.CreateVersion7(); + await test.InsertAsync(partition: blockedPartition, occurred: now.AddHours(-4)); + var processed = await test.InsertAsync(occurred: now.AddHours(-3)); + await test.SetStateAsync(processed, "processed_at = now()"); + var terminal = await test.InsertAsync(occurred: now.AddHours(-2)); + await test.SetStateAsync(terminal, "terminal_reason = 'unknown_type', dead_lettered_at = now()"); + var registry = OutboxEventRegistry.Create( + [OutboxEventRegistration.IntentionallyUnsubscribed(typeof(DispatchEventV1)), + OutboxEventRegistration.Required(typeof(TenantCreatedIntegrationEventV1), + [OutboxSubscriptionManifest.Pending("customization.initialize-content-type-default.v1")])], + [], IntegrationEventHandlerRegistry.Discover(), + [typeof(DispatchEventV1).Assembly, typeof(TenantCreatedIntegrationEventV1).Assembly]); + var store = test.Store(registry: registry); + IReadOnlyCollection<string> blocked = [blockedPartition.ToString("D")]; + var before = await test.DatabaseNowAsync(); + + var snapshot = await store.ReadSnapshotAsync(blocked); + + var after = await test.DatabaseNowAsync(); + snapshot.PendingCount.Should().Be(8, "delayed, leased, expired, requested, exhausted, deferred and blocked rows are pending"); + snapshot.DeferredCount.Should().Be(1); + snapshot.OldestEligibleAgeSeconds.Should().BeInRange( + (before - now.AddMinutes(-5)).TotalSeconds, (after - now.AddMinutes(-5)).TotalSeconds); + var claim = (await store.TryClaimAsync(blocked))!; + claim.Id.Should().Be(due, "snapshot eligibility must equal normal acquisition eligibility"); + (await store.CompleteAsync(claim)).Should().BeTrue(); + var excludedOnly = await store.ReadSnapshotAsync(blocked); + excludedOnly.PendingCount.Should().Be(7); + excludedOnly.DeferredCount.Should().Be(1); + excludedOnly.OldestEligibleAgeSeconds.Should().Be(0); + (await store.ReadSnapshotAsync(NoBlockedPartitions)).OldestEligibleAgeSeconds.Should().BeGreaterThan(14_000, + "removing the local hold admits the old blocked partition"); + } + + [Fact] + public async Task Empty_and_future_occurrence_snapshots_never_report_negative_eligible_age() + { + await using var test = await TestDatabase.CreateAsync(schema.Postgres); + var store = test.Store(); + (await store.ReadSnapshotAsync(NoBlockedPartitions)).Should().Be(new OutboxQueueSnapshot(0, 0, 0)); + var now = await test.DatabaseNowAsync(); + await test.InsertAsync(occurred: now.AddHours(1)); + + var snapshot = await store.ReadSnapshotAsync(NoBlockedPartitions); + + snapshot.PendingCount.Should().Be(1); + snapshot.DeferredCount.Should().Be(0); + snapshot.OldestEligibleAgeSeconds.Should().Be(0); + } + + [Fact] + public async Task Reserved_subscriber_terminal_operation_remains_unregistered() + { + await using var test = await TestDatabase.CreateAsync(schema.Postgres); + var catalog = test.Provider.GetRequiredService<IAuditCatalog>(); + catalog.TryGetOffPath("platform.outbox.dead_letter", out var producer).Should().BeTrue(); + producer.OperationClass.Should().Be(OperationClass.Must); + producer.OperationType.Should().Be(OperationType.SecurityEvent); + catalog.TryGetOffPath("platform.delivery.dead_letter", out _).Should().BeFalse(); + } + + private static Task<bool> TransitionAsync(OutboxDispatchStore store, OutboxDispatchClaim claim, string transition) => transition switch + { + "success" => store.CompleteAsync(claim), + "failure" => store.FailAsync(claim, OutboxFailureReason.PublishFailed), + "terminal-request" => store.RequestTerminalAsync(claim, OutboxTerminalReason.InvalidEnvelope), + _ => throw new ArgumentOutOfRangeException(nameof(transition)), + }; + + private static void AssertBackoff(Row row, int attempts, int seconds, DateTimeOffset before, DateTimeOffset after) + { + // Both endpoints are PostgreSQL readings, not elapsed wall-clock assertions. + // The interval must be the documented delay added by the intervening SQL. + row.AvailableAfter.Should().BeOnOrAfter(before.AddSeconds(seconds)); + row.AvailableAfter.Should().BeOnOrBefore(after.AddSeconds(seconds)); + row.Attempts.Should().Be(attempts); + row.Token.Should().BeNull(); + row.LockedUntil.Should().BeNull(); + row.TerminalReason.Should().BeNull(); + row.ProcessedAt.Should().BeNull(); + row.LastError.Should().NotBeNullOrWhiteSpace(); + } + + private static string ReplaceEventIdentity(string metadata) + { + var values = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(metadata)!; + values["event_id"] = JsonSerializer.SerializeToElement(Guid.CreateVersion7()); + return JsonSerializer.Serialize(values); + } + + private static OutboxEventRegistry EligibleRegistry() => OutboxEventRegistry.Create( + [OutboxEventRegistration.IntentionallyUnsubscribed(typeof(DispatchEventV1))], [], + IntegrationEventHandlerRegistry.Discover(), [typeof(DispatchEventV1).Assembly]); + + private static OutboxEventRegistry PendingRegistry() => OutboxEventRegistry.Create( + [OutboxEventRegistration.Required(typeof(TenantCreatedIntegrationEventV1), + [ + OutboxSubscriptionManifest.Pending("customization.initialize-content-type-default.v1"), + OutboxSubscriptionManifest.Pending("customization.initialize-taxonomy-default.v1"), + ], EarlierTenantIdentity)], [], IntegrationEventHandlerRegistry.Discover(), + [typeof(TenantCreatedIntegrationEventV1).Assembly]); + + [IntegrationEventPartition(nameof(SubjectId))] + public sealed record DispatchEventV1 : IntegrationEventBase + { + public required Guid SubjectId { get; init; } + public override string Topic => "learnstack.test.dispatch"; + public override string PartitionKey => SubjectId.ToString("D"); + } + + private sealed record Row( + Guid Id, int Attempts, string? Token, DateTimeOffset? LockedUntil, + DateTimeOffset AvailableAfter, DateTimeOffset? ProcessedAt, + string? TerminalReason, DateTimeOffset? DeadLetteredAt, string? LastError, + string Type, string Payload, string? Metadata); + + private sealed record AuditRow( + Guid Tenant, Guid? Organization, Guid Actor, string Module, string Operation, + string Type, string Class, string Outcome, string? Reason, string Correlation, string Metadata); + + private sealed class TestDatabase : IAsyncDisposable + { + private readonly DisposableSchemaDatabase _database; + private readonly NpgsqlDataSource _appSource; + + private TestDatabase(DisposableSchemaDatabase database) + { + _database = database; + _appSource = NpgsqlDataSource.Create(database.AppConnectionString); + DispatchSource = OutboxDispatcherDataSource.Build(database.OutboxConnectionString); + Provider = SeedComposition.Build(_appSource, null, NullLoggerFactory.Instance); + } + + public NpgsqlDataSource DispatchSource { get; } + public ServiceProvider Provider { get; } + public string OutboxConnectionString => _database.OutboxConnectionString; + + public static async Task<TestDatabase> CreateAsync(PostgresFixture postgres, bool seedTenant = true) + { + var test = new TestDatabase(await DisposableSchemaDatabase.CreateAsync(postgres)); + try + { + if (seedTenant) await test.SeedTenantAsync(); + return test; + } + catch + { + await test.DisposeAsync(); + throw; + } + } + + public OutboxDispatchStore Store( + OutboxEventRegistry? registry = null, IClock? clock = null, + Func<IAuditStore, IAuditStore>? decorate = null, NpgsqlDataSource? dispatchSource = null) => new( + new Lazy<NpgsqlDataSource>(() => dispatchSource ?? DispatchSource), registry ?? EligibleRegistry(), + new OutboxDispatchOptions(), new SystemGuidFactory(), clock ?? new FixedClock(Occurred), + decorate is null ? Provider.GetRequiredService<IServiceScopeFactory>() + : new DecoratingScopeFactory(Provider.GetRequiredService<IServiceScopeFactory>(), decorate)); + + public async Task<Guid> InsertAsync( + Guid? id = null, Guid? organization = null, int attempts = 0, + DateTimeOffset? available = null, DateTimeOffset? occurred = null, Guid? partition = null, + string? type = null, string? payload = null, string? correlation = null, + Guid? causation = null, Guid? actor = null, IntegrationEventBase? fact = null, + string? metadata = null) + { + fact ??= new DispatchEventV1 + { + EventId = id ?? Guid.CreateVersion7(), + TenantId = SchemaFixture.TenantA, + OccurredAt = occurred ?? Occurred, + SubjectId = partition ?? Guid.CreateVersion7(), + }; + await using var connection = await _appSource.OpenConnectionAsync(); + await using var transaction = await connection.BeginTransactionAsync(); + await SchemaQueries.AssertApplicationRoleAsync(connection); + await SchemaQueries.SetTenantAsync(connection, transaction, fact.TenantId); + if (organization is { } org) + await SchemaQueries.SetSettingAsync(connection, transaction, "app.organization_id", org.ToString("D")); + await using var insert = new NpgsqlCommand( + """ + INSERT INTO outbox_messages + (id, tenant_id, organization_id, occurred_at, correlation_id, + causation_id, actor_user_id, type, topic, partition_key, + payload, metadata, attempts, available_after) + VALUES (@id, @tenant, @organization, @occurred, @correlation, + @causation, @actor, @type, @topic, @partition, + @payload, @metadata, @attempts, @available) + """, connection, transaction); + insert.Parameters.AddWithValue("id", fact.EventId); + insert.Parameters.AddWithValue("tenant", fact.TenantId); + insert.Parameters.Add(new NpgsqlParameter("organization", NpgsqlDbType.Uuid) { Value = (object?)organization ?? DBNull.Value }); + insert.Parameters.AddWithValue("occurred", fact.OccurredAt); + insert.Parameters.AddWithValue("correlation", correlation ?? Traceparent); + insert.Parameters.Add(new NpgsqlParameter("causation", NpgsqlDbType.Uuid) { Value = (object?)causation ?? DBNull.Value }); + insert.Parameters.Add(new NpgsqlParameter("actor", NpgsqlDbType.Uuid) { Value = (object?)actor ?? DBNull.Value }); + insert.Parameters.AddWithValue("type", type ?? fact.GetType().AssemblyQualifiedName!); + insert.Parameters.AddWithValue("topic", fact.Topic); + insert.Parameters.AddWithValue("partition", fact.PartitionKey); + insert.Parameters.Add(new NpgsqlParameter("payload", NpgsqlDbType.Jsonb) { Value = payload ?? fact.ToPayloadJson() }); + insert.Parameters.Add(new NpgsqlParameter("metadata", NpgsqlDbType.Jsonb) { Value = (object?)metadata ?? DBNull.Value }); + insert.Parameters.AddWithValue("attempts", attempts); + insert.Parameters.AddWithValue("available", available ?? Occurred); + (await insert.ExecuteNonQueryAsync()).Should().Be(1); + await transaction.CommitAsync(); + return fact.EventId; + } + + public async Task<Row> ReadAsync(Guid id) + { + await using var connection = await DispatchSource.OpenConnectionAsync(); + await using var command = new NpgsqlCommand( + """ + SELECT id, attempts, locked_by, locked_until, available_after, + processed_at, terminal_reason, dead_lettered_at, last_error, + type, payload::text, metadata::text + FROM outbox_messages WHERE id = @id + """, connection); + command.Parameters.AddWithValue("id", id); + await using var reader = await command.ExecuteReaderAsync(); + (await reader.ReadAsync()).Should().BeTrue(); + return new Row(reader.GetGuid(0), reader.GetInt32(1), Text(reader, 2), Timestamp(reader, 3), + reader.GetFieldValue<DateTimeOffset>(4), Timestamp(reader, 5), Text(reader, 6), Timestamp(reader, 7), + Text(reader, 8), reader.GetString(9), reader.GetString(10), Text(reader, 11)); + } + + public async Task<Guid> SingleIdAsync() + { + await using var connection = await DispatchSource.OpenConnectionAsync(); + await using var command = new NpgsqlCommand("SELECT id FROM outbox_messages", connection); + await using var reader = await command.ExecuteReaderAsync(); + (await reader.ReadAsync()).Should().BeTrue(); + var id = reader.GetGuid(0); + (await reader.ReadAsync()).Should().BeFalse(); + return id; + } + + public Task ExpireAsync(Guid id) => UpdateAsync(id, "locked_until = now() - interval '1 second'"); + public Task MakeDueAsync(Guid id) => UpdateAsync(id, "available_after = now() - interval '1 second'"); + public Task SetStateAsync(Guid id, string assignment) => UpdateAsync(id, assignment); + + public async Task<DateTimeOffset> DatabaseNowAsync() + { + await using var connection = await DispatchSource.OpenConnectionAsync(); + await using var command = new NpgsqlCommand("SELECT clock_timestamp()", connection); + await using var reader = await command.ExecuteReaderAsync(); + (await reader.ReadAsync()).Should().BeTrue(); + return reader.GetFieldValue<DateTimeOffset>(0); + } + + public async Task AuditInsertGrantAsync(bool grant) + { + await using var connection = (NpgsqlConnection)await PostgresFixture.OpenAsync(_database.MigrationConnectionString); + await using var command = new NpgsqlCommand(grant + ? "GRANT INSERT ON audit_log TO learnstack_outbox_admin" + : "REVOKE INSERT ON audit_log FROM learnstack_outbox_admin", connection); + await command.ExecuteNonQueryAsync(); + } + + public async Task<long> AuditCountAsync() + { + await using var connection = await _appSource.OpenConnectionAsync(); + await using var transaction = await connection.BeginTransactionAsync(); + await SchemaQueries.AssertApplicationRoleAsync(connection); + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantA); + await SchemaQueries.SetSettingAsync(connection, transaction, "app.scope", "tenant"); + await using var command = new NpgsqlCommand( + "SELECT count(*) FROM audit_log WHERE operation = 'platform.outbox.dead_letter'", connection, transaction); + return (long)(await command.ExecuteScalarAsync())!; + } + + public async Task<AuditRow> ReadAuditAsync() + { + await using var connection = await _appSource.OpenConnectionAsync(); + await using var transaction = await connection.BeginTransactionAsync(); + await SchemaQueries.AssertApplicationRoleAsync(connection); + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantA); + await SchemaQueries.SetSettingAsync(connection, transaction, "app.organization_id", SchemaFixture.OrgA1.ToString("D")); + await using var command = new NpgsqlCommand( + """ + SELECT tenant_id, organization_id, actor_user_id, module, operation, + operation_type, operation_class, outcome, reason, correlation_id, metadata::text + FROM audit_log WHERE operation = 'platform.outbox.dead_letter' + """, connection, transaction); + await using var reader = await command.ExecuteReaderAsync(); + (await reader.ReadAsync()).Should().BeTrue(); + var row = new AuditRow(reader.GetGuid(0), reader.IsDBNull(1) ? null : reader.GetGuid(1), reader.GetGuid(2), + reader.GetString(3), reader.GetString(4), reader.GetString(5), reader.GetString(6), reader.GetString(7), + Text(reader, 8), reader.GetString(9), reader.GetString(10)); + (await reader.ReadAsync()).Should().BeFalse(); + return row; + } + + public async ValueTask DisposeAsync() + { + await Provider.DisposeAsync(); + await DispatchSource.DisposeAsync(); + await _appSource.DisposeAsync(); + await _database.DisposeAsync(); + } + + private async Task UpdateAsync(Guid id, string assignment) + { + // Assignments are closed test-owned statements; values remain parameters. + await using var connection = await DispatchSource.OpenConnectionAsync(); + await using var command = new NpgsqlCommand($"UPDATE outbox_messages SET {assignment} WHERE id = @id", connection); + command.Parameters.AddWithValue("id", id); + (await command.ExecuteNonQueryAsync()).Should().Be(1); + } + + private async Task SeedTenantAsync() + { + await using var connection = await _appSource.OpenConnectionAsync(); + await using var transaction = await connection.BeginTransactionAsync(); + await SchemaQueries.AssertApplicationRoleAsync(connection); + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantA); + await SchemaQueries.ExecuteAsync(connection, transaction, + """ + INSERT INTO tenants (id, slug, display_name, status, created_at, created_by, row_version) + VALUES (@tenant, 'dispatch-probe', 'Dispatch probe', 'Trial', now(), @actor, 0); + INSERT INTO organizations (id, tenant_id, slug, display_name, status, created_at, created_by, row_version) + VALUES (@organization, @tenant, 'hq', 'HQ', 'Active', now(), @actor, 0); + """, ("tenant", SchemaFixture.TenantA), ("organization", SchemaFixture.OrgA1), ("actor", UserId.SystemActor.Value)); + await transaction.CommitAsync(); + } + + private static string? Text(NpgsqlDataReader reader, int ordinal) => reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal); + private static DateTimeOffset? Timestamp(NpgsqlDataReader reader, int ordinal) => + reader.IsDBNull(ordinal) ? null : reader.GetFieldValue<DateTimeOffset>(ordinal); + } + + private sealed class DecoratingScopeFactory(IServiceScopeFactory inner, Func<IAuditStore, IAuditStore> decorate) : IServiceScopeFactory + { + public IServiceScope CreateScope() => new DecoratingScope(inner.CreateScope(), decorate); + } + + private sealed class DecoratingScope : IServiceScope, IAsyncDisposable + { + private readonly IServiceScope _inner; + + public DecoratingScope(IServiceScope inner, Func<IAuditStore, IAuditStore> decorate) + { + _inner = inner; + ServiceProvider = new DecoratingProvider(inner.ServiceProvider, decorate); + } + + public IServiceProvider ServiceProvider { get; } + public void Dispose() => _inner.Dispose(); + public ValueTask DisposeAsync() => _inner is IAsyncDisposable asyncDisposable + ? asyncDisposable.DisposeAsync() : DisposeSynchronously(); + + private ValueTask DisposeSynchronously() + { + _inner.Dispose(); + return ValueTask.CompletedTask; + } + } + + private sealed class DecoratingProvider(IServiceProvider inner, Func<IAuditStore, IAuditStore> decorate) : IServiceProvider + { + public object? GetService(Type serviceType) => serviceType == typeof(IAuditStore) + ? decorate(inner.GetRequiredService<IAuditStore>()) : inner.GetService(serviceType); + } + + private sealed class TerminalAuditDecorator( + IAuditStore inner, + Func<AuditEntryDraft, EventTerminalTransition, DbConnection, DbTransaction, CancellationToken, Task> terminal) : IAuditStore + { + public Task WritePendingAsync(IUnitOfWork unitOfWork, CancellationToken cancellationToken = default) => + inner.WritePendingAsync(unitOfWork, cancellationToken); + public Task WriteStandaloneAsync(AuditEntryDraft entry, CancellationToken cancellationToken = default) => + inner.WriteStandaloneAsync(entry, cancellationToken); + public Task WriteBestEffortAsync(AuditEntryDraft entry, CancellationToken cancellationToken = default) => + inner.WriteBestEffortAsync(entry, cancellationToken); + public Task WritePlatformScopeAsync(AuditEntryDraft entry, DbConnection connection, DbTransaction transaction, + CancellationToken cancellationToken = default) => inner.WritePlatformScopeAsync(entry, connection, transaction, cancellationToken); + public Task WriteEventTerminalAsync(AuditEntryDraft entry, EventTerminalTransition transition, DbConnection connection, + DbTransaction transaction, CancellationToken cancellationToken = default) => + terminal(entry, transition, connection, transaction, cancellationToken); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PlatformAdminScopeTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PlatformAdminScopeTests.cs index 029cbd46..8050a9f9 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PlatformAdminScopeTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PlatformAdminScopeTests.cs @@ -711,6 +711,7 @@ private static IServiceScopeFactory AuditStoreScopeFactory() => .AddLogging() .AddScoped<IAuditStateCapture, AuditStateCapture>() .AddSingleton<IAuditHealth, AuditHealth>() + .AddSingleton<IAuditCatalog>(new AuditCatalog([new TenancyAuditCatalogSource()])) .AddSingleton(new Lazy<NpgsqlDataSource>( () => throw new InvalidOperationException( "The platform-scope row is written on the scope's own connection, so " @@ -741,6 +742,14 @@ public Task WriteBestEffortAsync( AuditEntryDraft entry, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public Task WriteEventTerminalAsync( + AuditEntryDraft entry, + EventTerminalTransition transition, + System.Data.Common.DbConnection connection, + System.Data.Common.DbTransaction transaction, + CancellationToken cancellationToken = default) => + throw new NotSupportedException("The terminal-event path has its own cases."); + public Task WritePlatformScopeAsync( AuditEntryDraft entry, System.Data.Common.DbConnection connection, diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PublicReadTransactionTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PublicReadTransactionTests.cs index 136e2ca2..b0383421 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PublicReadTransactionTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PublicReadTransactionTests.cs @@ -315,7 +315,7 @@ private ServiceProvider BuildProvider(ILoggerFactory? injectedLogs = null) private static PostgresAuditStore Store(IServiceProvider services, AuditStateCapture capture) => new( capture, new Lazy<NpgsqlDataSource>(services.GetRequiredService<NpgsqlDataSource>), NullLogger<PostgresAuditStore>.Instance, - services.GetRequiredService<System.Diagnostics.Metrics.IMeterFactory>(), new AuditHealth()); + services.GetRequiredService<System.Diagnostics.Metrics.IMeterFactory>(), new AuditHealth(), new AuditCatalog([])); private static async Task AssertRoleAsync(IUnitOfWork unit) { diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenantAssertionAuditTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenantAssertionAuditTests.cs index db2c97d5..bdbdf611 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/TenantAssertionAuditTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantAssertionAuditTests.cs @@ -48,7 +48,8 @@ public async Task An_authenticated_mismatch_lands_a_denied_row_the_tenant_can_re new Lazy<NpgsqlDataSource>(() => dataSource), NullLogger<PostgresAuditStore>.Instance, MeterFactory, - new AuditHealth()); + new AuditHealth(), + new AuditCatalog([])); var recorder = Recorder(store, threshold: 10); @@ -99,7 +100,8 @@ public async Task An_anonymous_burst_lands_one_row_however_long_the_run_is() new Lazy<NpgsqlDataSource>(() => dataSource), NullLogger<PostgresAuditStore>.Instance, MeterFactory, - new AuditHealth()); + new AuditHealth(), + new AuditCatalog([])); var recorder = Recorder(store, threshold: 3); diff --git a/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/AuditingTenantAssertionRecorderTests.cs b/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/AuditingTenantAssertionRecorderTests.cs index 80ea90d6..fb4fd589 100644 --- a/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/AuditingTenantAssertionRecorderTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Api/Tenancy/AuditingTenantAssertionRecorderTests.cs @@ -259,7 +259,8 @@ public async Task The_real_store_reports_a_failure_once_and_the_recorder_does_no () => throw new InvalidOperationException("ConnectionStrings:Default is absent.")), storeLogger, meterFactory, - health); + health, + new AuditCatalog([])); var counted = 0L; using var listener = new MeterListener(); @@ -551,6 +552,14 @@ public Task WriteBestEffortAsync( AuditEntryDraft entry, CancellationToken cancellationToken = default) => throw new NotSupportedException("both slugs are MUST"); + public Task WriteEventTerminalAsync( + AuditEntryDraft entry, + EventTerminalTransition transition, + System.Data.Common.DbConnection connection, + System.Data.Common.DbTransaction transaction, + CancellationToken cancellationToken = default) => + throw new NotSupportedException("The terminal-event path has its own cases."); + public Task WritePlatformScopeAsync( AuditEntryDraft entry, System.Data.Common.DbConnection connection, diff --git a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/AuditPipelineHarness.cs b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/AuditPipelineHarness.cs index 5b57ea22..da0cd551 100644 --- a/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/AuditPipelineHarness.cs +++ b/backend/tests/LearnStack.Tests.Unit/Application/Pipeline/AuditPipelineHarness.cs @@ -167,6 +167,14 @@ public Task WriteBestEffortAsync(AuditEntryDraft entry, CancellationToken cancel return Task.CompletedTask; } + public Task WriteEventTerminalAsync( + AuditEntryDraft entry, + EventTerminalTransition transition, + System.Data.Common.DbConnection connection, + System.Data.Common.DbTransaction transaction, + CancellationToken cancellationToken = default) => + throw new NotSupportedException("The terminal-event path has its own cases."); + public Task WritePlatformScopeAsync( AuditEntryDraft entry, System.Data.Common.DbConnection connection, diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/OutboxDispatchOptionsTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/OutboxDispatchOptionsTests.cs new file mode 100644 index 00000000..7fe00b49 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/OutboxDispatchOptionsTests.cs @@ -0,0 +1,44 @@ +using FluentAssertions; +using LearnStack.Infrastructure.Messaging; +using Xunit; + +namespace LearnStack.Tests.Unit.Infrastructure.Messaging; + +public sealed class OutboxDispatchOptionsTests +{ + [Fact] + public void Approved_defaults_have_a_finite_publish_and_transition_margin() + { + var options = new OutboxDispatchOptions(); + options.Validate(); + options.LeaseDuration.Should().BeGreaterThan(options.PublishTimeout + options.SqlTimeout * 2); + options.MaxOutstandingPublishes.Should().Be(4); + } + + [Theory] + [InlineData("sql")] + [InlineData("publish")] + [InlineData("lease")] + [InlineData("poll")] + [InlineData("progress")] + [InlineData("snapshot")] + [InlineData("shutdown")] + [InlineData("capacity")] + public void Invalid_bounds_are_refused_before_runtime(string setting) + { + var options = new OutboxDispatchOptions(); + options = setting switch + { + "sql" => options with { SqlTimeout = TimeSpan.FromSeconds(6) }, + "publish" => options with { PublishTimeout = TimeSpan.Zero }, + "lease" => options with { LeaseDuration = TimeSpan.FromSeconds(20) }, + "poll" => options with { IdlePoll = Timeout.InfiniteTimeSpan }, + "progress" => options with { ProgressTimeout = TimeSpan.FromSeconds(46) }, + "snapshot" => options with { SnapshotInterval = TimeSpan.Zero }, + "shutdown" => options with { ShutdownTimeout = TimeSpan.FromSeconds(31) }, + "capacity" => options with { MaxOutstandingPublishes = 5 }, + _ => throw new ArgumentOutOfRangeException(nameof(setting)), + }; + options.Invoking(value => value.Validate()).Should().Throw<ArgumentException>(); + } +} diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/OutboxEnvelopeReaderTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/OutboxEnvelopeReaderTests.cs new file mode 100644 index 00000000..ea53bc80 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/OutboxEnvelopeReaderTests.cs @@ -0,0 +1,131 @@ +using System.Text.Json.Nodes; +using FluentAssertions; +using LearnStack.Infrastructure.Messaging; +using LearnStack.SharedKernel.Messaging; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace LearnStack.Tests.Unit.Infrastructure.Messaging; + +public sealed class OutboxEnvelopeReaderTests +{ + private const string Trace = "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01"; + private const string Alias = "test.previous.fact"; + + [Fact] + public void Intentional_absence_and_alias_validate_the_full_envelope_before_acknowledgement() + { + var fact = Fact(); + var claim = Claim(fact) with { Type = Alias, OrganizationId = Guid.NewGuid() }; + var result = Reader().Read(claim); + result.Disposition.Should().Be(OutboxEventDisposition.IntentionallyUnsubscribed); + result.TerminalReason.Should().BeNull(); + result.Envelope!.Event.Should().BeEquivalentTo(fact); + result.Envelope.OrganizationId.Should().Be(claim.OrganizationId); + + Reader().Read(claim with { CorrelationId = "not-a-trace" }).TerminalReason + .Should().Be(OutboxTerminalReason.InvalidEnvelope); + } + + [Fact] + public void Pending_precedes_even_malformed_payload_and_envelope_validation() + { + var reader = Reader(pending: true); + var result = reader.Read(Claim(Fact()) with { Payload = "{", CorrelationId = "invalid", Type = Alias }); + result.Disposition.Should().Be(OutboxEventDisposition.Pending); + result.Envelope.Should().BeNull(); + result.TerminalReason.Should().BeNull(); + } + + [Fact] + public void Unknown_and_explicitly_retired_identities_have_closed_distinct_reasons() + { + var claim = Claim(Fact()) with { Payload = "{" }; + Reader().Read(claim with { Type = "System.String, System.Private.CoreLib" }).TerminalReason + .Should().Be(OutboxTerminalReason.UnknownType); + Reader().Read(claim with { Type = "test.retired" }).TerminalReason + .Should().Be(OutboxTerminalReason.RetiredType); + } + + [Theory] + [InlineData("id")] + [InlineData("tenant")] + [InlineData("topic")] + [InlineData("partition")] + [InlineData("timestamp")] + [InlineData("organization")] + [InlineData("causation")] + [InlineData("actor")] + [InlineData("metadata")] + [InlineData("payload")] + public void Every_row_envelope_mismatch_refuses_without_parser_diagnostics(string mismatch) + { + var claim = Claim(Fact()); + claim = mismatch switch + { + "id" => claim with { Id = Guid.NewGuid() }, + "tenant" => claim with { TenantId = Guid.NewGuid() }, + "topic" => claim with { Topic = "learnstack.test.other" }, + "partition" => claim with { PartitionKey = Guid.NewGuid().ToString("D") }, + "timestamp" => claim with { OccurredAt = claim.OccurredAt.AddTicks(10) }, + "organization" => claim with { OrganizationId = Guid.Empty }, + "causation" => claim with { CausationId = Guid.Empty }, + "actor" => claim with { ActorUserId = Guid.Empty }, + "metadata" => claim with { MetadataIsNull = false }, + "payload" => claim with { Payload = "{\"private\":\"do-not-log\"" }, + _ => throw new ArgumentOutOfRangeException(nameof(mismatch)), + }; + var result = Reader().Read(claim); + result.TerminalReason.Should().Be(OutboxTerminalReason.InvalidEnvelope); + result.EventTypeLabel.Should().Be("invalid"); + result.Envelope.Should().BeNull(); + } + + [Fact] + public void PostgreSQL_microsecond_truncation_preserves_valid_occurrence_identity() + { + var fact = Fact() with + { + OccurredAt = DateTimeOffset.Parse("2026-10-12T12:00:00.1234567Z", System.Globalization.CultureInfo.InvariantCulture), + }; + var claim = Claim(fact) with { OccurredAt = fact.OccurredAt.AddTicks(-7) }; + Reader().Read(claim).Envelope.Should().NotBeNull(); + } + + [Fact] + public void Jsonb_whitespace_is_not_the_canonical_producer_byte_budget() + { + var claim = Claim(Fact()); + var root = JsonNode.Parse(claim.Payload)!; + claim = claim with { Payload = root.ToJsonString(new() { WriteIndented = true }) + new string(' ', 262_144) }; + Reader().Read(claim).Envelope.Should().NotBeNull(); + } + + private static OutboxEnvelopeReader Reader(bool pending = false) + { + var registration = pending + ? OutboxEventRegistration.Required(typeof(TestFact), [OutboxSubscriptionManifest.Pending("test.fact.v1")], Alias) + : OutboxEventRegistration.IntentionallyUnsubscribed(typeof(TestFact), Alias); + return new(OutboxEventRegistry.Create([registration], ["test.retired"], + IntegrationEventHandlerRegistry.FromServiceDescriptors(new ServiceCollection()), [typeof(TestFact).Assembly])); + } + + private static TestFact Fact() => new() + { + EventId = Guid.NewGuid(), + TenantId = Guid.NewGuid(), + OccurredAt = DateTimeOffset.UtcNow, + }; + + private static OutboxDispatchClaim Claim(TestFact fact) => new(fact.EventId, fact.TenantId, null, + Guid.NewGuid().ToString("D"), 1, DateTimeOffset.UtcNow.AddSeconds(30), OutboxClaimKind.Normal, + typeof(TestFact).AssemblyQualifiedName!, fact.Topic, fact.PartitionKey, fact.OccurredAt, + Trace, null, null, fact.ToPayloadJson(), true); + + [IntegrationEventPartition(nameof(TenantId), tenantWide: true)] + public sealed record TestFact : IntegrationEventBase + { + public override string Topic => "learnstack.test.fact"; + public override string PartitionKey => TenantId.ToString("D"); + } +} diff --git a/docs/architecture/15-event-and-outbox.md b/docs/architecture/15-event-and-outbox.md index 62a9b081..a4f92136 100644 --- a/docs/architecture/15-event-and-outbox.md +++ b/docs/architecture/15-event-and-outbox.md @@ -142,7 +142,9 @@ audit commit atomically; a restricted witness binds the audit to the actual fenc UPDATE result, because GUCs and INSERT-only cannot tenant-bind a bypass credential. The platform-admin gate and pre-entry audit remain unchanged. Four roles and separate credentials remain the closed model; the code-path and role rules are -Registered until their P02b-2 proof ships. +implemented for transition-witness construction and terminal audit in Step 2; +its independent reviews remain pending. Hosted execution and credential/publication +censuses remain Steps 3–4. <a id="inbox-table-per-consumer-module"></a> diff --git a/docs/architecture/31-audit-subsystem.md b/docs/architecture/31-audit-subsystem.md index cb7dd95e..051c31eb 100644 --- a/docs/architecture/31-audit-subsystem.md +++ b/docs/architecture/31-audit-subsystem.md @@ -80,7 +80,8 @@ for that second stage and withdrawn for the first. ### Confined event-terminal audit -**Accepted ADR-0046; fifth seam pending P02b-2.** Existing four delivered writes +**ADR-0046 fifth seam implemented in P02b-2 Step 2; reviews pending.** +Existing four delivered writes retain their contracts. The new narrow method is: ```csharp @@ -1628,7 +1629,7 @@ update it constrains. [ADR-0036](../decisions/0036-tenant-resolution-trusted-inputs.md)'s `tenancy.tenant_assertion.reject` and `tenancy.tenant_assertion.anonymous_burst`, and Audit's `audit.redaction.apply` and `audit.purge.apply`. They register by slug: the - three whose writer ships through `DeclareOffPath`, the other six — also + four whose writer ships through `DeclareOffPath`, the other five — also `(planned)` — when their writer lands. - *An `Off` registration carries no slug*, so it raises no matrix row and is outside both directions too (§ 5). @@ -1716,7 +1717,7 @@ for MUST-class would lock in exactly the defect ADR-0033 removes. |-------|-------------| | [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. | -| P02b-2 (Accepted; pending) | Confined producer terminal state and MUST audit through row-bound fifth seam; Audit-chain INSERT-only grant, bounded routine-dispatch exemption and decisive real-role atomicity/identity proofs. | +| P02b-2 Steps 1–2 | Schema/grants and confined producer transitions with MUST audit through the row-bound fifth seam; real-role atomicity/identity proofs. Hosted dispatch and metrics remain Step 3; Step 2 reviews pending. | | P02b-3 (Accepted contract; pending) | Shared transport-owned inbox and independent delivery ledger; per-subscription audit frames and Tenancy-owned terminal audit. Ordinary executor refuses privileged subscriptions. | | [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. | diff --git a/docs/glossary.md b/docs/glossary.md index 077ec499..d28c67c0 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -341,7 +341,7 @@ These entries distinguish P02d-2 contracts from P02d-3 internal read contracts: | **`IFeatureFlags`** | The **only** module-facing entitlement read: `IsEnabledAsync(FeatureKey)` and `GetLimitAsync(LimitKey)` returning `long`. It resolves the tenant from `ITenantContext` and throws when there is none, so module code can never name another tenant. It **composes** rather than queries: a plan-projected key resolves through `IEntitlementProvider.GetAsync` — not by reading `platform_entitlement_cache` — a tenant-flag key reads `tenant_feature_flags`, and the killswitch overlay is applied last and wins over both. Its implementation lives in `LearnStack.Modules.Tenancy.Infrastructure`, because `tenant_feature_flags` is that module's table, and is registered at the composition root. `IFeatureFlagService` and `IUsageLimitService` are **withdrawn**, not renamed ([ADR-0021](decisions/0021-feature-based-entitlement.md)). Per [ADR-0045 § 2](decisions/0045-entitlement-and-feature-flag-socket.md). | | **`IHostToTenantResolver` / `HostResolution`** | Interface for host → `(tenant_id, organization_id?)` resolution, returning the `HostResolution(TenantId, OrganizationId?)` record or null. Reads `platform_host_to_tenant` and **nothing else** — never the Hub, because an anonymous page load must not depend on a control plane being reachable ([ADR-0034](decisions/0034-hub-contract-surface-invariant.md)). | | **APISIX** | The gateway in standalone YAML-reload mode per [ADR-0015](decisions/0015-api-gateway-apisix.md). The intended tenant-facing ingress, demand-gated to [Phase 11](roadmap/phase-11-production-hardening.md); until then ASP.NET middleware carries the same responsibilities in-process. `/api/internal/*` is never proxied by it — that listener is mTLS-only inside the pod. | -| **`OutboxProcessor`** | The API-host BackgroundService that claims one admitted outbox row immediately before invocation, with a fresh acquisition token in `locked_by`, a database-time lease and one counted attempt. Token/expiry-fenced transitions own retry and explicit audited terminal state; stable worker identity is diagnostic only. Accepted [ADR-0046](decisions/0046-durable-event-delivery-and-recovery.md) authorizes P02b-2 implementation, which remains pending; at-least-once recovery still needs P02b-3 inbox deduplication. | +| **`OutboxProcessor`** | The API-host BackgroundService that claims one admitted outbox row immediately before invocation, with a fresh acquisition token in `locked_by`, a database-time lease and one counted attempt. Token/expiry-fenced transitions own retry and explicit audited terminal state; stable worker identity is diagnostic only. Accepted [ADR-0046](decisions/0046-durable-event-delivery-and-recovery.md) authorizes P02b-2 implementation, whose transitions and terminal witness ship in Step 2 while hosted execution remains Step 3; at-least-once recovery still needs P02b-3 inbox deduplication. | | **`IUnitOfWork`** | The ambient unit of work: **one database connection per scope**, and the transaction on it ([ADR-0040](decisions/0040-ambient-unit-of-work.md)). Every module `DbContext` resolved in the scope is built on that connection and enlisted in that transaction, and `IAuditStore` and `IOutbox` reach it through the same seam. The connection count is a correctness property, not a performance one: `SET LOCAL app.tenant_id` is connection- **and** transaction-local, so a context on its own connection reads zero rows from every tenant-owned table — silently. It exposes no `SaveChangesAsync`; contexts save themselves and it owns only the transaction boundary. [ADR-0040 Amendment 8](decisions/0040-ambient-unit-of-work.md#amendment-8--read-only-public-frames-2026-10-03) accepts explicit read-only/read-write intent, defaulting to writable: same-mode joins preserve ownership, mixed-mode joins poison the unit, and successful read-only completion permits a later writer. P02d-4 Step 1 delivers these modes and lifecycle controls; poison still requires a fresh scope. | | **`IInboxGuard`** | The Accepted shared, transport-owned inbox claim/completion seam, pending P02b-3. Deduplication is `(tenant_id, subscription_id, event_id)`, claimed before handler construction on the announced app-role transaction. Business state, completed inbox, produced outbox, MUST audit and fenced ledger completion commit together; rollback removes a new claim. Handlers do not call the guard, and targeted replay never resets a completed inbox. See [ADR-0046](decisions/0046-durable-event-delivery-and-recovery.md#subscriber-acceptance-and-inbox-ownership). | | **Inbox** | Shared tenant-owned completion record for one stable subscription/event pair. A new claim is uncommitted business-transaction work; only successful completion persists it. The delivery ledger owns failed/terminal/replay state. Canonical DDL remains P02b-3 in [Database Standards](standards/05-database.md#inbox). | @@ -349,7 +349,7 @@ These entries distinguish P02d-2 contracts from P02d-3 internal read contracts: | **Subscription readiness** | Construction-free Pending, Active or IntentionallyUnsubscribed declaration. Any required Pending subscription holds its contract/aliases before acquisition and validation. Active requires a real registered handler; intentional absence is explicit, never inferred from zero discovered handlers. Accepted P02b-2 policy under [ADR-0046](decisions/0046-durable-event-delivery-and-recovery.md). | | **Outbox lease** | Paired `locked_by` / `locked_until` fields; the former holds a fresh canonical UUID acquisition token, the latter a database-time expiry. Every transition checks both current token and unexpired lease. Recovery replaces expired ownership before another transition. Stable worker identity confers no ownership. | | **Dead letter** | Explicit completed terminal state after invalid data or exhausted attempts, distinct from a terminal request or delayed availability. Producer terminal state retains its envelope and commits with MUST audit; subscriber terminal state belongs to its independent delivery ledger and never completes its inbox. Operator reset/replay is separately owned by Phase 03. | -| **`EventTerminalTransition`** | Restricted immutable witness minted only from the successful fenced terminal UPDATE result in its live transaction. It binds actual row identity, acquisition and terminal values to `WriteEventTerminalAsync`; substituting draft tenant/org/event or transaction requires rollback of state and audit. Accepted fifth seam, pending P02b-2. | +| **`EventTerminalTransition`** | Restricted immutable witness minted only from the successful fenced terminal UPDATE result in its live transaction. It binds actual row identity, acquisition and terminal values to `WriteEventTerminalAsync`; substituting draft tenant/org/event or transaction requires rollback of state and audit. Fifth seam implemented in P02b-2 Step 2; independent reviews pending. | | **`DeploymentMode`** | Enum (`Development \| SaaS \| Dedicated \| SelfHostedOnline \| SelfHostedAirGapped`) read at the composition root to select provider implementations. Modules never read this enum. | diff --git a/docs/modules/tenancy/audit.md b/docs/modules/tenancy/audit.md index 0247e1ca..51070351 100644 --- a/docs/modules/tenancy/audit.md +++ b/docs/modules/tenancy/audit.md @@ -10,12 +10,13 @@ aggregate. A failed MUST write rolls back tenant, organization and outbox togeth The [integration-event catalogue](README.md#integration-event-catalogue) owns the flow; production dispatch and subscribers remain P02b-2/P02b-3. -**ADR-0046 Accepted — 2026-10-12; terminal writers pending.** The infrastructure -operations below belong to this catalogue/matrix, without a synthetic aggregate. -P02b-2 registers the producer terminal operation when its writer ships; P02b-3 -does the same for subscriber terminalization. Both use the restricted fifth -audit seam on the fenced state-transition transaction. Routine confined dispatch -has the explicit ADR-0046 exemption; platform-scope entry auditing is unchanged. +**ADR-0046 producer terminal writer implemented — 2026-10-12; reviews pending.** +The infrastructure operations below belong to this catalogue/matrix, without a +synthetic aggregate. P02b-2 Step 2 registers the delivered producer terminal +operation; P02b-3 registers subscriber terminalization when its writer ships. +Both use the restricted fifth audit seam on the fenced state-transition transaction. +Routine confined dispatch has the explicit ADR-0046 exemption; platform-scope +entry auditing is unchanged. **P02d-4 Step 2 implemented — 2026-10-03.** Both review rounds and focused fix verification pass. Site bootstrap @@ -43,8 +44,9 @@ separate allowlist. Verification request types are Off. Packet 7 ships four audited root writes through three commands: Tenant and default Organization provisioning, a separate Organization create, and host mapping creation. P02d-2 adds two locale commands (SHOULD over Tenant) and one branding command (MUST over -TenantSetting). Fifteen rows remain `(planned)`; seven operations have no MediatR -request and carry `(off-path)`. +TenantSetting). P02b-2 Step 2 adds the producer terminal MUST security-event writer. +Fourteen rows remain `(planned)`; seven operations have no MediatR request and +carry `(off-path)`. All four original writes are **MUST**. Their records commit with the business state, under [ADR-0033](../../decisions/0033-audit-durability-model.md). The branding writer @@ -109,7 +111,7 @@ already the permission key, and the first two segments of the two keys have to a so the spelling changes in both files or in neither. **A cell carries its slug and, where the row is not joined by request type, every marker -that applies** — a row can carry both, and two do +that applies** — a row can carry both ([ADR-0044 Amendment 3](../../decisions/0044-audit-write-path.md)). `(planned)` is a row classified ahead of the command that will raise it — the classification [Audit Coverage](../../standards/18-audit-coverage.md) asks for *before* the command @@ -120,7 +122,7 @@ unmarked row is one a shipped command already raises. | Resource | Operation | Class | Why | |---|---|---|---| -| `outbox_messages` | `platform.outbox.dead_letter` `(off-path)` `(planned)` | **MUST** (`security-event`) | P02b-2's consequential BYPASSRLS terminal transition; state and row commit together through the row-bound fifth audit seam, with stored tenant/org/event identity and SystemActor ([ADR-0046](../../decisions/0046-durable-event-delivery-and-recovery.md)) | +| `outbox_messages` | `platform.outbox.dead_letter` `(off-path)` | **MUST** (`security-event`) | P02b-2 Step 2's consequential BYPASSRLS terminal transition; state and row commit together through the row-bound fifth audit seam, with stored tenant/org/event identity and SystemActor ([ADR-0046](../../decisions/0046-durable-event-delivery-and-recovery.md)) | | delivery ledger | `platform.delivery.dead_letter` `(off-path)` `(planned)` | **MUST** (`security-event`) | P02b-3 owns subscriber terminalization, stable subscription/event identity and the same atomic transition/audit guarantee; no writer or operation registration ships at acceptance | | `Tenant` | `tenancy.tenant.create` | **MUST** | The root of a customer's data; its existence is a contractual fact | | `Tenant` | `tenancy.tenant.change_status` `(planned)` | **MUST** | Suspension withdraws access; an operator must be able to say who and when | @@ -162,7 +164,7 @@ in both directions ([ADR-0044 § 6](../../decisions/0044-audit-write-path.md)). ([ADR-0044 Amendment 3](../../decisions/0044-audit-write-path.md)). Catalogue → matrix is total: every entry this module's `IAuditCatalogSource` registers has a row here carrying the same slug, and one that does not fails. Matrix → catalogue binds only to a -slug whose request type **exists**, which is why the thirteen `(planned)` rows are +slug whose request type **exists**, which is why the remaining `(planned)` rows are classification and not drift — and a `(planned)` row whose command has since shipped fails, so the marker is a claim the rule re-checks on every run rather than an exemption. @@ -172,15 +174,19 @@ reason its own cell gives: `platform.admin_scope.enter`, written on the fourth w ([ADR-0044 § 10](../../decisions/0044-audit-write-path.md)); `tenancy.killswitch.toggle`, written inside `EnterPlatformAdminScope(reason)`; `tenancy.entitlement.refresh`, whose writer is `IEntitlementProvider.RefreshAsync` and -whose refreshing implementation lands in Phase 02c; and the two +whose refreshing implementation lands in Phase 02c; the two `tenancy.tenant_assertion.*` keys, written by a non-MediatR caller reaching `IAuditStore.WriteStandaloneAsync` -([ADR-0033 Amendment 2](../../decisions/0033-audit-durability-model.md)). Three are +([ADR-0033 Amendment 2](../../decisions/0033-audit-durability-model.md)); +`platform.outbox.dead_letter`, written with its fenced producer terminal UPDATE +through `IAuditStore.WriteEventTerminalAsync`; and `platform.delivery.dead_letter`, +whose subscriber writer belongs to P02b-3. Four are registered by slug through `DeclareOffPath` because their writer ships — -`platform.admin_scope.enter` and the two `tenancy.tenant_assertion.*` keys. +`platform.admin_scope.enter`, `platform.outbox.dead_letter` and the two +`tenancy.tenant_assertion.*` keys. `tenancy.killswitch.toggle`, `tenancy.entitlement.refresh`, -`platform.outbox.dead_letter` and `platform.delivery.dead_letter` also carry -`(planned)` and register by slug when Phase 03, Phase 02c, P02b-2 and P02b-3, +and `platform.delivery.dead_letter` also carry +`(planned)` and register by slug when Phase 03, Phase 02c and P02b-3, respectively, land their writers. Every other slug above is joined in both directions the day its command lands. diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index ef2d645e..5f71c40e 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -26,10 +26,11 @@ verification. The Accepted P02b-2 package below owns the current dispatcher work **P02b-2 decision pass — 2026-10-12.** The [decision package](#p02b-2-decision-package-2026-10-12) and [ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md) are Accepted. -The four implementation steps are authorized on development. Step 1 is in -progress; its [record](#p02b-2-step-1--decision-schema-and-confined-composition-2026-10-12) -owns delivery evidence. Dispatcher delivery and all P02b-3 implementation remain -pending. +The four implementation steps are authorized on development. Step 1 is complete; +its [record](#p02b-2-step-1--decision-schema-and-confined-composition-2026-10-12) owns +delivery evidence and both completed review rounds. Step 2 implements durable +transitions and terminal audit; verification and reviews are in progress. Hosted +dispatcher delivery and all P02b-3 implementation remain pending. **Phase 02d handoff — 2026-10-11.** P02b-0 re-verifies the delivered `/api/v1` public contracts, native HTTPS on retained `*.learnstack.local` seed hosts, @@ -253,7 +254,7 @@ Accepted, and its catalogue rows registered, before its first line of code. |---|---|---| | **P02b-0** | Phase entry: every premise the register below cites re-verified against `HEAD` — the code, the corpus, the Hub repository — and the register corrected where Phase 02d moved it. It drafts no record: each gate closes in the decision pass of the first packet it shapes, and each glossary headword this phase needs — `inbox`, outbox lease, dead letter, audit frame, job activator, integration-event consumer — is written in the pass whose gate fixes its meaning | [Phase 02d](phase-02d-walking-skeleton.md) exits | | **P02b-1** | **Complete and merged — [PR #29 closeout](#p02b-1-merge-and-closeout-2026-10-12).** Producer: `IOutbox`, the `OutboxFlushBehavior` invariant guard, successful-save domain-event collection and owner-only completion, and the first event with its enqueue path | G2, G3 (the key's derivation), G5 (the stored identities), G6, G18 (the payload rule), G19 Accepted | -| **P02b-2** | **Decision Accepted; Step 1 complete.** Dispatcher: the lease migration and grant extension, claim / publish / mark, backoff, the producer terminal state, the credential and its boot guard, the metrics and the health check | G1, G3, G4, G5, G7, G8, G17, G18 (`last_error`) Accepted; P02b-1 | +| **P02b-2** | **Decision Accepted; Step 1 complete; Step 2 verification/reviews in progress.** Dispatcher: the lease migration and grant extension, claim / publish / mark, backoff, the producer terminal state, the credential and its boot guard, the metrics and the health check | G1, G3, G4, G5, G7, G8, G17, G18 (`last_error`) Accepted; P02b-1 | | **P02b-3** | Consumer: durable acceptance, shared inbox/ledger DDL and `IInboxGuard`, the common per-delivery transaction, both real defaults subscriptions and Seeder handoff, independent attempts and audited terminal state, targeted internal ledger replay | G1, G6, G7 Accepted; P02b-2 | | **P02b-4** | Background jobs: the runner, its storage, the tenant-context hook, the enqueue guard, no job definition | G9 Accepted; P02b-3 | | **P02b-5** | API authentication: JwtBearer wiring and its insertion order, claim population, the host/JWT cross-check through the existing recorder, token-keyed rate limits, the 401 rows, one authenticated endpoint | G10, G11, G12 (the BFF's client), G14, G20 Accepted | @@ -1506,6 +1507,74 @@ saved TRX counters. The completed Step 1 commits are `d9bc77b`, `6a13455` and `b6fc714`; the next authorized implementation step is durable transitions and terminal audit. +### P02b-2 Step 2 — Durable transitions and terminal audit (2026-10-12) + +**Implemented; independent review rounds pending.** `OutboxDispatchStore` uses +short parameterized database-time transitions. Normal acquisition commits one +fresh token and one attempt before returning; expired ownership requires a fresh +recovery token without another attempt. Success, failure, terminal request and +terminalization require the current token and an unexpired lease. Five backoffs +follow six counted normal claims; exhausted or requested work never reenters +normal publication. Pending identities and aliases are excluded before payload +validation in every acquisition path. Local blocked partitions receive the same +exclusion. + +`OutboxEnvelopeReader` uses only the registry's concrete type and frozen options. +It compares persisted fact/routing/time, tolerates PostgreSQL microsecond +truncation and checks the canonical reserialization byte cap, rather than jsonb +whitespace. Complete envelope admission precedes intentional acknowledgement; +Pending, unknown, retired and invalid outcomes remain distinct. + +The fifth `IAuditStore` method preserves the existing four writes. Only +`OutboxDispatchStore.TryTerminalizeAsync` derives the minting capability and calls +it. The private immutable witness reads all identity and causal values from the +actual fenced UPDATE result, including the old acquisition token. The real audit +store requires the declared producer MUST/SecurityEvent, matching draft identity +and the same live connection/transaction. Both writes commit together. Invalid +causal values are omitted; invalid correlation uses a validated attempt trace. +No audit SQL is added outside `PostgresAuditStore`. Tenancy registers the producer +operation; the subscriber operation remains planned for P02b-3. + +Terminal SQL plus audit share one five-second deadline. A lost COMMIT response +triggers a separate bounded durable-state read before returning/retrying; it +cannot insert a second audit into a completed terminal row. A test-owned TCP +proxy drops the real PostgreSQL COMMIT acknowledgement after physical commit, +proves reconnection and checks one durable terminal/audit pair. Production has no +fault hook. Failed audit retains the terminal request, including below attempt +six, and never permits another publication. + +Tests use actual `learnstack_app` inserts with announced tenant/org and the +guarded `learnstack_outbox_admin` source for transitions. Disposable databases +isolate grant and state mutations. Nonempty whole-production Cecil censuses and +clean/dirty controls cover witness shape, minting/fifth-write callers, async and +delegate helpers, required catalogue injection and named dynamic type-loading +APIs. This is a bounded IL capability check, not arbitrary reflection/dataflow +analysis. SharedKernel has no friend assembly. + +Four behavioural mutation controls each fail as intended: removing lease expiry, +admitting terminal requests for publication, omitting MUST audit and discarding +successful uncertain-COMMIT reconciliation. Every edit is restored before the +clean validation run. Six-attempt tests start at zero and commit all six claims; +they cover both an explicit sixth failure and an expired sixth claim. Snapshot +proofs distinguish due, future, leased, expired, requested, exhausted, deferred, +locally blocked, processed and completed-terminal rows. + +The four transition/audit/type catalogue rules are Implemented. Hosted lifecycle, +publication/overrun proofs, credential caller enforcement and observation remain +Steps 3–4. Real default subscriptions stay Pending; no P02b-3 business effect, +inbox or delivery ledger is claimed. + +**Verification before review:** Release solution build has zero warnings/errors; +formatting covers all 21 changed C# files. TRX counters and process exit codes +record zero failures/skips: complete unit 1,825, complete architecture 273, +contract 4, complete Docker-free integration 180 and affected Docker integration +110. The Docker slice covers `OutboxDispatchStoreTests`, `AuditStoreTests`, +`PlatformAdminScopeTests`, `TenantAssertionAuditTests` and +`PublicReadTransactionTests`; it is not a full Docker-suite claim. All four +behavioural mutants fail their named tests, then the restored code passes. +Local file/fragment, historical-record/Accepted-ADR preservation, prose-width and +`git diff --check` controls pass. No Step 2 review completion is claimed yet. + ### Durable outbox dispatch P02b-1 Steps 2/3 implement `IOutbox`, replace Packet 3's step-7 shell with diff --git a/docs/standards/05-database.md b/docs/standards/05-database.md index 5c1fa4f5..ae75ecd4 100644 --- a/docs/standards/05-database.md +++ b/docs/standards/05-database.md @@ -1394,8 +1394,9 @@ Public read models consumed by other modules: **ADR-0046 schema implemented in P02b-2 Step 1; both review rounds complete.** The canonical -DDL below includes lease/terminal/identity/descriptor changes. Dispatcher execution -and terminal auditing remain later P02b-2 steps. Existing +DDL below includes lease/terminal/identity/descriptor changes. Step 2 implements +fenced transitions and atomic terminal auditing; its reviews remain pending. +Hosted execution and metrics remain Step 3. Existing migrations remain immutable; a new Platform-chain migration replaces free-form legacy `last_error` with `migration:legacy:redacted` before its byte CHECK. This specific ADR-0046 Platform migration temporarily uses owner-only `NO FORCE` @@ -1514,7 +1515,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. ADR-0046 accepts producer recovery and the shared inbox -contract; producer runtime remains P02b-2, inbox/delivery runtime P02b-3. +contract; P02b-2 Step 2 implements producer transitions and terminal audit, +Step 3 owns hosted dispatch, and P02b-3 owns inbox/delivery runtime. `organization_id` is `uuid NULL` and mirrors the organization context of the transaction that enqueued the row — diff --git a/docs/standards/18-audit-coverage.md b/docs/standards/18-audit-coverage.md index 90ed1165..0a2bcfaa 100644 --- a/docs/standards/18-audit-coverage.md +++ b/docs/standards/18-audit-coverage.md @@ -159,7 +159,7 @@ will raise them — which this standard requires it to do. `tenancy.entitlement.refresh` and the two tenant-assertion keys of § Baseline Coverage, and Audit's `audit.redaction.apply` and `audit.purge.apply`. They sit outside the request-type join in **both** directions and register by slug rather than by type — the - three whose writer ships through `DeclareOffPath` today, the other six when their + four whose writer ships through `DeclareOffPath` today, the other five when their writer lands. Producer terminal registration belongs to P02b-2; subscriber terminal registration belongs to P02b-3. Both are planned until their writers ship. - **One operation, one row.** A slug is classified in exactly one row across every @@ -502,7 +502,8 @@ table above is the policy those deliverables implement, not a description of tod outcomes, metrics/health and MUST-audited terminal transitions. This exception changes no platform-admin pre-entry audit or other bypass operation. - `WriteEventTerminalAsync(AuditEntryDraft, EventTerminalTransition, DbConnection, - DbTransaction, CancellationToken)` is the narrow fifth write, pending P02b-2. + DbTransaction, CancellationToken)` is the narrow fifth write, implemented in + P02b-2 Step 2; independent reviews remain pending. Only a successful fenced UPDATE result mints the restricted witness binding actual tenant/org/event/acquisition/kind/reason/attempts/subscription and live transaction. Validate declared MUST/SecurityEvent operation and matching draft diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 4975f743..710359f3 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -14,7 +14,8 @@ [ADR-0057 Transactional Event Production and Completion](../decisions/0057-transactional-event-production-and-completion.md) (producer lifecycle, payload and production-census proofs implemented), [ADR-0046 Durable Event Delivery and Recovery](../decisions/0046-durable-event-delivery-and-recovery.md) -(accepted delivery rules Registered; P02b-2/P02b-3 proofs pending). The +(P02b-2 transition and terminal-witness proofs implemented; hosted dispatch +and P02b-3 consumer proofs pending). The catalogue grows as subsequent ADRs and phases land their tests; per-test ownership stays with the originating ADR / standard. @@ -107,11 +108,12 @@ not implemented is the failure mode this column exists to prevent. ### Implemented today -**173 test methods run in +**180 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, P02d-4 Steps 1–3 and P02b-1. +Packet 8, Packet 9, Packet 10, P02d-1, P02d-2, P02d-3, P02d-4 Steps 1–3, P02b-1 +and P02b-2 Step 2. 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. @@ -138,8 +140,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. -**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 +**187 rules in this catalogue are Implemented, and 115 of them are in that assembly.** +The other 72 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. @@ -2645,8 +2647,8 @@ which decides identity, multiplicity, capture and classification; [ADR-0036 Amendment 7](../decisions/0036-tenant-resolution-trusted-inputs.md), so that one parser reads every audit slug and every permission key — and Audit's `audit.redaction.apply` and `audit.purge.apply`. They sit outside the request-type join - in both directions and register by slug: the three whose writer ships through - `DeclareOffPath` today, the other six — which also carry `(planned)` — when their + in both directions and register by slug: the four whose writer ships through + `DeclareOffPath` today, the other five — which also carry `(planned)` — when their writer lands. ADR-0046 assigns the producer terminal writer to P02b-2 and the subscriber terminal writer to P02b-3; acceptance alone registers neither slug. @@ -2886,7 +2888,23 @@ which decides identity, multiplicity, capture and classification; - **Source:** ADR-0033 (carried from ADR-0016); [31-audit-subsystem.md § 10](../architecture/31-audit-subsystem.md). - **Type:** xUnit + source / migration scan. **Kind:** structural. -- **Status:** **Implemented** (`AuditConventionTests`, Packet 9 step 8), in three parts: a source sweep over `backend/src` for an `UPDATE` or `DELETE` targeting `audit_log` — with or without `ONLY`, a schema qualifier or identifier quotes, which the first pattern missed until the fourth review of Packet 9 measured it; a companion, `The_AppendOnly_Sweep_Can_Actually_Fail`, that checks the pattern against the shapes it must catch and the shapes it must not, because with no offending statement anywhere the sweep passes whether it works or matches nothing; and a reflection check, `IAuditStore_Exposes_No_Update_Method`, that `IAuditStore` exposes exactly four write methods and no update. None of the three sanctioned redaction sites exists yet — they land in Phase 03 and Phase 11 — so the exemption set ships with the rule **empty**, as exact paths: the first site to land adds its own path, so it is exempted by name rather than the pattern widened. (A substring predicate stood there first, and exempted any Audit-module file whose path contained "Redaction".) Since the fifth review of Packet 9 the sweep also asserts its premise — it must have read `PostgresAuditStore`'s own `INSERT INTO audit_log` — because with no offender anywhere, a sweep that read nothing passed as well. +- **Status:** **Implemented** (`AuditConventionTests`, Packet 9 step 8), in three parts: + a source sweep over `backend/src` for an `UPDATE` or `DELETE` targeting `audit_log` — + with or without `ONLY`, a schema qualifier or identifier quotes, which the first + pattern missed until the fourth review of Packet 9 measured it; a companion, + `The_AppendOnly_Sweep_Can_Actually_Fail`, that checks the pattern against the shapes + it must catch and the shapes it must not, because with no offending statement anywhere + the sweep passes whether it works or matches nothing; and a reflection check, + `IAuditStore_Exposes_No_Update_Method`, that `IAuditStore` exposes exactly five write + methods and no update (the additive fifth terminal seam ships in P02b-2 Step 2). None + of the three sanctioned redaction sites exists yet — they land in Phase 03 and Phase + 11 — so the exemption set ships with the rule **empty**, as exact paths: the first + site to land adds its own path, so it is exempted by name rather than the pattern + widened. (A substring predicate stood there first, and exempted any Audit-module file + whose path contained "Redaction".) Since the fifth review of Packet 9 the sweep also + asserts its premise — it must have read `PostgresAuditStore`'s own `INSERT INTO + audit_log` — because with no offender anywhere, a sweep that read nothing passed as + well. - **Phase:** 02a (Packet 9). #### `Every_PII_Module_RegistersUserReferenceLocator` @@ -3347,7 +3365,8 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). - **Type:** production architecture census with planted controls where structural; real PostgreSQL/runtime integration proofs where behavioural. **Kind:** behavioural. -- **Status:** **Registered** — no implementation evidence yet. +- **Status:** **Implemented** (`OutboxDispatchStoreTests`): committed claim/backoff, + expiry/reclaim, sixth-claim recovery and terminal-request refusal controls. - **Phase:** 02b, P02b-2. #### `Outbox_Terminal_Transition_And_Must_Audit_Are_Atomic` @@ -3361,7 +3380,8 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). - **Type:** production architecture census with planted controls where structural; real PostgreSQL/runtime integration proofs where behavioural. **Kind:** behavioural. -- **Status:** **Registered** — no implementation evidence yet. +- **Status:** **Implemented** (`OutboxDispatchStoreTests`): real-role audit-grant + refusal, rollback/retry and completed-row replay controls. - **Phase:** 02b, P02b-2. #### `Outbox_Terminal_Audit_Identity_Is_Row_Bound` @@ -3376,7 +3396,9 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). - **Type:** production architecture census with planted controls where structural; real PostgreSQL/runtime integration proofs where behavioural. **Kind:** structural + behavioural. -- **Status:** **Registered** — no implementation evidence yet. +- **Status:** **Implemented** (`OutboxDispatchConventionTests`, + `OutboxDispatchStoreTests`): exact nonempty production mint/write censuses with + planted controls and real-row identity/transaction refusal proofs. - **Phase:** 02b, P02b-2. #### `Outbox_Type_Resolution_Is_Declared_And_Bounded` @@ -3391,7 +3413,9 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). - **Type:** production architecture census with planted controls where structural; real PostgreSQL/runtime integration proofs where behavioural. **Kind:** structural + startup + behavioural. -- **Status:** **Registered** — no implementation evidence yet. +- **Status:** **Implemented** (`OutboxDispatchConventionTests`, + `OutboxEnvelopeReaderTests`, `OutboxEventRegistryTests`): declared resolver and + transitive production helper census, planted loading calls and envelope probes. - **Phase:** 02b, P02b-2. #### `Outbox_Pending_Subscriptions_Preserve_Events` From ffce05f0b2e4e4815b13a3c00e9789ae4f29cbc2 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 22:00:24 +0300 Subject: [PATCH 74/92] fix(kernel): validate persisted outbox routing fields Refuse missing or altered get-only routing members before reconstructing an event, so canonical getters cannot hide invalid stored envelopes. Cover the refusal with payload mutations and real terminal audit proof. ADR: 0046 --- .../Messaging/OutboxEnvelopeReader.cs | 11 +++++- .../Database/OutboxDispatchStoreTests.cs | 39 +++++++++++++++++++ .../Messaging/OutboxEnvelopeReaderTests.cs | 33 ++++++++++++++++ docs/roadmap/phase-02b-events-auth.md | 19 ++++++++- 4 files changed, 100 insertions(+), 2 deletions(-) diff --git a/backend/src/LearnStack.Infrastructure/Messaging/OutboxEnvelopeReader.cs b/backend/src/LearnStack.Infrastructure/Messaging/OutboxEnvelopeReader.cs index e7dc591a..2d6f74d5 100644 --- a/backend/src/LearnStack.Infrastructure/Messaging/OutboxEnvelopeReader.cs +++ b/backend/src/LearnStack.Infrastructure/Messaging/OutboxEnvelopeReader.cs @@ -25,7 +25,16 @@ public OutboxEnvelopeReadResult Read(OutboxDispatchClaim claim) || claim.TenantId == TenantId.PlatformSentinel.Value || claim.OrganizationId == Guid.Empty) return Invalid(); - var fact = JsonSerializer.Deserialize(claim.Payload, resolution.EventType!, + using var document = JsonDocument.Parse(claim.Payload); + var root = document.RootElement; + // Read the stored routing fields before deserialization: get-only + // properties would otherwise silently regenerate missing or altered values. + if (root.ValueKind != JsonValueKind.Object + || root.GetProperty(nameof(IIntegrationEvent.Topic)).GetString() != claim.Topic + || root.GetProperty(nameof(IIntegrationEvent.PartitionKey)).GetString() != claim.PartitionKey) + return Invalid(); + + var fact = root.Deserialize(resolution.EventType!, IntegrationEventBase.PayloadJsonOptions) as IntegrationEventBase; if (fact is null) return Invalid(); var serialized = OutboxEventContract.Serialize(fact); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatchStoreTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatchStoreTests.cs index 174c4c30..8b25d0c5 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatchStoreTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatchStoreTests.cs @@ -1,5 +1,6 @@ using System.Data.Common; using System.Text.Json; +using System.Text.Json.Nodes; using FluentAssertions; using LearnStack.Infrastructure.Audit; using LearnStack.Infrastructure.Messaging; @@ -586,6 +587,44 @@ public async Task Real_TenantCreated_contracts_and_aliases_are_held_before_any_p (await test.AuditCountAsync()).Should().Be(0, "provisioning audits are separate from the terminal operation"); } + [Theory] + [InlineData("Topic", false)] + [InlineData("Topic", true)] + [InlineData("PartitionKey", false)] + [InlineData("PartitionKey", true)] + public async Task Stored_routing_corruption_requests_terminal_without_acknowledging_the_fact(string property, bool missing) + { + await using var test = await TestDatabase.CreateAsync(schema.Postgres); + var fact = new DispatchEventV1 + { + EventId = Guid.CreateVersion7(), + TenantId = SchemaFixture.TenantA, + OccurredAt = Occurred, + SubjectId = Guid.CreateVersion7(), + }; + var payload = JsonNode.Parse(fact.ToPayloadJson())!.AsObject(); + if (missing) payload.Remove(property); + else payload[property] = "private-invalid-routing"; + var id = await test.InsertAsync(fact: fact, payload: payload.ToJsonString()); + var registry = EligibleRegistry(); + var store = test.Store(registry: registry); + var claim = (await store.TryClaimAsync(NoBlockedPartitions))!; + + var result = new OutboxEnvelopeReader(registry).Read(claim); + + result.TerminalReason.Should().Be(OutboxTerminalReason.InvalidEnvelope); + result.Envelope.Should().BeNull(); + (await store.RequestTerminalAsync(claim, result.TerminalReason!.Value)).Should().BeTrue(); + (await store.TryClaimAsync(NoBlockedPartitions)).Should().BeNull(); + var terminal = (await store.TryClaimTerminalAsync(NoBlockedPartitions))!; + (await store.TryTerminalizeAsync(terminal, AttemptTraceparent)).Should().BeTrue(); + var row = await test.ReadAsync(id); + row.ProcessedAt.Should().BeNull(); + row.DeadLetteredAt.Should().NotBeNull(); + (await test.AuditCountAsync()).Should().Be(1); + (await test.ReadAuditAsync()).Metadata.Should().NotContain("private-invalid-routing"); + } + [Fact] public async Task An_intentionally_unsubscribed_contract_remains_eligible_for_explicit_acknowledgement() { diff --git a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/OutboxEnvelopeReaderTests.cs b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/OutboxEnvelopeReaderTests.cs index ea53bc80..2f1767b4 100644 --- a/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/OutboxEnvelopeReaderTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/OutboxEnvelopeReaderTests.cs @@ -81,6 +81,39 @@ public void Every_row_envelope_mismatch_refuses_without_parser_diagnostics(strin result.Envelope.Should().BeNull(); } + [Theory] + [InlineData("Topic", "missing")] + [InlineData("Topic", "changed")] + [InlineData("Topic", "null")] + [InlineData("Topic", "number")] + [InlineData("Topic", "object")] + [InlineData("PartitionKey", "missing")] + [InlineData("PartitionKey", "changed")] + [InlineData("PartitionKey", "null")] + [InlineData("PartitionKey", "number")] + [InlineData("PartitionKey", "object")] + public void Stored_get_only_routing_fields_must_exist_and_match_the_row(string property, string mutation) + { + var claim = Claim(Fact()); + var root = JsonNode.Parse(claim.Payload)!.AsObject(); + if (mutation == "missing") root.Remove(property); + else root[property] = mutation switch + { + "changed" => JsonValue.Create("private-invalid-routing"), + "null" => null, + "number" => JsonValue.Create(42), + "object" => new JsonObject(), + _ => throw new ArgumentOutOfRangeException(nameof(mutation)), + }; + + var result = Reader().Read(claim with { Payload = root.ToJsonString() }); + + result.TerminalReason.Should().Be(OutboxTerminalReason.InvalidEnvelope); + result.EventTypeLabel.Should().Be("invalid"); + result.Envelope.Should().BeNull(); + Reader().Read(claim).Envelope.Should().NotBeNull("the unchanged control must still be admitted"); + } + [Fact] public void PostgreSQL_microsecond_truncation_preserves_valid_occurrence_identity() { diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 5f71c40e..4861cca4 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -1573,7 +1573,24 @@ contract 4, complete Docker-free integration 180 and affected Docker integration `PublicReadTransactionTests`; it is not a full Docker-suite claim. All four behavioural mutants fail their named tests, then the restored code passes. Local file/fragment, historical-record/Accepted-ADR preservation, prose-width and -`git diff --check` controls pass. No Step 2 review completion is claimed yet. +`git diff --check` controls pass. + +#### Step 2 review round 1 + +Fresh read-only GPT-6-astra xhigh security/concurrency and GPT-6.1-sol xhigh +proof/corpus reviewers examined `c6ceaa9..7976113`. The proof review approved; +its independent rerun passed 273 architecture and 24 focused unit cases. The +security review reproduced a stored-routing gap: deserialization regenerates +get-only Topic/PartitionKey values, masking changed or absent payload members. +The reader now checks those required string members in the original JSON before +using the declared CLR contract, then compares the reconstructed routing with +the row. Ten unit mutation cases and four real-database cases cover changed, +missing and malformed routing, terminal-only progression and minimized audit. +The unchanged payload remains an admitted control. Removing the original-field +check makes all ten new unit cases fail. After restoration, the complete unit +suite passes 1,835 cases, architecture passes 273 and the four new Docker cases +pass, all with zero failures/skips. Round 2 follows the verified correction; +hosted lifecycle remains Step 3. ### Durable outbox dispatch From d4c06a46cf23bb6945f1adb736487a5c0826d058 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 22:04:18 +0300 Subject: [PATCH 75/92] docs(roadmap): close P02b-2 transition review rounds Record independently verified routing remediation and both clean second reviews before activating hosted dispatch in the next approved step. ADR: 0046 --- CLAUDE.md | 2 +- docs/roadmap/phase-02b-events-auth.md | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e1b3b75b..68c9af2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -187,7 +187,7 @@ Accepted — 2026-10-12.** The four implementation steps and two independent rev rounds per step are authorized on development. The [Step 1 record](docs/roadmap/phase-02b-events-auth.md#p02b-2-step-1--decision-schema-and-confined-composition-2026-10-12) records both completed Step 1 reviews. Step 2 implements fenced transitions and -row-bound terminal MUST audit; its verification and reviews are in progress. +row-bound terminal MUST audit; both independent review rounds pass. Hosted execution remains Step 3. Acceptance registers six delivery obligations; it claims no inbox or subscriber delivery. P02b-3 still owns durable acceptance, shared inbox/ledger, independent retries, both real default diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 4861cca4..61e8e902 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -1509,7 +1509,7 @@ terminal audit. ### P02b-2 Step 2 — Durable transitions and terminal audit (2026-10-12) -**Implemented; independent review rounds pending.** `OutboxDispatchStore` uses +**Implemented; both independent review rounds complete.** `OutboxDispatchStore` uses short parameterized database-time transitions. Normal acquisition commits one fresh token and one attempt before returning; expired ownership requires a fresh recovery token without another attempt. Success, failure, terminal request and @@ -1592,6 +1592,19 @@ suite passes 1,835 cases, architecture passes 273 and the four new Docker cases pass, all with zero failures/skips. Round 2 follows the verified correction; hosted lifecycle remains Step 3. +#### Step 2 review round 2 + +Fresh read-only GPT-6.1-sol xhigh security/concurrency and GPT-6-astra high +proof/corpus reviewers examined `c6ceaa9..ffce05f`. Both approved without verified +findings. Independent Release no-build reruns passed 34 envelope/options unit +cases, 19 dispatch/audit architecture cases and all 53 dispatch-store Docker +cases, with zero failures/skips. The proof reviewer independently passed all seven +dispatch capability facts and checked the real COMMIT-response-loss result in +the restored database receipt. Both inspected the actual row-bound audit and +routing correction rather than accepting review notes as evidence. Step 2 is +closed; Step 3 may activate the mandatory worker and observation. No P3 delivery +or inbox effect is claimed. + ### Durable outbox dispatch P02b-1 Steps 2/3 implement `IOutbox`, replace Packet 3's step-7 shell with From 756c8523cf21c3c001255331418032c5ceff5408 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 22:26:28 +0300 Subject: [PATCH 76/92] feat(kernel): host bounded outbox dispatch and observation Make dispatcher startup mandatory while preserving Pending production facts and retaining callback-owned resources through timeout and shutdown. Real host/database proofs cover live claims, poison progress and safe cleanup; API launchers confine the dispatcher credential to its owning process. ADR: 0046, 0057 Module: Audit, Tenancy --- .claude/skills/add-audit-coverage/SKILL.md | 2 +- .claude/skills/add-integration-test/SKILL.md | 18 +- .claude/skills/local-dev-setup/SKILL.md | 13 +- .claude/skills/wire-dapr-pubsub/SKILL.md | 2 +- .env.example | 4 +- CLAUDE.md | 13 +- README.md | 18 +- .../OutboxCompositionExtensions.cs | 21 + .../PersistenceCompositionExtensions.cs | 8 +- .../Messaging/OutboxHealthCheck.cs | 14 + .../Messaging/OutboxObservation.cs | 132 +++ .../Messaging/OutboxProcessor.cs | 333 ++++++++ .../Messaging/OutboxPublishAttempt.cs | 87 ++ .../PlatformAdminScopeConventionTests.cs | 18 +- .../DevelopmentWebApplicationFactory.cs | 12 + .../ApiHandlerCompositionTests.cs | 4 +- .../ApiVersioningHttpTests.cs | 4 +- .../AuthorityCeilingHttpTests.cs | 4 +- .../CompositionRootCatalogueTests.cs | 4 +- .../ConstructionOnlyApiFactory.cs | 39 + .../ConstructionOnlyApiFactoryTests.cs | 44 + .../CrossCuttingFoundationHttpTests.cs | 4 +- .../Database/CustomizationProjectionTests.cs | 4 +- ...ustomizationPublicationConcurrencyTests.cs | 4 +- .../Database/DomainEventProductionTests.cs | 2 +- .../Database/EducationWriterTests.cs | 4 +- .../Database/OutboxProcessorTests.cs | 782 ++++++++++++++++++ .../Database/OutboxProducerTests.cs | 4 +- .../Database/ProducerExecutionTests.cs | 2 +- .../Database/PublicLocalLaunchTests.cs | 3 +- .../PublicLocaleEvolutionHttpTests.cs | 4 +- .../Database/PublicReadFixture.cs | 5 +- .../Database/TenancyWriterTests.cs | 4 +- .../Database/TenantCreatedProducerTests.cs | 2 +- .../Database/TenantIsolationHttpTests.cs | 4 +- .../DeploymentModeCompositionTests.cs | 2 +- .../ErrorShapeHttpTests.cs | 4 +- .../HostClassificationHttpTests.cs | 4 +- .../IdempotencyHttpTests.cs | 4 +- .../RateLimitingHttpTests.cs | 4 +- .../SmokeTests.cs | 4 +- .../TenantAssertionHttpTests.cs | 14 +- .../VersionedRouteEnforcementTests.cs | 8 +- .../OutboxProductionCompositionTests.cs | 20 + .../Api/Messaging/OutboxObservationTests.cs | 172 ++++ .../Messaging/OutboxPublishAttemptTests.cs | 166 ++++ .../architecture/04-technical-architecture.md | 13 +- docs/architecture/09-tenant-isolation.md | 4 +- docs/architecture/15-event-and-outbox.md | 47 +- docs/architecture/31-audit-subsystem.md | 14 +- docs/decisions/README.md | 2 +- docs/glossary.md | 4 +- docs/modules/audit/README.md | 5 +- docs/modules/audit/audit.md | 5 +- docs/modules/tenancy/README.md | 3 +- docs/modules/tenancy/audit.md | 6 +- docs/modules/tenancy/permissions.md | 6 +- docs/roadmap/README.md | 6 +- docs/roadmap/phase-02b-events-auth.md | 87 +- docs/standards/02-backend-coding.md | 3 +- docs/standards/05-database.md | 8 +- docs/standards/09-error-handling.md | 4 +- docs/standards/10-observability.md | 15 +- docs/standards/11-security.md | 8 +- docs/standards/18-audit-coverage.md | 2 +- docs/standards/20-infrastructure-stack.md | 13 +- .../21-architecture-tests-catalogue.md | 32 +- frontend/apps/web/src/server/ingress.test.ts | 11 + frontend/apps/web/src/server/ingress.ts | 6 +- .../apps/web/src/test/demo-lifecycle.test.ts | 50 +- infra/dapr/README.md | 12 +- scripts/public-runtime.mjs | 54 +- scripts/seed.sh | 3 +- 73 files changed, 2240 insertions(+), 207 deletions(-) create mode 100644 backend/src/LearnStack.Api/Messaging/OutboxHealthCheck.cs create mode 100644 backend/src/LearnStack.Api/Messaging/OutboxObservation.cs create mode 100644 backend/src/LearnStack.Api/Messaging/OutboxProcessor.cs create mode 100644 backend/src/LearnStack.Api/Messaging/OutboxPublishAttempt.cs create mode 100644 backend/tests/LearnStack.Tests.Integration/ConstructionOnlyApiFactory.cs create mode 100644 backend/tests/LearnStack.Tests.Integration/ConstructionOnlyApiFactoryTests.cs create mode 100644 backend/tests/LearnStack.Tests.Integration/Database/OutboxProcessorTests.cs create mode 100644 backend/tests/LearnStack.Tests.Unit/Api/Messaging/OutboxObservationTests.cs create mode 100644 backend/tests/LearnStack.Tests.Unit/Api/Messaging/OutboxPublishAttemptTests.cs diff --git a/.claude/skills/add-audit-coverage/SKILL.md b/.claude/skills/add-audit-coverage/SKILL.md index 9ca776cf..25be356b 100644 --- a/.claude/skills/add-audit-coverage/SKILL.md +++ b/.claude/skills/add-audit-coverage/SKILL.md @@ -532,7 +532,7 @@ passes even when every policy is inert. See aggregate into another module's context inverts the dependency direction. - **`UPDATE`ing an audit row to add detail after the fact.** There is no second phase. The row is composed complete at the commit boundary, `IAuditStore` has **no update - method**. Its four delivered write methods are `WritePendingAsync`, + method**. Its original four write methods are `WritePendingAsync`, `WriteStandaloneAsync`, `WriteBestEffortAsync` and `WritePlatformScopeAsync`. ADR-0046 adds the fifth, implemented in P02b-2 Step 2, `WriteEventTerminalAsync(AuditEntryDraft, EventTerminalTransition, DbConnection, diff --git a/.claude/skills/add-integration-test/SKILL.md b/.claude/skills/add-integration-test/SKILL.md index 16256df7..5cbf7994 100644 --- a/.claude/skills/add-integration-test/SKILL.md +++ b/.claude/skills/add-integration-test/SKILL.md @@ -237,15 +237,19 @@ those shipped examples for Education rather than inventing an authenticated rout ### Step 3: Outbox round-trip -> **Steps 3 to 5 are the shape, not today's API.** `IOutbox` and the outbox -> dispatcher do not exist yet — Phase 02b owns both. `audit_log` shipped in Packet 9: -> for a real audit assertion, follow `AuditWorkflowTests`, which drives commands through +> **The `_fx.*` members in Steps 3 to 5 are illustrative.** P02b-1 implements +> `IOutbox`; follow `OutboxProducerTests` and `TenantCreatedProducerTests` for +> shipped producer fixtures. P02b-2 Step 3 implements the API-host dispatcher; +> its verification/reviews are in progress, and `OutboxProcessorTests` owns its +> runtime proofs. Real subscriptions remain Pending; consumer/inbox assertions +> belong to P02b-3. `audit_log` shipped in Packet 9: for a real audit assertion, +> follow `AuditWorkflowTests`, which drives commands through > the seeder's composition root on `SchemaFixture` as `learnstack_app` and reads the > persisted columns. The durable `IIdempotencyStore` ships on the trigger > [ADR-0037 Amendment 1](../../../docs/decisions/0037-idempotency-key-contract.md) > names: the first `[Idempotent]` endpoint, or the first deployment running more -> than one instance. Packet 6 shipped the `idempotency_keys` table, not the store. The `_fx.*` members below are illustrative of what those phases will -> provide. The schema examples above use `PostgresFixture`, `SchemaFixture` and +> than one instance. Packet 6 shipped the `idempotency_keys` table, not the store. +> The schema examples above use `PostgresFixture`, `SchemaFixture` and > `SchemaQueries`; production public HTTP cases use the separately shipped fixtures > linked above. Write against those delivered APIs until the owning phase lands. @@ -268,8 +272,8 @@ public async Task CreateEnrollment_publishes_EnrollmentCreated_via_outbox() } ``` -To assert the consumer side too, run the outbox processor synchronously in the -test: +The future P02b-3 consumer-side assertion below is illustrative; the shipped +worker has no `_fx.RunOutboxProcessorOnceAsync()` fixture API: ```csharp await _fx.RunOutboxProcessorOnceAsync(); diff --git a/.claude/skills/local-dev-setup/SKILL.md b/.claude/skills/local-dev-setup/SKILL.md index ae8eaa45..855a9465 100644 --- a/.claude/skills/local-dev-setup/SKILL.md +++ b/.claude/skills/local-dev-setup/SKILL.md @@ -105,14 +105,19 @@ P02d-5 Step 1 supplies the paired loopback launcher. Follow [README Quickstart](../../../README.md#3-prepare-local-https-and-start-the-applications) for `make public-env`, explicit mkcert trust/leaf creation and manual hosts aliases. Then run `make public-api` and `make public-web` in separate terminals. The API -helper loads only runtime database credentials and the paired hop network/secret; +helper loads application/dispatcher database credentials and the paired hop +network/secret; the web launcher captures native socket provenance before Next. Neither script shell-evaluates `.env`, edits hosts or installs trust. The optional web projection must match the root private source. -**Ordinary API startup is unchanged.** `dotnet run` still does not load `.env`. -For an API-only session, supply `ConnectionStrings:Default` through environment or -the project's `learnstack-api-dev` user-secrets store. Both empty hop lists remain +**Ordinary API startup.** `dotnet run` does not load `.env`. For an API-only +session, supply `ConnectionStrings:Default` (`learnstack_app`) and +`ConnectionStrings:OutboxDispatcher` (`learnstack_outbox_admin`) through environment +or the project's `learnstack-api-dev` user-secrets store. The mandatory API worker +resolves its guarded source and verifies dispatcher connectivity at startup. +The dispatcher credential belongs only to the API; `make seed` strips it, and +the web launcher does not receive it. Both empty hop lists remain valid in every mode; a network-only or secret-only configuration is refused. The application connection must name `learnstack_app`; the data-source guard also refuses direct or transitive BYPASSRLS/superuser access. Migration credentials diff --git a/.claude/skills/wire-dapr-pubsub/SKILL.md b/.claude/skills/wire-dapr-pubsub/SKILL.md index 40149f55..a2b3379c 100644 --- a/.claude/skills/wire-dapr-pubsub/SKILL.md +++ b/.claude/skills/wire-dapr-pubsub/SKILL.md @@ -130,7 +130,7 @@ await outbox.EnqueueAsync(new EnrollmentCreatedIntegrationEventV1 { ... }, ct); await db.SaveChangesAsync(ct); ``` -The accepted P02b-2 `OutboxProcessor` target (BackgroundService) polls +The API-host `OutboxProcessor` BackgroundService implemented in P02b-2 Step 3 polls `outbox_messages`, constructs an `IntegrationEventEnvelope`, and calls `IEventBus.PublishAsync(envelope)`. `DaprEventBus.PublishAsync` publishes to `envelope.Topic` with the envelope's diff --git a/.env.example b/.env.example index f72ac3e7..f6c1b3af 100644 --- a/.env.example +++ b/.env.example @@ -56,7 +56,7 @@ LEARNSTACK_OUTBOX_PW=learnstack-outbox-dev # FORCE ROW LEVEL SECURITY exists to defeat. # Default — every request-path and job-path DbContext. NOBYPASSRLS. # PlatformAdmin — resolvable only by PlatformAdminScope. BYPASSRLS. -# OutboxDispatcher — the worker running OutboxProcessor, and nothing else. +# OutboxDispatcher — API-owned OutboxProcessor only. Never Seeder or web. ConnectionStrings__Migration='Host=localhost;Port=5432;Database=learnstack;Username=learnstack_migration;Password=learnstack-migration-dev' ConnectionStrings__Default='Host=localhost;Port=5432;Database=learnstack;Username=learnstack_app;Password=learnstack-app-dev' ConnectionStrings__PlatformAdmin='Host=localhost;Port=5432;Database=learnstack;Username=learnstack_platform;Password=learnstack-platform-dev' @@ -109,7 +109,7 @@ ASPNETCORE_URLS=http://localhost:5080 # ─── Public renderer (ADR-0053; API and web share this private source) ─── # make public-env fills the empty secret with 32 random bytes encoded as ASCII. # No TLS/hosts/trust-store changes are automatic. See README Quickstart. -# make public-api loads only runtime DB credentials plus network AND secret. +# make public-api loads application/dispatcher DB credentials plus network AND secret. # pnpm dev/start loads these values; an optional web .env.local must match. LEARNSTACK_PUBLIC_API_ORIGIN=http://127.0.0.1:5080 LEARNSTACK_PUBLIC_HOP_SECRET= diff --git a/CLAUDE.md b/CLAUDE.md index 68c9af2d..260d4558 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -188,7 +188,10 @@ rounds per step are authorized on development. The [Step 1 record](docs/roadmap/phase-02b-events-auth.md#p02b-2-step-1--decision-schema-and-confined-composition-2026-10-12) records both completed Step 1 reviews. Step 2 implements fenced transitions and row-bound terminal MUST audit; both independent review rounds pass. -Hosted execution remains Step 3. Acceptance registers six delivery obligations; +Step 3 implements hosted execution and observation; its +[delivery record](docs/roadmap/phase-02b-events-auth.md#p02b-2-step-3--hosted-lifecycle-and-observability-2026-10-12) +owns verification and reviews, which are in progress. Step 4 enforcement remains +pending. Acceptance registers six delivery obligations; it claims no inbox or subscriber delivery. P02b-3 still owns durable acceptance, shared inbox/ledger, independent retries, both real default subscriptions and Seeder handoff. @@ -394,8 +397,9 @@ Then read the phase that is live and the one it stands on: — **the live phase**. P02b-0 entry verification is complete; P02b-1 is complete and merged through PR #29 on 2026-10-12. Its four steps deliver the producer runtime and production-census enforcement. P02b-2's revised package/ADR-0046 are - Accepted, with Step 1 and both review rounds complete; producer dispatch - delivery remains pending. + Accepted. Steps 1–2 completed both review rounds; Step 3 implements hosted + dispatch and observation, with verification/reviews in progress. Step 4 + enforcement remains pending. P02b-3 owns consumer/inbox delivery; each later packet accepts its gates first. - [docs/roadmap/phase-02d-walking-skeleton.md](docs/roadmap/phase-02d-walking-skeleton.md) — complete and merged; the delivered public-site baseline and P02b-0 handoff. @@ -645,7 +649,8 @@ rules: rows directly — use `IAuditStore`, `IEntitlementProvider.RefreshAsync`, `IOutbox`. Only ADR-0046's confined dispatcher writes outbox status through fenced parameterized SQL; its terminal UPDATE and narrow witness-bound audit INSERT - share one transaction. That accepted exception remains pending P02b-2 delivery. + share one transaction. P02b-2 Step 2 implements that accepted exception; both + independent review rounds passed. - Write rows through EF Core's set-based APIs — `ExecuteUpdate`, `ExecuteDelete`, `ExecuteSql*`. The audit capture sees only what the `ChangeTracker` holds ([ADR-0044 § 7](docs/decisions/0044-audit-write-path.md)), so such a write commits with diff --git a/README.md b/README.md index f88ca268..43235d43 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,11 @@ is complete. **P02b-1 is complete and merged** through [PR #29](https://github.com/HodeTech/LearnStack/pull/29) on 2026-10-12. Its [merge closeout](docs/roadmap/phase-02b-events-auth.md#p02b-1-merge-and-closeout-2026-10-12) records transactional event production, enforcement, completed reviews and final -CI verification under Accepted ADR-0057. Next is P02b-2's decision pass. +CI verification under Accepted ADR-0057. P02b-2's revised package and ADR-0046 +are Accepted. Steps 1–2 completed both review rounds; Step 3 implements hosted +dispatch and observation, with verification and reviews in progress. Its +[delivery record](docs/roadmap/phase-02b-events-auth.md#p02b-2-step-3--hosted-lifecycle-and-observability-2026-10-12) +owns current evidence. Step 4 enforcement remains pending. [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, @@ -121,8 +125,9 @@ proof pass real CI; the seventh required context is active. The owns CI enforcement, reviews and the positive phase-exit verdict. [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 +owns final verification. P02b-0 and P02b-1 are complete; P02b-2 Step 3 implements +the producer worker, with verification/reviews in progress and Step 4 enforcement +pending. Consumer delivery remains 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 @@ -134,7 +139,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, observability and transactional event production | Authentication and durable event dispatch/consumption in [Phase 02b](docs/roadmap/phase-02b-events-auth.md) | +| **API foundation** | Error contracts, validation, tenancy, concurrency, observability, transactional event production and hosted producer dispatch (Step 3 verification/reviews in progress) | Authentication and durable event 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 @@ -226,7 +231,10 @@ Readiness is <http://127.0.0.1:5080/healthz> for the API and <https://localhost:3000/api/healthz> for the web listener. `pnpm dev/start` also use this launcher. Both processes read the same private root source; a web `.env.local` projection is optional and must match. No shell evaluates the values. Ordinary -no-hop API startup remains supported outside this paired renderer recipe. +no-hop API startup remains supported outside this paired renderer recipe; supply +both `ConnectionStrings:Default` (`learnstack_app`) and +`ConnectionStrings:OutboxDispatcher` (`learnstack_outbox_admin`). The mandatory +API worker verifies dispatcher connectivity at startup. **P02d-5 is complete: ingress, visitor budgets and public entry.** The web bootstraps the captured host through the API, redirects using enabled/default diff --git a/backend/src/LearnStack.Api/Composition/OutboxCompositionExtensions.cs b/backend/src/LearnStack.Api/Composition/OutboxCompositionExtensions.cs index 38f0cc47..4ab9d192 100644 --- a/backend/src/LearnStack.Api/Composition/OutboxCompositionExtensions.cs +++ b/backend/src/LearnStack.Api/Composition/OutboxCompositionExtensions.cs @@ -1,4 +1,7 @@ +using LearnStack.Api.Messaging; using LearnStack.Infrastructure.Messaging; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Time; using Microsoft.Extensions.DependencyInjection.Extensions; using Npgsql; @@ -29,6 +32,24 @@ public static IServiceCollection AddLearnStackOutboxDispatcher( OutboxDispatcherDataSource.Key, (provider, key) => new Lazy<NpgsqlDataSource>( () => provider.GetRequiredKeyedService<NpgsqlDataSource>(key))); + + services.TryAddSingleton(new OutboxDispatchOptions()); + services.TryAddSingleton(TimeProvider.System); + services.TryAddSingleton<OutboxEnvelopeReader>(); + services.TryAddSingleton<OutboxObservation>(); + services.TryAddSingleton(provider => new OutboxDispatchStore( + provider.GetRequiredKeyedService<Lazy<NpgsqlDataSource>>(OutboxDispatcherDataSource.Key), + provider.GetRequiredService<OutboxEventRegistry>(), + provider.GetRequiredService<OutboxDispatchOptions>(), + provider.GetRequiredService<IGuidFactory>(), + provider.GetRequiredService<IClock>(), + provider.GetRequiredService<IServiceScopeFactory>())); + if (!services.Any(descriptor => descriptor.ServiceType == typeof(IHostedService) + && descriptor.ImplementationType == typeof(OutboxProcessor))) + { + services.AddHostedService<OutboxProcessor>(); + services.AddHealthChecks().AddCheck<OutboxHealthCheck>(OutboxHealthCheck.Name); + } return services; } } diff --git a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs index 19e2fc63..ac3aca77 100644 --- a/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs +++ b/backend/src/LearnStack.Api/Composition/PersistenceCompositionExtensions.cs @@ -35,7 +35,7 @@ namespace LearnStack.Api.Composition; /// </summary> /// <remarks> /// <para> -/// <b>One data source, and it must be the application role's.</b> +/// <b>Module contexts share the application-role data source.</b> /// <c>ConnectionStrings:Default</c> names <c>learnstack_app</c>, which is /// <c>NOBYPASSRLS</c> and holds <c>USAGE</c> but not <c>CREATE</c> on schema /// <c>public</c>. <c>ConnectionStrings:Migration</c> is never read here — that @@ -62,8 +62,8 @@ namespace LearnStack.Api.Composition; /// <para> /// <b>Built lazily; validated eagerly when there is anything to validate.</b> /// The data source is a singleton whose factory runs when something first needs a -/// connection, so a <c>WebApplicationFactory</c> test — and a deployment serving -/// only platform hosts — carries no database it does not use. The two checks above +/// connection, so a construction-only <c>WebApplicationFactory</c> test carries +/// no application database it does not use. The two checks above /// are not deferred with it: a present <c>ConnectionStrings:Default</c> is /// name-checked at <c>AddLearnStackPersistence</c> time, because a string naming /// <c>learnstack_migration</c> is precisely the ownership mistake this guard exists @@ -93,7 +93,7 @@ public static class PersistenceCompositionExtensions { private const string DefaultConnectionName = "Default"; - /// <summary>The one role a runtime process may connect as.</summary> + /// <summary>The role required by the application data source.</summary> internal const string RuntimeRole = ApplicationDataSource.RuntimeRole; public static IServiceCollection AddLearnStackPersistence( diff --git a/backend/src/LearnStack.Api/Messaging/OutboxHealthCheck.cs b/backend/src/LearnStack.Api/Messaging/OutboxHealthCheck.cs new file mode 100644 index 00000000..3670f954 --- /dev/null +++ b/backend/src/LearnStack.Api/Messaging/OutboxHealthCheck.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace LearnStack.Api.Messaging; + +/// <summary>Private readiness observation; the public liveness route does not select this check.</summary> +public sealed class OutboxHealthCheck(OutboxObservation observation) : IHealthCheck +{ + public const string Name = "outbox"; + + public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, + CancellationToken cancellationToken = default) => Task.FromResult(observation.IsHealthy + ? HealthCheckResult.Healthy() + : HealthCheckResult.Unhealthy("Outbox dispatch is not healthy.")); +} diff --git a/backend/src/LearnStack.Api/Messaging/OutboxObservation.cs b/backend/src/LearnStack.Api/Messaging/OutboxObservation.cs new file mode 100644 index 00000000..0c471f8d --- /dev/null +++ b/backend/src/LearnStack.Api/Messaging/OutboxObservation.cs @@ -0,0 +1,132 @@ +using System.Collections.Frozen; +using System.Diagnostics; +using System.Diagnostics.Metrics; +using LearnStack.Infrastructure.Messaging; + +namespace LearnStack.Api.Messaging; + +/// <summary>Immutable process observation; timestamps use TimeProvider's monotonic clock.</summary> +public sealed record OutboxObservationSnapshot( + OutboxQueueSnapshot Queue, + bool Started = false, + bool Stopped = false, + long? LastProgressTimestamp = null, + long? LastSnapshotTimestamp = null, + int OutstandingPublishes = 0, + bool RefreshFailed = false, + bool LoopFailed = false, + bool TerminalAuditFailed = false); + +/// <summary>Bounded dispatch telemetry; observable callbacks only read memory.</summary> +public sealed class OutboxObservation : IDisposable +{ + public const string InstrumentationName = "learnstack.outbox"; + private readonly object _gate = new(); + private readonly TimeProvider _time; + private readonly OutboxDispatchOptions _options; + private readonly FrozenSet<string> _labels; + private readonly Meter _meter = new(InstrumentationName); + private readonly ActivitySource _activities = new(InstrumentationName); + private readonly Histogram<double> _duration; + private readonly Counter<long> _failed; + private readonly Counter<long> _deadletter; + private readonly Counter<long> _leaseLost; + private OutboxObservationSnapshot _snapshot = new(new OutboxQueueSnapshot(0, 0, 0)); + + public OutboxObservation(TimeProvider time, OutboxDispatchOptions options, OutboxEventRegistry registry) + { + ArgumentNullException.ThrowIfNull(time); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(registry); + _time = time; + _options = options; + _labels = registry.EventTypeLabels.Concat(["unknown", "retired", "invalid"]) + .ToFrozenSet(StringComparer.Ordinal); + _duration = _meter.CreateHistogram<double>("learnstack_outbox_dispatch_duration_seconds", "s"); + _failed = _meter.CreateCounter<long>("learnstack_outbox_dispatch_failed_total"); + _deadletter = _meter.CreateCounter<long>("learnstack_outbox_deadletter_total"); + _leaseLost = _meter.CreateCounter<long>("learnstack_outbox_lease_lost_total"); + _meter.CreateObservableGauge("learnstack_outbox_pending_count", () => Snapshot.Queue.PendingCount); + _meter.CreateObservableGauge("learnstack_outbox_oldest_eligible_age_seconds", + () => Snapshot.Queue.OldestEligibleAgeSeconds, "s"); + _meter.CreateObservableGauge("learnstack_outbox_deferred_count", () => Snapshot.Queue.DeferredCount); + _meter.CreateObservableGauge("learnstack_outbox_heartbeat_age_seconds", () => HeartbeatAge.TotalSeconds, "s"); + } + + public OutboxObservationSnapshot Snapshot => Volatile.Read(ref _snapshot); + + public TimeSpan HeartbeatAge => Age(Snapshot.LastProgressTimestamp); + + public bool IsHealthy + { + get + { + var snapshot = Snapshot; + return snapshot.Started && !snapshot.Stopped && !snapshot.RefreshFailed + && !snapshot.LoopFailed && !snapshot.TerminalAuditFailed + && snapshot.OutstandingPublishes == 0 + && Age(snapshot.LastProgressTimestamp) < _options.ProgressTimeout + && Age(snapshot.LastSnapshotTimestamp) < _options.ProgressTimeout; + } + } + + internal void Started() => Update(value => value with { Started = true, Stopped = false }); + internal void Stopped() => Update(value => value with { Stopped = true }); + internal void Progress() => Update(value => value with + { + LastProgressTimestamp = _time.GetTimestamp(), + LoopFailed = false, + }); + internal void Refreshed(OutboxQueueSnapshot queue) => Update(value => value with + { + Queue = queue, + LastSnapshotTimestamp = _time.GetTimestamp(), + RefreshFailed = false, + }); + internal void RefreshFailed() => Update(value => value with { RefreshFailed = true }); + internal void LoopFailed() => Update(value => value with { LoopFailed = true }); + internal void TerminalAuditFailed() => Update(value => value with { TerminalAuditFailed = true }); + internal void TerminalAuditSucceeded() => Update(value => value with { TerminalAuditFailed = false }); + internal void Outstanding(int count) => Update(value => value with { OutstandingPublishes = count }); + internal void LeaseLost() => _leaseLost.Add(1); + internal void Failed(string label) => _failed.Add(1, EventType(label)); + internal void Deadletter(string label) => _deadletter.Add(1, EventType(label)); + internal void Duration(string label, TimeSpan duration) => + _duration.Record(Math.Max(0, duration.TotalSeconds), EventType(label)); + + internal Activity BeginAttempt(string correlation, string label) + { + var valid = ActivityContext.TryParse(correlation, traceState: null, out var parent); + var activity = _activities.StartActivity("outbox.dispatch", ActivityKind.Producer, parent); + if (activity is null) + { + // Audit needs a fresh valid attempt trace even with no telemetry listener. + activity = new Activity("outbox.dispatch").SetIdFormat(ActivityIdFormat.W3C); + if (valid) activity.SetParentId(parent.TraceId, parent.SpanId, parent.TraceFlags); + activity.Start(); + } + + activity.SetTag("event_type", SafeLabel(label)); + // Reconstruct the bounded identity; a future-version traceparent may + // contain additional fields that must not become diagnostic data. + if (valid) activity.SetTag("correlation.id", + $"00-{parent.TraceId}-{parent.SpanId}-{((parent.TraceFlags & ActivityTraceFlags.Recorded) != 0 ? "01" : "00")}"); + return activity; + } + + private KeyValuePair<string, object?> EventType(string label) => new("event_type", SafeLabel(label)); + private string SafeLabel(string label) => _labels.Contains(label) ? label : "invalid"; + private TimeSpan Age(long? timestamp) => timestamp is { } value + ? _time.GetElapsedTime(value, _time.GetTimestamp()) : _options.ProgressTimeout; + + private void Update(Func<OutboxObservationSnapshot, OutboxObservationSnapshot> change) + { + lock (_gate) Volatile.Write(ref _snapshot, change(_snapshot)); + } + + public void Dispose() + { + _activities.Dispose(); + _meter.Dispose(); + } +} diff --git a/backend/src/LearnStack.Api/Messaging/OutboxProcessor.cs b/backend/src/LearnStack.Api/Messaging/OutboxProcessor.cs new file mode 100644 index 00000000..44ccf8ed --- /dev/null +++ b/backend/src/LearnStack.Api/Messaging/OutboxProcessor.cs @@ -0,0 +1,333 @@ +using System.Diagnostics; +using LearnStack.Infrastructure.Messaging; +using Npgsql; + +namespace LearnStack.Api.Messaging; + +/// <summary>One just-in-time claimant with bounded, task-owned publication overruns.</summary> +public sealed partial class OutboxProcessor : BackgroundService +{ + private readonly Lazy<NpgsqlDataSource> _source; + private readonly OutboxDispatchStore _store; + private readonly OutboxEnvelopeReader _reader; + private readonly OutboxEventRegistry _registry; + private readonly OutboxDispatchOptions _options; + private readonly TimeProvider _time; + private readonly IServiceScopeFactory _scopes; + private readonly OutboxObservation _observation; + private readonly ILogger<OutboxProcessor> _logger; + private readonly object _attemptGate = new(); + private readonly List<OutboxPublishAttempt> _attempts = []; + + public OutboxProcessor( + [FromKeyedServices(OutboxDispatcherDataSource.Key)] Lazy<NpgsqlDataSource> source, + OutboxDispatchStore store, OutboxEnvelopeReader reader, OutboxEventRegistry registry, + OutboxDispatchOptions options, TimeProvider time, IServiceScopeFactory scopes, + OutboxObservation observation, ILogger<OutboxProcessor> logger) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(store); + ArgumentNullException.ThrowIfNull(reader); + ArgumentNullException.ThrowIfNull(registry); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(time); + ArgumentNullException.ThrowIfNull(scopes); + ArgumentNullException.ThrowIfNull(observation); + ArgumentNullException.ThrowIfNull(logger); + _source = source; + _store = store; + _reader = reader; + _registry = registry; + _options = options; + _time = time; + _scopes = scopes; + _observation = observation; + _logger = logger; + } + + public override async Task StartAsync(CancellationToken cancellationToken) + { + // .NET 10 runs ExecuteAsync in the background. Startup refusal therefore + // belongs here, before the host can report that it has started. + try + { + _options.Validate(); + using var timeout = new CancellationTokenSource(_options.SqlTimeout, _time); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token); + await using var connection = await _source.Value.OpenConnectionAsync(deadline.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw new OperationCanceledException(cancellationToken); + } + catch (Exception) + { + // No connection data, provider exception or inner exception escapes boot. + throw new InvalidOperationException("Outbox dispatcher startup was refused."); + } + + _observation.Started(); + await base.StartAsync(cancellationToken).ConfigureAwait(false); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + try + { + while (!stoppingToken.IsCancellationRequested) + { + var blocked = RetainedPartitions(); + var didWork = false; + try + { + if (OutstandingCount < _options.MaxOutstandingPublishes) + { + // Terminal requests and expired acquisitions never spend + // another publication attempt, and take priority over new work. + var claim = await _store.TryClaimTerminalAsync(blocked, stoppingToken).ConfigureAwait(false) + ?? await _store.TryAcquireRecoveryAsync(blocked, stoppingToken).ConfigureAwait(false) + ?? await _store.TryClaimAsync(blocked, stoppingToken).ConfigureAwait(false); + if (claim is not null) + { + didWork = true; + await DispatchAsync(claim, stoppingToken).ConfigureAwait(false); + } + + // Even an empty completed poll demonstrates real bounded work. + _observation.Progress(); + } + + await RefreshAsync(didWork, stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception) + { + _observation.LoopFailed(); + LogIterationFailure(_logger); + didWork = false; + } + + if (!didWork) + await Task.Delay(_options.IdlePoll, _time, stoppingToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + // Normal hosted-service cancellation; live attempts own their cleanup. + } + finally + { + CancelAttempts(); + _observation.Stopped(); + } + } + + private async Task DispatchAsync(OutboxDispatchClaim claim, CancellationToken stoppingToken) + { + var label = claim.Kind == OutboxClaimKind.Terminal + ? _reader.Read(claim).EventTypeLabel : _registry.Resolve(claim.Type).EventTypeLabel; + if (claim.Kind == OutboxClaimKind.Normal) + { + await DispatchNormalAsync(claim, stoppingToken).ConfigureAwait(false); + return; + } + + using var activity = _observation.BeginAttempt(claim.CorrelationId, label); + if (claim.Kind == OutboxClaimKind.Recovery) + { + Fence(await _store.FailAsync(claim, OutboxFailureReason.UnknownOutcome, stoppingToken).ConfigureAwait(false)); + activity.SetTag("outcome", "recovered"); + LogAttempt(activity, label); + return; + } + + try + { + // BeginAttempt always starts a W3C Activity, including without listeners. + var terminalized = await _store.TryTerminalizeAsync(claim, activity.Id!, stoppingToken).ConfigureAwait(false); + Fence(terminalized); + if (terminalized) + { + _observation.TerminalAuditSucceeded(); + _observation.Deadletter(label); + } + activity.SetTag("outcome", terminalized ? "deadlettered" : "lease_lost"); + } + catch (Exception) + { + _observation.TerminalAuditFailed(); + activity.SetTag("outcome", "terminal_audit_failed"); + throw; + } + finally + { + LogAttempt(activity, label); + } + } + + private async Task DispatchNormalAsync(OutboxDispatchClaim claim, CancellationToken stoppingToken) + { + var read = _reader.Read(claim); + using var activity = _observation.BeginAttempt(claim.CorrelationId, read.EventTypeLabel); + var started = _time.GetTimestamp(); + try + { + if (read.TerminalReason is { } terminal) + { + Fence(await _store.RequestTerminalAsync(claim, terminal, stoppingToken).ConfigureAwait(false)); + activity.SetTag("outcome", "terminal_requested"); + return; + } + + if (read.Disposition == OutboxEventDisposition.IntentionallyUnsubscribed) + { + Fence(await _store.CompleteAsync(claim, stoppingToken).ConfigureAwait(false)); + activity.SetTag("outcome", "intentionally_unsubscribed"); + return; + } + + // The immutable registry excludes Pending identities in SQL, before + // acquisition. Refuse publication if this invariant ever changes. + if (read.Disposition != OutboxEventDisposition.Active || read.Envelope is null) + throw new InvalidOperationException("Outbox publication was not admitted."); + + var attempt = new OutboxPublishAttempt(_scopes, read.Envelope); + lock (_attemptGate) _attempts.Add(attempt); + bool succeeded; + try + { + succeeded = await attempt.Completion.WaitAsync(_options.PublishTimeout, _time, stoppingToken) + .ConfigureAwait(false); + } + catch (TimeoutException) + { + attempt.RequestCancellation(); + RetainedPartitions(); + _observation.Failed(read.EventTypeLabel); + activity.SetTag("outcome", "publish_timeout"); + // This is the only durable timeout transition. A late callback's + // result is observed solely by its owned task, never acknowledged. + Fence(await _store.FailAsync(claim, OutboxFailureReason.PublishTimeout, stoppingToken).ConfigureAwait(false)); + return; + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + attempt.RequestCancellation(); + RetainedPartitions(); + activity.SetTag("outcome", "shutdown"); + // Expired-lease recovery owns this unknown result after shutdown. + throw; + } + + RetainedPartitions(); + stoppingToken.ThrowIfCancellationRequested(); + if (succeeded) + { + var completed = await _store.CompleteAsync(claim, stoppingToken).ConfigureAwait(false); + Fence(completed); + activity.SetTag("outcome", completed ? "published" : "lease_lost"); + } + else + { + _observation.Failed(read.EventTypeLabel); + Fence(await _store.FailAsync(claim, OutboxFailureReason.PublishFailed, stoppingToken).ConfigureAwait(false)); + activity.SetTag("outcome", "publish_failed"); + } + } + finally + { + _observation.Duration(read.EventTypeLabel, _time.GetElapsedTime(started, _time.GetTimestamp())); + LogAttempt(activity, read.EventTypeLabel); + } + } + + private void LogAttempt(Activity activity, string label) + { + if (_logger.IsEnabled(LogLevel.Information)) + { + var outcome = activity.GetTagItem("outcome") as string ?? "storage_failure"; + LogAttemptFinished(_logger, activity.Id, label, outcome); + } + } + + [LoggerMessage(Level = LogLevel.Warning, Message = "Outbox dispatch iteration failed with outcome storage_failure.")] + private static partial void LogIterationFailure(ILogger logger); + + [LoggerMessage(Level = LogLevel.Information, + Message = "Outbox dispatch attempt {CorrelationId} finished with event type {EventType} and outcome {Outcome}.")] + private static partial void LogAttemptFinished(ILogger logger, string? correlationId, string eventType, string outcome); + + private async Task RefreshAsync(bool force, CancellationToken cancellationToken) + { + var last = _observation.Snapshot.LastSnapshotTimestamp; + if (!force && last is { } timestamp + && _time.GetElapsedTime(timestamp, _time.GetTimestamp()) < _options.SnapshotInterval) return; + try + { + var queue = await _store.ReadSnapshotAsync(RetainedPartitions(), cancellationToken).ConfigureAwait(false); + _observation.Refreshed(queue); + } + catch (Exception) + { + _observation.RefreshFailed(); + throw; + } + } + + private void Fence(bool changed) + { + if (!changed) _observation.LeaseLost(); + } + + private int OutstandingCount + { + get { lock (_attemptGate) return _attempts.Count; } + } + + private string[] RetainedPartitions() + { + lock (_attemptGate) + { + // Completion includes cancellation callbacks and asynchronous scope + // disposal; only then may this partition be acquired locally again. + _attempts.RemoveAll(attempt => attempt.Completion.IsCompleted); + _observation.Outstanding(_attempts.Count); + return _attempts.Select(attempt => attempt.PartitionKey).Distinct(StringComparer.Ordinal).ToArray(); + } + } + + private OutboxPublishAttempt[] CancelAttempts() + { + OutboxPublishAttempt[] attempts; + lock (_attemptGate) attempts = _attempts.ToArray(); + foreach (var attempt in attempts) attempt.RequestCancellation(); + return attempts; + } + + public override async Task StopAsync(CancellationToken cancellationToken) + { + // The base service and callback drain share one overall budget. Never add + // a second shutdown wait after the host has already exhausted the first. + using var timeout = new CancellationTokenSource(_options.ShutdownTimeout, _time); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token); + try + { + await base.StopAsync(deadline.Token).ConfigureAwait(false); + var attempts = CancelAttempts(); + await Task.WhenAll(attempts.Select(attempt => attempt.Completion)).WaitAsync(deadline.Token) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (deadline.IsCancellationRequested) + { + CancelAttempts(); + } + finally + { + RetainedPartitions(); + _observation.Stopped(); + } + } +} diff --git a/backend/src/LearnStack.Api/Messaging/OutboxPublishAttempt.cs b/backend/src/LearnStack.Api/Messaging/OutboxPublishAttempt.cs new file mode 100644 index 00000000..9b6d660a --- /dev/null +++ b/backend/src/LearnStack.Api/Messaging/OutboxPublishAttempt.cs @@ -0,0 +1,87 @@ +using System.Diagnostics.CodeAnalysis; +using LearnStack.SharedKernel.Messaging; + +namespace LearnStack.Api.Messaging; + +/// <summary>Owns callback construction, cancellation callbacks and scope cleanup as one task.</summary> +[SuppressMessage("Design", "CA1001:Types that own disposable fields should be disposable", + Justification = "The owned task disposes its cancellation source only after callback and scope cleanup; caller disposal could invalidate a live attempt.")] +internal sealed class OutboxPublishAttempt +{ + private readonly object _gate = new(); + private readonly CancellationTokenSource _cancellation = new(); + private Task? _cancelCallbacks; + private bool _finishing; + + public OutboxPublishAttempt(IServiceScopeFactory scopes, IntegrationEventEnvelope envelope) + { + ArgumentNullException.ThrowIfNull(scopes); + ArgumentNullException.ThrowIfNull(envelope); + PartitionKey = envelope.PartitionKey; + // Resolution and even the synchronous prefix of PublishAsync belong to the + // retained task. Neither may monopolize the dispatcher's bounded wait. + Completion = Task.Run(() => PublishAsync(scopes, envelope), CancellationToken.None); + } + + public string PartitionKey { get; } + public Task<bool> Completion { get; } + + public void RequestCancellation() + { + lock (_gate) + { + if (_finishing || _cancelCallbacks is not null) return; + // CancelAsync changes token state immediately but runs registered + // callbacks asynchronously. A blocking registration cannot block StopAsync. + _cancelCallbacks = _cancellation.CancelAsync(); + } + } + + private async Task<bool> PublishAsync(IServiceScopeFactory scopes, IntegrationEventEnvelope envelope) + { + AsyncServiceScope? scope = null; + var succeeded = false; + try + { + scope = scopes.CreateAsyncScope(); + var bus = scope.Value.ServiceProvider.GetRequiredService<IEventBus>(); + await bus.PublishAsync(envelope, _cancellation.Token).ConfigureAwait(false); + succeeded = !_cancellation.IsCancellationRequested; + } + catch (Exception) + { + // Provider diagnostics and payloads never cross this dispatch boundary. + succeeded = false; + } + finally + { + Task? callbacks; + lock (_gate) + { + _finishing = true; + callbacks = _cancelCallbacks; + } + + if (callbacks is not null) + { + try { await callbacks.ConfigureAwait(false); } + catch (Exception) { succeeded = false; } + } + + try + { + if (scope is { } ownedScope) await ownedScope.DisposeAsync().ConfigureAwait(false); + } + catch (Exception) + { + succeeded = false; + } + finally + { + _cancellation.Dispose(); + } + } + + return succeeded; + } +} diff --git a/backend/tests/LearnStack.Tests.Architecture/PlatformAdminScopeConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/PlatformAdminScopeConventionTests.cs index 03ec6652..be114051 100644 --- a/backend/tests/LearnStack.Tests.Architecture/PlatformAdminScopeConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/PlatformAdminScopeConventionTests.cs @@ -1,6 +1,9 @@ using System.Reflection; using FluentAssertions; +using LearnStack.Api.Messaging; +using LearnStack.Infrastructure.Messaging; using LearnStack.SharedKernel.Tenancy; +using Microsoft.Extensions.DependencyInjection; using Xunit; namespace LearnStack.Tests.Architecture; @@ -31,6 +34,7 @@ public sealed class PlatformAdminScopeConventionTests // preserves this global resolution fence while permitting its approved root. private const string OutboxCompositionFile = "LearnStack.Api/Composition/OutboxCompositionExtensions.cs"; + private const string OutboxWorkerFile = "LearnStack.Api/Messaging/OutboxProcessor.cs"; [Fact] public void Platform_DataSource_Resolved_Only_By_PlatformAdminScope() @@ -53,9 +57,17 @@ public void Platform_DataSource_Resolved_Only_By_PlatformAdminScope() .ToList(); resolvers.Should().BeEquivalentTo( - [ScopeFile, CompositionFile, OutboxCompositionFile], - "the scope injects the keyed source and the composition root registers it; a " - + "third resolver is a second path to a credential that sees every tenant"); + [ScopeFile, CompositionFile, OutboxCompositionFile, OutboxWorkerFile], + "the platform scope and the separately confined dispatcher are the only " + + "credential consumers, alongside their composition roots"); + + typeof(OutboxProcessor).GetConstructors().SelectMany(constructor => constructor.GetParameters()) + .Select(parameter => parameter.GetCustomAttribute<FromKeyedServicesAttribute>()) + .OfType<FromKeyedServicesAttribute>().Should().ContainSingle().Which.Key + .Should().Be(OutboxDispatcherDataSource.Key, + "permitting the worker file must not permit the platform credential"); + File.ReadAllText(Path.Combine(SourceScan.SourceRoot, OutboxWorkerFile)) + .Should().NotContain("PlatformAdmin"); // Leg 2 — IConfiguration.GetConnectionString is called in exactly one file under // backend/src. Not "every credential is read in one file": the two design-time diff --git a/backend/tests/LearnStack.Tests.Contract/DevelopmentWebApplicationFactory.cs b/backend/tests/LearnStack.Tests.Contract/DevelopmentWebApplicationFactory.cs index be0a0e27..cb05ef3d 100644 --- a/backend/tests/LearnStack.Tests.Contract/DevelopmentWebApplicationFactory.cs +++ b/backend/tests/LearnStack.Tests.Contract/DevelopmentWebApplicationFactory.cs @@ -1,5 +1,8 @@ +using LearnStack.Api.Messaging; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; namespace LearnStack.Tests.Contract; @@ -23,5 +26,14 @@ public sealed class DevelopmentWebApplicationFactory : WebApplicationFactory<Pro protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseEnvironment(Environments.Development); + // This served-contract proof owns no dispatcher database. Remove only + // that worker; production startup tests retain the mandatory registration. + builder.ConfigureTestServices(services => + { + foreach (var descriptor in services.Where(descriptor => + descriptor.ServiceType == typeof(IHostedService) + && descriptor.ImplementationType == typeof(OutboxProcessor)).ToArray()) + services.Remove(descriptor); + }); } } diff --git a/backend/tests/LearnStack.Tests.Integration/ApiHandlerCompositionTests.cs b/backend/tests/LearnStack.Tests.Integration/ApiHandlerCompositionTests.cs index 9e36beb1..6a3a0c32 100644 --- a/backend/tests/LearnStack.Tests.Integration/ApiHandlerCompositionTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/ApiHandlerCompositionTests.cs @@ -31,12 +31,12 @@ namespace LearnStack.Tests.Integration; /// </remarks> [Trait(Database.RequiresDocker.Key, Database.RequiresDocker.Value)] [Collection(Database.SharedSchema.Name)] -public sealed class ApiHandlerCompositionTests : IClassFixture<WebApplicationFactory<Program>> +public sealed class ApiHandlerCompositionTests : IClassFixture<ConstructionOnlyApiFactory> { private readonly WebApplicationFactory<Program> _factory; private readonly Database.SchemaFixture _schema; - public ApiHandlerCompositionTests(WebApplicationFactory<Program> factory, Database.SchemaFixture schema) + public ApiHandlerCompositionTests(ConstructionOnlyApiFactory factory, Database.SchemaFixture schema) { _factory = factory; _schema = schema; diff --git a/backend/tests/LearnStack.Tests.Integration/ApiVersioningHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/ApiVersioningHttpTests.cs index 73b5b94d..57af6d07 100644 --- a/backend/tests/LearnStack.Tests.Integration/ApiVersioningHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/ApiVersioningHttpTests.cs @@ -279,9 +279,9 @@ private async Task<JsonDocument> GetDocumentAsync(string major) /// declaring <c>v2</c> — plus a second live major so the per-document /// filtering has something to filter. /// </summary> -public sealed class ApiVersioningFixture : WebApplicationFactory<Program> +public sealed class ApiVersioningFixture : ConstructionOnlyApiFactory { - protected override void ConfigureWebHost(IWebHostBuilder builder) + protected override void ConfigureTestHost(IWebHostBuilder builder) { ArgumentNullException.ThrowIfNull(builder); builder.UseEnvironment(Environments.Development); diff --git a/backend/tests/LearnStack.Tests.Integration/AuthorityCeilingHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/AuthorityCeilingHttpTests.cs index e31b18aa..f23cc741 100644 --- a/backend/tests/LearnStack.Tests.Integration/AuthorityCeilingHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/AuthorityCeilingHttpTests.cs @@ -175,13 +175,13 @@ public sealed class HostClassificationMeter : ICollectionFixture<HostClassificat } /// <summary>A host whose resolver maps one tenant host and nothing else.</summary> -public sealed class AuthorityCeilingFixture : WebApplicationFactory<Program> +public sealed class AuthorityCeilingFixture : ConstructionOnlyApiFactory { public const string TenantHost = "school.example.com"; public static readonly Guid Tenant = Guid.Parse("018f4d40-0000-7000-8000-0000000000d1"); - protected override void ConfigureWebHost(IWebHostBuilder builder) + protected override void ConfigureTestHost(IWebHostBuilder builder) { ArgumentNullException.ThrowIfNull(builder); builder.UseEnvironment(Environments.Development); diff --git a/backend/tests/LearnStack.Tests.Integration/CompositionRootCatalogueTests.cs b/backend/tests/LearnStack.Tests.Integration/CompositionRootCatalogueTests.cs index 42dd7b3d..22dc27d8 100644 --- a/backend/tests/LearnStack.Tests.Integration/CompositionRootCatalogueTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/CompositionRootCatalogueTests.cs @@ -29,11 +29,11 @@ namespace LearnStack.Tests.Integration; /// exactly the entries the discovered sources declare — no source missing, none extra. /// </para> /// </remarks> -public sealed class CompositionRootCatalogueTests : IClassFixture<WebApplicationFactory<Program>> +public sealed class CompositionRootCatalogueTests : IClassFixture<ConstructionOnlyApiFactory> { private readonly WebApplicationFactory<Program> _factory; - public CompositionRootCatalogueTests(WebApplicationFactory<Program> factory) => _factory = factory; + public CompositionRootCatalogueTests(ConstructionOnlyApiFactory factory) => _factory = factory; [Fact] public void The_API_root_builds_the_discovered_catalogue() diff --git a/backend/tests/LearnStack.Tests.Integration/ConstructionOnlyApiFactory.cs b/backend/tests/LearnStack.Tests.Integration/ConstructionOnlyApiFactory.cs new file mode 100644 index 00000000..7eaf3a7d --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/ConstructionOnlyApiFactory.cs @@ -0,0 +1,39 @@ +using LearnStack.Api.Messaging; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace LearnStack.Tests.Integration; + +/// <summary> +/// Request/composition proofs own no background dispatcher. Production startup +/// proofs use an ordinary factory and must supply its confined credential. +/// </summary> +public class ConstructionOnlyApiFactory : WebApplicationFactory<Program> +{ + protected sealed override void ConfigureWebHost(IWebHostBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + ConfigureTestHost(builder); + builder.ConfigureTestServices(RemoveDispatcher); + } + + protected virtual void ConfigureTestHost(IWebHostBuilder builder) + { + ArgumentNullException.ThrowIfNull(builder); + } + + internal static void RemoveDispatcher(IServiceCollection services) + { + // Leave every other hosted service intact, including telemetry. This is + // test composition, never a production flag or a blanket hosted-service ban. + foreach (var descriptor in services.Where(descriptor => + descriptor.ServiceType == typeof(IHostedService) + && descriptor.ImplementationType == typeof(OutboxProcessor)).ToArray()) + { + services.Remove(descriptor); + } + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/ConstructionOnlyApiFactoryTests.cs b/backend/tests/LearnStack.Tests.Integration/ConstructionOnlyApiFactoryTests.cs new file mode 100644 index 00000000..09e17e8a --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/ConstructionOnlyApiFactoryTests.cs @@ -0,0 +1,44 @@ +using FluentAssertions; +using LearnStack.Api.Messaging; +using LearnStack.Infrastructure.Messaging; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Integration; + +public sealed class ConstructionOnlyApiFactoryTests +{ + [Fact] + public void Request_only_test_composition_removes_exactly_the_dispatcher_and_preserves_other_workers() + { + var services = new ServiceCollection(); + services.AddHostedService<OutboxProcessor>(); + services.AddHostedService<OtherWorker>(); + services.Count(descriptor => descriptor.ServiceType == typeof(IHostedService)).Should().Be(2); + + ConstructionOnlyApiFactory.RemoveDispatcher(services); + + services.Where(descriptor => descriptor.ServiceType == typeof(IHostedService)) + .Should().ContainSingle().Which.ImplementationType.Should().Be<OtherWorker>(); + } + + [Fact] + public async Task Construction_only_host_leaves_the_dispatcher_source_unresolved() + { + await using var host = new ConstructionOnlyApiFactory(); + using var client = host.CreateClient(); + + host.Services.GetServices<IHostedService>().Should().NotContain(service => service is OutboxProcessor); + host.Services.GetRequiredKeyedService<Lazy<NpgsqlDataSource>>(OutboxDispatcherDataSource.Key) + .IsValueCreated.Should().BeFalse(); + (await client.GetAsync("/healthz")).IsSuccessStatusCode.Should().BeTrue(); + } + + private sealed class OtherWorker : IHostedService + { + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs index e6a58822..194bfb41 100644 --- a/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/CrossCuttingFoundationHttpTests.cs @@ -290,9 +290,9 @@ public void The_Audit_Health_State_Is_A_Singleton_Across_Request_Scopes() /// <c>AddLearnStackCrossCuttingFoundation</c> wiring so the L1 handler + /// ValidationBehavior tested here is the same code production runs. /// </summary> -public sealed class CrossCuttingHttpFixture : WebApplicationFactory<Program> +public sealed class CrossCuttingHttpFixture : ConstructionOnlyApiFactory { - protected override void ConfigureWebHost(IWebHostBuilder builder) + protected override void ConfigureTestHost(IWebHostBuilder builder) { ArgumentNullException.ThrowIfNull(builder); builder.UseEnvironment(Environments.Development); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionTests.cs index 1d4b70e2..2d61d4d7 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationProjectionTests.cs @@ -27,8 +27,8 @@ namespace LearnStack.Tests.Integration.Database; [Trait(RequiresDocker.Key, RequiresDocker.Value)] [Collection(SharedSchema.Name)] -public sealed partial class CustomizationProjectionTests(SchemaFixture schema, WebApplicationFactory<Program> factory, ITestOutputHelper output) - : IClassFixture<WebApplicationFactory<Program>> +public sealed partial class CustomizationProjectionTests(SchemaFixture schema, ConstructionOnlyApiFactory factory, ITestOutputHelper output) + : IClassFixture<ConstructionOnlyApiFactory> { private static readonly Dictionary<string, string> Label = new() { ["en"] = "Label", ["tr"] = "Etiket" }; private const string Profile = """ diff --git a/backend/tests/LearnStack.Tests.Integration/Database/CustomizationPublicationConcurrencyTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationPublicationConcurrencyTests.cs index 6edd491a..e65838e4 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/CustomizationPublicationConcurrencyTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/CustomizationPublicationConcurrencyTests.cs @@ -26,8 +26,8 @@ namespace LearnStack.Tests.Integration.Database; [Trait(RequiresDocker.Key, RequiresDocker.Value)] [Collection(SharedSchema.Name)] -public sealed class CustomizationPublicationConcurrencyTests(SchemaFixture schema, WebApplicationFactory<Program> factory) - : IClassFixture<WebApplicationFactory<Program>> +public sealed class CustomizationPublicationConcurrencyTests(SchemaFixture schema, ConstructionOnlyApiFactory factory) + : IClassFixture<ConstructionOnlyApiFactory> { [Theory] [InlineData(false, false)] diff --git a/backend/tests/LearnStack.Tests.Integration/Database/DomainEventProductionTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/DomainEventProductionTests.cs index fdf073d3..59fbea34 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/DomainEventProductionTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/DomainEventProductionTests.cs @@ -797,7 +797,7 @@ public async Task Disposing_an_unfinished_scope_discards_collected_and_uncollect private static WebApplicationFactory<Program> Host( string connectionString, DispatchObservation observation, ITenantContext? context = null) => - new WebApplicationFactory<Program>().WithWebHostBuilder(builder => + new ConstructionOnlyApiFactory().WithWebHostBuilder(builder => { builder.UseSetting("ConnectionStrings:Default", connectionString); builder.ConfigureServices(services => diff --git a/backend/tests/LearnStack.Tests.Integration/Database/EducationWriterTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/EducationWriterTests.cs index 6be92811..86ec0d97 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/EducationWriterTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/EducationWriterTests.cs @@ -30,8 +30,8 @@ namespace LearnStack.Tests.Integration.Database; [Trait(RequiresDocker.Key, RequiresDocker.Value)] [Collection(SharedSchema.Name)] -public sealed class EducationWriterTests(SchemaFixture schema, WebApplicationFactory<Program> factory) - : IClassFixture<WebApplicationFactory<Program>> +public sealed class EducationWriterTests(SchemaFixture schema, ConstructionOnlyApiFactory factory) + : IClassFixture<ConstructionOnlyApiFactory> { private const string StringSchema = """{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"text":{"type":"string"}},"required":["text"],"additionalProperties":false}"""; private const string NumberSchema = """{"$schema":"https://json-schema.org/draft/2020-12/schema","type":"object","properties":{"text":{"type":"number"}},"required":["text"],"additionalProperties":false}"""; diff --git a/backend/tests/LearnStack.Tests.Integration/Database/OutboxProcessorTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/OutboxProcessorTests.cs new file mode 100644 index 00000000..a0c1d43b --- /dev/null +++ b/backend/tests/LearnStack.Tests.Integration/Database/OutboxProcessorTests.cs @@ -0,0 +1,782 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net; +using FluentAssertions; +using LearnStack.Api.Messaging; +using LearnStack.Infrastructure.Messaging; +using LearnStack.Modules.Tenancy.Application.Contracts.IntegrationEvents; +using LearnStack.Modules.Tenancy.Application.Contracts.Tenant; +using LearnStack.SharedKernel.Identifiers; +using LearnStack.SharedKernel.Messaging; +using LearnStack.SharedKernel.Tenancy; +using MediatR; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Npgsql; +using NpgsqlTypes; +using Xunit; + +namespace LearnStack.Tests.Integration.Database; + +/// <summary>The registered production worker, real PostgreSQL roles and actual transport scopes.</summary> +[Trait(RequiresDocker.Key, RequiresDocker.Value)] +[Collection(SharedSchema.Name)] +public sealed class OutboxProcessorTests(SchemaFixture schema) +{ + private const string Traceparent = "00-11111111111111111111111111111111-2222222222222222-01"; + private const string SensitiveCanary = "private-outbox-canary-84e315"; + private static readonly DateTimeOffset Occurred = new(2026, 10, 1, 12, 0, 0, TimeSpan.Zero); + private static readonly TimeSpan ProofDeadline = TimeSpan.FromSeconds(10); + private static readonly OutboxDispatchOptions Bounds = new() + { + SqlTimeout = TimeSpan.FromSeconds(1), + PublishTimeout = TimeSpan.FromMilliseconds(150), + LeaseDuration = TimeSpan.FromSeconds(3), + IdlePoll = TimeSpan.FromMilliseconds(20), + ProgressTimeout = TimeSpan.FromSeconds(5), + SnapshotInterval = TimeSpan.FromMilliseconds(20), + ShutdownTimeout = TimeSpan.FromMilliseconds(150), + }; + + [Fact] + public async Task The_actual_API_registers_exactly_one_worker_and_boots_its_guarded_source() + { + await using var test = await WorkerDatabase.CreateAsync(schema.Postgres); + test.Start(); + + test.Services.GetServices<IHostedService>().OfType<OutboxProcessor>() + .Should().ContainSingle().Which.Should().BeSameAs(test.Worker); + await UntilAsync(() => test.Observation.IsHealthy); + var source = test.Services.GetRequiredKeyedService<Lazy<NpgsqlDataSource>>(OutboxDispatcherDataSource.Key); + source.IsValueCreated.Should().BeTrue("startup must connect even when there are no rows"); + await using var connection = await source.Value.OpenConnectionAsync(); + await using var role = new NpgsqlCommand("SELECT session_user, current_user", connection); + await using var reader = await role.ExecuteReaderAsync(); + (await reader.ReadAsync()).Should().BeTrue(); + reader.GetString(0).Should().Be(OutboxDispatcherDataSource.Role); + reader.GetString(1).Should().Be(OutboxDispatcherDataSource.Role); + } + + [Fact] + public async Task Two_independent_actual_processors_cannot_publish_or_claim_one_live_lease_twice() + { + await using var test = await WorkerDatabase.CreateAsync(schema.Postgres); + var liveBounds = Bounds with + { + PublishTimeout = TimeSpan.FromSeconds(2), + LeaseDuration = TimeSpan.FromSeconds(6), + ProgressTimeout = TimeSpan.FromSeconds(8), + }; + var partition = Guid.CreateVersion7(); + var held = test.Callbacks.Hold(partition); + var id = await test.InsertAsync(partition: partition); + test.Start(liveBounds); + await held.Entered.Task.WaitAsync(ProofDeadline); + var acquired = await test.ReadAsync(id); + acquired.Attempts.Should().Be(1); + acquired.Token.Should().NotBeNullOrWhiteSpace(); + await using var contender = new WorkerApiFactory(test, test.OutboxConnectionString, liveBounds); + OutboxProcessor? secondWorker = null; + try + { + var secondServices = contender.Services; + secondWorker = secondServices.GetServices<IHostedService>().OfType<OutboxProcessor>() + .Should().ContainSingle().Which; + secondWorker.Should().NotBeSameAs(test.Worker); + var secondObservation = secondServices.GetRequiredService<OutboxObservation>(); + for (var poll = 0; poll < 2; poll++) + { + var previous = secondObservation.Snapshot.LastSnapshotTimestamp ?? long.MinValue; + await UntilAsync(() => secondObservation.Snapshot.LastSnapshotTimestamp > previous); + } + + (await test.LeaseIsLiveAsync(id)).Should().BeTrue("the contention assertion must run before expiry or a timeout"); + (await test.ReadAsync(id)).Should().Be(acquired); + test.Callbacks.Constructions.Should().Be(1); + secondObservation.Snapshot.Queue.PendingCount.Should().Be(1); + secondObservation.Snapshot.Queue.OldestEligibleAgeSeconds.Should().Be(0); + held.ReleaseCallback.TrySetResult(); + await UntilAsync(async () => (await test.ReadAsync(id)).ProcessedAt is not null); + (await test.ReadAsync(id)).Attempts.Should().Be(1); + test.Callbacks.Constructions.Should().Be(1); + } + finally + { + held.ReleaseCallback.TrySetResult(); + held.ReleaseCleanup.TrySetResult(); + await test.Worker.StopAsync(CancellationToken.None).WaitAsync(ProofDeadline); + if (secondWorker is not null) await secondWorker.StopAsync(CancellationToken.None).WaitAsync(ProofDeadline); + await test.Callbacks.AwaitCleanupAsync().WaitAsync(ProofDeadline); + } + } + + [Theory] + [InlineData("missing")] + [InlineData("malformed")] + [InlineData("wrong-user")] + [InlineData("unreachable")] + public async Task Actual_host_startup_rejects_invalid_credentials_without_provider_or_secret_text(string invalid) + { + await using var test = await WorkerDatabase.CreateAsync(schema.Postgres); + var credential = invalid switch + { + "missing" => string.Empty, + "malformed" => "Password=" + SensitiveCanary + ";invalid-field=value", + "wrong-user" => new NpgsqlConnectionStringBuilder(test.OutboxConnectionString) + { + Username = "learnstack_app", + Password = SensitiveCanary, + }.ConnectionString, + "unreachable" => new NpgsqlConnectionStringBuilder(test.OutboxConnectionString) + { + Host = "127.0.0.1", + Port = 1, + Password = SensitiveCanary, + Timeout = 1, + }.ConnectionString, + _ => throw new ArgumentOutOfRangeException(nameof(invalid)), + }; + await using var factory = new WorkerApiFactory(test, credential); + + var failure = await Record.ExceptionAsync(() => Task.Run(() => _ = factory.Services).WaitAsync(ProofDeadline)); + + failure.Should().NotBeNull(); + failure.Should().BeAssignableTo<InvalidOperationException>(); + failure!.ToString().Should().NotContain(SensitiveCanary).And.NotContain(credential == string.Empty ? "Password=" : credential); + test.Logs.Text.Should().NotContain(SensitiveCanary); + } + + [Fact] + public async Task Real_provisioning_rows_remain_Pending_across_hosted_polls_without_construction_or_mutation() + { + await using var test = await WorkerDatabase.CreateAsync(schema.Postgres, seedTenant: false); + test.Start(); + await using (var scope = test.Services.CreateAsyncScope()) + { + var result = await scope.ServiceProvider.GetRequiredService<ISender>().Send(new ProvisionTenantCommand( + TenantId.From(SchemaFixture.TenantA), "worker-probe", "Worker probe", + OrganizationId.From(SchemaFixture.OrgA1), "hq", "HQ")); + result.IsSuccess.Should().BeTrue(); + } + var id = await test.SingleIdAsync(); + var before = await test.ReadAsync(id); + for (var iteration = 0; iteration < 3; iteration++) await test.NextSnapshotAsync(); + + (await test.ReadAsync(id)).Should().Be(before); + before.Type.Should().Be(typeof(TenantCreatedIntegrationEventV1).AssemblyQualifiedName); + before.Attempts.Should().Be(0); + test.Callbacks.Constructions.Should().Be(0); + test.Observation.Snapshot.Queue.PendingCount.Should().Be(1); + test.Observation.Snapshot.Queue.DeferredCount.Should().Be(1); + (await test.ReadTerminalAuditsAsync()).Should().BeEmpty(); + } + + [Fact] + public async Task A_poison_first_row_terminalizes_with_MUST_audit_and_the_following_real_handler_progresses() + { + await using var test = await WorkerDatabase.CreateAsync(schema.Postgres); + using var traces = new DispatcherTraces(); + var poison = await test.InsertAsync(type: SensitiveCanary, payload: "{\"private\":\"" + SensitiveCanary + "\"}", correlation: SensitiveCanary); + var following = await test.InsertAsync(); + test.Start(); + + await UntilAsync(async () => (await test.ReadAsync(poison)).DeadLetteredAt is not null + && (await test.ReadAsync(following)).ProcessedAt is not null); + + var row = await test.ReadAsync(poison); + row.TerminalReason.Should().Be("unknown_type"); + row.Attempts.Should().Be(1); + row.ProcessedAt.Should().BeNull(); + test.Callbacks.Constructions.Should().Be(1); + var audit = (await test.ReadTerminalAuditsAsync()).Should().ContainSingle().Which; + audit.OperationClass.Should().Be("Must"); + audit.OperationType.Should().Be("SecurityEvent"); + audit.Metadata.Should().Contain(poison.ToString("D")).And.NotContain(SensitiveCanary); + audit.Correlation.Should().NotBe(SensitiveCanary); + ActivityContext.TryParse(audit.Correlation, null, out _).Should().BeTrue(); + test.Logs.Text.Should().NotContain(SensitiveCanary); + row.LastError.Should().Be("terminal:unknown_type:closed"); + traces.Records.Should().NotBeEmpty(); + string.Join("\n", traces.Records).Should().NotContain(SensitiveCanary); + await test.NextSnapshotAsync(); + (await test.ReadTerminalAuditsAsync()).Should().ContainSingle(); + } + + [Fact] + public async Task Ordinary_handler_failure_persists_a_closed_descriptor_and_allows_following_work() + { + await using var test = await WorkerDatabase.CreateAsync(schema.Postgres); + using var traces = new DispatcherTraces(); + var partition = Guid.CreateVersion7(); + test.Callbacks.Fail(partition, new InvalidOperationException(SensitiveCanary)); + var failed = await test.InsertAsync(partition: partition); + var following = await test.InsertAsync(); + test.Start(); + await UntilAsync(async () => (await test.ReadAsync(failed)).LastError == "publish:failed:provider"); + await test.PostponeAsync(failed); + await UntilAsync(async () => (await test.ReadAsync(following)).ProcessedAt is not null); + + var row = await test.ReadAsync(failed); + row.Attempts.Should().Be(1); + row.Token.Should().BeNull(); + row.ProcessedAt.Should().BeNull(); + test.Callbacks.Constructions.Should().Be(2); + test.Logs.Text.Should().NotContain(SensitiveCanary); + traces.Records.Should().NotBeEmpty(); + string.Join("\n", traces.Records).Should().NotContain(SensitiveCanary); + } + + [Fact] + public async Task Explicit_intentional_absence_acknowledges_without_constructing_a_handler() + { + await using var test = await WorkerDatabase.CreateAsync(schema.Postgres); + var fact = new UnsubscribedEventV1 + { + EventId = Guid.CreateVersion7(), + TenantId = SchemaFixture.TenantA, + OccurredAt = Occurred, + SubjectId = Guid.CreateVersion7(), + }; + var id = await test.InsertAsync(fact: fact); + test.Start(); + + await UntilAsync(async () => (await test.ReadAsync(id)).ProcessedAt is not null); + + (await test.ReadAsync(id)).Attempts.Should().Be(1); + test.Callbacks.Constructions.Should().Be(0); + (await test.ReadTerminalAuditsAsync()).Should().BeEmpty(); + } + + [Fact] + public async Task Timeout_persists_failure_and_holds_the_partition_until_actual_callback_scope_cleanup() + { + await using var test = await WorkerDatabase.CreateAsync(schema.Postgres); + var partition = Guid.CreateVersion7(); + var held = test.Callbacks.Hold(partition, holdCleanup: true); + var first = await test.InsertAsync(partition: partition); + var sibling = await test.InsertAsync(partition: partition); + var unrelated = await test.InsertAsync(); + test.Start(); + await held.Entered.Task.WaitAsync(ProofDeadline); + await UntilAsync(async () => (await test.ReadAsync(first)).LastError == "publish:timeout:deadline"); + await test.PostponeAsync(first); + await UntilAsync(async () => (await test.ReadAsync(unrelated)).ProcessedAt is not null); + + test.Observation.IsHealthy.Should().BeFalse(); + (await test.ReadAsync(sibling)).Attempts.Should().Be(0); + held.Disposals.Should().Be(0); + held.CancellationToken.IsCancellationRequested.Should().BeTrue(); + held.ReleaseCallback.TrySetResult(); + await held.CleanupEntered.Task.WaitAsync(ProofDeadline); + await test.NextSnapshotAsync(); + (await test.ReadAsync(sibling)).Attempts.Should().Be(0, "partition ownership includes asynchronous scope cleanup"); + held.Disposals.Should().Be(0); + _ = held.CancellationToken.WaitHandle; + + held.ReleaseCleanup.TrySetResult(); + await UntilAsync(async () => (await test.ReadAsync(sibling)).ProcessedAt is not null); + await UntilAsync(() => test.Observation.Snapshot.OutstandingPublishes == 0); + var failed = await test.ReadAsync(first); + failed.ProcessedAt.Should().BeNull("late callback success cannot acknowledge a timed-out claim"); + failed.Attempts.Should().Be(1); + failed.Token.Should().BeNull(); + held.Disposals.Should().Be(2, "the released partition subsequently constructs and disposes its sibling's handler"); + } + + [Fact] + public async Task Four_retained_callbacks_stop_all_acquisition_and_releasing_one_restores_capacity() + { + await using var test = await WorkerDatabase.CreateAsync(schema.Postgres); + var held = new List<CallbackBarrier>(); + var heldIds = new List<Guid>(); + for (var index = 0; index < 4; index++) + { + var partition = Guid.CreateVersion7(); + held.Add(test.Callbacks.Hold(partition)); + heldIds.Add(await test.InsertAsync(partition: partition)); + } + var fifth = await test.InsertAsync(); + test.Start(); + await Task.WhenAll(held.Select(item => item.Entered.Task)).WaitAsync(ProofDeadline); + await UntilAsync(() => test.Observation.Snapshot.OutstandingPublishes == 4); + foreach (var id in heldIds) await test.PostponeAsync(id); + await test.NextSnapshotAsync(); + + (await test.ReadAsync(fifth)).Attempts.Should().Be(0, "the four retained callbacks exhaust the global allowance"); + test.Callbacks.Constructions.Should().Be(4); + test.Observation.IsHealthy.Should().BeFalse(); + held[0].ReleaseCallback.TrySetResult(); + await UntilAsync(async () => (await test.ReadAsync(fifth)).ProcessedAt is not null); + test.Observation.Snapshot.OutstandingPublishes.Should().Be(3); + (await test.ReadAsync(heldIds[0])).ProcessedAt.Should().BeNull(); + } + + [Fact] + public async Task Shutdown_uses_one_finite_wait_for_all_four_retained_callbacks() + { + await using var test = await WorkerDatabase.CreateAsync(schema.Postgres); + var held = new List<CallbackBarrier>(); + for (var index = 0; index < 4; index++) + { + var partition = Guid.CreateVersion7(); + held.Add(test.Callbacks.Hold(partition)); + await test.InsertAsync(partition: partition); + } + test.Start(); + await Task.WhenAll(held.Select(item => item.Entered.Task)).WaitAsync(ProofDeadline); + await UntilAsync(() => test.Observation.Snapshot.OutstandingPublishes == 4); + var stop = Stopwatch.StartNew(); + + await test.Worker.StopAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(2)); + + stop.Elapsed.Should().BeLessThan(Bounds.ShutdownTimeout * 3, + "all live callbacks share the one shutdown budget rather than receiving successive waits"); + foreach (var callback in held) + { + callback.Disposals.Should().Be(0); + _ = callback.CancellationToken.WaitHandle; + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Timeout_and_shutdown_bound_the_wait_and_keep_live_scope_and_CTS_owned_until_completion(bool synchronousBlock) + { + await using var test = await WorkerDatabase.CreateAsync(schema.Postgres); + var partition = Guid.CreateVersion7(); + var held = test.Callbacks.Hold(partition, synchronousBlock: synchronousBlock); + var id = await test.InsertAsync(partition: partition); + test.Start(); + await held.Entered.Task.WaitAsync(ProofDeadline); + await UntilAsync(async () => (await test.ReadAsync(id)).LastError == "publish:timeout:deadline"); + await UntilAsync(() => test.Observation.Snapshot.OutstandingPublishes == 1); + var stop = Stopwatch.StartNew(); + + await test.Worker.StopAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(2)); + + stop.Elapsed.Should().BeLessThan(TimeSpan.FromSeconds(2)); + held.Disposals.Should().Be(0); + held.CancellationToken.IsCancellationRequested.Should().BeTrue(); + _ = held.CancellationToken.WaitHandle; + held.ReleaseCallback.TrySetResult(); + await held.Completed.Task.WaitAsync(ProofDeadline); + await UntilAsync(() => TokenSourceDisposed(held.CancellationToken)); + held.Disposals.Should().Be(1); + (await test.ReadAsync(id)).ProcessedAt.Should().BeNull(); + } + + [Fact] + public async Task A_failed_terminal_audit_reports_unhealthy_while_the_actual_healthz_remains_live() + { + await using var test = await WorkerDatabase.CreateAsync(schema.Postgres); + test.Start(); + await UntilAsync(() => test.Observation.IsHealthy); + await test.AuditInsertGrantAsync(grant: false); + var id = await test.InsertAsync(type: "unknown-audit-probe"); + await UntilAsync(() => test.Observation.Snapshot.TerminalAuditFailed); + + test.Observation.IsHealthy.Should().BeFalse(); + var row = await test.ReadAsync(id); + row.TerminalReason.Should().Be("unknown_type"); + row.DeadLetteredAt.Should().BeNull(); + row.ProcessedAt.Should().BeNull(); + (await test.ReadTerminalAuditsAsync()).Should().BeEmpty(); + var health = await test.Services.GetRequiredService<HealthCheckService>().CheckHealthAsync( + registration => registration.Name == OutboxHealthCheck.Name); + health.Entries.Keys.Should().ContainSingle().Which.Should().Be(OutboxHealthCheck.Name); + health.Status.Should().Be(HealthStatus.Unhealthy); + var response = await test.Client.GetAsync("/healthz"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + private static bool TokenSourceDisposed(CancellationToken token) + { + try { _ = token.WaitHandle; return false; } + catch (ObjectDisposedException) { return true; } + } + + private static Task UntilAsync(Func<bool> condition) => UntilAsync(() => Task.FromResult(condition())); + + private static async Task UntilAsync(Func<Task<bool>> condition) + { + using var deadline = new CancellationTokenSource(ProofDeadline); + while (!await condition().WaitAsync(deadline.Token)) await Task.Delay(TimeSpan.FromMilliseconds(10), deadline.Token); + } + + [IntegrationEventPartition(nameof(SubjectId))] + public sealed record WorkerEventV1 : IntegrationEventBase + { + public required Guid SubjectId { get; init; } + public override string Topic => "learnstack.test.worker"; + public override string PartitionKey => SubjectId.ToString("D"); + } + + [IntegrationEventPartition(nameof(SubjectId))] + public sealed record UnsubscribedEventV1 : IntegrationEventBase + { + public required Guid SubjectId { get; init; } + public override string Topic => "learnstack.test.unsubscribed"; + public override string PartitionKey => SubjectId.ToString("D"); + } + + /// <summary>One discovered, configurable real-bus handler; no production subscription is activated.</summary> + public sealed class WorkerHandler(CallbackControls controls, ITenantContextAccessor accessor) + : IIntegrationEventHandler<WorkerEventV1>, IAsyncDisposable + { + private readonly CallbackControls _controls = controls.RecordConstruction(); + private CallbackBarrier? _barrier; + + public Task HandleAsync(WorkerEventV1 integrationEvent, CancellationToken cancellationToken) + { + accessor.Current.Should().NotBeNull(); + accessor.Current!.TenantId.Value.Should().Be(integrationEvent.TenantId); + accessor.Current.UserId.Should().Be(UserId.SystemActor); + _barrier = _controls.Enter(integrationEvent.SubjectId, cancellationToken); + if (_barrier.Failure is { } failure) throw failure; + if (_barrier.SynchronousBlock) _barrier.ReleaseCallback.Task.GetAwaiter().GetResult(); + return _barrier.ReleaseCallback.Task; + } + + public async ValueTask DisposeAsync() + { + if (_barrier is null) return; + _barrier.CleanupEntered.TrySetResult(); + await _barrier.ReleaseCleanup.Task; + _barrier.Disposed(); + } + } + + public sealed class CallbackControls + { + private readonly ConcurrentDictionary<Guid, CallbackBarrier> _partitions = new(); + private int _constructions; + public int Constructions => Volatile.Read(ref _constructions); + + public CallbackBarrier Hold(Guid partition, bool holdCleanup = false, bool synchronousBlock = false) + { + var barrier = new CallbackBarrier(holdCallback: true, holdCleanup, synchronousBlock); + _partitions.TryAdd(partition, barrier).Should().BeTrue(); + return barrier; + } + + public void Fail(Guid partition, Exception failure) => _partitions.TryAdd(partition, + new CallbackBarrier(false, false, false) { Failure = failure }).Should().BeTrue(); + + public CallbackControls RecordConstruction() + { + Interlocked.Increment(ref _constructions); + return this; + } + + public CallbackBarrier Enter(Guid partition, CancellationToken token) + { + var barrier = _partitions.GetOrAdd(partition, _ => new CallbackBarrier(false, false, false)); + barrier.CancellationToken = token; + barrier.Entered.TrySetResult(); + return barrier; + } + + public void ReleaseAll() + { + foreach (var barrier in _partitions.Values) + { + barrier.ReleaseCallback.TrySetResult(); + barrier.ReleaseCleanup.TrySetResult(); + } + } + + public Task AwaitCleanupAsync() => Task.WhenAll(_partitions.Values + .Where(barrier => barrier.Entered.Task.IsCompleted).Select(barrier => barrier.Completed.Task)); + } + + public sealed class CallbackBarrier + { + private int _disposals; + public CallbackBarrier(bool holdCallback, bool holdCleanup, bool synchronousBlock) + { + SynchronousBlock = synchronousBlock; + if (!holdCallback) ReleaseCallback.TrySetResult(); + if (!holdCleanup) ReleaseCleanup.TrySetResult(); + } + + public Exception? Failure { get; init; } + public bool SynchronousBlock { get; } + public int Disposals => Volatile.Read(ref _disposals); + public CancellationToken CancellationToken { get; set; } + public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource ReleaseCallback { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource CleanupEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource ReleaseCleanup { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Completed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public void Disposed() + { + Interlocked.Increment(ref _disposals); + Completed.TrySetResult(); + } + } + + private sealed record Row(int Attempts, string? Token, DateTimeOffset? ProcessedAt, + string? TerminalReason, DateTimeOffset? DeadLetteredAt, string? LastError, + DateTimeOffset AvailableAfter, string Type, string Payload); + private sealed record TerminalAudit(string OperationType, string OperationClass, string Correlation, string Metadata); + + private sealed class WorkerApiFactory(WorkerDatabase database, string? credential, OutboxDispatchOptions? bounds = null) : WebApplicationFactory<Program> + { + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment(Environments.Development); + builder.UseSetting("ConnectionStrings:Default", database.AppConnectionString); + builder.UseSetting("ConnectionStrings:PlatformAdmin", database.PlatformConnectionString); + builder.UseSetting("ConnectionStrings:OutboxDispatcher", credential); + builder.ConfigureTestServices(services => + { + services.RemoveAll<OutboxDispatchOptions>(); + services.AddSingleton(bounds ?? Bounds); + services.RemoveAll<IntegrationEventHandlerRegistry>(); + var handlers = IntegrationEventHandlerRegistry.Discover(typeof(WorkerHandler).Assembly); + handlers.All.Should().ContainSingle().Which.HandlerType.Should().Be<WorkerHandler>(); + services.AddSingleton(handlers); + services.RemoveAll<OutboxEventRegistry>(); + services.AddSingleton(OutboxEventRegistry.Create([ + OutboxEventRegistration.Required(typeof(WorkerEventV1), + [OutboxSubscriptionManifest.Active("test.worker.v1", typeof(WorkerHandler))]), + OutboxEventRegistration.IntentionallyUnsubscribed(typeof(UnsubscribedEventV1)), + OutboxEventRegistration.Required(typeof(TenantCreatedIntegrationEventV1), [ + OutboxSubscriptionManifest.Pending("customization.initialize-content-type-default.v1"), + OutboxSubscriptionManifest.Pending("customization.initialize-taxonomy-default.v1"), + ]), + ], [], handlers, [typeof(WorkerEventV1).Assembly, typeof(TenantCreatedIntegrationEventV1).Assembly])); + services.AddSingleton(database.Callbacks); + services.AddScoped<WorkerHandler>(); + services.AddSingleton<ILogger<OutboxProcessor>>(database.Logs); + }); + } + } + + private sealed class WorkerDatabase : IAsyncDisposable + { + private readonly DisposableSchemaDatabase _database; + private readonly NpgsqlDataSource _appSource; + private readonly NpgsqlDataSource _dispatchSource; + private WorkerApiFactory? _factory; + private HttpClient? _client; + private int _insertSequence; + private WorkerDatabase(DisposableSchemaDatabase database) + { + _database = database; + _appSource = NpgsqlDataSource.Create(database.AppConnectionString); + _dispatchSource = OutboxDispatcherDataSource.Build(database.OutboxConnectionString); + } + + public string AppConnectionString => _database.AppConnectionString; + public string PlatformConnectionString => _database.PlatformConnectionString; + public string OutboxConnectionString => _database.OutboxConnectionString; + public CallbackControls Callbacks { get; } = new(); + public RecordingLogger Logs { get; } = new(); + public IServiceProvider Services => _factory!.Services; + public OutboxProcessor Worker => Services.GetServices<IHostedService>().OfType<OutboxProcessor>().Single(); + public OutboxObservation Observation => Services.GetRequiredService<OutboxObservation>(); + public HttpClient Client => _client!; + + public static async Task<WorkerDatabase> CreateAsync(PostgresFixture postgres, bool seedTenant = true) + { + var test = new WorkerDatabase(await DisposableSchemaDatabase.CreateAsync(postgres)); + try + { + if (seedTenant) await test.SeedTenantAsync(); + return test; + } + catch { await test.DisposeAsync(); throw; } + } + + public void Start(OutboxDispatchOptions? bounds = null) + { + _factory = new WorkerApiFactory(this, OutboxConnectionString, bounds); + _client = _factory.CreateClient(new WebApplicationFactoryClientOptions { BaseAddress = new Uri("http://localhost") }); + Services.GetRequiredService<IEventBus>().Should().BeOfType<InProcessEventBus>(); + } + + public async Task NextSnapshotAsync() + { + var before = Observation.Snapshot.LastSnapshotTimestamp ?? long.MinValue; + await UntilAsync(() => Observation.Snapshot.LastSnapshotTimestamp > before); + } + + public async Task<Guid> InsertAsync(Guid? partition = null, string? type = null, + string? payload = null, string? correlation = null, IntegrationEventBase? fact = null) + { + fact ??= new WorkerEventV1 + { + EventId = Guid.CreateVersion7(), + TenantId = SchemaFixture.TenantA, + OccurredAt = Occurred, + SubjectId = partition ?? Guid.CreateVersion7(), + }; + await using var connection = await _appSource.OpenConnectionAsync(); + await using var transaction = await connection.BeginTransactionAsync(); + await SchemaQueries.AssertApplicationRoleAsync(connection); + await SchemaQueries.SetTenantAsync(connection, transaction, fact.TenantId); + await using var insert = new NpgsqlCommand(""" + INSERT INTO outbox_messages + (id, tenant_id, occurred_at, correlation_id, type, topic, partition_key, payload, available_after) + VALUES (@id, @tenant, @occurred, @correlation, @type, @topic, @partition, @payload, @available) + """, connection, transaction); + insert.Parameters.AddWithValue("id", fact.EventId); + insert.Parameters.AddWithValue("tenant", fact.TenantId); + insert.Parameters.AddWithValue("occurred", fact.OccurredAt); + insert.Parameters.AddWithValue("correlation", correlation ?? Traceparent); + insert.Parameters.AddWithValue("type", type ?? fact.GetType().AssemblyQualifiedName!); + insert.Parameters.AddWithValue("topic", fact.Topic); + insert.Parameters.AddWithValue("partition", fact.PartitionKey); + insert.Parameters.Add(new NpgsqlParameter("payload", NpgsqlDbType.Jsonb) { Value = payload ?? fact.ToPayloadJson() }); + insert.Parameters.AddWithValue("available", Occurred.AddMilliseconds(Interlocked.Increment(ref _insertSequence))); + (await insert.ExecuteNonQueryAsync()).Should().Be(1); + await transaction.CommitAsync(); + return fact.EventId; + } + + public async Task<Row> ReadAsync(Guid id) + { + await using var connection = await _dispatchSource.OpenConnectionAsync(); + await using var read = new NpgsqlCommand(""" + SELECT attempts, locked_by, processed_at, terminal_reason, dead_lettered_at, + last_error, available_after, type, payload::text FROM outbox_messages WHERE id = @id + """, connection); + read.Parameters.AddWithValue("id", id); + await using var reader = await read.ExecuteReaderAsync(); + (await reader.ReadAsync()).Should().BeTrue(); + return new Row(reader.GetInt32(0), Text(reader, 1), Timestamp(reader, 2), Text(reader, 3), + Timestamp(reader, 4), Text(reader, 5), reader.GetFieldValue<DateTimeOffset>(6), reader.GetString(7), reader.GetString(8)); + } + + public async Task<Guid> SingleIdAsync() + { + await using var connection = await _dispatchSource.OpenConnectionAsync(); + await using var read = new NpgsqlCommand("SELECT id FROM outbox_messages", connection); + await using var reader = await read.ExecuteReaderAsync(); + (await reader.ReadAsync()).Should().BeTrue(); + var id = reader.GetGuid(0); + (await reader.ReadAsync()).Should().BeFalse(); + return id; + } + + public async Task<bool> LeaseIsLiveAsync(Guid id) + { + await using var connection = await _dispatchSource.OpenConnectionAsync(); + await using var read = new NpgsqlCommand( + "SELECT locked_until > clock_timestamp() FROM outbox_messages WHERE id = @id", connection); + read.Parameters.AddWithValue("id", id); + return await read.ExecuteScalarAsync() is true; + } + + public async Task PostponeAsync(Guid id) + { + await using var connection = await _dispatchSource.OpenConnectionAsync(); + await using var update = new NpgsqlCommand( + "UPDATE outbox_messages SET available_after = clock_timestamp() + interval '1 hour' WHERE id = @id", connection); + update.Parameters.AddWithValue("id", id); + (await update.ExecuteNonQueryAsync()).Should().Be(1); + } + + public async Task AuditInsertGrantAsync(bool grant) + { + await using var connection = await PostgresFixture.OpenAsync(_database.MigrationConnectionString); + await using var command = connection.CreateCommand(); + command.CommandText = grant ? "GRANT INSERT ON audit_log TO learnstack_outbox_admin" + : "REVOKE INSERT ON audit_log FROM learnstack_outbox_admin"; + await command.ExecuteNonQueryAsync(); + } + + public async Task<List<TerminalAudit>> ReadTerminalAuditsAsync() + { + await using var connection = await _appSource.OpenConnectionAsync(); + await using var transaction = await connection.BeginTransactionAsync(); + await SchemaQueries.AssertApplicationRoleAsync(connection); + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantA); + await using var read = new NpgsqlCommand(""" + SELECT operation_type, operation_class, correlation_id, metadata::text + FROM audit_log WHERE operation = 'platform.outbox.dead_letter' + """, connection, transaction); + var rows = new List<TerminalAudit>(); + await using var reader = await read.ExecuteReaderAsync(); + while (await reader.ReadAsync()) rows.Add(new TerminalAudit(reader.GetString(0), reader.GetString(1), reader.GetString(2), reader.GetString(3))); + return rows; + } + + public async ValueTask DisposeAsync() + { + Callbacks.ReleaseAll(); + if (_factory is not null) + { + await Worker.StopAsync(CancellationToken.None).WaitAsync(ProofDeadline); + await Callbacks.AwaitCleanupAsync().WaitAsync(ProofDeadline); + _client?.Dispose(); + await _factory.DisposeAsync(); + } + await _dispatchSource.DisposeAsync(); + await _appSource.DisposeAsync(); + await _database.DisposeAsync(); + } + + private async Task SeedTenantAsync() + { + await using var connection = await _appSource.OpenConnectionAsync(); + await using var transaction = await connection.BeginTransactionAsync(); + await SchemaQueries.AssertApplicationRoleAsync(connection); + await SchemaQueries.SetTenantAsync(connection, transaction, SchemaFixture.TenantA); + await SchemaQueries.ExecuteAsync(connection, transaction, """ + INSERT INTO tenants (id, slug, display_name, status, created_at, created_by, row_version) + VALUES (@tenant, 'worker-probe', 'Worker probe', 'Trial', now(), @actor, 0); + INSERT INTO organizations (id, tenant_id, slug, display_name, status, created_at, created_by, row_version) + VALUES (@organization, @tenant, 'hq', 'HQ', 'Active', now(), @actor, 0); + """, ("tenant", SchemaFixture.TenantA), ("organization", SchemaFixture.OrgA1), ("actor", UserId.SystemActor.Value)); + await transaction.CommitAsync(); + } + + private static string? Text(NpgsqlDataReader reader, int ordinal) => reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal); + private static DateTimeOffset? Timestamp(NpgsqlDataReader reader, int ordinal) => + reader.IsDBNull(ordinal) ? null : reader.GetFieldValue<DateTimeOffset>(ordinal); + } + + private sealed class DispatcherTraces : IDisposable + { + private readonly ActivityListener _listener; + private readonly ConcurrentQueue<string> _records = new(); + public DispatcherTraces() + { + _listener = new ActivityListener + { + ShouldListenTo = source => source.Name == OutboxObservation.InstrumentationName, + Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDataAndRecorded, + ActivityStopped = activity => _records.Enqueue(string.Join("\n", new[] + { + activity.DisplayName, activity.Id, activity.ParentId, activity.TraceStateString, + activity.StatusDescription, + string.Join(";", activity.TagObjects.Select(tag => tag.Key + "=" + tag.Value)), + string.Join(";", activity.Events.Select(@event => @event.Name + ":" + + string.Join(",", @event.Tags.Select(tag => tag.Key + "=" + tag.Value)))), + })), + }; + ActivitySource.AddActivityListener(_listener); + } + public IReadOnlyCollection<string> Records => _records.ToArray(); + public void Dispose() => _listener.Dispose(); + } + + private sealed class RecordingLogger : ILogger<OutboxProcessor> + { + private readonly ConcurrentQueue<string> _messages = new(); + public string Text => string.Join('\n', _messages); + public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel logLevel) => true; + public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func<TState, Exception?, string> formatter) => _messages.Enqueue(formatter(state, exception) + exception); + } +} diff --git a/backend/tests/LearnStack.Tests.Integration/Database/OutboxProducerTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/OutboxProducerTests.cs index 549cb9fd..4dcb6719 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/OutboxProducerTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/OutboxProducerTests.cs @@ -55,7 +55,7 @@ public async Task Both_roots_insert_the_complete_runtime_event_before_business_a 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<Program>().WithWebHostBuilder(builder => + await using var host = new ConstructionOnlyApiFactory().WithWebHostBuilder(builder => { builder.UseSetting("ConnectionStrings:Default", database.AppConnectionString); builder.ConfigureServices(services => services.AddSingleton<ITenantContextAccessor>( @@ -153,7 +153,7 @@ 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<Program>().WithWebHostBuilder(builder => + await using var host = new ConstructionOnlyApiFactory().WithWebHostBuilder(builder => { builder.UseSetting("ConnectionStrings:Default", database.AppConnectionString); builder.ConfigureServices(services => diff --git a/backend/tests/LearnStack.Tests.Integration/Database/ProducerExecutionTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/ProducerExecutionTests.cs index 07914ece..1e786492 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/ProducerExecutionTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/ProducerExecutionTests.cs @@ -452,7 +452,7 @@ public async Task Both_compositions_reconcile_an_independent_scope_inside_anothe await SeedTenantAsync(database.AppConnectionString); await using var dataSource = NpgsqlDataSource.Create(database.AppConnectionString); await using var seed = Compose(dataSource); - await using var host = new WebApplicationFactory<Program>().WithWebHostBuilder(builder => + await using var host = new ConstructionOnlyApiFactory().WithWebHostBuilder(builder => { builder.UseSetting("ConnectionStrings:Default", database.AppConnectionString); builder.ConfigureServices(services => services.AddSingleton<ITenantContextAccessor>( diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PublicLocalLaunchTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PublicLocalLaunchTests.cs index 35c588b4..636f9a84 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PublicLocalLaunchTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PublicLocalLaunchTests.cs @@ -11,7 +11,7 @@ namespace LearnStack.Tests.Integration.Database; -/// <summary>Exercises the actual local API launcher with only the application credential.</summary> +/// <summary>Exercises the actual local API launcher with explicit application and dispatcher credentials.</summary> [Collection(PublicReadTestGroup.Name)] [Trait(RequiresDocker.Key, RequiresDocker.Value)] public sealed class PublicLocalLaunchTests(PublicReadFixture fixture) @@ -107,6 +107,7 @@ await File.WriteAllTextAsync(script, """ process.StartInfo.ArgumentList.Add("api"); } process.StartInfo.Environment["ConnectionStrings__Default"] = fixture.AppConnectionString; + process.StartInfo.Environment["ConnectionStrings__OutboxDispatcher"] = fixture.OutboxConnectionString; process.StartInfo.Environment["LEARNSTACK_PUBLIC_API_ORIGIN"] = "http://127.0.0.1:0"; process.StartInfo.Environment["LEARNSTACK_PUBLIC_HOP_SECRET"] = Secret; // API configuration validates paths but does not open renderer TLS files. diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PublicLocaleEvolutionHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/PublicLocaleEvolutionHttpTests.cs index 4f85383d..db972ad6 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PublicLocaleEvolutionHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PublicLocaleEvolutionHttpTests.cs @@ -32,8 +32,8 @@ namespace LearnStack.Tests.Integration.Database; [Collection(SharedSchema.Name)] [Trait(RequiresDocker.Key, RequiresDocker.Value)] -public sealed class PublicLocaleEvolutionHttpTests(SchemaFixture schema, WebApplicationFactory<Program> factory) - : IClassFixture<WebApplicationFactory<Program>> +public sealed class PublicLocaleEvolutionHttpTests(SchemaFixture schema, ConstructionOnlyApiFactory factory) + : IClassFixture<ConstructionOnlyApiFactory> { private const string Root = "/api/v1/public/courses"; private const string Site = "/api/v1/public/site"; diff --git a/backend/tests/LearnStack.Tests.Integration/Database/PublicReadFixture.cs b/backend/tests/LearnStack.Tests.Integration/Database/PublicReadFixture.cs index f52dd5d0..3ef2d216 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/PublicReadFixture.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/PublicReadFixture.cs @@ -26,10 +26,11 @@ public sealed class PublicReadTestGroup : ICollectionFixture<PublicReadFixture> } /// <summary>Production API composition, seeded through real writers in an isolated four-role database.</summary> -public sealed class PublicReadFixture : WebApplicationFactory<Program>, IAsyncLifetime +public sealed class PublicReadFixture : ConstructionOnlyApiFactory, IAsyncLifetime { private readonly PostgresFixture _postgres = new(); public string AppConnectionString => _postgres.AppConnectionString; + public string OutboxConnectionString => _postgres.OutboxConnectionString; public PublicReadObservation Observation { get; } = new(); public async Task InitializeAsync() @@ -68,7 +69,7 @@ protected override void Dispose(bool disposing) } } - protected override void ConfigureWebHost(IWebHostBuilder builder) + protected override void ConfigureTestHost(IWebHostBuilder builder) { ArgumentNullException.ThrowIfNull(builder); builder.UseEnvironment(Environments.Development); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenancyWriterTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenancyWriterTests.cs index 42209fab..c1d1b1b6 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/TenancyWriterTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenancyWriterTests.cs @@ -32,8 +32,8 @@ namespace LearnStack.Tests.Integration.Database; [Trait(RequiresDocker.Key, RequiresDocker.Value)] [Collection(SharedSchema.Name)] -public sealed class TenancyWriterTests(SchemaFixture schema, WebApplicationFactory<Program> factory) - : IClassFixture<WebApplicationFactory<Program>> +public sealed class TenancyWriterTests(SchemaFixture schema, ConstructionOnlyApiFactory factory) + : IClassFixture<ConstructionOnlyApiFactory> { private const string Theme = """{"primary":"#2345aa","background":"#ffffff","foreground":"#111111","muted":"#555555"}"""; private const string OtherTheme = """{"primary":"#663399","background":"#ffffff","foreground":"#000000","muted":"#444444"}"""; diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenantCreatedProducerTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenantCreatedProducerTests.cs index a61c1075..e592eca8 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/TenantCreatedProducerTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantCreatedProducerTests.cs @@ -44,7 +44,7 @@ public async Task Provisioning_business_outbox_and_both_MUST_rows_share_one_comm await using var source = NpgsqlDataSource.Create(database.AppConnectionString); await using var seed = SeedComposition.Build(source, null, NullLoggerFactory.Instance); - await using var host = new WebApplicationFactory<Program>().WithWebHostBuilder(builder => + await using var host = new ConstructionOnlyApiFactory().WithWebHostBuilder(builder => { builder.UseSetting("ConnectionStrings:Default", database.AppConnectionString); builder.ConfigureServices(services => services.AddSingleton<ITenantContextAccessor>( diff --git a/backend/tests/LearnStack.Tests.Integration/Database/TenantIsolationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/TenantIsolationHttpTests.cs index a541fde4..0ee86fc8 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/TenantIsolationHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/TenantIsolationHttpTests.cs @@ -326,7 +326,7 @@ private async Task<IReadOnlyList<string>> GetAsync(string host, string route) /// <c>SeedRunner</c> and serves HTTP, and sharing would make the schema suite's exact row /// counts depend on whether these tests ran first. /// </remarks> -public sealed class TenantIsolationFixture : WebApplicationFactory<Program>, IAsyncLifetime +public sealed class TenantIsolationFixture : ConstructionOnlyApiFactory, IAsyncLifetime { private readonly PostgresFixture _postgres = new(); @@ -442,7 +442,7 @@ public HttpClient ClientFor(string host) return client; } - protected override void ConfigureWebHost(IWebHostBuilder builder) + protected override void ConfigureTestHost(IWebHostBuilder builder) { ArgumentNullException.ThrowIfNull(builder); builder.UseEnvironment(Environments.Development); diff --git a/backend/tests/LearnStack.Tests.Integration/DeploymentModeCompositionTests.cs b/backend/tests/LearnStack.Tests.Integration/DeploymentModeCompositionTests.cs index 0edc79b1..66994c25 100644 --- a/backend/tests/LearnStack.Tests.Integration/DeploymentModeCompositionTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/DeploymentModeCompositionTests.cs @@ -133,7 +133,7 @@ public void Tenant_Context_Resolution_Forwards_Each_Access_To_The_Accessor() /// itself reads. /// </remarks> private static WebApplicationFactory<Program> For(string mode) => - new WebApplicationFactory<Program>().WithWebHostBuilder(builder => + new ConstructionOnlyApiFactory().WithWebHostBuilder(builder => { builder.UseSetting("Deployment:Mode", mode); builder.UseSetting("ErrorTracking:Sentry:Dsn", DevelopmentShapedDsn); diff --git a/backend/tests/LearnStack.Tests.Integration/ErrorShapeHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/ErrorShapeHttpTests.cs index 7d74975e..8a44ac33 100644 --- a/backend/tests/LearnStack.Tests.Integration/ErrorShapeHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/ErrorShapeHttpTests.cs @@ -345,9 +345,9 @@ private static async Task AssertProblemAsync( } } -public sealed class ErrorShapeFixture : WebApplicationFactory<Program> +public sealed class ErrorShapeFixture : ConstructionOnlyApiFactory { - protected override void ConfigureWebHost(IWebHostBuilder builder) + protected override void ConfigureTestHost(IWebHostBuilder builder) { ArgumentNullException.ThrowIfNull(builder); builder.UseEnvironment(Environments.Development); diff --git a/backend/tests/LearnStack.Tests.Integration/HostClassificationHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/HostClassificationHttpTests.cs index 93470d95..5468047d 100644 --- a/backend/tests/LearnStack.Tests.Integration/HostClassificationHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/HostClassificationHttpTests.cs @@ -305,7 +305,7 @@ public async Task An_Unclassified_Prefix_Is_Served_Whatever_Its_Host() } /// <summary>A host whose resolver is a stub, so no database is involved.</summary> -public sealed class HostClassificationFixture : WebApplicationFactory<Program> +public sealed class HostClassificationFixture : ConstructionOnlyApiFactory { public const string OrganizationHost = "branch.example.com"; public const string TenantHost = "school.example.com"; @@ -318,7 +318,7 @@ public sealed class HostClassificationFixture : WebApplicationFactory<Program> public StubResolver Resolver { get; } = new(); - protected override void ConfigureWebHost(IWebHostBuilder builder) + protected override void ConfigureTestHost(IWebHostBuilder builder) { ArgumentNullException.ThrowIfNull(builder); builder.UseEnvironment(Environments.Development); diff --git a/backend/tests/LearnStack.Tests.Integration/IdempotencyHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/IdempotencyHttpTests.cs index b0baf7b1..ce9ae6c5 100644 --- a/backend/tests/LearnStack.Tests.Integration/IdempotencyHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/IdempotencyHttpTests.cs @@ -508,7 +508,7 @@ private async Task<HttpResponseMessage> PostAsync( /// which made every test in the class depend on the order the others ran in — /// Standards 06 § Forbidden bars exactly that. /// </remarks> -public sealed class IdempotencyFixture : WebApplicationFactory<Program> +public sealed class IdempotencyFixture : ConstructionOnlyApiFactory { public static readonly Guid TenantA = Guid.Parse("018f4d40-0000-7000-8000-00000000000a"); public static readonly Guid TenantB = Guid.Parse("018f4d40-0000-7000-8000-00000000000b"); @@ -518,7 +518,7 @@ public sealed class IdempotencyFixture : WebApplicationFactory<Program> internal const string TenantHeader = "X-Test-Tenant"; internal const string UserHeader = "X-Test-User"; - protected override void ConfigureWebHost(IWebHostBuilder builder) + protected override void ConfigureTestHost(IWebHostBuilder builder) { ArgumentNullException.ThrowIfNull(builder); builder.UseEnvironment(Environments.Development); diff --git a/backend/tests/LearnStack.Tests.Integration/RateLimitingHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/RateLimitingHttpTests.cs index ad6f7b33..c82b9de4 100644 --- a/backend/tests/LearnStack.Tests.Integration/RateLimitingHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/RateLimitingHttpTests.cs @@ -115,9 +115,9 @@ public async Task A_Forwarded_For_Header_Does_Not_Buy_A_Fresh_Budget() /// <summary>A host used by nothing else, so its exhausted budget poisons nothing.</summary> -public class RateLimitedHostFixture : WebApplicationFactory<Program> +public class RateLimitedHostFixture : ConstructionOnlyApiFactory { - protected override void ConfigureWebHost(IWebHostBuilder builder) + protected override void ConfigureTestHost(IWebHostBuilder builder) { ArgumentNullException.ThrowIfNull(builder); builder.UseEnvironment(Environments.Development); diff --git a/backend/tests/LearnStack.Tests.Integration/SmokeTests.cs b/backend/tests/LearnStack.Tests.Integration/SmokeTests.cs index e53dc60b..d95d94a9 100644 --- a/backend/tests/LearnStack.Tests.Integration/SmokeTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/SmokeTests.cs @@ -5,13 +5,13 @@ namespace LearnStack.Tests.Integration; -public sealed class SmokeTests : IClassFixture<WebApplicationFactory<Program>> +public sealed class SmokeTests : IClassFixture<ConstructionOnlyApiFactory> { private static readonly Uri HealthzPath = new("/healthz", UriKind.Relative); private readonly WebApplicationFactory<Program> _factory; - public SmokeTests(WebApplicationFactory<Program> factory) + public SmokeTests(ConstructionOnlyApiFactory factory) { _factory = factory; } diff --git a/backend/tests/LearnStack.Tests.Integration/TenantAssertionHttpTests.cs b/backend/tests/LearnStack.Tests.Integration/TenantAssertionHttpTests.cs index f7e7d2be..b19fd067 100644 --- a/backend/tests/LearnStack.Tests.Integration/TenantAssertionHttpTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/TenantAssertionHttpTests.cs @@ -319,9 +319,9 @@ public async Task The_Same_Request_Without_The_Header_Is_Served() /// </summary> public sealed class TenantWideFixture : ResolvedTenantFixture { - protected override void ConfigureWebHost(IWebHostBuilder builder) + protected override void ConfigureTestHost(IWebHostBuilder builder) { - base.ConfigureWebHost(builder); + base.ConfigureTestHost(builder); builder.ConfigureTestServices(services => { services.RemoveAll<ITenantContext>(); @@ -468,13 +468,13 @@ public void A_Threshold_Below_One_Is_Refused_At_Boot() /// misconfigured variants take their override through /// <see cref="MisconfiguredBurstFixture"/>, which a case builds directly. /// </remarks> -public sealed class RegisteredRecorderFixture : WebApplicationFactory<Program>; +public sealed class RegisteredRecorderFixture : ConstructionOnlyApiFactory; /// <summary>The real host with one burst setting bent out of shape.</summary> public sealed class MisconfiguredBurstFixture(string key, string value) - : WebApplicationFactory<Program> + : ConstructionOnlyApiFactory { - protected override void ConfigureWebHost(IWebHostBuilder builder) + protected override void ConfigureTestHost(IWebHostBuilder builder) { ArgumentNullException.ThrowIfNull(builder); @@ -485,12 +485,12 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) } } -public class ResolvedTenantFixture : WebApplicationFactory<Program> +public class ResolvedTenantFixture : ConstructionOnlyApiFactory { public static readonly Guid TenantId = Guid.Parse("018f4d40-0000-7000-8000-0000000000aa"); public static readonly Guid OrganizationId = Guid.Parse("018f4d40-0000-7000-8000-0000000000bb"); - protected override void ConfigureWebHost(IWebHostBuilder builder) + protected override void ConfigureTestHost(IWebHostBuilder builder) { ArgumentNullException.ThrowIfNull(builder); builder.UseEnvironment(Environments.Development); diff --git a/backend/tests/LearnStack.Tests.Integration/VersionedRouteEnforcementTests.cs b/backend/tests/LearnStack.Tests.Integration/VersionedRouteEnforcementTests.cs index e333a181..92b97776 100644 --- a/backend/tests/LearnStack.Tests.Integration/VersionedRouteEnforcementTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/VersionedRouteEnforcementTests.cs @@ -211,7 +211,7 @@ public void Apply(ApplicationModel application) /// The production host, with no test controllers added — its endpoint set is /// exactly what a deployed instance serves. /// </summary> -public sealed class ProductionHostFixture : WebApplicationFactory<Program> +public sealed class ProductionHostFixture : ConstructionOnlyApiFactory { private IReadOnlyList<string>? _patterns; @@ -223,7 +223,7 @@ public sealed class ProductionHostFixture : WebApplicationFactory<Program> .Select(endpoint => (endpoint.RoutePattern.RawText ?? string.Empty).TrimStart('/')) .Where(pattern => pattern.Length > 0)]; - protected override void ConfigureWebHost(IWebHostBuilder builder) + protected override void ConfigureTestHost(IWebHostBuilder builder) { ArgumentNullException.ThrowIfNull(builder); builder.UseEnvironment(Environments.Development); @@ -231,9 +231,9 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) } /// <summary>Host that admits exactly one probe controller, to prove a guard fires.</summary> -public sealed class ProbeHostFixture(Type controller) : WebApplicationFactory<Program> +public sealed class ProbeHostFixture(Type controller) : ConstructionOnlyApiFactory { - protected override void ConfigureWebHost(IWebHostBuilder builder) + protected override void ConfigureTestHost(IWebHostBuilder builder) { ArgumentNullException.ThrowIfNull(builder); builder.UseEnvironment(Environments.Development); diff --git a/backend/tests/LearnStack.Tests.Unit/Api/Composition/OutboxProductionCompositionTests.cs b/backend/tests/LearnStack.Tests.Unit/Api/Composition/OutboxProductionCompositionTests.cs index d63a39f5..3ddb427c 100644 --- a/backend/tests/LearnStack.Tests.Unit/Api/Composition/OutboxProductionCompositionTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Api/Composition/OutboxProductionCompositionTests.cs @@ -1,15 +1,35 @@ using FluentAssertions; using LearnStack.Api.Composition; +using LearnStack.Api.Messaging; using LearnStack.Infrastructure.Messaging; using LearnStack.Modules.Tenancy.Application.Contracts.IntegrationEvents; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; using Xunit; namespace LearnStack.Tests.Unit.Api.Composition; public sealed class OutboxProductionCompositionTests { + [Fact] + public void Production_registers_exactly_one_mandatory_worker_and_the_unmapped_outbox_health_check() + { + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder().Build(); + services.AddLearnStackOutboxDispatcher(configuration); + services.AddLearnStackOutboxDispatcher(configuration); + + services.Where(descriptor => descriptor.ServiceType == typeof(IHostedService) + && descriptor.ImplementationType == typeof(OutboxProcessor)) + .Should().ContainSingle("mandatory execution must not depend on a configuration toggle"); + using var provider = services.BuildServiceProvider(); + provider.GetRequiredService<IOptions<HealthCheckServiceOptions>>().Value.Registrations + .Where(registration => registration.Name == OutboxHealthCheck.Name).Should().ContainSingle(); + } + [Fact] public void Real_provisioning_contract_remains_pending_for_both_distinct_default_consumers() { diff --git a/backend/tests/LearnStack.Tests.Unit/Api/Messaging/OutboxObservationTests.cs b/backend/tests/LearnStack.Tests.Unit/Api/Messaging/OutboxObservationTests.cs new file mode 100644 index 00000000..5a84e558 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Api/Messaging/OutboxObservationTests.cs @@ -0,0 +1,172 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; +using FluentAssertions; +using LearnStack.Api.Messaging; +using LearnStack.Infrastructure.Messaging; +using LearnStack.SharedKernel.Messaging; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Xunit; + +namespace LearnStack.Tests.Unit.Api.Messaging; + +public sealed class OutboxObservationTests +{ + [Fact] + public async Task Health_requires_progress_and_fresh_snapshot_on_the_monotonic_clock() + { + var time = new ManualTimeProvider(); + using var observation = Create(time); + var check = new OutboxHealthCheck(observation); + observation.IsHealthy.Should().BeFalse(); + observation.Started(); + observation.Progress(); + observation.IsHealthy.Should().BeFalse("there is no successful queue observation yet"); + observation.Refreshed(new(9, 4, 20)); + (await check.CheckHealthAsync(new HealthCheckContext())).Status.Should().Be(HealthStatus.Healthy); + + time.Advance(TimeSpan.FromSeconds(44)); + observation.IsHealthy.Should().BeTrue(); + observation.HeartbeatAge.Should().Be(TimeSpan.FromSeconds(44)); + time.UtcNow = time.UtcNow.AddDays(-30); + observation.IsHealthy.Should().BeTrue("wall-clock adjustments do not reset elapsed progress"); + time.Advance(TimeSpan.FromSeconds(1)); + observation.IsHealthy.Should().BeFalse("45 seconds is the stale boundary"); + observation.Progress(); + observation.IsHealthy.Should().BeFalse("fresh progress cannot conceal a stale SQL snapshot"); + observation.Refreshed(new(8, 3, 25)); + observation.IsHealthy.Should().BeTrue(); + observation.Stopped(); + (await check.CheckHealthAsync(new HealthCheckContext())).Status.Should().Be(HealthStatus.Unhealthy); + } + + [Fact] + public void Failed_refresh_retains_immutable_snapshot_and_terminal_failure_survives_empty_polls() + { + using var observation = Create(new ManualTimeProvider()); + observation.Started(); + observation.Progress(); + observation.Refreshed(new(7, 5, 13)); + var before = observation.Snapshot; + observation.RefreshFailed(); + observation.Snapshot.Queue.Should().BeSameAs(before.Queue); + before.RefreshFailed.Should().BeFalse("published snapshots are immutable"); + observation.IsHealthy.Should().BeFalse(); + observation.Refreshed(new(6, 5, 13)); + observation.IsHealthy.Should().BeTrue(); + + observation.TerminalAuditFailed(); + observation.Progress(); + observation.Refreshed(new(0, 0, 0)); + observation.IsHealthy.Should().BeFalse("a successful empty poll is not terminal audit recovery"); + observation.TerminalAuditSucceeded(); + observation.IsHealthy.Should().BeTrue(); + observation.Outstanding(1); + observation.IsHealthy.Should().BeFalse(); + observation.Outstanding(0); + observation.IsHealthy.Should().BeTrue(); + observation.LoopFailed(); + observation.IsHealthy.Should().BeFalse(); + observation.Progress(); + observation.IsHealthy.Should().BeTrue(); + } + + [Fact] + public void Exact_metric_inventory_has_memory_only_gauges_and_finite_event_labels() + { + var instruments = new List<Instrument>(); + var measurements = new List<(Instrument Instrument, double Value, KeyValuePair<string, object?>[] Tags)>(); + using var listener = new MeterListener(); + listener.InstrumentPublished = (instrument, owner) => + { + if (instrument.Meter.Name != OutboxObservation.InstrumentationName) return; + lock (instruments) instruments.Add(instrument); + owner.EnableMeasurementEvents(instrument); + }; + listener.SetMeasurementEventCallback<long>((instrument, value, tags, _) => + { + lock (measurements) measurements.Add((instrument, value, tags.ToArray())); + }); + listener.SetMeasurementEventCallback<double>((instrument, value, tags, _) => + { + lock (measurements) measurements.Add((instrument, value, tags.ToArray())); + }); + listener.Start(); + var time = new ManualTimeProvider(); + using var observation = Create(time); + observation.Started(); + observation.Progress(); + observation.Refreshed(new(17, 11, 23)); + observation.Duration(nameof(TestEvent), TimeSpan.FromSeconds(3.5)); + Meter ownMeter; + lock (measurements) ownMeter = measurements.Single(value => value.Value == 3.5).Instrument.Meter; + observation.Failed("private-stored-type-and-tenant"); + observation.Deadletter("retired"); + observation.LeaseLost(); + time.Advance(TimeSpan.FromSeconds(8)); + listener.RecordObservableInstruments(); + + Instrument[] ownInstruments; + lock (instruments) ownInstruments = instruments.Where(value => ReferenceEquals(value.Meter, ownMeter)).ToArray(); + ownInstruments.Select(value => value.Name).Should().BeEquivalentTo( + [ + "learnstack_outbox_pending_count", "learnstack_outbox_dispatch_duration_seconds", + "learnstack_outbox_dispatch_failed_total", "learnstack_outbox_deadletter_total", + "learnstack_outbox_lease_lost_total", "learnstack_outbox_oldest_eligible_age_seconds", + "learnstack_outbox_deferred_count", "learnstack_outbox_heartbeat_age_seconds", + ]); + lock (measurements) + { + var own = measurements.Where(value => ReferenceEquals(value.Instrument.Meter, ownMeter)).ToArray(); + own.Single(value => value.Instrument.Name == "learnstack_outbox_pending_count").Value.Should().Be(17); + own.Single(value => value.Instrument.Name == "learnstack_outbox_deferred_count").Value.Should().Be(11); + own.Single(value => value.Instrument.Name == "learnstack_outbox_oldest_eligible_age_seconds").Value.Should().Be(23); + own.Single(value => value.Instrument.Name == "learnstack_outbox_heartbeat_age_seconds").Value.Should().Be(8); + own.Single(value => value.Instrument.Name == "learnstack_outbox_dispatch_failed_total").Tags + .Should().Equal([new KeyValuePair<string, object?>("event_type", "invalid")]); + own.Where(value => value.Instrument.IsObservable || value.Instrument.Name == "learnstack_outbox_lease_lost_total") + .Should().OnlyContain(value => value.Tags.Length == 0); + own.SelectMany(value => value.Tags).Should().OnlyContain(value => value.Key == "event_type"); + } + } + + [Theory] + [InlineData("00-0123456789abcdef0123456789abcdef-0123456789abcdef-01")] + [InlineData("01-0123456789abcdef0123456789abcdef-0123456789abcdef-01-private-extension")] + [InlineData("private-invalid-correlation")] + public void Attempts_always_get_fresh_valid_trace_identity_without_listeners(string correlation) + { + using var observation = Create(new ManualTimeProvider()); + using var first = observation.BeginAttempt(correlation, "private-undeclared-type"); + ActivityContext.TryParse(first.Id, null, out var firstContext).Should().BeTrue(); + using var second = observation.BeginAttempt(correlation, "unknown"); + ActivityContext.TryParse(second.Id, null, out var secondContext).Should().BeTrue(); + firstContext.SpanId.Should().NotBe(secondContext.SpanId); + first.GetTagItem("event_type").Should().Be("invalid"); + if (!ActivityContext.TryParse(correlation, null, out _)) + first.GetTagItem("correlation.id").Should().BeNull(); + else + first.GetTagItem("correlation.id").Should().Be( + "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01"); + } + + private static OutboxObservation Create(TimeProvider time) => new(time, new OutboxDispatchOptions(), + OutboxEventRegistry.Create([OutboxEventRegistration.IntentionallyUnsubscribed(typeof(TestEvent))], [], + IntegrationEventHandlerRegistry.FromServiceDescriptors(new ServiceCollection()), [typeof(TestEvent).Assembly])); + + private sealed record TestEvent : IntegrationEventBase + { + public override string Topic => "learnstack.test.observation"; + public override string PartitionKey => TenantId.ToString("D"); + } + + private sealed class ManualTimeProvider : TimeProvider + { + private long _timestamp; + public DateTimeOffset UtcNow { get; set; } = new(2026, 10, 12, 0, 0, 0, TimeSpan.Zero); + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + public override long GetTimestamp() => _timestamp; + public override DateTimeOffset GetUtcNow() => UtcNow; + public void Advance(TimeSpan duration) => _timestamp += duration.Ticks; + } +} diff --git a/backend/tests/LearnStack.Tests.Unit/Api/Messaging/OutboxPublishAttemptTests.cs b/backend/tests/LearnStack.Tests.Unit/Api/Messaging/OutboxPublishAttemptTests.cs new file mode 100644 index 00000000..0f68676a --- /dev/null +++ b/backend/tests/LearnStack.Tests.Unit/Api/Messaging/OutboxPublishAttemptTests.cs @@ -0,0 +1,166 @@ +using FluentAssertions; +using LearnStack.Api.Messaging; +using LearnStack.SharedKernel.Messaging; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace LearnStack.Tests.Unit.Api.Messaging; + +public sealed class OutboxPublishAttemptTests +{ + [Fact] + public async Task Synchronous_construction_and_publish_prefix_run_inside_the_owned_task() + { + using var constructRelease = new ManualResetEventSlim(); + using var publishRelease = new ManualResetEventSlim(); + var constructed = Signal(); + var published = Signal(); + var disposed = Signal(); + var services = new ServiceCollection(); + services.AddScoped<IEventBus>(_ => + { + constructed.TrySetResult(); + constructRelease.Wait(); // Intentionally hostile synchronous construction. + return new CallbackBus((_, _) => + { + published.TrySetResult(); + publishRelease.Wait(CancellationToken.None); // Intentionally hostile synchronous PublishAsync prefix. + return Task.CompletedTask; + }, () => { disposed.TrySetResult(); return ValueTask.CompletedTask; }); + }); + await using var provider = services.BuildServiceProvider(); + var attempt = new OutboxPublishAttempt(provider.GetRequiredService<IServiceScopeFactory>(), Envelope()); + try + { + await constructed.Task.WaitAsync(TimeSpan.FromSeconds(5)); + attempt.Completion.IsCompleted.Should().BeFalse(); + constructRelease.Set(); + await published.Task.WaitAsync(TimeSpan.FromSeconds(5)); + attempt.Completion.IsCompleted.Should().BeFalse(); + disposed.Task.IsCompleted.Should().BeFalse(); + attempt.RequestCancellation(); + } + finally + { + constructRelease.Set(); + publishRelease.Set(); + await attempt.Completion.WaitAsync(TimeSpan.FromSeconds(5)); + } + + (await attempt.Completion).Should().BeFalse("cancelled late success cannot become a successful attempt"); + disposed.Task.IsCompleted.Should().BeTrue(); + } + + [Fact] + public async Task Ignored_cancellation_and_async_cleanup_retain_scope_and_token_until_actual_completion() + { + var entered = Signal(); + var releaseCallback = Signal(); + var cleanupEntered = Signal(); + var releaseCleanup = Signal(); + CancellationToken captured = default; + var disposeCount = 0; + var services = new ServiceCollection(); + services.AddScoped<IEventBus>(_ => new CallbackBus(async (_, token) => + { + captured = token; + entered.TrySetResult(); + await releaseCallback.Task; + }, async () => + { + Interlocked.Increment(ref disposeCount); + cleanupEntered.TrySetResult(); + await releaseCleanup.Task; + })); + await using var provider = services.BuildServiceProvider(); + var attempt = new OutboxPublishAttempt(provider.GetRequiredService<IServiceScopeFactory>(), Envelope()); + try + { + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + attempt.RequestCancellation(); + captured.IsCancellationRequested.Should().BeTrue(); + captured.WaitHandle.Should().NotBeNull(); + disposeCount.Should().Be(0); + attempt.Completion.IsCompleted.Should().BeFalse(); + releaseCallback.TrySetResult(); + await cleanupEntered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + captured.WaitHandle.Should().NotBeNull("asynchronous cleanup still owns the cancellation source"); + attempt.Completion.IsCompleted.Should().BeFalse(); + } + finally + { + releaseCallback.TrySetResult(); + releaseCleanup.TrySetResult(); + await attempt.Completion.WaitAsync(TimeSpan.FromSeconds(5)); + } + + disposeCount.Should().Be(1); + var accessDisposedToken = () => captured.WaitHandle; + accessDisposedToken.Should().Throw<ObjectDisposedException>(); + } + + [Fact] + public async Task Blocking_cancellation_registration_is_retained_and_observed_before_scope_disposal() + { + var entered = Signal(); + var releaseCallback = Signal(); + var cancellationEntered = Signal(); + using var releaseCancellation = new ManualResetEventSlim(); + var disposed = Signal(); + var services = new ServiceCollection(); + services.AddScoped<IEventBus>(_ => new CallbackBus(async (_, token) => + { + token.Register(() => + { + cancellationEntered.TrySetResult(); + releaseCancellation.Wait(); // Exercise a cancellation callback that blocks and faults. + throw new InvalidOperationException("private-provider-cancellation-error"); + }); + entered.TrySetResult(); + await releaseCallback.Task; + }, () => { disposed.TrySetResult(); return ValueTask.CompletedTask; })); + await using var provider = services.BuildServiceProvider(); + var attempt = new OutboxPublishAttempt(provider.GetRequiredService<IServiceScopeFactory>(), Envelope()); + try + { + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + attempt.RequestCancellation(); + await cancellationEntered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + releaseCallback.TrySetResult(); + attempt.Completion.IsCompleted.Should().BeFalse(); + disposed.Task.IsCompleted.Should().BeFalse(); + } + finally + { + releaseCallback.TrySetResult(); + releaseCancellation.Set(); + await attempt.Completion.WaitAsync(TimeSpan.FromSeconds(5)); + } + + (await attempt.Completion).Should().BeFalse(); + disposed.Task.IsCompleted.Should().BeTrue(); + } + + private static TaskCompletionSource Signal() => new(TaskCreationOptions.RunContinuationsAsynchronously); + private static IntegrationEventEnvelope Envelope() => new(new TestEvent + { + EventId = Guid.NewGuid(), + TenantId = Guid.NewGuid(), + OccurredAt = DateTimeOffset.UtcNow, + }, "00-0123456789abcdef0123456789abcdef-0123456789abcdef-01"); + + private sealed record TestEvent : IntegrationEventBase + { + public override string Topic => "learnstack.test.attempt"; + public override string PartitionKey => TenantId.ToString("D"); + } + + private sealed class CallbackBus( + Func<IntegrationEventEnvelope, CancellationToken, Task> publish, + Func<ValueTask> dispose) : IEventBus, IAsyncDisposable + { + public Task PublishAsync(IntegrationEventEnvelope envelope, CancellationToken cancellationToken = default) => + publish(envelope, cancellationToken); + public ValueTask DisposeAsync() => dispose(); + } +} diff --git a/docs/architecture/04-technical-architecture.md b/docs/architecture/04-technical-architecture.md index ec3183aa..4a33bd70 100644 --- a/docs/architecture/04-technical-architecture.md +++ b/docs/architecture/04-technical-architecture.md @@ -40,9 +40,9 @@ flowchart LR end subgraph backend["LearnStack Backend"] - api[ASP.NET Core API<br/>Modular Monolith] + api[ASP.NET Core API<br/>Modular Monolith + OutboxProcessor] daprd1[Dapr sidecar] - workers[Hangfire Workers<br/>jobs + outbox dispatcher] + workers[Hangfire Workers<br/>background jobs] end subgraph hubplane["LearnStack Hub (separate codebase)"] @@ -173,13 +173,13 @@ See [Database Standards](../standards/05-database.md). flowchart LR trans[Module transaction] outbox[(outbox table)] - dispatcher[Outbox dispatcher<br/>Hangfire recurring job] + dispatcher[API OutboxProcessor<br/>BackgroundService] bus[In-process event bus] handlers[Integration event handlers] externals[External calls<br/>email / search index / etc.] trans -- domain change + integration event row --> outbox - dispatcher -- pull batch --> outbox + dispatcher -- claim one admitted row --> outbox dispatcher -- dispatch --> bus bus --> handlers bus --> externals @@ -187,7 +187,10 @@ flowchart LR ``` - All side-effects beyond the database transaction are deferred to **integration events** written to a transactional `outbox` table in the same transaction as the domain change. -- A dispatcher polls the outbox and dispatches events to in-process handlers and external systems. +- The API-host `OutboxProcessor` polls the outbox and publishes admitted events + through `IEventBus`. P02b-2 Step 3 implements hosted execution, with + verification/reviews in progress; real required subscriptions remain Pending + until P02b-3 supplies durable consumer execution. - Idempotency keys plus consumer-side deduplication guarantee at-least-once with effective once-only semantics. Full details: [Events & Outbox](15-event-and-outbox.md). diff --git a/docs/architecture/09-tenant-isolation.md b/docs/architecture/09-tenant-isolation.md index fe05bbde..ed883b35 100644 --- a/docs/architecture/09-tenant-isolation.md +++ b/docs/architecture/09-tenant-isolation.md @@ -161,8 +161,8 @@ 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. +and owner-only completion. P02b-2 Step 3 implements confined hosted dispatch; +verification and reviews are in progress. Consumer delivery remains P02b-3. ## Default org semantics diff --git a/docs/architecture/15-event-and-outbox.md b/docs/architecture/15-event-and-outbox.md index a4f92136..2c03d605 100644 --- a/docs/architecture/15-event-and-outbox.md +++ b/docs/architecture/15-event-and-outbox.md @@ -24,10 +24,11 @@ not land together. | Piece | Owning phase | State | |---|---|---| -| `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 | +| `outbox_messages` table, its schema, and its LearnStack ownership | [Phase 02a Packet 6](../roadmap/phase-02a-kernel-tenancy.md) | Schema and ownership shipped in Packet 6; producer dispatch is implemented in P02b-2 Step 3 | | `IEventBus` port + `InProcessEventBus` | [Phase 02a Packet 5](../roadmap/phase-02a-kernel-tenancy.md) | The only registered implementation | | `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 | +| `OutboxProcessor` and the producer claim protocol | [P02b-2](../roadmap/phase-02b-events-auth.md#p02b-2-step-3--hosted-lifecycle-and-observability-2026-10-12) | Implemented through Step 3; verification/reviews in progress; Step 4 enforcement pending | +| `IInboxGuard` and durable consumer transactions | [P02b-3](../roadmap/phase-02b-events-auth.md) | Pending | | 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)) | @@ -49,15 +50,20 @@ 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. ADR-0046 accepts dispatcher/inbox -contracts; implementation remains P02b-2/P02b-3. +contracts; producer dispatch is implemented through P02b-2 Step 3, while +inbox/consumer implementation remains P02b-3. The [P02b-2 decision package](../roadmap/phase-02b-events-auth.md#p02b-2-decision-package-2026-10-12) and [Accepted ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md) authorize fenced producer recovery, atomic terminal audit and the coupled shared -inbox/subscriber contract. Step 1 schema/composition is implemented with reviews -pending; no dispatcher or inbox delivery -is claimed by acceptance. Numerical runtime bounds live in the package, canonical -DDL/grants in Standards 05 and metric names in Standards 10. +inbox/subscriber contract. Steps 1–2 implement schema/composition, fenced +transitions and atomic terminal audit; each completed both review rounds. +Step 3 implements hosted execution and observation, with verification/reviews +in progress in its +[delivery record](../roadmap/phase-02b-events-auth.md#p02b-2-step-3--hosted-lifecycle-and-observability-2026-10-12). +Step 4 enforcement and P02b-3 inbox/consumer delivery remain pending. Numerical +runtime bounds live in the package, canonical DDL/grants in Standards 05 and +metric names in Standards 10. [ADR-0038](../decisions/0038-cross-cutting-port-and-event-contracts.md) stands as the decision that Dapr is the @@ -98,8 +104,10 @@ Text fallback: the producer commits state, outbox and audit together. The worker claims one admitted row immediately before publishing and fences its acknowledgement. Required Pending subscriptions hold rows before acquisition. P02b-3 durably accepts required subscriptions, then executes each on its own app-role transaction. Shared -inbox completion and business/audit/ledger completion commit together. Only producer -production exists today; worker and delivery execution remain their named packets. +inbox completion and business/audit/ledger completion commit together. Producer +production and the API worker are implemented; Step 3 verification/reviews are in +progress. Durable consumer execution remains P02b-3, and real required +subscriptions remain Pending. ## Outbox table @@ -116,19 +124,18 @@ establish durable publish order. Accepted P02b-2 adds paired `locked_by` / `locked_until` lease fields, `terminal_reason` and `dead_lettered_at`; the former is a fresh acquisition token, never stable worker identity. `available_after` controls due time and retry backoff. Canonical DDL/indexes are in Standards 05, -implemented by P02b-2 Step 1 with both review rounds complete; dispatcher execution -remains -a later step. +implemented by P02b-2 Step 1 with both review rounds complete. Step 3 implements +dispatcher execution; verification and reviews are in progress. ### Why the `learnstack_outbox_admin` role bypasses RLS — and what bounds the bypass One API-host BackgroundService schedules every tenant. The existing bypass role uses its own named keyed, lazy data source; no Seeder/web forwarding or app-role membership is allowed. Configuration validates the exact username without -connecting or disclosing parse input. Step 3 will require worker-startup -connectivity; every new -physical connection verifies exact session/current role, bounded attributes and -no privilege-escalating membership. +connecting or disclosing parse input. Step 3 implements mandatory worker-startup +resolution and connectivity checks; verification and reviews are in progress. +Every new physical connection verifies exact session/current role, bounded +attributes and no privilege-escalating membership. [Database Standards](../standards/05-database.md#grant-matrix) owns the exact grant budget: named outbox status UPDATEs and audit INSERT-only in P02b-2, with no audit @@ -143,8 +150,9 @@ UPDATE result, because GUCs and INSERT-only cannot tenant-bind a bypass credenti The platform-admin gate and pre-entry audit remain unchanged. Four roles and separate credentials remain the closed model; the code-path and role rules are implemented for transition-witness construction and terminal audit in Step 2; -its independent reviews remain pending. Hosted execution and credential/publication -censuses remain Steps 3–4. +both independent review rounds passed. Step 3 implements hosted execution and +observation, with verification/reviews in progress. Credential/publication +censuses remain Step 4. <a id="inbox-table-per-consumer-module"></a> @@ -344,7 +352,8 @@ poll, which is neither. ### The claim protocol -**Accepted ADR-0046 target; runtime P02b-2.** The processor admits a construction-free +**ADR-0046 producer runtime implemented through P02b-2 Step 3; verification and +reviews in progress.** The processor admits a construction-free manifest policy before acquisition. Any required Pending subscription holds the contract and all aliases unchanged, even if stored payload is malformed. Active requires declared handler registration. Only explicit intentional absence may diff --git a/docs/architecture/31-audit-subsystem.md b/docs/architecture/31-audit-subsystem.md index 051c31eb..e23c3c95 100644 --- a/docs/architecture/31-audit-subsystem.md +++ b/docs/architecture/31-audit-subsystem.md @@ -80,7 +80,7 @@ for that second stage and withdrawn for the first. ### Confined event-terminal audit -**ADR-0046 fifth seam implemented in P02b-2 Step 2; reviews pending.** +**ADR-0046 fifth seam implemented in P02b-2 Step 2; both review rounds complete.** Existing four delivered writes retain their contracts. The new narrow method is: @@ -671,8 +671,9 @@ 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 under Accepted ADR-0046. +and owner-only domain drain. P02b-2 Step 3 implements hosted producer dispatch, +with verification/reviews in progress; production consumer delivery/inbox wiring +remains P02b-3 under Accepted ADR-0046. ### The two catalogue faces, and the three columns nothing fills yet @@ -883,7 +884,8 @@ uses `CancellationToken.None` and cannot replace the original failure. The share 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. +MUST audit/commit. P02b-2 Step 3 implements hosted producer dispatch, with +verification/reviews in progress; consumer delivery/inbox remains 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 @@ -1716,8 +1718,8 @@ 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. | -| P02b-2 Steps 1–2 | Schema/grants and confined producer transitions with MUST audit through the row-bound fifth seam; real-role atomicity/identity proofs. Hosted dispatch and metrics remain Step 3; Step 2 reviews pending. | +| 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. Hosted dispatch is implemented in P02b-2 Step 3; consumer delivery/inbox remains P02b-3. | +| P02b-2 Steps 1–3 | Schema/grants and confined producer transitions with MUST audit through the row-bound fifth seam; real-role atomicity/identity proofs. Steps 1–2 completed both review rounds. Step 3 implements hosted dispatch and observation, with verification/reviews in progress; Step 4 enforcement remains pending. | | P02b-3 (Accepted contract; pending) | Shared transport-owned inbox and independent delivery ledger; per-subscription audit frames and Tenancy-owned terminal audit. Ordinary executor refuses privileged subscriptions. | | [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. | diff --git a/docs/decisions/README.md b/docs/decisions/README.md index eb1e8689..be2f061d 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -75,7 +75,7 @@ name P02b-2/P02b-3 activation; acceptance claims no runtime delivery. | 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. [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 | -| 0046 | [Durable Event Delivery and Recovery](0046-durable-event-delivery-and-recovery.md) | Accepted 2026-10-12: fenced producer leases/retries, Pending readiness holds and atomic row-bound terminal audit; P02b-2 implementation authorized and pending. Shared inbox/durable subscription acceptance and independent retries remain P02b-3. Named bounded supersessions preserve earlier Accepted records. | +| 0046 | [Durable Event Delivery and Recovery](0046-durable-event-delivery-and-recovery.md) | Accepted 2026-10-12: fenced producer leases/retries, Pending readiness holds and atomic row-bound terminal audit; P02b-2 Steps 1–2 implemented with both review rounds complete; Step 3 hosted dispatch/observation implemented with verification/reviews in progress; Step 4 enforcement pending. Shared inbox/durable subscription acceptance and independent retries remain P02b-3. Named bounded supersessions preserve earlier Accepted records. | | 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 | | 0052 | [Anonymous Public Read Boundary](0052-anonymous-public-read-boundary.md) | Accepted 2026-10-03: host-resolved bootstrap, immutable host ceiling, mandatory marked dispatch and read-only ambient frames; ADR-0036/0040 Amendment 8; P02d-4 Steps 1–3 deliver host provenance, physical read-only frames and all institution GET/HEAD reads; both Step 3 review rounds pass; Step 4 delivers OpenAPI/SDK/CI controls; both review rounds and focused fix verification pass; six real required CI jobs and live sixth-check activation verified; P02d-4 complete and merged through PR #25 on 2026-10-08 ([Amendment 13](0052-anonymous-public-read-boundary.md#amendment-13--merge-closeout-2026-10-08)) | diff --git a/docs/glossary.md b/docs/glossary.md index d28c67c0..f54480fa 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -341,7 +341,7 @@ These entries distinguish P02d-2 contracts from P02d-3 internal read contracts: | **`IFeatureFlags`** | The **only** module-facing entitlement read: `IsEnabledAsync(FeatureKey)` and `GetLimitAsync(LimitKey)` returning `long`. It resolves the tenant from `ITenantContext` and throws when there is none, so module code can never name another tenant. It **composes** rather than queries: a plan-projected key resolves through `IEntitlementProvider.GetAsync` — not by reading `platform_entitlement_cache` — a tenant-flag key reads `tenant_feature_flags`, and the killswitch overlay is applied last and wins over both. Its implementation lives in `LearnStack.Modules.Tenancy.Infrastructure`, because `tenant_feature_flags` is that module's table, and is registered at the composition root. `IFeatureFlagService` and `IUsageLimitService` are **withdrawn**, not renamed ([ADR-0021](decisions/0021-feature-based-entitlement.md)). Per [ADR-0045 § 2](decisions/0045-entitlement-and-feature-flag-socket.md). | | **`IHostToTenantResolver` / `HostResolution`** | Interface for host → `(tenant_id, organization_id?)` resolution, returning the `HostResolution(TenantId, OrganizationId?)` record or null. Reads `platform_host_to_tenant` and **nothing else** — never the Hub, because an anonymous page load must not depend on a control plane being reachable ([ADR-0034](decisions/0034-hub-contract-surface-invariant.md)). | | **APISIX** | The gateway in standalone YAML-reload mode per [ADR-0015](decisions/0015-api-gateway-apisix.md). The intended tenant-facing ingress, demand-gated to [Phase 11](roadmap/phase-11-production-hardening.md); until then ASP.NET middleware carries the same responsibilities in-process. `/api/internal/*` is never proxied by it — that listener is mTLS-only inside the pod. | -| **`OutboxProcessor`** | The API-host BackgroundService that claims one admitted outbox row immediately before invocation, with a fresh acquisition token in `locked_by`, a database-time lease and one counted attempt. Token/expiry-fenced transitions own retry and explicit audited terminal state; stable worker identity is diagnostic only. Accepted [ADR-0046](decisions/0046-durable-event-delivery-and-recovery.md) authorizes P02b-2 implementation, whose transitions and terminal witness ship in Step 2 while hosted execution remains Step 3; at-least-once recovery still needs P02b-3 inbox deduplication. | +| **`OutboxProcessor`** | The API-host BackgroundService that claims one admitted outbox row immediately before invocation, with a fresh acquisition token in `locked_by`, a database-time lease and one counted attempt. Token/expiry-fenced transitions own retry and explicit audited terminal state; stable worker identity is diagnostic only. Accepted [ADR-0046](decisions/0046-durable-event-delivery-and-recovery.md) governs P02b-2 implementation: Steps 1–2 completed both review rounds, and Step 3 implements hosted execution/observation with verification/reviews in progress. Step 4 enforcement remains pending; at-least-once recovery still needs P02b-3 inbox deduplication. | | **`IUnitOfWork`** | The ambient unit of work: **one database connection per scope**, and the transaction on it ([ADR-0040](decisions/0040-ambient-unit-of-work.md)). Every module `DbContext` resolved in the scope is built on that connection and enlisted in that transaction, and `IAuditStore` and `IOutbox` reach it through the same seam. The connection count is a correctness property, not a performance one: `SET LOCAL app.tenant_id` is connection- **and** transaction-local, so a context on its own connection reads zero rows from every tenant-owned table — silently. It exposes no `SaveChangesAsync`; contexts save themselves and it owns only the transaction boundary. [ADR-0040 Amendment 8](decisions/0040-ambient-unit-of-work.md#amendment-8--read-only-public-frames-2026-10-03) accepts explicit read-only/read-write intent, defaulting to writable: same-mode joins preserve ownership, mixed-mode joins poison the unit, and successful read-only completion permits a later writer. P02d-4 Step 1 delivers these modes and lifecycle controls; poison still requires a fresh scope. | | **`IInboxGuard`** | The Accepted shared, transport-owned inbox claim/completion seam, pending P02b-3. Deduplication is `(tenant_id, subscription_id, event_id)`, claimed before handler construction on the announced app-role transaction. Business state, completed inbox, produced outbox, MUST audit and fenced ledger completion commit together; rollback removes a new claim. Handlers do not call the guard, and targeted replay never resets a completed inbox. See [ADR-0046](decisions/0046-durable-event-delivery-and-recovery.md#subscriber-acceptance-and-inbox-ownership). | | **Inbox** | Shared tenant-owned completion record for one stable subscription/event pair. A new claim is uncommitted business-transaction work; only successful completion persists it. The delivery ledger owns failed/terminal/replay state. Canonical DDL remains P02b-3 in [Database Standards](standards/05-database.md#inbox). | @@ -349,7 +349,7 @@ These entries distinguish P02d-2 contracts from P02d-3 internal read contracts: | **Subscription readiness** | Construction-free Pending, Active or IntentionallyUnsubscribed declaration. Any required Pending subscription holds its contract/aliases before acquisition and validation. Active requires a real registered handler; intentional absence is explicit, never inferred from zero discovered handlers. Accepted P02b-2 policy under [ADR-0046](decisions/0046-durable-event-delivery-and-recovery.md). | | **Outbox lease** | Paired `locked_by` / `locked_until` fields; the former holds a fresh canonical UUID acquisition token, the latter a database-time expiry. Every transition checks both current token and unexpired lease. Recovery replaces expired ownership before another transition. Stable worker identity confers no ownership. | | **Dead letter** | Explicit completed terminal state after invalid data or exhausted attempts, distinct from a terminal request or delayed availability. Producer terminal state retains its envelope and commits with MUST audit; subscriber terminal state belongs to its independent delivery ledger and never completes its inbox. Operator reset/replay is separately owned by Phase 03. | -| **`EventTerminalTransition`** | Restricted immutable witness minted only from the successful fenced terminal UPDATE result in its live transaction. It binds actual row identity, acquisition and terminal values to `WriteEventTerminalAsync`; substituting draft tenant/org/event or transaction requires rollback of state and audit. Fifth seam implemented in P02b-2 Step 2; independent reviews pending. | +| **`EventTerminalTransition`** | Restricted immutable witness minted only from the successful fenced terminal UPDATE result in its live transaction. It binds actual row identity, acquisition and terminal values to `WriteEventTerminalAsync`; substituting draft tenant/org/event or transaction requires rollback of state and audit. Fifth seam implemented in P02b-2 Step 2; both independent review rounds complete. | | **`DeploymentMode`** | Enum (`Development \| SaaS \| Dedicated \| SelfHostedOnline \| SelfHostedAirGapped`) read at the composition root to select provider implementations. Modules never read this enum. | diff --git a/docs/modules/audit/README.md b/docs/modules/audit/README.md index 261f433c..141e184c 100644 --- a/docs/modules/audit/README.md +++ b/docs/modules/audit/README.md @@ -3,7 +3,7 @@ **Status:** Design stable, write path implemented. Shipped today: the two tables and their isolation, the three append-only layers, the SharedKernel ports and value types, `AuditDbContext`, and — in `LearnStack.Infrastructure.Audit` — `AuditStateCapture`, -`AuditChangeTrackerInterceptor`, `PostgresAuditStore` with all four write methods, the +`AuditChangeTrackerInterceptor`, `PostgresAuditStore` with all five write methods, the merged `AuditCatalog` and `AuditConfigService`, registered at both composition roots and attached to every module's `DbContext`. The pipeline is lit: `AuditLogBehavior` classifies and parks intents at step 3 and reconciles in its `finally`, @@ -28,7 +28,8 @@ module's `IUserReferenceLocator` retention purge land in [Phase 11](../../roadmap/phase-11-production-hardening.md) against written triggers ([ADR-0035](../../decisions/0035-demand-gated-infrastructure.md)). -**ADR-0046 Accepted — 2026-10-12; fifth writer pending P02b-2.** The existing +**ADR-0046 fifth writer implemented in P02b-2 Step 2 — 2026-10-12; both review +rounds complete.** The existing four writes remain implemented. `WriteEventTerminalAsync` adds INSERT-only access for the confined outbox role and validates the audit draft against a restricted witness from the actual fenced terminal UPDATE on the caller's transaction. diff --git a/docs/modules/audit/audit.md b/docs/modules/audit/audit.md index d219f966..78607d8a 100644 --- a/docs/modules/audit/audit.md +++ b/docs/modules/audit/audit.md @@ -6,8 +6,9 @@ of the [module spec](README.md). Accepted [ADR-0046](../../decisions/0046-durable-event-delivery-and-recovery.md) adds the confined terminal-audit seam in P02b-2, not an Audit-module operation. `platform.outbox.dead_letter` and P02b-3's `platform.delivery.dead_letter` belong -to [Tenancy's off-path matrix](../tenancy/audit.md); both writers remain pending -at decision acceptance. Phase 03 owns privileged erasure execution and its bounded +to [Tenancy's off-path matrix](../tenancy/audit.md). P02b-2 Step 2 implements +the producer writer, with both review rounds complete; the subscriber writer +remains P02b-3. Phase 03 owns privileged erasure execution and its bounded caller contract; Phase 11 owns retention purge. **The module that owns the log is audited like any other.** Reading someone's audit diff --git a/docs/modules/tenancy/README.md b/docs/modules/tenancy/README.md index ff0d2438..f61accc3 100644 --- a/docs/modules/tenancy/README.md +++ b/docs/modules/tenancy/README.md @@ -494,7 +494,8 @@ 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. +pending rows; P02b-2 Step 3 implements hosted dispatch, with verification/reviews +in progress. P02b-3 owns consumer delivery. The subscriptions below are accepted flow contracts, not delivered handlers. [ADR-0046](../../decisions/0046-durable-event-delivery-and-recovery.md) accepts G1's diff --git a/docs/modules/tenancy/audit.md b/docs/modules/tenancy/audit.md index 51070351..8c94ddb2 100644 --- a/docs/modules/tenancy/audit.md +++ b/docs/modules/tenancy/audit.md @@ -8,9 +8,11 @@ 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. +P02b-2 Step 3 implements hosted producer dispatch, with verification/reviews in +progress; subscribers remain P02b-3. -**ADR-0046 producer terminal writer implemented — 2026-10-12; reviews pending.** +**ADR-0046 producer terminal writer implemented — 2026-10-12; both review rounds +complete.** The infrastructure operations below belong to this catalogue/matrix, without a synthetic aggregate. P02b-2 Step 2 registers the delivered producer terminal operation; P02b-3 registers subscriber terminalization when its writer ships. diff --git a/docs/modules/tenancy/permissions.md b/docs/modules/tenancy/permissions.md index 784888bb..6a7457a8 100644 --- a/docs/modules/tenancy/permissions.md +++ b/docs/modules/tenancy/permissions.md @@ -9,8 +9,10 @@ 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. -**ADR-0046 Accepted — 2026-10-12; implementation pending.** P02b-2's API-owned -dispatcher and terminal audit add no permission key or HTTP endpoint. P02b-3's +**ADR-0046 implementation through P02b-2 Step 3 — 2026-10-12.** Steps 1–2 +completed both review rounds; Step 3 verification/reviews are in progress and +Step 4 enforcement remains pending. The API-owned dispatcher and terminal audit +add no permission key or HTTP endpoint. P02b-3's targeted replay is internal; Phase 03 separately decides operator permission and routes. Neither grants a tenant administrator bypass-role access. diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index d1805adb..ecc873e3 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -100,7 +100,11 @@ not deferred to the showcase phase. authorize the implemented lifecycle, enqueue and provisioning publisher. The [P02b-1 merge closeout](phase-02b-events-auth.md#p02b-1-merge-and-closeout-2026-10-12) records final CI verification and the completed delivery/review history. - Next is P02b-2's decision pass; dispatcher/consumer work remains P02b-2/P02b-3. + P02b-2's revised package and ADR-0046 are Accepted. Steps 1–2 completed both + review rounds; Step 3 implements hosted dispatch and observation, with + verification/reviews in progress in its + [delivery record](phase-02b-events-auth.md#p02b-2-step-3--hosted-lifecycle-and-observability-2026-10-12). + Step 4 enforcement remains pending; consumer delivery remains 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 61e8e902..4363bcb1 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -29,8 +29,11 @@ verification. The Accepted P02b-2 package below owns the current dispatcher work The four implementation steps are authorized on development. Step 1 is complete; its [record](#p02b-2-step-1--decision-schema-and-confined-composition-2026-10-12) owns delivery evidence and both completed review rounds. Step 2 implements durable -transitions and terminal audit; verification and reviews are in progress. Hosted -dispatcher delivery and all P02b-3 implementation remain pending. +transitions and terminal audit; both independent review rounds pass. Step 3 +implements hosted lifecycle and observation; its +[record](#p02b-2-step-3--hosted-lifecycle-and-observability-2026-10-12) owns +verification/reviews, which are in progress. Step 4 enforcement and P02b-3 +implementation remain pending. **Phase 02d handoff — 2026-10-11.** P02b-0 re-verifies the delivered `/api/v1` public contracts, native HTTPS on retained `*.learnstack.local` seed hosts, @@ -99,21 +102,20 @@ keeps the phase from re-deciding settled questions or repairing repaired defects 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 delivered dispatcher, 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. +enqueue and the enrollment/invariant guard. P02b-2 Step 3 implements the +API-host `OutboxProcessor` BackgroundService, with verification/reviews in +progress. Still absent are `IInboxGuard`, `inbox_messages`, Hangfire and its +package pins, and the realm's protocol mappers. Packet 5 documented a planned +`IInboxGuard` seam in comments, without an interface declaration. P02b-3 owns its +shared transport implementation and table under ADR-0046. ### What this phase is not **It is not the phase where a transport is chosen.** `IEventBus` and `InProcessEventBus` shipped in [Phase 02a Packet 5](phase-02a-kernel-tenancy.md) as a **first-class -transport** — the same `IIntegrationEventHandler<T>`, the same `IInboxGuard` seam, the -same tenant-context restoration as any durable path. Phase 02b adds *durability and -redelivery* on top of that transport; it does not add a second one. +transport** with the shared `IIntegrationEventHandler<T>` contract and +tenant-context restoration. P02b-3 adds shared inbox durability and independent +subscription redelivery on top of that transport; it does not add a second one. The Dapr pub/sub and Kafka adapters are **not in this phase**. Both are demand-gated to [Phase 11](phase-11-production-hardening.md) by @@ -254,7 +256,7 @@ Accepted, and its catalogue rows registered, before its first line of code. |---|---|---| | **P02b-0** | Phase entry: every premise the register below cites re-verified against `HEAD` — the code, the corpus, the Hub repository — and the register corrected where Phase 02d moved it. It drafts no record: each gate closes in the decision pass of the first packet it shapes, and each glossary headword this phase needs — `inbox`, outbox lease, dead letter, audit frame, job activator, integration-event consumer — is written in the pass whose gate fixes its meaning | [Phase 02d](phase-02d-walking-skeleton.md) exits | | **P02b-1** | **Complete and merged — [PR #29 closeout](#p02b-1-merge-and-closeout-2026-10-12).** Producer: `IOutbox`, the `OutboxFlushBehavior` invariant guard, successful-save domain-event collection and owner-only completion, and the first event with its enqueue path | G2, G3 (the key's derivation), G5 (the stored identities), G6, G18 (the payload rule), G19 Accepted | -| **P02b-2** | **Decision Accepted; Step 1 complete; Step 2 verification/reviews in progress.** Dispatcher: the lease migration and grant extension, claim / publish / mark, backoff, the producer terminal state, the credential and its boot guard, the metrics and the health check | G1, G3, G4, G5, G7, G8, G17, G18 (`last_error`) Accepted; P02b-1 | +| **P02b-2** | **Decision Accepted; Steps 1–2 complete; Step 3 in progress.** Dispatcher: the lease migration and grant extension, claim / publish / mark, backoff, the producer terminal state, the credential and its boot guard, the metrics and the health check | G1, G3, G4, G5, G7, G8, G17, G18 (`last_error`) Accepted; P02b-1 | | **P02b-3** | Consumer: durable acceptance, shared inbox/ledger DDL and `IInboxGuard`, the common per-delivery transaction, both real defaults subscriptions and Seeder handoff, independent attempts and audited terminal state, targeted internal ledger replay | G1, G6, G7 Accepted; P02b-2 | | **P02b-4** | Background jobs: the runner, its storage, the tenant-context hook, the enqueue guard, no job definition | G9 Accepted; P02b-3 | | **P02b-5** | API authentication: JwtBearer wiring and its insertion order, claim population, the host/JWT cross-check through the existing recorder, token-keyed rate limits, the 401 rows, one authenticated endpoint | G10, G11, G12 (the BFF's client), G14, G20 Accepted | @@ -1605,11 +1607,62 @@ routing correction rather than accepting review notes as evidence. Step 2 is closed; Step 3 may activate the mandatory worker and observation. No P3 delivery or inbox effect is claimed. +### P02b-2 Step 3 — Hosted lifecycle and observability (2026-10-12) + +**Implemented; verification and independent reviews in progress.** The API owns +its mandatory `OutboxProcessor` BackgroundService, guarded dispatcher-source +startup resolution/connectivity, bounded publication lifecycle and retained +overrun ownership. Observation supplies immutable gauge snapshots, bounded +producer diagnostics and the registered, unmapped `outbox` health check. +`/healthz` remains liveness-only. Paired API launchers supply application and +confined dispatcher credentials; Seeder strips the dispatcher credential and +web receives neither database credential. + +**Verification before review:** the Release solution build reports zero +warnings/errors. TRX counters record zero failures/skips in the complete unit +suite (1,845), contract suite (4), Docker-free integration suite (182), restored +architecture suite (273), and the actual-host PostgreSQL `OutboxProcessorTests` +slice (16). Worker helper/observation unit cases are included in the unit total. +The initial architecture run exposed the newly registered worker in the global +keyed-resolution allowlist; the exact outbox-key allowance is corrected and the +restored 273-case run passes. This is a focused Docker-runtime receipt, not a +full Docker-suite claim. All three actual API-launcher cases also pass after +correcting the environment reader to accept the explicit dispatcher credential. +The two affected frontend files pass 116 tests; web lint and typecheck pass. +Whitespace verification passes for all 39 changed C# files; affected TypeScript +and runtime-script Prettier checks, script syntax, diff whitespace and 2,172 +relative links/396 fragments pass. Historical producer delivery records and +Accepted ADR bodies remain byte-preserved. + +`OutboxProcessorTests` covers actual registration/guarded boot, invalid credential +refusal, real provisioning Pending holds, poison/ordinary failure progression, +explicit intentional absence, retained callback/cleanup ownership, four-task +saturation and finite shutdown. Observation tests cover monotonic stale health, +failed-refresh retention, terminal-audit failure, finite metric labels and fresh +trace identity, with bounded canonical correlation reconstructed from parsed +identity rather than optional traceparent extensions. Two independent actual API +workers prove no second publication/claim while the first live lease is held. +Never-blocks, live-ownership and Pending catalogue rules are Implemented. The full +health production census/planted controls remain Step 4; that rule is Registered. + +Two behavioural mutations fail in the intended executed test: removing local +partition admission permits a sibling claim before retained cleanup completes; +removing live-lease admission permits a competing worker to claim the held row. +Both source files are byte-restored before the clean build/unit/worker rerun. +Both independent review rounds remain pending; neither has started. +Steps 1–2 completed both rounds in their +records above. Step 4 owns full credential/publication censuses, catalogue +enforcement and packet closeout. P02b-3 still owns durable acceptance, shared +inbox/ledger, independent subscriber retries, both real default subscriptions and +Seeder handoff. Real required subscriptions remain Pending; no consumer business +effect is claimed. + ### Durable outbox dispatch 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. +first publisher. P02b-2 Step 3 implements hosted producer dispatch, with +verification/reviews in progress; consumer delivery remains P02b-3. The table shipped in Packet 6. A handler calls `IOutbox.EnqueueAsync` and the row commits on the same **transaction** @@ -1628,7 +1681,7 @@ cannot enqueue. Production delivery wiring remains P02b-3. 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 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 +P02b-2 Step 3 registers the mandatory hosted processor. 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 @@ -1641,8 +1694,8 @@ Dapr trigger verbatim — so it stays with Phase 11. - `ConnectionStrings:OutboxDispatcher` carries the `learnstack_outbox_admin` credential, registered as a keyed data source behind a `Lazy<>` with a boot-time role guard, on the pattern `Platform_DataSource_Resolved_Only_By_PlatformAdminScope` already holds. - `LearnStack_OutboxAdmin_Role_OnlyUsedBy_OutboxProcessor` is **Awaiting backfill** in - the catalogue and is carried here or nowhere. + `LearnStack_OutboxAdmin_Role_OnlyUsedBy_OutboxProcessor` remains **Registered** + in the catalogue until Step 4 supplies its full credential caller census. - The three carriers that say the credential lives in "the worker host" — [Database Standards § Database roles](../standards/05-database.md#database-roles), `architecture/15` and `.env.example` — are derived cells, not a topology decision; diff --git a/docs/standards/02-backend-coding.md b/docs/standards/02-backend-coding.md index e6701326..75209dd4 100644 --- a/docs/standards/02-backend-coding.md +++ b/docs/standards/02-backend-coding.md @@ -410,7 +410,8 @@ decides what happens *inside* the steps ADR-0033 named; the order is untouched: 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`. + contract; P02b-2 Step 3 implements hosted dispatch through `IEventBus`, with + verification and reviews in progress. 8. **Handler** — domain logic; returns `Result<T>`. **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 ae75ecd4..82793c70 100644 --- a/docs/standards/05-database.md +++ b/docs/standards/05-database.md @@ -1395,8 +1395,9 @@ Public read models consumed by other modules: **ADR-0046 schema implemented in P02b-2 Step 1; both review rounds complete.** The canonical DDL below includes lease/terminal/identity/descriptor changes. Step 2 implements -fenced transitions and atomic terminal auditing; its reviews remain pending. -Hosted execution and metrics remain Step 3. Existing +fenced transitions and atomic terminal auditing; both review rounds passed. +Step 3 implements hosted execution and metrics; verification/reviews are in progress. +Step 4 enforcement remains pending. Existing migrations remain immutable; a new Platform-chain migration replaces free-form legacy `last_error` with `migration:legacy:redacted` before its byte CHECK. This specific ADR-0046 Platform migration temporarily uses owner-only `NO FORCE` @@ -1516,7 +1517,8 @@ 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. ADR-0046 accepts producer recovery and the shared inbox contract; P02b-2 Step 2 implements producer transitions and terminal audit, -Step 3 owns hosted dispatch, and P02b-3 owns inbox/delivery runtime. +Step 3 implements hosted dispatch with verification/reviews in progress, and +P02b-3 owns inbox/delivery runtime. `organization_id` is `uuid NULL` and mirrors the organization context of the transaction that enqueued the row — diff --git a/docs/standards/09-error-handling.md b/docs/standards/09-error-handling.md index 5bfff218..d5a39bc7 100644 --- a/docs/standards/09-error-handling.md +++ b/docs/standards/09-error-handling.md @@ -564,7 +564,9 @@ exception types (`LiveKit.NET.LiveKitException`, `Stripe.StripeException`, ## Outbox -**Accepted ADR-0046; runtime delivery pending P02b-2/P02b-3.** +**Accepted ADR-0046; producer runtime implemented through P02b-2 Step 3.** +Step 3 verification and reviews are in progress; Step 4 enforcement remains +pending. Subscriber delivery/retries and inbox remain P02b-3. - Each committed normal claim increments producer `attempts` once, including an unknown crash outcome. Six total claims use retry delays 1 s, 5 s, 30 s, 5 min diff --git a/docs/standards/10-observability.md b/docs/standards/10-observability.md index 0504bde0..6226d891 100644 --- a/docs/standards/10-observability.md +++ b/docs/standards/10-observability.md @@ -153,7 +153,7 @@ Auto-instrument: - Entity Framework Core queries. - MediatR commands and queries. - Hangfire job invocations. -- Outbox dispatcher attempts (P02b-2 target; no batch span). +- Outbox dispatcher attempts (P02b-2 Step 3; no batch span). - Valkey client calls. - SeaweedFS/S3 SDK calls. - LiveKit provider calls. @@ -253,8 +253,9 @@ backend treat business rejections as system failures. | `learnstack_entitlement_source_total` | counter | `source` | | `learnstack_entitlement_unresolved_total` | counter | — | -Outbox metrics above are Accepted under ADR-0046 and pending P02b-2; inbox -counters remain P02b-3 and job metrics P02b-4. `event_type` comes only from the +Outbox metrics above are implemented in P02b-2 Step 3 under ADR-0046; +verification and reviews are in progress. Inbox counters remain P02b-3 and job +metrics P02b-4. `event_type` comes only from the finite declared registry plus fixed unknown/retired/invalid categories. Never label event metrics by tenant, event, partition, subscription or trace identity. @@ -272,7 +273,8 @@ health bound. Failure retains the prior snapshot and reports unhealthy. The own deadlines and task limits. Dispatcher-owned logs/spans use validated correlation and bounded outcomes, without exception text/stacks, payload, raw stored type or connection data. P02b-3 reconciles existing transport/consumer -exception telemetry before Active consumers; no current minimization is claimed. +exception telemetry before Active consumers; no current transport/consumer +minimization is claimed. Cache `cache.name` is a governed, low-cardinality family from the Standards 20 inventory (`hub:host-map`, `hub:entitlement`, `identity:permissions`, @@ -309,9 +311,10 @@ touched. | Check | Registered as | Unhealthy when | |---|---|---| | Audit write path | `audit` | The most recent MUST-class standalone audit write failed and no later one has succeeded | -| Outbox dispatcher (P02b-2 target) | `outbox` | No actual progress or observation refresh for 45 s, any retained callback overrun, or terminal-audit failure | +| Outbox dispatcher (P02b-2 Step 3) | `outbox` | No actual progress or observation refresh for 45 s, any retained callback overrun, or terminal-audit failure | -The `audit` check is registered in Phase 02a Packet 9; P02b-2 registers `outbox`. +The `audit` check is registered in Phase 02a Packet 9; P02b-2 Step 3 registers +`outbox`, with verification and reviews in progress. Both are **not mapped**: `/healthz` stays a liveness probe, because a process that cannot write audit rows is still worth leaving alive. The readiness surface that reads the check, and the deployment-level backstop that diff --git a/docs/standards/11-security.md b/docs/standards/11-security.md index 75b1ecf0..deced645 100644 --- a/docs/standards/11-security.md +++ b/docs/standards/11-security.md @@ -244,9 +244,11 @@ for the full strategy. Standards-side: ### Confined event dispatcher ADR-0046 accepts this P02b-2 boundary. Step 1 implements source/role guards; -both review rounds are complete. Step 3 still owns mandatory worker startup resolution -and connectivity proof. The named keyed, lazy dispatcher source belongs only to -the API host. Configuration validates the exact username without connecting or +both review rounds are complete. Step 3 implements mandatory worker startup +resolution and connectivity checks; verification/reviews are in progress. +Step 4 credential/publication enforcement remains pending. The named keyed, lazy +dispatcher source belongs only to the API host. Configuration validates the exact +username without connecting or echoing parse input. Worker startup must verify connectivity. Every new physical connection verifies exact session/current `learnstack_outbox_admin`, BYPASSRLS, NOSUPERUSER, NOCREATEDB, NOCREATEROLE, diff --git a/docs/standards/18-audit-coverage.md b/docs/standards/18-audit-coverage.md index 0a2bcfaa..b0dc9465 100644 --- a/docs/standards/18-audit-coverage.md +++ b/docs/standards/18-audit-coverage.md @@ -503,7 +503,7 @@ table above is the policy those deliverables implement, not a description of tod changes no platform-admin pre-entry audit or other bypass operation. - `WriteEventTerminalAsync(AuditEntryDraft, EventTerminalTransition, DbConnection, DbTransaction, CancellationToken)` is the narrow fifth write, implemented in - P02b-2 Step 2; independent reviews remain pending. + P02b-2 Step 2; both independent review rounds passed. Only a successful fenced UPDATE result mints the restricted witness binding actual tenant/org/event/acquisition/kind/reason/attempts/subscription and live transaction. Validate declared MUST/SecurityEvent operation and matching draft diff --git a/docs/standards/20-infrastructure-stack.md b/docs/standards/20-infrastructure-stack.md index 424db841..df8895e3 100644 --- a/docs/standards/20-infrastructure-stack.md +++ b/docs/standards/20-infrastructure-stack.md @@ -454,8 +454,10 @@ Rules: retries/terminal/replay. Targeted replay invokes one internal executor, never broadcast; the outbox processor remains the only production publisher. - Operator routes, permissions and producer reset/fencing belong to Phase 03. - Brokers and retention purge remain demand-gated Phase 11. Acceptance claims no - dispatcher, inbox or effectively-once delivery implementation. + Brokers and retention purge remain demand-gated Phase 11. P02b-2 Step 3 + implements hosted producer dispatch, with verification/reviews in progress; + Step 4 enforcement remains pending. Inbox and effectively-once consumer delivery + remain P02b-3. Full deep dive: [15-event-and-outbox.md](../architecture/15-event-and-outbox.md). @@ -644,7 +646,7 @@ are the Hub's public API, governed by the Hub repository. `AuditLogBehavior`; there is no per-module audit code. - `IAuditStore` is the only sanctioned write path; the architecture test `Modules_Do_Not_Write_AuditLog_Directly` is the rule that enforces it. The - delivered store carries **four** write methods and no update method: + original four write methods retain their contracts, with no update method: `WritePendingAsync` on the ambient transaction, the two standalone writers (`WriteStandaloneAsync`, `WriteBestEffortAsync`), and `WritePlatformScopeAsync`, whose only caller is `EnterPlatformAdminScope(reason)` — @@ -656,8 +658,9 @@ are the Hub's public API, governed by the Hub repository. Accepted ADR-0046 adds the confined fifth method `WriteEventTerminalAsync(AuditEntryDraft, EventTerminalTransition, DbConnection, -DbTransaction, CancellationToken)`; P02b-2 implementation remains pending. Its -restricted witness comes only from the actual successful fenced terminal UPDATE. +DbTransaction, CancellationToken)`, implemented in P02b-2 Step 2 with both +independent review rounds complete. The delivered store has five write methods. +Its restricted witness comes only from the actual successful fenced terminal UPDATE. The store validates declared operation/class/type, row identity and live caller transaction, then INSERTs without opening/committing a transaction. Existing four writes retain their contracts. The terminal UPDATE and audit commit together, or diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 710359f3..73dee67d 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -14,8 +14,9 @@ [ADR-0057 Transactional Event Production and Completion](../decisions/0057-transactional-event-production-and-completion.md) (producer lifecycle, payload and production-census proofs implemented), [ADR-0046 Durable Event Delivery and Recovery](../decisions/0046-durable-event-delivery-and-recovery.md) -(P02b-2 transition and terminal-witness proofs implemented; hosted dispatch -and P02b-3 consumer proofs pending). The +(P02b-2 transition and terminal-witness proofs implemented; Step 3 hosted dispatch +implemented with verification/reviews in progress; Step 4 enforcement and +P02b-3 consumer proofs pending). The catalogue grows as subsequent ADRs and phases land their tests; per-test ownership stays with the originating ADR / standard. @@ -113,7 +114,7 @@ not implemented is the failure mode this column exists to prevent. 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, P02d-4 Steps 1–3, P02b-1 -and P02b-2 Step 2. +and P02b-2 Steps 2–3. 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. @@ -140,8 +141,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. -**187 rules in this catalogue are Implemented, and 115 of them are in that assembly.** -The other 72 are no less binding, and most could not live there. The table says where +**190 rules in this catalogue are Implemented, and 115 of them are in that assembly.** +The other 75 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. @@ -3337,7 +3338,10 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). - **Source:** [15-event-and-outbox.md § Architecture tests](../architecture/15-event-and-outbox.md); Phase 02b deliverable. - **Type:** **integration** test (Testcontainers). **Kind:** behavioural. -- **Status:** **Registered.** +- **Status:** **Implemented** (`OutboxProcessorTests`, `OutboxPublishAttemptTests`): + actual hosted ordinary-failure, retained-scope/cleanup, partition hold, four-task + saturation and finite-shutdown proofs; hostile synchronous construction and + cancellation callbacks retain ownership through completion. - **Phase:** 02b. #### `Outbox_Claim_IsHeld_Until_Dispatch_Completes` @@ -3351,7 +3355,10 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). [15-event-and-outbox.md](../architecture/15-event-and-outbox.md). - **Type:** **integration** test (Testcontainers, two independent instances). **Kind:** behavioural. -- **Status:** **Registered.** +- **Status:** **Implemented** (`OutboxProcessorTests`, `OutboxDispatchStoreTests`): + two actual API workers contend while a live publication is held; committed + leases, repeat reads, expiry/reclaim and stale transitions have real-role + controls. Removing live-lease admission makes the hosted contention proof fail. - **Phase:** 02b. #### `Outbox_Retry_And_Terminal_Transitions_Are_Fenced` @@ -3430,7 +3437,11 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). - **Type:** production architecture census with planted controls where structural; real PostgreSQL/runtime integration proofs where behavioural. **Kind:** structural + behavioural. -- **Status:** **Registered** — no implementation evidence yet. +- **Status:** **Implemented** (`OutboxProcessorTests`, `OutboxDispatchStoreTests`, + `OutboxProductionCompositionTests`): real provisioning rows remain unchanged + across hosted Pending polls; aliases/malformed payloads are held before every + acquisition path. Test-owned Active handlers publish/mark, while explicit + intentional absence acknowledges without construction. - **Phase:** 02b, P02b-2. #### `Outbox_Health_And_Metrics_Are_Bounded` @@ -3446,7 +3457,10 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). - **Type:** production architecture census with planted controls where structural; real PostgreSQL/runtime integration proofs where behavioural. **Kind:** structural + behavioural. -- **Status:** **Registered** — no implementation evidence yet. +- **Status:** **Registered** — Step 3 runtime/observation evidence exists in + `OutboxProcessorTests`, `OutboxObservationTests` and + `OutboxProductionCompositionTests`; full production census/planted controls + remain Step 4. - **Phase:** 02b, P02b-2. ### Demand-gated: lands with its adapter diff --git a/frontend/apps/web/src/server/ingress.test.ts b/frontend/apps/web/src/server/ingress.test.ts index 8ab994bd..064587c9 100644 --- a/frontend/apps/web/src/server/ingress.test.ts +++ b/frontend/apps/web/src/server/ingress.test.ts @@ -204,6 +204,17 @@ describe('private local configuration', () => { ).toThrow('Conflicting public server configuration'); }); + it('loads the API dispatcher credential from the selected file or explicit environment', () => { + writeFileSync(join(directory, '.env'), 'ConnectionStrings__OutboxDispatcher=selected'); + expect(loadLocalEnvironment(directory, {}).ConnectionStrings__OutboxDispatcher).toBe( + 'selected', + ); + expect( + loadLocalEnvironment(directory, { ConnectionStrings__OutboxDispatcher: 'explicit' }) + .ConnectionStrings__OutboxDispatcher, + ).toBe('explicit'); + }); + const configuration = { LEARNSTACK_PUBLIC_API_ORIGIN: 'http://127.0.0.1:5080', LEARNSTACK_PUBLIC_HOP_SECRET: secret, diff --git a/frontend/apps/web/src/server/ingress.ts b/frontend/apps/web/src/server/ingress.ts index 141c685d..4fe577cf 100644 --- a/frontend/apps/web/src/server/ingress.ts +++ b/frontend/apps/web/src/server/ingress.ts @@ -275,7 +275,11 @@ export function loadLocalEnvironment( if (new Set(values).size > 1) throw new Error('Conflicting public server configuration'); if (values[0] !== undefined) result[key] = values[0]; } - for (const key of ['ConnectionStrings__Default', 'ConnectionStrings__PlatformAdmin']) { + for (const key of [ + 'ConnectionStrings__Default', + 'ConnectionStrings__PlatformAdmin', + 'ConnectionStrings__OutboxDispatcher', + ]) { if (environment[key] !== undefined) result[key] = environment[key]; } return result; diff --git a/frontend/apps/web/src/test/demo-lifecycle.test.ts b/frontend/apps/web/src/test/demo-lifecycle.test.ts index b7e68af7..978a4df8 100644 --- a/frontend/apps/web/src/test/demo-lifecycle.test.ts +++ b/frontend/apps/web/src/test/demo-lifecycle.test.ts @@ -92,7 +92,7 @@ describe('shared explicit demo stack selection', () => { expect(result.stderr).not.toContain('foreign'); } }); - it('launches the argument-free Seeder from the documented shell path', () => { + it('launches the argument-free Seeder without selected or ambient dispatcher credentials', () => { const root = temporary(); for (const directory of ['scripts', 'backend', 'bin']) mkdirSync(join(root, directory)); for (const file of ['seed.sh', 'connection-string.awk']) @@ -102,13 +102,13 @@ describe('shared explicit demo stack selection', () => { ); writeFileSync( join(root, '.env'), - 'ConnectionStrings__Migration=Username=learnstack_migration\nConnectionStrings__Default=Username=learnstack_app\n', + 'ConnectionStrings__Migration=Username=learnstack_migration\nConnectionStrings__Default=Username=learnstack_app\nConnectionStrings__OutboxDispatcher=Username=learnstack_outbox_admin\n', ); for (const [name, body] of Object.entries({ docker: '[ "$*" = "compose version" ]', node: '[ "$*" = "scripts/wait-compose.mjs" ]', dotnet: - '[ "$#" = 3 ] && [ "$1" = run ] && [ "$2" = --project ] && [ "$3" = src/LearnStack.Tools.Seeder ] && [ "$PWD" = "$TEST_ROOT/backend" ] && [ "$ConnectionStrings__Default" = Username=learnstack_app ] && printf launched > "$TEST_ROOT/launched"', + '[ "$#" = 3 ] && [ "$1" = run ] && [ "$2" = --project ] && [ "$3" = src/LearnStack.Tools.Seeder ] && [ "$PWD" = "$TEST_ROOT/backend" ] && [ "$ConnectionStrings__Default" = Username=learnstack_app ] && [ -z "${ConnectionStrings__OutboxDispatcher+x}" ] && printf launched > "$TEST_ROOT/launched"', })) writeFileSync(join(root, 'bin', name), `#!/bin/sh\n${body}\n`, { mode: 0o700 }); const launch = () => @@ -118,6 +118,7 @@ describe('shared explicit demo stack selection', () => { PATH: `${join(root, 'bin')}:${process.env.PATH}`, TEST_ROOT: root, NODE_ENV: 'test', + ConnectionStrings__OutboxDispatcher: 'ambient-dispatcher', }, encoding: 'utf8', timeout: 5000, @@ -255,7 +256,10 @@ describe('demo runtime boundaries', () => { it('isolates the Development API user-secret profile within the invocation', () => { const root = temporary(); const env = publicDemoApiEnvironment( - { ConnectionStrings__Default: 'application' }, + { + ConnectionStrings__Default: 'application', + ConnectionStrings__OutboxDispatcher: 'dispatcher', + }, { apiOrigin: 'http://127.0.0.1:5080', secret: 'private' }, root, { @@ -270,6 +274,7 @@ describe('demo runtime boundaries', () => { expect(env.TMPDIR).toBe(realpathSync(root)); expect(env).not.toHaveProperty('DOTNET_CLI_HOME'); expect(env.ConnectionStrings__Default).toBe('application'); + expect(env.ConnectionStrings__OutboxDispatcher).toBe('dispatcher'); expect(env.ASPNETCORE_ENVIRONMENT).toBe('Development'); }); it('gives native web addons an owned cache without ambient credentials or diagnostics', () => { @@ -280,9 +285,15 @@ describe('demo runtime boundaries', () => { LEARNSTACK_PUBLIC_TLS_CERT: '/owned/cert', LEARNSTACK_PUBLIC_TLS_KEY: '/owned/key', ConnectionStrings__Migration: 'owner', + ConnectionStrings__OutboxDispatcher: 'dispatcher', }; expect( - publicWebEnvironment(values, root, { PATH: '/bin', DEBUG: '*', HOME: '/foreign' }), + publicWebEnvironment(values, root, { + PATH: '/bin', + DEBUG: '*', + HOME: '/foreign', + ConnectionStrings__OutboxDispatcher: 'ambient-dispatcher', + }), ).toEqual({ PATH: '/bin', TMPDIR: root, @@ -295,9 +306,12 @@ describe('demo runtime boundaries', () => { ), }); }); - it('passes only request runtime credentials and explicit hop configuration', () => { + it('passes only explicit API credentials and hop configuration', () => { const env = publicApiEnvironment( - { ConnectionStrings__Default: 'application' }, + { + ConnectionStrings__Default: 'application', + ConnectionStrings__OutboxDispatcher: 'dispatcher', + }, { apiOrigin: 'http://127.0.0.1:5080', secret: 'private' }, { PATH: '/bin', @@ -311,12 +325,34 @@ describe('demo runtime boundaries', () => { expect(env).toEqual({ PATH: '/bin', ConnectionStrings__Default: 'application', + ConnectionStrings__OutboxDispatcher: 'dispatcher', ASPNETCORE_ENVIRONMENT: 'Development', ASPNETCORE_URLS: 'http://127.0.0.1:5080', Tenancy__TrustedHop__Networks__0: '127.0.0.1/32', Tenancy__TrustedHop__Secrets__0: 'private', }); }); + it.each(['ConnectionStrings__Default', 'ConnectionStrings__OutboxDispatcher'])( + 'requires explicit %s even when the ambient environment has it', + (missing) => { + const values: Record<string, string> = { + ConnectionStrings__Default: 'application', + ConnectionStrings__OutboxDispatcher: 'dispatcher', + }; + const configuration = { apiOrigin: 'http://127.0.0.1:5080', secret: 'private' }; + const ambient = { ...values }; + delete values[missing]; + expect(() => publicApiEnvironment(values, configuration, {})).toThrow( + 'credential is missing', + ); + expect(() => publicApiEnvironment(values, configuration, ambient)).toThrow( + 'credential is missing', + ); + expect(() => publicDemoApiEnvironment(values, configuration, temporary(), ambient)).toThrow( + 'credential is missing', + ); + }, + ); it('refuses a foreign listener without closing it', async () => { const server = createServer(); await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve)); diff --git a/infra/dapr/README.md b/infra/dapr/README.md index b6a78085..3989016b 100644 --- a/infra/dapr/README.md +++ b/infra/dapr/README.md @@ -28,11 +28,11 @@ Phase ownership (per [phase-02a](../../docs/roadmap/phase-02a-kernel-tenancy.md) familiarity only — no backend code path reaches them, and `backend/Directory.Packages.props` declares no client library for any of the four. -- **Phase 02b** wires the `OutboxProcessor`, which is the only sanctioned - caller of `IEventBus.PublishAsync` per ADR-0010 Amendment 1. Modules - therefore *consume* `ICacheService` and `ISecretProvider` from 02a but - do not *write* to `IEventBus` directly until the outbox path exists in - 02b. +- **P02b-2 Step 3** implements the API-host `OutboxProcessor`, with + verification/reviews in progress. It is the only sanctioned production caller + of `IEventBus.PublishAsync` per ADR-0010 Amendment 1. Modules consume + `ICacheService` and `ISecretProvider` from 02a and enqueue through `IOutbox`; + they never publish through `IEventBus` directly. Service invocation, workflow, bindings, **actors**, configuration, and distributed lock are **not adopted**; if a future need appears the gate is a @@ -138,7 +138,7 @@ YAML metadata field. the interfaces and default adapters shipped in Phase 02a; Dapr-backed adapters belong to Phase 11 (see § Phase ownership above). - Outbox dispatcher (`OutboxProcessor` polling + dispatch) — Phase 02b. -- Per-module `inbox_messages` table + `IInboxGuard` — Phase 02b. +- Shared transport-owned `inbox_messages` table + `IInboxGuard` — P02b-3. - Production Vault setup (HA mode, auto-unseal, AppRole policies) — Phase 11. - Additional sidecars for the Hub overlay (separate app id, separate consumer group) — Phase 02c. diff --git a/scripts/public-runtime.mjs b/scripts/public-runtime.mjs index 69fc7a53..38506cad 100644 --- a/scripts/public-runtime.mjs +++ b/scripts/public-runtime.mjs @@ -1,25 +1,40 @@ -import { realpathSync } from 'node:fs'; -import { join } from 'node:path'; +import { realpathSync } from "node:fs"; +import { join } from "node:path"; -/** Keep privileged setup/worker credentials and arbitrary configuration out of runtime. */ -export function publicApiEnvironment(values, configuration, base = process.env) { +/** Give the API its explicit application/dispatcher credentials and bounded configuration. */ +export function publicApiEnvironment( + values, + configuration, + base = process.env, +) { const environment = {}; - for (const name of ['PATH', 'HOME', 'DOTNET_ROOT', 'TMPDIR', 'APPDATA']) + for (const name of ["PATH", "HOME", "DOTNET_ROOT", "TMPDIR", "APPDATA"]) if (base[name] !== undefined) environment[name] = base[name]; - if (!values.ConnectionStrings__Default) throw new Error('Application credential is missing'); + if (!values.ConnectionStrings__Default) + throw new Error("Application credential is missing"); + if (!values.ConnectionStrings__OutboxDispatcher) + throw new Error("Outbox dispatcher credential is missing"); environment.ConnectionStrings__Default = values.ConnectionStrings__Default; + environment.ConnectionStrings__OutboxDispatcher = + values.ConnectionStrings__OutboxDispatcher; if (values.ConnectionStrings__PlatformAdmin) - environment.ConnectionStrings__PlatformAdmin = values.ConnectionStrings__PlatformAdmin; + environment.ConnectionStrings__PlatformAdmin = + values.ConnectionStrings__PlatformAdmin; return { ...environment, - ASPNETCORE_ENVIRONMENT: 'Development', + ASPNETCORE_ENVIRONMENT: "Development", ASPNETCORE_URLS: configuration.apiOrigin, - Tenancy__TrustedHop__Networks__0: '127.0.0.1/32', + Tenancy__TrustedHop__Networks__0: "127.0.0.1/32", Tenancy__TrustedHop__Secrets__0: configuration.secret, }; } /** Development configuration must not consult the workstation's user secrets. */ -export function publicDemoApiEnvironment(values, configuration, privateRoot, base = process.env) { +export function publicDemoApiEnvironment( + values, + configuration, + privateRoot, + base = process.env, +) { const profile = realpathSync(privateRoot); return publicApiEnvironment(values, configuration, { ...base, @@ -34,16 +49,19 @@ export function publicWebEnvironment(values, privateRoot, base = process.env) { const environment = { PATH: base.PATH, TMPDIR: privateRoot, - SWC_NATIVE_BINDING_CACHE: join(realpathSync(privateRoot), 'swc-native-cache'), - NODE_ENV: 'production', - NEXT_TELEMETRY_DISABLED: '1', - NODE_TLS_REJECT_UNAUTHORIZED: '1', + SWC_NATIVE_BINDING_CACHE: join( + realpathSync(privateRoot), + "swc-native-cache", + ), + NODE_ENV: "production", + NEXT_TELEMETRY_DISABLED: "1", + NODE_TLS_REJECT_UNAUTHORIZED: "1", }; for (const key of [ - 'LEARNSTACK_PUBLIC_API_ORIGIN', - 'LEARNSTACK_PUBLIC_HOP_SECRET', - 'LEARNSTACK_PUBLIC_TLS_CERT', - 'LEARNSTACK_PUBLIC_TLS_KEY', + "LEARNSTACK_PUBLIC_API_ORIGIN", + "LEARNSTACK_PUBLIC_HOP_SECRET", + "LEARNSTACK_PUBLIC_TLS_CERT", + "LEARNSTACK_PUBLIC_TLS_KEY", ]) environment[key] = values[key]; return environment; diff --git a/scripts/seed.sh b/scripts/seed.sh index 60b892fd..3687503d 100755 --- a/scripts/seed.sh +++ b/scripts/seed.sh @@ -126,7 +126,8 @@ fi # Run from `backend/`, where the SDK pin lives (`backend/global.json`): from the # repository root no global.json applies and whichever SDK is newest answers, which is # not the one CI and `make migrate` use. -if ! (cd backend && ConnectionStrings__Default="$seed_cs" \ +# The dispatcher belongs to the API host, even when exported by the caller. +if ! (cd backend && unset ConnectionStrings__OutboxDispatcher && ConnectionStrings__Default="$seed_cs" \ dotnet run --project src/LearnStack.Tools.Seeder); then red "seed: tenant seeding failed." red " Has the schema been applied? → make migrate" From da637e357ea73df41a814d1b11ab6a5c412b41a4 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 22:41:14 +0300 Subject: [PATCH 77/92] fix(dev): confine dispatcher credentials to the API host Keep local demo database validation inside the launcher instead of passing bypass credentials to a Seeder validation process. Share an explicit application-only child environment across manifest, demo seed and standalone seed paths, including ambient alias controls. ADR: 0046 --- .../DemoDatabaseConfiguration.cs | 32 ---- .../src/LearnStack.Tools.Seeder/Program.cs | 9 -- .../DemoManifestTests.cs | 50 ------- docs/roadmap/phase-02b-events-auth.md | 31 +++- .../apps/web/src/test/demo-lifecycle.test.ts | 140 +++++++++++++++++- scripts/demo-database.d.mts | 5 + scripts/demo-database.mjs | 101 +++++++++++++ scripts/demo.mjs | 15 +- scripts/seed.sh | 16 +- 9 files changed, 285 insertions(+), 114 deletions(-) delete mode 100644 backend/src/LearnStack.Tools.Seeder/DemoDatabaseConfiguration.cs create mode 100644 scripts/demo-database.d.mts create mode 100644 scripts/demo-database.mjs diff --git a/backend/src/LearnStack.Tools.Seeder/DemoDatabaseConfiguration.cs b/backend/src/LearnStack.Tools.Seeder/DemoDatabaseConfiguration.cs deleted file mode 100644 index 9d280687..00000000 --- a/backend/src/LearnStack.Tools.Seeder/DemoDatabaseConfiguration.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Npgsql; - -namespace LearnStack.Tools.Seeder; - -/// <summary>Rejects stale local credentials before infrastructure or migrations run.</summary> -public static class DemoDatabaseConfiguration -{ - public static bool IsValid(IReadOnlyDictionary<string, string> values) - { - try - { - foreach (var (key, role, password) in new[] - { - ("Migration", "learnstack_migration", "LEARNSTACK_MIGRATION_PW"), - ("Default", "learnstack_app", "LEARNSTACK_APP_PW"), - ("PlatformAdmin", "learnstack_platform", "LEARNSTACK_PLATFORM_PW"), - ("OutboxDispatcher", "learnstack_outbox_admin", "LEARNSTACK_OUTBOX_PW"), - }) - { - var builder = new NpgsqlConnectionStringBuilder(values[$"ConnectionStrings__{key}"]); - if (builder.Username != role || builder.Password != values[password] - || builder.Database != values["POSTGRES_DB"] || builder.Port != 5432 - || builder.Host is not ("localhost" or "127.0.0.1")) return false; - } - return true; - } - catch (Exception failure) when (failure is ArgumentException or KeyNotFoundException) - { - return false; - } - } -} diff --git a/backend/src/LearnStack.Tools.Seeder/Program.cs b/backend/src/LearnStack.Tools.Seeder/Program.cs index 2fe700e6..2ecae1cb 100644 --- a/backend/src/LearnStack.Tools.Seeder/Program.cs +++ b/backend/src/LearnStack.Tools.Seeder/Program.cs @@ -15,15 +15,6 @@ Console.WriteLine(JsonSerializer.Serialize(DemoManifest.Create(), JsonSerializerOptions.Web)); return 0; } -if (args is ["--validate-demo-config"]) -{ - // Configuration arrives via the environment, never credential-bearing argv. - var values = Environment.GetEnvironmentVariables().Cast<System.Collections.DictionaryEntry>() - .ToDictionary(row => (string)row.Key, row => (string)row.Value!, StringComparer.Ordinal); - if (DemoDatabaseConfiguration.IsValid(values)) return 0; - Console.Error.WriteLine("demo: database configuration disagrees with the selected local stack."); - return 2; -} if (args.Length != 0) { Console.Error.WriteLine("seed: unsupported arguments."); diff --git a/backend/tests/LearnStack.Tests.Integration/DemoManifestTests.cs b/backend/tests/LearnStack.Tests.Integration/DemoManifestTests.cs index 33902e19..4a3886ee 100644 --- a/backend/tests/LearnStack.Tests.Integration/DemoManifestTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/DemoManifestTests.cs @@ -1,7 +1,6 @@ using System.Text.Json; using FluentAssertions; using LearnStack.Tools.Seeder; -using Npgsql; using Xunit; namespace LearnStack.Tests.Integration; @@ -60,53 +59,4 @@ public void Demo_manifest_covers_every_enabled_seed_lane_and_nine_distinct_real_ manifest.Select(row => row.Fields.Count).Distinct().Should().HaveCount(2, "the browser proof must observe two genuinely distinct authored shapes"); } - - [Theory] - [InlineData("Default", "Username=learnstack_platform")] - [InlineData("Migration", "Username=learnstack_app")] - [InlineData("Default", "Host=remote.example")] - [InlineData("Default", "Port=5433")] - [InlineData("Default", "Database=other")] - [InlineData("Default", "Password=stale")] - public void Demo_configuration_refuses_stale_or_privileged_connection_values(string key, string replacement) - { - var values = Configuration(); - DemoDatabaseConfiguration.IsValid(values).Should().BeTrue(); - values[$"ConnectionStrings__{key}"] += ";" + replacement; - DemoDatabaseConfiguration.IsValid(values).Should().BeFalse(); - } - - [Fact] - public void Demo_configuration_refuses_missing_and_malformed_values() - { - DemoDatabaseConfiguration.IsValid(new Dictionary<string, string>()).Should().BeFalse(); - var values = Configuration(); - values["ConnectionStrings__Default"] = "not a connection string"; - DemoDatabaseConfiguration.IsValid(values).Should().BeFalse(); - } - - private static Dictionary<string, string> Configuration() - { - var values = new Dictionary<string, string>(StringComparer.Ordinal) { ["POSTGRES_DB"] = "demo" }; - foreach (var (suffix, role, key) in new[] - { - ("Migration", "learnstack_migration", "LEARNSTACK_MIGRATION_PW"), - ("Default", "learnstack_app", "LEARNSTACK_APP_PW"), - ("PlatformAdmin", "learnstack_platform", "LEARNSTACK_PLATFORM_PW"), - ("OutboxDispatcher", "learnstack_outbox_admin", "LEARNSTACK_OUTBOX_PW"), - }) - { - // Ephemeral parser inputs only: no service uses these credentials. - var password = Guid.NewGuid().ToString("N"); - values[key] = password; - values[$"ConnectionStrings__{suffix}"] = new NpgsqlConnectionStringBuilder - { - Host = "localhost", - Database = "demo", - Username = role, - Password = password, - }.ConnectionString; - } - return values; - } } diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 4363bcb1..b9413329 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -1615,8 +1615,8 @@ startup resolution/connectivity, bounded publication lifecycle and retained overrun ownership. Observation supplies immutable gauge snapshots, bounded producer diagnostics and the registered, unmapped `outbox` health check. `/healthz` remains liveness-only. Paired API launchers supply application and -confined dispatcher credentials; Seeder strips the dispatcher credential and -web receives neither database credential. +confined dispatcher credentials; every Seeder invocation uses an explicit +application-only environment, and web receives neither database credential. **Verification before review:** the Release solution build reports zero warnings/errors. TRX counters record zero failures/skips in the complete unit @@ -1649,14 +1649,37 @@ Two behavioural mutations fail in the intended executed test: removing local partition admission permits a sibling claim before retained cleanup completes; removing live-lease admission permits a competing worker to claim the held row. Both source files are byte-restored before the clean build/unit/worker rerun. -Both independent review rounds remain pending; neither has started. -Steps 1–2 completed both rounds in their +Step 3 round 1 is complete; round 2 follows the verified launcher correction +below. Steps 1–2 completed both rounds in their records above. Step 4 owns full credential/publication censuses, catalogue enforcement and packet closeout. P02b-3 still owns durable acceptance, shared inbox/ledger, independent subscriber retries, both real default subscriptions and Seeder handoff. Real required subscriptions remain Pending; no consumer business effect is claimed. +#### Step 3 review round 1 + +Fresh read-only GPT-6-astra xhigh security/lifecycle and GPT-6.1-sol xhigh +proof/corpus reviewers examined `d4c06a46..756c8523`. Security approved without +verified findings. The proof reviewer found that the demo's pre-existing +`--validate-demo-config` invocation forwarded all four credentials to Seeder, +contradicting the new confinement claim. Validation now runs inside the launcher +with a closed local connection grammar; the obsolete Seeder mode is removed. +Manifest and convergence modes share the same explicit child allowlist, and +standalone `make seed` uses it too, excluding ambient aliases and role passwords. +Stale role/password/database/host/port and malformed configuration controls move +with their owner instead of weakening the check. The complete Docker integration +regression at the implementation commit passes 1,083 cases with zero failures or +skips. This supplements the focused pre-review receipt above; final packet +verification will rerun the changed tooling and actual PR-head CI. +The correction's Release build has zero warnings/errors; the Docker-free +integration run passes 175 cases after seven obsolete parser cases moved to the +launcher suite. The guarded complete frontend run passes 1,608 cases (1,548 web +and 60 SDK), with zero skips/todos; 91 demo lifecycle cases include all four-role +configuration and actual shell/child-environment controls. Typecheck, affected +formatting, script syntax, diff whitespace and the delivery-record link check +pass. These are correction receipts, not a new full-Docker rerun. + ### Durable outbox dispatch P02b-1 Steps 2/3 implement `IOutbox`, replace Packet 3's step-7 shell with diff --git a/frontend/apps/web/src/test/demo-lifecycle.test.ts b/frontend/apps/web/src/test/demo-lifecycle.test.ts index 978a4df8..be23c476 100644 --- a/frontend/apps/web/src/test/demo-lifecycle.test.ts +++ b/frontend/apps/web/src/test/demo-lifecycle.test.ts @@ -1,5 +1,6 @@ // @vitest-environment node import { execFileSync, spawnSync } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; import { once } from 'node:events'; import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; import { createServer as createHttpsServer } from 'node:https'; @@ -10,6 +11,10 @@ import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { composeSelection } from '../../../../../scripts/compose.mjs'; +import { + publicSeederEnvironment, + validateDemoDatabaseConfiguration, +} from '../../../../../scripts/demo-database.mjs'; import { boundedBody, demoRequest, vacant } from '../../../../../scripts/demo-http.mjs'; import { apiCompletionObserver, @@ -45,6 +50,126 @@ function owner(signal = new AbortController().signal) { return processes; } +describe('launcher-owned local database validation', () => { + const roles = [ + ['Migration', 'learnstack_migration', 'LEARNSTACK_MIGRATION_PW'], + ['Default', 'learnstack_app', 'LEARNSTACK_APP_PW'], + ['PlatformAdmin', 'learnstack_platform', 'LEARNSTACK_PLATFORM_PW'], + ['OutboxDispatcher', 'learnstack_outbox_admin', 'LEARNSTACK_OUTBOX_PW'], + ] as const; + const databasePassword = randomBytes(16).toString('hex'); + function databaseValues(password = databasePassword) { + const values: Record<string, string> = { POSTGRES_DB: 'learnstack' }; + for (const [suffix, role, passwordKey] of roles) { + values[passwordKey] = password; + values[`ConnectionStrings__${suffix}`] = + `Host=localhost;Port=5432;Database=learnstack;Username=${role};Password="${password.replaceAll('"', '""')}"`; + } + return values; + } + it.each(['simple', 'semi;colon', ' leading and trailing ', 'a"double"quote', "a'single'quote"])( + 'accepts all four local roles with quoted password %s', + (password) => { + expect(() => validateDemoDatabaseConfiguration(databaseValues(password))).not.toThrow(); + }, + ); + it('accepts supported key aliases, single quoting, loopback and the default port', () => { + const values = databaseValues("semi;single'quote"); + for (const [suffix, role] of roles) + values[`ConnectionStrings__${suffix}`] = + ` Server = 127.0.0.1 ; Database = learnstack ; User ID = ${role} ; Pwd = 'semi;single''quote' ;`; + expect(() => validateDemoDatabaseConfiguration(values)).not.toThrow(); + }); + it.each( + roles.flatMap(([suffix]) => + [ + ['role', (value: string) => value.replace(/Username=[^;]+/, 'Username=foreign')], + ['password', (value: string) => value.replace(/Password=.*/, 'Password=foreign')], + ['database', (value: string) => value.replace('Database=learnstack', 'Database=foreign')], + ['host', (value: string) => value.replace('Host=localhost', 'Host=foreign')], + ['port', (value: string) => value.replace('Port=5432', 'Port=5433')], + ['malformed', (value: string) => value + ';Password="unclosed'], + ].map(([field, mutate]) => ({ suffix, field, mutate })), + ), + )('refuses $suffix $field without exposing configuration', ({ suffix, mutate }) => { + const values = databaseValues(); + const key = `ConnectionStrings__${suffix}`; + if (typeof mutate !== 'function') throw new Error('Missing mutation'); + values[key] = mutate(values[key] ?? ''); + expect(() => validateDemoDatabaseConfiguration(values)).toThrowError( + /^Local demo database configuration is invalid$/, + ); + }); + const applicationConnection = databaseValues().ConnectionStrings__Default!; + it.each([ + applicationConnection.replace(/Password=.*/, 'Password="unclosed'), + applicationConnection.replace(/Password=.*/, 'Password="closed"junk'), + applicationConnection.replace(/Password=.*/, 'Password=plain"quote'), + applicationConnection + ';UID=learnstack_platform', + applicationConnection + ';Unknown=value', + applicationConnection.replace('Host', 'H o s t'), + applicationConnection.replace('Port=5432', 'Port=nope'), + applicationConnection.replace('Port=5432', 'Port=5432.0'), + applicationConnection.replace(/;Password=.*/, ''), + applicationConnection.replace(';Username=learnstack_app', ''), + applicationConnection.replace(';Database=learnstack', ''), + applicationConnection.replace(/^[^;]+;/, ''), + applicationConnection.replace(/Password=.*/, 'Password='), + ['postgres://learnstack_app', databasePassword + '@localhost/learnstack'].join(':'), + '', + ])('refuses unsupported or malformed local connection syntax %#', (connection) => { + const values = databaseValues(); + values.ConnectionStrings__Default = connection; + expect(() => validateDemoDatabaseConfiguration(values)).toThrowError( + /^Local demo database configuration is invalid$/, + ); + }); + it.each([ + ...roles.map(([suffix]) => `ConnectionStrings__${suffix}`), + ...roles.map(([, , key]) => key), + 'POSTGRES_DB', + ])('requires selected configuration %s', (missing) => { + const values = databaseValues(); + delete values[missing]; + expect(() => validateDemoDatabaseConfiguration(values)).toThrowError( + /^Local demo database configuration is invalid$/, + ); + }); + it('keeps every Seeder mode free of inherited credentials and diagnostics', () => { + const base = { + PATH: '/bin', + HOME: '/home', + DOTNET_ROOT: '/dotnet', + TMPDIR: '/tmp', + APPDATA: '/profile', + ConnectionStrings__Default: 'ambient-application', + ConnectionStrings__Migration: 'owner', + ConnectionStrings__OutboxDispatcher: 'dispatcher', + CONNECTIONSTRINGS__OUTBOXDISPATCHER: 'uppercase-dispatcher', + 'ConnectionStrings:OutboxDispatcher': 'colon-dispatcher', + LEARNSTACK_OUTBOX_PW: 'dispatcher-password', + DEBUG: '*', + }; + const prerequisites = { + PATH: '/bin', + HOME: '/home', + DOTNET_ROOT: '/dotnet', + TMPDIR: '/tmp', + APPDATA: '/profile', + }; + expect(publicSeederEnvironment(undefined, base)).toEqual(prerequisites); + expect(publicSeederEnvironment('selected-application', base)).toEqual({ + ...prerequisites, + ConnectionStrings__Default: 'selected-application', + }); + const source = readFileSync( + new URL('../../../../../scripts/demo.mjs', import.meta.url), + 'utf8', + ); + expect(source).not.toContain('--validate-demo-config'); + }); +}); + describe('shared explicit demo stack selection', () => { it.each([ 'COMPOSE_PROFILES=gated', @@ -95,7 +220,7 @@ describe('shared explicit demo stack selection', () => { it('launches the argument-free Seeder without selected or ambient dispatcher credentials', () => { const root = temporary(); for (const directory of ['scripts', 'backend', 'bin']) mkdirSync(join(root, directory)); - for (const file of ['seed.sh', 'connection-string.awk']) + for (const file of ['seed.sh', 'connection-string.awk', 'demo-database.mjs']) writeFileSync( join(root, 'scripts', file), readFileSync(new URL(`../../../../../scripts/${file}`, import.meta.url)), @@ -106,9 +231,9 @@ describe('shared explicit demo stack selection', () => { ); for (const [name, body] of Object.entries({ docker: '[ "$*" = "compose version" ]', - node: '[ "$*" = "scripts/wait-compose.mjs" ]', + node: 'if [ "$*" = "scripts/wait-compose.mjs" ]; then exit 0; fi\nexec "$REAL_NODE" "$@"', dotnet: - '[ "$#" = 3 ] && [ "$1" = run ] && [ "$2" = --project ] && [ "$3" = src/LearnStack.Tools.Seeder ] && [ "$PWD" = "$TEST_ROOT/backend" ] && [ "$ConnectionStrings__Default" = Username=learnstack_app ] && [ -z "${ConnectionStrings__OutboxDispatcher+x}" ] && printf launched > "$TEST_ROOT/launched"', + '[ "$#" = 3 ] && [ "$1" = run ] && [ "$2" = --project ] && [ "$3" = src/LearnStack.Tools.Seeder ] && [ "$(basename "$PWD")" = backend ] && [ "$ConnectionStrings__Default" = Username=learnstack_app ] && [ "$HOME" = /seed-home ] && [ -n "$PATH" ] && [ -z "${ConnectionStrings__OutboxDispatcher+x}" ] && [ -z "${CONNECTIONSTRINGS__OUTBOXDISPATCHER+x}" ] && [ -z "${LEARNSTACK_OUTBOX_PW+x}" ] && ! env | /usr/bin/grep -q "^ConnectionStrings:OutboxDispatcher=" && printf launched > ../launched', })) writeFileSync(join(root, 'bin', name), `#!/bin/sh\n${body}\n`, { mode: 0o700 }); const launch = () => @@ -117,8 +242,13 @@ describe('shared explicit demo stack selection', () => { env: { PATH: `${join(root, 'bin')}:${process.env.PATH}`, TEST_ROOT: root, + REAL_NODE: process.execPath, + HOME: '/seed-home', NODE_ENV: 'test', ConnectionStrings__OutboxDispatcher: 'ambient-dispatcher', + CONNECTIONSTRINGS__OUTBOXDISPATCHER: 'ambient-alias', + 'ConnectionStrings:OutboxDispatcher': 'ambient-colon-alias', + LEARNSTACK_OUTBOX_PW: 'ambient-password', }, encoding: 'utf8', timeout: 5000, @@ -129,8 +259,8 @@ describe('shared explicit demo stack selection', () => { writeFileSync( script, readFileSync(script, 'utf8').replace( - 'dotnet run --project src/LearnStack.Tools.Seeder', - 'dotnet run --project src/LearnStack.Tools.Seeder --nologo', + "['run', '--project', 'src/LearnStack.Tools.Seeder']", + "['run', '--project', 'src/LearnStack.Tools.Seeder', '--nologo']", ), ); const refused = launch(); diff --git a/scripts/demo-database.d.mts b/scripts/demo-database.d.mts new file mode 100644 index 00000000..e8b62bbf --- /dev/null +++ b/scripts/demo-database.d.mts @@ -0,0 +1,5 @@ +export function validateDemoDatabaseConfiguration(values: Record<string, string>): void; +export function publicSeederEnvironment( + applicationConnectionString?: string, + base?: Partial<NodeJS.ProcessEnv>, +): NodeJS.ProcessEnv; diff --git a/scripts/demo-database.mjs b/scripts/demo-database.mjs new file mode 100644 index 00000000..cfb225c7 --- /dev/null +++ b/scripts/demo-database.mjs @@ -0,0 +1,101 @@ +const invalidConfiguration = () => new Error('Local demo database configuration is invalid'); +const aliases = new Map([ + ['host', 'host'], + ['server', 'host'], + ['port', 'port'], + ['database', 'database'], + ['username', 'username'], + ['user name', 'username'], + ['userid', 'username'], + ['user id', 'username'], + ['uid', 'username'], + ['password', 'password'], + ['pwd', 'password'], + ['psw', 'password'], +]); + +// The local demo accepts only these five fields, not the general Npgsql grammar. +// Quoted values preserve semicolons/whitespace and use doubled quote escaping. +function localConnectionFields(source) { + if (typeof source !== 'string' || /[\u0000-\u0008\u000a-\u001f\u007f]/.test(source)) + throw invalidConfiguration(); + const fields = new Map(); + let index = 0; + while (index < source.length) { + while (/[; \t]/.test(source[index] ?? '') && index < source.length) index++; + if (index === source.length) break; + const start = index; + while (index < source.length && source[index] !== '=') { + if (!/[a-z \t]/i.test(source[index])) throw invalidConfiguration(); + index++; + } + if (index === source.length) throw invalidConfiguration(); + const key = aliases.get(source.slice(start, index).trim().toLowerCase()); + if (!key || fields.has(key)) throw invalidConfiguration(); + index++; + while (index < source.length && /[ \t]/.test(source[index])) index++; + let value = ''; + const quote = source[index]; + if (quote === '"' || quote === "'") { + index++; + let closed = false; + while (index < source.length) { + const character = source[index++]; + if (character !== quote) value += character; + else if (source[index] === quote) { + value += quote; + index++; + } else { + closed = true; + break; + } + } + if (!closed) throw invalidConfiguration(); + while (index < source.length && /[ \t]/.test(source[index])) index++; + if (index < source.length && source[index] !== ';') throw invalidConfiguration(); + } else { + const start = index; + while (index < source.length && source[index] !== ';') { + if (source[index] === '"' || source[index] === "'") throw invalidConfiguration(); + index++; + } + value = source.slice(start, index).trim(); + } + if (!value) throw invalidConfiguration(); + fields.set(key, value); + } + return fields; +} + +/** Reject stale local credentials in the launcher without giving them to a child process. */ +export function validateDemoDatabaseConfiguration(values) { + if (typeof values.POSTGRES_DB !== 'string' || !values.POSTGRES_DB) throw invalidConfiguration(); + for (const [suffix, role, passwordKey] of [ + ['Migration', 'learnstack_migration', 'LEARNSTACK_MIGRATION_PW'], + ['Default', 'learnstack_app', 'LEARNSTACK_APP_PW'], + ['PlatformAdmin', 'learnstack_platform', 'LEARNSTACK_PLATFORM_PW'], + ['OutboxDispatcher', 'learnstack_outbox_admin', 'LEARNSTACK_OUTBOX_PW'], + ]) { + const fields = localConnectionFields(values[`ConnectionStrings__${suffix}`]); + const port = fields.get('port') ?? '5432'; + if ( + fields.get('username') !== role || + fields.get('password') !== values[passwordKey] || + fields.get('database') !== values.POSTGRES_DB || + !['localhost', '127.0.0.1'].includes(fields.get('host')) || + !/^\d+$/.test(port) || + Number(port) !== 5432 + ) + throw invalidConfiguration(); + } +} + +/** Every Seeder mode receives only its own credential and process prerequisites. */ +export function publicSeederEnvironment(applicationConnectionString, base = process.env) { + const environment = {}; + for (const key of ['PATH', 'HOME', 'DOTNET_ROOT', 'TMPDIR', 'APPDATA']) + if (base[key] !== undefined) environment[key] = base[key]; + if (applicationConnectionString !== undefined) + environment.ConnectionStrings__Default = applicationConnectionString; + return environment; +} diff --git a/scripts/demo.mjs b/scripts/demo.mjs index 3b59ae2b..b6941634 100644 --- a/scripts/demo.mjs +++ b/scripts/demo.mjs @@ -20,6 +20,7 @@ import { demoRequest, vacant } from './demo-http.mjs'; import { defaultServices, waitCompose } from './wait-compose.mjs'; import { publicDemoApiEnvironment, publicWebEnvironment } from './public-runtime.mjs'; import { apiCompletionObserver } from './demo-observation.mjs'; +import { publicSeederEnvironment, validateDemoDatabaseConfiguration } from './demo-database.mjs'; const appRoot = join(repositoryRoot, 'frontend/apps/web'); const backendRoot = join(repositoryRoot, 'backend'); @@ -242,6 +243,7 @@ export async function startDemo(mode) { await run('dotnet', [seederDll, '--demo-manifest'], 'Seed-derived manifest', { capture: true, cwd: backendRoot, + env: publicSeederEnvironment(), }), ); const hosts = [...new Set(manifest.map((lane) => lane.host))]; @@ -313,12 +315,7 @@ export async function startDemo(mode) { const values = loadLocalEnvironment(repositoryRoot, process.env); const configuration = publicServerConfiguration(values, repositoryRoot); await vacant(Number(new URL(configuration.apiOrigin).port || '80')); - await run( - 'dotnet', - [seederDll, '--validate-demo-config'], - 'Local database configuration validation', - { cwd: backendRoot, env: { ...process.env, ...rawValues } }, - ); + validateDemoDatabaseConfiguration(rawValues); const certificate = readFileSync(configuration.certificate); const leaf = validateCertificate(certificate, readFileSync(configuration.privateKey), [ ...hosts, @@ -391,11 +388,7 @@ export async function startDemo(mode) { await waitCompose(selected, processes, controller.signal); await run('dotnet', [seederDll], 'Contextual seed convergence', { cwd: backendRoot, - env: { - PATH: process.env.PATH, - HOME: process.env.HOME, - ConnectionStrings__Default: rawValues.ConnectionStrings__Default, - }, + env: publicSeederEnvironment(rawValues.ConnectionStrings__Default), timeout: 180000, }); if (mode === 'ci') { diff --git a/scripts/seed.sh b/scripts/seed.sh index 3687503d..d2ca7150 100755 --- a/scripts/seed.sh +++ b/scripts/seed.sh @@ -126,9 +126,19 @@ fi # Run from `backend/`, where the SDK pin lives (`backend/global.json`): from the # repository root no global.json applies and whichever SDK is newest answers, which is # not the one CI and `make migrate` use. -# The dispatcher belongs to the API host, even when exported by the caller. -if ! (cd backend && unset ConnectionStrings__OutboxDispatcher && ConnectionStrings__Default="$seed_cs" \ - dotnet run --project src/LearnStack.Tools.Seeder); then +# The API's dispatcher credential and its aliases never enter the Seeder process. +# Node is already required above and shares the demo's exact child allowlist. +if ! ConnectionStrings__Default="$seed_cs" node --input-type=module <<'NODE' +import { spawnSync } from 'node:child_process'; +import { publicSeederEnvironment } from './scripts/demo-database.mjs'; +const result = spawnSync('dotnet', ['run', '--project', 'src/LearnStack.Tools.Seeder'], { + cwd: 'backend', + env: publicSeederEnvironment(process.env.ConnectionStrings__Default), + stdio: 'inherit', +}); +process.exit(result.status ?? 1); +NODE +then red "seed: tenant seeding failed." red " Has the schema been applied? → make migrate" exit 1 From 02540d058124be4fce4638aa547d57ea37a523fc Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 22:49:47 +0300 Subject: [PATCH 78/92] fix(kernel): clarify dispatcher boot refusal and close step 3 Keep startup diagnostics useful without disclosing supplied credentials. Record both independent reviews and their verified corrections before starting the final enforcement step. ADR: 0046 --- .../Messaging/OutboxProcessor.cs | 4 +++- .../Database/OutboxProcessorTests.cs | 3 +++ docs/roadmap/phase-02b-events-auth.md | 22 ++++++++++++++++--- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/backend/src/LearnStack.Api/Messaging/OutboxProcessor.cs b/backend/src/LearnStack.Api/Messaging/OutboxProcessor.cs index 44ccf8ed..70243843 100644 --- a/backend/src/LearnStack.Api/Messaging/OutboxProcessor.cs +++ b/backend/src/LearnStack.Api/Messaging/OutboxProcessor.cs @@ -63,7 +63,9 @@ public override async Task StartAsync(CancellationToken cancellationToken) catch (Exception) { // No connection data, provider exception or inner exception escapes boot. - throw new InvalidOperationException("Outbox dispatcher startup was refused."); + throw new InvalidOperationException("Outbox dispatcher startup was refused. " + + "Configure ConnectionStrings:OutboxDispatcher in Npgsql key/value form " + + "for learnstack_outbox_admin and verify database connectivity."); } _observation.Started(); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/OutboxProcessorTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/OutboxProcessorTests.cs index a0c1d43b..ce58a5ee 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/OutboxProcessorTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/OutboxProcessorTests.cs @@ -149,6 +149,9 @@ public async Task Actual_host_startup_rejects_invalid_credentials_without_provid failure.Should().NotBeNull(); failure.Should().BeAssignableTo<InvalidOperationException>(); failure!.ToString().Should().NotContain(SensitiveCanary).And.NotContain(credential == string.Empty ? "Password=" : credential); + if (invalid == "missing") + failure.Message.Should().Contain("ConnectionStrings:OutboxDispatcher") + .And.Contain("Npgsql key/value").And.Contain("learnstack_outbox_admin"); test.Logs.Text.Should().NotContain(SensitiveCanary); } diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index b9413329..4bc98e1b 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -1609,7 +1609,7 @@ or inbox effect is claimed. ### P02b-2 Step 3 — Hosted lifecycle and observability (2026-10-12) -**Implemented; verification and independent reviews in progress.** The API owns +**Complete; both independent review rounds passed.** The API owns its mandatory `OutboxProcessor` BackgroundService, guarded dispatcher-source startup resolution/connectivity, bounded publication lifecycle and retained overrun ownership. Observation supplies immutable gauge snapshots, bounded @@ -1649,8 +1649,8 @@ Two behavioural mutations fail in the intended executed test: removing local partition admission permits a sibling claim before retained cleanup completes; removing live-lease admission permits a competing worker to claim the held row. Both source files are byte-restored before the clean build/unit/worker rerun. -Step 3 round 1 is complete; round 2 follows the verified launcher correction -below. Steps 1–2 completed both rounds in their +Step 3 completed both independent rounds and the verified corrections below. +Steps 1–2 completed both rounds in their records above. Step 4 owns full credential/publication censuses, catalogue enforcement and packet closeout. P02b-3 still owns durable acceptance, shared inbox/ledger, independent subscriber retries, both real default subscriptions and @@ -1680,6 +1680,22 @@ configuration and actual shell/child-environment controls. Typecheck, affected formatting, script syntax, diff whitespace and the delivery-record link check pass. These are correction receipts, not a new full-Docker rerun. +#### Step 3 review round 2 + +Fresh read-only GPT-6.1-sol xhigh security/lifecycle and GPT-6-astra xhigh +proof/corpus reviewers examined `d4c06a46..da637e35`. Neither found a Blocker or +Major. Security identified one operability gap: the safe startup refusal omitted +the configuration key/form required by the accepted completion criterion. The +fixed message now names `ConnectionStrings:OutboxDispatcher`, Npgsql key/value +syntax and `learnstack_outbox_admin`, without provider or supplied-value text. +The real-host negative theory asserts that guidance for missing configuration; +all four credential cases pass after the correction. Independent review reruns +passed 91 demo lifecycle cases, 11 focused outbox unit cases and two construction +host cases; the security reviewer separately passed nine helper/observation cases. +Both inspected the prior receipts and production paths. They did not claim a new +full build or Docker rerun. Step 3 is closed; Step 4 may add the complete +credential/publication/health censuses and close the packet. + ### Durable outbox dispatch P02b-1 Steps 2/3 implement `IOutbox`, replace Packet 3's step-7 shell with From d2531f283dd16df1d265e140e4945fff8e834149 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 23:14:33 +0300 Subject: [PATCH 79/92] test(kernel): enforce confined outbox dispatch boundaries Keep dispatcher credentials, publication and status SQL confined to nonempty production subjects with dirty and clean controls. Reconcile delivery documentation without claiming pending consumer work or CI. ADR: 0046, 0057 Module: Tenancy, Audit --- .claude/skills/add-architecture-test/SKILL.md | 3 +- .claude/skills/add-integration-test/SKILL.md | 2 +- .claude/skills/local-dev-setup/SKILL.md | 9 +- CLAUDE.md | 10 +- README.md | 24 +- .../CrossCuttingFoundationTests.cs | 2 + .../OutboxDispatchBoundaryTests.cs | 633 ++++++++++++++++++ .../OutboxDispatchConventionTests.cs | 2 +- .../OutboxDispatchObservationTests.cs | 340 ++++++++++ .../architecture/04-technical-architecture.md | 2 +- docs/architecture/09-tenant-isolation.md | 2 +- docs/architecture/15-event-and-outbox.md | 27 +- docs/architecture/31-audit-subsystem.md | 6 +- ...046-durable-event-delivery-and-recovery.md | 26 + docs/decisions/README.md | 2 +- docs/glossary.md | 2 +- docs/modules/tenancy/README.md | 4 +- docs/modules/tenancy/audit.md | 4 +- docs/modules/tenancy/permissions.md | 8 +- docs/roadmap/README.md | 6 +- docs/roadmap/phase-02b-events-auth.md | 95 ++- docs/standards/02-backend-coding.md | 2 +- docs/standards/05-database.md | 9 +- docs/standards/09-error-handling.md | 5 +- docs/standards/10-observability.md | 4 +- docs/standards/11-security.md | 5 +- docs/standards/20-infrastructure-stack.md | 6 +- .../21-architecture-tests-catalogue.md | 56 +- infra/dapr/README.md | 2 +- 29 files changed, 1192 insertions(+), 106 deletions(-) create mode 100644 backend/tests/LearnStack.Tests.Architecture/OutboxDispatchBoundaryTests.cs create mode 100644 backend/tests/LearnStack.Tests.Architecture/OutboxDispatchObservationTests.cs diff --git a/.claude/skills/add-architecture-test/SKILL.md b/.claude/skills/add-architecture-test/SKILL.md index 5ca3049e..5e637542 100644 --- a/.claude/skills/add-architecture-test/SKILL.md +++ b/.claude/skills/add-architecture-test/SKILL.md @@ -129,11 +129,12 @@ Add yours to the file whose subject it shares: | `ApiConventionTests.cs` | Live majors, forwarded headers, required `Deployment:Mode`, unversioned route prefixes. | | `CrossCuttingFoundationTests.cs` | Pipeline order, `Result<T>` returns, topic naming, and the direct-reference bans (Sentry, `DeploymentMode`, `IEventBus`, the cache clients, the Hub, provider SDK exceptions). | | `RequestSurfaceTests.cs` | What the step-4 authority ceiling admits: the two request markers, their permitted sets, the shape of the attributes themselves, and the ban on request shapes MediatR runs with no pipeline. | -| `PlatformAdminScopeConventionTests.cs` | The single sanctioned `BYPASSRLS` path: who may resolve the keyed platform data source, where connection strings are read, the entry gate, and what the scope must not touch. | +| `PlatformAdminScopeConventionTests.cs` | The platform-admin `BYPASSRLS` path: who may resolve its keyed data source, where connection strings are read, the entry gate, and what the scope must not touch. ADR-0046 separately confines the dispatcher credential. | | `RepositoryLayoutTests.cs` | `No_Source_Folder_Named_Verticals`, the single-frontend-app rule, and the commit-subject grammar — checked by running the `commit-msg` hook. | | `DomainEventHandlerTests.cs` | Complete domain-handler/helper/interface census; outbox-only reactions with planted forbidden-capability controls. | | `IntegrationEventContractTests.cs` | Production event inheritance, explicit identifier partition derivation and identifier-only payload/serializer shape. | | `OutboxProducerConventionTests.cs` | Producer/helper fence against extra connections, commit ownership and direct publication. | +| `OutboxDispatchConventionTests` (partial files) | Whole-production dispatcher credentials/publication, exact literal-SQL writers and memory-only observation/registration censuses, with compiled clean/dirty controls. | | `AggregateWriteTests.cs` | The one sanctioned cross-aggregate write, and the count of aggregate roots a handler's ports can write that keeps it at one. | | `CustomizationRegistryTests.cs` | The closed renderer registries that exist twice — in C# and in TypeScript — and stop the two drifting. | | `AuditConventionTests.cs` | The audit aggregate's shape, the closed-set columns against their `CHECK`s, append-only enforcement at the source level, the closed set of `audit_log` writers, and the `OperationType` table. | diff --git a/.claude/skills/add-integration-test/SKILL.md b/.claude/skills/add-integration-test/SKILL.md index 5cbf7994..b1b3d00b 100644 --- a/.claude/skills/add-integration-test/SKILL.md +++ b/.claude/skills/add-integration-test/SKILL.md @@ -240,7 +240,7 @@ those shipped examples for Education rather than inventing an authenticated rout > **The `_fx.*` members in Steps 3 to 5 are illustrative.** P02b-1 implements > `IOutbox`; follow `OutboxProducerTests` and `TenantCreatedProducerTests` for > shipped producer fixtures. P02b-2 Step 3 implements the API-host dispatcher; -> its verification/reviews are in progress, and `OutboxProcessorTests` owns its +> its both independent review rounds are complete, and `OutboxProcessorTests` owns its > runtime proofs. Real subscriptions remain Pending; consumer/inbox assertions > belong to P02b-3. `audit_log` shipped in Packet 9: for a real audit assertion, > follow `AuditWorkflowTests`, which drives commands through diff --git a/.claude/skills/local-dev-setup/SKILL.md b/.claude/skills/local-dev-setup/SKILL.md index 855a9465..dc33a7b3 100644 --- a/.claude/skills/local-dev-setup/SKILL.md +++ b/.claude/skills/local-dev-setup/SKILL.md @@ -109,15 +109,18 @@ helper loads application/dispatcher database credentials and the paired hop network/secret; the web launcher captures native socket provenance before Next. Neither script shell-evaluates `.env`, edits hosts or installs trust. The optional web projection -must match the root private source. +must match the root private source. README Quickstart owns the +[closed local demo database grammar](../../../README.md#3-prepare-local-https-and-start-the-applications) +and its supported field aliases; it does not promise general Npgsql parsing. **Ordinary API startup.** `dotnet run` does not load `.env`. For an API-only session, supply `ConnectionStrings:Default` (`learnstack_app`) and `ConnectionStrings:OutboxDispatcher` (`learnstack_outbox_admin`) through environment or the project's `learnstack-api-dev` user-secrets store. The mandatory API worker resolves its guarded source and verifies dispatcher connectivity at startup. -The dispatcher credential belongs only to the API; `make seed` strips it, and -the web launcher does not receive it. Both empty hop lists remain +The dispatcher credential belongs only to the API; every Seeder invocation uses +an explicit application-only environment, and the web launcher receives neither +database credential. Both empty hop lists remain valid in every mode; a network-only or secret-only configuration is refused. The application connection must name `learnstack_app`; the data-source guard also refuses direct or transitive BYPASSRLS/superuser access. Migration credentials diff --git a/CLAUDE.md b/CLAUDE.md index 260d4558..4707f6a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -190,8 +190,10 @@ records both completed Step 1 reviews. Step 2 implements fenced transitions and row-bound terminal MUST audit; both independent review rounds pass. Step 3 implements hosted execution and observation; its [delivery record](docs/roadmap/phase-02b-events-auth.md#p02b-2-step-3--hosted-lifecycle-and-observability-2026-10-12) -owns verification and reviews, which are in progress. Step 4 enforcement remains -pending. Acceptance registers six delivery obligations; +owns verification and both completed review rounds. Step 4 enforcement is implemented; +validation and reviews are in progress in its +[delivery record](docs/roadmap/phase-02b-events-auth.md#p02b-2-step-4--enforcement-and-closeout-2026-10-12). +Acceptance registers six delivery obligations; it claims no inbox or subscriber delivery. P02b-3 still owns durable acceptance, shared inbox/ledger, independent retries, both real default subscriptions and Seeder handoff. @@ -398,8 +400,8 @@ Then read the phase that is live and the one it stands on: and merged through PR #29 on 2026-10-12. Its four steps deliver the producer runtime and production-census enforcement. P02b-2's revised package/ADR-0046 are Accepted. Steps 1–2 completed both review rounds; Step 3 implements hosted - dispatch and observation, with verification/reviews in progress. Step 4 - enforcement remains pending. + dispatch and observation, with both independent review rounds complete. Step 4 + enforcement is implemented; validation and reviews are in progress. P02b-3 owns consumer/inbox delivery; each later packet accepts its gates first. - [docs/roadmap/phase-02d-walking-skeleton.md](docs/roadmap/phase-02d-walking-skeleton.md) — complete and merged; the delivered public-site baseline and P02b-0 handoff. diff --git a/README.md b/README.md index 43235d43..8f9a092d 100644 --- a/README.md +++ b/README.md @@ -73,9 +73,12 @@ is complete. **P02b-1 is complete and merged** through records transactional event production, enforcement, completed reviews and final CI verification under Accepted ADR-0057. P02b-2's revised package and ADR-0046 are Accepted. Steps 1–2 completed both review rounds; Step 3 implements hosted -dispatch and observation, with verification and reviews in progress. Its +dispatch and observation, with both independent review rounds complete. Its [delivery record](docs/roadmap/phase-02b-events-auth.md#p02b-2-step-3--hosted-lifecycle-and-observability-2026-10-12) -owns current evidence. Step 4 enforcement remains pending. +owns current evidence. Step 4 enforcement is implemented; validation and reviews +are in progress +in its +[delivery record](docs/roadmap/phase-02b-events-auth.md#p02b-2-step-4--enforcement-and-closeout-2026-10-12). [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, @@ -126,8 +129,8 @@ owns CI enforcement, reviews and the positive phase-exit verdict. [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; P02b-2 Step 3 implements -the producer worker, with verification/reviews in progress and Step 4 enforcement -pending. Consumer delivery remains P02b-3. The +the producer worker, with both independent review rounds complete and Step 4 enforcement +in progress. Consumer delivery remains 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 @@ -139,7 +142,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, observability, transactional event production and hosted producer dispatch (Step 3 verification/reviews in progress) | Authentication and durable event consumption in [Phase 02b](docs/roadmap/phase-02b-events-auth.md) | +| **API foundation** | Error contracts, validation, tenancy, concurrency, observability, transactional event production and hosted producer dispatch (both Step 3 review rounds complete) | Authentication and durable event 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 @@ -218,6 +221,17 @@ foreign listener is refused; no foreign process is killed. If a hard kill leaves `.data/demo.lock`, confirm the earlier process tree has stopped before removing that lock manually. Missing/stale configuration or TLS is refused without reset. +Local demo database validation accepts only `Host`, `Port`, `Database`, +`Username` and `Password`, with case-insensitive keys. Supported aliases are +`Server` for `Host`; `User Name`, `Userid`, `User Id` and `Uid` for `Username`; +and `Pwd` and `Psw` for `Password`. Single- or double-quoted values preserve +whitespace and semicolons; double the matching quote to escape it. Duplicate +canonical fields and unsupported keys are refused. Host must be `localhost` or +`127.0.0.1`; port defaults to 5432 and must resolve to 5432. Role, password and +database must match the root configuration. This is the closed local demo +grammar, not general Npgsql connection-string support; +[demo-database.mjs](scripts/demo-database.mjs) owns validation. + For separate development/watch terminals, retain `make public-api` and `make public-web`. The CI lifecycle uses an isolated checkout/project and disposable configuration through `make demo DEMO_MODE=ci`. Install its locked managed browser diff --git a/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs index 70b96952..5187b70a 100644 --- a/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/CrossCuttingFoundationTests.cs @@ -774,6 +774,8 @@ private static bool FollowsTopicConvention(string topic) [Fact] public void Modules_Do_Not_Inject_IEventBus_Directly() { + OutboxDispatchConventionTests.AssertOnlyWorkerPublishes(); + // Standards 20 § IEventBus: the only sanctioned publisher is the // OutboxProcessor. A module that injects IEventBus gets a synchronous // cross-module call with no durability and no transactional atomicity — diff --git a/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchBoundaryTests.cs b/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchBoundaryTests.cs new file mode 100644 index 00000000..5db65d4e --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchBoundaryTests.cs @@ -0,0 +1,633 @@ +using FluentAssertions; +using LearnStack.Api.Composition; +using LearnStack.Infrastructure.Messaging; +using LearnStack.Infrastructure.MultiTenancy; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Mono.Cecil; +using Mono.Cecil.Cil; +using Npgsql; +using Xunit; + +namespace LearnStack.Tests.Architecture; + +public sealed partial class OutboxDispatchConventionTests +{ + private const string BoundaryApi = "LearnStack.Api"; + private const string BoundaryComposition = "LearnStack.Api.Composition.OutboxCompositionExtensions"; + private const string BoundaryPersistence = "LearnStack.Api.Composition.PersistenceCompositionExtensions"; + private const string BoundarySource = "LearnStack.Infrastructure.Messaging.OutboxDispatcherDataSource"; + private const string BoundaryWorker = "LearnStack.Api.Messaging.OutboxProcessor"; + private const string BoundaryAttempt = "LearnStack.Api.Messaging.OutboxPublishAttempt"; + private const string BoundaryPlatform = "LearnStack.Infrastructure.MultiTenancy.PlatformAdminScope"; + private const string BoundaryBus = "LearnStack.SharedKernel.Messaging.IEventBus"; + private const string BoundaryTransport = "LearnStack.Infrastructure.Messaging.InProcessEventBus"; + private const string BoundaryDataSource = "Npgsql.NpgsqlDataSource"; + private const string BoundaryLazySource = "System.Lazy`1<Npgsql.NpgsqlDataSource>"; + + /// <summary> + /// ADR-0046: census every production assembly, exact keyed factories and parameter + /// bindings. Role/connectivity/grant behavior is proved by the PostgreSQL suites. + /// </summary> + [Fact] + public void LearnStack_OutboxAdmin_Role_OnlyUsedBy_OutboxProcessor() + { + using var census = ProductionCensus.Read(); + CredentialOffenders(census).Should().BeEmpty( + "Fix: confine each keyed source to its exact production registration, lazy factory and consumer; " + + "the platform key never authorizes the dispatcher or its helpers"); + } + + internal static void AssertOnlyWorkerPublishes() + { + using var census = ProductionCensus.Read(); + PublicationOffenders(census).Should().BeEmpty( + "Fix: only OutboxProcessor.DispatchNormalAsync constructs the owned publication attempt, " + + "whose private PublishAsync is the sole production IEventBus publisher; producers use IOutbox"); + } + + [Fact] + public void Credential_census_rejects_generic_direct_delegate_async_and_extra_helper_resolution() + { + using var census = ProductionCensus.Read(); + var application = census.Modules.Single(module => module.Assembly.Name.Name == "LearnStack.Application"); + var extensions = typeof(ServiceProviderKeyedServiceExtensions).GetMethods() + .Where(method => method.Name is "GetRequiredKeyedService" or "GetKeyedService" or "GetKeyedServices") + .ToArray(); + var targets = extensions.Concat(typeof(IKeyedServiceProvider).GetMethods()) + .Concat(typeof(ServiceProvider).GetMethods().Where(method => method.Name is "GetRequiredKeyedService" or "GetKeyedService")); + foreach (var target in targets) + foreach (var opcode in new[] { OpCodes.Call, OpCodes.Callvirt, OpCodes.Ldftn, OpCodes.Ldvirtftn }) + AssertMutation(census, "extra keyed resolution through " + target.DeclaringType!.Name + "." + target.Name + "/" + opcode, + () => AddCallProbe(application, "CredentialCaller", application.ImportReference(target), opcode), CredentialOffenders); + + var resolver = application.ImportReference(extensions.First()); + AssertMutation(census, "a cross-assembly async keyed resolver", () => + AddAsyncCallProbe(application, "AsyncCredentialCaller", resolver), CredentialOffenders); + AssertMutation(census, "an unrelated helper on the sanctioned composition type", () => + { + var type = census.Types[(BoundaryApi, BoundaryComposition)]; + var method = AddCall(type, "UnrelatedCredentialHelper", resolver, OpCodes.Call); + return () => type.Methods.Remove(method); + }, CredentialOffenders); + AssertMutation(census, "a generated-name impersonation of the composition root", () => + { + var type = census.Types[(BoundaryApi, BoundaryComposition)]; + var generated = new TypeDefinition(string.Empty, "<AddLearnStackOutboxDispatcher>d__999", TypeAttributes.NestedPrivate, + type.Module.TypeSystem.Object); + type.NestedTypes.Add(generated); + AddCall(generated, "MoveNext", resolver, OpCodes.Call); + return () => type.NestedTypes.Remove(generated); + }, CredentialOffenders); + AssertMutation(census, "AnyKey enumeration even inside a sanctioned root", () => + { + var type = census.Types[(BoundaryApi, BoundaryComposition)]; + var method = AddCall(type, "AnyCredential", type.Module.ImportReference(typeof(KeyedService) + .GetProperty(nameof(KeyedService.AnyKey))!.GetMethod!), OpCodes.Call); + return () => type.Methods.Remove(method); + }, CredentialOffenders); + AssertMutation(census, "an outbox credential indexer in another production assembly", () => + AddStringProbe(application, "CredentialIndexer", "ConnectionStrings:OutboxDispatcher"), CredentialOffenders); + AssertMutation(census, "an outbox environment credential in another production assembly", () => + AddStringProbe(application, "CredentialEnvironment", "ConnectionStrings__OutboxDispatcher"), CredentialOffenders); + foreach (var alias in new[] { "OUTBOXDISPATCHER", "CONNECTIONSTRINGS:OUTBOXDISPATCHER", "CONNECTIONSTRINGS__OUTBOXDISPATCHER" }) + AssertMutation(census, "a case-insensitive configuration alias " + alias, () => + AddStringProbe(application, "UppercaseCredentialAlias", alias), CredentialOffenders); + foreach (var subject in new[] { (BoundaryApi, BoundaryWorker), (InfrastructureAssembly, Store) }) + foreach (var opcode in new[] { OpCodes.Ldfld, OpCodes.Ldflda }) + AssertMutation(census, "an extra privileged-source forwarding helper using " + opcode, () => + { + var type = census.Types[subject]; + var source = type.Fields.Single(field => field.Name == "_source"); + var method = new MethodDefinition("ForwardSource", MethodAttributes.Private, type.Module.TypeSystem.Void); + method.Body.Instructions.Add(Instruction.Create(opcode, source)); + method.Body.Instructions.Add(Instruction.Create(OpCodes.Pop)); + method.Body.Instructions.Add(Instruction.Create(OpCodes.Ret)); + type.Methods.Add(method); + return () => type.Methods.Remove(method); + }, CredentialOffenders); + var store = census.Types[(InfrastructureAssembly, Store)]; + var storeConstructor = store.Methods.Single(method => method.IsConstructor && !method.IsStatic); + AssertMutation(census, "a second consumer constructing the privileged store", () => + AddCallProbe(application, "AdditionalStoreConsumer", storeConstructor, OpCodes.Newobj), CredentialOffenders); + var resolveStore = typeof(ServiceProviderServiceExtensions).GetMethods().Single(method => method.Name == "GetRequiredService" + && method.IsGenericMethodDefinition && method.GetParameters().Length == 1).MakeGenericMethod(typeof(OutboxDispatchStore)); + AssertMutation(census, "an unkeyed locator escaping through the privileged store", () => + AddCallProbe(application, "LocatedStoreConsumer", application.ImportReference(resolveStore), OpCodes.Call), CredentialOffenders); + AssertMutation(census, "an unrelated helper on the approved worker using the privileged store", () => + { + var worker = census.Types[(BoundaryApi, BoundaryWorker)]; + var method = AddCall(worker, "UnrelatedStoreConsumer", store.Methods.Single(method => method.Name == "TryClaimAsync"), OpCodes.Call); + return () => worker.Methods.Remove(method); + }, CredentialOffenders); + } + + [Fact] + public void Credential_census_rejects_crossed_keys_changed_closures_and_missing_subjects() + { + using var census = ProductionCensus.Read(); + foreach (var subject in new[] { (BoundaryApi, BoundaryWorker), (InfrastructureAssembly, BoundaryPlatform) }) + { + var parameter = census.Types[subject].Methods.Single(method => method.IsConstructor && !method.IsStatic) + .Parameters.Single(parameter => parameter.CustomAttributes.Any(attribute => + attribute.AttributeType.Name == "FromKeyedServicesAttribute")); + var attribute = parameter.CustomAttributes.Single(item => item.AttributeType.Name == "FromKeyedServicesAttribute"); + AssertMutation(census, "a consumer resolving the other privileged key", () => + { + var original = attribute.ConstructorArguments[0]; + attribute.ConstructorArguments[0] = new CustomAttributeArgument(original.Type, + subject.Item2 == BoundaryWorker ? PlatformAdminScope.PlatformDataSourceKey : OutboxDispatcherDataSource.Key); + return () => attribute.ConstructorArguments[0] = original; + }, CredentialOffenders); + AssertMutation(census, "a raw source replacing the required lazy binding", () => + { + var original = parameter.ParameterType; + parameter.ParameterType = census.Types[subject].Module.ImportReference(typeof(NpgsqlDataSource)); + return () => parameter.ParameterType = original; + }, CredentialOffenders); + } + + foreach (var key in new[] { OutboxDispatcherDataSource.Key, PlatformAdminScope.PlatformDataSourceKey }) + { + var descriptor = BoundaryRegistrations().Single(item => item.IsKeyedService && Equals(item.ServiceKey, key) + && item.ServiceType == typeof(Lazy<NpgsqlDataSource>)); + var factory = FindRuntimeMethod(census, descriptor.KeyedImplementationFactory!.Method)!; + var leafReference = DelegateTargets(factory).Single(); + var leaf = census.Types[Key(leafReference.DeclaringType)].Methods.Single(method => MethodId(method) == MethodId(leafReference)); + var call = leaf.Body.Instructions.Single(instruction => instruction.Operand is MethodReference method && IsKeyedResolution(method)); + AssertMutation(census, "a lazy callback substituting the other key", () => + ReplaceInstruction(call.Previous!, OpCodes.Ldstr, key == OutboxDispatcherDataSource.Key + ? PlatformAdminScope.PlatformDataSourceKey : OutboxDispatcherDataSource.Key), CredentialOffenders); + var captured = (FieldReference)call.Previous!.Operand; + var assignment = factory.Body.Instructions.Single(instruction => instruction.OpCode == OpCodes.Stfld + && instruction.Operand is FieldReference field && field.FullName == captured.FullName); + AssertMutation(census, "a factory capturing a different key instead of its registered key", () => + ReplaceInstruction(assignment.Previous!, OpCodes.Ldstr, "unrelated-key"), CredentialOffenders); + } + + var root = census.Types[(BoundaryApi, BoundaryComposition)].Methods.Single(method => method.Name == "AddLearnStackOutboxDispatcher"); + var registrationKey = root.Body.Instructions.First(instruction => Equals(instruction.Operand, OutboxDispatcherDataSource.Key)); + AssertMutation(census, "a changed actual registration key", () => + ReplaceInstruction(registrationKey, OpCodes.Ldstr, PlatformAdminScope.PlatformDataSourceKey), CredentialOffenders); + var configurationKey = root.Body.Instructions.Single(instruction => Equals(instruction.Operand, "OutboxDispatcher")); + AssertMutation(census, "the dispatcher reading the platform configuration", () => + ReplaceInstruction(configurationKey, OpCodes.Ldstr, "PlatformAdmin"), CredentialOffenders); + var source = census.Types[(InfrastructureAssembly, BoundarySource)]; + AssertMutation(census, "a missing production data-source subject", () => + { + var module = source.Module; + module.Types.Remove(source); + return () => module.Types.Add(source); + }, CredentialOffenders); + } + + [Fact] + public void Credential_registration_detector_rejects_missing_duplicate_wrong_key_and_non_singleton_sources() + { + IServiceCollection services = BoundaryRegistrations(); + RegistrationOffenders(services).Should().BeEmpty("Fix: the real production composition must be the clean control"); + var descriptor = services.Single(item => item.IsKeyedService && Equals(item.ServiceKey, OutboxDispatcherDataSource.Key) + && item.ServiceType == typeof(NpgsqlDataSource)); + services.Remove(descriptor); + RegistrationOffenders(services).Should().NotBeEmpty("Fix: a missing privileged source must fail"); + services.Add(descriptor); + services.Add(descriptor); + RegistrationOffenders(services).Should().NotBeEmpty("Fix: a duplicate privileged source must fail"); + services.Remove(descriptor); + services.Remove(descriptor); + var wrongKey = new ServiceCollection(); + wrongKey.AddKeyedSingleton<NpgsqlDataSource>("unrelated-key", (_, _) => throw new InvalidOperationException("Never resolved")); + services.Add(wrongKey.Single()); + RegistrationOffenders(services).Should().NotBeEmpty("Fix: a renamed privileged source must fail"); + services.Remove(wrongKey.Single()); + var scoped = new ServiceCollection(); + scoped.AddKeyedScoped<NpgsqlDataSource>(OutboxDispatcherDataSource.Key, (_, _) => throw new InvalidOperationException("Never resolved")); + services.Add(scoped.Single()); + RegistrationOffenders(services).Should().NotBeEmpty("Fix: a non-singleton privileged source must fail"); + services.Remove(scoped.Single()); + services.Add(descriptor); + RegistrationOffenders(services).Should().BeEmpty("Fix: restoring the actual descriptors must restore the clean control"); + } + + [Fact] + public void Publication_census_rejects_interface_concrete_delegate_async_and_unowned_attempt_paths() + { + using var census = ProductionCensus.Read(); + var port = census.Types[(KernelAssembly, BoundaryBus)].Methods.Single(method => method.Name == "PublishAsync"); + var concrete = census.Types[(InfrastructureAssembly, BoundaryTransport)].Methods.Single(method => method.Name == "PublishAsync"); + foreach (var assembly in new[] { BoundaryApi, "LearnStack.Application", "LearnStack.Tools.Seeder", "LearnStack.Modules.Tenancy.Application" }) + { + var module = census.Modules.Single(item => item.Assembly.Name.Name == assembly); + foreach (var target in new[] { port, concrete }) + foreach (var opcode in new[] { OpCodes.Call, OpCodes.Callvirt, OpCodes.Ldftn, OpCodes.Ldvirtftn }) + AssertMutation(census, assembly + " publication through " + target.DeclaringType.Name + "/" + opcode, + () => AddCallProbe(module, "ExtraPublisher", target, opcode), PublicationOffenders); + AssertMutation(census, assembly + " async publisher", () => AddAsyncCallProbe(module, "AsyncPublisher", port), PublicationOffenders); + } + var attempt = census.Types[(BoundaryApi, BoundaryAttempt)]; + var worker = census.Types[(BoundaryApi, BoundaryWorker)]; + var constructor = attempt.Methods.Single(method => method.IsConstructor && !method.IsStatic); + foreach (var target in new[] { constructor, attempt.Methods.Single(method => method.Name == "PublishAsync"), port }) + AssertMutation(census, "an unrelated method on the permitted worker/attempt type", () => + { + var owner = target == port ? attempt : worker; + var method = AddCall(owner, "UnrelatedPublisher", target, target == constructor ? OpCodes.Newobj : OpCodes.Call); + return () => owner.Methods.Remove(method); + }, PublicationOffenders); + AssertMutation(census, "a generated name impersonating the real publication owner", () => + { + var generated = new TypeDefinition(string.Empty, "<PublishAsync>d__999", TypeAttributes.NestedPrivate, attempt.Module.TypeSystem.Object); + attempt.NestedTypes.Add(generated); + AddCall(generated, "MoveNext", port, OpCodes.Callvirt); + return () => attempt.NestedTypes.Remove(generated); + }, PublicationOffenders); + var application = census.Modules.Single(module => module.Assembly.Name.Name == "LearnStack.Application"); + AssertMutation(census, "a second inherited event-bus implementation", () => + { + var type = AddProbe(application, "InheritedEventBus"); + type.BaseType = application.ImportReference(concrete.DeclaringType); + return () => application.Types.Remove(type); + }, PublicationOffenders); + AssertMutation(census, "an extra publish call inside the sanctioned method", () => + { + var owner = census.Types.Values.SelectMany(type => type.Methods).Single(method => method.HasBody + && method.Body.Instructions.Any(instruction => instruction.Operand is MethodReference target + && target.Name == "PublishAsync" && target.DeclaringType.FullName == BoundaryBus)); + var instruction = Instruction.Create(OpCodes.Callvirt, owner.Module.ImportReference(port)); + owner.Body.Instructions.Add(instruction); + return () => owner.Body.Instructions.Remove(instruction); + }, PublicationOffenders); + } + + [Fact] + public void Boundary_censuses_preserve_unkeyed_services_unrelated_publishers_and_both_real_keys() + { + using var census = ProductionCensus.Read(); + var module = census.Modules.Single(item => item.Assembly.Name.Name == "LearnStack.Application"); + var type = AddProbe(module, "CleanBoundaryHelper"); + var ordinaryResolver = typeof(ServiceProviderServiceExtensions).GetMethods().Single(method => + method.Name == "GetRequiredService" && method.IsGenericMethodDefinition && method.GetParameters().Length == 1); + AddCall(type, "ResolveOrdinaryService", module.ImportReference(ordinaryResolver), OpCodes.Call); + var unrelated = new MethodDefinition("PublishAsync", MethodAttributes.Public | MethodAttributes.Static, module.TypeSystem.Void); + unrelated.Body.Instructions.Add(Instruction.Create(OpCodes.Ret)); + type.Methods.Add(unrelated); + AddCall(type, "CallUnrelatedPublisher", unrelated, OpCodes.Call); + try + { + CredentialOffenders(census).Should().BeEmpty("Fix: both distinct production keys and ordinary unkeyed resolution remain valid"); + PublicationOffenders(census).Should().BeEmpty("Fix: unrelated methods named PublishAsync are not event-bus publication"); + } + finally { module.Types.Remove(type); } + } + + private static Action ReplaceInstruction(Instruction instruction, OpCode opcode, object operand) + { + var originalCode = instruction.OpCode; + var originalOperand = instruction.Operand; + instruction.OpCode = opcode; + instruction.Operand = operand; + return () => { instruction.OpCode = originalCode; instruction.Operand = originalOperand; }; + } + + private static Action AddStringProbe(ModuleDefinition module, string name, string value) + { + var type = AddProbe(module, name); + var method = new MethodDefinition("Run", MethodAttributes.Public | MethodAttributes.Static, module.TypeSystem.Void); + method.Body.Instructions.Add(Instruction.Create(OpCodes.Ldstr, value)); + method.Body.Instructions.Add(Instruction.Create(OpCodes.Pop)); + method.Body.Instructions.Add(Instruction.Create(OpCodes.Ret)); + type.Methods.Add(method); + return () => module.Types.Remove(type); + } + + private static ServiceCollection BoundaryRegistrations() + { + var services = new ServiceCollection(); + // Real composition, no provider build, pool construction or connection. + services.AddLearnStackPersistence(new ConfigurationBuilder().Build()); + return services; + } + + private static SortedSet<string> RegistrationOffenders(IEnumerable<ServiceDescriptor> services) + { + var offenders = new SortedSet<string>(StringComparer.Ordinal); + var expected = new[] + { + PlatformAdminScope.PlatformDataSourceKey + ":" + typeof(NpgsqlDataSource), + PlatformAdminScope.PlatformDataSourceKey + ":" + typeof(Lazy<NpgsqlDataSource>), + OutboxDispatcherDataSource.Key + ":" + typeof(NpgsqlDataSource), + OutboxDispatcherDataSource.Key + ":" + typeof(Lazy<NpgsqlDataSource>), + }; + var keyed = services.Where(descriptor => descriptor.IsKeyedService).ToArray(); + CheckSet(offenders, "keyed production registrations", keyed.Select(descriptor => + descriptor.ServiceKey + ":" + descriptor.ServiceType), expected); + foreach (var descriptor in keyed) + if (descriptor.Lifetime != ServiceLifetime.Singleton || descriptor.KeyedImplementationFactory is null) + offenders.Add("Keyed source must be a container-owned singleton factory: " + descriptor.ServiceType); + return offenders; + } + + private static SortedSet<string> CredentialOffenders(ProductionCensus census) + { + var services = BoundaryRegistrations(); + var offenders = RegistrationOffenders(services); + var types = census.Types; + CheckSet(offenders, "production assemblies", census.Modules.Select(module => module.Assembly.Name.Name), ProductionAssemblies.Names()); + foreach (var subject in new[] { (BoundaryApi, BoundaryComposition), (BoundaryApi, BoundaryPersistence), + (BoundaryApi, BoundaryWorker), (InfrastructureAssembly, BoundaryPlatform), + (InfrastructureAssembly, BoundarySource), (InfrastructureAssembly, Store) }) + if (!types.ContainsKey(subject)) offenders.Add("Missing credential subject: " + subject.Item2); + if (offenders.Count != 0) return offenders; + + var outboxRoot = types[(BoundaryApi, BoundaryComposition)].Methods.Single(method => method.Name == "AddLearnStackOutboxDispatcher"); + var platformRoot = types[(BoundaryApi, BoundaryPersistence)].Methods.Single(method => method.Name == "AddLearnStackPersistence"); + var roots = new[] { (Root: outboxRoot, Key: OutboxDispatcherDataSource.Key), + (Root: platformRoot, Key: PlatformAdminScope.PlatformDataSourceKey) }; + var expectedResolvers = new List<string>(); + var expectedFactories = new List<string>(); + MethodDefinition? outboxBuildFactory = null; + + foreach (var (root, key) in roots) + { + var descriptors = services.Where(descriptor => descriptor.IsKeyedService && Equals(descriptor.ServiceKey, key)).ToArray(); + foreach (var descriptor in descriptors) + { + var factory = FindRuntimeMethod(census, descriptor.KeyedImplementationFactory!.Method); + if (factory is null) { offenders.Add("Missing keyed factory: " + key); continue; } + expectedFactories.Add(MethodId(root)); + if (!DelegateTargets(root).Any(target => MethodId(target) == MethodId(factory))) + offenders.Add(MethodId(root) + ": must directly register its actual factory " + MethodId(factory)); + if (descriptor.ServiceType == typeof(NpgsqlDataSource)) + { + if (key == OutboxDispatcherDataSource.Key) outboxBuildFactory = factory; + continue; + } + + var leaves = DelegateTargets(factory).ToArray(); + if (leaves.Length != 1 || !types.TryGetValue(Key(leaves[0].DeclaringType), out var leafType)) + { offenders.Add(MethodId(factory) + ": requires one actual lazy delegate"); continue; } + var leaf = leafType.Methods.SingleOrDefault(method => MethodId(method) == MethodId(leaves[0])); + if (leaf is null) { offenders.Add("Missing lazy delegate: " + leaves[0].FullName); continue; } + expectedResolvers.Add(MethodId(leaf)); + CheckCapturedKeyResolver(offenders, factory, leaf); + } + + // Pin registration keys independently from callback keys. A changed key in + // the in-memory IL cannot be hidden by the unmodified reflection descriptors. + CheckSet(offenders, MethodId(root) + " registration keys", root.Body.Instructions + .Where(instruction => instruction.Operand is string value + && (value == OutboxDispatcherDataSource.Key || value == PlatformAdminScope.PlatformDataSourceKey) + && instruction.Next?.Operand is not MethodReference { Name: "GetConnectionString" }) + .Select(instruction => (string)instruction.Operand), [key, key]); + var keyCalls = MethodOperands(root).Where(IsKeyedRegistration).ToArray(); + CheckSet(offenders, MethodId(root) + " registration service types", keyCalls.Select(GenericService), + [BoundaryDataSource, BoundaryLazySource]); + } + + var storeDescriptor = services.Single(descriptor => !descriptor.IsKeyedService && descriptor.ServiceType == typeof(OutboxDispatchStore)); + var storeFactory = FindRuntimeMethod(census, storeDescriptor.ImplementationFactory!.Method); + if (storeFactory is null) offenders.Add("Missing actual dispatcher-store factory"); + else + { + expectedResolvers.Add(MethodId(storeFactory)); + if (!DelegateTargets(outboxRoot).Any(target => MethodId(target) == MethodId(storeFactory))) + offenders.Add("Dispatcher store factory must be registered by the outbox root"); + CheckLiteralKeyResolver(offenders, storeFactory, BoundaryLazySource, OutboxDispatcherDataSource.Key); + } + + var sites = CallSites(census).ToArray(); + CheckSet(offenders, "keyed resolver callers", sites.Where(site => IsKeyedResolution(site.Target)).Select(site => site.Owner), expectedResolvers); + CheckSet(offenders, "keyed registration callers", sites.Where(site => IsKeyedRegistration(site.Target)).Select(site => site.Owner), expectedFactories); + CheckSet(offenders, "outbox pool construction", sites.Where(site => site.Target.DeclaringType.FullName == BoundarySource + && site.Target.Name == "Build").Select(site => site.Owner), outboxBuildFactory is null ? [] : [MethodId(outboxBuildFactory)]); + CheckSet(offenders, "outbox configuration validation", sites.Where(site => site.Target.DeclaringType.FullName == BoundarySource + && site.Target.Name == "Validate").Select(site => site.Owner), [MethodId(outboxRoot)]); + CheckSet(offenders, "connection-string reader census", sites.Where(site => IsConnectionStringRead(site.Target)) + .Select(site => site.Owner), [MethodId(outboxRoot), MethodId(platformRoot), MethodId(platformRoot)]); + + var injections = new List<string>(); + foreach (var method in types.Values.SelectMany(type => type.Methods)) + { + foreach (var parameter in method.Parameters) + foreach (var attribute in parameter.CustomAttributes.Where(attribute => + attribute.AttributeType.FullName == "Microsoft.Extensions.DependencyInjection.FromKeyedServicesAttribute")) + injections.Add(MethodId(method) + ":" + parameter.ParameterType.FullName + ":" + + BoundaryAttributeValue(attribute.ConstructorArguments.FirstOrDefault())); + if (!method.HasBody) continue; + foreach (var instruction in method.Body.Instructions) + { + if (instruction.Operand is MethodReference call && IsConnectionStringRead(call)) + { + var key = instruction.Previous?.Operand as string; + var expected = method == outboxRoot ? new[] { "OutboxDispatcher" } + : method == platformRoot ? ["Default", "PlatformAdmin"] : []; + if (key is null || !expected.Contains(key, StringComparer.Ordinal)) + offenders.Add(MethodId(method) + ": unauthorized connection-string read"); + } + if (instruction.Operand is FieldReference field && field.DeclaringType.FullName == "Microsoft.Extensions.DependencyInjection.KeyedService" + && field.Name == "AnyKey") offenders.Add(MethodId(method) + ": AnyKey reopens both credentials"); + if (instruction.Operand is MethodReference anyKey && anyKey.DeclaringType.FullName == "Microsoft.Extensions.DependencyInjection.KeyedService" + && anyKey.Name == "get_AnyKey") offenders.Add(MethodId(method) + ": AnyKey reopens both credentials"); + if (instruction.Operand is string literal && (literal.Equals("OutboxDispatcher", StringComparison.OrdinalIgnoreCase) + || literal.Equals("ConnectionStrings:OutboxDispatcher", StringComparison.OrdinalIgnoreCase) + || literal.Equals("ConnectionStrings__OutboxDispatcher", StringComparison.OrdinalIgnoreCase)) && method != outboxRoot) + offenders.Add(MethodId(method) + ": dispatcher credential spelling outside its reader"); + } + } + var workerConstructor = types[(BoundaryApi, BoundaryWorker)].Methods.Single(method => method.IsConstructor && !method.IsStatic); + var scopeConstructor = types[(InfrastructureAssembly, BoundaryPlatform)].Methods.Single(method => method.IsConstructor && !method.IsStatic); + CheckSet(offenders, "keyed parameter bindings", injections, + [MethodId(workerConstructor) + ":" + BoundaryLazySource + ":" + OutboxDispatcherDataSource.Key, + MethodId(scopeConstructor) + ":" + BoundaryLazySource + ":" + PlatformAdminScope.PlatformDataSourceKey]); + CheckSourceFieldAccess(offenders, census, types[(BoundaryApi, BoundaryWorker)], ["StartAsync"]); + CheckSourceFieldAccess(offenders, census, types[(InfrastructureAssembly, Store)], + ["AcquireAsync", "UpdateAsync", "TryTerminalizeAsync", "IsDurablyTerminalAsync", "ReadSnapshotAsync"]); + if (storeFactory is not null) CheckStoreConsumers(offenders, census, outboxRoot, storeFactory); + return offenders; + } + + private static void CheckStoreConsumers(SortedSet<string> offenders, ProductionCensus census, + MethodDefinition composition, MethodDefinition factory) + { + // A caller can obtain the same bypass through the unkeyed store facade. + // Confine typed consumers, including generic locator calls and wrappers; + // reflection and arbitrary runtime dataflow are not claimed by this census. + var owners = OwnedBodies(census).Where(item => item.Owner.DeclaringType.FullName != Store + && ReferencesStore(item.Body)).Select(item => MethodId(item.Owner)) + .Concat(census.Types.Values.SelectMany(type => type.Methods).Where(method => !method.HasBody + && method.DeclaringType.FullName != Store && ReferencesStore(method)).Select(MethodId)) + .Distinct(StringComparer.Ordinal); + var worker = census.Types[(BoundaryApi, BoundaryWorker)]; + var workerMethods = worker.Methods.Where(method => method.IsConstructor && !method.IsStatic + || method.Name is "ExecuteAsync" or "DispatchAsync" or "DispatchNormalAsync" or "RefreshAsync"); + CheckSet(offenders, "privileged store consumers", owners, + workerMethods.Select(MethodId).Concat([MethodId(composition), MethodId(factory)])); + // Only an attribute-linked Store state machine may carry its own captured + // instance. A similarly named nested type gains no authority from its name. + var stateMachines = OwnedBodies(census).Where(item => item.Body.Name == "MoveNext" && item.Body != item.Owner + && item.Owner.DeclaringType.FullName == Store).Select(item => Key(item.Body.DeclaringType)).ToHashSet(); + // Direct instance holders carry the capability. Generic wrappers (including + // the compiler's cached registration delegate) remain covered at every typed + // method/field-use site above, rather than receiving a generated-name exception. + var fields = census.Types.Values.SelectMany(type => type.Fields).Where(field => field.FieldType.FullName == Store + && !(stateMachines.Contains(Key(field.DeclaringType)) && field.Name == "<>4__this" + && field.FieldType.FullName == Store)).ToArray(); + CheckSet(offenders, "privileged store fields", fields.Select(field => TypeId(field.DeclaringType) + ":" + field.Name), + [TypeId(worker) + ":_store"]); + foreach (var field in fields) + if (!field.IsPrivate || !field.IsInitOnly) offenders.Add(field.FullName + ": store capability must remain private and readonly"); + } + + private static bool ReferencesStore(MethodDefinition method) + { + if (ContainsStore(method.ReturnType) || method.Parameters.Any(parameter => ContainsStore(parameter.ParameterType))) return true; + if (!method.HasBody) return false; + if (method.Body.Variables.Any(variable => ContainsStore(variable.VariableType))) return true; + return method.Body.Instructions.Any(instruction => instruction.Operand switch + { + GenericInstanceMethod generic => generic.GenericArguments.Any(ContainsStore) || ContainsStore(generic.DeclaringType) + || ContainsStore(generic.ReturnType) || generic.Parameters.Any(parameter => ContainsStore(parameter.ParameterType)), + MethodReference call => ContainsStore(call.DeclaringType) || ContainsStore(call.ReturnType) + || call.Parameters.Any(parameter => ContainsStore(parameter.ParameterType)), + FieldReference field => ContainsStore(field.FieldType), + TypeReference type => ContainsStore(type), + _ => false, + }); + } + + private static bool ContainsStore(TypeReference type) => type.FullName == Store + || (type is GenericInstanceType generic && generic.GenericArguments.Any(ContainsStore)) + || (type is TypeSpecification specification && ContainsStore(specification.ElementType)); + + private static void CheckSourceFieldAccess(SortedSet<string> offenders, ProductionCensus census, + TypeDefinition type, string[] readers) + { + var fields = type.Fields.Where(field => field.Name == "_source").ToArray(); + if (fields.Length != 1 || !fields[0].IsPrivate || !fields[0].IsInitOnly || fields[0].IsStatic + || fields[0].FieldType.FullName != BoundaryLazySource) + { offenders.Add(TypeId(type) + ": requires its private readonly lazy source"); return; } + // Close forwarding of an already injected capability as well as fresh keyed + // resolution. Exact access counts also catch another use in an allowed method. + var actual = OwnedBodies(census).SelectMany(item => item.Body.Body.Instructions + .Where(instruction => instruction.Operand is FieldReference field && SameType(field.DeclaringType, type) + && field.Name == fields[0].Name) + .Select(instruction => MethodId(item.Owner) + ":" + instruction.OpCode.Name)); + var expected = type.Methods.Where(method => method.IsConstructor && !method.IsStatic) + .Select(method => MethodId(method) + ":stfld") + .Concat(type.Methods.Where(method => readers.Contains(method.Name, StringComparer.Ordinal)) + .Select(method => MethodId(method) + ":ldfld")); + CheckSet(offenders, TypeId(type) + " privileged source field access", actual, expected); + } + + private static void CheckCapturedKeyResolver(SortedSet<string> offenders, MethodDefinition factory, MethodDefinition leaf) + { + // Recognize this one compiler-emitted closure: (provider, key) => + // new Lazy<NpgsqlDataSource>(() => provider.GetRequiredKeyedService<NpgsqlDataSource>(key)). + // No general dataflow interpreter or generated-name permission is involved. + var calls = leaf.Body.Instructions.Where(instruction => instruction.Operand is MethodReference call && IsKeyedResolution(call)).ToArray(); + var call = calls.Length == 1 ? calls[0] : null; + var keyField = call?.Previous?.Operand as FieldReference; + var providerField = call?.Previous?.Previous?.Previous?.Operand as FieldReference; + if (call?.Operand is not MethodReference target || target.Name != "GetRequiredKeyedService" + || GenericService(target) != BoundaryDataSource || keyField is null || providerField is null + || call.Previous!.OpCode != OpCodes.Ldfld || call.Previous.Previous!.OpCode != OpCodes.Ldarg_0 + || call.Previous.Previous.Previous!.OpCode != OpCodes.Ldfld + || !CapturesParameter(factory, keyField, 1) || !CapturesParameter(factory, providerField, 0)) + offenders.Add(MethodId(leaf) + ": lazy resolver must forward its own registered key and provider unchanged"); + } + + private static bool CapturesParameter(MethodDefinition factory, FieldReference field, int parameter) => + factory.Body.Instructions.Count(instruction => instruction.OpCode == OpCodes.Stfld + && instruction.Operand is FieldReference assigned && assigned.FullName == field.FullName + && LoadsArgument(instruction.Previous, parameter + (factory.HasThis ? 1 : 0))) == 1; + + private static bool LoadsArgument(Instruction? instruction, int index) => instruction is not null && (index switch + { + 0 => instruction.OpCode == OpCodes.Ldarg_0, + 1 => instruction.OpCode == OpCodes.Ldarg_1, + 2 => instruction.OpCode == OpCodes.Ldarg_2, + 3 => instruction.OpCode == OpCodes.Ldarg_3, + _ => false, + }); + + private static void CheckLiteralKeyResolver(SortedSet<string> offenders, MethodDefinition owner, string service, string key) + { + var calls = owner.Body.Instructions.Where(instruction => instruction.Operand is MethodReference call && IsKeyedResolution(call)).ToArray(); + if (calls.Length != 1 || calls[0].Operand is not MethodReference target + || target.Name != "GetRequiredKeyedService" || GenericService(target) != service + || calls[0].Previous?.OpCode != OpCodes.Ldstr || !Equals(calls[0].Previous.Operand, key)) + offenders.Add(MethodId(owner) + ": requires exactly its declared keyed service"); + } + + private static bool IsKeyedResolution(MethodReference method) => + method.DeclaringType.Namespace.StartsWith("Microsoft.Extensions.DependencyInjection", StringComparison.Ordinal) + && method.Name is "GetKeyedService" or "GetRequiredKeyedService" or "GetKeyedServices"; + + private static bool IsConnectionStringRead(MethodReference method) => + method.DeclaringType.FullName == "Microsoft.Extensions.Configuration.ConfigurationExtensions" && method.Name == "GetConnectionString"; + + private static object? BoundaryAttributeValue(CustomAttributeArgument argument, int depth = 0) => depth >= 8 ? null + : argument.Value is CustomAttributeArgument nested ? BoundaryAttributeValue(nested, depth + 1) : argument.Value; + + private static bool IsKeyedRegistration(MethodReference method) => + method.DeclaringType.Namespace.StartsWith("Microsoft.Extensions.DependencyInjection", StringComparison.Ordinal) + && (method.Name.StartsWith("AddKeyed", StringComparison.Ordinal) || method.Name.StartsWith("TryAddKeyed", StringComparison.Ordinal) + || method.Name.StartsWith("KeyedSingleton", StringComparison.Ordinal) || method.Name.StartsWith("KeyedScoped", StringComparison.Ordinal) + || method.Name.StartsWith("KeyedTransient", StringComparison.Ordinal) + || method.Name == "DescribeKeyed" + || (method.Name == ".ctor" && method.DeclaringType.Name == "ServiceDescriptor" + && method.Parameters.Count >= 3 && method.Parameters[1].ParameterType.FullName == "System.Object")); + + private static string GenericService(MethodReference method) => method is GenericInstanceMethod generic + && generic.GenericArguments.Count == 1 ? generic.GenericArguments[0].FullName : "non-generic:" + method.FullName; + + private static IEnumerable<MethodReference> MethodOperands(MethodDefinition method) => method.HasBody + ? method.Body.Instructions.Select(instruction => instruction.Operand).OfType<MethodReference>() : []; + + private static IEnumerable<MethodReference> DelegateTargets(MethodDefinition method) => method.HasBody + ? method.Body.Instructions.Where(instruction => instruction.OpCode == OpCodes.Ldftn || instruction.OpCode == OpCodes.Ldvirtftn) + .Select(instruction => instruction.Operand).OfType<MethodReference>() : []; + + private static MethodDefinition? FindRuntimeMethod(ProductionCensus census, System.Reflection.MethodInfo method) => + census.Modules.Single(module => module.Assembly.Name.Name == method.Module.Assembly.GetName().Name) + .GetTypes().SelectMany(type => type.Methods).SingleOrDefault(candidate => candidate.MetadataToken.ToInt32() == method.MetadataToken); + + private static SortedSet<string> PublicationOffenders(ProductionCensus census) + { + var offenders = new SortedSet<string>(StringComparer.Ordinal); + var types = census.Types; + CheckSet(offenders, "production assemblies", census.Modules.Select(module => module.Assembly.Name.Name), ProductionAssemblies.Names()); + foreach (var subject in new[] { (KernelAssembly, BoundaryBus), (InfrastructureAssembly, BoundaryTransport), + (BoundaryApi, BoundaryWorker), (BoundaryApi, BoundaryAttempt) }) + if (!types.ContainsKey(subject)) offenders.Add("Missing publication subject: " + subject.Item2); + if (offenders.Count != 0) return offenders; + var port = types[(KernelAssembly, BoundaryBus)]; + var attempt = types[(BoundaryApi, BoundaryAttempt)]; + var worker = types[(BoundaryApi, BoundaryWorker)]; + CheckSet(offenders, "production event-bus implementations", types.Values.Where(type => !type.IsInterface + && !type.IsAbstract && Derives(type, port, types)).Select(TypeId), + [TypeId(types[(InfrastructureAssembly, BoundaryTransport)])]); + var publishers = attempt.Methods.Where(method => method.Name == "PublishAsync").ToArray(); + var constructors = attempt.Methods.Where(method => method.IsConstructor && !method.IsStatic).ToArray(); + var dispatchers = worker.Methods.Where(method => method.Name == "DispatchNormalAsync").ToArray(); + if (publishers.Length != 1 || !publishers[0].IsPrivate || publishers[0].IsStatic + || publishers[0].ReturnType.FullName != "System.Threading.Tasks.Task`1<System.Boolean>" + || !ParametersMatch(publishers[0], ["Microsoft.Extensions.DependencyInjection.IServiceScopeFactory", + "LearnStack.SharedKernel.Messaging.IntegrationEventEnvelope"]) || !attempt.IsSealed || attempt.IsPublic) + offenders.Add(BoundaryAttempt + ": requires its internal sealed, private publication boundary"); + if (constructors.Length != 1 || dispatchers.Length != 1) + offenders.Add(BoundaryAttempt + ": requires one constructor and one worker owner"); + if (offenders.Count != 0) return offenders; + var sites = CallSites(census).ToArray(); + CheckSet(offenders, "event-bus publication callers", sites.Where(site => site.Target.Name.EndsWith("PublishAsync", StringComparison.Ordinal) + && (SameType(site.Target.DeclaringType, port) || Derives(site.Target.DeclaringType, port, types))) + .Select(site => site.Owner), [MethodId(publishers[0])]); + CheckSet(offenders, "publication attempt constructors", sites.Where(site => site.Target.Name == ".ctor" + && SameType(site.Target.DeclaringType, attempt)).Select(site => site.Owner), [MethodId(dispatchers[0])]); + var callbacks = DelegateTargets(constructors[0]).ToArray(); + if (callbacks.Length != 1) offenders.Add(BoundaryAttempt + ": requires exactly one task-owned callback"); + CheckSet(offenders, "private publication callback callers", sites.Where(site => site.Target.Name == "PublishAsync" + && SameType(site.Target.DeclaringType, attempt)).Select(site => site.Owner), callbacks.Select(MethodId)); + return offenders; + } +} diff --git a/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchConventionTests.cs index 21585024..2cd8ad32 100644 --- a/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchConventionTests.cs @@ -6,7 +6,7 @@ namespace LearnStack.Tests.Architecture; /// <summary>ADR-0046: the dispatcher owns the restricted terminal-transition capability.</summary> -public sealed class OutboxDispatchConventionTests +public sealed partial class OutboxDispatchConventionTests { private const string KernelAssembly = "LearnStack.SharedKernel"; private const string InfrastructureAssembly = "LearnStack.Infrastructure"; diff --git a/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchObservationTests.cs b/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchObservationTests.cs new file mode 100644 index 00000000..9c4ca9f3 --- /dev/null +++ b/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchObservationTests.cs @@ -0,0 +1,340 @@ +using System.Text.RegularExpressions; +using FluentAssertions; +using LearnStack.Api.Composition; +using LearnStack.Api.Messaging; +using LearnStack.Infrastructure.Messaging; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using Mono.Cecil; +using Mono.Cecil.Cil; +using Xunit; + +namespace LearnStack.Tests.Architecture; + +public sealed partial class OutboxDispatchConventionTests +{ + private const string ApiAssembly = "LearnStack.Api"; + private const string Observation = "LearnStack.Api.Messaging.OutboxObservation"; + private const string Health = "LearnStack.Api.Messaging.OutboxHealthCheck"; + private static readonly string[] ObservationSubjects = + [ + Observation, Health, "LearnStack.Api.Messaging.OutboxObservationSnapshot", + "LearnStack.Api.Messaging.OutboxProcessor", "LearnStack.Api.Messaging.OutboxPublishAttempt", + ]; + + /// <summary> + /// ADR-0046: the real worker/check registrations and memory-only observation graph + /// cannot disappear or acquire persistence/service-locator capabilities. Runtime + /// tests separately prove deadlines, snapshot values, diagnostics and stale health. + /// </summary> + [Fact] + public void Outbox_Health_And_Metrics_Are_Bounded() + { + using var census = ProductionCensus.Read(); + ObservationOffenders(census).Should().BeEmpty( + "Fix: retain the production worker/observation subjects and keep gauge/health helpers memory-only"); + ObservationRegistrationOffenders(ObservationServices()).Should().BeEmpty( + "Fix: register exactly one mandatory dispatcher and private outbox health check without forcing its lazy source"); + } + + [Fact] + public void Observation_walk_detects_persistence_locators_async_and_polymorphic_helpers() + { + using var census = ProductionCensus.Read(); + var application = census.Modules.Single(module => module.Assembly.Name.Name == "LearnStack.Application"); + foreach (var rootName in new[] { Observation, Health }) + foreach (var capability in new[] { typeof(Npgsql.NpgsqlCommand), typeof(System.Data.Common.DbConnection), + typeof(IServiceProvider), typeof(IServiceScopeFactory) }) + { + AssertMutation(census, "a cross-assembly observation helper with " + capability.Name, () => + { + var root = census.Types[(ApiAssembly, rootName)]; + var helper = AddProbe(application, "ObservationPersistenceHelper"); + helper.Fields.Add(new FieldDefinition("Capability", FieldAttributes.Public, + application.ImportReference(capability))); + var field = new FieldDefinition("ReviewHelper", FieldAttributes.Private, root.Module.ImportReference(helper)); + root.Fields.Add(field); + return () => { root.Fields.Remove(field); application.Types.Remove(helper); }; + }, ObservationOffenders); + } + foreach (var isInterface in new[] { true, false }) + AssertMutation(census, "a polymorphic observation helper", () => + { + var root = census.Types[(ApiAssembly, Observation)]; + var contract = AddProbe(application, "ObservationContract"); + if (isInterface) contract.Attributes |= TypeAttributes.Interface | TypeAttributes.Abstract; + var implementation = AddProbe(application, "ObservationImplementation"); + if (isInterface) implementation.Interfaces.Add(new InterfaceImplementation(contract)); + else implementation.BaseType = contract; + var getter = application.ImportReference(typeof(IServiceScopeFactory).GetMethod(nameof(IServiceScopeFactory.CreateScope))!); + var undoAsync = AddAsyncCall(implementation, "Read", getter); + var field = new FieldDefinition("ReviewHelper", FieldAttributes.Private, root.Module.ImportReference(contract)); + root.Fields.Add(field); + return () => + { + root.Fields.Remove(field); + undoAsync(); + application.Types.Remove(implementation); + application.Types.Remove(contract); + }; + }, ObservationOffenders); + foreach (var subject in ObservationSubjects) + AssertMutation(census, "a missing observation subject " + subject, () => + { + var type = census.Types[(ApiAssembly, subject)]; + var module = type.Module; + module.Types.Remove(type); + return () => module.Types.Add(type); + }, ObservationOffenders); + } + + [Fact] + public void Observation_walk_keeps_clean_memory_helpers_and_checks_real_gauge_callbacks() + { + using var census = ProductionCensus.Read(); + var observation = census.Types[(ApiAssembly, Observation)]; + var callbacks = observation.Methods.SelectMany(method => method.HasBody + ? method.Body.Instructions : Enumerable.Empty<Instruction>()) + .Where(instruction => instruction.OpCode == OpCodes.Ldftn && instruction.Operand is MethodReference) + .Select(instruction => (MethodReference)instruction.Operand) + .Where(method => SameType(method.DeclaringType, observation)).ToArray(); + callbacks.Should().HaveCountGreaterThanOrEqualTo(4, + "Fix: the census must inspect the shipped observable callbacks, not an empty test-only graph"); + foreach (var callback in callbacks) + AssertMutation(census, "persistence inside an actual observation callback", () => + { + var method = observation.Methods.Single(method => method.FullName == callback.FullName); + var reference = observation.Module.ImportReference(typeof(Npgsql.NpgsqlCommand).GetConstructor(Type.EmptyTypes)!); + var instruction = Instruction.Create(OpCodes.Newobj, reference); + method.Body.Instructions.Insert(0, instruction); + return () => method.Body.Instructions.Remove(instruction); + }, ObservationOffenders); + + var helper = AddProbe(observation.Module, "MemoryObservationHelper"); + var field = new FieldDefinition("ReviewHelper", FieldAttributes.Private, helper); + helper.Fields.Add(new FieldDefinition("Count", FieldAttributes.Public, helper.Module.TypeSystem.Int32)); + observation.Fields.Add(field); + try { ObservationOffenders(census).Should().BeEmpty("Fix: pure memory helpers must remain admitted"); } + finally { observation.Fields.Remove(field); observation.Module.Types.Remove(helper); } + } + + [Fact] + public void Observation_registration_control_detects_missing_duplicate_or_replaced_worker_and_check() + { + IServiceCollection services = ObservationServices(); + ObservationRegistrationOffenders(services).Should().BeEmpty(); + var worker = services.Single(descriptor => descriptor.ServiceType == typeof(IHostedService) + && descriptor.ImplementationType == typeof(OutboxProcessor)); + services.Remove(worker); + ObservationRegistrationOffenders(services).Should().NotBeEmpty("Fix: missing mandatory execution must be detected"); + services.Add(worker); + services.Add(worker); + ObservationRegistrationOffenders(services).Should().NotBeEmpty("Fix: duplicate workers must be detected"); + services.Remove(worker); + foreach (var replacement in new[] { "missing", "duplicate", "wrong-factory" }) + { + var modified = ObservationServices(); + modified.PostConfigure<HealthCheckServiceOptions>(options => + { + var registration = options.Registrations.Single(item => item.Name == OutboxHealthCheck.Name); + options.Registrations.Remove(registration); + if (replacement != "missing") + options.Registrations.Add(new HealthCheckRegistration(OutboxHealthCheck.Name, + _ => new UnrelatedHealthCheck(), HealthStatus.Unhealthy, [])); + if (replacement == "duplicate") options.Registrations.Add(registration); + }); + ObservationRegistrationOffenders(modified).Should().NotBeEmpty("Fix: detect " + replacement + " health registration"); + } + ObservationRegistrationOffenders(ObservationServices()).Should().BeEmpty(); + } + + /// <summary> + /// ADR-0046: all literal SQL targeting outbox row mutations belongs to the producer, + /// exact transition methods or the one legacy backfill. This structural companion + /// does not claim to analyze dynamically constructed table names or SQL dataflow. + /// Real PostgreSQL tests prove predicates, fencing and atomicity. + /// </summary> + [Fact] + public void Outbox_Retry_And_Terminal_Transitions_Are_Fenced() + { + using var census = ProductionCensus.Read(); + OutboxSqlOffenders(census).Should().BeEmpty( + "Fix: keep outbox literal INSERT/status mutation SQL in the exact producer, dispatcher and backfill methods"); + } + + [Fact] + public void Outbox_SQL_census_detects_quoted_schema_CTE_extra_and_missing_writer_paths() + { + using var census = ProductionCensus.Read(); + var application = census.Modules.Single(module => module.Assembly.Name.Name == "LearnStack.Application"); + foreach (var sql in new[] + { + "update outbox_messages set attempts = 0", "UPDATE ONLY public.\"outbox_messages\" SET attempts=0", + "WITH c AS (SELECT 1) UpDaTe \"public\" . \"outbox_messages\" SET attempts=0", + "INSERT INTO public.outbox_messages (id) VALUES (NULL)", "DELETE FROM \"outbox_messages\"", + "DELETE FROM ONLY public.outbox_messages", "UPDATE /* review */ public.outbox_messages SET attempts=0", + "MERGE INTO outbox_messages USING x ON true WHEN MATCHED THEN DELETE", + "COPY public.outbox_messages FROM STDIN", "TRUNCATE TABLE public.outbox_messages", + }) + AssertMutation(census, "an unauthorized literal SQL writer: " + sql, () => AddSqlProbe(application, sql), OutboxSqlOffenders); + AssertMutation(census, "an extra helper on the allowed store", () => + { + var store = census.Types[(InfrastructureAssembly, Store)]; + var method = AddSqlMethod(store, "UnexpectedWrite", "UPDATE outbox_messages SET attempts=0"); + return () => store.Methods.Remove(method); + }, OutboxSqlOffenders); + AssertMutation(census, "an extra overload of an allowed transition", () => + { + var store = census.Types[(InfrastructureAssembly, Store)]; + var method = AddSqlMethod(store, "AcquireAsync", "UPDATE outbox_messages SET attempts=0"); + return () => store.Methods.Remove(method); + }, OutboxSqlOffenders); + AssertMutation(census, "a missing production mutation path", () => + { + var literal = OwnedBodies(census).Where(item => item.Owner.DeclaringType.FullName == Store) + .SelectMany(item => item.Body.Body.Instructions) + .First(instruction => instruction.OpCode == OpCodes.Ldstr && instruction.Operand is string sql && OutboxMutation.IsMatch(sql)); + var original = literal.Operand; + literal.Operand = "SELECT 1"; + return () => literal.Operand = original; + }, OutboxSqlOffenders); + } + + [Fact] + public void Outbox_SQL_census_preserves_reads_and_unrelated_table_writes() + { + using var census = ProductionCensus.Read(); + var application = census.Modules.Single(module => module.Assembly.Name.Name == "LearnStack.Application"); + foreach (var sql in new[] { "SELECT * FROM outbox_messages FOR UPDATE SKIP LOCKED", + "UPDATE another_outbox_messages SET attempts=0", "INSERT INTO outbox_messages_archive VALUES (1)" }) + { + var undo = AddSqlProbe(application, sql); + try { OutboxSqlOffenders(census).Should().BeEmpty("Fix: admitted reads and unrelated table writes must stay clean"); } + finally { undo(); } + } + } + + private static readonly Regex OutboxMutation = new( + "\\b(?:INSERT\\s+INTO|UPDATE|DELETE\\s+FROM|MERGE\\s+INTO|COPY|TRUNCATE(?:\\s+TABLE)?)" + + "(?:\\s|/\\*.*?\\*/|--[^\\r\\n]*(?:\\r?\\n|$))+(?:ONLY\\s+)?" + + "(?:(?:\"[^\"]+\"|[a-z_][a-z0-9_$]*)\\s*\\.\\s*)?(?:\"outbox_messages\"|outbox_messages)(?=\\s|[;(]|$)", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Singleline, TimeSpan.FromSeconds(1)); + + private static SortedSet<string> OutboxSqlOffenders(ProductionCensus census) + { + var offenders = new SortedSet<string>(StringComparer.Ordinal); + CheckSet(offenders, "SQL production assemblies", census.Modules.Select(module => module.Assembly.Name.Name), ProductionAssemblies.Names()); + var writers = OwnedBodies(census).SelectMany(item => item.Body.Body.Instructions + .Where(instruction => instruction.OpCode == OpCodes.Ldstr && instruction.Operand is string sql && OutboxMutation.IsMatch(sql)) + .Select(_ => MethodId(item.Owner))); + var expected = new List<string>(); + foreach (var (typeName, methodName) in new[] + { + ("LearnStack.Infrastructure.Messaging.PostgresOutbox", "EnqueueAsync"), + (Store, "AcquireAsync"), (Store, "UpdateAsync"), (Store, "TryTerminalizeAsync"), + ("LearnStack.Infrastructure.Persistence.Migrations.add_outbox_dispatch_state", "Up"), + }) + { + if (!census.Types.TryGetValue((InfrastructureAssembly, typeName), out var type)) + { offenders.Add("Missing SQL writer type: " + typeName); continue; } + var methods = type.Methods.Where(method => method.Name == methodName).ToArray(); + if (methods.Length != 1) offenders.Add("Missing or ambiguous SQL writer: " + typeName + "." + methodName); + else expected.Add(MethodId(methods[0])); + } + CheckSet(offenders, "literal outbox writer methods", writers, expected); + return offenders; + } + + private static IEnumerable<(MethodDefinition Body, MethodDefinition Owner)> OwnedBodies(ProductionCensus census) + { + var owners = census.Types.Values.SelectMany(type => type.Methods) + .SelectMany(method => method.CustomAttributes.Where(attribute => attribute.AttributeType.FullName is + "System.Runtime.CompilerServices.AsyncStateMachineAttribute" or "System.Runtime.CompilerServices.IteratorStateMachineAttribute" + or "System.Runtime.CompilerServices.AsyncIteratorStateMachineAttribute") + .Where(attribute => attribute.ConstructorArguments.FirstOrDefault().Value is TypeReference) + .Select(attribute => (Type: Key((TypeReference)attribute.ConstructorArguments[0].Value), Method: method))) + .ToDictionary(item => item.Type, item => item.Method); + foreach (var method in census.Types.Values.SelectMany(type => type.Methods).Where(method => method.HasBody)) + yield return (method, method.Name == "MoveNext" && owners.TryGetValue(Key(method.DeclaringType), out var owner) ? owner : method); + } + + private static SortedSet<string> ObservationOffenders(ProductionCensus census) + { + var offenders = new SortedSet<string>(StringComparer.Ordinal); + var types = census.Types; + CheckSet(offenders, "observation production assemblies", census.Modules.Select(module => module.Assembly.Name.Name), ProductionAssemblies.Names()); + foreach (var subject in ObservationSubjects) + if (!types.ContainsKey((ApiAssembly, subject))) offenders.Add("Missing observation subject: " + subject); + if (offenders.Count != 0) return offenders; + var pending = new Queue<TypeDefinition>(new[] { types[(ApiAssembly, Observation)], types[(ApiAssembly, Health)] }); + var visited = new HashSet<(string Assembly, string Type)>(); + while (pending.TryDequeue(out var type)) + { + if (!visited.Add(Key(type))) continue; + foreach (var reference in Il.ReferencedTypeReferences(type)) + { + if (reference.Namespace.StartsWith("Npgsql", StringComparison.Ordinal) + || reference.Namespace.StartsWith("System.Data", StringComparison.Ordinal) + || reference.Namespace.StartsWith("Microsoft.EntityFrameworkCore", StringComparison.Ordinal) + || reference.FullName is "System.IServiceProvider" or "Microsoft.Extensions.DependencyInjection.IServiceScopeFactory" + or "Microsoft.Extensions.DependencyInjection.IKeyedServiceProvider") + offenders.Add(TypeId(type) + " -> " + reference.FullName); + if (!types.TryGetValue(Key(reference), out var helper)) continue; + pending.Enqueue(helper); + if (!helper.IsSealed) + foreach (var implementation in types.Values.Where(candidate => !candidate.IsInterface && Derives(candidate, helper, types))) + pending.Enqueue(implementation); + } + } + return offenders; + } + + private static ServiceCollection ObservationServices() + { + var services = new ServiceCollection(); + services.AddSingleton(IntegrationEventHandlerRegistry.Discover()); + services.AddLearnStackPersistence(new ConfigurationBuilder().Build()); + return services; + } + + private static SortedSet<string> ObservationRegistrationOffenders(IServiceCollection services) + { + var offenders = new SortedSet<string>(StringComparer.Ordinal); + var workers = services.Where(descriptor => descriptor.ServiceType == typeof(IHostedService) + && descriptor.ImplementationType == typeof(OutboxProcessor)).ToArray(); + if (workers.Length != 1 || workers[0].Lifetime != ServiceLifetime.Singleton) offenders.Add("Mandatory singleton worker registration"); + using var provider = services.BuildServiceProvider(); + var checks = provider.GetRequiredService<IOptions<HealthCheckServiceOptions>>().Value.Registrations + .Where(check => check.Name == OutboxHealthCheck.Name).ToArray(); + if (checks.Length != 1 || checks[0].Factory(provider).GetType() != typeof(OutboxHealthCheck)) offenders.Add("Private outbox health registration"); + if (provider.GetRequiredKeyedService<Lazy<Npgsql.NpgsqlDataSource>>(OutboxDispatcherDataSource.Key).IsValueCreated) + offenders.Add("Observation forced dispatcher source construction"); + return offenders; + } + + private static Action AddSqlProbe(ModuleDefinition module, string sql) + { + var type = AddProbe(module, "OutboxSqlWriter"); + AddSqlMethod(type, "Write", sql); + return () => module.Types.Remove(type); + } + + private static MethodDefinition AddSqlMethod(TypeDefinition type, string name, string sql) + { + var method = new MethodDefinition(name, MethodAttributes.Public | MethodAttributes.Static, type.Module.TypeSystem.Void); + method.Body.Instructions.Add(Instruction.Create(OpCodes.Ldstr, sql)); + method.Body.Instructions.Add(Instruction.Create(OpCodes.Pop)); + method.Body.Instructions.Add(Instruction.Create(OpCodes.Ret)); + type.Methods.Add(method); + return method; + } + + private sealed class UnrelatedHealthCheck : IHealthCheck + { + public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) => + Task.FromResult(HealthCheckResult.Healthy()); + } +} diff --git a/docs/architecture/04-technical-architecture.md b/docs/architecture/04-technical-architecture.md index 4a33bd70..14df334d 100644 --- a/docs/architecture/04-technical-architecture.md +++ b/docs/architecture/04-technical-architecture.md @@ -189,7 +189,7 @@ flowchart LR - All side-effects beyond the database transaction are deferred to **integration events** written to a transactional `outbox` table in the same transaction as the domain change. - The API-host `OutboxProcessor` polls the outbox and publishes admitted events through `IEventBus`. P02b-2 Step 3 implements hosted execution, with - verification/reviews in progress; real required subscriptions remain Pending + both independent review rounds complete; real required subscriptions remain Pending until P02b-3 supplies durable consumer execution. - Idempotency keys plus consumer-side deduplication guarantee at-least-once with effective once-only semantics. diff --git a/docs/architecture/09-tenant-isolation.md b/docs/architecture/09-tenant-isolation.md index ed883b35..bc2136e4 100644 --- a/docs/architecture/09-tenant-isolation.md +++ b/docs/architecture/09-tenant-isolation.md @@ -162,7 +162,7 @@ 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. P02b-2 Step 3 implements confined hosted dispatch; -verification and reviews are in progress. Consumer delivery remains P02b-3. +both independent review rounds are complete. Consumer delivery remains P02b-3. ## Default org semantics diff --git a/docs/architecture/15-event-and-outbox.md b/docs/architecture/15-event-and-outbox.md index 2c03d605..a863c48e 100644 --- a/docs/architecture/15-event-and-outbox.md +++ b/docs/architecture/15-event-and-outbox.md @@ -27,7 +27,7 @@ not land together. | `outbox_messages` table, its schema, and its LearnStack ownership | [Phase 02a Packet 6](../roadmap/phase-02a-kernel-tenancy.md) | Schema and ownership shipped in Packet 6; producer dispatch is implemented in P02b-2 Step 3 | | `IEventBus` port + `InProcessEventBus` | [Phase 02a Packet 5](../roadmap/phase-02a-kernel-tenancy.md) | The only registered implementation | | `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` and the producer claim protocol | [P02b-2](../roadmap/phase-02b-events-auth.md#p02b-2-step-3--hosted-lifecycle-and-observability-2026-10-12) | Implemented through Step 3; verification/reviews in progress; Step 4 enforcement pending | +| `OutboxProcessor` and the producer claim protocol | [P02b-2](../roadmap/phase-02b-events-auth.md#p02b-2-step-3--hosted-lifecycle-and-observability-2026-10-12) | Implemented through Step 3; both independent review rounds complete; Step 4 enforcement implemented; validation/reviews in progress | | `IInboxGuard` and durable consumer transactions | [P02b-3](../roadmap/phase-02b-events-auth.md) | Pending | | 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)) | @@ -58,10 +58,11 @@ and [Accepted ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md authorize fenced producer recovery, atomic terminal audit and the coupled shared inbox/subscriber contract. Steps 1–2 implement schema/composition, fenced transitions and atomic terminal audit; each completed both review rounds. -Step 3 implements hosted execution and observation, with verification/reviews -in progress in its +Step 3 implements hosted execution and observation, with both independent +review rounds complete in its [delivery record](../roadmap/phase-02b-events-auth.md#p02b-2-step-3--hosted-lifecycle-and-observability-2026-10-12). -Step 4 enforcement and P02b-3 inbox/consumer delivery remain pending. Numerical +Step 4 enforcement is implemented; validation and reviews are in progress. +P02b-3 inbox/consumer delivery remains pending. Numerical runtime bounds live in the package, canonical DDL/grants in Standards 05 and metric names in Standards 10. @@ -105,8 +106,8 @@ claims one admitted row immediately before publishing and fences its acknowledge Required Pending subscriptions hold rows before acquisition. P02b-3 durably accepts required subscriptions, then executes each on its own app-role transaction. Shared inbox completion and business/audit/ledger completion commit together. Producer -production and the API worker are implemented; Step 3 verification/reviews are in -progress. Durable consumer execution remains P02b-3, and real required +production and the API worker are implemented; both Step 3 review rounds are +complete. Durable consumer execution remains P02b-3, and real required subscriptions remain Pending. ## Outbox table @@ -125,7 +126,7 @@ establish durable publish order. Accepted P02b-2 adds paired `locked_by` / is a fresh acquisition token, never stable worker identity. `available_after` controls due time and retry backoff. Canonical DDL/indexes are in Standards 05, implemented by P02b-2 Step 1 with both review rounds complete. Step 3 implements -dispatcher execution; verification and reviews are in progress. +dispatcher execution; both independent review rounds are complete. ### Why the `learnstack_outbox_admin` role bypasses RLS — and what bounds the bypass @@ -133,7 +134,7 @@ One API-host BackgroundService schedules every tenant. The existing bypass role uses its own named keyed, lazy data source; no Seeder/web forwarding or app-role membership is allowed. Configuration validates the exact username without connecting or disclosing parse input. Step 3 implements mandatory worker-startup -resolution and connectivity checks; verification and reviews are in progress. +resolution and connectivity checks; both independent review rounds are complete. Every new physical connection verifies exact session/current role, bounded attributes and no privilege-escalating membership. @@ -151,8 +152,9 @@ The platform-admin gate and pre-entry audit remain unchanged. Four roles and separate credentials remain the closed model; the code-path and role rules are implemented for transition-witness construction and terminal audit in Step 2; both independent review rounds passed. Step 3 implements hosted execution and -observation, with verification/reviews in progress. Credential/publication -censuses remain Step 4. +observation, with both independent review rounds complete. Step 4 implements +credential/publication censuses and planted controls; final validation and +reviews are in progress. <a id="inbox-table-per-consumer-module"></a> @@ -352,8 +354,9 @@ poll, which is neither. ### The claim protocol -**ADR-0046 producer runtime implemented through P02b-2 Step 3; verification and -reviews in progress.** The processor admits a construction-free +**ADR-0046 producer runtime implemented through P02b-2 Step 3; both independent +review rounds complete.** Step 4 enforcement, validation and reviews are in +progress. The processor admits a construction-free manifest policy before acquisition. Any required Pending subscription holds the contract and all aliases unchanged, even if stored payload is malformed. Active requires declared handler registration. Only explicit intentional absence may diff --git a/docs/architecture/31-audit-subsystem.md b/docs/architecture/31-audit-subsystem.md index e23c3c95..24084dc6 100644 --- a/docs/architecture/31-audit-subsystem.md +++ b/docs/architecture/31-audit-subsystem.md @@ -672,7 +672,7 @@ 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. P02b-2 Step 3 implements hosted producer dispatch, -with verification/reviews in progress; production consumer delivery/inbox wiring +with both independent review rounds complete; production consumer delivery/inbox wiring remains P02b-3 under Accepted ADR-0046. ### The two catalogue faces, and the three columns nothing fills yet @@ -885,7 +885,7 @@ 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. P02b-2 Step 3 implements hosted producer dispatch, with -verification/reviews in progress; consumer delivery/inbox remains P02b-3. +both independent review rounds complete; consumer delivery/inbox remains 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 @@ -1719,7 +1719,7 @@ for MUST-class would lock in exactly the defect ADR-0033 removes. |-------|-------------| | [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. Hosted dispatch is implemented in P02b-2 Step 3; consumer delivery/inbox remains P02b-3. | -| P02b-2 Steps 1–3 | Schema/grants and confined producer transitions with MUST audit through the row-bound fifth seam; real-role atomicity/identity proofs. Steps 1–2 completed both review rounds. Step 3 implements hosted dispatch and observation, with verification/reviews in progress; Step 4 enforcement remains pending. | +| P02b-2 Steps 1–3 | Schema/grants and confined producer transitions with MUST audit through the row-bound fifth seam; real-role atomicity/identity proofs. Steps 1–2 completed both review rounds. Step 3 implements hosted dispatch and observation, with both independent review rounds complete; Step 4 enforcement is implemented; validation and reviews are in progress. | | P02b-3 (Accepted contract; pending) | Shared transport-owned inbox and independent delivery ledger; per-subscription audit frames and Tenancy-owned terminal audit. Ordinary executor refuses privileged subscriptions. | | [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. | diff --git a/docs/decisions/0046-durable-event-delivery-and-recovery.md b/docs/decisions/0046-durable-event-delivery-and-recovery.md index e53858ed..67598aea 100644 --- a/docs/decisions/0046-durable-event-delivery-and-recovery.md +++ b/docs/decisions/0046-durable-event-delivery-and-recovery.md @@ -430,6 +430,32 @@ Registered until P02b-3 and must not pass on an empty production-handler set. The package names the decisive migration, deadline, contention, recovery and privilege proofs. +## Amendments + +### Amendment 1 — Producer runtime delivery (2026-10-12) + +P02b-2 Steps 1–3 deliver the producer runtime authorized above: schema/grants and +confined composition, fenced claims/recovery/transitions with row-bound atomic +terminal MUST audit, and the mandatory API-host processor with bounded +publication ownership, observation and unmapped health. All three steps completed +both independent review rounds. The +[Step 3 delivery record](../roadmap/phase-02b-events-auth.md#p02b-2-step-3--hosted-lifecycle-and-observability-2026-10-12) +owns validation, reviewed corrections and the runtime proof scope. + +[Step 4 enforcement and closeout](../roadmap/phase-02b-events-auth.md#p02b-2-step-4--enforcement-and-closeout-2026-10-12) +implements the full production censuses and planted controls; the focused 21-case +census/publication run passes with zero skips and the remaining role/health +catalogue rules are Implemented. Final regression, independent reviews and +exact-head CI remain in progress. This +amendment records delivered producer runtime, not completed P02b-2 packet closeout +or a merge, and changes no decision, rationale, grant budget or subscriber policy. + +P02b-3 remains unimplemented: durable acceptance, shared inbox/delivery ledger, +independent subscriber retries and terminalization, both real default +subscriptions and Seeder handoff stay with that packet. Required production +subscriptions remain Pending; test-owned Active subscriptions supply producer +runtime proof without claiming durable consumer delivery. + ## References - [ADR-0006](0006-events-and-outbox.md), [ADR-0010](0010-cross-module-communication.md) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index be2f061d..a7d5e76b 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -75,7 +75,7 @@ name P02b-2/P02b-3 activation; acceptance claims no runtime delivery. | 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. [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 | -| 0046 | [Durable Event Delivery and Recovery](0046-durable-event-delivery-and-recovery.md) | Accepted 2026-10-12: fenced producer leases/retries, Pending readiness holds and atomic row-bound terminal audit; P02b-2 Steps 1–2 implemented with both review rounds complete; Step 3 hosted dispatch/observation implemented with verification/reviews in progress; Step 4 enforcement pending. Shared inbox/durable subscription acceptance and independent retries remain P02b-3. Named bounded supersessions preserve earlier Accepted records. | +| 0046 | [Durable Event Delivery and Recovery](0046-durable-event-delivery-and-recovery.md) | Accepted 2026-10-12: fenced producer leases/retries, Pending readiness holds and atomic row-bound terminal audit; P02b-2 Steps 1–2 implemented with both review rounds complete; Step 3 hosted dispatch/observation implemented with both independent review rounds complete; Step 4 enforcement implemented; validation/reviews in progress. [Amendment 1](0046-durable-event-delivery-and-recovery.md#amendment-1--producer-runtime-delivery-2026-10-12) records producer runtime delivery and its review evidence. Shared inbox/durable subscription acceptance and independent retries remain P02b-3. Named bounded supersessions preserve earlier Accepted records. | | 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 | | 0052 | [Anonymous Public Read Boundary](0052-anonymous-public-read-boundary.md) | Accepted 2026-10-03: host-resolved bootstrap, immutable host ceiling, mandatory marked dispatch and read-only ambient frames; ADR-0036/0040 Amendment 8; P02d-4 Steps 1–3 deliver host provenance, physical read-only frames and all institution GET/HEAD reads; both Step 3 review rounds pass; Step 4 delivers OpenAPI/SDK/CI controls; both review rounds and focused fix verification pass; six real required CI jobs and live sixth-check activation verified; P02d-4 complete and merged through PR #25 on 2026-10-08 ([Amendment 13](0052-anonymous-public-read-boundary.md#amendment-13--merge-closeout-2026-10-08)) | diff --git a/docs/glossary.md b/docs/glossary.md index f54480fa..d106d76b 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -341,7 +341,7 @@ These entries distinguish P02d-2 contracts from P02d-3 internal read contracts: | **`IFeatureFlags`** | The **only** module-facing entitlement read: `IsEnabledAsync(FeatureKey)` and `GetLimitAsync(LimitKey)` returning `long`. It resolves the tenant from `ITenantContext` and throws when there is none, so module code can never name another tenant. It **composes** rather than queries: a plan-projected key resolves through `IEntitlementProvider.GetAsync` — not by reading `platform_entitlement_cache` — a tenant-flag key reads `tenant_feature_flags`, and the killswitch overlay is applied last and wins over both. Its implementation lives in `LearnStack.Modules.Tenancy.Infrastructure`, because `tenant_feature_flags` is that module's table, and is registered at the composition root. `IFeatureFlagService` and `IUsageLimitService` are **withdrawn**, not renamed ([ADR-0021](decisions/0021-feature-based-entitlement.md)). Per [ADR-0045 § 2](decisions/0045-entitlement-and-feature-flag-socket.md). | | **`IHostToTenantResolver` / `HostResolution`** | Interface for host → `(tenant_id, organization_id?)` resolution, returning the `HostResolution(TenantId, OrganizationId?)` record or null. Reads `platform_host_to_tenant` and **nothing else** — never the Hub, because an anonymous page load must not depend on a control plane being reachable ([ADR-0034](decisions/0034-hub-contract-surface-invariant.md)). | | **APISIX** | The gateway in standalone YAML-reload mode per [ADR-0015](decisions/0015-api-gateway-apisix.md). The intended tenant-facing ingress, demand-gated to [Phase 11](roadmap/phase-11-production-hardening.md); until then ASP.NET middleware carries the same responsibilities in-process. `/api/internal/*` is never proxied by it — that listener is mTLS-only inside the pod. | -| **`OutboxProcessor`** | The API-host BackgroundService that claims one admitted outbox row immediately before invocation, with a fresh acquisition token in `locked_by`, a database-time lease and one counted attempt. Token/expiry-fenced transitions own retry and explicit audited terminal state; stable worker identity is diagnostic only. Accepted [ADR-0046](decisions/0046-durable-event-delivery-and-recovery.md) governs P02b-2 implementation: Steps 1–2 completed both review rounds, and Step 3 implements hosted execution/observation with verification/reviews in progress. Step 4 enforcement remains pending; at-least-once recovery still needs P02b-3 inbox deduplication. | +| **`OutboxProcessor`** | The API-host BackgroundService that claims one admitted outbox row immediately before invocation, with a fresh acquisition token in `locked_by`, a database-time lease and one counted attempt. Token/expiry-fenced transitions own retry and explicit audited terminal state; stable worker identity is diagnostic only. Accepted [ADR-0046](decisions/0046-durable-event-delivery-and-recovery.md) governs P02b-2 implementation: Steps 1–2 completed both review rounds, and Step 3 implements hosted execution/observation with both independent review rounds complete. Step 4 enforcement is implemented; validation and reviews are in progress; at-least-once recovery still needs P02b-3 inbox deduplication. | | **`IUnitOfWork`** | The ambient unit of work: **one database connection per scope**, and the transaction on it ([ADR-0040](decisions/0040-ambient-unit-of-work.md)). Every module `DbContext` resolved in the scope is built on that connection and enlisted in that transaction, and `IAuditStore` and `IOutbox` reach it through the same seam. The connection count is a correctness property, not a performance one: `SET LOCAL app.tenant_id` is connection- **and** transaction-local, so a context on its own connection reads zero rows from every tenant-owned table — silently. It exposes no `SaveChangesAsync`; contexts save themselves and it owns only the transaction boundary. [ADR-0040 Amendment 8](decisions/0040-ambient-unit-of-work.md#amendment-8--read-only-public-frames-2026-10-03) accepts explicit read-only/read-write intent, defaulting to writable: same-mode joins preserve ownership, mixed-mode joins poison the unit, and successful read-only completion permits a later writer. P02d-4 Step 1 delivers these modes and lifecycle controls; poison still requires a fresh scope. | | **`IInboxGuard`** | The Accepted shared, transport-owned inbox claim/completion seam, pending P02b-3. Deduplication is `(tenant_id, subscription_id, event_id)`, claimed before handler construction on the announced app-role transaction. Business state, completed inbox, produced outbox, MUST audit and fenced ledger completion commit together; rollback removes a new claim. Handlers do not call the guard, and targeted replay never resets a completed inbox. See [ADR-0046](decisions/0046-durable-event-delivery-and-recovery.md#subscriber-acceptance-and-inbox-ownership). | | **Inbox** | Shared tenant-owned completion record for one stable subscription/event pair. A new claim is uncommitted business-transaction work; only successful completion persists it. The delivery ledger owns failed/terminal/replay state. Canonical DDL remains P02b-3 in [Database Standards](standards/05-database.md#inbox). | diff --git a/docs/modules/tenancy/README.md b/docs/modules/tenancy/README.md index f61accc3..e2b9811a 100644 --- a/docs/modules/tenancy/README.md +++ b/docs/modules/tenancy/README.md @@ -494,8 +494,8 @@ 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 Step 3 implements hosted dispatch, with verification/reviews -in progress. P02b-3 owns consumer delivery. +pending rows; P02b-2 Step 3 implements hosted dispatch, with both independent +review rounds complete. P02b-3 owns consumer delivery. The subscriptions below are accepted flow contracts, not delivered handlers. [ADR-0046](../../decisions/0046-durable-event-delivery-and-recovery.md) accepts G1's diff --git a/docs/modules/tenancy/audit.md b/docs/modules/tenancy/audit.md index 8c94ddb2..87f10f81 100644 --- a/docs/modules/tenancy/audit.md +++ b/docs/modules/tenancy/audit.md @@ -8,8 +8,8 @@ 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; -P02b-2 Step 3 implements hosted producer dispatch, with verification/reviews in -progress; subscribers remain P02b-3. +P02b-2 Step 3 implements hosted producer dispatch, with both independent review +rounds complete; subscribers remain P02b-3. **ADR-0046 producer terminal writer implemented — 2026-10-12; both review rounds complete.** diff --git a/docs/modules/tenancy/permissions.md b/docs/modules/tenancy/permissions.md index 6a7457a8..597282f3 100644 --- a/docs/modules/tenancy/permissions.md +++ b/docs/modules/tenancy/permissions.md @@ -9,10 +9,10 @@ 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. -**ADR-0046 implementation through P02b-2 Step 3 — 2026-10-12.** Steps 1–2 -completed both review rounds; Step 3 verification/reviews are in progress and -Step 4 enforcement remains pending. The API-owned dispatcher and terminal audit -add no permission key or HTTP endpoint. P02b-3's +**ADR-0046 implementation through P02b-2 Step 3 — 2026-10-12.** Steps 1–3 +completed both independent review rounds. Step 4 enforcement, validation and +reviews are in progress. The API-owned dispatcher and terminal audit add no +permission key or HTTP endpoint. P02b-3's targeted replay is internal; Phase 03 separately decides operator permission and routes. Neither grants a tenant administrator bypass-role access. diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index ecc873e3..7ccc1a1e 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -102,9 +102,11 @@ not deferred to the showcase phase. records final CI verification and the completed delivery/review history. P02b-2's revised package and ADR-0046 are Accepted. Steps 1–2 completed both review rounds; Step 3 implements hosted dispatch and observation, with - verification/reviews in progress in its + both independent review rounds complete in its [delivery record](phase-02b-events-auth.md#p02b-2-step-3--hosted-lifecycle-and-observability-2026-10-12). - Step 4 enforcement remains pending; consumer delivery remains P02b-3. + Step 4 enforcement is implemented; validation and reviews are in progress in its + [delivery record](phase-02b-events-auth.md#p02b-2-step-4--enforcement-and-closeout-2026-10-12); + consumer delivery remains 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 4bc98e1b..69280f62 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -32,8 +32,10 @@ delivery evidence and both completed review rounds. Step 2 implements durable transitions and terminal audit; both independent review rounds pass. Step 3 implements hosted lifecycle and observation; its [record](#p02b-2-step-3--hosted-lifecycle-and-observability-2026-10-12) owns -verification/reviews, which are in progress. Step 4 enforcement and P02b-3 -implementation remain pending. +verification and both completed review rounds. Step 4 enforcement is implemented; +validation and reviews are in progress in its +[record](#p02b-2-step-4--enforcement-and-closeout-2026-10-12). P02b-3 implementation +remains pending. **Phase 02d handoff — 2026-10-11.** P02b-0 re-verifies the delivered `/api/v1` public contracts, native HTTPS on retained `*.learnstack.local` seed hosts, @@ -103,8 +105,8 @@ keeps the phase from re-deciding settled questions or repairing repaired defects 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. P02b-2 Step 3 implements the -API-host `OutboxProcessor` BackgroundService, with verification/reviews in -progress. Still absent are `IInboxGuard`, `inbox_messages`, Hangfire and its +API-host `OutboxProcessor` BackgroundService, with both independent review +rounds complete. Still absent are `IInboxGuard`, `inbox_messages`, Hangfire and its package pins, and the realm's protocol mappers. Packet 5 documented a planned `IInboxGuard` seam in comments, without an interface declaration. P02b-3 owns its shared transport implementation and table under ADR-0046. @@ -256,7 +258,7 @@ Accepted, and its catalogue rows registered, before its first line of code. |---|---|---| | **P02b-0** | Phase entry: every premise the register below cites re-verified against `HEAD` — the code, the corpus, the Hub repository — and the register corrected where Phase 02d moved it. It drafts no record: each gate closes in the decision pass of the first packet it shapes, and each glossary headword this phase needs — `inbox`, outbox lease, dead letter, audit frame, job activator, integration-event consumer — is written in the pass whose gate fixes its meaning | [Phase 02d](phase-02d-walking-skeleton.md) exits | | **P02b-1** | **Complete and merged — [PR #29 closeout](#p02b-1-merge-and-closeout-2026-10-12).** Producer: `IOutbox`, the `OutboxFlushBehavior` invariant guard, successful-save domain-event collection and owner-only completion, and the first event with its enqueue path | G2, G3 (the key's derivation), G5 (the stored identities), G6, G18 (the payload rule), G19 Accepted | -| **P02b-2** | **Decision Accepted; Steps 1–2 complete; Step 3 in progress.** Dispatcher: the lease migration and grant extension, claim / publish / mark, backoff, the producer terminal state, the credential and its boot guard, the metrics and the health check | G1, G3, G4, G5, G7, G8, G17, G18 (`last_error`) Accepted; P02b-1 | +| **P02b-2** | **Decision Accepted; Steps 1–3 complete; Step 4 implemented, validation/reviews in progress.** Dispatcher: the lease migration and grant extension, claim / publish / mark, backoff, the producer terminal state, the credential and its boot guard, the metrics and the health check | G1, G3, G4, G5, G7, G8, G17, G18 (`last_error`) Accepted; P02b-1 | | **P02b-3** | Consumer: durable acceptance, shared inbox/ledger DDL and `IInboxGuard`, the common per-delivery transaction, both real defaults subscriptions and Seeder handoff, independent attempts and audited terminal state, targeted internal ledger replay | G1, G6, G7 Accepted; P02b-2 | | **P02b-4** | Background jobs: the runner, its storage, the tenant-context hook, the enqueue guard, no job definition | G9 Accepted; P02b-3 | | **P02b-5** | API authentication: JwtBearer wiring and its insertion order, claim population, the host/JWT cross-check through the existing recorder, token-keyed rate limits, the 401 rows, one authenticated endpoint | G10, G11, G12 (the BFF's client), G14, G20 Accepted | @@ -285,14 +287,14 @@ premise a row cites is re-verified at that pass rather than trusted. | # | 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` keyed by tenant/stable subscription/event, transport-claimed before construction; a separate delivery ledger owns retries, terminal state and targeted replay; completion shares the business/audit/outbox transaction | **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) | Accepted — 2026-10-12; delivery pending | +| 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` keyed by tenant/stable subscription/event, transport-claimed before construction; a separate delivery ledger owns retries, terminal state and targeted replay; completion shares the business/audit/outbox transaction | **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) | Accepted — 2026-10-12; decision/amendments delivered; subscriber implementation pending P02b-3 | | 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 under this 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, deterministic eligible selection; no enqueue/commit-order guarantee | Derivation: [Accepted ADR-0057](../decisions/0057-transactional-event-production-and-completion.md). Ordering: Accepted ADR-0046 and the P02b-2 package, followed by [§ Ordering](../architecture/15-event-and-outbox.md#ordering) alignment under this acceptance | P02b-1 (whether the key's derivation is checked structurally: the first event declares a key), P02b-2 (the ordering bound) | Derivation and ordering Accepted; ordering delivery pending 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 | Accepted ADR-0046 + dated ADR-0006 disclosure under this acceptance; Database Standards § Outbox DDL, index and grant edits | P02b-2 | Accepted — 2026-10-12; delivery pending | -| 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 under this acceptance; Database Standards alignment. ADR-0046 accepts declared resolution, Pending holds and intentional absence; dispatch runtime remains P02b-2 | P02b-1 (the stored type identity and the row's identity, both written at enqueue), P02b-2 (unresolvable, retired and unsubscribed types) | Stored identities and resolution outcomes Accepted; dispatch pending P02b-2 | +| 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, deterministic eligible selection; no enqueue/commit-order guarantee | Derivation: [Accepted ADR-0057](../decisions/0057-transactional-event-production-and-completion.md). Ordering: Accepted ADR-0046 and the P02b-2 package, followed by [§ Ordering](../architecture/15-event-and-outbox.md#ordering) alignment under this acceptance | P02b-1 (whether the key's derivation is checked structurally: the first event declares a key), P02b-2 (the ordering bound) | Derivation and ordering Accepted; scoped ordering runtime [implemented in P02b-2](#p02b-2-step-3--hosted-lifecycle-and-observability-2026-10-12); final Step 4 validation/reviews in progress | +| 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 | Accepted ADR-0046 + dated ADR-0006 disclosure under this acceptance; Database Standards § Outbox DDL, index and grant edits | P02b-2 | Accepted — 2026-10-12; producer attempts/terminal state [implemented](#p02b-2-step-2--durable-transitions-and-terminal-audit-2026-10-12) | +| 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 under this acceptance; Database Standards alignment. ADR-0046 accepts declared resolution, Pending holds and intentional absence; dispatch runtime remains P02b-2 | P02b-1 (the stored type identity and the row's identity, both written at enqueue), P02b-2 (unresolvable, retired and unsubscribed types) | Stored identities and resolution outcomes Accepted; resolver/readiness runtime [implemented](#p02b-2-step-3--hosted-lifecycle-and-observability-2026-10-12); Active durable subscriptions pending P02b-3 | | 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 delivered; delivery/inbox contract Accepted, implementation pending 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`? | MUST `security-event`; producer `platform.outbox.dead_letter` and reserved P3 `platform.delivery.dead_letter`; fenced terminal transition and audit INSERT on the same short transaction through a narrow fifth write seam; failed audit retries terminalization without publication | Accepted ADR-0046 + bounded ADR-0044 audit-grant replacement, separate additive fifth-writer seam amendment and ADR-0033 same-transaction clarification under this acceptance; `DeclareOffPath` registration and Audit Coverage baseline | P02b-2 (the producer dead-letter row its criteria assert), P02b-3 | Accepted — 2026-10-12; delivery pending | -| 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 | Accepted ADR-0046 explicitly replaces the routine-dispatch coverage rule and bounds the ADR-0003 grant extension; dated disclosures under this acceptance, not an ADR-0041 correction | P02b-2 | Accepted — 2026-10-12; delivery pending | +| 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`? | MUST `security-event`; producer `platform.outbox.dead_letter` and reserved P3 `platform.delivery.dead_letter`; fenced terminal transition and audit INSERT on the same short transaction through a narrow fifth write seam; failed audit retries terminalization without publication | Accepted ADR-0046 + bounded ADR-0044 audit-grant replacement, separate additive fifth-writer seam amendment and ADR-0033 same-transaction clarification under this acceptance; `DeclareOffPath` registration and Audit Coverage baseline | P02b-2 (the producer dead-letter row its criteria assert), P02b-3 | Accepted — 2026-10-12; producer terminal audit [implemented](#p02b-2-step-2--durable-transitions-and-terminal-audit-2026-10-12); subscriber terminal audit pending 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 | Accepted ADR-0046 explicitly replaces the routine-dispatch coverage rule and bounds the ADR-0003 grant extension; dated disclosures under this acceptance, not an ADR-0041 correction | P02b-2 | Accepted — 2026-10-12; confined dispatcher exception/census [implemented](#p02b-2-step-4--enforcement-and-closeout-2026-10-12); final validation/reviews in progress | | 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 | @@ -301,8 +303,8 @@ premise a row cites is re-verified at that pass rather than trusted. | 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? | Targeted internal subscriber replay in P02b-3; producer/operator reset and fencing require a separate Phase 03 contract | 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) | Accepted — internal subscriber replay P02b-3; operator contract Phase 03 | | 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) | Event metrics Accepted — 2026-10-12; job metrics P02b-4 | -| 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 under this 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 and failure descriptors Accepted; erasure inventory open 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) | Event metrics Accepted — 2026-10-12; runtime/guards [implemented](#p02b-2-step-4--enforcement-and-closeout-2026-10-12); final validation/reviews in progress; job metrics pending P02b-4 | +| 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 under this 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 and failure descriptors Accepted and implemented; erasure inventory open 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 under this acceptance. ADR-0046 accepts distinct subscription deduplication and envelope delivery scope; 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 and delivery contract Accepted; subscriptions/Seeder handoff pending P02b-3 | | 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 | @@ -1696,12 +1698,49 @@ Both inspected the prior receipts and production paths. They did not claim a new full build or Docker rerun. Step 3 is closed; Step 4 may add the complete credential/publication/health censuses and close the packet. +### P02b-2 Step 4 — Enforcement and closeout (2026-10-12) + +**Implementation complete; validation and independent reviews in progress.** +Steps 1–3 completed both independent review rounds; Step 3 is closed at `02540d0`. +Step 4 adds nonempty whole-production credential/publication censuses, exact +keyed factories and consumer bindings, and planted helper/delegate/async controls. +Only the processor constructs the task-owned publication attempt; its private +callback is the sole event-bus publication path. The existing canonical module +bus rule now also enforces that complete production fence. + +The five-subject observation graph follows async and polymorphic helpers, +rejects persistence/service-locator capabilities, and inspects actual gauge +callbacks. Actual DI controls reject missing/duplicate workers/checks and wrong +factories. The literal-SQL census admits only exact producer enqueue, dispatcher +status-transition and migration backfill methods; quoted/schema/case/CTE and +missing/extra-writer controls prove its reach. These are bounded IL/literal-SQL +checks, not arbitrary reflection or dynamic-SQL dataflow analysis. + +**Verification in progress:** the full Release solution build reports zero +warnings/errors, and solution-wide `dotnet format --verify-no-changes --no-restore` +passes. All 21 focused census/publication cases pass with zero skips, +including compiled actual production subjects and every planted control. Role +and Health catalogue rules are Implemented; Retry also names its canonical +architecture method. The source/catalogue recount is 193 architecture test +methods, 192 Implemented rules, 118 in that assembly and 74 elsewhere. + +Full Release regression, mandatory Docker proofs, applicable launcher/frontend +checks and both Step 4 review rounds remain in progress. Final packet closeout +also requires the seven existing CI checks at the exact PR head. This focused +receipt claims no completed full regression, Step 4 review round, PR-head CI, +packet closeout or merge. + +P02b-3 still owns durable acceptance, shared inbox/ledger, independent subscriber +retries, both real default subscriptions and Seeder handoff. Required production +subscriptions remain Pending; test-owned Active contracts prove producer runtime +without claiming consumer business effects. + ### Durable outbox dispatch 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. P02b-2 Step 3 implements hosted producer dispatch, with -verification/reviews in progress; consumer delivery remains P02b-3. +first publisher. P02b-2 Step 3 implements hosted producer dispatch, with both +independent review rounds complete; consumer delivery remains P02b-3. The table shipped in Packet 6. A handler calls `IOutbox.EnqueueAsync` and the row commits on the same **transaction** @@ -1733,8 +1772,9 @@ Dapr trigger verbatim — so it stays with Phase 11. - `ConnectionStrings:OutboxDispatcher` carries the `learnstack_outbox_admin` credential, registered as a keyed data source behind a `Lazy<>` with a boot-time role guard, on the pattern `Platform_DataSource_Resolved_Only_By_PlatformAdminScope` already holds. - `LearnStack_OutboxAdmin_Role_OnlyUsedBy_OutboxProcessor` remains **Registered** - in the catalogue until Step 4 supplies its full credential caller census. + `LearnStack_OutboxAdmin_Role_OnlyUsedBy_OutboxProcessor` is **Implemented** + after Step 4's nonempty credential caller census and planted controls pass; + final full regression and independent reviews remain in progress. - The three carriers that say the credential lives in "the worker host" — [Database Standards § Database roles](../standards/05-database.md#database-roles), `architecture/15` and `.env.example` — are derived cells, not a topology decision; @@ -2208,11 +2248,12 @@ Its own tests assert the positive case, a clean case staying clean, a sensitive outside a `throw` staying clean, no `AD0001` crash — the failure mode ADR-0032 Amendment 1 records for `LS0001` — and that the analyzer's token set equals the catalogue's. -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. 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. +P02b-2 bounds the durable failure descriptor under **G18** to closed stage, +reason and category codes, with only allowlisted SQLSTATE where applicable; +exception text/type, trace identity and payload are not persisted there. The +canonical DDL owns the byte bound. Step 1 implements the migration beside the +lease columns, including redaction of legacy descriptors without rewriting +event facts/state; Steps 2–3 implement transitions and bounded diagnostics. ### Architecture tests @@ -2240,9 +2281,9 @@ What this phase owes the catalogue beyond implementing those rows: 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. + `Outbox_Claim_IsHeld_Until_Dispatch_Completes` replaces the former unconditional + exactly-once assertion with live-lease contention, expiry/crash redelivery and + stale-transition refusal; Step 3 implements the actual two-worker proof. `Hangfire_Job_Payloads_Include_TenantId` gains the organization, actor and traceparent fields. `OutboxProcessor_NeverBlocks_OnSingleMessageFailure` gains the hanging-handler case. Two rules the catalogue stamps **02a** also @@ -2313,8 +2354,8 @@ command. The phase retains zero skips, per assembly. that adds `locked_by` / `locked_until` **and** extends `learnstack_outbox_admin`'s column-scoped `UPDATE` grant in the same change. - The dispatcher's credential registered as a keyed data source behind a boot-checked - `Lazy<>`, with `LearnStack_OutboxAdmin_Role_OnlyUsedBy_OutboxProcessor` moved from - Awaiting backfill to Implemented and a companion proving the sweep can fail. + `Lazy<>`, with `LearnStack_OutboxAdmin_Role_OnlyUsedBy_OutboxProcessor` promoted + from Registered only after the full census and planted controls pass. - `partition_key` persisted onto the row and rebuilt into the envelope on dispatch. `PartitionKey` abstract on `IntegrationEventBase` and per-key serialisation in `InProcessEventBus` shipped in Packet 5; the durable hop is this phase's. diff --git a/docs/standards/02-backend-coding.md b/docs/standards/02-backend-coding.md index 75209dd4..88513222 100644 --- a/docs/standards/02-backend-coding.md +++ b/docs/standards/02-backend-coding.md @@ -411,7 +411,7 @@ decides what happens *inside* the steps ADR-0033 named; the order is untouched: 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 Step 3 implements hosted dispatch through `IEventBus`, with - verification and reviews in progress. + both independent review rounds complete. 8. **Handler** — domain logic; returns `Result<T>`. **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 82793c70..49526e85 100644 --- a/docs/standards/05-database.md +++ b/docs/standards/05-database.md @@ -1007,7 +1007,7 @@ privileges implicitly. | `lesson_translations` | `SELECT, INSERT` | `SELECT` | — | ADR-0046 accepts the two P02b-2 cells above. Step 1 implements their migrations -and exact-grant proofs; both independent review rounds remain pending in the +and exact-grant proofs; both independent review rounds are complete in the [delivery record](../roadmap/phase-02b-events-auth.md#p02b-2-step-1--decision-schema-and-confined-composition-2026-10-12). Outbox status grants land in the Platform chain. The audit INSERT grant lands in a new Audit-chain migration after `audit_log` exists, never @@ -1396,8 +1396,9 @@ Public read models consumed by other modules: The canonical DDL below includes lease/terminal/identity/descriptor changes. Step 2 implements fenced transitions and atomic terminal auditing; both review rounds passed. -Step 3 implements hosted execution and metrics; verification/reviews are in progress. -Step 4 enforcement remains pending. Existing +Step 3 implements hosted execution and metrics; both independent review rounds +are complete. +Step 4 enforcement is implemented; validation and reviews are in progress. Existing migrations remain immutable; a new Platform-chain migration replaces free-form legacy `last_error` with `migration:legacy:redacted` before its byte CHECK. This specific ADR-0046 Platform migration temporarily uses owner-only `NO FORCE` @@ -1517,7 +1518,7 @@ 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. ADR-0046 accepts producer recovery and the shared inbox contract; P02b-2 Step 2 implements producer transitions and terminal audit, -Step 3 implements hosted dispatch with verification/reviews in progress, and +Step 3 implements hosted dispatch with both independent review rounds complete, and P02b-3 owns inbox/delivery runtime. `organization_id` is `uuid NULL` and mirrors the organization context of the transaction diff --git a/docs/standards/09-error-handling.md b/docs/standards/09-error-handling.md index d5a39bc7..a68f3aff 100644 --- a/docs/standards/09-error-handling.md +++ b/docs/standards/09-error-handling.md @@ -565,8 +565,9 @@ exception types (`LiveKit.NET.LiveKitException`, `Stripe.StripeException`, ## Outbox **Accepted ADR-0046; producer runtime implemented through P02b-2 Step 3.** -Step 3 verification and reviews are in progress; Step 4 enforcement remains -pending. Subscriber delivery/retries and inbox remain P02b-3. +Both Step 3 independent review rounds are complete. Step 4 enforcement is implemented; +validation and reviews are in progress. Subscriber delivery/retries and inbox +remain P02b-3. - Each committed normal claim increments producer `attempts` once, including an unknown crash outcome. Six total claims use retry delays 1 s, 5 s, 30 s, 5 min diff --git a/docs/standards/10-observability.md b/docs/standards/10-observability.md index 6226d891..40826599 100644 --- a/docs/standards/10-observability.md +++ b/docs/standards/10-observability.md @@ -254,7 +254,7 @@ backend treat business rejections as system failures. | `learnstack_entitlement_unresolved_total` | counter | — | Outbox metrics above are implemented in P02b-2 Step 3 under ADR-0046; -verification and reviews are in progress. Inbox counters remain P02b-3 and job +both independent review rounds are complete. Inbox counters remain P02b-3 and job metrics P02b-4. `event_type` comes only from the finite declared registry plus fixed unknown/retired/invalid categories. Never label event metrics by tenant, event, partition, subscription or trace identity. @@ -314,7 +314,7 @@ touched. | Outbox dispatcher (P02b-2 Step 3) | `outbox` | No actual progress or observation refresh for 45 s, any retained callback overrun, or terminal-audit failure | The `audit` check is registered in Phase 02a Packet 9; P02b-2 Step 3 registers -`outbox`, with verification and reviews in progress. +`outbox`, with both independent review rounds complete. Both are **not mapped**: `/healthz` stays a liveness probe, because a process that cannot write audit rows is still worth leaving alive. The readiness surface that reads the check, and the deployment-level backstop that diff --git a/docs/standards/11-security.md b/docs/standards/11-security.md index deced645..d7b4495c 100644 --- a/docs/standards/11-security.md +++ b/docs/standards/11-security.md @@ -245,8 +245,9 @@ for the full strategy. Standards-side: ADR-0046 accepts this P02b-2 boundary. Step 1 implements source/role guards; both review rounds are complete. Step 3 implements mandatory worker startup -resolution and connectivity checks; verification/reviews are in progress. -Step 4 credential/publication enforcement remains pending. The named keyed, lazy +resolution and connectivity checks; both independent review rounds are complete. +Step 4 credential/publication enforcement is implemented; validation and +reviews are in progress. The named keyed, lazy dispatcher source belongs only to the API host. Configuration validates the exact username without connecting or echoing parse input. Worker startup must verify connectivity. Every new physical diff --git a/docs/standards/20-infrastructure-stack.md b/docs/standards/20-infrastructure-stack.md index df8895e3..2ca9225b 100644 --- a/docs/standards/20-infrastructure-stack.md +++ b/docs/standards/20-infrastructure-stack.md @@ -455,9 +455,9 @@ Rules: broadcast; the outbox processor remains the only production publisher. - Operator routes, permissions and producer reset/fencing belong to Phase 03. Brokers and retention purge remain demand-gated Phase 11. P02b-2 Step 3 - implements hosted producer dispatch, with verification/reviews in progress; - Step 4 enforcement remains pending. Inbox and effectively-once consumer delivery - remain P02b-3. + implements hosted producer dispatch, with both independent review rounds complete; + Step 4 enforcement is implemented; validation and reviews are in progress. Inbox and + effectively-once consumer delivery remain P02b-3. Full deep dive: [15-event-and-outbox.md](../architecture/15-event-and-outbox.md). diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 73dee67d..698e9277 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -14,9 +14,9 @@ [ADR-0057 Transactional Event Production and Completion](../decisions/0057-transactional-event-production-and-completion.md) (producer lifecycle, payload and production-census proofs implemented), [ADR-0046 Durable Event Delivery and Recovery](../decisions/0046-durable-event-delivery-and-recovery.md) -(P02b-2 transition and terminal-witness proofs implemented; Step 3 hosted dispatch -implemented with verification/reviews in progress; Step 4 enforcement and -P02b-3 consumer proofs pending). The +(P02b-2 transition, terminal-witness and hosted runtime proofs implemented; +Steps 1–3 completed both independent review rounds. Step 4 enforcement is implemented; +validation and reviews are in progress; P02b-3 consumer proofs remain pending). The catalogue grows as subsequent ADRs and phases land their tests; per-test ownership stays with the originating ADR / standard. @@ -109,12 +109,12 @@ not implemented is the failure mode this column exists to prevent. ### Implemented today -**180 test methods run in +**193 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, P02d-4 Steps 1–3, P02b-1 -and P02b-2 Steps 2–3. +and P02b-2 Steps 2–4. 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. @@ -141,8 +141,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. -**190 rules in this catalogue are Implemented, and 115 of them are in that assembly.** -The other 75 are no less binding, and most could not live there. The table says where +**192 rules in this catalogue are Implemented, and 118 of them are in that assembly.** +The other 74 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. @@ -1802,7 +1802,11 @@ diagnostic above row security, not the boundary, as its own note says. [ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md). - **Type:** architecture/DI census and real PostgreSQL role probes. **Kind:** structural + startup + behavioural. -- **Status:** **Registered** — acceptance authorizes P02b-2 implementation. +- **Status:** **Implemented** (`OutboxDispatchConventionTests`, + `OutboxDispatcherRoleGuardTests`, `OutboxProcessorTests`): exact nonempty + whole-production credential/factory/consumer census, keyed registration and + constructor bindings, planted clean/dirty helper/delegate/async controls, and + real PostgreSQL role/grant/startup refusal proofs. - **Phase:** 02b, P02b-2. #### `CoreInfrastructure_DoesNotDependOn_AnyModule` @@ -3371,9 +3375,12 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). [P02b-2 proof obligations](../roadmap/phase-02b-events-auth.md#decisive-proof-obligations). - **Type:** production architecture census with planted controls where structural; real PostgreSQL/runtime integration proofs where behavioural. - **Kind:** behavioural. -- **Status:** **Implemented** (`OutboxDispatchStoreTests`): committed claim/backoff, - expiry/reclaim, sixth-claim recovery and terminal-request refusal controls. + **Kind:** structural + behavioural. +- **Status:** **Implemented** (`OutboxDispatchConventionTests`, + `OutboxDispatchStoreTests`): exact production literal-SQL writer census with + quoted/schema/case/CTE and missing/extra-writer controls; committed claim/backoff, + expiry/reclaim, sixth-claim recovery and terminal-request refusal proofs. The + SQL census does not claim arbitrary dynamic-SQL dataflow analysis. - **Phase:** 02b, P02b-2. #### `Outbox_Terminal_Transition_And_Must_Audit_Are_Atomic` @@ -3457,10 +3464,12 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). - **Type:** production architecture census with planted controls where structural; real PostgreSQL/runtime integration proofs where behavioural. **Kind:** structural + behavioural. -- **Status:** **Registered** — Step 3 runtime/observation evidence exists in - `OutboxProcessorTests`, `OutboxObservationTests` and - `OutboxProductionCompositionTests`; full production census/planted controls - remain Step 4. +- **Status:** **Implemented** (`OutboxDispatchConventionTests`, + `OutboxProcessorTests`, `OutboxObservationTests`, + `OutboxProductionCompositionTests`): nonempty five-subject observation graph, + actual gauge callbacks, async/polymorphic helpers and persistence/locator + capability controls; actual DI missing/duplicate/wrong-factory controls plus + bounded runtime, immutable snapshot, metric-label and liveness proofs. - **Phase:** 02b, P02b-2. ### Demand-gated: lands with its adapter @@ -3490,11 +3499,15 @@ registered, which is the shape of gap this catalogue exists to close. It is ther - **Asserts:** no type in a module assembly takes, returns or stores `IEventBus`, and no module takes or stores `IServiceProvider` as a service-locator escape hatch. Constructor - and method parameters, return types, fields and properties are checked. The only - sanctioned publisher is the `OutboxProcessor`; modules write to the outbox. + and method parameters, return types, fields and properties are checked. A nonempty + whole-production publication census admits only the private task-owned publish + callback and the attempt constructed by `OutboxProcessor.DispatchNormalAsync`; + interface/concrete/delegate/async publication and unowned attempts are refused. + Modules write to the outbox. - **Source:** [20-infrastructure-stack.md § `IEventBus`](20-infrastructure-stack.md); [ADR-0010](../decisions/0010-cross-module-communication.md). -- **Type:** xUnit + reflection over module assemblies. **Kind:** structural. +- **Type:** xUnit + reflection over module assemblies and whole-production IL + publication census. **Kind:** structural. - **Status:** **Implemented** (`CrossCuttingFoundationTests`). A module holding the bus gets a synchronous cross-module call with no durability and no transactional atomicity — a fifth cross-module mechanism in everything but name, and one that looks @@ -3503,8 +3516,11 @@ registered, which is the shape of gap this catalogue exists to close. It is ther `LearnStack.SharedKernel.Messaging` for `IIntegrationEvent` and `IIntegrationEventHandler<T>`. The module sweep has had a subject since Packet 7 — four modules ship code — and the checker is also pointed at direct-injection, - method-injection and service-locator deliberate offenders in the test assembly, so a - clean sweep is evidence rather than an absence. + method-injection and service-locator deliberate offenders in the test assembly. + P02b-2 Step 4 adds the complete production publication fence through the same + canonical test; missing subjects, extra implementations/callers, delegate/async + escapes and unowned attempts have dirty/clean controls. This is bounded IL + capability inspection, not arbitrary reflection/dataflow analysis. - **Phase:** 02a (Packet 5). #### `Integration_Event_TopicNames_FollowConvention` diff --git a/infra/dapr/README.md b/infra/dapr/README.md index 3989016b..b4a0fe92 100644 --- a/infra/dapr/README.md +++ b/infra/dapr/README.md @@ -29,7 +29,7 @@ Phase ownership (per [phase-02a](../../docs/roadmap/phase-02a-kernel-tenancy.md) `backend/Directory.Packages.props` declares no client library for any of the four. - **P02b-2 Step 3** implements the API-host `OutboxProcessor`, with - verification/reviews in progress. It is the only sanctioned production caller + both independent review rounds complete. It is the only sanctioned production caller of `IEventBus.PublishAsync` per ADR-0010 Amendment 1. Modules consume `ICacheService` and `ISecretProvider` from 02a and enqueue through `IOutbox`; they never publish through `IEventBus` directly. From 2f8693e7817b74f84b95d0a681db7c3edbc9fa82 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 23:24:42 +0300 Subject: [PATCH 80/92] fix(kernel): close dispatcher census mutation gaps Make closure provenance, literal SQL targets and worker registration controls reject the verified alternate paths instead of overstating coverage. Preserve production behavior and record the first review. ADR: 0046, 0057 --- .../OutboxDispatchBoundaryTests.cs | 80 +++++++++++++++++-- .../OutboxDispatchObservationTests.cs | 48 +++++++++-- docs/roadmap/phase-02b-events-auth.md | 40 ++++++++-- 3 files changed, 148 insertions(+), 20 deletions(-) diff --git a/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchBoundaryTests.cs b/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchBoundaryTests.cs index 5db65d4e..a6a125fd 100644 --- a/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchBoundaryTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchBoundaryTests.cs @@ -163,6 +163,37 @@ public void Credential_census_rejects_crossed_keys_changed_closures_and_missing_ && instruction.Operand is FieldReference field && field.FullName == captured.FullName); AssertMutation(census, "a factory capturing a different key instead of its registered key", () => ReplaceInstruction(assignment.Previous!, OpCodes.Ldstr, "unrelated-key"), CredentialOffenders); + var provider = (FieldReference)call.Previous.Previous!.Previous!.Operand; + foreach (var field in new[] { captured, provider }) + { + AssertMutation(census, "the real lazy callback overwriting captured " + field.Name, () => + InsertCaptureMutation(leaf, field, address: false), CredentialOffenders); + AssertMutation(census, "the actual factory overwriting captured " + field.Name + " after its original assignment", () => + InsertCaptureMutation(factory, field, address: false, afterCapture: true), CredentialOffenders); + AssertMutation(census, "a closure helper overwriting captured " + field.Name, () => + { + var helper = new MethodDefinition("RewriteCapturedField", MethodAttributes.Private, leaf.Module.TypeSystem.Void); + helper.Body.Instructions.Add(Instruction.Create(OpCodes.Ret)); + leaf.DeclaringType.Methods.Add(helper); + var undo = InsertCaptureMutation(helper, field, address: false); + return () => { undo(); leaf.DeclaringType.Methods.Remove(helper); }; + }, CredentialOffenders); + AssertMutation(census, "the real lazy callback exposing the address of captured " + field.Name, () => + InsertCaptureMutation(leaf, field, address: true), CredentialOffenders); + var parameter = factory.Parameters[field == captured ? 1 : 0]; + foreach (var opcode in new[] { OpCodes.Starg, OpCodes.Starg_S, OpCodes.Ldarga, OpCodes.Ldarga_S }) + AssertMutation(census, "the actual factory mutating or exposing its " + parameter.Name + " argument through " + opcode, () => + { + var address = opcode == OpCodes.Ldarga || opcode == OpCodes.Ldarga_S; + var replacement = parameter.ParameterType.FullName == "System.Object" + ? Instruction.Create(OpCodes.Ldstr, PlatformAdminScope.PlatformDataSourceKey) : Instruction.Create(OpCodes.Ldnull); + var inserted = address + ? new[] { Instruction.Create(opcode, parameter), Instruction.Create(OpCodes.Pop) } + : [replacement, Instruction.Create(opcode, parameter)]; + for (var index = 0; index < inserted.Length; index++) factory.Body.Instructions.Insert(index, inserted[index]); + return () => { foreach (var instruction in inserted) factory.Body.Instructions.Remove(instruction); }; + }, CredentialOffenders); + } } var root = census.Types[(BoundaryApi, BoundaryComposition)].Methods.Single(method => method.Name == "AddLearnStackOutboxDispatcher"); @@ -289,6 +320,26 @@ private static Action ReplaceInstruction(Instruction instruction, OpCode opcode, return () => { instruction.OpCode = originalCode; instruction.Operand = originalOperand; }; } + private static Action InsertCaptureMutation(MethodDefinition method, FieldReference field, bool address, bool afterCapture = false) + { + var instructions = method.Body.Instructions; + var index = afterCapture ? instructions.IndexOf(instructions.Single(instruction => instruction.OpCode == OpCodes.Stfld + && instruction.Operand is FieldReference assigned && assigned.FullName == field.FullName)) + 1 : 0; + var replacement = field.FieldType.FullName == "System.Object" + ? Instruction.Create(OpCodes.Ldstr, PlatformAdminScope.PlatformDataSourceKey) : Instruction.Create(OpCodes.Ldnull); + // The factory still has the freshly captured closure on its stack; an + // instance callback/helper uses its own closure. Neither removes the real + // capture or changes the recognized resolver tail. + var inserted = new[] + { + Instruction.Create(afterCapture ? OpCodes.Dup : OpCodes.Ldarg_0), + address ? Instruction.Create(OpCodes.Ldflda, field) : replacement, + address ? Instruction.Create(OpCodes.Pop) : Instruction.Create(OpCodes.Stfld, field), + }; + for (var offset = 0; offset < inserted.Length; offset++) instructions.Insert(index + offset, inserted[offset]); + return () => { foreach (var instruction in inserted) instructions.Remove(instruction); }; + } + private static Action AddStringProbe(ModuleDefinition module, string name, string value) { var type = AddProbe(module, name); @@ -369,7 +420,7 @@ private static SortedSet<string> CredentialOffenders(ProductionCensus census) var leaf = leafType.Methods.SingleOrDefault(method => MethodId(method) == MethodId(leaves[0])); if (leaf is null) { offenders.Add("Missing lazy delegate: " + leaves[0].FullName); continue; } expectedResolvers.Add(MethodId(leaf)); - CheckCapturedKeyResolver(offenders, factory, leaf); + CheckCapturedKeyResolver(offenders, census, factory, leaf); } // Pin registration keys independently from callback keys. A changed key in @@ -519,7 +570,8 @@ private static void CheckSourceFieldAccess(SortedSet<string> offenders, Producti CheckSet(offenders, TypeId(type) + " privileged source field access", actual, expected); } - private static void CheckCapturedKeyResolver(SortedSet<string> offenders, MethodDefinition factory, MethodDefinition leaf) + private static void CheckCapturedKeyResolver(SortedSet<string> offenders, ProductionCensus census, + MethodDefinition factory, MethodDefinition leaf) { // Recognize this one compiler-emitted closure: (provider, key) => // new Lazy<NpgsqlDataSource>(() => provider.GetRequiredKeyedService<NpgsqlDataSource>(key)). @@ -532,14 +584,28 @@ private static void CheckCapturedKeyResolver(SortedSet<string> offenders, Method || GenericService(target) != BoundaryDataSource || keyField is null || providerField is null || call.Previous!.OpCode != OpCodes.Ldfld || call.Previous.Previous!.OpCode != OpCodes.Ldarg_0 || call.Previous.Previous.Previous!.OpCode != OpCodes.Ldfld - || !CapturesParameter(factory, keyField, 1) || !CapturesParameter(factory, providerField, 0)) + || !CapturesParameter(census, factory, keyField, 1) || !CapturesParameter(census, factory, providerField, 0)) offenders.Add(MethodId(leaf) + ": lazy resolver must forward its own registered key and provider unchanged"); } - private static bool CapturesParameter(MethodDefinition factory, FieldReference field, int parameter) => - factory.Body.Instructions.Count(instruction => instruction.OpCode == OpCodes.Stfld - && instruction.Operand is FieldReference assigned && assigned.FullName == field.FullName - && LoadsArgument(instruction.Previous, parameter + (factory.HasThis ? 1 : 0))) == 1; + private static bool CapturesParameter(ProductionCensus census, MethodDefinition factory, FieldReference field, int parameter) + { + var argument = factory.Parameters[parameter]; + if (factory.Body.Instructions.Any(instruction => instruction.Operand is ParameterDefinition target && target == argument + && (instruction.OpCode == OpCodes.Starg || instruction.OpCode == OpCodes.Starg_S + || instruction.OpCode == OpCodes.Ldarga || instruction.OpCode == OpCodes.Ldarga_S))) return false; + // The matching original capture is insufficient: a later callback/helper + // write can replace the key or provider while preserving that instruction. + // Close every direct write and address escape of this exact captured field. + var mutations = census.Types.Values.SelectMany(type => type.Methods).Where(method => method.HasBody) + .SelectMany(method => method.Body.Instructions.Where(instruction => + instruction.Operand is FieldReference assigned && SameType(assigned.DeclaringType, field.DeclaringType) + && assigned.Name == field.Name && (instruction.OpCode == OpCodes.Stfld || instruction.OpCode == OpCodes.Stsfld + || instruction.OpCode == OpCodes.Ldflda || instruction.OpCode == OpCodes.Ldsflda)) + .Select(instruction => (Method: method, Instruction: instruction))).ToArray(); + return mutations.Length == 1 && mutations[0].Method == factory && mutations[0].Instruction.OpCode == OpCodes.Stfld + && LoadsArgument(mutations[0].Instruction.Previous, parameter + (factory.HasThis ? 1 : 0)); + } private static bool LoadsArgument(Instruction? instruction, int index) => instruction is not null && (index switch { diff --git a/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchObservationTests.cs b/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchObservationTests.cs index 9c4ca9f3..50a78314 100644 --- a/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchObservationTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchObservationTests.cs @@ -134,6 +134,17 @@ public void Observation_registration_control_detects_missing_duplicate_or_replac services.Add(worker); ObservationRegistrationOffenders(services).Should().NotBeEmpty("Fix: duplicate workers must be detected"); services.Remove(worker); + foreach (var extra in new[] + { + ServiceDescriptor.Singleton<IHostedService>(provider => ActivatorUtilities.CreateInstance<OutboxProcessor>(provider)), + ServiceDescriptor.Singleton<IHostedService>(new UnrelatedHostedService()), + }) + { + services.Add(extra); + ObservationRegistrationOffenders(services).Should().NotBeEmpty("Fix: factory and instance hosted-service duplicates must be detected"); + services.Remove(extra); + } + ObservationRegistrationOffenders(services).Should().BeEmpty("Fix: restoring the descriptor set must restore the clean control"); foreach (var replacement in new[] { "missing", "duplicate", "wrong-factory" }) { var modified = ObservationServices(); @@ -178,6 +189,12 @@ public void Outbox_SQL_census_detects_quoted_schema_CTE_extra_and_missing_writer "DELETE FROM ONLY public.outbox_messages", "UPDATE /* review */ public.outbox_messages SET attempts=0", "MERGE INTO outbox_messages USING x ON true WHEN MATCHED THEN DELETE", "COPY public.outbox_messages FROM STDIN", "TRUNCATE TABLE public.outbox_messages", + "UPDATE outbox_messages/* review */ SET attempts=0", + "INSERT/* review */INTO outbox_messages(id) VALUES(NULL)", + "DELETE/* review */FROM outbox_messages WHERE true", + "TRUNCATE TABLE another_table, outbox_messages", + "TRUNCATE ONLY another_table, ONLY public.\"outbox_messages\" RESTART IDENTITY", + "TRUNCATE TABLE another_table, outbox_messages*", }) AssertMutation(census, "an unauthorized literal SQL writer: " + sql, () => AddSqlProbe(application, sql), OutboxSqlOffenders); AssertMutation(census, "an extra helper on the allowed store", () => @@ -209,7 +226,8 @@ public void Outbox_SQL_census_preserves_reads_and_unrelated_table_writes() using var census = ProductionCensus.Read(); var application = census.Modules.Single(module => module.Assembly.Name.Name == "LearnStack.Application"); foreach (var sql in new[] { "SELECT * FROM outbox_messages FOR UPDATE SKIP LOCKED", - "UPDATE another_outbox_messages SET attempts=0", "INSERT INTO outbox_messages_archive VALUES (1)" }) + "UPDATE another_outbox_messages SET attempts=0", "INSERT INTO outbox_messages_archive VALUES (1)", + "TRUNCATE TABLE another_table, outbox_messages_archive" }) { var undo = AddSqlProbe(application, sql); try { OutboxSqlOffenders(census).Should().BeEmpty("Fix: admitted reads and unrelated table writes must stay clean"); } @@ -217,10 +235,17 @@ public void Outbox_SQL_census_preserves_reads_and_unrelated_table_writes() } } + private const string SqlGap = "(?:\\s|/\\*.*?\\*/|--[^\\r\\n]*(?:\\r?\\n|$))"; + private const string SqlIdentifier = "(?:\"[^\"]+\"|[a-z_][a-z0-9_$]*)"; + private const string SqlQualifier = "(?:" + SqlIdentifier + SqlGap + "*\\." + SqlGap + "*)?"; + private const string OutboxTarget = SqlQualifier + "(?:\"outbox_messages\"|outbox_messages)"; private static readonly Regex OutboxMutation = new( - "\\b(?:INSERT\\s+INTO|UPDATE|DELETE\\s+FROM|MERGE\\s+INTO|COPY|TRUNCATE(?:\\s+TABLE)?)" - + "(?:\\s|/\\*.*?\\*/|--[^\\r\\n]*(?:\\r?\\n|$))+(?:ONLY\\s+)?" - + "(?:(?:\"[^\"]+\"|[a-z_][a-z0-9_$]*)\\s*\\.\\s*)?(?:\"outbox_messages\"|outbox_messages)(?=\\s|[;(]|$)", + "\\b(?:INSERT" + SqlGap + "+INTO|UPDATE|DELETE" + SqlGap + "+FROM|MERGE" + SqlGap + "+INTO|COPY)" + + SqlGap + "+(?:ONLY" + SqlGap + "+)?" + OutboxTarget + "(?=" + SqlGap + "|[;(*]|$)" + + "|\\bTRUNCATE" + SqlGap + "+(?:TABLE" + SqlGap + "+)?" + + "(?:(?:ONLY" + SqlGap + "+)?" + SqlQualifier + SqlIdentifier + "(?:" + SqlGap + "*\\*)?" + + SqlGap + "*," + SqlGap + "*)*(?:ONLY" + SqlGap + "+)?" + OutboxTarget + + "(?=" + SqlGap + "|[,*;]|$)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Singleline, TimeSpan.FromSeconds(1)); private static SortedSet<string> OutboxSqlOffenders(ProductionCensus census) @@ -303,9 +328,12 @@ private static ServiceCollection ObservationServices() private static SortedSet<string> ObservationRegistrationOffenders(IServiceCollection services) { var offenders = new SortedSet<string>(StringComparer.Ordinal); - var workers = services.Where(descriptor => descriptor.ServiceType == typeof(IHostedService) - && descriptor.ImplementationType == typeof(OutboxProcessor)).ToArray(); - if (workers.Length != 1 || workers[0].Lifetime != ServiceLifetime.Singleton) offenders.Add("Mandatory singleton worker registration"); + // Include the framework health publisher in the complete descriptor set so + // factory/instance duplicates cannot hide behind the dispatcher type filter. + var workers = services.Where(descriptor => descriptor.ServiceType == typeof(IHostedService)).ToArray(); + CheckSet(offenders, "hosted-service descriptor types", workers.Select(descriptor => descriptor.ImplementationType?.FullName ?? "non-type registration"), + [typeof(OutboxProcessor).FullName!, "Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckPublisherHostedService"]); + if (workers.Any(descriptor => descriptor.Lifetime != ServiceLifetime.Singleton)) offenders.Add("Mandatory singleton worker registration"); using var provider = services.BuildServiceProvider(); var checks = provider.GetRequiredService<IOptions<HealthCheckServiceOptions>>().Value.Registrations .Where(check => check.Name == OutboxHealthCheck.Name).ToArray(); @@ -337,4 +365,10 @@ private sealed class UnrelatedHealthCheck : IHealthCheck public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) => Task.FromResult(HealthCheckResult.Healthy()); } + + private sealed class UnrelatedHostedService : IHostedService + { + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } } diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 69280f62..7ddaec8a 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -1716,7 +1716,7 @@ status-transition and migration backfill methods; quoted/schema/case/CTE and missing/extra-writer controls prove its reach. These are bounded IL/literal-SQL checks, not arbitrary reflection or dynamic-SQL dataflow analysis. -**Verification in progress:** the full Release solution build reports zero +**Verification before review:** the full Release solution build reports zero warnings/errors, and solution-wide `dotnet format --verify-no-changes --no-restore` passes. All 21 focused census/publication cases pass with zero skips, including compiled actual production subjects and every planted control. Role @@ -1724,17 +1724,45 @@ and Health catalogue rules are Implemented; Retry also names its canonical architecture method. The source/catalogue recount is 193 architecture test methods, 192 Implemented rules, 118 in that assembly and 74 elsewhere. -Full Release regression, mandatory Docker proofs, applicable launcher/frontend -checks and both Step 4 review rounds remain in progress. Final packet closeout -also requires the seven existing CI checks at the exact PR head. This focused -receipt claims no completed full regression, Step 4 review round, PR-head CI, -packet closeout or merge. +The full Release backend regression passes 3,393 cases with zero failures/skips: +1,845 unit, 286 architecture, four contract, 175 Docker-free integration and +1,083 Docker integration. TRX counters were inspected directly; the nonempty-run +guard passes. The three additional case-insensitive credential-alias controls +also pass in the focused seven-case run. Step 3's corrected launcher/frontend +receipt remains the applicable local evidence: 91 lifecycle cases and 1,608 +frontend cases. These checks are not claimed as new Step 4 frontend reruns. +The full PR-range documentation check passes 2,555 relative links/459 fragments, +and all seven ADR-status script tests pass. Historical records and Accepted ADR +text remain preserved. Independent reviews and exact-head CI still gate final +packet closeout; no merge is claimed. P02b-3 still owns durable acceptance, shared inbox/ledger, independent subscriber retries, both real default subscriptions and Seeder handoff. Required production subscriptions remain Pending; test-owned Active contracts prove producer runtime without claiming consumer business effects. +#### Step 4 review round 1 + +Fresh read-only GPT-6-astra xhigh boundary and GPT-6.1-sol xhigh proof/corpus +reviewers examined `02540d05..d2531f2`. They reproduced three structural test gaps, +without finding a current production dispatcher defect. Captured key/provider +fields could be reassigned after their original capture; valid comment-separated +SQL and a later `TRUNCATE` target escaped the literal matcher; an additional +factory-based hosted worker escaped the descriptor type filter. + +The fixes close all three: captured-field mutations are restricted to the exact +factory parameter assignment, SQL recognizes those literal token separators and +all listed truncate targets, and the actual hosted descriptor set includes only +the singleton dispatcher and framework health publisher. Added dirty controls +cover key/provider writes in the factory, callback and helper, field/argument +address escapes and argument reassignment, SQL variants, and factory/instance +worker duplicates. No test method or catalogue count is added. All 21 focused +cases pass after the three corrections; the final seven boundary cases pass +again after the adjacent argument-mutation controls, with zero failures/skips. +The full regression above predates these test-only changes. The proof reviewer +independently passed 26 existing focused/corpus cases, with zero failures/skips; +neither reviewer claimed a new full suite. Round 2 remains pending. + ### Durable outbox dispatch P02b-1 Steps 2/3 implement `IOutbox`, replace Packet 3's step-7 shell with From 606362bcdbffd13c0d566a05b63b819f3b3562f0 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 23:35:57 +0300 Subject: [PATCH 81/92] fix(kernel): pin dispatcher authority and observation bindings Bind privileged method grants to literal signatures and observation to its actual system clock and singleton health state so added overloads or same-type detached factories cannot pass the enforcement census. ADR: 0046 --- .../OutboxDispatchBoundaryTests.cs | 119 ++++++++++++++++-- .../OutboxDispatchObservationTests.cs | 54 +++++++- docs/roadmap/phase-02b-events-auth.md | 38 +++++- .../21-architecture-tests-catalogue.md | 22 +++- 4 files changed, 208 insertions(+), 25 deletions(-) diff --git a/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchBoundaryTests.cs b/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchBoundaryTests.cs index a6a125fd..8330022d 100644 --- a/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchBoundaryTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchBoundaryTests.cs @@ -24,6 +24,11 @@ public sealed partial class OutboxDispatchConventionTests private const string BoundaryTransport = "LearnStack.Infrastructure.Messaging.InProcessEventBus"; private const string BoundaryDataSource = "Npgsql.NpgsqlDataSource"; private const string BoundaryLazySource = "System.Lazy`1<Npgsql.NpgsqlDataSource>"; + private const string BoundaryTask = "System.Threading.Tasks.Task"; + private const string BoundaryBooleanTask = "System.Threading.Tasks.Task`1<System.Boolean>"; + private const string BoundaryCancellation = "System.Threading.CancellationToken"; + private const string BoundaryClaim = "LearnStack.Infrastructure.Messaging.OutboxDispatchClaim"; + private const string BoundaryStrings = "System.Collections.Generic.IReadOnlyCollection`1<System.String>"; /// <summary> /// ADR-0046: census every production assembly, exact keyed factories and parameter @@ -120,6 +125,42 @@ public void Credential_census_rejects_generic_direct_delegate_async_and_extra_he var method = AddCall(worker, "UnrelatedStoreConsumer", store.Methods.Single(method => method.Name == "TryClaimAsync"), OpCodes.Call); return () => worker.Methods.Remove(method); }, CredentialOffenders); + var boundaryWorker = census.Types[(BoundaryApi, BoundaryWorker)]; + foreach (var name in new[] { "ExecuteAsync", "DispatchAsync", "DispatchNormalAsync", "RefreshAsync" }) + AssertMutation(census, "a public store-forwarding overload borrowing the approved worker name " + name, () => + AddBoundaryFieldGetter(boundaryWorker, name, "_store"), CredentialOffenders); + AssertMutation(census, "a public raw-source overload borrowing the approved worker name StartAsync", () => + AddBoundaryFieldGetter(boundaryWorker, "StartAsync", "_source"), CredentialOffenders); + foreach (var name in new[] { "AcquireAsync", "UpdateAsync", "TryTerminalizeAsync", "IsDurablyTerminalAsync", "ReadSnapshotAsync" }) + AssertMutation(census, "a public raw-source overload borrowing the approved store name " + name, () => + AddBoundaryFieldGetter(store, name, "_source"), CredentialOffenders); + foreach (var method in new[] { boundaryWorker.Methods.Single(item => item.Name == "StartAsync"), + store.Methods.Single(item => item.Name == "ReadSnapshotAsync") }) + { + AssertMutation(census, "a changed source-reader visibility", () => + ChangeAttributes(() => method.Attributes, value => method.Attributes = value, + (method.Attributes & ~MethodAttributes.MemberAccessMask) | MethodAttributes.Private), CredentialOffenders); + AssertMutation(census, "a static source reader replacing its approved instance binding", () => + ChangeAttributes(() => method.Attributes, value => method.Attributes = value, + method.Attributes | MethodAttributes.Static), CredentialOffenders); + AssertMutation(census, "a changed source-reader return type", () => + { + var original = method.ReturnType; + method.ReturnType = method.Module.TypeSystem.Object; + return () => method.ReturnType = original; + }, CredentialOffenders); + AssertMutation(census, "a changed source-reader parameter type", () => + { + var parameter = method.Parameters[0]; + var original = parameter.ParameterType; + parameter.ParameterType = method.Module.TypeSystem.Int32; + return () => parameter.ParameterType = original; + }, CredentialOffenders); + } + var dispatch = boundaryWorker.Methods.Single(method => method.Name == "DispatchAsync"); + AssertMutation(census, "a public store-consumer method replacing its private binding", () => + ChangeAttributes(() => dispatch.Attributes, value => dispatch.Attributes = value, + (dispatch.Attributes & ~MethodAttributes.MemberAccessMask) | MethodAttributes.Public), CredentialOffenders); } [Fact] @@ -351,6 +392,17 @@ private static Action AddStringProbe(ModuleDefinition module, string name, strin return () => module.Types.Remove(type); } + private static Action AddBoundaryFieldGetter(TypeDefinition type, string name, string fieldName) + { + var field = type.Fields.Single(item => item.Name == fieldName); + var method = new MethodDefinition(name, MethodAttributes.Public, field.FieldType); + method.Body.Instructions.Add(Instruction.Create(OpCodes.Ldarg_0)); + method.Body.Instructions.Add(Instruction.Create(OpCodes.Ldfld, field)); + method.Body.Instructions.Add(Instruction.Create(OpCodes.Ret)); + type.Methods.Add(method); + return () => type.Methods.Remove(method); + } + private static ServiceCollection BoundaryRegistrations() { var services = new ServiceCollection(); @@ -485,14 +537,31 @@ private static SortedSet<string> CredentialOffenders(ProductionCensus census) offenders.Add(MethodId(method) + ": dispatcher credential spelling outside its reader"); } } - var workerConstructor = types[(BoundaryApi, BoundaryWorker)].Methods.Single(method => method.IsConstructor && !method.IsStatic); + var worker = types[(BoundaryApi, BoundaryWorker)]; + var workerConstructor = BoundaryWorkerConstructor(offenders, worker); var scopeConstructor = types[(InfrastructureAssembly, BoundaryPlatform)].Methods.Single(method => method.IsConstructor && !method.IsStatic); CheckSet(offenders, "keyed parameter bindings", injections, - [MethodId(workerConstructor) + ":" + BoundaryLazySource + ":" + OutboxDispatcherDataSource.Key, + [workerConstructor + ":" + BoundaryLazySource + ":" + OutboxDispatcherDataSource.Key, MethodId(scopeConstructor) + ":" + BoundaryLazySource + ":" + PlatformAdminScope.PlatformDataSourceKey]); - CheckSourceFieldAccess(offenders, census, types[(BoundaryApi, BoundaryWorker)], ["StartAsync"]); - CheckSourceFieldAccess(offenders, census, types[(InfrastructureAssembly, Store)], - ["AcquireAsync", "UpdateAsync", "TryTerminalizeAsync", "IsDurablyTerminalAsync", "ReadSnapshotAsync"]); + CheckSourceFieldAccess(offenders, census, worker, workerConstructor, + [RequireBoundaryMethod(offenders, worker, "StartAsync", BoundaryTask, MethodAttributes.Public, BoundaryCancellation)]); + var store = types[(InfrastructureAssembly, Store)]; + var storeConstructor = RequireBoundaryMethod(offenders, store, ".ctor", "System.Void", MethodAttributes.Public, + BoundaryLazySource, "LearnStack.Infrastructure.Messaging.OutboxEventRegistry", + "LearnStack.Infrastructure.Messaging.OutboxDispatchOptions", "LearnStack.SharedKernel.Identifiers.IGuidFactory", + "LearnStack.SharedKernel.Time.IClock", "Microsoft.Extensions.DependencyInjection.IServiceScopeFactory"); + CheckSourceFieldAccess(offenders, census, store, storeConstructor, + [ + RequireBoundaryMethod(offenders, store, "AcquireAsync", "System.Threading.Tasks.Task`1<" + BoundaryClaim + ">", MethodAttributes.Private, + "LearnStack.Infrastructure.Messaging.OutboxClaimKind", "System.String", BoundaryStrings, BoundaryCancellation), + RequireBoundaryMethod(offenders, store, "UpdateAsync", BoundaryBooleanTask, MethodAttributes.Private, + BoundaryClaim, "System.String", "System.String", "System.String", BoundaryCancellation, "System.String"), + RequireBoundaryMethod(offenders, store, "TryTerminalizeAsync", BoundaryBooleanTask, MethodAttributes.Public, + BoundaryClaim, "System.String", BoundaryCancellation), + RequireBoundaryMethod(offenders, store, "IsDurablyTerminalAsync", BoundaryBooleanTask, MethodAttributes.Private, "System.Guid"), + RequireBoundaryMethod(offenders, store, "ReadSnapshotAsync", "System.Threading.Tasks.Task`1<LearnStack.Infrastructure.Messaging.OutboxQueueSnapshot>", + MethodAttributes.Public, BoundaryStrings, BoundaryCancellation), + ]); if (storeFactory is not null) CheckStoreConsumers(offenders, census, outboxRoot, storeFactory); return offenders; } @@ -509,10 +578,16 @@ private static void CheckStoreConsumers(SortedSet<string> offenders, ProductionC && method.DeclaringType.FullName != Store && ReferencesStore(method)).Select(MethodId)) .Distinct(StringComparer.Ordinal); var worker = census.Types[(BoundaryApi, BoundaryWorker)]; - var workerMethods = worker.Methods.Where(method => method.IsConstructor && !method.IsStatic - || method.Name is "ExecuteAsync" or "DispatchAsync" or "DispatchNormalAsync" or "RefreshAsync"); + var workerMethods = new[] + { + BoundaryWorkerConstructor(offenders, worker), + RequireBoundaryMethod(offenders, worker, "ExecuteAsync", BoundaryTask, MethodAttributes.Family, BoundaryCancellation), + RequireBoundaryMethod(offenders, worker, "DispatchAsync", BoundaryTask, MethodAttributes.Private, BoundaryClaim, BoundaryCancellation), + RequireBoundaryMethod(offenders, worker, "DispatchNormalAsync", BoundaryTask, MethodAttributes.Private, BoundaryClaim, BoundaryCancellation), + RequireBoundaryMethod(offenders, worker, "RefreshAsync", BoundaryTask, MethodAttributes.Private, "System.Boolean", BoundaryCancellation), + }; CheckSet(offenders, "privileged store consumers", owners, - workerMethods.Select(MethodId).Concat([MethodId(composition), MethodId(factory)])); + workerMethods.Concat([MethodId(composition), MethodId(factory)])); // Only an attribute-linked Store state machine may carry its own captured // instance. A similarly named nested type gains no authority from its name. var stateMachines = OwnedBodies(census).Where(item => item.Body.Name == "MoveNext" && item.Body != item.Owner @@ -551,7 +626,7 @@ private static bool ContainsStore(TypeReference type) => type.FullName == Store || (type is TypeSpecification specification && ContainsStore(specification.ElementType)); private static void CheckSourceFieldAccess(SortedSet<string> offenders, ProductionCensus census, - TypeDefinition type, string[] readers) + TypeDefinition type, string constructor, string[] readers) { var fields = type.Fields.Where(field => field.Name == "_source").ToArray(); if (fields.Length != 1 || !fields[0].IsPrivate || !fields[0].IsInitOnly || fields[0].IsStatic @@ -563,13 +638,31 @@ private static void CheckSourceFieldAccess(SortedSet<string> offenders, Producti .Where(instruction => instruction.Operand is FieldReference field && SameType(field.DeclaringType, type) && field.Name == fields[0].Name) .Select(instruction => MethodId(item.Owner) + ":" + instruction.OpCode.Name)); - var expected = type.Methods.Where(method => method.IsConstructor && !method.IsStatic) - .Select(method => MethodId(method) + ":stfld") - .Concat(type.Methods.Where(method => readers.Contains(method.Name, StringComparer.Ordinal)) - .Select(method => MethodId(method) + ":ldfld")); + var expected = new[] { constructor + ":stfld" }.Concat(readers.Select(reader => reader + ":ldfld")); CheckSet(offenders, TypeId(type) + " privileged source field access", actual, expected); } + private static string BoundaryWorkerConstructor(SortedSet<string> offenders, TypeDefinition worker) => + RequireBoundaryMethod(offenders, worker, ".ctor", "System.Void", MethodAttributes.Public, + BoundaryLazySource, Store, "LearnStack.Infrastructure.Messaging.OutboxEnvelopeReader", + "LearnStack.Infrastructure.Messaging.OutboxEventRegistry", "LearnStack.Infrastructure.Messaging.OutboxDispatchOptions", + "System.TimeProvider", "Microsoft.Extensions.DependencyInjection.IServiceScopeFactory", "LearnStack.Api.Messaging.OutboxObservation", + "Microsoft.Extensions.Logging.ILogger`1<" + BoundaryWorker + ">"); + + private static string RequireBoundaryMethod(SortedSet<string> offenders, TypeDefinition type, string name, + string result, MethodAttributes visibility, params string[] parameters) + { + // Expected authority comes from this fixed contract, never from all current + // methods sharing a permitted name. A new overload cannot authorize itself. + var expected = Key(type).Assembly + ":" + result + " " + type.FullName + "::" + name + "(" + string.Join(",", parameters) + ")"; + var declarations = type.Methods.Where(method => method.Name == name).ToArray(); + if (declarations.Length != 1 || declarations[0].IsStatic || declarations[0].HasGenericParameters + || (declarations[0].Attributes & MethodAttributes.MemberAccessMask) != visibility + || declarations[0].ReturnType.FullName != result || !ParametersMatch(declarations[0], parameters)) + offenders.Add(expected + ": requires exactly one instance declaration with its approved signature and visibility"); + return expected; + } + private static void CheckCapturedKeyResolver(SortedSet<string> offenders, ProductionCensus census, MethodDefinition factory, MethodDefinition leaf) { diff --git a/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchObservationTests.cs b/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchObservationTests.cs index 50a78314..55080d44 100644 --- a/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchObservationTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchObservationTests.cs @@ -160,6 +160,30 @@ public void Observation_registration_control_detects_missing_duplicate_or_replac ObservationRegistrationOffenders(modified).Should().NotBeEmpty("Fix: detect " + replacement + " health registration"); } ObservationRegistrationOffenders(ObservationServices()).Should().BeEmpty(); + foreach (var replacement in new[] { "missing", "duplicate", "instance", "factory" }) + { + IServiceCollection modified = ObservationServices(); + var clock = modified.Single(descriptor => descriptor.ServiceType == typeof(TimeProvider)); + modified.Remove(clock); + if (replacement == "duplicate") { modified.Add(clock); modified.Add(clock); } + if (replacement == "instance") modified.AddSingleton<TimeProvider>(new AlternateTimeProvider()); + if (replacement == "factory") modified.AddSingleton<TimeProvider>(_ => new AlternateTimeProvider()); + ObservationRegistrationOffenders(modified).Should().NotBeEmpty("Fix: reject " + replacement + " production clock registration"); + } + var detachedServices = ObservationServices(); + using var detachedProvider = detachedServices.BuildServiceProvider(); + using var detached = new OutboxObservation(TimeProvider.System, new OutboxDispatchOptions(), + detachedProvider.GetRequiredService<OutboxEventRegistry>()); + detachedServices.PostConfigure<HealthCheckServiceOptions>(options => + { + options.Registrations.Remove(options.Registrations.Single(item => item.Name == OutboxHealthCheck.Name)); + options.Registrations.Add(new HealthCheckRegistration(OutboxHealthCheck.Name, + _ => new OutboxHealthCheck(detached), HealthStatus.Unhealthy, [])); + }); + ObservationRegistrationOffenders(detachedServices).Should().NotBeEmpty( + "Fix: a same-type check disconnected from the worker's singleton observation must fail"); + ObservationRegistrationOffenders(ObservationServices()).Should().BeEmpty( + "Fix: the exact production clock and singleton health binding must remain the clean control"); } /// <summary> @@ -334,10 +358,36 @@ private static SortedSet<string> ObservationRegistrationOffenders(IServiceCollec CheckSet(offenders, "hosted-service descriptor types", workers.Select(descriptor => descriptor.ImplementationType?.FullName ?? "non-type registration"), [typeof(OutboxProcessor).FullName!, "Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckPublisherHostedService"]); if (workers.Any(descriptor => descriptor.Lifetime != ServiceLifetime.Singleton)) offenders.Add("Mandatory singleton worker registration"); + // The helper graph walks production contracts. Its one framework-owned + // virtual clock capability is admitted only as the shipped system instance. + var clocks = services.Where(descriptor => descriptor.ServiceType == typeof(TimeProvider)).ToArray(); + if (clocks.Length != 1 || clocks[0].Lifetime != ServiceLifetime.Singleton + || !ReferenceEquals(clocks[0].ImplementationInstance, TimeProvider.System)) + { + offenders.Add("Observation requires its exact singleton system clock registration"); + return offenders; + } using var provider = services.BuildServiceProvider(); var checks = provider.GetRequiredService<IOptions<HealthCheckServiceOptions>>().Value.Registrations .Where(check => check.Name == OutboxHealthCheck.Name).ToArray(); - if (checks.Length != 1 || checks[0].Factory(provider).GetType() != typeof(OutboxHealthCheck)) offenders.Add("Private outbox health registration"); + var check = checks.Length == 1 ? checks[0].Factory(provider) : null; + if (check is not OutboxHealthCheck) offenders.Add("Private outbox health registration"); + else + { + var observation = provider.GetRequiredService<OutboxObservation>(); + observation.Started(); + observation.Progress(); + observation.Refreshed(new OutboxQueueSnapshot(0, 0, 0)); + var context = new HealthCheckContext { Registration = checks[0] }; + if (check.CheckHealthAsync(context).GetAwaiter().GetResult().Status != HealthStatus.Healthy) + offenders.Add("Registered check does not follow healthy singleton observation"); + observation.TerminalAuditFailed(); + if (check.CheckHealthAsync(context).GetAwaiter().GetResult().Status != HealthStatus.Unhealthy) + offenders.Add("Registered check does not follow failed singleton observation"); + observation.TerminalAuditSucceeded(); + if (check.CheckHealthAsync(context).GetAwaiter().GetResult().Status != HealthStatus.Healthy) + offenders.Add("Registered check does not follow recovered singleton observation"); + } if (provider.GetRequiredKeyedService<Lazy<Npgsql.NpgsqlDataSource>>(OutboxDispatcherDataSource.Key).IsValueCreated) offenders.Add("Observation forced dispatcher source construction"); return offenders; @@ -371,4 +421,6 @@ private sealed class UnrelatedHostedService : IHostedService public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; } + + private sealed class AlternateTimeProvider : TimeProvider; } diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 7ddaec8a..4a55a087 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -1700,7 +1700,8 @@ credential/publication/health censuses and close the packet. ### P02b-2 Step 4 — Enforcement and closeout (2026-10-12) -**Implementation complete; validation and independent reviews in progress.** +**Implementation delivered; review fixes, validation and independent reviews in +progress.** Steps 1–3 completed both independent review rounds; Step 3 is closed at `02540d0`. Step 4 adds nonempty whole-production credential/publication censuses, exact keyed factories and consumer bindings, and planted helper/delegate/async controls. @@ -1708,10 +1709,13 @@ Only the processor constructs the task-owned publication attempt; its private callback is the sole event-bus publication path. The existing canonical module bus rule now also enforces that complete production fence. -The five-subject observation graph follows async and polymorphic helpers, -rejects persistence/service-locator capabilities, and inspects actual gauge -callbacks. Actual DI controls reject missing/duplicate workers/checks and wrong -factories. The literal-SQL census admits only exact producer enqueue, dispatcher +The five-subject observation graph follows async and production-owned +polymorphic contracts, rejects persistence/service-locator capabilities, and +inspects actual gauge callbacks. Actual DI controls reject missing/duplicate +workers/checks and wrong factories. Round 2 strengthening of exact credential +reader signatures, the external clock pin and actual health/observation binding +is in progress; its focused verification and fresh fix review remain pending. +The literal-SQL census admits only exact producer enqueue, dispatcher status-transition and migration backfill methods; quoted/schema/case/CTE and missing/extra-writer controls prove its reach. These are bounded IL/literal-SQL checks, not arbitrary reflection or dynamic-SQL dataflow analysis. @@ -1763,6 +1767,30 @@ The full regression above predates these test-only changes. The proof reviewer independently passed 26 existing focused/corpus cases, with zero failures/skips; neither reviewer claimed a new full suite. Round 2 remains pending. +#### Step 4 review round 2 + +Fresh read-only GPT-6.1-sol xhigh boundary and GPT-6-astra xhigh proof reviewers +examined `02540d05..2f8693e`. They verified three test-guard gaps without finding +a current production defect. Named worker/store source-reader overloads could +self-authorize; an external `TimeProvider` subclass was invisible to the +production-owned contract graph and DI checks; a same-type health factory detached +from the singleton observation passed the returned-type check. + +The fixes are being implemented: pin unique literal source-reader method +signatures, visibility and staticness with overload controls; require the actual +`TimeProvider.System` singleton instance with missing/duplicate/alternate-instance +and factory controls; and exercise the actual DI-registered health check through +Healthy → Unhealthy → Healthy against the singleton observation, with a detached +factory dirty control. The polymorphic graph covers production-owned contracts; +the external clock is admitted only through the explicit System-instance pin. +No test method, catalogue rule or published count is added. + +Post-round-1 verification at `2f8693e` passes the full 286-case architecture suite +and five Corpus cases; the Release build has zero warnings/errors. These receipts +predate the round-2 fixes. Focused verification of those fixes and fresh fix review +remain pending. No passing correction, final review approval or packet closeout +is claimed here. + ### Durable outbox dispatch P02b-1 Steps 2/3 implement `IOutbox`, replace Packet 3's step-7 shell with diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 698e9277..4264dbe6 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -1796,8 +1796,10 @@ diagnostic above row security, not the boundary, as its own note says. lazy `ConnectionStrings:OutboxDispatcher` source in the API host. Seeder/web and arbitrary keyed/service-provider resolution are refused. Physical connections verify the exact outbox role, bounded attributes and memberships. A nonempty - production census has planted clean/dirty resolution and registration controls; - real role/grant probes include valid and elevated/transitive negative controls. + production census has planted clean/dirty resolution and registration controls. + Named worker/store source readers require unique literal method signatures, + visibility and staticness; a same-name overload grants no authority. Real + role/grant probes include valid and elevated/transitive negative controls. - **Source:** [05-database.md § GRANT matrix](05-database.md#grant-matrix); [ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md). - **Type:** architecture/DI census and real PostgreSQL role probes. @@ -1806,7 +1808,9 @@ diagnostic above row security, not the boundary, as its own note says. `OutboxDispatcherRoleGuardTests`, `OutboxProcessorTests`): exact nonempty whole-production credential/factory/consumer census, keyed registration and constructor bindings, planted clean/dirty helper/delegate/async controls, and - real PostgreSQL role/grant/startup refusal proofs. + real PostgreSQL role/grant/startup refusal proofs. Round 2 strengthening pins + exact source-reader signatures/visibility/staticness; its overload controls, + focused verification and fresh fix review are pending. - **Phase:** 02b, P02b-2. #### `CoreInfrastructure_DoesNotDependOn_AnyModule` @@ -3459,6 +3463,10 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). Due/delayed/leased/deferred/terminal-requested/terminal rows distinguish gauges. Labels and diagnostics prohibit unbounded identities or sensitive exception/type/payload/connection data; /healthz remains liveness-only. + Polymorphic helper traversal covers production-owned contracts. The external + clock is pinned to the actual singleton `TimeProvider.System` instance; actual + DI health must follow Healthy → Unhealthy → Healthy on its singleton observation, + refusing a same-type detached factory. - **Source:** [ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md); [P02b-2 proof obligations](../roadmap/phase-02b-events-auth.md#decisive-proof-obligations). - **Type:** production architecture census with planted controls where structural; @@ -3467,9 +3475,11 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). - **Status:** **Implemented** (`OutboxDispatchConventionTests`, `OutboxProcessorTests`, `OutboxObservationTests`, `OutboxProductionCompositionTests`): nonempty five-subject observation graph, - actual gauge callbacks, async/polymorphic helpers and persistence/locator - capability controls; actual DI missing/duplicate/wrong-factory controls plus - bounded runtime, immutable snapshot, metric-label and liveness proofs. + actual gauge callbacks, async/production-owned polymorphic helpers and + persistence/locator capability controls; actual DI missing/duplicate/wrong-factory + controls plus bounded runtime, immutable snapshot, metric-label and liveness + proofs. Round 2 strengthening pins the external System clock and actual + health/observation binding; focused verification and fresh fix review are pending. - **Phase:** 02b, P02b-2. ### Demand-gated: lands with its adapter From 7bf14a06cb3ea65c4e0d76e8570901c7f3a05027 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 23:43:42 +0300 Subject: [PATCH 82/92] docs(roadmap): close P02b-2 implementation and review rounds Record verified producer delivery and correction evidence while keeping subscriber work with P02b-3 and CI/merge state with the closeout record. Preserve acceptance history and earlier step receipts. ADR: 0046 --- CLAUDE.md | 11 ++- README.md | 15 ++-- docs/architecture/15-event-and-outbox.md | 17 ++-- docs/architecture/31-audit-subsystem.md | 2 +- ...046-durable-event-delivery-and-recovery.md | 28 +++++++ docs/decisions/README.md | 2 +- docs/glossary.md | 2 +- docs/modules/tenancy/README.md | 6 +- docs/modules/tenancy/audit.md | 6 +- docs/modules/tenancy/permissions.md | 7 +- docs/roadmap/README.md | 3 +- docs/roadmap/phase-02b-events-auth.md | 78 ++++++++++++------- docs/standards/05-database.md | 4 +- docs/standards/09-error-handling.md | 6 +- docs/standards/11-security.md | 5 +- docs/standards/20-infrastructure-stack.md | 4 +- .../21-architecture-tests-catalogue.md | 17 ++-- 17 files changed, 144 insertions(+), 69 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4707f6a9..4e661d9c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -190,9 +190,11 @@ records both completed Step 1 reviews. Step 2 implements fenced transitions and row-bound terminal MUST audit; both independent review rounds pass. Step 3 implements hosted execution and observation; its [delivery record](docs/roadmap/phase-02b-events-auth.md#p02b-2-step-3--hosted-lifecycle-and-observability-2026-10-12) -owns verification and both completed review rounds. Step 4 enforcement is implemented; -validation and reviews are in progress in its -[delivery record](docs/roadmap/phase-02b-events-auth.md#p02b-2-step-4--enforcement-and-closeout-2026-10-12). +owns verification and both completed review rounds. Step 4 enforcement and local +closeout are complete; all four steps completed both independent review rounds. +The +[delivery record](docs/roadmap/phase-02b-events-auth.md#p02b-2-step-4--enforcement-and-closeout-2026-10-12) +owns local closeout evidence and CI/merge status. Acceptance registers six delivery obligations; it claims no inbox or subscriber delivery. P02b-3 still owns durable acceptance, shared inbox/ledger, independent retries, both real default @@ -401,7 +403,8 @@ Then read the phase that is live and the one it stands on: runtime and production-census enforcement. P02b-2's revised package/ADR-0046 are Accepted. Steps 1–2 completed both review rounds; Step 3 implements hosted dispatch and observation, with both independent review rounds complete. Step 4 - enforcement is implemented; validation and reviews are in progress. + enforcement and local closeout are complete; all four steps completed both + independent review rounds; the Step 4 record above owns CI/merge status. P02b-3 owns consumer/inbox delivery; each later packet accepts its gates first. - [docs/roadmap/phase-02d-walking-skeleton.md](docs/roadmap/phase-02d-walking-skeleton.md) — complete and merged; the delivered public-site baseline and P02b-0 handoff. diff --git a/README.md b/README.md index 8f9a092d..e840ca06 100644 --- a/README.md +++ b/README.md @@ -75,9 +75,9 @@ CI verification under Accepted ADR-0057. P02b-2's revised package and ADR-0046 are Accepted. Steps 1–2 completed both review rounds; Step 3 implements hosted dispatch and observation, with both independent review rounds complete. Its [delivery record](docs/roadmap/phase-02b-events-auth.md#p02b-2-step-3--hosted-lifecycle-and-observability-2026-10-12) -owns current evidence. Step 4 enforcement is implemented; validation and reviews -are in progress -in its +owns its runtime evidence. Step 4 enforcement and local closeout are complete; +all four steps completed both independent review rounds. CI and merge status +live in the [delivery record](docs/roadmap/phase-02b-events-auth.md#p02b-2-step-4--enforcement-and-closeout-2026-10-12). [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. @@ -128,9 +128,10 @@ proof pass real CI; the seventh required context is active. The owns CI enforcement, reviews and the positive phase-exit verdict. [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; P02b-2 Step 3 implements -the producer worker, with both independent review rounds complete and Step 4 enforcement -in progress. Consumer delivery remains P02b-3. The +owns final verification. P02b-0 and P02b-1 are complete; P02b-2 producer dispatch +and enforcement are locally complete, with both independent review rounds complete +for all four steps; the Step 4 record above owns CI/merge status. Consumer +delivery remains 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 @@ -142,7 +143,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, observability, transactional event production and hosted producer dispatch (both Step 3 review rounds complete) | Authentication and durable event consumption in [Phase 02b](docs/roadmap/phase-02b-events-auth.md) | +| **API foundation** | Error contracts, validation, tenancy, concurrency, observability, transactional event production and hosted producer dispatch with production-census enforcement (all four P02b-2 steps and both review rounds locally complete) | Authentication and durable event 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/docs/architecture/15-event-and-outbox.md b/docs/architecture/15-event-and-outbox.md index a863c48e..e5ef0f3e 100644 --- a/docs/architecture/15-event-and-outbox.md +++ b/docs/architecture/15-event-and-outbox.md @@ -27,7 +27,7 @@ not land together. | `outbox_messages` table, its schema, and its LearnStack ownership | [Phase 02a Packet 6](../roadmap/phase-02a-kernel-tenancy.md) | Schema and ownership shipped in Packet 6; producer dispatch is implemented in P02b-2 Step 3 | | `IEventBus` port + `InProcessEventBus` | [Phase 02a Packet 5](../roadmap/phase-02a-kernel-tenancy.md) | The only registered implementation | | `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` and the producer claim protocol | [P02b-2](../roadmap/phase-02b-events-auth.md#p02b-2-step-3--hosted-lifecycle-and-observability-2026-10-12) | Implemented through Step 3; both independent review rounds complete; Step 4 enforcement implemented; validation/reviews in progress | +| `OutboxProcessor` and the producer claim protocol | [P02b-2](../roadmap/phase-02b-events-auth.md#p02b-2-step-3--hosted-lifecycle-and-observability-2026-10-12) | Producer runtime and enforcement locally complete; all four steps completed both independent review rounds; CI/merge status: [Step 4 record](../roadmap/phase-02b-events-auth.md#p02b-2-step-4--enforcement-and-closeout-2026-10-12) | | `IInboxGuard` and durable consumer transactions | [P02b-3](../roadmap/phase-02b-events-auth.md) | Pending | | 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)) | @@ -61,7 +61,9 @@ transitions and atomic terminal audit; each completed both review rounds. Step 3 implements hosted execution and observation, with both independent review rounds complete in its [delivery record](../roadmap/phase-02b-events-auth.md#p02b-2-step-3--hosted-lifecycle-and-observability-2026-10-12). -Step 4 enforcement is implemented; validation and reviews are in progress. +Step 4 enforcement and local closeout are complete; all four steps completed both +independent review rounds. The [Step 4 record](../roadmap/phase-02b-events-auth.md#p02b-2-step-4--enforcement-and-closeout-2026-10-12) +owns CI/merge status. P02b-3 inbox/consumer delivery remains pending. Numerical runtime bounds live in the package, canonical DDL/grants in Standards 05 and metric names in Standards 10. @@ -153,8 +155,9 @@ separate credentials remain the closed model; the code-path and role rules are implemented for transition-witness construction and terminal audit in Step 2; both independent review rounds passed. Step 3 implements hosted execution and observation, with both independent review rounds complete. Step 4 implements -credential/publication censuses and planted controls; final validation and -reviews are in progress. +credential/publication censuses and planted controls; all four steps completed +both independent review rounds and local closeout. The [Step 4 record](../roadmap/phase-02b-events-auth.md#p02b-2-step-4--enforcement-and-closeout-2026-10-12) +owns CI/merge status. <a id="inbox-table-per-consumer-module"></a> @@ -354,9 +357,9 @@ poll, which is neither. ### The claim protocol -**ADR-0046 producer runtime implemented through P02b-2 Step 3; both independent -review rounds complete.** Step 4 enforcement, validation and reviews are in -progress. The processor admits a construction-free +**ADR-0046 producer runtime and enforcement locally complete in P02b-2; all four +steps completed both independent review rounds.** The [Step 4 record](../roadmap/phase-02b-events-auth.md#p02b-2-step-4--enforcement-and-closeout-2026-10-12) +owns CI/merge status. The processor admits a construction-free manifest policy before acquisition. Any required Pending subscription holds the contract and all aliases unchanged, even if stored payload is malformed. Active requires declared handler registration. Only explicit intentional absence may diff --git a/docs/architecture/31-audit-subsystem.md b/docs/architecture/31-audit-subsystem.md index 24084dc6..5deb611f 100644 --- a/docs/architecture/31-audit-subsystem.md +++ b/docs/architecture/31-audit-subsystem.md @@ -1719,7 +1719,7 @@ for MUST-class would lock in exactly the defect ADR-0033 removes. |-------|-------------| | [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. Hosted dispatch is implemented in P02b-2 Step 3; consumer delivery/inbox remains P02b-3. | -| P02b-2 Steps 1–3 | Schema/grants and confined producer transitions with MUST audit through the row-bound fifth seam; real-role atomicity/identity proofs. Steps 1–2 completed both review rounds. Step 3 implements hosted dispatch and observation, with both independent review rounds complete; Step 4 enforcement is implemented; validation and reviews are in progress. | +| P02b-2 Steps 1–4 | Schema/grants and confined producer transitions with MUST audit through the row-bound fifth seam; real-role atomicity/identity proofs. Steps 1–2 completed both review rounds. Step 3 implements hosted dispatch and observation, with both independent review rounds complete; Step 4 enforcement and local closeout are complete; all four steps completed both independent review rounds. The [Step 4 record](../roadmap/phase-02b-events-auth.md#p02b-2-step-4--enforcement-and-closeout-2026-10-12) owns CI/merge status. | | P02b-3 (Accepted contract; pending) | Shared transport-owned inbox and independent delivery ledger; per-subscription audit frames and Tenancy-owned terminal audit. Ordinary executor refuses privileged subscriptions. | | [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. | diff --git a/docs/decisions/0046-durable-event-delivery-and-recovery.md b/docs/decisions/0046-durable-event-delivery-and-recovery.md index 67598aea..62e919ad 100644 --- a/docs/decisions/0046-durable-event-delivery-and-recovery.md +++ b/docs/decisions/0046-durable-event-delivery-and-recovery.md @@ -7,6 +7,11 @@ Accepted — 2026-10-12. Implementation is authorized; delivery remains pending. **Date:** 2026-10-12 **Deciders:** @cemil +Delivery updates: [Amendment 1](#amendment-1--producer-runtime-delivery-2026-10-12) +and [Amendment 2](#amendment-2--producer-enforcement-and-local-closeout-2026-10-12). +The Status above records acceptance-time state; Amendment 2 records local producer +closeout with PR-head CI and merge pending. + The [P02b-2 decision package](../roadmap/phase-02b-events-auth.md#p02b-2-decision-package-2026-10-12) owns implementation sequencing and numerical runtime bounds. This reserved record decides producer dispatch and the coupled P02b-3 subscriber state machine before @@ -456,6 +461,29 @@ subscriptions and Seeder handoff stay with that packet. Required production subscriptions remain Pending; test-owned Active subscriptions supply producer runtime proof without claiming durable consumer delivery. +### Amendment 2 — Producer enforcement and local closeout (2026-10-12) + +P02b-2 producer runtime and enforcement are locally complete. All four +implementation steps completed both independent review rounds. The +[Step 4 delivery record](../roadmap/phase-02b-events-auth.md#p02b-2-step-4--enforcement-and-closeout-2026-10-12) +records nonempty production credential/publication and observation censuses, +planted controls, review findings and verified corrections. Test-only round-2 +fixes at `606362b` pin exact source-reader method signatures, the actual +`TimeProvider.System` singleton and DI health binding to singleton observation. +All 26 focused/corpus cases and both fresh fix reviews pass. The post-fix Release +build has zero warnings/errors, all 286 architecture cases pass with zero +failures/skips, and solution-wide format verification passes. The full 3,393-case +backend regression predates those test-only guard fixes; the delivery record +states its scope and applicable earlier launcher/frontend evidence. + +The catalogue remains 193 architecture test methods and 192 Implemented rules: +118 in that assembly and 74 elsewhere. This amendment changes no accepted +decision, grant budget or consumer policy. PR-head CI and merge remain pending. +P02b-3 is unimplemented: durable acceptance, shared inbox/delivery ledger, +independent subscriber retries/terminalization, both real default subscriptions +and Seeder handoff remain its obligations. Required production subscriptions +remain Pending; test-owned Active contracts prove producer runtime only. + ## References - [ADR-0006](0006-events-and-outbox.md), [ADR-0010](0010-cross-module-communication.md) diff --git a/docs/decisions/README.md b/docs/decisions/README.md index a7d5e76b..1f2a03f5 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -75,7 +75,7 @@ name P02b-2/P02b-3 activation; acceptance claims no runtime delivery. | 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. [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 | -| 0046 | [Durable Event Delivery and Recovery](0046-durable-event-delivery-and-recovery.md) | Accepted 2026-10-12: fenced producer leases/retries, Pending readiness holds and atomic row-bound terminal audit; P02b-2 Steps 1–2 implemented with both review rounds complete; Step 3 hosted dispatch/observation implemented with both independent review rounds complete; Step 4 enforcement implemented; validation/reviews in progress. [Amendment 1](0046-durable-event-delivery-and-recovery.md#amendment-1--producer-runtime-delivery-2026-10-12) records producer runtime delivery and its review evidence. Shared inbox/durable subscription acceptance and independent retries remain P02b-3. Named bounded supersessions preserve earlier Accepted records. | +| 0046 | [Durable Event Delivery and Recovery](0046-durable-event-delivery-and-recovery.md) | Accepted 2026-10-12: fenced producer leases/retries, Pending readiness holds and atomic row-bound terminal audit; P02b-2 Steps 1–2 implemented with both review rounds complete; Step 3 hosted dispatch/observation implemented with both independent review rounds complete; Step 4 enforcement and local closeout complete; all four steps completed both independent review rounds; the [Step 4 record](../roadmap/phase-02b-events-auth.md#p02b-2-step-4--enforcement-and-closeout-2026-10-12) owns CI/merge status. [Amendment 2](0046-durable-event-delivery-and-recovery.md#amendment-2--producer-enforcement-and-local-closeout-2026-10-12) records verified local closeout. [Amendment 1](0046-durable-event-delivery-and-recovery.md#amendment-1--producer-runtime-delivery-2026-10-12) records producer runtime delivery and its review evidence. Shared inbox/durable subscription acceptance and independent retries remain P02b-3. Named bounded supersessions preserve earlier Accepted records. | | 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 | | 0052 | [Anonymous Public Read Boundary](0052-anonymous-public-read-boundary.md) | Accepted 2026-10-03: host-resolved bootstrap, immutable host ceiling, mandatory marked dispatch and read-only ambient frames; ADR-0036/0040 Amendment 8; P02d-4 Steps 1–3 deliver host provenance, physical read-only frames and all institution GET/HEAD reads; both Step 3 review rounds pass; Step 4 delivers OpenAPI/SDK/CI controls; both review rounds and focused fix verification pass; six real required CI jobs and live sixth-check activation verified; P02d-4 complete and merged through PR #25 on 2026-10-08 ([Amendment 13](0052-anonymous-public-read-boundary.md#amendment-13--merge-closeout-2026-10-08)) | diff --git a/docs/glossary.md b/docs/glossary.md index d106d76b..5547fb06 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -341,7 +341,7 @@ These entries distinguish P02d-2 contracts from P02d-3 internal read contracts: | **`IFeatureFlags`** | The **only** module-facing entitlement read: `IsEnabledAsync(FeatureKey)` and `GetLimitAsync(LimitKey)` returning `long`. It resolves the tenant from `ITenantContext` and throws when there is none, so module code can never name another tenant. It **composes** rather than queries: a plan-projected key resolves through `IEntitlementProvider.GetAsync` — not by reading `platform_entitlement_cache` — a tenant-flag key reads `tenant_feature_flags`, and the killswitch overlay is applied last and wins over both. Its implementation lives in `LearnStack.Modules.Tenancy.Infrastructure`, because `tenant_feature_flags` is that module's table, and is registered at the composition root. `IFeatureFlagService` and `IUsageLimitService` are **withdrawn**, not renamed ([ADR-0021](decisions/0021-feature-based-entitlement.md)). Per [ADR-0045 § 2](decisions/0045-entitlement-and-feature-flag-socket.md). | | **`IHostToTenantResolver` / `HostResolution`** | Interface for host → `(tenant_id, organization_id?)` resolution, returning the `HostResolution(TenantId, OrganizationId?)` record or null. Reads `platform_host_to_tenant` and **nothing else** — never the Hub, because an anonymous page load must not depend on a control plane being reachable ([ADR-0034](decisions/0034-hub-contract-surface-invariant.md)). | | **APISIX** | The gateway in standalone YAML-reload mode per [ADR-0015](decisions/0015-api-gateway-apisix.md). The intended tenant-facing ingress, demand-gated to [Phase 11](roadmap/phase-11-production-hardening.md); until then ASP.NET middleware carries the same responsibilities in-process. `/api/internal/*` is never proxied by it — that listener is mTLS-only inside the pod. | -| **`OutboxProcessor`** | The API-host BackgroundService that claims one admitted outbox row immediately before invocation, with a fresh acquisition token in `locked_by`, a database-time lease and one counted attempt. Token/expiry-fenced transitions own retry and explicit audited terminal state; stable worker identity is diagnostic only. Accepted [ADR-0046](decisions/0046-durable-event-delivery-and-recovery.md) governs P02b-2 implementation: Steps 1–2 completed both review rounds, and Step 3 implements hosted execution/observation with both independent review rounds complete. Step 4 enforcement is implemented; validation and reviews are in progress; at-least-once recovery still needs P02b-3 inbox deduplication. | +| **`OutboxProcessor`** | The API-host BackgroundService that claims one admitted outbox row immediately before invocation, with a fresh acquisition token in `locked_by`, a database-time lease and one counted attempt. Token/expiry-fenced transitions own retry and explicit audited terminal state; stable worker identity is diagnostic only. Accepted [ADR-0046](decisions/0046-durable-event-delivery-and-recovery.md) governs P02b-2 implementation: Steps 1–2 completed both review rounds, and Step 3 implements hosted execution/observation with both independent review rounds complete. Step 4 enforcement and local closeout are complete; all four steps completed both independent review rounds. The [Step 4 record](roadmap/phase-02b-events-auth.md#p02b-2-step-4--enforcement-and-closeout-2026-10-12) owns CI/merge status; at-least-once recovery still needs P02b-3 inbox deduplication. | | **`IUnitOfWork`** | The ambient unit of work: **one database connection per scope**, and the transaction on it ([ADR-0040](decisions/0040-ambient-unit-of-work.md)). Every module `DbContext` resolved in the scope is built on that connection and enlisted in that transaction, and `IAuditStore` and `IOutbox` reach it through the same seam. The connection count is a correctness property, not a performance one: `SET LOCAL app.tenant_id` is connection- **and** transaction-local, so a context on its own connection reads zero rows from every tenant-owned table — silently. It exposes no `SaveChangesAsync`; contexts save themselves and it owns only the transaction boundary. [ADR-0040 Amendment 8](decisions/0040-ambient-unit-of-work.md#amendment-8--read-only-public-frames-2026-10-03) accepts explicit read-only/read-write intent, defaulting to writable: same-mode joins preserve ownership, mixed-mode joins poison the unit, and successful read-only completion permits a later writer. P02d-4 Step 1 delivers these modes and lifecycle controls; poison still requires a fresh scope. | | **`IInboxGuard`** | The Accepted shared, transport-owned inbox claim/completion seam, pending P02b-3. Deduplication is `(tenant_id, subscription_id, event_id)`, claimed before handler construction on the announced app-role transaction. Business state, completed inbox, produced outbox, MUST audit and fenced ledger completion commit together; rollback removes a new claim. Handlers do not call the guard, and targeted replay never resets a completed inbox. See [ADR-0046](decisions/0046-durable-event-delivery-and-recovery.md#subscriber-acceptance-and-inbox-ownership). | | **Inbox** | Shared tenant-owned completion record for one stable subscription/event pair. A new claim is uncommitted business-transaction work; only successful completion persists it. The delivery ledger owns failed/terminal/replay state. Canonical DDL remains P02b-3 in [Database Standards](standards/05-database.md#inbox). | diff --git a/docs/modules/tenancy/README.md b/docs/modules/tenancy/README.md index e2b9811a..2c520ae9 100644 --- a/docs/modules/tenancy/README.md +++ b/docs/modules/tenancy/README.md @@ -494,8 +494,10 @@ 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 Step 3 implements hosted dispatch, with both independent -review rounds complete. P02b-3 owns consumer delivery. +pending rows; P02b-2 implements hosted dispatch and production-census enforcement. +All four steps completed both independent review rounds and local closeout. +The [Step 4 record](../../roadmap/phase-02b-events-auth.md#p02b-2-step-4--enforcement-and-closeout-2026-10-12) +owns CI/merge status. P02b-3 owns consumer delivery. The subscriptions below are accepted flow contracts, not delivered handlers. [ADR-0046](../../decisions/0046-durable-event-delivery-and-recovery.md) accepts G1's diff --git a/docs/modules/tenancy/audit.md b/docs/modules/tenancy/audit.md index 87f10f81..1d1100a8 100644 --- a/docs/modules/tenancy/audit.md +++ b/docs/modules/tenancy/audit.md @@ -8,8 +8,10 @@ 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; -P02b-2 Step 3 implements hosted producer dispatch, with both independent review -rounds complete; subscribers remain P02b-3. +P02b-2 implements hosted producer dispatch and production-census enforcement. +All four steps completed both independent review rounds and local closeout. +The [Step 4 record](../../roadmap/phase-02b-events-auth.md#p02b-2-step-4--enforcement-and-closeout-2026-10-12) +owns CI/merge status. Subscribers remain P02b-3. **ADR-0046 producer terminal writer implemented — 2026-10-12; both review rounds complete.** diff --git a/docs/modules/tenancy/permissions.md b/docs/modules/tenancy/permissions.md index 597282f3..dff3c612 100644 --- a/docs/modules/tenancy/permissions.md +++ b/docs/modules/tenancy/permissions.md @@ -9,9 +9,10 @@ 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. -**ADR-0046 implementation through P02b-2 Step 3 — 2026-10-12.** Steps 1–3 -completed both independent review rounds. Step 4 enforcement, validation and -reviews are in progress. The API-owned dispatcher and terminal audit add no +**ADR-0046 P02b-2 implementation locally complete — 2026-10-12.** All four steps +completed both independent review rounds. The [Step 4 record](../../roadmap/phase-02b-events-auth.md#p02b-2-step-4--enforcement-and-closeout-2026-10-12) +owns CI/merge status. +The API-owned dispatcher and terminal audit add no permission key or HTTP endpoint. P02b-3's targeted replay is internal; Phase 03 separately decides operator permission and routes. Neither grants a tenant administrator bypass-role access. diff --git a/docs/roadmap/README.md b/docs/roadmap/README.md index 7ccc1a1e..1a7c5c1d 100644 --- a/docs/roadmap/README.md +++ b/docs/roadmap/README.md @@ -104,7 +104,8 @@ not deferred to the showcase phase. review rounds; Step 3 implements hosted dispatch and observation, with both independent review rounds complete in its [delivery record](phase-02b-events-auth.md#p02b-2-step-3--hosted-lifecycle-and-observability-2026-10-12). - Step 4 enforcement is implemented; validation and reviews are in progress in its + Step 4 enforcement and local closeout are complete; all four steps completed + both independent review rounds. CI/merge status lives in its [delivery record](phase-02b-events-auth.md#p02b-2-step-4--enforcement-and-closeout-2026-10-12); consumer delivery remains P02b-3. - [Phase 03: Identity Domain, Authorization, and Admin Foundation](phase-03-identity-admin.md) diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 4a55a087..b2f9b47d 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -32,8 +32,9 @@ delivery evidence and both completed review rounds. Step 2 implements durable transitions and terminal audit; both independent review rounds pass. Step 3 implements hosted lifecycle and observation; its [record](#p02b-2-step-3--hosted-lifecycle-and-observability-2026-10-12) owns -verification and both completed review rounds. Step 4 enforcement is implemented; -validation and reviews are in progress in its +verification and both completed review rounds. Step 4 enforcement and local +closeout are complete; all four steps completed both independent review rounds. +PR-head CI and merge remain pending in its [record](#p02b-2-step-4--enforcement-and-closeout-2026-10-12). P02b-3 implementation remains pending. @@ -258,7 +259,7 @@ Accepted, and its catalogue rows registered, before its first line of code. |---|---|---| | **P02b-0** | Phase entry: every premise the register below cites re-verified against `HEAD` — the code, the corpus, the Hub repository — and the register corrected where Phase 02d moved it. It drafts no record: each gate closes in the decision pass of the first packet it shapes, and each glossary headword this phase needs — `inbox`, outbox lease, dead letter, audit frame, job activator, integration-event consumer — is written in the pass whose gate fixes its meaning | [Phase 02d](phase-02d-walking-skeleton.md) exits | | **P02b-1** | **Complete and merged — [PR #29 closeout](#p02b-1-merge-and-closeout-2026-10-12).** Producer: `IOutbox`, the `OutboxFlushBehavior` invariant guard, successful-save domain-event collection and owner-only completion, and the first event with its enqueue path | G2, G3 (the key's derivation), G5 (the stored identities), G6, G18 (the payload rule), G19 Accepted | -| **P02b-2** | **Decision Accepted; Steps 1–3 complete; Step 4 implemented, validation/reviews in progress.** Dispatcher: the lease migration and grant extension, claim / publish / mark, backoff, the producer terminal state, the credential and its boot guard, the metrics and the health check | G1, G3, G4, G5, G7, G8, G17, G18 (`last_error`) Accepted; P02b-1 | +| **P02b-2** | **Decision Accepted; all four implementation steps and both independent review rounds complete; local closeout complete, PR-head CI/merge pending.** Dispatcher: the lease migration and grant extension, claim / publish / mark, backoff, the producer terminal state, the credential and its boot guard, the metrics and the health check | G1, G3, G4, G5, G7, G8, G17, G18 (`last_error`) Accepted; P02b-1 | | **P02b-3** | Consumer: durable acceptance, shared inbox/ledger DDL and `IInboxGuard`, the common per-delivery transaction, both real defaults subscriptions and Seeder handoff, independent attempts and audited terminal state, targeted internal ledger replay | G1, G6, G7 Accepted; P02b-2 | | **P02b-4** | Background jobs: the runner, its storage, the tenant-context hook, the enqueue guard, no job definition | G9 Accepted; P02b-3 | | **P02b-5** | API authentication: JwtBearer wiring and its insertion order, claim population, the host/JWT cross-check through the existing recorder, token-keyed rate limits, the 401 rows, one authenticated endpoint | G10, G11, G12 (the BFF's client), G14, G20 Accepted | @@ -289,12 +290,12 @@ premise a row cites is re-verified at that pass rather than trusted. |---|---|---|---|---|---| | 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` keyed by tenant/stable subscription/event, transport-claimed before construction; a separate delivery ledger owns retries, terminal state and targeted replay; completion shares the business/audit/outbox transaction | **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) | Accepted — 2026-10-12; decision/amendments delivered; subscriber implementation pending P02b-3 | | 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 under this 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, deterministic eligible selection; no enqueue/commit-order guarantee | Derivation: [Accepted ADR-0057](../decisions/0057-transactional-event-production-and-completion.md). Ordering: Accepted ADR-0046 and the P02b-2 package, followed by [§ Ordering](../architecture/15-event-and-outbox.md#ordering) alignment under this acceptance | P02b-1 (whether the key's derivation is checked structurally: the first event declares a key), P02b-2 (the ordering bound) | Derivation and ordering Accepted; scoped ordering runtime [implemented in P02b-2](#p02b-2-step-3--hosted-lifecycle-and-observability-2026-10-12); final Step 4 validation/reviews in progress | +| 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, deterministic eligible selection; no enqueue/commit-order guarantee | Derivation: [Accepted ADR-0057](../decisions/0057-transactional-event-production-and-completion.md). Ordering: Accepted ADR-0046 and the P02b-2 package, followed by [§ Ordering](../architecture/15-event-and-outbox.md#ordering) alignment under this acceptance | P02b-1 (whether the key's derivation is checked structurally: the first event declares a key), P02b-2 (the ordering bound) | Derivation and ordering Accepted; scoped ordering runtime [implemented in P02b-2](#p02b-2-step-3--hosted-lifecycle-and-observability-2026-10-12); local closeout complete; PR-head CI/merge pending | | 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 | Accepted ADR-0046 + dated ADR-0006 disclosure under this acceptance; Database Standards § Outbox DDL, index and grant edits | P02b-2 | Accepted — 2026-10-12; producer attempts/terminal state [implemented](#p02b-2-step-2--durable-transitions-and-terminal-audit-2026-10-12) | | 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 under this acceptance; Database Standards alignment. ADR-0046 accepts declared resolution, Pending holds and intentional absence; dispatch runtime remains P02b-2 | P02b-1 (the stored type identity and the row's identity, both written at enqueue), P02b-2 (unresolvable, retired and unsubscribed types) | Stored identities and resolution outcomes Accepted; resolver/readiness runtime [implemented](#p02b-2-step-3--hosted-lifecycle-and-observability-2026-10-12); Active durable subscriptions pending P02b-3 | | 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 delivered; delivery/inbox contract Accepted, implementation pending 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`? | MUST `security-event`; producer `platform.outbox.dead_letter` and reserved P3 `platform.delivery.dead_letter`; fenced terminal transition and audit INSERT on the same short transaction through a narrow fifth write seam; failed audit retries terminalization without publication | Accepted ADR-0046 + bounded ADR-0044 audit-grant replacement, separate additive fifth-writer seam amendment and ADR-0033 same-transaction clarification under this acceptance; `DeclareOffPath` registration and Audit Coverage baseline | P02b-2 (the producer dead-letter row its criteria assert), P02b-3 | Accepted — 2026-10-12; producer terminal audit [implemented](#p02b-2-step-2--durable-transitions-and-terminal-audit-2026-10-12); subscriber terminal audit pending 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 | Accepted ADR-0046 explicitly replaces the routine-dispatch coverage rule and bounds the ADR-0003 grant extension; dated disclosures under this acceptance, not an ADR-0041 correction | P02b-2 | Accepted — 2026-10-12; confined dispatcher exception/census [implemented](#p02b-2-step-4--enforcement-and-closeout-2026-10-12); final validation/reviews in progress | +| 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 | Accepted ADR-0046 explicitly replaces the routine-dispatch coverage rule and bounds the ADR-0003 grant extension; dated disclosures under this acceptance, not an ADR-0041 correction | P02b-2 | Accepted — 2026-10-12; confined dispatcher exception/census [implemented](#p02b-2-step-4--enforcement-and-closeout-2026-10-12); local closeout complete; PR-head CI/merge pending | | 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 | @@ -303,7 +304,7 @@ premise a row cites is re-verified at that pass rather than trusted. | 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? | Targeted internal subscriber replay in P02b-3; producer/operator reset and fencing require a separate Phase 03 contract | 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) | Accepted — internal subscriber replay P02b-3; operator contract Phase 03 | | 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) | Event metrics Accepted — 2026-10-12; runtime/guards [implemented](#p02b-2-step-4--enforcement-and-closeout-2026-10-12); final validation/reviews in progress; job metrics pending P02b-4 | +| 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) | Event metrics Accepted — 2026-10-12; runtime/guards [implemented](#p02b-2-step-4--enforcement-and-closeout-2026-10-12); local closeout complete; PR-head CI/merge pending; job metrics pending P02b-4 | | 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 under this 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 and failure descriptors Accepted and implemented; erasure inventory open 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 under this acceptance. ADR-0046 accepts distinct subscription deduplication and envelope delivery scope; 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 and delivery contract Accepted; subscriptions/Seeder handoff pending P02b-3 | | 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 | @@ -1700,8 +1701,8 @@ credential/publication/health censuses and close the packet. ### P02b-2 Step 4 — Enforcement and closeout (2026-10-12) -**Implementation delivered; review fixes, validation and independent reviews in -progress.** +**Implementation and local closeout complete; both independent review rounds +passed. PR-head CI and merge remain pending.** Steps 1–3 completed both independent review rounds; Step 3 is closed at `02540d0`. Step 4 adds nonempty whole-production credential/publication censuses, exact keyed factories and consumer bindings, and planted helper/delegate/async controls. @@ -1712,9 +1713,9 @@ bus rule now also enforces that complete production fence. The five-subject observation graph follows async and production-owned polymorphic contracts, rejects persistence/service-locator capabilities, and inspects actual gauge callbacks. Actual DI controls reject missing/duplicate -workers/checks and wrong factories. Round 2 strengthening of exact credential -reader signatures, the external clock pin and actual health/observation binding -is in progress; its focused verification and fresh fix review remain pending. +workers/checks and wrong factories. Round 2 fixes at `606362b` pin exact credential +reader signatures, the external System clock and actual health/observation binding. +All 26 focused/corpus cases and both fresh fix reviews pass. The literal-SQL census admits only exact producer enqueue, dispatcher status-transition and migration backfill methods; quoted/schema/case/CTE and missing/extra-writer controls prove its reach. These are bounded IL/literal-SQL @@ -1737,8 +1738,9 @@ receipt remains the applicable local evidence: 91 lifecycle cases and 1,608 frontend cases. These checks are not claimed as new Step 4 frontend reruns. The full PR-range documentation check passes 2,555 relative links/459 fragments, and all seven ADR-status script tests pass. Historical records and Accepted ADR -text remain preserved. Independent reviews and exact-head CI still gate final -packet closeout; no merge is claimed. +text remain preserved. All four implementation steps and both independent review +rounds are locally complete; post-fix verification is recorded below. PR-head CI +and merge remain pending. P02b-3 still owns durable acceptance, shared inbox/ledger, independent subscriber retries, both real default subscriptions and Seeder handoff. Required production @@ -1776,7 +1778,7 @@ self-authorize; an external `TimeProvider` subclass was invisible to the production-owned contract graph and DI checks; a same-type health factory detached from the singleton observation passed the returned-type check. -The fixes are being implemented: pin unique literal source-reader method +The fixes are delivered at `606362b`: pin unique literal source-reader method signatures, visibility and staticness with overload controls; require the actual `TimeProvider.System` singleton instance with missing/duplicate/alternate-instance and factory controls; and exercise the actual DI-registered health check through @@ -1786,17 +1788,34 @@ the external clock is admitted only through the explicit System-instance pin. No test method, catalogue rule or published count is added. Post-round-1 verification at `2f8693e` passes the full 286-case architecture suite -and five Corpus cases; the Release build has zero warnings/errors. These receipts -predate the round-2 fixes. Focused verification of those fixes and fresh fix review -remain pending. No passing correction, final review approval or packet closeout -is claimed here. +and five Corpus cases; the Release build has zero warnings/errors. After the +round-2 fixes at `606362b`, all 26 focused/corpus cases pass with zero +failures/skips. Fresh GPT-6.1-sol xhigh boundary and GPT-6-astra xhigh observation +fix reviewers both approve with no remaining verified findings. The boundary +reviewer independently rejects both named-method overload plants (two offenders +each), observes zero clean/restored offenders and passes three focused Release +cases. The observation reviewer independently detects the SQL-capable external +clock and detached same-type health factory, observes zero restored graph +offenders and passes two focused Release cases. Neither claims a new full suite. + +The full post-fix Release build has zero warnings/errors, all 286 architecture +cases pass with zero failures/skips, and solution-wide format verification +passes. The final local documentation check passes 2,575 relative links and +479 fragments with zero errors. The 3,393-case regression above predates these +test-only guard fixes; its +TRX counters and the 26-case focused/corpus counters were checked directly. No +test method, catalogue rule or published count changes. All four P02b-2 +implementation steps and both independent review rounds are locally complete. +PR-head CI and merge remain pending; this closes producer dispatch/enforcement +without claiming any P02b-3 consumer delivery. ### Durable outbox dispatch 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. P02b-2 Step 3 implements hosted producer dispatch, with both -independent review rounds complete; consumer delivery remains P02b-3. +first publisher. P02b-2 delivers hosted producer dispatch and production-census +enforcement; all four steps and both independent review rounds are locally +complete. PR-head CI and merge remain pending; consumer delivery remains P02b-3. The table shipped in Packet 6. A handler calls `IOutbox.EnqueueAsync` and the row commits on the same **transaction** @@ -1829,8 +1848,9 @@ Dapr trigger verbatim — so it stays with Phase 11. registered as a keyed data source behind a `Lazy<>` with a boot-time role guard, on the pattern `Platform_DataSource_Resolved_Only_By_PlatformAdminScope` already holds. `LearnStack_OutboxAdmin_Role_OnlyUsedBy_OutboxProcessor` is **Implemented** - after Step 4's nonempty credential caller census and planted controls pass; - final full regression and independent reviews remain in progress. + with Step 4's nonempty credential caller census and passing planted controls; + all four steps and both independent review rounds are locally complete. + PR-head CI and merge remain pending. - The three carriers that say the credential lives in "the worker host" — [Database Standards § Database roles](../standards/05-database.md#database-roles), `architecture/15` and `.env.example` — are derived cells, not a topology decision; @@ -2516,6 +2536,12 @@ shape depends on an open gate say so, and are written when that gate is Accepted **Producer and dispatcher** +P02b-2-owned dispatcher criteria below are met by the +[Step 4 local closeout](#p02b-2-step-4--enforcement-and-closeout-2026-10-12). +The combined HTTP/event-delivery tracing criterion still requires P02b-3 +consumer execution; the whole phase is not complete. PR-head CI and merge +remain pending. + - 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 successfully announced transaction tenant is refused, including unresolved @@ -2543,10 +2569,10 @@ shape depends on an open gate say so, and are written when that gate is Accepted row in the table, the age metric reports the **due** row's age — the case that separates it from `min(occurred_at) WHERE processed_at IS NULL`. - A `ConnectionStrings:OutboxDispatcher` naming any role other than - `learnstack_outbox_admin` refuses the boot at registration; an absent or malformed - value names its key and the expected form; a valid one registers without connecting; - and no message carries the password. The four cases already exist for the application - data source and are copied rather than re-derived. + `learnstack_outbox_admin` refuses API startup upon mandatory worker resolution; + an absent or malformed value names its key and the expected form. Lazy registration + constructs no data source and performs no connection; mandatory worker startup + resolves the source and verifies connectivity. No message carries the password. - Declared compatible aliases deserialize through frozen payload options; unknown/retired/invalid rows terminalize under closed reasons. Pending required subscriptions, including aliases, preserve rows byte-exact before claim and diff --git a/docs/standards/05-database.md b/docs/standards/05-database.md index 49526e85..8176d8d6 100644 --- a/docs/standards/05-database.md +++ b/docs/standards/05-database.md @@ -1398,7 +1398,9 @@ DDL below includes lease/terminal/identity/descriptor changes. Step 2 implements fenced transitions and atomic terminal auditing; both review rounds passed. Step 3 implements hosted execution and metrics; both independent review rounds are complete. -Step 4 enforcement is implemented; validation and reviews are in progress. Existing +Step 4 enforcement and local closeout are complete; all four steps completed both +independent review rounds. The [Step 4 record](../roadmap/phase-02b-events-auth.md#p02b-2-step-4--enforcement-and-closeout-2026-10-12) +owns CI/merge status. Existing migrations remain immutable; a new Platform-chain migration replaces free-form legacy `last_error` with `migration:legacy:redacted` before its byte CHECK. This specific ADR-0046 Platform migration temporarily uses owner-only `NO FORCE` diff --git a/docs/standards/09-error-handling.md b/docs/standards/09-error-handling.md index a68f3aff..7fa7ff6e 100644 --- a/docs/standards/09-error-handling.md +++ b/docs/standards/09-error-handling.md @@ -564,9 +564,9 @@ exception types (`LiveKit.NET.LiveKitException`, `Stripe.StripeException`, ## Outbox -**Accepted ADR-0046; producer runtime implemented through P02b-2 Step 3.** -Both Step 3 independent review rounds are complete. Step 4 enforcement is implemented; -validation and reviews are in progress. Subscriber delivery/retries and inbox +**Accepted ADR-0046; P02b-2 producer runtime and enforcement locally complete.** +All four steps completed both independent review rounds. The [Step 4 record](../roadmap/phase-02b-events-auth.md#p02b-2-step-4--enforcement-and-closeout-2026-10-12) +owns CI/merge status. Subscriber delivery/retries and inbox remain P02b-3. - Each committed normal claim increments producer `attempts` once, including an diff --git a/docs/standards/11-security.md b/docs/standards/11-security.md index d7b4495c..3a68eb62 100644 --- a/docs/standards/11-security.md +++ b/docs/standards/11-security.md @@ -246,8 +246,9 @@ for the full strategy. Standards-side: ADR-0046 accepts this P02b-2 boundary. Step 1 implements source/role guards; both review rounds are complete. Step 3 implements mandatory worker startup resolution and connectivity checks; both independent review rounds are complete. -Step 4 credential/publication enforcement is implemented; validation and -reviews are in progress. The named keyed, lazy +Step 4 credential/publication enforcement and local closeout are complete; all +four steps completed both independent review rounds. The [Step 4 record](../roadmap/phase-02b-events-auth.md#p02b-2-step-4--enforcement-and-closeout-2026-10-12) +owns CI/merge status. The named keyed, lazy dispatcher source belongs only to the API host. Configuration validates the exact username without connecting or echoing parse input. Worker startup must verify connectivity. Every new physical diff --git a/docs/standards/20-infrastructure-stack.md b/docs/standards/20-infrastructure-stack.md index 2ca9225b..878ee301 100644 --- a/docs/standards/20-infrastructure-stack.md +++ b/docs/standards/20-infrastructure-stack.md @@ -456,7 +456,9 @@ Rules: - Operator routes, permissions and producer reset/fencing belong to Phase 03. Brokers and retention purge remain demand-gated Phase 11. P02b-2 Step 3 implements hosted producer dispatch, with both independent review rounds complete; - Step 4 enforcement is implemented; validation and reviews are in progress. Inbox and + Step 4 enforcement and local closeout are complete; all four steps completed + both independent review rounds. The [Step 4 record](../roadmap/phase-02b-events-auth.md#p02b-2-step-4--enforcement-and-closeout-2026-10-12) + owns CI/merge status. Inbox and effectively-once consumer delivery remain P02b-3. Full deep dive: [15-event-and-outbox.md](../architecture/15-event-and-outbox.md). diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 4264dbe6..beed7b08 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -15,8 +15,9 @@ (producer lifecycle, payload and production-census proofs implemented), [ADR-0046 Durable Event Delivery and Recovery](../decisions/0046-durable-event-delivery-and-recovery.md) (P02b-2 transition, terminal-witness and hosted runtime proofs implemented; -Steps 1–3 completed both independent review rounds. Step 4 enforcement is implemented; -validation and reviews are in progress; P02b-3 consumer proofs remain pending). The +all four steps completed both independent review rounds and local closeout; +the [Step 4 record](../roadmap/phase-02b-events-auth.md#p02b-2-step-4--enforcement-and-closeout-2026-10-12) +owns CI/merge status; P02b-3 consumer proofs remain pending). The catalogue grows as subsequent ADRs and phases land their tests; per-test ownership stays with the originating ADR / standard. @@ -1808,9 +1809,9 @@ diagnostic above row security, not the boundary, as its own note says. `OutboxDispatcherRoleGuardTests`, `OutboxProcessorTests`): exact nonempty whole-production credential/factory/consumer census, keyed registration and constructor bindings, planted clean/dirty helper/delegate/async controls, and - real PostgreSQL role/grant/startup refusal proofs. Round 2 strengthening pins - exact source-reader signatures/visibility/staticness; its overload controls, - focused verification and fresh fix review are pending. + real PostgreSQL role/grant/startup refusal proofs. Exact unique literal + source-reader signatures, visibility and staticness reject named-method overloads; + the focused controls and both fresh fix reviews pass at `606362b`. - **Phase:** 02b, P02b-2. #### `CoreInfrastructure_DoesNotDependOn_AnyModule` @@ -3478,8 +3479,10 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). actual gauge callbacks, async/production-owned polymorphic helpers and persistence/locator capability controls; actual DI missing/duplicate/wrong-factory controls plus bounded runtime, immutable snapshot, metric-label and liveness - proofs. Round 2 strengthening pins the external System clock and actual - health/observation binding; focused verification and fresh fix review are pending. + proofs. The external clock is pinned to the actual `TimeProvider.System` singleton, + and actual DI health follows Healthy → Unhealthy → Healthy against the singleton + observation; alternate clock and detached same-type factory controls are rejected. + Focused verification and both fresh fix reviews pass at `606362b`. - **Phase:** 02b, P02b-2. ### Demand-gated: lands with its adapter From a80e06d525fe435721485057c279e703a451abb5 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Mon, 12 Oct 2026 23:45:14 +0300 Subject: [PATCH 83/92] docs(roadmap): link P02b-2 PR and live verification ownership Keep dated local receipts intact and use PR 30 as the current-head CI, review and merge surface instead of repeating an evergreen pending status throughout the corpus. ADR: 0046 --- ...046-durable-event-delivery-and-recovery.md | 3 ++ docs/roadmap/phase-02b-events-auth.md | 49 +++++++++++++------ 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/docs/decisions/0046-durable-event-delivery-and-recovery.md b/docs/decisions/0046-durable-event-delivery-and-recovery.md index 62e919ad..5a9ed9d2 100644 --- a/docs/decisions/0046-durable-event-delivery-and-recovery.md +++ b/docs/decisions/0046-durable-event-delivery-and-recovery.md @@ -11,6 +11,9 @@ Delivery updates: [Amendment 1](#amendment-1--producer-runtime-delivery-2026-10- and [Amendment 2](#amendment-2--producer-enforcement-and-local-closeout-2026-10-12). The Status above records acceptance-time state; Amendment 2 records local producer closeout with PR-head CI and merge pending. +Current CI/review/merge navigation (2026-10-12): the +[PR handoff](../roadmap/phase-02b-events-auth.md#p02b-2-pr-handoff-2026-10-12) +links the live PR; the delivery notes retain their as-of status. The [P02b-2 decision package](../roadmap/phase-02b-events-auth.md#p02b-2-decision-package-2026-10-12) owns implementation sequencing and numerical runtime bounds. This reserved record diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index b2f9b47d..21a5a25a 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -34,9 +34,8 @@ implements hosted lifecycle and observation; its [record](#p02b-2-step-3--hosted-lifecycle-and-observability-2026-10-12) owns verification and both completed review rounds. Step 4 enforcement and local closeout are complete; all four steps completed both independent review rounds. -PR-head CI and merge remain pending in its -[record](#p02b-2-step-4--enforcement-and-closeout-2026-10-12). P02b-3 implementation -remains pending. +The [PR handoff](#p02b-2-pr-handoff-2026-10-12) owns live CI/review/merge state. +P02b-3 implementation remains pending. **Phase 02d handoff — 2026-10-11.** P02b-0 re-verifies the delivered `/api/v1` public contracts, native HTTPS on retained `*.learnstack.local` seed hosts, @@ -259,7 +258,7 @@ Accepted, and its catalogue rows registered, before its first line of code. |---|---|---| | **P02b-0** | Phase entry: every premise the register below cites re-verified against `HEAD` — the code, the corpus, the Hub repository — and the register corrected where Phase 02d moved it. It drafts no record: each gate closes in the decision pass of the first packet it shapes, and each glossary headword this phase needs — `inbox`, outbox lease, dead letter, audit frame, job activator, integration-event consumer — is written in the pass whose gate fixes its meaning | [Phase 02d](phase-02d-walking-skeleton.md) exits | | **P02b-1** | **Complete and merged — [PR #29 closeout](#p02b-1-merge-and-closeout-2026-10-12).** Producer: `IOutbox`, the `OutboxFlushBehavior` invariant guard, successful-save domain-event collection and owner-only completion, and the first event with its enqueue path | G2, G3 (the key's derivation), G5 (the stored identities), G6, G18 (the payload rule), G19 Accepted | -| **P02b-2** | **Decision Accepted; all four implementation steps and both independent review rounds complete; local closeout complete, PR-head CI/merge pending.** Dispatcher: the lease migration and grant extension, claim / publish / mark, backoff, the producer terminal state, the credential and its boot guard, the metrics and the health check | G1, G3, G4, G5, G7, G8, G17, G18 (`last_error`) Accepted; P02b-1 | +| **P02b-2** | **Decision Accepted; all four implementation steps and both independent review rounds complete; local closeout complete; [PR handoff](#p02b-2-pr-handoff-2026-10-12) owns CI/review/merge state.** Dispatcher: the lease migration and grant extension, claim / publish / mark, backoff, the producer terminal state, the credential and its boot guard, the metrics and the health check | G1, G3, G4, G5, G7, G8, G17, G18 (`last_error`) Accepted; P02b-1 | | **P02b-3** | Consumer: durable acceptance, shared inbox/ledger DDL and `IInboxGuard`, the common per-delivery transaction, both real defaults subscriptions and Seeder handoff, independent attempts and audited terminal state, targeted internal ledger replay | G1, G6, G7 Accepted; P02b-2 | | **P02b-4** | Background jobs: the runner, its storage, the tenant-context hook, the enqueue guard, no job definition | G9 Accepted; P02b-3 | | **P02b-5** | API authentication: JwtBearer wiring and its insertion order, claim population, the host/JWT cross-check through the existing recorder, token-keyed rate limits, the 401 rows, one authenticated endpoint | G10, G11, G12 (the BFF's client), G14, G20 Accepted | @@ -290,12 +289,12 @@ premise a row cites is re-verified at that pass rather than trusted. |---|---|---|---|---|---| | 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` keyed by tenant/stable subscription/event, transport-claimed before construction; a separate delivery ledger owns retries, terminal state and targeted replay; completion shares the business/audit/outbox transaction | **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) | Accepted — 2026-10-12; decision/amendments delivered; subscriber implementation pending P02b-3 | | 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 under this 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, deterministic eligible selection; no enqueue/commit-order guarantee | Derivation: [Accepted ADR-0057](../decisions/0057-transactional-event-production-and-completion.md). Ordering: Accepted ADR-0046 and the P02b-2 package, followed by [§ Ordering](../architecture/15-event-and-outbox.md#ordering) alignment under this acceptance | P02b-1 (whether the key's derivation is checked structurally: the first event declares a key), P02b-2 (the ordering bound) | Derivation and ordering Accepted; scoped ordering runtime [implemented in P02b-2](#p02b-2-step-3--hosted-lifecycle-and-observability-2026-10-12); local closeout complete; PR-head CI/merge pending | +| 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, deterministic eligible selection; no enqueue/commit-order guarantee | Derivation: [Accepted ADR-0057](../decisions/0057-transactional-event-production-and-completion.md). Ordering: Accepted ADR-0046 and the P02b-2 package, followed by [§ Ordering](../architecture/15-event-and-outbox.md#ordering) alignment under this acceptance | P02b-1 (whether the key's derivation is checked structurally: the first event declares a key), P02b-2 (the ordering bound) | Derivation and ordering Accepted; scoped ordering runtime [implemented in P02b-2](#p02b-2-step-3--hosted-lifecycle-and-observability-2026-10-12); local closeout complete; [PR handoff](#p02b-2-pr-handoff-2026-10-12) owns CI/review/merge state | | 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 | Accepted ADR-0046 + dated ADR-0006 disclosure under this acceptance; Database Standards § Outbox DDL, index and grant edits | P02b-2 | Accepted — 2026-10-12; producer attempts/terminal state [implemented](#p02b-2-step-2--durable-transitions-and-terminal-audit-2026-10-12) | | 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 under this acceptance; Database Standards alignment. ADR-0046 accepts declared resolution, Pending holds and intentional absence; dispatch runtime remains P02b-2 | P02b-1 (the stored type identity and the row's identity, both written at enqueue), P02b-2 (unresolvable, retired and unsubscribed types) | Stored identities and resolution outcomes Accepted; resolver/readiness runtime [implemented](#p02b-2-step-3--hosted-lifecycle-and-observability-2026-10-12); Active durable subscriptions pending P02b-3 | | 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 delivered; delivery/inbox contract Accepted, implementation pending 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`? | MUST `security-event`; producer `platform.outbox.dead_letter` and reserved P3 `platform.delivery.dead_letter`; fenced terminal transition and audit INSERT on the same short transaction through a narrow fifth write seam; failed audit retries terminalization without publication | Accepted ADR-0046 + bounded ADR-0044 audit-grant replacement, separate additive fifth-writer seam amendment and ADR-0033 same-transaction clarification under this acceptance; `DeclareOffPath` registration and Audit Coverage baseline | P02b-2 (the producer dead-letter row its criteria assert), P02b-3 | Accepted — 2026-10-12; producer terminal audit [implemented](#p02b-2-step-2--durable-transitions-and-terminal-audit-2026-10-12); subscriber terminal audit pending 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 | Accepted ADR-0046 explicitly replaces the routine-dispatch coverage rule and bounds the ADR-0003 grant extension; dated disclosures under this acceptance, not an ADR-0041 correction | P02b-2 | Accepted — 2026-10-12; confined dispatcher exception/census [implemented](#p02b-2-step-4--enforcement-and-closeout-2026-10-12); local closeout complete; PR-head CI/merge pending | +| 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 | Accepted ADR-0046 explicitly replaces the routine-dispatch coverage rule and bounds the ADR-0003 grant extension; dated disclosures under this acceptance, not an ADR-0041 correction | P02b-2 | Accepted — 2026-10-12; confined dispatcher exception/census [implemented](#p02b-2-step-4--enforcement-and-closeout-2026-10-12); local closeout complete; [PR handoff](#p02b-2-pr-handoff-2026-10-12) owns CI/review/merge state | | 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 | @@ -304,7 +303,7 @@ premise a row cites is re-verified at that pass rather than trusted. | 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? | Targeted internal subscriber replay in P02b-3; producer/operator reset and fencing require a separate Phase 03 contract | 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) | Accepted — internal subscriber replay P02b-3; operator contract Phase 03 | | 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) | Event metrics Accepted — 2026-10-12; runtime/guards [implemented](#p02b-2-step-4--enforcement-and-closeout-2026-10-12); local closeout complete; PR-head CI/merge pending; job metrics pending P02b-4 | +| 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) | Event metrics Accepted — 2026-10-12; runtime/guards [implemented](#p02b-2-step-4--enforcement-and-closeout-2026-10-12); local closeout complete; [PR handoff](#p02b-2-pr-handoff-2026-10-12) owns CI/review/merge state; job metrics pending P02b-4 | | 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 under this 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 and failure descriptors Accepted and implemented; erasure inventory open 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 under this acceptance. ADR-0046 accepts distinct subscription deduplication and envelope delivery scope; 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 and delivery contract Accepted; subscriptions/Seeder handoff pending P02b-3 | | 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 | @@ -1702,7 +1701,8 @@ credential/publication/health censuses and close the packet. ### P02b-2 Step 4 — Enforcement and closeout (2026-10-12) **Implementation and local closeout complete; both independent review rounds -passed. PR-head CI and merge remain pending.** +passed.** Current CI/review/merge state belongs to the +[PR handoff](#p02b-2-pr-handoff-2026-10-12). Steps 1–3 completed both independent review rounds; Step 3 is closed at `02540d0`. Step 4 adds nonempty whole-production credential/publication censuses, exact keyed factories and consumer bindings, and planted helper/delegate/async controls. @@ -1739,8 +1739,8 @@ frontend cases. These checks are not claimed as new Step 4 frontend reruns. The full PR-range documentation check passes 2,555 relative links/459 fragments, and all seven ADR-status script tests pass. Historical records and Accepted ADR text remain preserved. All four implementation steps and both independent review -rounds are locally complete; post-fix verification is recorded below. PR-head CI -and merge remain pending. +rounds are locally complete; post-fix verification is recorded below. The +[PR handoff](#p02b-2-pr-handoff-2026-10-12) owns current CI/review/merge state. P02b-3 still owns durable acceptance, shared inbox/ledger, independent subscriber retries, both real default subscriptions and Seeder handoff. Required production @@ -1809,13 +1809,34 @@ implementation steps and both independent review rounds are locally complete. PR-head CI and merge remain pending; this closes producer dispatch/enforcement without claiming any P02b-3 consumer delivery. +### P02b-2 PR handoff (2026-10-12) + +[PR #30](https://github.com/HodeTech/LearnStack/pull/30) opens development → main +from local closeout `7bf14a0`. All four implementation steps and both independent +review rounds per step are complete. The PR's checks and current-head evidence +in its description own live CI state; its review and merge state remain on the +same PR. Earlier pending statements in dated local receipts are as-of records, +not the current check verdict. No merge is claimed by opening the PR. + +The seven existing required contexts and strict up-to-date protection were read +back from GitHub unchanged. Final verification must use the PR's actual current +head, not an earlier passing run. The PR description records run URLs and suite +counters after that verification. Five final local corpus checks pass with zero +failures/skips; this is not a CI result. + +P02b-3 is next after P02b-2 review/merge and its own decision pass: shared inbox +and delivery ledger, durable acceptance, independent subscriber retries/audit, +both real defaults subscriptions and Seeder handoff. No consumer delivery, +background-job or authentication completion is claimed here. + ### Durable outbox dispatch 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. P02b-2 delivers hosted producer dispatch and production-census enforcement; all four steps and both independent review rounds are locally -complete. PR-head CI and merge remain pending; consumer delivery remains P02b-3. +complete. The [PR handoff](#p02b-2-pr-handoff-2026-10-12) owns current CI/review/merge +state; consumer delivery remains P02b-3. The table shipped in Packet 6. A handler calls `IOutbox.EnqueueAsync` and the row commits on the same **transaction** @@ -1850,7 +1871,7 @@ Dapr trigger verbatim — so it stays with Phase 11. `LearnStack_OutboxAdmin_Role_OnlyUsedBy_OutboxProcessor` is **Implemented** with Step 4's nonempty credential caller census and passing planted controls; all four steps and both independent review rounds are locally complete. - PR-head CI and merge remain pending. + The [PR handoff](#p02b-2-pr-handoff-2026-10-12) owns CI/review/merge state. - The three carriers that say the credential lives in "the worker host" — [Database Standards § Database roles](../standards/05-database.md#database-roles), `architecture/15` and `.env.example` — are derived cells, not a topology decision; @@ -2539,8 +2560,8 @@ shape depends on an open gate say so, and are written when that gate is Accepted P02b-2-owned dispatcher criteria below are met by the [Step 4 local closeout](#p02b-2-step-4--enforcement-and-closeout-2026-10-12). The combined HTTP/event-delivery tracing criterion still requires P02b-3 -consumer execution; the whole phase is not complete. PR-head CI and merge -remain pending. +consumer execution; the whole phase is not complete. The +[PR handoff](#p02b-2-pr-handoff-2026-10-12) owns CI/review/merge state. - 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 From a851b7cd4181ac12dce66fbf416d0a1b8f268dbb Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Tue, 13 Oct 2026 00:07:58 +0300 Subject: [PATCH 84/92] fix(dev): reject paired missing demo role passwords Require each selected role password explicitly so an omitted connection password and environment value cannot validate by comparing undefined. Prove the refusal for all four roles and retain valid password grammar. ADR: 0046 --- docs/roadmap/phase-02b-events-auth.md | 26 +++++++++++++++++++ .../apps/web/src/test/demo-lifecycle.test.ts | 13 ++++++++++ scripts/demo-database.mjs | 5 +++- 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 21a5a25a..3e89633d 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -1829,6 +1829,32 @@ and delivery ledger, durable acceptance, independent subscriber retries/audit, both real defaults subscriptions and Seeder handoff. No consumer delivery, background-job or authentication completion is claimed here. +### P02b-2 PR review follow-up (2026-10-13) + +All seven required jobs pass on PR head `a80e06d`, including the complete +3,393-case backend regression and 1,608 frontend cases, with zero failures/skips. +[Run 38306262165](https://github.com/HodeTech/LearnStack/actions/runs/38306262165) +also supplies 27 matching Lighthouse JSON/HTML report pairs and nine screenshots. +All reports have zero CLS, accessibility score 1 and normalized gzip JavaScript +between 106,359 and 106,553 bytes. Proof finishes in 646.454 seconds; owned process, +Compose, private-file, environment and lock cleanup all pass. These are receipts +for that head, not evidence for a later correction. + +Automated PR review identified a local-launcher validation defect: omitting both +a role's connection-string password and its selected password variable compared +two absent values as equal. Independent reproduction accepts that invalid state +for all four roles. Four new regression cases fail against the previous validator +and pass after requiring a nonempty configured string password. Existing quoted +password, whitespace and alias controls remain green; diagnostics remain fixed +and contain no supplied configuration. No runtime dispatcher or API contract +changes. + +Local verification passes 95 lifecycle cases and the guarded frontend suite's +1,612 cases (1,552 web and 60 SDK), with zero failures/skips/todos; lifecycle cases +are a subset, not an additional suite count. Workspace lint/typecheck pass. +Both independent correction reviews and final correction-head CI remain pending. +The [PR handoff](#p02b-2-pr-handoff-2026-10-12) continues to own live PR state. + ### Durable outbox dispatch P02b-1 Steps 2/3 implement `IOutbox`, replace Packet 3's step-7 shell with diff --git a/frontend/apps/web/src/test/demo-lifecycle.test.ts b/frontend/apps/web/src/test/demo-lifecycle.test.ts index be23c476..651e2df2 100644 --- a/frontend/apps/web/src/test/demo-lifecycle.test.ts +++ b/frontend/apps/web/src/test/demo-lifecycle.test.ts @@ -135,6 +135,19 @@ describe('launcher-owned local database validation', () => { /^Local demo database configuration is invalid$/, ); }); + it.each(roles)( + 'refuses %s when both password sources are missing', + (suffix, role, passwordKey) => { + const values = databaseValues(); + delete values[passwordKey]; + values[`ConnectionStrings__${suffix}`] = + `Host=localhost;Port=5432;Database=learnstack;Username=${role}`; + expect(() => validateDemoDatabaseConfiguration(values)).toThrowError( + /^Local demo database configuration is invalid$/, + ); + expect(() => validateDemoDatabaseConfiguration(databaseValues())).not.toThrow(); + }, + ); it('keeps every Seeder mode free of inherited credentials and diagnostics', () => { const base = { PATH: '/bin', diff --git a/scripts/demo-database.mjs b/scripts/demo-database.mjs index cfb225c7..26314c02 100644 --- a/scripts/demo-database.mjs +++ b/scripts/demo-database.mjs @@ -76,11 +76,14 @@ export function validateDemoDatabaseConfiguration(values) { ['PlatformAdmin', 'learnstack_platform', 'LEARNSTACK_PLATFORM_PW'], ['OutboxDispatcher', 'learnstack_outbox_admin', 'LEARNSTACK_OUTBOX_PW'], ]) { + const password = values[passwordKey]; const fields = localConnectionFields(values[`ConnectionStrings__${suffix}`]); const port = fields.get('port') ?? '5432'; if ( + typeof password !== 'string' || + !password || fields.get('username') !== role || - fields.get('password') !== values[passwordKey] || + fields.get('password') !== password || fields.get('database') !== values.POSTGRES_DB || !['localhost', '127.0.0.1'].includes(fields.get('host')) || !/^\d+$/.test(port) || From 440897079679dffc8aa6717fa9d62e94c44d9832 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Tue, 13 Oct 2026 00:11:34 +0300 Subject: [PATCH 85/92] docs(roadmap): record demo password correction reviews Record both independent correction reviews and bounded local evidence while leaving current-head CI to the PR's live verification record. ADR: 0046 --- docs/roadmap/phase-02b-events-auth.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 3e89633d..514c2ae8 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -1852,7 +1852,12 @@ changes. Local verification passes 95 lifecycle cases and the guarded frontend suite's 1,612 cases (1,552 web and 60 SDK), with zero failures/skips/todos; lifecycle cases are a subset, not an additional suite count. Workspace lint/typecheck pass. -Both independent correction reviews and final correction-head CI remain pending. +Fresh sequential GPT-6.1-sol high and GPT-6-sol high correction reviewers both +approve `a80e06d..a851b7c` with no findings. Each independently reproduces the four +parent/current differences and passes all 95 lifecycle cases. Five Corpus cases +and seven ADR-status script tests also pass; the final link check covers 2,586 +relative links and 490 fragments with zero errors. Final correction-head CI +remains pending in this local receipt. The [PR handoff](#p02b-2-pr-handoff-2026-10-12) continues to own live PR state. ### Durable outbox dispatch From 13cd632b604335b1765650a5b7dd7ade995c42fc Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Tue, 13 Oct 2026 01:36:13 +0300 Subject: [PATCH 86/92] fix(kernel): align recovery selection and preserve commit faults Keep Pending backlog outside ordered recovery scans and preserve both uncertain COMMIT and reconciliation failures. Expose the fixed local configuration diagnostic, tighten lifecycle proofs and reconcile the reviewed documentation with the shipped producer boundary. ADR: 0046 --- .claude/skills/add-integration-test/SKILL.md | 2 +- CLAUDE.md | 4 +- .../Messaging/OutboxProcessor.cs | 6 +- .../Messaging/OutboxDispatchModels.cs | 2 +- .../Messaging/OutboxDispatchStore.cs | 19 +- ...54_align_outbox_recovery_index.Designer.cs | 29 +++ ...61012222654_align_outbox_recovery_index.cs | 36 ++++ .../DevelopmentWebApplicationFactory.cs | 6 +- .../ConstructionOnlyApiFactory.cs | 4 + .../Database/CommitResponseDroppingProxy.cs | 11 +- .../Database/OutboxDispatchMigrationTests.cs | 134 +++++++++++++- .../Database/OutboxDispatchStoreTests.cs | 47 +++++ .../Database/OutboxProcessorTests.cs | 165 ++++++++++++++---- docs/architecture/15-event-and-outbox.md | 9 +- docs/architecture/29-dapr-integration.md | 16 +- docs/glossary.md | 4 +- docs/roadmap/phase-02b-events-auth.md | 60 ++++++- docs/roadmap/phase-11-production-hardening.md | 11 +- docs/standards/05-database.md | 4 +- docs/standards/18-audit-coverage.md | 6 +- .../21-architecture-tests-catalogue.md | 5 +- .../apps/web/src/test/demo-lifecycle.test.ts | 43 ++++- scripts/demo-database.d.mts | 2 +- scripts/demo-database.mjs | 8 +- scripts/demo.d.mts | 1 + scripts/demo.mjs | 22 ++- scripts/public-runtime.mjs | 53 ++---- 27 files changed, 586 insertions(+), 123 deletions(-) create mode 100644 backend/src/LearnStack.Infrastructure/Persistence/Migrations/20261012222654_align_outbox_recovery_index.Designer.cs create mode 100644 backend/src/LearnStack.Infrastructure/Persistence/Migrations/20261012222654_align_outbox_recovery_index.cs diff --git a/.claude/skills/add-integration-test/SKILL.md b/.claude/skills/add-integration-test/SKILL.md index b1b3d00b..8beaa950 100644 --- a/.claude/skills/add-integration-test/SKILL.md +++ b/.claude/skills/add-integration-test/SKILL.md @@ -240,7 +240,7 @@ those shipped examples for Education rather than inventing an authenticated rout > **The `_fx.*` members in Steps 3 to 5 are illustrative.** P02b-1 implements > `IOutbox`; follow `OutboxProducerTests` and `TenantCreatedProducerTests` for > shipped producer fixtures. P02b-2 Step 3 implements the API-host dispatcher; -> its both independent review rounds are complete, and `OutboxProcessorTests` owns its +> both independent review rounds are complete, and `OutboxProcessorTests` owns its > runtime proofs. Real subscriptions remain Pending; consumer/inbox assertions > belong to P02b-3. `audit_log` shipped in Packet 9: for a real audit assertion, > follow `AuditWorkflowTests`, which drives commands through diff --git a/CLAUDE.md b/CLAUDE.md index 4e661d9c..4acb64d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -195,8 +195,8 @@ closeout are complete; all four steps completed both independent review rounds. The [delivery record](docs/roadmap/phase-02b-events-auth.md#p02b-2-step-4--enforcement-and-closeout-2026-10-12) owns local closeout evidence and CI/merge status. -Acceptance registers six delivery obligations; -it claims no inbox or subscriber delivery. P02b-3 still owns +Acceptance registers six new catalogue rules; the Step 4 record owns their +delivery evidence. P02b-2 claims no inbox or subscriber delivery. P02b-3 still owns durable acceptance, shared inbox/ledger, independent retries, both real default subscriptions and Seeder handoff. Phase 06 retains the full renderer/Studio expansion. diff --git a/backend/src/LearnStack.Api/Messaging/OutboxProcessor.cs b/backend/src/LearnStack.Api/Messaging/OutboxProcessor.cs index 70243843..090787b1 100644 --- a/backend/src/LearnStack.Api/Messaging/OutboxProcessor.cs +++ b/backend/src/LearnStack.Api/Messaging/OutboxProcessor.cs @@ -129,14 +129,14 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) private async Task DispatchAsync(OutboxDispatchClaim claim, CancellationToken stoppingToken) { - var label = claim.Kind == OutboxClaimKind.Terminal - ? _reader.Read(claim).EventTypeLabel : _registry.Resolve(claim.Type).EventTypeLabel; if (claim.Kind == OutboxClaimKind.Normal) { await DispatchNormalAsync(claim, stoppingToken).ConfigureAwait(false); return; } + var label = claim.Kind == OutboxClaimKind.Terminal + ? _reader.Read(claim).EventTypeLabel : _registry.Resolve(claim.Type).EventTypeLabel; using var activity = _observation.BeginAttempt(claim.CorrelationId, label); if (claim.Kind == OutboxClaimKind.Recovery) { @@ -255,7 +255,7 @@ private void LogAttempt(Activity activity, string label) } } - [LoggerMessage(Level = LogLevel.Warning, Message = "Outbox dispatch iteration failed with outcome storage_failure.")] + [LoggerMessage(Level = LogLevel.Warning, Message = "Outbox dispatch iteration failed with outcome iteration_failed.")] private static partial void LogIterationFailure(ILogger logger); [LoggerMessage(Level = LogLevel.Information, diff --git a/backend/src/LearnStack.Infrastructure/Messaging/OutboxDispatchModels.cs b/backend/src/LearnStack.Infrastructure/Messaging/OutboxDispatchModels.cs index 785f63a1..9dc0f449 100644 --- a/backend/src/LearnStack.Infrastructure/Messaging/OutboxDispatchModels.cs +++ b/backend/src/LearnStack.Infrastructure/Messaging/OutboxDispatchModels.cs @@ -4,7 +4,7 @@ namespace LearnStack.Infrastructure.Messaging; public enum OutboxClaimKind { Normal, Recovery, Terminal } public enum OutboxTerminalReason { UnknownType, RetiredType, InvalidEnvelope, RetryExhausted } -public enum OutboxFailureReason { PublishFailed, PublishTimeout, Shutdown, UnknownOutcome } +public enum OutboxFailureReason { PublishFailed, PublishTimeout, UnknownOutcome } /// <summary>A committed acquisition; its token is meaningful only until database-time expiry.</summary> public sealed record OutboxDispatchClaim( diff --git a/backend/src/LearnStack.Infrastructure/Messaging/OutboxDispatchStore.cs b/backend/src/LearnStack.Infrastructure/Messaging/OutboxDispatchStore.cs index 56f97079..bb8f11dc 100644 --- a/backend/src/LearnStack.Infrastructure/Messaging/OutboxDispatchStore.cs +++ b/backend/src/LearnStack.Infrastructure/Messaging/OutboxDispatchStore.cs @@ -14,6 +14,10 @@ public sealed class OutboxDispatchStore : EventTerminalTransition.MintingSource private const string Incomplete = "processed_at IS NULL AND dead_lettered_at IS NULL"; private const string Normal = Incomplete + " AND terminal_reason IS NULL AND attempts < 6" + " AND available_after <= clock_timestamp() AND locked_by IS NULL"; + private const string Recovery = Incomplete + " AND terminal_reason IS NULL" + + " AND (locked_by IS NOT NULL OR attempts >= 6)" + + " AND ((locked_by IS NOT NULL AND locked_until <= clock_timestamp())" + + " OR (locked_by IS NULL AND attempts >= 6))"; private const string Admitted = " AND NOT (type = ANY(@pending)) AND NOT (partition_key = ANY(@blocked))"; private const string ClaimColumns = "id, tenant_id, organization_id, locked_by, attempts, locked_until," + " type, topic, partition_key, occurred_at, correlation_id, causation_id, actor_user_id, payload::text, metadata IS NULL"; @@ -51,9 +55,7 @@ public OutboxDispatchStore(Lazy<NpgsqlDataSource> source, OutboxEventRegistry re public Task<OutboxDispatchClaim?> TryAcquireRecoveryAsync(IReadOnlyCollection<string> blockedPartitions, CancellationToken cancellationToken = default) => - AcquireAsync(OutboxClaimKind.Recovery, Incomplete + " AND terminal_reason IS NULL AND" - + " ((locked_until IS NOT NULL AND locked_until <= clock_timestamp())" - + " OR (locked_by IS NULL AND attempts >= 6))", blockedPartitions, cancellationToken); + AcquireAsync(OutboxClaimKind.Recovery, Recovery, blockedPartitions, cancellationToken); public Task<OutboxDispatchClaim?> TryClaimTerminalAsync(IReadOnlyCollection<string> blockedPartitions, CancellationToken cancellationToken = default) => @@ -113,7 +115,6 @@ public Task<bool> FailAsync(OutboxDispatchClaim claim, OutboxFailureReason reaso { OutboxFailureReason.PublishFailed => "publish:failed:provider", OutboxFailureReason.PublishTimeout => "publish:timeout:deadline", - OutboxFailureReason.Shutdown => "publish:canceled:shutdown", OutboxFailureReason.UnknownOutcome => "recovery:unknown:expired", _ => throw new ArgumentOutOfRangeException(nameof(reason)), }; @@ -214,7 +215,15 @@ public async Task<bool> TryTerminalizeAsync(OutboxDispatchClaim claim, string va { // COMMIT may have reached PostgreSQL. Reconcile on a fresh bounded connection; // a durable terminal row already includes its audit and must never be inserted again. - if (await IsDurablyTerminalAsync(claim.Id).ConfigureAwait(false)) return true; + try + { + if (await IsDurablyTerminalAsync(claim.Id).ConfigureAwait(false)) return true; + } + catch (Exception reconciliationFailure) + { + throw new AggregateException("The outbox terminal commit outcome remains unknown.", + exception, reconciliationFailure); + } throw; } } diff --git a/backend/src/LearnStack.Infrastructure/Persistence/Migrations/20261012222654_align_outbox_recovery_index.Designer.cs b/backend/src/LearnStack.Infrastructure/Persistence/Migrations/20261012222654_align_outbox_recovery_index.Designer.cs new file mode 100644 index 00000000..61e61153 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Persistence/Migrations/20261012222654_align_outbox_recovery_index.Designer.cs @@ -0,0 +1,29 @@ +// <auto-generated /> +using LearnStack.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace LearnStack.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(PlatformDbContext))] + [Migration("20261012222654_align_outbox_recovery_index")] + partial class align_outbox_recovery_index + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/LearnStack.Infrastructure/Persistence/Migrations/20261012222654_align_outbox_recovery_index.cs b/backend/src/LearnStack.Infrastructure/Persistence/Migrations/20261012222654_align_outbox_recovery_index.cs new file mode 100644 index 00000000..81cd4229 --- /dev/null +++ b/backend/src/LearnStack.Infrastructure/Persistence/Migrations/20261012222654_align_outbox_recovery_index.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LearnStack.Infrastructure.Persistence.Migrations +{ + /// <inheritdoc /> + public partial class align_outbox_recovery_index : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + // ADR-0046 requires the same total selection order for both recovery + // branches. Keep ordinary unleased Pending backlog outside the index. + migrationBuilder.Sql(""" + DROP INDEX ix_outbox_messages_recovery; + CREATE INDEX ix_outbox_messages_recovery + ON outbox_messages (available_after, occurred_at, id) + WHERE processed_at IS NULL AND dead_lettered_at IS NULL + AND terminal_reason IS NULL AND (locked_by IS NOT NULL OR attempts >= 6); + """); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(""" + DROP INDEX ix_outbox_messages_recovery; + CREATE INDEX ix_outbox_messages_recovery + ON outbox_messages (locked_until, occurred_at, id) + WHERE processed_at IS NULL AND dead_lettered_at IS NULL + AND terminal_reason IS NULL AND locked_by IS NOT NULL; + """); + } + } +} diff --git a/backend/tests/LearnStack.Tests.Contract/DevelopmentWebApplicationFactory.cs b/backend/tests/LearnStack.Tests.Contract/DevelopmentWebApplicationFactory.cs index cb05ef3d..31e7f255 100644 --- a/backend/tests/LearnStack.Tests.Contract/DevelopmentWebApplicationFactory.cs +++ b/backend/tests/LearnStack.Tests.Contract/DevelopmentWebApplicationFactory.cs @@ -18,8 +18,10 @@ namespace LearnStack.Tests.Contract; /// gated <c>MapOpenApi()</c> on <c>IsDevelopment()</c>, so the document 404'd /// in CI without it. Packet 4 removed that gate — the document is the contract /// the SDK generates from and CI diffs, so it is served in every environment — -/// and the override now exists only to keep the contract suite on one known -/// environment rather than on whatever the runner happens to set. +/// and the environment override keeps the contract suite on one known environment. +/// This served-contract fixture also removes the dispatcher worker because it owns +/// no dispatcher database. Its retained private outbox health check intentionally +/// remains Unhealthy; the static <c>/healthz</c> liveness endpoint stays independent. /// </remarks> public sealed class DevelopmentWebApplicationFactory : WebApplicationFactory<Program> { diff --git a/backend/tests/LearnStack.Tests.Integration/ConstructionOnlyApiFactory.cs b/backend/tests/LearnStack.Tests.Integration/ConstructionOnlyApiFactory.cs index 7eaf3a7d..39a8832b 100644 --- a/backend/tests/LearnStack.Tests.Integration/ConstructionOnlyApiFactory.cs +++ b/backend/tests/LearnStack.Tests.Integration/ConstructionOnlyApiFactory.cs @@ -11,6 +11,10 @@ namespace LearnStack.Tests.Integration; /// Request/composition proofs own no background dispatcher. Production startup /// proofs use an ordinary factory and must supply its confined credential. /// </summary> +/// <remarks> +/// The retained private outbox health check intentionally remains Unhealthy when +/// its worker is removed. The static <c>/healthz</c> liveness endpoint is independent. +/// </remarks> public class ConstructionOnlyApiFactory : WebApplicationFactory<Program> { protected sealed override void ConfigureWebHost(IWebHostBuilder builder) diff --git a/backend/tests/LearnStack.Tests.Integration/Database/CommitResponseDroppingProxy.cs b/backend/tests/LearnStack.Tests.Integration/Database/CommitResponseDroppingProxy.cs index 7b1356a9..21c4dde9 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/CommitResponseDroppingProxy.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/CommitResponseDroppingProxy.cs @@ -16,12 +16,14 @@ internal sealed class CommitResponseDroppingProxy : IAsyncDisposable private readonly List<Task> _sessions = []; private readonly string _targetHost; private readonly int _targetPort; + private readonly bool _rejectReconciliationConnections; private readonly Task _accepting; private int _droppedCommitResponses; private int _acceptedConnections; - public CommitResponseDroppingProxy(string connectionString) + public CommitResponseDroppingProxy(string connectionString, bool rejectReconciliationConnections = false) { + _rejectReconciliationConnections = rejectReconciliationConnections; var target = new NpgsqlConnectionStringBuilder(connectionString); _targetHost = target.Host ?? throw new ArgumentException("The test proxy requires a PostgreSQL host.", nameof(connectionString)); _targetPort = target.Port; @@ -68,6 +70,13 @@ private async Task AcceptAsync() { var client = await _listener.AcceptTcpClientAsync(_stopping.Token); Interlocked.Increment(ref _acceptedConnections); + if (_rejectReconciliationConnections && DroppedCommitResponses != 0) + { + // Refuse the fresh durability probe only after the real + // terminal transaction committed and its response was dropped. + client.Dispose(); + continue; + } _sessions.Add(ForwardSessionAsync(client)); } } diff --git a/backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatchMigrationTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatchMigrationTests.cs index 74445d5b..e6e68601 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatchMigrationTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatchMigrationTests.cs @@ -1,5 +1,8 @@ using System.Data.Common; +using System.Reflection; +using System.Text.Json; using FluentAssertions; +using LearnStack.Infrastructure.Messaging; using LearnStack.Infrastructure.Persistence; using LearnStack.Modules.Audit.Infrastructure.Persistence; using LearnStack.SharedKernel.Tenancy; @@ -16,8 +19,11 @@ namespace LearnStack.Tests.Integration.Database; public sealed class OutboxDispatchMigrationTests(SchemaFixture schema) { private const string PlatformPredecessor = "20260828085701_create_platform_infrastructure_tables"; + private const string DispatchPredecessor = "20261012180355_add_outbox_dispatch_state"; private const string AuditPredecessor = "20260914114341_audit_log_org_insert_scope"; private const string SanitizedLegacy = "migration:legacy:redacted"; + private static readonly string[] PendingPlanIdentities = ["pending.contract", "pending.alias"]; + private static readonly string[] BlockedPlanPartitions = ["blocked"]; [Fact] public async Task Legacy_facts_survive_up_down_reapply_and_diagnostics_are_redacted_across_tenants() @@ -35,7 +41,7 @@ public async Task Legacy_facts_survive_up_down_reapply_and_diagnostics_are_redac await AssertLegacyDiagnosticsAndRlsAsync(database); await AssertIndexAsync(database, "ix_outbox_messages_pending", ["available_after, occurred_at, id", "dead_lettered_at IS NULL", "terminal_reason IS NULL", "attempts < 6", "locked_by IS NULL"]); - await AssertIndexAsync(database, "ix_outbox_messages_recovery", ["locked_until, occurred_at, id", "locked_by IS NOT NULL"]); + await AssertRecoveryIndexAsync(database); await AssertIndexAsync(database, "ix_outbox_messages_terminal", ["terminal_reason IS NOT NULL"]); await context.GetService<IMigrator>().MigrateAsync(PlatformPredecessor); @@ -58,6 +64,35 @@ SELECT count(*) FROM pg_constraint await AssertLegacyDiagnosticsAndRlsAsync(database); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task Recovery_index_covers_expired_and_exhausted_selection_without_scanning_pending_backlog(bool expiredLease) + { + await using var database = await DisposableSchemaDatabase.CreateAsync(schema.Postgres, applyMigrations: false); + await using var context = Platform(database); + await context.GetService<IMigrator>().MigrateAsync(DispatchPredecessor); + var target = Guid.Parse("10000000-0000-0000-0000-000000000002"); + await SeedRecoveryPlanAsync(database, target, expiredLease); + await using var dispatcher = await PostgresFixture.OpenAsync(database.OutboxConnectionString); + + // The applied predecessor is the negative control: its leased-only + // predicate cannot cover the exhausted branch and requires a full scan. + using (var previous = await ExplainRecoveryAsync(dispatcher)) + { + var nodes = PlanNodes(previous.RootElement[0].GetProperty("Plan")).ToArray(); + IndexNames(nodes).Should().NotContain("ix_outbox_messages_recovery"); + nodes.Sum(node => PlanRows(node, "Rows Removed by Filter")).Should().BeGreaterThanOrEqualTo(50_000); + } + + await context.Database.MigrateAsync(); + await AssertRecoveryPlanAsync(database, dispatcher, target); + await context.GetService<IMigrator>().MigrateAsync(DispatchPredecessor); + await AssertIndexAsync(database, "ix_outbox_messages_recovery", ["locked_until, occurred_at, id", "locked_by IS NOT NULL"]); + await context.Database.MigrateAsync(); + await AssertRecoveryPlanAsync(database, dispatcher, target); + } + [Theory] [InlineData("id", "00000000-0000-0000-0000-000000000000")] [InlineData("tenant", "00000000-0000-0000-0000-000000000000")] @@ -215,6 +250,103 @@ private static async Task AssertIndexAsync(DisposableSchemaDatabase database, st } } + private static Task AssertRecoveryIndexAsync(DisposableSchemaDatabase database) => + AssertIndexAsync(database, "ix_outbox_messages_recovery", + ["available_after, occurred_at, id", "processed_at IS NULL", "dead_lettered_at IS NULL", + "terminal_reason IS NULL", "(locked_by IS NOT NULL) OR (attempts >= 6)"]); + + private static async Task SeedRecoveryPlanAsync(DisposableSchemaDatabase database, Guid target, bool expiredLease) + { + await using (var app = await PostgresFixture.OpenAsync(database.AppConnectionString)) + await using (var transaction = await app.BeginTransactionAsync()) + { + await SchemaQueries.AssertApplicationRoleAsync(app); + await SchemaQueries.SetTenantAsync(app, transaction, SchemaFixture.TenantA); + await SchemaQueries.ExecuteAsync(app, transaction, """ + INSERT INTO outbox_messages + (id, tenant_id, correlation_id, type, topic, partition_key, payload, available_after) + SELECT uuidv7(), @tenant, 'plan-proof', 'pending.contract', 'test.topic', 'allowed', '{}', '2000-01-01' + FROM generate_series(1, 50000); + INSERT INTO outbox_messages + (id, tenant_id, correlation_id, type, topic, partition_key, payload, + available_after, occurred_at, attempts, locked_by, locked_until) + SELECT candidate.id, @tenant, 'plan-proof', candidate.type, 'test.topic', candidate.partition_key, '{}', + candidate.available_after, candidate.occurred_at, CASE WHEN @expired THEN 1 ELSE 6 END, + CASE WHEN @expired THEN 'expired-token' ELSE NULL END, + CASE WHEN @expired THEN clock_timestamp() - interval '1 hour' ELSE NULL END + FROM (VALUES + (@target, 'active.contract', 'allowed', '2001-01-01'::timestamptz, '2001-01-01'::timestamptz), + ('10000000-0000-0000-0000-000000000003'::uuid, 'active.contract', 'allowed', '2001-01-01'::timestamptz, '2001-01-01'::timestamptz), + ('10000000-0000-0000-0000-000000000001'::uuid, 'active.contract', 'allowed', '2001-01-01'::timestamptz, '2002-01-01'::timestamptz), + (uuidv7(), 'active.contract', 'allowed', '2002-01-01'::timestamptz, '2000-01-01'::timestamptz), + (uuidv7(), 'pending.contract', 'allowed', '2000-01-01'::timestamptz, '2000-01-01'::timestamptz), + (uuidv7(), 'pending.alias', 'allowed', '2000-01-01'::timestamptz, '2000-01-01'::timestamptz), + (uuidv7(), 'active.contract', 'blocked', '2000-01-01'::timestamptz, '2000-01-01'::timestamptz) + ) AS candidate(id, type, partition_key, available_after, occurred_at); + INSERT INTO outbox_messages + (id, tenant_id, correlation_id, type, topic, partition_key, payload, + available_after, locked_by, locked_until) + VALUES (uuidv7(), @tenant, 'plan-proof', 'active.contract', 'test.topic', 'allowed', '{}', + '2000-01-01', 'live-token', clock_timestamp() + interval '1 hour'); + """, ("tenant", SchemaFixture.TenantA), ("target", target), ("expired", expiredLease)); + await transaction.CommitAsync(); + } + await using var owner = await PostgresFixture.OpenAsync(database.MigrationConnectionString); + await SchemaQueries.ExecuteAsync(owner, null, "ANALYZE outbox_messages"); + } + + private static NpgsqlCommand RecoverySelection(DbConnection dispatcher) + { + // Read the production predicates so an eligibility/admission regression + // cannot leave this proof explaining an independently copied WHERE clause. + static string Predicate(string name) => (string)typeof(OutboxDispatchStore) + .GetField(name, BindingFlags.Static | BindingFlags.NonPublic)!.GetRawConstantValue()!; + var command = new NpgsqlCommand($""" + SELECT id FROM outbox_messages WHERE {Predicate("Recovery")}{Predicate("Admitted")} + ORDER BY available_after, occurred_at, id FOR UPDATE SKIP LOCKED LIMIT 1 + """, (NpgsqlConnection)dispatcher); + command.Parameters.AddWithValue("pending", PendingPlanIdentities); + command.Parameters.AddWithValue("blocked", BlockedPlanPartitions); + return command; + } + + private static async Task<JsonDocument> ExplainRecoveryAsync(DbConnection dispatcher) + { + await using var command = RecoverySelection(dispatcher); + command.CommandText = "EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) " + command.CommandText; + return JsonDocument.Parse((string)(await command.ExecuteScalarAsync())!); + } + + private static async Task AssertRecoveryPlanAsync(DisposableSchemaDatabase database, DbConnection dispatcher, Guid target) + { + await AssertRecoveryIndexAsync(database); + using var plan = await ExplainRecoveryAsync(dispatcher); + var nodes = PlanNodes(plan.RootElement[0].GetProperty("Plan")).ToArray(); + IndexNames(nodes).Should().ContainSingle(name => name == "ix_outbox_messages_recovery"); + nodes.Should().NotContain(node => node.GetProperty("Node Type").GetString() == "Sort" + || node.GetProperty("Node Type").GetString() == "Seq Scan"); + nodes.Sum(node => PlanRows(node, "Rows Removed by Filter")).Should().BeLessThanOrEqualTo(4, + "only the two Pending identities, blocked partition and live lease precede the first eligible row"); + await using var selection = RecoverySelection(dispatcher); + (await selection.ExecuteScalarAsync()).Should().Be(target, "recovery keeps the global three-column order and admission exclusions"); + } + + private static IEnumerable<JsonElement> PlanNodes(JsonElement node) + { + yield return node; + if (!node.TryGetProperty("Plans", out var children)) yield break; + foreach (var child in children.EnumerateArray()) + foreach (var descendant in PlanNodes(child)) + yield return descendant; + } + + private static double PlanRows(JsonElement node, string property) => + node.TryGetProperty(property, out var value) ? value.GetDouble() : 0; + + private static IEnumerable<string?> IndexNames(IEnumerable<JsonElement> nodes) => + nodes.Where(node => node.TryGetProperty("Index Name", out _)) + .Select(node => node.GetProperty("Index Name").GetString()); + private static async Task AssertAuditInsertGrantAsync(DisposableSchemaDatabase database, bool expected) { await using var owner = await PostgresFixture.OpenAsync(database.MigrationConnectionString); diff --git a/backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatchStoreTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatchStoreTests.cs index 8b25d0c5..6b638350 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatchStoreTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatchStoreTests.cs @@ -391,6 +391,53 @@ public async Task A_lost_physical_COMMIT_response_reconciles_durable_terminal_st proxy.DroppedCommitResponses.Should().Be(1); } + [Fact] + public async Task A_lost_COMMIT_response_and_failed_reconciliation_preserve_both_failures_without_duplicate_audit() + { + await using var test = await TestDatabase.CreateAsync(schema.Postgres); + var id = await test.InsertAsync(organization: SchemaFixture.OrgA1); + var direct = test.Store(); + var normal = (await direct.TryClaimAsync(NoBlockedPartitions))!; + (await direct.RequestTerminalAsync(normal, OutboxTerminalReason.InvalidEnvelope)).Should().BeTrue(); + var terminal = (await direct.TryClaimTerminalAsync(NoBlockedPartitions))!; + await using var proxy = new CommitResponseDroppingProxy(test.OutboxConnectionString, + rejectReconciliationConnections: true); + await using var source = OutboxDispatcherDataSource.Build(proxy.ConnectionString); + var uncertain = test.Store(dispatchSource: source); + + var attempt = () => uncertain.TryTerminalizeAsync(terminal, AttemptTraceparent) + .WaitAsync(TimeSpan.FromSeconds(15)); + var exception = (await attempt.Should().ThrowAsync<AggregateException>()).Which; + + exception.Message.Should().StartWith("The outbox terminal commit outcome remains unknown."); + exception.InnerExceptions.Should().HaveCount(2); + var commitFailure = exception.InnerExceptions[0]; + var reconciliationFailure = exception.InnerExceptions[1]; + commitFailure.Should().BeOfType<NpgsqlException>(); + reconciliationFailure.Should().BeOfType<NpgsqlException>().And.NotBeSameAs(commitFailure); + commitFailure.StackTrace.Should().Contain("NpgsqlTransaction.Commit"); + reconciliationFailure.StackTrace.Should().Contain("NpgsqlDataSource.OpenConnectionAsync") + .And.Contain("IsDurablyTerminalAsync"); + proxy.DroppedCommitResponses.Should().Be(1); + proxy.AcceptedConnections.Should().BeGreaterThanOrEqualTo(2, + "the durability probe must open a fresh connection after the committed session breaks"); + // A separate healthy source establishes the physical outcome even though + // the caller cannot reconcile it through the failed connection path. + var row = await test.ReadAsync(id); + row.DeadLetteredAt.Should().NotBeNull(); + row.ProcessedAt.Should().BeNull(); + row.TerminalReason.Should().Be("invalid_envelope"); + row.Token.Should().BeNull(); + row.LockedUntil.Should().BeNull(); + (await test.AuditCountAsync()).Should().Be(1); + using var metadata = JsonDocument.Parse((await test.ReadAuditAsync()).Metadata); + metadata.RootElement.GetProperty("event_id").GetGuid().Should().Be(id); + (await direct.TryTerminalizeAsync(terminal, AttemptTraceparent)).Should().BeFalse(); + (await direct.TryClaimTerminalAsync(NoBlockedPartitions)).Should().BeNull(); + (await test.ReadAsync(id)).Should().Be(row); + (await test.AuditCountAsync()).Should().Be(1); + } + [Theory] [InlineData(false)] [InlineData(true)] diff --git a/backend/tests/LearnStack.Tests.Integration/Database/OutboxProcessorTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/OutboxProcessorTests.cs index ce58a5ee..9425c98c 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/OutboxProcessorTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/OutboxProcessorTests.cs @@ -69,18 +69,13 @@ public async Task Two_independent_actual_processors_cannot_publish_or_claim_one_ await using var test = await WorkerDatabase.CreateAsync(schema.Postgres); var liveBounds = Bounds with { - PublishTimeout = TimeSpan.FromSeconds(2), - LeaseDuration = TimeSpan.FromSeconds(6), - ProgressTimeout = TimeSpan.FromSeconds(8), + PublishTimeout = TimeSpan.FromSeconds(10), + LeaseDuration = TimeSpan.FromSeconds(30), + ProgressTimeout = TimeSpan.FromSeconds(45), }; var partition = Guid.CreateVersion7(); var held = test.Callbacks.Hold(partition); - var id = await test.InsertAsync(partition: partition); test.Start(liveBounds); - await held.Entered.Task.WaitAsync(ProofDeadline); - var acquired = await test.ReadAsync(id); - acquired.Attempts.Should().Be(1); - acquired.Token.Should().NotBeNullOrWhiteSpace(); await using var contender = new WorkerApiFactory(test, test.OutboxConnectionString, liveBounds); OutboxProcessor? secondWorker = null; try @@ -90,20 +85,40 @@ public async Task Two_independent_actual_processors_cannot_publish_or_claim_one_ .Should().ContainSingle().Which; secondWorker.Should().NotBeSameAs(test.Worker); var secondObservation = secondServices.GetRequiredService<OutboxObservation>(); + await UntilAsync(() => test.Observation.IsHealthy && secondObservation.IsHealthy); + + // Host construction and the first empty polls finish before the live + // lease begins. The whole contention proof shares a shorter deadline. + using var proof = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var id = await test.InsertAsync(partition: partition).WaitAsync(proof.Token); + await held.Entered.Task.WaitAsync(proof.Token); + var acquired = await test.ReadAsync(id).WaitAsync(proof.Token); + acquired.Attempts.Should().Be(1); + acquired.Token.Should().NotBeNullOrWhiteSpace(); + var firstPrevious = test.Observation.Snapshot.LastSnapshotTimestamp; + var secondPrevious = secondObservation.Snapshot.LastSnapshotTimestamp; + await UntilAsync(() => test.Observation.Snapshot.LastSnapshotTimestamp > firstPrevious + || secondObservation.Snapshot.LastSnapshotTimestamp > secondPrevious, proof.Token); + // Either warm processor may win; the other must continue real polls + // while the winning processor is awaiting its callback. + var observer = test.Observation.Snapshot.LastSnapshotTimestamp > firstPrevious + ? test.Observation : secondObservation; for (var poll = 0; poll < 2; poll++) { - var previous = secondObservation.Snapshot.LastSnapshotTimestamp ?? long.MinValue; - await UntilAsync(() => secondObservation.Snapshot.LastSnapshotTimestamp > previous); + var previous = observer.Snapshot.LastSnapshotTimestamp; + await UntilAsync(() => observer.Snapshot.LastSnapshotTimestamp > previous, proof.Token); } - (await test.LeaseIsLiveAsync(id)).Should().BeTrue("the contention assertion must run before expiry or a timeout"); - (await test.ReadAsync(id)).Should().Be(acquired); + (await test.LeaseIsLiveAsync(id).WaitAsync(proof.Token)).Should().BeTrue("the contention assertion must run before expiry or a timeout"); + (await test.ReadAsync(id).WaitAsync(proof.Token)).Should().Be(acquired); test.Callbacks.Constructions.Should().Be(1); - secondObservation.Snapshot.Queue.PendingCount.Should().Be(1); - secondObservation.Snapshot.Queue.OldestEligibleAgeSeconds.Should().Be(0); + observer.Snapshot.Queue.PendingCount.Should().Be(1); + observer.Snapshot.Queue.OldestEligibleAgeSeconds.Should().Be(0); held.ReleaseCallback.TrySetResult(); - await UntilAsync(async () => (await test.ReadAsync(id)).ProcessedAt is not null); - (await test.ReadAsync(id)).Attempts.Should().Be(1); + await UntilAsync(async () => (await test.ReadAsync(id)).ProcessedAt is not null, proof.Token); + var completed = await test.ReadAsync(id).WaitAsync(proof.Token); + completed.Attempts.Should().Be(1); + completed.Token.Should().BeNull(); test.Callbacks.Constructions.Should().Be(1); } finally @@ -324,6 +339,7 @@ public async Task Four_retained_callbacks_stop_all_acquisition_and_releasing_one public async Task Shutdown_uses_one_finite_wait_for_all_four_retained_callbacks() { await using var test = await WorkerDatabase.CreateAsync(schema.Postgres); + var time = new ShutdownTimeProvider(Bounds.ShutdownTimeout); var held = new List<CallbackBarrier>(); for (var index = 0; index < 4; index++) { @@ -331,18 +347,25 @@ public async Task Shutdown_uses_one_finite_wait_for_all_four_retained_callbacks( held.Add(test.Callbacks.Hold(partition)); await test.InsertAsync(partition: partition); } - test.Start(); + test.Start(time: time); await Task.WhenAll(held.Select(item => item.Entered.Task)).WaitAsync(ProofDeadline); await UntilAsync(() => test.Observation.Snapshot.OutstandingPublishes == 4); - var stop = Stopwatch.StartNew(); - - await test.Worker.StopAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(2)); - - stop.Elapsed.Should().BeLessThan(Bounds.ShutdownTimeout * 3, - "all live callbacks share the one shutdown budget rather than receiving successive waits"); + time.ControlShutdownTimers(); + var stop = test.Worker.StopAsync(CancellationToken.None); + await time.BudgetRegistered.Task.WaitAsync(ProofDeadline); + await test.Worker.ExecuteTask!.WaitAsync(ProofDeadline); + + // The worker has exited, but all callback scopes remain live. Expire + // exactly the first deadline; a fresh drain budget must not get time. + stop.IsCompleted.Should().BeFalse(); + time.Advance(Bounds.ShutdownTimeout); + await stop.WaitAsync(ProofDeadline); + + test.Observation.Snapshot.OutstandingPublishes.Should().Be(4); foreach (var callback in held) { callback.Disposals.Should().Be(0); + callback.CancellationToken.IsCancellationRequested.Should().BeTrue(); _ = callback.CancellationToken.WaitHandle; } } @@ -382,10 +405,13 @@ public async Task A_failed_terminal_audit_reports_unhealthy_while_the_actual_hea test.Start(); await UntilAsync(() => test.Observation.IsHealthy); await test.AuditInsertGrantAsync(grant: false); - var id = await test.InsertAsync(type: "unknown-audit-probe"); + var id = await test.InsertAsync(type: SensitiveCanary, correlation: SensitiveCanary); await UntilAsync(() => test.Observation.Snapshot.TerminalAuditFailed); + await UntilAsync(() => test.Logs.Text.Contains("Outbox dispatch iteration failed with outcome iteration_failed.", + StringComparison.Ordinal)); test.Observation.IsHealthy.Should().BeFalse(); + test.Logs.Text.Should().NotContain(SensitiveCanary); var row = await test.ReadAsync(id); row.TerminalReason.Should().Be("unknown_type"); row.DeadLetteredAt.Should().BeNull(); @@ -405,11 +431,13 @@ private static bool TokenSourceDisposed(CancellationToken token) catch (ObjectDisposedException) { return true; } } - private static Task UntilAsync(Func<bool> condition) => UntilAsync(() => Task.FromResult(condition())); + private static Task UntilAsync(Func<bool> condition, CancellationToken cancellationToken = default) + => UntilAsync(() => Task.FromResult(condition()), cancellationToken); - private static async Task UntilAsync(Func<Task<bool>> condition) + private static async Task UntilAsync(Func<Task<bool>> condition, CancellationToken cancellationToken = default) { - using var deadline = new CancellationTokenSource(ProofDeadline); + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(ProofDeadline); while (!await condition().WaitAsync(deadline.Token)) await Task.Delay(TimeSpan.FromMilliseconds(10), deadline.Token); } @@ -530,7 +558,79 @@ private sealed record Row(int Attempts, string? Token, DateTimeOffset? Processed DateTimeOffset AvailableAfter, string Type, string Payload); private sealed record TerminalAudit(string OperationType, string OperationClass, string Correlation, string Metadata); - private sealed class WorkerApiFactory(WorkerDatabase database, string? credential, OutboxDispatchOptions? bounds = null) : WebApplicationFactory<Program> + /// <summary>Controls shutdown deadlines while all other host timers keep running normally.</summary> + private sealed class ShutdownTimeProvider(TimeSpan shutdownTimeout) : TimeProvider + { + private readonly object _gate = new(); + private readonly List<ShutdownTimer> _timers = []; + private bool _controlled; + private long? _origin; + private TimeSpan _elapsed; + private TimeSpan _lastRegistration; + public TaskCompletionSource BudgetRegistered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public void ControlShutdownTimers() { lock (_gate) _controlled = true; } + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + lock (_gate) + { + if (!_controlled || dueTime != shutdownTimeout) + return System.CreateTimer(callback, state, dueTime, period); + period.Should().Be(Timeout.InfiniteTimeSpan); + _origin ??= Stopwatch.GetTimestamp(); + var registration = _timers.Count == 0 ? TimeSpan.Zero : Stopwatch.GetElapsedTime(_origin.Value); + // Preserve the real ordering even below TimeSpan's resolution: + // a later budget cannot expire at the first budget's deadline. + if (_timers.Count > 0 && registration <= _lastRegistration) + registration = _lastRegistration + TimeSpan.FromTicks(1); + _lastRegistration = registration; + var timer = new ShutdownTimer(this, callback, state, + (_elapsed > registration ? _elapsed : registration) + dueTime); + _timers.Add(timer); + BudgetRegistered.TrySetResult(); + return timer; + } + } + + public void Advance(TimeSpan duration) + { + ShutdownTimer[] due; + lock (_gate) + { + _elapsed += duration; + due = _timers.Where(timer => timer.Due is { } at && at <= _elapsed).ToArray(); + foreach (var timer in due) timer.Due = null; + } + foreach (var timer in due) timer.Fire(); + } + + private sealed class ShutdownTimer(ShutdownTimeProvider owner, TimerCallback callback, + object? state, TimeSpan due) : ITimer + { + private bool _disposed; + public TimeSpan? Due { get; set; } = due; + public void Fire() => callback(state); + public bool Change(TimeSpan dueTime, TimeSpan period) + { + lock (owner._gate) + { + if (_disposed) return false; + period.Should().Be(Timeout.InfiniteTimeSpan); + Due = dueTime == Timeout.InfiniteTimeSpan ? null : owner._elapsed + dueTime; + return true; + } + } + public void Dispose() + { + lock (owner._gate) { _disposed = true; Due = null; } + } + public ValueTask DisposeAsync() { Dispose(); return ValueTask.CompletedTask; } + } + } + + private sealed class WorkerApiFactory(WorkerDatabase database, string? credential, + OutboxDispatchOptions? bounds = null, TimeProvider? time = null) : WebApplicationFactory<Program> { protected override void ConfigureWebHost(IWebHostBuilder builder) { @@ -542,6 +642,11 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) { services.RemoveAll<OutboxDispatchOptions>(); services.AddSingleton(bounds ?? Bounds); + if (time is not null) + { + services.RemoveAll<TimeProvider>(); + services.AddSingleton(time); + } services.RemoveAll<IntegrationEventHandlerRegistry>(); var handlers = IntegrationEventHandlerRegistry.Discover(typeof(WorkerHandler).Assembly); handlers.All.Should().ContainSingle().Which.HandlerType.Should().Be<WorkerHandler>(); @@ -599,9 +704,9 @@ public static async Task<WorkerDatabase> CreateAsync(PostgresFixture postgres, b catch { await test.DisposeAsync(); throw; } } - public void Start(OutboxDispatchOptions? bounds = null) + public void Start(OutboxDispatchOptions? bounds = null, TimeProvider? time = null) { - _factory = new WorkerApiFactory(this, OutboxConnectionString, bounds); + _factory = new WorkerApiFactory(this, OutboxConnectionString, bounds, time); _client = _factory.CreateClient(new WebApplicationFactoryClientOptions { BaseAddress = new Uri("http://localhost") }); Services.GetRequiredService<IEventBus>().Should().BeOfType<InProcessEventBus>(); } diff --git a/docs/architecture/15-event-and-outbox.md b/docs/architecture/15-event-and-outbox.md index e5ef0f3e..d2d09a61 100644 --- a/docs/architecture/15-event-and-outbox.md +++ b/docs/architecture/15-event-and-outbox.md @@ -342,6 +342,8 @@ is the rule that enforces it, in force since Phase 02a Packet 10. ### The claim problem +The former batch sketch exposed the released-lock defect described here; the +single-row leased protocol below supersedes it under ADR-0046. `SELECT ... FOR UPDATE SKIP LOCKED` holds its row locks **only until the transaction that took them ends**. A processor that selects a batch, commits to release the locks, and *then* starts publishing has released every row it is about to work on. A second @@ -350,10 +352,9 @@ not blocked by any lock, and dispatches the same events. The first processor the them processed, and nothing in the system records that they went out twice. That is why this document no longer claims "no double dispatch". The delivery contract is -**at-least-once**, and consumer-side idempotency through `IInboxGuard` is what makes it -safe — not the row lock. What the claim protocol buys is that duplicates come from crash -recovery and lease expiry, which are rare and bounded, rather than from every concurrent -poll, which is neither. +**at-least-once**. P02b-3's transport-owned shared inbox supplies consumer-side +idempotency; handlers do not call `IInboxGuard`. The claim protocol prevents +concurrent live owners; crash recovery and lease expiry can still redeliver. ### The claim protocol diff --git a/docs/architecture/29-dapr-integration.md b/docs/architecture/29-dapr-integration.md index 31a45a39..0857b48c 100644 --- a/docs/architecture/29-dapr-integration.md +++ b/docs/architecture/29-dapr-integration.md @@ -2,7 +2,8 @@ **Derives from:** [ADR-0038](../decisions/0038-cross-cutting-port-and-event-contracts.md), [ADR-0006](../decisions/0006-events-and-outbox.md), -[ADR-0010](../decisions/0010-cross-module-communication.md). +[ADR-0010](../decisions/0010-cross-module-communication.md), +[ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md). > **Read this first.** This document describes Dapr in the present tense as the **target > design**. Per [ADR-0035](../decisions/0035-demand-gated-infrastructure.md) no Dapr @@ -210,12 +211,13 @@ developer starts the gated sidecar for inspection. It is a **transport, not a st construction-free subscription metadata, gives each subscription one consumer activity and one async DI scope, restores tenant and module context before resolving exactly one concrete `IIntegrationEventHandler<T>`, and restores the publisher's context after the -full dispatch. It leaves `IInboxGuard` deduplication to the handler exactly as the -durable path does and preserves per-partition-key ordering. A default path that skipped -those is a path where the isolation code is never exercised — see -[15-event-and-outbox.md](15-event-and-outbox.md), which owns the implementation, and -[ADR-0035](../decisions/0035-demand-gated-infrastructure.md), which makes the four -obligations a condition of the gating. +full dispatch. It serializes invocation per partition key within its process; +it opens no business transaction and implements no durable inbox or subscriber ledger. +[ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md) assigns shared +`IInboxGuard` claim/completion to the transport before handler construction, with +implementation in P02b-3. Handlers do not call the guard. Phase 11's Dapr adapter +preserves that accepted contract. See [15-event-and-outbox.md](15-event-and-outbox.md) +for the shipped baseline and the P02b-3 durable acceptance/execution target. ## 4. Cache implementation (DaprCacheService) diff --git a/docs/glossary.md b/docs/glossary.md index 5547fb06..13edb8de 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -346,9 +346,9 @@ These entries distinguish P02d-2 contracts from P02d-3 internal read contracts: | **`IInboxGuard`** | The Accepted shared, transport-owned inbox claim/completion seam, pending P02b-3. Deduplication is `(tenant_id, subscription_id, event_id)`, claimed before handler construction on the announced app-role transaction. Business state, completed inbox, produced outbox, MUST audit and fenced ledger completion commit together; rollback removes a new claim. Handlers do not call the guard, and targeted replay never resets a completed inbox. See [ADR-0046](decisions/0046-durable-event-delivery-and-recovery.md#subscriber-acceptance-and-inbox-ownership). | | **Inbox** | Shared tenant-owned completion record for one stable subscription/event pair. A new claim is uncommitted business-transaction work; only successful completion persists it. The delivery ledger owns failed/terminal/replay state. Canonical DDL remains P02b-3 in [Database Standards](standards/05-database.md#inbox). | | **Integration-event subscription** | One explicitly identified consumer reaction, independent of handler class or assembly. Its stable `{module}.{subscription}.v{N}` ID versions deduplication semantics, not event schema or built-in revision. Renaming needs a migration/replay decision; a handler refactor preserves identity. [ADR-0046](decisions/0046-durable-event-delivery-and-recovery.md#subscriber-acceptance-and-inbox-ownership) owns the exact grammar. | -| **Subscription readiness** | Construction-free Pending, Active or IntentionallyUnsubscribed declaration. Any required Pending subscription holds its contract/aliases before acquisition and validation. Active requires a real registered handler; intentional absence is explicit, never inferred from zero discovered handlers. Accepted P02b-2 policy under [ADR-0046](decisions/0046-durable-event-delivery-and-recovery.md). | +| **Subscription readiness** | Construction-free Pending, Active or IntentionallyUnsubscribed declaration in `OutboxEventRegistry`, the closed resolver of declared concrete contracts, compatible aliases and retired identities. Any required Pending subscription holds its contract/aliases before acquisition and validation. Active requires a real registered handler; intentional absence is explicit, never inferred from zero discovered handlers. Accepted P02b-2 policy under [ADR-0046](decisions/0046-durable-event-delivery-and-recovery.md). | | **Outbox lease** | Paired `locked_by` / `locked_until` fields; the former holds a fresh canonical UUID acquisition token, the latter a database-time expiry. Every transition checks both current token and unexpired lease. Recovery replaces expired ownership before another transition. Stable worker identity confers no ownership. | -| **Dead letter** | Explicit completed terminal state after invalid data or exhausted attempts, distinct from a terminal request or delayed availability. Producer terminal state retains its envelope and commits with MUST audit; subscriber terminal state belongs to its independent delivery ledger and never completes its inbox. Operator reset/replay is separately owned by Phase 03. | +| **Dead letter** | Explicit completed terminal state after invalid data or exhausted attempts, distinct from a terminal request or delayed availability. On the producer row, `terminal_reason` requests terminalization and excludes normal publication; only `dead_lettered_at` records completed terminal state. Producer terminal state retains its envelope and commits with MUST audit; subscriber terminal state belongs to its independent delivery ledger and never completes its inbox. Operator reset/replay is separately owned by Phase 03. See [ADR-0046](decisions/0046-durable-event-delivery-and-recovery.md#producer-dispatch-and-recovery). | | **`EventTerminalTransition`** | Restricted immutable witness minted only from the successful fenced terminal UPDATE result in its live transaction. It binds actual row identity, acquisition and terminal values to `WriteEventTerminalAsync`; substituting draft tenant/org/event or transaction requires rollback of state and audit. Fifth seam implemented in P02b-2 Step 2; both independent review rounds complete. | | **`DeploymentMode`** | Enum (`Development \| SaaS \| Dedicated \| SelfHostedOnline \| SelfHostedAirGapped`) read at the composition root to select provider implementations. Modules never read this enum. | diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 514c2ae8..66fc2ef7 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -1860,6 +1860,61 @@ relative links and 490 fragments with zero errors. Final correction-head CI remains pending in this local receipt. The [PR handoff](#p02b-2-pr-handoff-2026-10-12) continues to own live PR state. +### P02b-2 external review remediation (2026-10-13) + +Both external reports against `44089707` were treated as review evidence and +checked against current code and Accepted ADR-0046. No new decision, consumer +delivery or grant change is part of this correction. Existing migrations and +Accepted ADR bodies remain unchanged. + +- Recovery now states its index predicate explicitly, without relying on + PostgreSQL to infer the lease CHECK. A new Platform migration replaces only + the recovery index: both expired leases and unleased exhausted rows retain + `(available_after, occurred_at, id)` order. Real migrated-database plans with + 50,000 Pending rows distinguish the predecessor's scan from indexed recovery; + admission exclusions, ordering and down/reapply are asserted. +- A failed durability probe after uncertain terminal COMMIT preserves both + exceptions, in COMMIT/probe order. The real response-dropping proxy exercises + that failure and independently verifies one durable terminal/audit pair and + no duplicate on retry. The unused persisted shutdown outcome was removed; + shutdown continues to leave ownership for expiry recovery. +- The launcher exposes only the exact fixed local database diagnostic, with + secret-bearing prefix/unknown-message controls. Absent configuration uses the + same diagnostic. Root runtime-script formatting is restored. The audit marker + count, transport-owned inbox wording, historical batch discussion, catalogue + proof kind, glossary and later-phase navigation now match implementation. +- The generic iteration diagnostic no longer mislabels every exception as a + storage failure. Normal dispatch avoids its unused registry lookup. Contention + warms both independent hosts before its live-lease deadline. Shutdown advances + exactly one controlled deadline with four retained callbacks; an isolated + second-budget mutant fails, and the rebuilt clean control passes. + +The remaining report suggestions were checked rather than adopted as new policy: + +| Concern | Disposition | +|---|---| +| Final `44089707` CI remains outstanding | Already resolved: all seven required jobs pass in [run 38309098697](https://github.com/HodeTech/LearnStack/actions/runs/38309098697). The earlier local receipt is dated evidence, not live state; this correction needs its own head verification | +| Failure metric precedes a successful fence; gauges may straddle a refresh | Metrics observe attempts, not durable transitions. Every gauge reads an immutable snapshot; no scrape-wide atomicity is promised. The phase criterion below now names that exact invariant | +| Disabled test worker leaves outbox health unhealthy | Intentional and now documented in factory remarks. The private check reports the absent worker; static `/healthz` remains liveness-only | +| Invalid options are reported as credential failures | Not on the shipped DI path: the store validates the shared options before worker construction | +| Alternate worker registration, duplicate short type labels, permissive terminal-request kind | No current production violation: composition census pins the sole worker; the registry has one production contract; the worker requests terminal state only from Normal claims. Recovery is deliberately used by a store fixture | +| Witness acquisition is not compared with audit metadata | SQL fences the actual token and returns it into the transaction-bound witness. The audit draft has no acquisition field; adding one is not required by ADR-0046 | +| Invalid causal metadata changes the original correlation | Matches ADR-0046: invalid correlation/causation/actor uses a validated attempt trace and omits invalid causal values | +| Role guard should reject other principals inheriting dispatcher rights | The guard validates the dispatcher's effective role and escalating memberships. Reverse grant auditing is outside that connection contract; changing it would not sandbox compromised privileged SQL | +| Ambient local credentials, migration-child environment, quoted passwords | Ambient overrides are explicit existing loader policy and tested. The administrative migration child is distinct from the confined web/Seeder children. Single/double-quote parsing is the declared closed local grammar, with existing controls; no provider mismatch was demonstrated | +| Audit seam needs duplicate tests in another file; sub-microsecond and census optimization suggestions | The real transition tests already exercise the fifth seam and rollback substitutions. Quantization has explicit envelope controls. Duplicate tests or census caching do not repair a demonstrated contract violation | +| ADR navigation date, test placement and optional trace descriptor | Historical ADR navigation is preserved; local-loader tests sit with that loader. Standard 09 and ADR-0046 allow optional trace data without requiring its persistence | + +Local validation passes 1,845 unit, 286 architecture, four contract and 175 +Docker-free integration cases, plus 97 focused Docker cases (54 store, 27 +migration, 16 worker), with zero failures/skips. Guarded frontend passes 1,616 +cases (1,556 web, 60 SDK), with zero failures/skips/todos; lint/typecheck pass. +Release build reports zero warnings/errors; format, seven ADR-status script +controls, 2,587 relative links and 491 fragments pass. The new regression cases +fail their predecessor/mutant controls. Both fresh independent correction review +rounds and complete correction-head CI remain pending in this local receipt; +the PR description owns live head/check evidence. + ### Durable outbox dispatch P02b-1 Steps 2/3 implement `IOutbox`, replace Packet 3's step-7 shell with @@ -2801,8 +2856,9 @@ consumer execution; the whole phase is not complete. The the regenerated SDK surface. - Every event metric uses the literal name and bounded dimensions in [Observability Standards](../standards/10-observability.md#required-metrics). - Gauge snapshots agree atomically; Pending, deferred, terminal and normal - eligibility are tested as distinct states. Job dimensions remain G9/G17 work. + Refresh publishes one complete immutable queue snapshot; each gauge reads an + immutable snapshot. Pending, deferred, terminal and normal eligibility are + tested as distinct states. Job dimensions remain G9/G17 work. - **No row in the [catalogue](../standards/21-architecture-tests-catalogue.md) whose Phase names 02b is still Registered or Awaiting backfill.** Structural rows are green in the `backend` check and behavioural rows in the `backend integration` check, which diff --git a/docs/roadmap/phase-11-production-hardening.md b/docs/roadmap/phase-11-production-hardening.md index 96a290d7..68037b31 100644 --- a/docs/roadmap/phase-11-production-hardening.md +++ b/docs/roadmap/phase-11-production-hardening.md @@ -322,10 +322,11 @@ bounds request *cost* once a request is inside. Neither substitutes for the othe audit row — ships in [Phase 02b](phase-02b-events-auth.md). - Outbox dispatcher reliability under multi-pod load. The claim protocol is already specified — [Events and Outbox § The claim protocol](../architecture/15-event-and-outbox.md#the-claim-protocol) - writes the lease, [ADR-0006 Amendment 2](../decisions/0006-events-and-outbox.md) - assigns its columns and grant to Phase 02b's migration, and - [§ The simpler alternative, and its cost](../architecture/15-event-and-outbox.md#the-simpler-alternative-and-its-cost) - demotes the batch-held transaction to a fallback. What this phase adds is the + describes [ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md)'s + single-row acquisition tokens and fenced transitions. P02b-2 implements that + producer protocol; P02b-3 owns durable subscriber execution. The + [simpler-alternative section](../architecture/15-event-and-outbox.md#the-simpler-alternative-and-its-cost) + records the supersession of the batch-held transaction fallback. This phase adds the multi-instance evidence the single-instance phase cannot produce: two real dispatcher processes across pods, lease-duration tuning against measured tail latency, and the lost-lease alert. @@ -559,7 +560,7 @@ same `ILiveClassProvider`. and by the glossary, and a cross-process transport does not change it. The retry and dead-letter contract being exercised here is [Phase 02b](phase-02b-events-auth.md)'s, unchanged. -- Two dispatcher processes in separate pods drain one pending batch with no duplicate +- Two dispatcher processes in separate pods drain the same queue with no duplicate business effect; a lease that expires mid-dispatch is reclaimed, the stale processor's writes are no-ops under the ownership fence, and `learnstack_outbox_lease_lost_total` both increments and pages. The lease duration is set against measured tail latency diff --git a/docs/standards/05-database.md b/docs/standards/05-database.md index 8176d8d6..d3d47519 100644 --- a/docs/standards/05-database.md +++ b/docs/standards/05-database.md @@ -1464,9 +1464,9 @@ CREATE INDEX ix_outbox_messages_tenant_pending ON outbox_messages (tenant_id, available_after) WHERE processed_at IS NULL AND dead_lettered_at IS NULL; CREATE INDEX ix_outbox_messages_recovery - ON outbox_messages (locked_until, occurred_at, id) + ON outbox_messages (available_after, occurred_at, id) WHERE processed_at IS NULL AND dead_lettered_at IS NULL - AND terminal_reason IS NULL AND locked_by IS NOT NULL; + AND terminal_reason IS NULL AND (locked_by IS NOT NULL OR attempts >= 6); CREATE INDEX ix_outbox_messages_terminal ON outbox_messages (available_after, occurred_at, id) WHERE processed_at IS NULL AND dead_lettered_at IS NULL AND terminal_reason IS NOT NULL; diff --git a/docs/standards/18-audit-coverage.md b/docs/standards/18-audit-coverage.md index b0dc9465..10fdceb0 100644 --- a/docs/standards/18-audit-coverage.md +++ b/docs/standards/18-audit-coverage.md @@ -160,13 +160,13 @@ will raise them — which this standard requires it to do. and Audit's `audit.redaction.apply` and `audit.purge.apply`. They sit outside the request-type join in **both** directions and register by slug rather than by type — the four whose writer ships through `DeclareOffPath` today, the other five when their - writer lands. Producer terminal registration belongs to P02b-2; subscriber - terminal registration belongs to P02b-3. Both are planned until their writers ship. + writer lands. P02b-2 Step 2 registers the shipped producer terminal writer; + subscriber terminal registration remains planned for P02b-3. - **One operation, one row.** A slug is classified in exactly one row across every matrix. A copy is a second answer that passes every comparison the moment it agrees, so the matrix → catalogue direction refuses it, and the catalogue → matrix direction compares every row that carries a slug rather than the first. -- **A row can carry both markers**, and six do. `tenancy.killswitch.toggle` is +- **A row can carry both markers**, and five do. `tenancy.killswitch.toggle` is `(off-path)` because every write runs inside `EnterPlatformAdminScope(reason)` rather than through the pipeline, and `(planned)` because Packet 9 ships the table, the policies and the read path but no writer — diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index beed7b08..1e35882d 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -3446,9 +3446,8 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). publish/mark path; real subscriptions stay Pending until P02b-3. - **Source:** [ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md); [P02b-2 proof obligations](../roadmap/phase-02b-events-auth.md#decisive-proof-obligations). -- **Type:** production architecture census with planted controls where structural; - real PostgreSQL/runtime integration proofs where behavioural. - **Kind:** structural + behavioural. +- **Type:** real PostgreSQL/runtime integration proofs and startup composition checks. + **Kind:** startup + behavioural. - **Status:** **Implemented** (`OutboxProcessorTests`, `OutboxDispatchStoreTests`, `OutboxProductionCompositionTests`): real provisioning rows remain unchanged across hosted Pending polls; aliases/malformed payloads are held before every diff --git a/frontend/apps/web/src/test/demo-lifecycle.test.ts b/frontend/apps/web/src/test/demo-lifecycle.test.ts index 651e2df2..8fd79b64 100644 --- a/frontend/apps/web/src/test/demo-lifecycle.test.ts +++ b/frontend/apps/web/src/test/demo-lifecycle.test.ts @@ -22,6 +22,7 @@ import { } from '../../../../../scripts/demo-observation.mjs'; import { DemoProcesses } from '../../../../../scripts/demo-process.mjs'; import { + demoFailureMessage, disposeDemoResources, isApiListenerEvent, validateCertificate, @@ -67,12 +68,46 @@ describe('launcher-owned local database validation', () => { } return values; } - it.each(['simple', 'semi;colon', ' leading and trailing ', 'a"double"quote', "a'single'quote"])( - 'accepts all four local roles with quoted password %s', - (password) => { - expect(() => validateDemoDatabaseConfiguration(databaseValues(password))).not.toThrow(); + it.each([null, undefined])( + 'refuses absent configuration (%s) with the fixed diagnostic', + (values) => { + expect(() => validateDemoDatabaseConfiguration(values)).toThrowError( + /^Local demo database configuration is invalid$/, + ); }, ); + it('reports the real database validation failure through the launcher diagnostic boundary', () => { + const values = databaseValues(); + delete values.LEARNSTACK_OUTBOX_PW; + let failure: unknown; + try { + validateDemoDatabaseConfiguration(values); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + expect(demoFailureMessage(failure)).toBe('Local demo database configuration is invalid'); + const secret = randomBytes(16).toString('hex'); + for (const message of [ + `Provider failure: ${secret}`, + `Local demo database configuration is invalid: ${secret}`, + `Local demo database configuration is invalid\n${secret}`, + ]) { + expect(demoFailureMessage(new Error(message))).toBe( + 'demo: startup/proof failed; inspect prerequisites, TLS, configuration and selected stack', + ); + } + }); + it.each([ + 'simple', + 'semi;colon', + ' leading and trailing ', + ' ', + 'a"double"quote', + "a'single'quote", + ])('accepts all four local roles with quoted password %s', (password) => { + expect(() => validateDemoDatabaseConfiguration(databaseValues(password))).not.toThrow(); + }); it('accepts supported key aliases, single quoting, loopback and the default port', () => { const values = databaseValues("semi;single'quote"); for (const [suffix, role] of roles) diff --git a/scripts/demo-database.d.mts b/scripts/demo-database.d.mts index e8b62bbf..f30ac117 100644 --- a/scripts/demo-database.d.mts +++ b/scripts/demo-database.d.mts @@ -1,4 +1,4 @@ -export function validateDemoDatabaseConfiguration(values: Record<string, string>): void; +export function validateDemoDatabaseConfiguration(values: Record<string, string> | null | undefined): void; export function publicSeederEnvironment( applicationConnectionString?: string, base?: Partial<NodeJS.ProcessEnv>, diff --git a/scripts/demo-database.mjs b/scripts/demo-database.mjs index 26314c02..49bca807 100644 --- a/scripts/demo-database.mjs +++ b/scripts/demo-database.mjs @@ -69,7 +69,13 @@ function localConnectionFields(source) { /** Reject stale local credentials in the launcher without giving them to a child process. */ export function validateDemoDatabaseConfiguration(values) { - if (typeof values.POSTGRES_DB !== 'string' || !values.POSTGRES_DB) throw invalidConfiguration(); + if ( + values === null || + values === undefined || + typeof values.POSTGRES_DB !== 'string' || + !values.POSTGRES_DB + ) + throw invalidConfiguration(); for (const [suffix, role, passwordKey] of [ ['Migration', 'learnstack_migration', 'LEARNSTACK_MIGRATION_PW'], ['Default', 'learnstack_app', 'LEARNSTACK_APP_PW'], diff --git a/scripts/demo.d.mts b/scripts/demo.d.mts index 1ffc8e2b..5392fc16 100644 --- a/scripts/demo.d.mts +++ b/scripts/demo.d.mts @@ -1,4 +1,5 @@ export function isApiListenerEvent(line: string, origin: string): boolean; +export function demoFailureMessage(error: unknown): string; export function validateCertificate( certificate: Buffer, privateKey: Buffer, diff --git a/scripts/demo.mjs b/scripts/demo.mjs index b6941634..a6bb7abb 100644 --- a/scripts/demo.mjs +++ b/scripts/demo.mjs @@ -72,6 +72,18 @@ export async function disposeDemoResources({ processes, finish, teardown, remove await removePrivate(); } +/** Only fixed local diagnostics may leave the launcher boundary. */ +export function demoFailureMessage(error) { + return error instanceof DemoCommandError || + (error instanceof Error && + (error.message === 'Local demo database configuration is invalid' || + /^(?:Use make demo|Port \d+ is occupied|Another demo owns|TLS leaf|Seed hosts|Environment disagrees|CI requires)/.test( + error.message, + ))) + ? error.message + : 'demo: startup/proof failed; inspect prerequisites, TLS, configuration and selected stack'; +} + /** Start the same seeded, production-built topology for foreground use and CI proof. */ export async function startDemo(mode) { if (!['local', 'ci'].includes(mode)) throw new Error('Use make demo DEMO_MODE=local or ci'); @@ -532,15 +544,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur } catch (error) { if (runtime?.mode !== 'local' || !runtime.signalRequested()) { // Labels are fixed locally; never echo provider, configuration or build output. - console.error( - error instanceof DemoCommandError || - (error instanceof Error && - /^(?:Use make demo|Port \d+ is occupied|Another demo owns|TLS leaf|Seed hosts|Environment disagrees|CI requires)/.test( - error.message, - )) - ? error.message - : 'demo: startup/proof failed; inspect prerequisites, TLS, configuration and selected stack', - ); + console.error(demoFailureMessage(error)); process.exitCode = 1; } } finally { diff --git a/scripts/public-runtime.mjs b/scripts/public-runtime.mjs index 38506cad..86be368a 100644 --- a/scripts/public-runtime.mjs +++ b/scripts/public-runtime.mjs @@ -1,40 +1,28 @@ -import { realpathSync } from "node:fs"; -import { join } from "node:path"; +import { realpathSync } from 'node:fs'; +import { join } from 'node:path'; /** Give the API its explicit application/dispatcher credentials and bounded configuration. */ -export function publicApiEnvironment( - values, - configuration, - base = process.env, -) { +export function publicApiEnvironment(values, configuration, base = process.env) { const environment = {}; - for (const name of ["PATH", "HOME", "DOTNET_ROOT", "TMPDIR", "APPDATA"]) + for (const name of ['PATH', 'HOME', 'DOTNET_ROOT', 'TMPDIR', 'APPDATA']) if (base[name] !== undefined) environment[name] = base[name]; - if (!values.ConnectionStrings__Default) - throw new Error("Application credential is missing"); + if (!values.ConnectionStrings__Default) throw new Error('Application credential is missing'); if (!values.ConnectionStrings__OutboxDispatcher) - throw new Error("Outbox dispatcher credential is missing"); + throw new Error('Outbox dispatcher credential is missing'); environment.ConnectionStrings__Default = values.ConnectionStrings__Default; - environment.ConnectionStrings__OutboxDispatcher = - values.ConnectionStrings__OutboxDispatcher; + environment.ConnectionStrings__OutboxDispatcher = values.ConnectionStrings__OutboxDispatcher; if (values.ConnectionStrings__PlatformAdmin) - environment.ConnectionStrings__PlatformAdmin = - values.ConnectionStrings__PlatformAdmin; + environment.ConnectionStrings__PlatformAdmin = values.ConnectionStrings__PlatformAdmin; return { ...environment, - ASPNETCORE_ENVIRONMENT: "Development", + ASPNETCORE_ENVIRONMENT: 'Development', ASPNETCORE_URLS: configuration.apiOrigin, - Tenancy__TrustedHop__Networks__0: "127.0.0.1/32", + Tenancy__TrustedHop__Networks__0: '127.0.0.1/32', Tenancy__TrustedHop__Secrets__0: configuration.secret, }; } /** Development configuration must not consult the workstation's user secrets. */ -export function publicDemoApiEnvironment( - values, - configuration, - privateRoot, - base = process.env, -) { +export function publicDemoApiEnvironment(values, configuration, privateRoot, base = process.env) { const profile = realpathSync(privateRoot); return publicApiEnvironment(values, configuration, { ...base, @@ -49,19 +37,16 @@ export function publicWebEnvironment(values, privateRoot, base = process.env) { const environment = { PATH: base.PATH, TMPDIR: privateRoot, - SWC_NATIVE_BINDING_CACHE: join( - realpathSync(privateRoot), - "swc-native-cache", - ), - NODE_ENV: "production", - NEXT_TELEMETRY_DISABLED: "1", - NODE_TLS_REJECT_UNAUTHORIZED: "1", + SWC_NATIVE_BINDING_CACHE: join(realpathSync(privateRoot), 'swc-native-cache'), + NODE_ENV: 'production', + NEXT_TELEMETRY_DISABLED: '1', + NODE_TLS_REJECT_UNAUTHORIZED: '1', }; for (const key of [ - "LEARNSTACK_PUBLIC_API_ORIGIN", - "LEARNSTACK_PUBLIC_HOP_SECRET", - "LEARNSTACK_PUBLIC_TLS_CERT", - "LEARNSTACK_PUBLIC_TLS_KEY", + 'LEARNSTACK_PUBLIC_API_ORIGIN', + 'LEARNSTACK_PUBLIC_HOP_SECRET', + 'LEARNSTACK_PUBLIC_TLS_CERT', + 'LEARNSTACK_PUBLIC_TLS_KEY', ]) environment[key] = values[key]; return environment; From 41ac29240145e1a53bb5b0e51be6af499909a547 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Tue, 13 Oct 2026 01:42:16 +0300 Subject: [PATCH 87/92] docs(kernel): reconcile integration contract delivery remarks Keep source guidance aligned with ADR-0046 so future consumers use the transport-owned shared inbox and do not infer durable order from a key. Record the first correction review and its verified adjacent finding. ADR: 0046 --- .../Messaging/IIntegrationEvent.cs | 20 +++++++++---------- .../Messaging/IIntegrationEventHandler.cs | 15 +++++++------- docs/roadmap/phase-02b-events-auth.md | 20 +++++++++++++++++-- 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/backend/src/LearnStack.SharedKernel/Messaging/IIntegrationEvent.cs b/backend/src/LearnStack.SharedKernel/Messaging/IIntegrationEvent.cs index f5d7cc85..1c31a914 100644 --- a/backend/src/LearnStack.SharedKernel/Messaging/IIntegrationEvent.cs +++ b/backend/src/LearnStack.SharedKernel/Messaging/IIntegrationEvent.cs @@ -10,15 +10,15 @@ namespace LearnStack.SharedKernel.Messaging; /// implementing this interface directly, so that every event carries the same /// identity, tenancy and ordering fields and a consumer can rely on them without /// knowing the type. The architecture test -/// <c>Integration_Events_Inherit_From_IntegrationEventBase</c> is registered for -/// <see href="../../../../docs/roadmap/phase-02b-events-auth.md">Phase 02b</see>, -/// with the first event type for it to hold to that. +/// <c>Integration_Events_Inherit_From_IntegrationEventBase</c> is implemented by +/// P02b-2 against the declared concrete contract registry. /// </remarks> public interface IIntegrationEvent { /// <summary>Identity for consumer-side deduplication.</summary> /// <remarks> - /// Delivery is at-least-once, so this is what <c>IInboxGuard</c> keys on. + /// Delivery is at-least-once; P02b-3's transport-owned shared inbox uses this + /// value together with tenant and stable subscription identity. /// It is assigned once by the producer and never re-derived — a redelivery /// carries the same value, which is the entire point. /// </remarks> @@ -51,14 +51,14 @@ public interface IIntegrationEvent string Topic { get; } /// <summary> - /// The ordering domain this event belongs to. + /// The partition identity this event belongs to. /// </summary> /// <remarks> - /// Ordering is guaranteed per partition key and nowhere else, so a partition - /// key nobody sets is a guarantee nobody has. Normally the id of the - /// aggregate the event is about; the tenant id for events about the tenant as - /// a whole. Resolved once, by the producer that knows the domain, and never - /// re-derived downstream. + /// Normally the id of the aggregate the event is about; the tenant id for + /// events about the tenant as a whole. Resolved once by the producer, never + /// re-derived downstream. ADR-0046 bounds eligible selection within one + /// processor; this key alone promises no enqueue, commit, retry or + /// cross-process publication order. /// </remarks> string PartitionKey { get; } } diff --git a/backend/src/LearnStack.SharedKernel/Messaging/IIntegrationEventHandler.cs b/backend/src/LearnStack.SharedKernel/Messaging/IIntegrationEventHandler.cs index a7b200d4..65a6ccb0 100644 --- a/backend/src/LearnStack.SharedKernel/Messaging/IIntegrationEventHandler.cs +++ b/backend/src/LearnStack.SharedKernel/Messaging/IIntegrationEventHandler.cs @@ -13,13 +13,14 @@ namespace LearnStack.SharedKernel.Messaging; /// production. Both transports resolve this same contract. /// </para> /// <para> -/// <b>A handler must call <c>IInboxGuard.IsAlreadyProcessedAsync</c> before any -/// business logic.</b> Delivery is at-least-once by design, so deduplication is -/// the consumer's obligation rather than the transport's. The guard, its -/// per-module <c>inbox_messages</c> table and the architecture test that holds -/// every handler to it, <c>Integration_Event_Handlers_Use_InboxGuard</c>, land in -/// <see href="../../../../docs/roadmap/phase-02b-events-auth.md">Phase 02b</see>; -/// the contract is shaped for it now so no handler is written twice. +/// Delivery is at-least-once. Under +/// <see href="../../../../docs/decisions/0046-durable-event-delivery-and-recovery.md">ADR-0046</see>, +/// the transport claims the shared <c>IInboxGuard</c> before constructing the +/// handler; handlers do not call it. Business state, inbox completion and fenced +/// delivery completion share one transaction. Durable acceptance, the shared +/// <c>inbox_messages</c> table and the nonempty production proof for +/// <c>Integration_Event_Handlers_Use_InboxGuard</c> belong to P02b-3. +/// The shipped in-process baseline does not yet provide durable deduplication. /// </para> /// </remarks> /// <remarks> diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 66fc2ef7..50c00e06 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -1910,11 +1910,27 @@ Docker-free integration cases, plus 97 focused Docker cases (54 store, 27 migration, 16 worker), with zero failures/skips. Guarded frontend passes 1,616 cases (1,556 web, 60 SDK), with zero failures/skips/todos; lint/typecheck pass. Release build reports zero warnings/errors; format, seven ADR-status script -controls, 2,587 relative links and 491 fragments pass. The new regression cases -fail their predecessor/mutant controls. Both fresh independent correction review +controls, 2,587 relative links and 491 fragments pass. Planner predecessor and +isolated second-budget mutant controls fail; clean counterparts pass. Both fresh +independent correction review rounds and complete correction-head CI remain pending in this local receipt; the PR description owns live head/check evidence. +#### External correction review round 1 + +Fresh GPT-6-astra xhigh store/security and GPT-6.1-sol xhigh proof/corpus +reviewers approve `44089707..13cd632` without introduced findings. Independent +verification passes seven focused PostgreSQL cases and 99 lifecycle cases. +The PostgreSQL rerun uses the existing Release binaries after a whitespace-only +source correction; a fresh committed-source build and CI remain separate checks. + +The corpus reviewer finds a pre-existing adjacent source-comment contradiction: +`IIntegrationEventHandler` still requires handler-owned per-module inboxes. +Its remarks now name ADR-0046's transport-owned shared inbox and P02b-3 boundary. +The adjacent `IIntegrationEvent` remarks also stop promising unconditional +partition ordering or describing the implemented contract guard as Registered. +These are documentation corrections, with no interface or runtime change. + ### Durable outbox dispatch P02b-1 Steps 2/3 implement `IOutbox`, replace Packet 3's step-7 shell with From 3531a2eb6aeed677eadb991f0779369843527583 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Tue, 13 Oct 2026 01:48:35 +0300 Subject: [PATCH 88/92] docs(roadmap): record external correction reviews and verification Keep review and test evidence tied to the corrected source instead of reusing the earlier PR head. Record both independent rounds, the complete local suites and the remaining exact-head CI verification boundary. ADR: 0046 --- docs/roadmap/phase-02b-events-auth.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 50c00e06..3c03704c 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -1931,6 +1931,30 @@ The adjacent `IIntegrationEvent` remarks also stop promising unconditional partition ordering or describing the implemented contract guard as Registered. These are documentation corrections, with no interface or runtime change. +#### External correction review round 2 and local closeout + +Fresh GPT-6-astra xhigh runtime/security and GPT-6.1-sol xhigh proof/corpus +reviewers independently approve `44089707..41ac2924`, with zero findings across +all 29 changed files. Both read the complete correction and the first round's +source-comment fix. The runtime reviewer independently inspects the completed +TRX counters; neither repeats the root's complete suites or mutation experiment. +Both correction review rounds are complete. No Accepted ADR text, grant or +consumer-delivery contract changes in this remediation. + +Complete local backend verification passes 3,396 cases: 1,845 unit, 286 +architecture, four contract, 175 Docker-free integration and 1,086 Docker +integration, with zero failures/skips. The 97 focused Docker cases above are a +subset, not additional cases. Guarded frontend passes 1,616 cases (1,556 web, +60 SDK), with zero failures/skips/todos; lint/typecheck pass. A fresh Release +build of `41ac2924` reports zero warnings/errors after the source-comment and +whitespace corrections. Five corpus controls, seven ADR-status script controls, +2,693 relative links and 500 fragments pass; historical P02b-0/P02b-1 records +and Accepted ADR text remain preserved. + +Correction-head PR CI and its real demo artifacts still require independent +verification after publication. This local receipt does not reuse the earlier +green head as evidence; the PR description owns live head/check evidence. + ### Durable outbox dispatch P02b-1 Steps 2/3 implement `IOutbox`, replace Packet 3's step-7 shell with From a5b1e72364c9ff81c758d6af1677928c29828017 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Tue, 13 Oct 2026 08:30:03 +0300 Subject: [PATCH 89/92] fix(kernel): close dispatcher membership and credential census gaps Detect reverse role grants at new dispatcher connections and close the compiled configuration-indexer and assembly-instantiation census gaps. Add targeted ownership controls and reconcile current documentation; retain accepted wire semantics and record unconfirmed suggestions. ADR: 0003, 0046 --- .claude/skills/add-integration-event/SKILL.md | 10 +-- .claude/skills/wire-dapr-pubsub/SKILL.md | 13 ++-- .env.example | 3 +- .../Messaging/OutboxDispatcherDataSource.cs | 8 ++- .../Messaging/OutboxEnvelopeReader.cs | 2 + .../OutboxDispatchBoundaryTests.cs | 64 +++++++++++++++++ .../OutboxDispatchConventionTests.cs | 3 +- .../Database/OutboxDispatchStoreTests.cs | 27 +++++++ .../OutboxDispatcherRoleGuardTests.cs | 49 +++++++++++++ .../Database/OutboxProcessorTests.cs | 2 +- .../Messaging/OutboxPublishAttemptTests.cs | 34 +++++++++ docs/glossary.md | 4 +- docs/roadmap/phase-02b-events-auth.md | 72 +++++++++++++++++++ docs/standards/05-database.md | 13 ++-- docs/standards/11-security.md | 7 +- docs/standards/20-infrastructure-stack.md | 13 ++-- .../21-architecture-tests-catalogue.md | 50 +++++++++---- 17 files changed, 331 insertions(+), 43 deletions(-) diff --git a/.claude/skills/add-integration-event/SKILL.md b/.claude/skills/add-integration-event/SKILL.md index 17eb8f4f..4c2d8185 100644 --- a/.claude/skills/add-integration-event/SKILL.md +++ b/.claude/skills/add-integration-event/SKILL.md @@ -229,19 +229,21 @@ suppress its sibling or overwrite authored, retired or deleted definitions. ### Step 5: Subscription registration The current baseline exposes the consumer assembly to the composition root so the -construction-free registry discovers and registers its concrete handlers: +construction-free registry discovers and registers its concrete handlers. This +abridged assembly-registration example uses the shipped public module marker; +retain the other module assemblies in the actual composition root: ```csharp builder.AddLearnStackCrossCuttingFoundation( deploymentMode, - typeof(QueueWelcomeNotificationOnEnrollmentCreated).Assembly); + typeof(LearnStack.Modules.Customization.Application.AssemblyMarker).Assembly); ``` Today `InProcessEventBus` resolves the concrete `IIntegrationEventHandler<T>` directly from DI in that subscription's async scope. MediatR is not involved. There is no shipped -`AddDaprSubscription<T>` helper; do not invent one. The accepted construction-free -manifest adds stable subscription IDs, compatible +`AddDaprSubscription<T>` helper; do not invent one. P02b-2's construction-free +manifest implements stable subscription IDs, compatible type aliases and required Pending/Active readiness. Pending holds the producer row before acquisition or payload validation. Only explicit intentional absence may acknowledge a known type without publication. P02b-3 makes Active publication mean diff --git a/.claude/skills/wire-dapr-pubsub/SKILL.md b/.claude/skills/wire-dapr-pubsub/SKILL.md index a2b3379c..1b3e9724 100644 --- a/.claude/skills/wire-dapr-pubsub/SKILL.md +++ b/.claude/skills/wire-dapr-pubsub/SKILL.md @@ -152,22 +152,25 @@ architecture test; the transport never re-derives or renames it. `InProcessEventBus` discovers handler types from assemblies passed at the composition root. Ensure the consumer assembly is included and let the registry -register the concrete handler type: +register the concrete handler type. This abridged assembly-registration example +uses the shipped public module marker; retain the other module assemblies in the +actual composition root: ```csharp builder.AddLearnStackCrossCuttingFoundation( deploymentMode, - typeof(QueueWelcomeNotificationOnEnrollmentCreated).Assembly); + typeof(LearnStack.Modules.Customization.Application.AssemblyMarker).Assembly); ``` There is no shipped `AddDaprSubscription<T>` helper or `[Topic]` attribute. Do not invent one. The event declares its topic and the construction-free handler registry supplies the current subscription metadata. -P02b-3 adds the declared manifest: stable versioned subscription IDs, compatible +P02b-2 implements the declared manifest: stable versioned subscription IDs, compatible aliases, and required Pending/Active readiness. Pending subscriptions hold producer -rows before acquisition; Active publish returns only after every matching delivery -is durably accepted. A separate executor owns independent per-subscription retries, +rows before acquisition. P02b-3 adds durable acceptance: Active publish returns only +after every matching delivery is durably accepted. A separate executor owns +independent per-subscription retries, terminal state and targeted replay. Neither publication nor replay invokes healthy sibling business handlers again. No current registration helper is claimed here. diff --git a/.env.example b/.env.example index f6c1b3af..67e25271 100644 --- a/.env.example +++ b/.env.example @@ -109,7 +109,8 @@ ASPNETCORE_URLS=http://localhost:5080 # ─── Public renderer (ADR-0053; API and web share this private source) ─── # make public-env fills the empty secret with 32 random bytes encoded as ASCII. # No TLS/hosts/trust-store changes are automatic. See README Quickstart. -# make public-api loads application/dispatcher DB credentials plus network AND secret. +# make public-api loads application/platform-admin/dispatcher DB credentials +# plus network AND secret. # pnpm dev/start loads these values; an optional web .env.local must match. LEARNSTACK_PUBLIC_API_ORIGIN=http://127.0.0.1:5080 LEARNSTACK_PUBLIC_HOP_SECRET= diff --git a/backend/src/LearnStack.Infrastructure/Messaging/OutboxDispatcherDataSource.cs b/backend/src/LearnStack.Infrastructure/Messaging/OutboxDispatcherDataSource.cs index 8390061a..d4fdf4c6 100644 --- a/backend/src/LearnStack.Infrastructure/Messaging/OutboxDispatcherDataSource.cs +++ b/backend/src/LearnStack.Infrastructure/Messaging/OutboxDispatcherDataSource.cs @@ -81,7 +81,13 @@ AND NOT role.rolreplication AND NOT EXISTS ( SELECT 1 FROM pg_roles reachable WHERE reachable.oid <> role.oid - AND pg_has_role(role.oid, reachable.oid, 'MEMBER'))) + AND pg_has_role(role.oid, reachable.oid, 'MEMBER')) + AND NOT EXISTS ( + SELECT 1 FROM pg_roles member + WHERE member.oid <> role.oid + -- Superusers implicitly belong to every role without a grant. + AND NOT member.rolsuper + AND pg_has_role(member.oid, role.oid, 'MEMBER'))) """; command.Parameters.AddWithValue("role", Role); diff --git a/backend/src/LearnStack.Infrastructure/Messaging/OutboxEnvelopeReader.cs b/backend/src/LearnStack.Infrastructure/Messaging/OutboxEnvelopeReader.cs index 2d6f74d5..1a460595 100644 --- a/backend/src/LearnStack.Infrastructure/Messaging/OutboxEnvelopeReader.cs +++ b/backend/src/LearnStack.Infrastructure/Messaging/OutboxEnvelopeReader.cs @@ -38,6 +38,8 @@ public OutboxEnvelopeReadResult Read(OutboxDispatchClaim claim) IntegrationEventBase.PayloadJsonOptions) as IntegrationEventBase; if (fact is null) return Invalid(); var serialized = OutboxEventContract.Serialize(fact); + // Npgsql writes timestamps at microsecond precision by truncating + // sub-microsecond ticks; compare that stored precision, not a tolerance. if (serialized.EventId != claim.Id || serialized.TenantId != claim.TenantId || serialized.Topic != claim.Topic || serialized.PartitionKey != claim.PartitionKey || serialized.OccurredAt.UtcTicks / 10 != claim.OccurredAt.UtcTicks / 10) diff --git a/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchBoundaryTests.cs b/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchBoundaryTests.cs index 8330022d..1475aa5f 100644 --- a/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchBoundaryTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchBoundaryTests.cs @@ -253,6 +253,55 @@ public void Credential_census_rejects_crossed_keys_changed_closures_and_missing_ }, CredentialOffenders); } + [Fact] + public void Credential_census_rejects_compiled_connection_string_indexers_and_composed_keys() + { + using var census = ProductionCensus.Read(); + var application = census.Modules.Single(module => module.Assembly.Name.Name == "LearnStack.Application"); + foreach (var name in new[] { "Concatenated", "ReadonlyName", "Interpolated", "Formatted", "Manager", "Root", "Section" }) + AssertMutation(census, "a compiled configuration credential indexer: " + name, () => + AddCompiledCredentialProbe(application, name), CredentialOffenders); + foreach (var name in new[] { "UnrelatedLiteral", "UnrelatedDynamic", "Dictionary", "CredentialDiagnostic" }) + { + var undo = AddCompiledCredentialProbe(application, name); + try { CredentialOffenders(census).Should().BeEmpty("Fix: unrelated configuration, dictionary indexers and diagnostics remain valid"); } + finally { undo(); } + } + } + + private static Action AddCompiledCredentialProbe(ModuleDefinition production, string name) + { + // Use the compiler's actual concat/interpolation/indexer IL in the same + // nonempty production census, rather than planting the missing full key. + var probes = ModuleDefinition.ReadModule(typeof(OutboxDispatchConventionTests).Assembly.Location, + new ReaderParameters { ReadingMode = ReadingMode.Immediate }); + var source = probes.GetTypes().Single(type => type.Name == nameof(CredentialIndexerProbes)); + var method = source.Methods.Single(item => item.Name == name); + // Decode IL while its tokens still belong to the source module. Moving a + // deferred body first would read those tokens through the production module. + _ = method.Body.Instructions.Count; + source.Methods.Remove(method); + var planted = AddProbe(production, "CompiledCredentialIndexer"); + planted.Methods.Add(method); + return () => { production.Types.Remove(planted); probes.Dispose(); }; + } + + private static class CredentialIndexerProbes + { + private static readonly string ConnectionName = "OutboxDispatcher"; + public static string? Concatenated(IConfiguration configuration, string name) => configuration["ConnectionStrings:" + name]; + public static string? ReadonlyName(IConfiguration configuration) => configuration["ConnectionStrings:" + ConnectionName]; + public static string? Interpolated(IConfiguration configuration, string name) => configuration[$"ConnectionStrings:{name}"]; + public static string? Formatted(IConfiguration configuration, string name) => configuration[string.Format(System.Globalization.CultureInfo.InvariantCulture, "ConnectionStrings:{0}", name)]; + public static string? Manager(ConfigurationManager configuration, string name) => configuration["ConnectionStrings:" + name]; + public static string? Root(ConfigurationRoot configuration, string name) => configuration["ConnectionStrings:" + name]; + public static string? Section(ConfigurationSection configuration, string name) => configuration["ConnectionStrings:" + name]; + public static string? UnrelatedLiteral(IConfiguration configuration) => configuration["Telemetry:OtlpEndpoint"]; + public static string? UnrelatedDynamic(IConfiguration configuration, string key) => configuration[key]; + public static string? Dictionary(IDictionary<string, string?> values, string name) => values["ConnectionStrings:" + name]; + public static string CredentialDiagnostic(string name) => $"ConnectionStrings:{name} is not configured."; + } + [Fact] public void Credential_registration_detector_rejects_missing_duplicate_wrong_key_and_non_singleton_sources() { @@ -517,6 +566,15 @@ private static SortedSet<string> CredentialOffenders(ProductionCensus census) injections.Add(MethodId(method) + ":" + parameter.ParameterType.FullName + ":" + BoundaryAttributeValue(attribute.ConstructorArguments.FirstOrDefault())); if (!method.HasBody) continue; + // GetConnectionString is the sole admitted credential-read member at + // the two roots above. Its indexer equivalent can compose the name, + // leaving no complete dispatcher literal in IL. Conservatively refuse + // a connection-string prefix alongside an explicit configuration indexer; + // this does not classify arbitrary key dataflow or unrelated indexers. + if (method.Body.Instructions.Any(instruction => instruction.Operand is MethodReference call && IsConfigurationIndexer(call)) + && method.Body.Instructions.Any(instruction => instruction.OpCode == OpCodes.Ldstr && instruction.Operand is string value + && value.StartsWith("ConnectionStrings:", StringComparison.OrdinalIgnoreCase))) + offenders.Add(MethodId(method) + ": connection-string indexer bypasses the declared credential readers"); foreach (var instruction in method.Body.Instructions) { if (instruction.Operand is MethodReference call && IsConnectionStringRead(call)) @@ -725,6 +783,12 @@ private static bool IsKeyedResolution(MethodReference method) => private static bool IsConnectionStringRead(MethodReference method) => method.DeclaringType.FullName == "Microsoft.Extensions.Configuration.ConfigurationExtensions" && method.Name == "GetConnectionString"; + private static bool IsConfigurationIndexer(MethodReference method) => method.Name == "get_Item" + && method.ReturnType.FullName == "System.String" && ParametersMatch(method, ["System.String"]) + && method.DeclaringType.FullName is "Microsoft.Extensions.Configuration.IConfiguration" + or "Microsoft.Extensions.Configuration.ConfigurationManager" or "Microsoft.Extensions.Configuration.ConfigurationRoot" + or "Microsoft.Extensions.Configuration.ConfigurationSection"; + private static object? BoundaryAttributeValue(CustomAttributeArgument argument, int depth = 0) => depth >= 8 ? null : argument.Value is CustomAttributeArgument nested ? BoundaryAttributeValue(nested, depth + 1) : argument.Value; diff --git a/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchConventionTests.cs b/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchConventionTests.cs index 2cd8ad32..956bf997 100644 --- a/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchConventionTests.cs +++ b/backend/tests/LearnStack.Tests.Architecture/OutboxDispatchConventionTests.cs @@ -249,6 +249,7 @@ public void Resolution_walk_detects_loading_construction_async_and_polymorphic_h typeof(System.Reflection.Assembly).GetMethod(nameof(System.Reflection.Assembly.Load), [typeof(string)])!, typeof(System.Reflection.Assembly).GetMethod(nameof(System.Reflection.Assembly.LoadFrom), [typeof(string)])!, typeof(System.Reflection.Assembly).GetMethod(nameof(System.Reflection.Assembly.GetType), [typeof(string)])!, + typeof(System.Reflection.Assembly).GetMethod(nameof(System.Reflection.Assembly.CreateInstance), [typeof(string)])!, typeof(Activator).GetMethod(nameof(Activator.CreateInstance), [typeof(Type)])!, }; foreach (var target in targets) @@ -418,7 +419,7 @@ private static SortedSet<string> ResolutionOffenders(ProductionCensus census) private static bool DynamicResolution(MethodReference target) => target.DeclaringType.FullName switch { "System.Type" => target.Name == "GetType", - "System.Reflection.Assembly" => target.Name is "GetType" or "Load" or "LoadFrom" or "LoadFile" or "UnsafeLoadFrom", + "System.Reflection.Assembly" => target.Name is "GetType" or "CreateInstance" or "Load" or "LoadFrom" or "LoadFile" or "UnsafeLoadFrom", "System.Activator" => target.Name is "CreateInstance" or "CreateInstanceFrom", _ => false, }; diff --git a/backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatchStoreTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatchStoreTests.cs index 6b638350..c8629cbe 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatchStoreTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatchStoreTests.cs @@ -96,6 +96,33 @@ public async Task A_live_success_commits_processed_state_and_clears_its_lease_on (await test.AuditCountAsync()).Should().Be(0); } + [Theory] + [InlineData("complete", OutboxClaimKind.Recovery)] + [InlineData("complete", OutboxClaimKind.Terminal)] + [InlineData("fail", OutboxClaimKind.Terminal)] + [InlineData("terminalize", OutboxClaimKind.Normal)] + [InlineData("terminalize", OutboxClaimKind.Recovery)] + public async Task Wrong_claim_kind_refuses_before_changing_the_row_or_audit(string operation, OutboxClaimKind kind) + { + await using var test = await TestDatabase.CreateAsync(schema.Postgres); + var id = await test.InsertAsync(); + var store = test.Store(); + var claim = (await store.TryClaimAsync(NoBlockedPartitions))! with { Kind = kind }; + var before = await test.ReadAsync(id); + Func<Task> act = () => operation switch + { + "complete" => store.CompleteAsync(claim), + "fail" => store.FailAsync(claim, OutboxFailureReason.PublishFailed), + "terminalize" => store.TryTerminalizeAsync(claim, AttemptTraceparent), + _ => throw new ArgumentOutOfRangeException(nameof(operation)), + }; + + await act.Should().ThrowAsync<ArgumentException>(); + + (await test.ReadAsync(id)).Should().Be(before); + (await test.AuditCountAsync()).Should().Be(0); + } + [Theory] [InlineData("success")] [InlineData("failure")] diff --git a/backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatcherRoleGuardTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatcherRoleGuardTests.cs index a784c433..6329129f 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatcherRoleGuardTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatcherRoleGuardTests.cs @@ -1,5 +1,6 @@ using FluentAssertions; using LearnStack.Infrastructure.Messaging; +using LearnStack.Infrastructure.Persistence; using Npgsql; using Xunit; @@ -102,6 +103,54 @@ await postgres.ExecuteAsSuperuserAsync(throughBridge healthy.State.Should().Be(System.Data.ConnectionState.Open); } + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task App_membership_in_the_dispatcher_is_refused_directly_and_transitively_then_restoration_connects( + bool throughBridge, bool synchronous) + { + await using var source = OutboxDispatcherDataSource.Build(postgres.OutboxConnectionString); + await using var first = await source.OpenConnectionAsync(); + await using var appSource = ApplicationDataSource.Build(postgres.AppConnectionString); + // The app guard checks new physical connections. A later grant can still + // escalate an existing session, so the dispatcher checks incoming grants too. + await using var app = await appSource.OpenConnectionAsync(); + try + { + await postgres.ExecuteAsSuperuserAsync(throughBridge + ? "CREATE ROLE outbox_guard_member_bridge NOLOGIN; " + + "GRANT learnstack_outbox_admin TO outbox_guard_member_bridge; " + + "GRANT outbox_guard_member_bridge TO learnstack_app" + : "GRANT learnstack_outbox_admin TO learnstack_app"); + await using var switchRole = new NpgsqlCommand("SET ROLE learnstack_outbox_admin", app); + await switchRole.ExecuteNonQueryAsync(); + await using var truth = new NpgsqlCommand( + """ + SELECT session_user = 'learnstack_app' + AND current_user = 'learnstack_outbox_admin' AND rolbypassrls + FROM pg_roles WHERE rolname = current_user + """, app); + (await truth.ExecuteScalarAsync()).Should().Be(true, + "the direct or transitive grant must really let an existing app session acquire the bypass role"); + + await AssertGuardRefusesAsync(source, synchronous); + } + finally + { + await postgres.ExecuteAsSuperuserAsync(throughBridge + ? "DROP ROLE IF EXISTS outbox_guard_member_bridge" + : "REVOKE learnstack_outbox_admin FROM learnstack_app"); + await using var resetRole = new NpgsqlCommand("RESET ROLE", app); + await resetRole.ExecuteNonQueryAsync(); + } + + await using var healthy = await source.OpenConnectionAsync(); + healthy.State.Should().Be(System.Data.ConnectionState.Open); + healthy.ProcessID.Should().NotBe(first.ProcessID); + } + [Fact] public async Task A_startup_role_switch_cannot_change_the_dispatcher_current_role() { diff --git a/backend/tests/LearnStack.Tests.Integration/Database/OutboxProcessorTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/OutboxProcessorTests.cs index 9425c98c..6044b9c5 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/OutboxProcessorTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/OutboxProcessorTests.cs @@ -41,7 +41,7 @@ public sealed class OutboxProcessorTests(SchemaFixture schema) IdlePoll = TimeSpan.FromMilliseconds(20), ProgressTimeout = TimeSpan.FromSeconds(5), SnapshotInterval = TimeSpan.FromMilliseconds(20), - ShutdownTimeout = TimeSpan.FromMilliseconds(150), + ShutdownTimeout = TimeSpan.FromMilliseconds(200), }; [Fact] diff --git a/backend/tests/LearnStack.Tests.Unit/Api/Messaging/OutboxPublishAttemptTests.cs b/backend/tests/LearnStack.Tests.Unit/Api/Messaging/OutboxPublishAttemptTests.cs index 0f68676a..d6b98f03 100644 --- a/backend/tests/LearnStack.Tests.Unit/Api/Messaging/OutboxPublishAttemptTests.cs +++ b/backend/tests/LearnStack.Tests.Unit/Api/Messaging/OutboxPublishAttemptTests.cs @@ -8,6 +8,35 @@ namespace LearnStack.Tests.Unit.Api.Messaging; public sealed class OutboxPublishAttemptTests { + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Completion_reports_publication_outcome_after_disposing_the_owned_scope(bool throws) + { + var disposed = false; + CancellationToken captured = default; + IntegrationEventEnvelope? observed = null; + var envelope = Envelope(); + var services = new ServiceCollection(); + services.AddScoped<IEventBus>(_ => new CallbackBus((received, token) => + { + observed = received; + captured = token; + return throws ? Task.FromException(new InvalidOperationException("private-provider-error")) + : Task.CompletedTask; + }, () => { disposed = true; return ValueTask.CompletedTask; })); + await using var provider = services.BuildServiceProvider(); + var attempt = new OutboxPublishAttempt(provider.GetRequiredService<IServiceScopeFactory>(), envelope); + + (await attempt.Completion.WaitAsync(TimeSpan.FromSeconds(5))).Should().Be(!throws); + + disposed.Should().BeTrue(); + observed.Should().BeSameAs(envelope); + var accessDisposedToken = () => captured.WaitHandle; + accessDisposedToken.Should().Throw<ObjectDisposedException>(); + attempt.RequestCancellation(); // Completed attempts safely ignore late cancellation. + } + [Fact] public async Task Synchronous_construction_and_publish_prefix_run_inside_the_owned_task() { @@ -107,11 +136,13 @@ public async Task Blocking_cancellation_registration_is_retained_and_observed_be var cancellationEntered = Signal(); using var releaseCancellation = new ManualResetEventSlim(); var disposed = Signal(); + var cancellationCount = 0; var services = new ServiceCollection(); services.AddScoped<IEventBus>(_ => new CallbackBus(async (_, token) => { token.Register(() => { + Interlocked.Increment(ref cancellationCount); cancellationEntered.TrySetResult(); releaseCancellation.Wait(); // Exercise a cancellation callback that blocks and faults. throw new InvalidOperationException("private-provider-cancellation-error"); @@ -125,6 +156,7 @@ public async Task Blocking_cancellation_registration_is_retained_and_observed_be { await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); attempt.RequestCancellation(); + attempt.RequestCancellation(); await cancellationEntered.Task.WaitAsync(TimeSpan.FromSeconds(5)); releaseCallback.TrySetResult(); attempt.Completion.IsCompleted.Should().BeFalse(); @@ -139,6 +171,8 @@ public async Task Blocking_cancellation_registration_is_retained_and_observed_be (await attempt.Completion).Should().BeFalse(); disposed.Task.IsCompleted.Should().BeTrue(); + cancellationCount.Should().Be(1); + attempt.RequestCancellation(); } private static TaskCompletionSource Signal() => new(TaskCreationOptions.RunContinuationsAsynchronously); diff --git a/docs/glossary.md b/docs/glossary.md index 13edb8de..8b719f59 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -326,9 +326,9 @@ These entries distinguish P02d-2 contracts from P02d-3 internal read contracts: | **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`** | Non-generic envelope publication port. `InProcessEventBus` is the only registered implementation until the Phase 11 trigger. P02b-3 changes success to durable acceptance by every required subscription, then independent execution owns business outcomes; failed/unknown acceptance remains producer retry. The outbox processor remains the only production publisher; subscriber retries invoke an internal executor. Current ambient restoration/per-key invocation exists; durable acceptance/inbox do not. See [ADR-0046](decisions/0046-durable-event-delivery-and-recovery.md). | | **`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. | +| **`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. [ADR-0046](decisions/0046-durable-event-delivery-and-recovery.md#producer-dispatch-and-recovery) defines eligible-selection order and its limits; the declaration supplies no durable publish-order guarantee. | | **`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 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. | +| **`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; [ADR-0046](decisions/0046-durable-event-delivery-and-recovery.md#producer-dispatch-and-recovery) defines eligible-selection order and its limits. 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/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 3c03704c..6b6adcd1 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -1955,6 +1955,78 @@ Correction-head PR CI and its real demo artifacts still require independent verification after publication. This local receipt does not reuse the earlier green head as evidence; the PR description owns live head/check evidence. +### P02b-2 review of 3531a2eb (2026-10-13) + +The next external report was checked against `3531a2eb`, not adopted as an +instruction source. That baseline's seven required jobs and complete demo +artifacts pass in [run 38318773974](https://github.com/HodeTech/LearnStack/actions/runs/38318773974). +This follow-up requires its own review and CI evidence. No Accepted ADR body, +migration, grant, consumer-delivery contract or dependency changes here. + +- The earlier reverse-membership disposition is reconsidered using a real + direct/transitive `SET ROLE` control. The app guard already rejects elevated + memberships on new app connections; an administrator's later grant can still + elevate an existing app session. The dispatcher now detects incoming grants + on its next physical connection, enforcing the existing four-role boundary. + Superusers are excluded from that predicate because PostgreSQL implicitly + treats them as members of every role. This is grant-drift detection, not + continuous revocation. Four sync/async direct/transitive cases prove actual + escalation, refusal and healthy reconnection after restoration. An isolated + PostgreSQL comparison distinguishes the predecessor and corrected predicates. +- Credential census now rejects explicit configuration indexers with a + same-body `ConnectionStrings:` key/prefix, including compiled concatenation, + interpolation, formatting and static-readonly names. Unrelated configuration, + dictionary indexers and diagnostic text remain clean. This bounded check + does not claim arbitrary cross-method key dataflow. A separate planted control + closes `Assembly.CreateInstance`; `Activator.CreateInstanceFrom` was already + covered. Both omissions were reproduced against the predecessor detector. +- The constraint/RLS statement, shared-inbox and fifth-writer statuses, settled + ordering pointers and catalogue proof references now match current code. + Skills use a shipped public assembly marker in explicitly abridged examples. + The architecture method count is 194; Implemented rule count remains 192. +- Publication tests now cover successful/throwing completion, cleanup and + repeated/late cancellation. Wrong-kind store calls must leave the actual row + and audit unchanged. Test shutdown and publish timeouts use distinct values. + A source comment explains exact Npgsql microsecond truncation; the local API + environment comment names all three forwarded credentials. + +The remaining claims were checked against their actual scope: + +| Concern | Disposition | +|---|---| +| Startup options failure is reported as a credential error | Not on the shipped DI path: `OutboxDispatchStore` validates the same options before worker construction or source resolution | +| Broaden COMMIT catch to cure sticky terminal health | No demonstrated shipped exception escapes the existing provider/network/cancellation set. The dispatcher configures no custom driver logger. Failed reconciliation remains an unknown outcome even with catch-all; conservative health clears after successful terminalization | +| Non-admitted Normal claim hot-loops | Immutable registry and SQL exclude Pending identities before acquisition; unknown/retired/invalid rows already request terminalization. No current path reaches that invariant arm | +| Reject every unmapped JSON member | ADR-0046 explicitly uses the frozen payload options and compares declared facts; producer contracts enforce identifier-only fields. Unknown fields are not passed to the handler. A new stored-wire rejection policy is not inferred from this suggestion | +| Producer witness kind/subscription should anticipate subscriber minting | The current closed minting census admits only the producer store. P02b-3 owns a distinct subscriber capability and its identity controls; no subscriber factory is introduced early | +| Legacy-row diagnostics, blocking migration and attempt constants | Invalid facts deliberately refuse migration. Existing migrations remain immutable; checks/index creation need an owner maintenance window, with no online-migration claim. Tests pin six claims and the full backoff/DDL state; copying a new constant into historical migrations would not eliminate drift | +| UTF-8 SQL literals escape the literal census | Npgsql's command entrypoints accept strings. Decoding bytes is dynamic construction/dataflow outside this explicitly bounded literal census; raw byte data alone is not an executable SQL call | +| Platform credential rule is no longer independently sufficient | The whole-production credential/factory/consumer census independently pins both exact roots and their distinct sources. Repeating it inside the companion source rule adds no uncovered current boundary | +| COMMIT/probe stack assertions are redundant | Both failures are `NpgsqlException`; the stage-specific stack assertions distinguish their order. They supplement the physical outcome/no-duplicate proof and currently pass with the pinned driver | +| Ambient public-api overrides and seed validation should cover four credentials | Explicit ambient API overrides are an existing tested local-loader policy. Standalone seed consumes only Default and Migration; other ambient credentials never reach its child. Shared demo validates all four before startup | +| Forward proxy/cache/home environment into Seeder | The narrow child allowlist deliberately excludes ambient credentials/diagnostics; proxy values can themselves contain credentials. No failure on the documented restored-dependency path was demonstrated; the API's separate allowlist intentionally excludes `DOTNET_CLI_HOME` | +| Print the failed connection-string suffix | The fixed diagnostic is now safely visible. More detailed refusal messages are optional; values remain excluded and no further operational regression was reproduced | +| Ingress env tests depend on file order | Each loader test writes its own selected file before reading it; they are sequential, not concurrent. The later configuration test uses an explicit object, not that file | +| Duplicated construction-only factory helper | The remarks explain the intentionally absent worker/unhealthy private check. Sharing helpers across separate test assemblies would add a dependency without correcting behaviour | +| Skill index claims handler-owned inbox | Not reproduced: its generic inventory names an inbox guard but assigns no handler ownership; detailed skills now identify the shipped/P02b-3 boundary | +| Persist traceparent or measure recovery/terminal as publication duration | Trace persistence is optional. The duration histogram measures publication; recovery and terminal transitions do not publish and retain their own spans/outcomes | +| Retained partition also delays terminal acquisition | Deliberate local ownership: no further acquisition for that partition until callback and scope cleanup actually finish, including terminal work; ADR-0046's four-overrun saturation applies | +| Assembly-qualified identities, closed generics and invalid disposition label | Exact assembly-qualified identity plus explicit compatible aliases is Accepted. A declared closed generic is a concrete known type, not arbitrary loading. Invalid-envelope reason and the fixed `invalid` label remain distinct from unknown-type reason | +| Legacy non-NULL metadata should be backfilled | ADR-0057 requires new producer metadata to be SQL NULL; ADR-0046 deliberately terminalizes invalid legacy envelopes rather than silently rewriting their facts | +| Alternate health-registration dedupe and audit trigger | The sole production composition is pinned. A new trigger/privilege design is Phase 11 work, not an unreviewed replacement for the Accepted row-bound terminal seam | +| Squash migrations or mechanically gate published fragment counts | Existing migrations/history are preserved. Link/fragment totals are explicitly local checks, not CI enforcement claims; the prior and current source scans remain separate evidence | + +Local Release build reports zero warnings/errors; format verification passes. +Unit passes 1,847, architecture 287, contract four and Docker-free integration +175 cases, all with zero failures/skips. Twenty role controls and six focused +claim-kind/shutdown cases pass. The new compiled-probe helper's initial deferred +Cecil-body failure was corrected before the successful full architecture rerun; +the guard expectation was preserved. Seven ADR-status controls, 2,711 relative +links and 505 fragments pass. Historical delivery records and Accepted ADR text +remain unchanged. Complete Docker validation, both fresh independent review +rounds and correction-head CI remain pending in this initial local receipt; +the PR description owns final head/check evidence. + ### Durable outbox dispatch P02b-1 Steps 2/3 implement `IOutbox`, replace Packet 3's step-7 shell with diff --git a/docs/standards/05-database.md b/docs/standards/05-database.md index d3d47519..9cbb2dc6 100644 --- a/docs/standards/05-database.md +++ b/docs/standards/05-database.md @@ -218,12 +218,13 @@ CREATE INDEX ix_courses_tenant_id_organization_id ON courses (tenant_id, organiz PostgreSQL evaluates referential integrity at **runtime** as a security-restricted operation on behalf of the table owner, and those RI triggers are **not subject to Row -Level Security**. (The DDL path is the opposite and matters in migrations: the scan -`ALTER TABLE … ADD CONSTRAINT` / `VALIDATE CONSTRAINT` performs runs as the -issuing role -under its policies, so a constraint added to a populated tenant-owned table validates -against the rows that role can see. § Data Migrations carries the consequence.) A -single-column `lessons.course_id → courses.id` therefore lets a row in tenant A +Level Security** ([PostgreSQL row-security documentation](https://www.postgresql.org/docs/18/ddl-rowsecurity.html)). +Constraint validation also checks existing rows regardless of their RLS visibility: +`ALTER TABLE … ADD CONSTRAINT` checks all existing rows unless `NOT VALID` skips +that scan; `VALIDATE CONSTRAINT` performs the deferred check +([PostgreSQL ALTER TABLE documentation](https://www.postgresql.org/docs/18/sql-altertable.html)). +An ordinary `UPDATE` backfill remains subject to RLS; see [Data Migrations](#data-migrations). +A single-column `lessons.course_id → courses.id` therefore lets a row in tenant A reference a row in tenant B: the child's own `WITH CHECK` passes because its `tenant_id` is A's, and the FK check passes because it can see B's row. The result is a permanent cross-tenant reference in the one system whose top-line threat is diff --git a/docs/standards/11-security.md b/docs/standards/11-security.md index 3a68eb62..6c84817a 100644 --- a/docs/standards/11-security.md +++ b/docs/standards/11-security.md @@ -254,8 +254,11 @@ username without connecting or echoing parse input. Worker startup must verify connectivity. Every new physical connection verifies exact session/current `learnstack_outbox_admin`, BYPASSRLS, NOSUPERUSER, NOCREATEDB, NOCREATEROLE, -NOREPLICATION and no privilege-escalating membership. No Seeder/web forwarding or -shared app credential is allowed. [Database Standards](05-database.md#grant-matrix) +NOREPLICATION and no outgoing role membership or incoming membership by a +non-superuser, directly or transitively. Superusers inherently reach every role; +this check detects grant drift on a new physical connection, not continuous +revocation of an existing session. No Seeder/web forwarding or shared app +credential is allowed. [Database Standards](05-database.md#grant-matrix) owns exact status grants and audit INSERT-only; no business access or audit read, update or delete is admitted. diff --git a/docs/standards/20-infrastructure-stack.md b/docs/standards/20-infrastructure-stack.md index 878ee301..a10105e7 100644 --- a/docs/standards/20-infrastructure-stack.md +++ b/docs/standards/20-infrastructure-stack.md @@ -30,8 +30,8 @@ intent multiplicity, capture, classification, and which assembly each type lives **partition lifecycle**. This standard records that partitioning is demand-gated, where the audit types live, and that modules never write `audit_log` directly. Assembly placement and the delivered four-method write path in § Audit Plumbing are ADR-0044's; -ADR-0046 adds the pending confined fifth seam; both write-path authorities are -named in the header above. +ADR-0046 adds the confined fifth seam implemented in P02b-2; both write-path +authorities are named in the header above. This standard defines how application code uses the foundation infrastructure introduced in the 2026-05-18 redesign: Dapr building blocks, the APISIX gateway, the Hub HTTPS @@ -81,10 +81,11 @@ Rules that follow from this: written against the port and holds for the default implementation too. If a rule only makes sense for the vendor adapter, it is a rule about the adapter and belongs in that adapter's section, not in module guidance. -- **`InProcessEventBus` is a first-class transport, not a stub.** Same - `IIntegrationEventHandler<T>`, same `IInboxGuard`, same tenant-context restoration as - the durable path. A development path that skips those never exercises the isolation - code, and every consumer would end up needing two implementations. +- **`InProcessEventBus` is a first-class transport, not a stub.** It uses + `IIntegrationEventHandler<T>` and restores tenant context before handler resolution. + Durable acceptance and the shared transport-owned `IInboxGuard` remain P02b-3 + work under [ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md#subscriber-acceptance-and-inbox-ownership). + Phase 11's adapter must preserve that consumer contract. - **A demand-gated block is not "deferred".** It qualifies only with all four of: a port, a working default implementation, an owning phase, and a written trigger. If a trigger fires earlier than its phase, the item moves to the phase where it fired and diff --git a/docs/standards/21-architecture-tests-catalogue.md b/docs/standards/21-architecture-tests-catalogue.md index 1e35882d..87e1e4de 100644 --- a/docs/standards/21-architecture-tests-catalogue.md +++ b/docs/standards/21-architecture-tests-catalogue.md @@ -110,7 +110,7 @@ not implemented is the failure mode this column exists to prevent. ### Implemented today -**193 test methods run in +**194 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, @@ -1796,7 +1796,8 @@ diagnostic above row security, not the boundary, as its own note says. - **Asserts:** only the confined dispatcher infrastructure resolves the named keyed, lazy `ConnectionStrings:OutboxDispatcher` source in the API host. Seeder/web and arbitrary keyed/service-provider resolution are refused. Physical connections - verify the exact outbox role, bounded attributes and memberships. A nonempty + verify the exact outbox role, bounded attributes and memberships, including + direct/transitive incoming membership from other non-superuser roles. A nonempty production census has planted clean/dirty resolution and registration controls. Named worker/store source readers require unique literal method signatures, visibility and staticness; a same-name overload grants no authority. Real @@ -1811,7 +1812,14 @@ diagnostic above row security, not the boundary, as its own note says. constructor bindings, planted clean/dirty helper/delegate/async controls, and real PostgreSQL role/grant/startup refusal proofs. Exact unique literal source-reader signatures, visibility and staticness reject named-method overloads; - the focused controls and both fresh fix reviews pass at `606362b`. + those focused controls and both fresh fix reviews passed at `606362b`. + [OutboxDispatchConventionTests.Credential_census_rejects_compiled_connection_string_indexers_and_composed_keys](../../backend/tests/LearnStack.Tests.Architecture/OutboxDispatchBoundaryTests.cs) + plants actual compiled concatenation, interpolation, formatting and configuration + indexer calls, with clean configuration/dictionary/diagnostic controls. + [OutboxDispatcherRoleGuardTests.App_membership_in_the_dispatcher_is_refused_directly_and_transitively_then_restoration_connects](../../backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatcherRoleGuardTests.cs) + proves an existing app session can escalate through the planted grant, then + verifies refusal on a new dispatcher physical connection and successful + connection after grant removal, for both direct and transitive membership. - **Phase:** 02b, P02b-2. #### `CoreInfrastructure_DoesNotDependOn_AnyModule` @@ -3239,15 +3247,16 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). 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. + ADR-0046 defines eligible-selection order and its limits. - **Source:** [ADR-0057](../decisions/0057-transactional-event-production-and-completion.md); + [ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md#producer-dispatch-and-recovery); [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). +- **Phase:** 02b, P02b-1 (derivation); P02b-2 (selection-order bounds). #### `Outbox_Enqueue_Uses_The_Announced_Transaction` @@ -3396,11 +3405,18 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). duplicate the terminal audit. Exact real-role grants allow only the declared writes. - **Source:** [ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md); [P02b-2 proof obligations](../roadmap/phase-02b-events-auth.md#decisive-proof-obligations). -- **Type:** production architecture census with planted controls where structural; - real PostgreSQL/runtime integration proofs where behavioural. +- **Type:** integration tests (Testcontainers), including physical COMMIT-response + loss and real-role grant controls. **Kind:** behavioural. -- **Status:** **Implemented** (`OutboxDispatchStoreTests`): real-role audit-grant - refusal, rollback/retry and completed-row replay controls. +- **Status:** **Implemented** (`OutboxDispatchStoreTests`, + `OutboxDispatchMigrationTests`), with these proofs: + [OutboxDispatchStoreTests.Refused_audit_rolls_back_the_terminal_update_and_retains_the_request_for_audited_retry](../../backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatchStoreTests.cs), + [OutboxDispatchStoreTests.Expiry_alone_refuses_every_transition_without_changing_state](../../backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatchStoreTests.cs), + [OutboxDispatchStoreTests.A_lost_physical_COMMIT_response_reconciles_durable_terminal_state_without_duplicate_audit](../../backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatchStoreTests.cs), + [OutboxDispatchStoreTests.A_lost_COMMIT_response_and_failed_reconciliation_preserve_both_failures_without_duplicate_audit](../../backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatchStoreTests.cs), + and [OutboxDispatchMigrationTests.Outbox_role_has_no_audit_read_mutation_or_business_access](../../backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatchMigrationTests.cs) + prove audit-grant refusal, atomic rollback/retry, stale ownership, durable + reconciliation and completed-row replay without duplicate audit. - **Phase:** 02b, P02b-2. #### `Outbox_Terminal_Audit_Identity_Is_Row_Bound` @@ -3446,13 +3462,19 @@ Introduced by [Phase 02b](../roadmap/phase-02b-events-auth.md). publish/mark path; real subscriptions stay Pending until P02b-3. - **Source:** [ADR-0046](../decisions/0046-durable-event-delivery-and-recovery.md); [P02b-2 proof obligations](../roadmap/phase-02b-events-auth.md#decisive-proof-obligations). -- **Type:** real PostgreSQL/runtime integration proofs and startup composition checks. +- **Type:** integration tests (Testcontainers) and unit tests of production + composition and manifest refusal. **Kind:** startup + behavioural. - **Status:** **Implemented** (`OutboxProcessorTests`, `OutboxDispatchStoreTests`, - `OutboxProductionCompositionTests`): real provisioning rows remain unchanged - across hosted Pending polls; aliases/malformed payloads are held before every - acquisition path. Test-owned Active handlers publish/mark, while explicit - intentional absence acknowledges without construction. + `OutboxProductionCompositionTests`, `OutboxEventRegistryTests`), with these proofs: + [OutboxProcessorTests.Real_provisioning_rows_remain_Pending_across_hosted_polls_without_construction_or_mutation](../../backend/tests/LearnStack.Tests.Integration/Database/OutboxProcessorTests.cs), + [OutboxDispatchStoreTests.Real_TenantCreated_contracts_and_aliases_are_held_before_any_payload_validation](../../backend/tests/LearnStack.Tests.Integration/Database/OutboxDispatchStoreTests.cs), + [OutboxProductionCompositionTests.Real_provisioning_contract_remains_pending_for_both_distinct_default_consumers](../../backend/tests/LearnStack.Tests.Unit/Api/Composition/OutboxProductionCompositionTests.cs), + and [OutboxEventRegistryTests.Active_manifest_and_discovered_handlers_must_match_in_both_directions](../../backend/tests/LearnStack.Tests.Unit/Infrastructure/Messaging/OutboxEventRegistryTests.cs) + prove real Pending holds and composition refusal. Test-owned Active publication + runs in [OutboxProcessorTests.A_poison_first_row_terminalizes_with_MUST_audit_and_the_following_real_handler_progresses](../../backend/tests/LearnStack.Tests.Integration/Database/OutboxProcessorTests.cs); + [OutboxProcessorTests.Explicit_intentional_absence_acknowledges_without_constructing_a_handler](../../backend/tests/LearnStack.Tests.Integration/Database/OutboxProcessorTests.cs) + proves the distinct intentional-absence policy. - **Phase:** 02b, P02b-2. #### `Outbox_Health_And_Metrics_Are_Bounded` From 3620b421f0f5f43e7a083a011f19abb58ba59a23 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Tue, 13 Oct 2026 08:43:19 +0300 Subject: [PATCH 90/92] docs(roadmap): record dispatcher guard follow-up verification Keep the latest review dispositions and both independent correction rounds tied to the tested source. Record complete local validation and the remaining exact-head CI boundary without reusing earlier evidence. ADR: 0003, 0046 --- docs/roadmap/phase-02b-events-auth.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 6b6adcd1..3aa79458 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -2027,6 +2027,29 @@ remain unchanged. Complete Docker validation, both fresh independent review rounds and correction-head CI remain pending in this initial local receipt; the PR description owns final head/check evidence. +#### Review of 3531a2eb: verification closeout + +Both fresh independent review rounds approve `3531a2eb..a5b1e723` with zero +findings across all 17 changed files. Each round uses a fresh GPT-6-astra xhigh +security/runtime reviewer and GPT-6.1-sol xhigh proof/corpus reviewer. All review +lenses were covered. Reviewers inspect source and TRX evidence without repeating +the root's full suites; the second proof reviewer independently confirms 194 +architecture methods, 192 Implemented rules and all 13 explicit method links. +No further runtime correction was required after the implementation commit. + +Complete local backend verification passes 3,408 cases: 1,847 unit, 287 +architecture, four contract, 175 Docker-free integration and 1,095 Docker +integration, with zero failures/skips. The 20 role and six focused claim-kind/ +shutdown cases are subsets of Docker integration, not additional cases. Release +build, format, seven ADR-status controls and the 2,711-link/505-fragment local +check pass. Historical delivery records and Accepted ADR text remain preserved. +No frontend source or dependency changed in this correction; the next real CI +run re-verifies frontend and demo artifacts along with the corrected backend. + +Both correction rounds and local validation are complete. Exact correction-head +CI remains pending in this local receipt; the PR description owns live checks +and artifact-backed evidence after publication. + ### Durable outbox dispatch P02b-1 Steps 2/3 implement `IOutbox`, replace Packet 3's step-7 shell with From 3baf3a22d188a63c9af3963194fd9fa3ef019dde Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Tue, 13 Oct 2026 09:37:24 +0300 Subject: [PATCH 91/92] fix(kernel): respect the outbox snapshot refresh interval Avoid a full backlog scan after every successful claim. Prove continued dispatch with a retained snapshot before the configured interval and a refresh at its exact monotonic-clock boundary. ADR: 0046 --- .../Messaging/OutboxProcessor.cs | 2 +- .../Database/OutboxProcessorTests.cs | 57 +++++++++++++++++++ docs/roadmap/phase-02b-events-auth.md | 12 ++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/backend/src/LearnStack.Api/Messaging/OutboxProcessor.cs b/backend/src/LearnStack.Api/Messaging/OutboxProcessor.cs index 090787b1..1e42450c 100644 --- a/backend/src/LearnStack.Api/Messaging/OutboxProcessor.cs +++ b/backend/src/LearnStack.Api/Messaging/OutboxProcessor.cs @@ -99,7 +99,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) _observation.Progress(); } - await RefreshAsync(didWork, stoppingToken).ConfigureAwait(false); + await RefreshAsync(force: false, stoppingToken).ConfigureAwait(false); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { diff --git a/backend/tests/LearnStack.Tests.Integration/Database/OutboxProcessorTests.cs b/backend/tests/LearnStack.Tests.Integration/Database/OutboxProcessorTests.cs index 6044b9c5..63e0af8f 100644 --- a/backend/tests/LearnStack.Tests.Integration/Database/OutboxProcessorTests.cs +++ b/backend/tests/LearnStack.Tests.Integration/Database/OutboxProcessorTests.cs @@ -195,6 +195,55 @@ public async Task Real_provisioning_rows_remain_Pending_across_hosted_polls_with (await test.ReadTerminalAuditsAsync()).Should().BeEmpty(); } + [Fact] + public async Task Successful_claims_keep_the_snapshot_until_its_interval_and_refresh_at_the_boundary() + { + await using var test = await WorkerDatabase.CreateAsync(schema.Postgres); + var time = new SnapshotTimeProvider(); + var bounds = Bounds with + { + SnapshotInterval = TimeSpan.FromSeconds(1), + PublishTimeout = TimeSpan.FromSeconds(10), + LeaseDuration = TimeSpan.FromSeconds(30), + ProgressTimeout = TimeSpan.FromSeconds(45), + }; + test.Start(bounds, time); + await UntilAsync(() => test.Observation.IsHealthy); + var initial = test.Observation.Snapshot; + initial.LastSnapshotTimestamp.Should().Be(0); + + var first = await test.InsertAsync(); + await UntilAsync(async () => (await test.ReadAsync(first)).ProcessedAt is not null); + var secondPartition = Guid.CreateVersion7(); + var second = test.Callbacks.Hold(secondPartition); + var secondId = await test.InsertAsync(partition: secondPartition); + await second.Entered.Task.WaitAsync(ProofDeadline); + // Entering the next callback proves the preceding iteration (including + // its refresh decision) completed. Wall-clock duration cannot expire this clock. + test.Observation.Snapshot.Queue.Should().BeSameAs(initial.Queue); + + time.Advance(bounds.SnapshotInterval - TimeSpan.FromTicks(1)); + second.ReleaseCallback.TrySetResult(); + await UntilAsync(async () => (await test.ReadAsync(secondId)).ProcessedAt is not null); + var thirdPartition = Guid.CreateVersion7(); + var third = test.Callbacks.Hold(thirdPartition); + var thirdId = await test.InsertAsync(partition: thirdPartition); + await third.Entered.Task.WaitAsync(ProofDeadline); + test.Observation.Snapshot.Queue.Should().BeSameAs(initial.Queue); + test.Observation.Snapshot.LastSnapshotTimestamp.Should().Be(0); + + time.Advance(TimeSpan.FromTicks(1)); + third.ReleaseCallback.TrySetResult(); + await UntilAsync(() => test.Observation.Snapshot.LastSnapshotTimestamp == time.GetTimestamp()); + + (await test.ReadAsync(thirdId)).ProcessedAt.Should().NotBeNull(); + test.Observation.Snapshot.Queue.Should().NotBeSameAs(initial.Queue); + test.Observation.Snapshot.Queue.PendingCount.Should().Be(0); + test.Observation.Snapshot.LastSnapshotTimestamp.Should().Be(bounds.SnapshotInterval.Ticks); + test.Callbacks.Constructions.Should().Be(3); + test.Observation.IsHealthy.Should().BeTrue(); + } + [Fact] public async Task A_poison_first_row_terminalizes_with_MUST_audit_and_the_following_real_handler_progresses() { @@ -558,6 +607,14 @@ private sealed record Row(int Attempts, string? Token, DateTimeOffset? Processed DateTimeOffset AvailableAfter, string Type, string Payload); private sealed record TerminalAudit(string OperationType, string OperationClass, string Correlation, string Metadata); + private sealed class SnapshotTimeProvider : TimeProvider + { + private long _timestamp; + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + public override long GetTimestamp() => Interlocked.Read(ref _timestamp); + public void Advance(TimeSpan duration) => Interlocked.Add(ref _timestamp, duration.Ticks); + } + /// <summary>Controls shutdown deadlines while all other host timers keep running normally.</summary> private sealed class ShutdownTimeProvider(TimeSpan shutdownTimeout) : TimeProvider { diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index 3aa79458..dc6e1235 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -2050,6 +2050,18 @@ Both correction rounds and local validation are complete. Exact correction-head CI remains pending in this local receipt; the PR description owns live checks and artifact-backed evidence after publication. +#### Snapshot interval correction (2026-10-13) + +The dispatch loop no longer forces a full queue snapshot after every successful +claim. It checks `SnapshotInterval` after completed iterations, while `didWork` +still controls idle polling. A controlled monotonic-clock integration case +dispatches three real rows: the initial snapshot survives successful work and +the last tick before the interval; the exact boundary refreshes it after the +next completed dispatch. Release build reports zero warnings/errors; format, +1,847 unit, 287 architecture and all 17 worker integration cases pass, with zero +failures/skips. Independent correction reviews and new-head CI remain pending +in this initial local receipt. + ### Durable outbox dispatch P02b-1 Steps 2/3 implement `IOutbox`, replace Packet 3's step-7 shell with From 4b4e3e40cf9147262934652c4eb9621258356f4c Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Tue, 13 Oct 2026 09:45:10 +0300 Subject: [PATCH 92/92] docs(roadmap): record snapshot cadence correction reviews Record both independent approvals and the isolated predecessor failure alongside the passing local regression evidence. ADR: 0046 --- docs/roadmap/phase-02b-events-auth.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/roadmap/phase-02b-events-auth.md b/docs/roadmap/phase-02b-events-auth.md index dc6e1235..34a58ade 100644 --- a/docs/roadmap/phase-02b-events-auth.md +++ b/docs/roadmap/phase-02b-events-auth.md @@ -2059,8 +2059,12 @@ dispatches three real rows: the initial snapshot survives successful work and the last tick before the interval; the exact boundary refreshes it after the next completed dispatch. Release build reports zero warnings/errors; format, 1,847 unit, 287 architecture and all 17 worker integration cases pass, with zero -failures/skips. Independent correction reviews and new-head CI remain pending -in this initial local receipt. +failures/skips. Two fresh sequential independent reviews of `3620b421..3baf3a22` +approve with no findings. In an isolated tracked-source copy, reverting only the +production call to forced refresh makes the new case fail its retained-snapshot +assertion (one executed, one failed); the corrected case passes. New-head CI +remains pending in this local receipt; the PR description owns live checks and +artifact-backed evidence after publication. ### Durable outbox dispatch