Skip to content

catch up with dev - #34

Merged
Powerworks merged 49 commits into
mainfrom
dev
Jul 31, 2026
Merged

catch up with dev#34
Powerworks merged 49 commits into
mainfrom
dev

Conversation

@Powerworks

Copy link
Copy Markdown
Owner

Summary

  • Board/yaml reconciliation: Spec/K9CRUSH.emlang.v3.2026-07-31.yaml synced with the eventmodelers board (cut-scope chapters removed, board-only chapters added, Surrendering Your Dog split into Simple/Full Intake)
  • fix: correct Marten schema isolation for events and documents
  • feat: add multi-instance chapter orchestrator + quality gate to build-kit-dotnet-es, plus a follow-up fix preventing the orchestrator from force-destroying uncommitted Ralph work on cleanup
  • ADR-031: full event-sourcing retrofit across Media/Admin/Notifications/Identity/ShelterAdoption
  • Product descope: Discovery/Chat/Places/Moderation removed, Profiles merged into ShelterAdoption, Home page rebuilt
  • Dependency bumps: FluentAssertions 8.10.0, Mono.Cecil 0.11.6, xunit 2.9.3, xunit.runner.visualstudio 3.1.5, and a consolidated Wolverine/Marten bump (WolverineFx family → 6.23.1, Marten family → 9.20.1)

Test plan

  • dotnet build clean (0 errors)
  • All 383 non-integration tests passing across 6 assemblies
  • Integration tests (Testcontainers-based) not run this session

🤖 Generated with Claude Code

Powerworks and others added 30 commits July 22, 2026 05:27
… emlang enrichments

Brings ShelterAdoption's two already-built chapters in line with
Spec/K9CRUSH.emlang.v3.yaml, ahead of building v3's new SurrenderingYourDog/
FosteringADog/VolunteeringAndHomeChecks chapters (which depend on this
foundation - FosteringADog's cascades reference DogListing.Status directly).

TheWouldBeAdopter: SubmitApplicationRequest now carries an 18-field
household/lifestyle intake questionnaire (home ownership/type, garden,
children, other pets, daily routine, energy preference, prior experience,
data processing consent), captured on Application.Intake at the point of
submission via a new ApplicationIntake value object. Conditionally-required
fields (GardenSize/GardenEnclosed/ChildrenAgeRange/OtherPetsDetails)
validated via IValidatableObject.

ShelterManagingListings: DogListing gains a Status field
(Available|NotReadyYet|InFoster|PendingAdoption|Adopted, Available=0 so
pre-existing documents deserialize as adoptable) plus a new
UpdateListingStatusHandler for shelter staff to set it manually. New
listings now start NotReadyYet instead of implicitly-available. Two
consequences of that: GetAdoptionListings now filters to Status ==
Available so newly-added/adopted dogs stop appearing in public browse, and
ApproveApplicationHandler now cascades the listing's status to Adopted
(a same-module state change, not a cross-module event) - both explicitly
called out in the v3 spec's ShelterManagingListings comment.

Also fills two pre-existing test gaps surfaced while touching this code:
DogListing had no Layer 1 domain tests at all, and SubmitApplicationHandler
had no direct test coverage beyond the drafts integration test (query-based,
Layer 3 only).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
An event-modeling checklist pass (validated against
https://github.com/Nebulit-GmbH/Eventmodelers-Build-Kits's
eventmodeling-validating-event-models-checklist skill) flagged that
UpdateListingStatusHandler had no transition guard at all - a shelter
could mark a listing Adopted with no approved Application on file, or
flip an already-adopted listing back to Available/InFoster/etc. by hand.

Adopted is now a one-way door: not a settable target via this endpoint
(only ApproveApplicationHandler's cascade can reach it) and not editable
once reached. Spec/K9CRUSH.emlang.v3.yaml's ShelterManagingListings
chapter updated to match - the command's props no longer list Adopted as
a legal input, and a new blocked-outcome pseudo-event ("Listing Status
Update Blocked: Adopted Requires Approval") documents the guard in both
directions, same convention as this file's other blocked-outcome events.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Builds Spec/K9CRUSH.emlang.v3.yaml's SurrenderingYourDog chapter - a
member surrendering their own dog into a shelter's care, distinct from
TheWouldBeAdopter's applicant journey. Built directly from the yaml
(eventmodelers board is currently unreachable - "organization license is
not active" - user opted to proceed without the load-slice pipeline for
this slice rather than pause).

New DogSurrenderRequest entity (Requested -> UnderReview ->
{AdditionalDetailsRequested -> UnderReview | Accepted | Declined}) plus
seven slices:
- RequestDogSurrender (any verified owner)
- GetSurrenderReviewQueue (Admin, platform-wide, unfiltered by status)
- ReviewSurrenderRequest, RequestAdditionalSurrenderDetails,
  AcceptDogSurrender, DeclineDogSurrender (Admin, no ownership check -
  same platform-wide reasoning as ApproveShelterAccountHandler)
- SubmitAdditionalSurrenderDetails (ownership-gated to the surrendering
  member, no request body - same shape as Application's identically-named
  step)

AcceptDogSurrender cascades into ShelterManagingListings' Add Dog Listing
(status NotReadyYet) in the same session - TemperamentNotes becomes the
new listing's Bio, HealthNotes stays on the surrender record only.
ShelterAccountId was a necessary disclosed gap-fill on that request: the
yaml never specifies which shelter receives the dog, but DogListing.Create
requires one.

25 new tests (104 -> 129 in ShelterAdoption.Tests), all Layer 1-2 except
GetSurrenderReviewQueue's unscoped query (Layer 3, own dedicated container
per the established shared-collection-pollution gotcha). v3 yaml updated:
chapter tag flipped [PLANNED] -> [BUILT], gap-fill and no-request-body
decisions documented inline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Builds Spec/K9CRUSH.emlang.v3.yaml's FosteringADog chapter - a member
providing temporary foster care for a shelter's dog. Built directly from
the yaml (eventmodelers board still returns a 402 license error).

New FosterApplication entity (Submitted -> UnderReview -> Approved |
Rejected, reuses Application's HomeType enum) plus 7 slices covering
becoming an approved caregiver and placing/ending a foster placement:
ApplyToFoster, GetFosterApplicationsQueue, ReviewFosterApplication,
ApproveFosterCaregiver, RejectFosterApplication, PlaceDogInFoster,
MarkFosterDogReadyForAdoption, EndFosterPlacement - all Admin-gated per
the yaml's own swimlane, no ownership check (platform-wide, same
reasoning as the Surrender chapter's Admin actions).

Foster placement is a status change on the EXISTING DogListing, not a
separate document, per the chapter's own framing - added
DogListing.CurrentFosterCaregiverOwnerId to track who's currently
fostering. It deliberately survives MarkFosterDogReadyForAdoption's
Status flip back to Available (the caregiver is still fostering until
EndFosterPlacement resolves it, even while the listing is open to other
applicants again) and respects Adopted's one-way-door rule.
UpdateListingStatusHandler now also rejects manual status changes while
a placement is active, to stop that field going stale.

"Convert Foster To Adoption" needed no new command at all - it IS
TheWouldBeAdopter's SubmitApplicationHandler, called by the foster
caregiver for their own fostered dog, no special-casing (documented via
cross-reference). "Place Dog In Foster" needed one disclosed traceability
fix: takes fosterApplicationId instead of the yaml's bare
fosterCaregiverOwnerId, deriving the caregiver from the loaded, Approved
FosterApplication rather than trusting a caller-supplied id.

30 new tests (129 -> 159 in ShelterAdoption.Tests), all Layer 1-2 except
GetFosterApplicationsQueue's unscoped query (Layer 3, dedicated
container). v3 yaml updated: chapter tag flipped [PLANNED] -> [BUILT],
deviations documented inline.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
First slice of the v3 VolunteeringAndHomeChecks chapter, placed in
ShelterAdoption alongside Surrender/Foster. A verified owner applies to
volunteer with an area of interest, creating a VolunteerApplication in
Submitted status.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…iew)

Second slice of the chapter - Admin's read model over every
VolunteerApplication, unfiltered by status, same pattern as
GetFosterApplicationsQueueHandler.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Third slice of the chapter - Admin moves a Submitted volunteer
application to UnderReview, same shape as ReviewFosterApplicationHandler.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fourth slice of the chapter - Admin moves an UnderReview volunteer
application to Approved, same shape as ApproveFosterCaregiverHandler.
Being an "approved volunteer" is just having an Approved
VolunteerApplication on file, not a new OwnerRole.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Frontend design will be done in Penpot (design system: tokens + a small
core component set) and mapped onto MudBlazor's theming API rather than
hand-rolling every Razor component - chosen given the team's stated
design-skill gap, trading some visual distinctiveness for much faster
implementation.

Wires up the baseline: MudBlazor package (pinned in Directory.Packages.props),
AddMudServices(), MudBlazor CSS/JS + font references in App.razor, and a
MudLayout shell (AppBar + MainContent) in MainLayout.razor with a default
MudTheme placeholder until Penpot's actual tokens exist. Verified live -
built and ran the Blazor app, confirmed the shell renders with real
MudBlazor CSS classes (mud-appbar, mud-button-filled, theme CSS variables)
via a throwaway test page, then removed it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ismatch

Rebuilds the placeholder feed page as real MudBlazor components
(MudProgressCircular loading, MudAlert error/empty states with a Try
Again button instead of an unhandled 500, MudCard/MudChip per dog).

Fixes two latent bugs found while wiring it up: the page's local
DiscoveryFeedEntry record didn't match GetDiscoveryFeedHandler's actual
response shape (missing Name/MatchType, DistanceKm vs DistanceMiles), and
the query string sent radiusKm instead of the radiusMiles the endpoint
actually binds - both silently dropped/filtered real data.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
MudDrawer + MudNavMenu with a toggle button in the AppBar, so the app
has a real navigation structure future pages can hang off of - up to
now there was only one reachable route ("/"). Only one nav entry
(Discover) exists yet since it's the only page built; more get added
as pages do.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Supabase owns the entire signup/login/confirmation lifecycle (ADR-005) -
Api.Host only ever validates the JWT it issues, never mints one. This
adds the missing frontend half: Login.razor/Register.razor call
Supabase's own /auth/v1 REST API directly via a new SupabaseAuthService,
and on success sign the caller into a local cookie (CookieAuthentication)
carrying the Supabase JWT as an "access_token" claim for later use against
Api.Host's [Authorize]-gated endpoints.

Both pages are static SSR (no @rendermode) by design - HttpContext.
SignInAsync only works reliably during the classic request/response cycle
a static form POST goes through, not inside an interactive Server circuit,
matching the same pattern ASP.NET Core's own Identity-scaffolded Login
page uses.

Found and fixed a real MudBlazor gap while building this: MudTextField
does not participate in static-SSR EditForm binding (confirmed empirically
- it silently renders with no name attribute, so submitted values never
bind). Swapped to the framework-native InputText/type=password instead,
styled by hand via a small .mud-static-input CSS class, rather than
pulling in an unofficial third-party static-input package for two form
fields.

MainLayout's AppBar now shows Log In/Log Out + the caller's email via
AuthorizeView; Routes.razor upgraded to AuthorizeRouteView so future
[Authorize]-gated pages work without extra plumbing; Register shows a
"check your email" message rather than assuming an immediate session,
since Identity's ConfirmProfile flow means Supabase won't return an
access_token until the confirmation email is clicked.

Verified live end-to-end against a real HTTP POST (crafted antiforgery
token + cookie jar via curl): form fields bind correctly
(name="Input.Email"/"Input.Password" with echoed values on failure), and
a Supabase-unreachable failure now shows a friendly MudAlert instead of
crashing - couldn't verify an actual successful Supabase round-trip since
this session has no real Supabase project credentials (CHANGE_ME
placeholders only).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
New AuthorizedApiClient wraps the "K9CrushApi" HttpClient with the
current session's Supabase access_token (set at login) as a Bearer
header, read via AuthenticationStateProvider rather than
IHttpContextAccessor - the latter is unreliable inside an interactive
Server circuit, the cascading AuthenticationState survives for the
circuit's lifetime instead.

Account.razor ([Authorize], InteractiveServer) calls GET
/api/v1/identity/me/settings and shows email/display name/role/member
since/deletion status. Nav link only rendered when authenticated
(AuthorizeView in MainLayout's MudNavMenu).

Verified live: /account correctly 302-redirects an anonymous request to
/login?ReturnUrl=%2Faccount (cookie auth's LoginPath), and the nav link
is absent from the anonymous home page response. Couldn't verify the
populated-card path against a real Supabase-authenticated request for
the same reason as Home.razor/Login.razor - no live Supabase project
credentials in this session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Listings.razor (/listings) - MudGrid/MudCard per dog (Name, Breed) from
GET /api/v1/shelter-adoption/dog-listings, linking to
ListingDetails.razor (/listings/{id:guid}) which calls GET
.../dog-listings/{id} for the full record (AgeInMonths, Bio, Status).
Both [Authorize]-gated via AuthorizedApiClient, same pattern as
Account.razor - the backend endpoints themselves require VerifiedOwner,
not anonymous, despite "adoption browsing" sounding public.

DogListingStatus is redeclared client-side matching the backend enum's
exact declaration order, since Api.Host has no JsonStringEnumConverter
registered - the wire format is a plain int, not a string.

Verified live: both routes correctly 302-redirect anonymous requests to
/login (with ReturnUrl preserved, including for the parameterized detail
route), and the new nav link is absent from the anonymous home page.
Couldn't verify the populated-listing render path for the same reason as
prior pages - no live Supabase-authenticated request possible without
real project credentials in this session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ApplyToAdopt.razor (/listings/{id}/apply) - the 18-field household/
lifestyle intake form for TheWouldBeAdopter's SubmitApplication slice.
Local InputModel mirrors SubmitApplicationRequest field-for-field,
including its IValidatableObject conditional rules (garden size/enclosed
required when HasGarden, children age range required when HasChildren,
other pets details required when HasOtherPets, data processing consent
required) - gives real client-side validation matching the backend
exactly rather than only catching these on round-trip.

Handles all the backend's actual response cases: 404 (listing not
found), 409 Conflict with the raw string body (open-application limit),
and 200 OK branching on WasDuplicate (backend treats "already applied"
as a no-op success, not an error). Enums (HomeOwnership/HomeType/
GardenSize/EnergyLevelPreference) redeclared client-side matching the
backend's exact ordinal order, same reasoning as DogListingStatus in
ListingDetails.razor.

ListingDetails.razor gained an "Apply to Adopt" button, shown only when
Status is Available (client-side UX guard - the backend handler itself
has no such guard per its own gap, disclosed rather than assumed).

Verified live: temporarily removed [Authorize] to confirm the full form
actually renders without runtime errors (all top-level fields present,
conditional fields correctly hidden by default), then restored it -
couldn't test an authenticated submission for the same reason as prior
pages (no live Supabase project credentials this session).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ApplicationStatus.razor (/applications/{id}) calls the real
GetApplicationStatusHandler - single application by id only, there's no
"list my applications" endpoint anywhere in ShelterAdoption (confirmed by
research pass, not assumed). Handler enforces ApplicantOwnerId == caller
server-side (403 on mismatch) - the page surfaces that as "This isn't
your application" rather than a generic error.

ApplyToAdopt.razor's success state now captures the returned
ApplicationId and links straight to this page - the only way to reach an
application's status today is via the id you get back right after
submitting (or, once other pages exist, whatever surfaces it - no list
view exists yet to browse back to it later).

Verified live: anonymous access 302-redirects to /login with ReturnUrl
preserved; temporarily un-gated the page to confirm it actually renders
(network-failure path correctly shows a friendly MudAlert, no server
exceptions), then restored [Authorize].

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
VolunteerApply.razor (/volunteer/apply) posts to the ApplyToVolunteer
endpoint built earlier this session (single area-of-interest select).
FosterApply.razor (/foster/apply) posts to ApplyToFoster (home type,
has garden, has other pets, available-from date) - same shape as the
adoption application form but smaller, no conditional fields.

Both enums (VolunteerAreaOfInterest, HomeType) redeclared client-side
matching the backend's exact ordinal declaration order, same reasoning
as every other enum-carrying page this session. Neither module exposes
a member-facing application-status lookup (Volunteer/Foster review is
Admin-only), so both pages show a plain success message rather than
linking anywhere - unlike ApplyToAdopt, which does have a real status
endpoint to link to.

Nav links added for both. Verified live: both routes correctly
302-redirect anonymous requests to /login; temporarily un-gated each
page to confirm the actual form renders without runtime errors
(including MudDatePicker on the foster form), then restored [Authorize].

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
SurrenderApply.razor (/surrender/apply) posts to RequestDogSurrender
(dog name, breed, age in months, reason, temperament notes, health
notes - no enums in this request, all plain text/numeric fields).
Same pattern as Volunteer/Foster: no member-facing status lookup exists
for surrender requests (review is Admin-only via GetSurrenderReviewQueue),
so the page shows a plain success message with no link.

Nav link added. Verified live: anonymous access 302-redirects to
/login; temporarily un-gated the page to confirm all 6 fields render
correctly with no server errors, then restored [Authorize].

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Connected the full local stack to a real Supabase project for the first
time and fixed everything that broke - all previously speculative/
"unverified this session" per the code's own prior comments:

- Api.Host validated JWTs against a hardcoded HS256 SymmetricSecurityKey,
  but real Supabase projects sign session tokens with ES256/JWKS. Fixed
  to use Authority-based OIDC discovery (Supabase exposes a real
  /auth/v1/.well-known/openid-configuration document) so ASP.NET Core's
  JwtBearer handler fetches/caches/rotates the signing key itself - no
  manual key material in config at all anymore.

- VerifiedOwner/Admin/Shelter policies all checked RequireClaim(
  "email_verified", "true"), but a real Supabase JWT has no such
  top-level claim - it's nested inside the "user_metadata" claim as JSON
  ({"email_verified":true}). New EmailVerifiedRequirement/
  EmailVerifiedAuthorizationHandler (BuildingBlocks.Web) parses it
  correctly. This affected every VerifiedOwner-gated endpoint in the
  app, not just one page.

- Login.razor/Register.razor rendered a duplicate __RequestVerificationToken
  hidden field - EditForm(method="post") already renders one
  automatically, and an explicit <AntiforgeryToken /> rendered a second
  identical one, breaking real browser form submission (curl-based
  testing never caught this since manually-built POST bodies only ever
  included one token).

- Blazor.App's Data Protection keys were ephemeral (regenerated on every
  restart), silently invalidating outstanding antiforgery tokens/auth
  cookies. Now persisted to disk (.dataprotection-keys/, gitignored).

ADR-030 added: dotnet user-secrets is now the local-dev secrets store
for both Api.Host and Blazor.App (UserSecretsId added to both), not
appsettings.Development.json - safer (genuinely outside the repo) and
avoids the precedence trap discovered this session (a stale user-secrets
file from an earlier session silently overrode every
appsettings.Development.json edit for hours before being found).
GETTING_STARTED.md updated to match, plus the IPv6-only Direct
Connection gotcha and the Database-Webhooks-don't-fire-retroactively
gotcha, both discovered live.

Verified end-to-end for real (not just anonymous-redirect checks): real
signup, real email confirmation, real login, real JWT validation, real
authorization, real OwnerAccount data rendered in My Account.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
After reviewing 6 real shelter/rescue charity sites (Dogs Trust Ireland,
MADRA, RSPCA, ISPCA/NSPCA, Mission K9 Rescue, Blue Cross), decided to
descope the product away from its original "dog dating app" framing
toward a shelter/rescue/foster/adoption operations tool plus non-profit
administration.

Cut: Discovery/Matching, Chat, Places, Moderation, Community, Providers,
Shop. Merged: Profiles -> ShelterAdoption. Kept as-is: Identity,
ShelterAdoption, Notifications, Media, Admin. Kept on roadmap: Content,
Lost & Found. Repurposed: Scheduling (shelter ops instead of its
original unclear scope). New: Donations/Sponsorship.

This commit only records the decision - a SCOPE NOTE header in
Spec/K9CRUSH.emlang.v3.yaml and a superseded-pointer at the top of
docs/04-high-level-design.md. No code, tests, or schemas touched or
deleted - that's a separate, not-yet-started pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Executes the descope decision recorded in Spec/K9CRUSH.emlang.v3.yaml's
SCOPE NOTE and module-boundaries memory: cuts the swipe/matching
mechanic, user-to-user chat, place reviews/meetups, and content
moderation entirely, since none of these appeared on any of the 6 real
shelter/rescue charity sites reviewed, and they were core to the
product's original "dog dating app" framing this pivot moves away from.

Removed: src/Modules/{Discovery,Chat,Places,Moderation} (Domain/Contracts/
Api), tests/K9Crush.Modules.{Discovery,Moderation,Places}.Tests, and
tests/K9Crush.IntegrationTests/{Chat,Discovery,Moderation}.

Severed the two real cross-module dependencies found via a dedicated
investigation pass before starting:
- Notifications' NotifyOnMatch automation (consumed Discovery's
  MatchCreatedV1) - deleted along with its test and the Discovery.Contracts
  project reference.
- Media's RemoveMediaOnContentRemovalRequested automation (consumed
  Moderation's ContentRemovalRequestedV1) - deleted along with its test
  and the Moderation.Contracts project reference; the now-orphaned
  media.integration-events queue registration removed too since nothing
  else in Media needs a cross-module queue. MediaContentFlaggedV1 (Media's
  own event, previously consumed by Moderation) is left in place - Report
  Media is a reasonable standalone feature independent of Moderation's
  removal, just currently has no consumer - disclosed, not silently
  dropped.

Updated Program.cs (module registration array, using directives, the two
Marten-domain-event SubscribeToEvent<T> registrations that only Discovery/
Chat used), Api.Host.csproj, K9Crush.sln (via `dotnet sln remove`), and
all three K9Crush.ArchitectureTests fitness test files (ModuleBoundaryTests,
EntitySerializationFitnessTests, HandlerNamingFitnessTests) plus their
csproj - each of these fitness tests reflects over every module's
assemblies by name, so they needed the same five-module list trimmed
that everything else did.

Verified: full solution builds clean, all 7 non-integration test
assemblies pass (393 tests: Admin 11, Profiles 16, Identity 45,
ArchitectureTests 121, ShelterAdoption 169, Notifications 20, Media 11).

Known follow-ups, not yet done (separate commits):
- Home.razor still calls the now-deleted /api/v1/discovery/feed endpoint
  at runtime (compiles fine, breaks on click) - replacing with a static
  landing page per user's explicit choice.
- Profiles module (DogProfile/AddDogProfile wizard) still exists
  standalone - merging it into ShelterAdoption with photo-attachment
  support ported onto DogListing is a separate, larger design task.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Home.razor called the now-deleted /api/v1/discovery/feed endpoint at
runtime (compiled fine, would 404 on click) - Discovery was removed in
the previous commit as part of the product's descope. Replaced with a
static landing page (no API call, no rendermode needed): a welcome
message plus four action cards linking to the journeys that actually
matter now - Browse Adoptable Dogs, Apply to Foster, Apply to Volunteer,
Surrender a Dog.

Nav label changed from "Discover" to "Home" to match - "Discover" was
named for the swipe-feed framing that's gone now.

Verified live: 200 OK, all four cards render with correct hrefs, no
server errors.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ge 3/3)

Removes the Profiles module entirely - its "member's own dog" dating
profile concept is moot now that Discovery (its only consumer) is gone.
The one capability worth keeping, photo attachment, is ported onto
DogListing directly: new PhotoIds field + AttachPhoto method, and a new
AddDogListingPhoto slice (ownership-checked the same way EditDogListing
is). GetAdoptionListings/GetDogListingDetails now surface PhotoIds too.

Completes the module cut list from the 2026-07-23 descope decision -
Chat/Discovery/Moderation/Places were removed in stage 1, Home page
fixed in stage 2, Profiles merged here in stage 3.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…odel (Phase 0/5)

Establishes the governance/tooling foundation for retrofitting all 5 live
modules (Identity, ShelterAdoption, Notifications, Media, Admin) from plain
Marten document-store to event-sourced, per an explicit product decision
that event sourcing is the whole point of this app.

- ADR-031 recorded in docs/03-solution-architecture.md; Section 2.1's
  module-classification table and the ADR-019 worked example (previously
  citing the deleted Discovery/MatchAggregate) both updated.
- docs/05-event-modeling-blueprint.md's mechanical code samples, folder
  convention, and fitness-test section updated for the FetchForWriting/
  AggregateStreamAsync + self-aggregating dual-use snapshot pattern.
- New CommandStateFitnessTests.cs: a Mono.Cecil IL-scan catching a command
  handler that accidentally LoadAsync/Query's a snapshot-registered type
  instead of using FetchForWriting - verified via a real self-test using
  actual Marten types, not asserted. Required walking into compiler-generated
  async state-machine nested types, since the naive method-level scan missed
  calls hidden behind await.
- TestingApproach.md updated for the new default, including a correction:
  FetchForWriting/FetchForExclusiveWriting were initially assumed unmockable
  (misread of an unrelated internal type named FetchForWritingExtensions) -
  confirmed via reflection + a working NSubstitute spike that they're real
  IEventStoreOperations interface members, mockable exactly like LoadAsync.
  Layer 2 does not shrink because of this retrofit.
- Global Marten.Exceptions.ConcurrentUpdateException -> 409 mapping in
  Program.cs (name corrected the same way - not ConcurrencyException).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---
updated-dependencies:
- dependency-name: FluentAssertions
  dependency-version: 8.10.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Media is the proof-of-concept module for the event-sourcing retrofit -
smallest surface (1 entity, 4 command handlers, no read models). Validates
the whole pattern end-to-end against real Postgres before the harder
modules (Notifications' lock fusion, Identity's grace-period saga,
ShelterAdoption's cross-stream writes) build on top of it.

- MediaAsset becomes a self-aggregating event-sourced entity (Create/Apply
  over MediaAssetUploadedV1/SharedV1/RemovedV1). RemoveMediaHandler's old
  genuine session.Delete() becomes a MediaAssetRemovedV1 flag - streams
  can't be hard-deleted.
- ReportMediaHandler (read-only, never mutates the asset) uses a minimal
  ADR-019-compliant ReportMediaState via AggregateStreamAsync, not
  FetchForWriting - it has nothing to append.
- MediaModule.cs registers the event stream only; no Inline snapshot, since
  nothing under ReadModels/** queries MediaAsset today.

Two things found only by running real code against Testcontainers Postgres
(neither visible from mocks or compile-time checks alone), both corrected
in Program.cs and this phase's own commit:
- Every retrofitted entity's .Domain project needs a direct Marten
  PackageReference, not just .Api - Marten 9's Create/Apply dispatch is a
  compile-time source generator with no runtime fallback, and it only runs
  in an assembly that references the package.
- FetchForWriting/AppendOne concurrency conflicts throw
  JasperFx.Events.EventStreamUnexpectedMaxEventIdException (base:
  JasperFx.ConcurrencyException) - a different hierarchy from
  Marten.Exceptions.ConcurrentUpdateException, which is Marten's document-
  level (Store()) concurrency exception. Two earlier guesses at this type
  name were wrong before this was confirmed by actually racing two
  sessions against the same stream in a Testcontainers spike test.

Also confirmed live, matching what was assumed: FetchForWriting/
AggregateStreamAsync return null on a nonexistent stream rather than
throwing - every handler's NotFound branch is correct as designed.

FetchForWriting/AppendOne/AggregateStreamAsync are all genuine
IEventStoreOperations interface members (confirmed via reflection + a
working NSubstitute spike during Phase 0) - Layer 2 tests mock them
exactly like LoadAsync/Store always worked, no Layer 3 fallback needed
for Media's command handlers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Admin proves two mechanics Media (Phase 1) didn't need: a stream whose
first event comes from a cross-module automation trigger rather than a
local command, and the dual-use Inline-snapshot pattern for a genuinely
queried read model.

- FeedbackInboxItem becomes self-aggregating (Create/Apply over
  FeedbackInboxItemCreatedV1/RespondedV1/ResolvedV1). Its stream id is
  externally supplied (Identity's own FeedbackId), not freshly generated -
  the same wrinkle planned for Identity's Phase 4, encountered here first.
- FeedbackSubmittedProjectorHandler (an automation reacting to Identity's
  cross-module FeedbackSubmittedV1) used to rely on Store()'s upsert
  semantics for safe at-least-once redelivery. Event streams don't upsert -
  StartStream on an existing id throws - so it now checks via
  AggregateStreamAsync first and no-ops if the stream already exists.
- GetFeedbackInboxHandler/GetFeedbackDetailHandler genuinely query
  FeedbackInboxItem (Query<T>/LoadAsync), so unlike Media's MediaAsset it
  IS registered as its own Inline snapshot in AdminModule.cs - confirmed
  working for real via Postgres (6 Layer 3 tests, including the existing
  GetFeedbackInboxIntegrationTests seeding through real StartStream calls
  instead of a raw Store()). Added to CommandStateFitnessTests.cs's
  snapshot watchlist.

Both Phase 1 findings applied without rediscovery: Admin.Domain now
references Marten directly (source-generator dispatch), and the global
409 mapping already covers the real JasperFx.ConcurrencyException
hierarchy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ase 3/5)

Notifications proves the module has genuinely different entity shapes
under one roof, and settles the lock-fusion design question flagged when
the retrofit plan was written.

- NotificationTemplate: Locked/LockedByOwnerId stay genuine domain state
  fused into NotificationTemplateEditedV1 (one event, matching the
  pre-retrofit Edit() method exactly) - not replaced by Marten's
  session-level FetchForExclusiveWriting lock, since Locked is displayed
  in ViewNotificationTemplatesHandler's read model and a session-scoped
  lock is invisible to a second session's read. Registered as its own
  Inline snapshot for that same read model.
- NotificationPreference: event-sourced (Create/Apply over Created/
  UpdatedV1), also Inline-snapshotted for ViewNotificationPreferencesHandler.
  NotificationDispatcher's plain LoadAsync read of it (used by every
  Notify* automation) is not an ADR-019 violation - Dispatcher never
  mutates NotificationPreference or decides its validity, only reads a
  fact from it to inform an unrelated write, the same shape
  ApproveApplicationHandler already uses reading ShelterAccount.
- NotificationLog: event-sourced, create-only (no Apply overloads at all -
  nothing ever mutates a log entry), no snapshot (nothing queries it).
- OwnerContact: deliberately stays a plain document, not event-sourced -
  a pure cross-module denormalized cache with no domain transitions of
  its own to capture as events.
- UpdateNotificationPreferencesHandler's lazy-create-on-first-update
  branch (FetchForWriting comes back with a null Aggregate -> StartStream
  with both the default-created event and the immediate update event
  together, since a null-Aggregate handle can't itself be appended to)
  is verified against real Postgres in a new integration test, not
  assumed - confirms both the fresh-stream and already-exists branches
  behave correctly across separate sessions.

Both Phase 1 findings applied without rediscovery: Notifications.Domain
now references Marten directly, and the existing global 409 mapping
already covers whatever this module throws.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Identity proves a caller-supplied (Supabase) stream id works identically
to a freshly-generated one, and surfaces a real gap in the fitness test's
own design.

- OwnerAccount: self-aggregating (Create/Apply over 9 event types matching
  its 9 domain methods 1:1). Registered as its own Inline snapshot -
  OwnerAccountView/ViewProfileSettings query it, and MartenOwnerRoleLookup
  (ADR-017, read on every Admin/Shelter-policy-gated request) does too.
  Domain events named OwnerAccountXxxV1 throughout specifically to avoid
  colliding with this module's own Contracts events of similar names
  (e.g. Contracts.AccountDeletionRequestedV1).
- Feedback: event-sourced, create-only (no Apply overloads), same shape
  as Notifications' NotificationLog (Phase 3).
- ProvisionOwnerOnSupabaseSignupHandler (Supabase webhook automation)
  gets the same idempotent-redelivery treatment as Admin's
  FeedbackSubmittedProjectorHandler (Phase 2): AggregateStreamAsync
  existence check before StartStream, since redelivery can no longer
  rely on Store()'s upsert semantics.
- The account-deletion grace-period saga (Request -> Confirm -> scheduled
  CheckAccountGracePeriodExpired -> re-check -> PermanentlyDelete) keeps
  its exact re-check idempotency shape under FetchForWriting/AppendOne -
  no behavioral change, just the persistence mechanism underneath.

Found and fixed a real gap in Phase 0's CommandStateFitnessTests.cs:
BootstrapAdminHandler's session.Query<OwnerAccount>().AnyAsync(x => x.Role
== Admin) - a legitimate cross-population check (does ANY OwnerAccount
have Role Admin, checked before separately FetchForWriting-ing the
CALLER's own account) - was flagged as a violation, since the IL scanner
can't distinguish a population query from a self-load. Resolved with a
narrow, hand-reviewed allowlist (ReviewedCrossPopulationQueryExceptions)
for Query<T>() specifically - LoadAsync<T>() against a snapshot type
stays unconditionally flagged, since a command always has
FetchForWriting/AggregateStreamAsync available for a genuine by-id load.
ShelterAdoption's SubmitApplicationHandler (Phase 5) will need the same
treatment for its own cross-population Query<Application>() check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…Phase 5/5, production code)

All 6 entities (ShelterAccount, DogListing, Application, DogSurrenderRequest,
FosterApplication, VolunteerApplication) converted to self-aggregating
Create/Apply event-sourced entities with Inline snapshots; handlers moved
to FetchForWriting/StartStream/AppendOne. Includes the no-hard-delete flag
pattern for DogListing (IsRemoved) and the two genuine multi-stream writes
(ApproveApplicationHandler, AcceptDogSurrenderHandler).

Test suite rewrite for the new entity API surface is still in progress
(tracked separately) - this commit is production code only, plus the
CommandStateFitnessTests watchlist/allowlist entries for this module's
entities and cross-population queries.
Powerworks and others added 18 commits July 25, 2026 03:40
…Phase 5/5, test suite)

Rewrites all ShelterAdoption Domain/Handlers unit tests and Layer 3
integration tests for the new self-aggregating entity API (RequestNew/
AddNew/SubmitNew/StartDraftNew/ApplyNew tuple factories, FetchForWriting/
AppendOne/StartStream in place of LoadAsync/Store). Adds
MartenEventStoreTestHelpers.cs (mirrors the helper already used by Media/
Admin/Notifications/Identity).

Also extends CommandStateFitnessTests.cs with a new
ReviewedCrossEntityLoadExceptions allowlist: unlike earlier phases,
ShelterAdoption's 6 entities are densely cross-referenced and all
Inline-snapshotted, so legitimate read-only LoadAsync calls against a
DIFFERENT aggregate (e.g. an ownership check against ShelterAccount from
a handler mutating DogListing) were indistinguishable from a genuine
self-load to the IL scanner. Each entry is reviewed and documents which
entity is mutated vs. which different entity is read.

Full solution now builds and tests green: 176 ShelterAdoption unit tests,
116 architecture fitness tests, 39 integration tests (25 ShelterAdoption),
plus all other modules unaffected. This completes the "full retrofit of
all 5 live modules to event sourcing" directive (ADR-031) - Media, Admin,
Notifications, Identity, and now ShelterAdoption are all event-sourced.
Generic template forked from build-kit-dotnet after K9Crush's ADR-031
retrofit proved out "event-sourced everywhere, Inline snapshots only
where queried" as the right default. Removes the document-store branch
from build-state-change/build-state-view/build-automation, reconciles
ADR-019's live-computed decision state with ADR-031's Inline snapshots
as two distinct rules rather than a contradiction, and genericizes
paths/naming so the kit can be dropped into any Wolverine.Http + Marten
+ RabbitMQ .NET project. Untested against a real board/solution so far.
Captures the Wolverine/Marten package combo and host setup worked out
from a real dotnet build, since the skills assume this wiring exists
without ever defining it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---
updated-dependencies:
- dependency-name: Mono.Cecil
  dependency-version: 0.11.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
---
updated-dependencies:
- dependency-name: xunit
  dependency-version: 2.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
---
updated-dependencies:
- dependency-name: xunit.runner.visualstudio
  dependency-version: 3.1.5
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Brought in from outside the repo for convenience; not part of the
tracked project.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…-kit-dotnet-es

Ports the orchestrate.mjs setup from the sibling PawMatch project: picks a
board chapter, retrofits SLICE_BORDER markers onto columns that predate
slices, flips them Planned, and runs N Ralph instances in parallel git
worktrees (avoiding the file-clobbering races a shared working tree causes)
until every slice reaches a terminal state.

Also brings over the reliability fixes that make unattended/parallel Ralph
runs safe (capped retry + auto-Blocked on repeated failure, maxSlicesPerRun,
per-slice budget cap, a prompt-path fix for the kit's actual location) and
the Phase 1 pre-checkin quality gate (build/format/vulnerable-package/secret
checks + a stuck-loop guard), wired into the K9Crush solution's own Claude
Code settings.

Along the way: build-kit-dotnet-es's own runtime state (.slices/, tasks.json,
progress.txt, logs) was never gitignored — only the older build-kit-dotnet's
was. Fixed. quality-checks.md is genericized for this project's actual
architecture (RabbitMQ, ADR-031's unconditional event sourcing) rather than
carrying over PawMatch-specific ADR references that don't apply here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Every module's IMartenModuleConfiguration.Configure() independently set
options.Events.DatabaseSchemaName on the one shared StoreOptions
instance - a single store-wide setting, not per-module (confirmed
against Marten's own docs), so whichever module ran last (Media) was
silently winning for every module's event streams. Separately, none of
the Inline-snapshot documents outside Notifications' OwnerContact had
the per-type options.Schema.For<T>().DatabaseSchemaName() call Marten
actually requires, so they defaulted to Postgres's public schema -
briefly a real exposure window given that's the schema Supabase's
PostgREST auto-exposes.

Fix: event store schema is now set once, centrally, in Program.cs
(eventstore, shared by design); every module now explicitly scopes its
own Inline-snapshot documents to its own schema. ADR-003 updated from
Proposed to Decided with the corrected documents-vs-events split.

Found live while seeding activity into the real Supabase project after
a Supabase inactivity warning; existing 2026-07-30 test data was
migrated into the correct schemas as part of this fix, not left behind
or discarded. Full test suite (422 tests, including 39 real-Postgres
integration tests) passing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ed snapshot

Board and yaml were out of sync in both directions: removed the 18
cut-scope chapters (Discovery/Chat/Places/Moderation/Community/Providers/
Shop/Profiles) that the 2026-07-23 descope decided but never executed in
this file, added the 6 chapters that existed only on the board
(BookingAppointments, VolunteerShiftRoster, MakingADonation,
ManagingARecurringDonation, HandlingDataSubjectRequests, ErasureCascade),
and split SurrenderingYourDog into the real Simple/[BUILT] flow plus a new
SurrenderingYourDogFullIntake [PLANNED] chapter (per-shelter config,
matching a corresponding board-side split).

Renamed K9CRUSH.emlang.v3.yaml -> K9CRUSH.emlang.v3.2026-07-31.yaml since
this file is now treated as a dated snapshot of the board rather than an
incrementing version - future re-syncs replace it with a new dated file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
mergeWorktrees() trusted a self-reported board sliceStatus as the sole
completion signal, then blindly retried `git worktree remove --force` on
ANY removal failure - including the case where a Ralph loop had already
picked up a new Planned slice (getFirstPlannedSlice pulls from a shared,
un-chapter-scoped .slices/ index) and left uncommitted work in the
worktree the instant its tracked slice went terminal.

Gate removal on `git status --porcelain` instead: a dirty worktree is left
in place with manual-cleanup instructions rather than force-deleted. Also
add a best-effort .ralph-stop signal file, dropped into a worktree right
before merging, that the ralph loop checks each iteration to stop picking
up new planned-slice work - narrows the race window, though the git-status
gate is the real backstop since it can't interrupt in-flight work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…caffold/K9Crush/FluentAssertions-8.10.0

Bump FluentAssertions from 6.12.1 to 8.10.0
…scaffold/K9Crush/Mono.Cecil-0.11.6

Bump Mono.Cecil from 0.11.3 to 0.11.6
…scaffold/K9Crush/xunit-2.9.3

Bump xunit from 2.9.2 to 2.9.3
…/code/K9Crush-scaffold/K9Crush/xunit.runner.visualstudio-3.1.5

# Conflicts:
#	code/K9Crush-scaffold/K9Crush/Directory.Packages.props
…scaffold/K9Crush/xunit.runner.visualstudio-3.1.5

Bump xunit.runner.visualstudio from 2.8.2 to 3.1.5
WolverineFx/.Http/.Marten/.RabbitMQ/.RuntimeCompilation to 6.23.1, Marten/
Marten.AspNetCore to 9.20.1 - applied as one manual bump instead of
merging Dependabot PRs #24/#27/#28/#29/#30 individually, since those
packages release in lockstep (per this file's own existing comment) and
the 5 PRs disagreed with each other on shared version lines (#24 vs #28
both touched Marten to different targets; #27/#29/#30 all touched
WolverineFx independently). Verified: solution builds clean, all 383
non-integration tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@socket-security

socket-security Bot commented Jul 31, 2026

Copy link
Copy Markdown

Re-enabling both after their run-duration-driven disable, but only for
main - dev is pushed to directly and frequently, which is why they were
disabled in the first place.
@Powerworks
Powerworks merged commit c569ed7 into main Jul 31, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant